// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"errors"
"fmt"
"os"
"github.com/openpubkey/opkssh/policy"
)
// AddCmd provides functionality to read and update the opkssh policy file
type AddCmd struct {
HomePolicyLoader *policy.HomePolicyLoader
SystemPolicyLoader *policy.SystemPolicyLoader
// Username is the username to lookup when the system policy file cannot be
// read and we fallback to the user's policy file.
//
// See AddCmd.LoadPolicy for more details.
Username string
}
// LoadPolicy reads the opkssh policy at the policy.SystemDefaultPolicyPath. If
// there is a permission error when reading this file, then the user's local
// policy file (defined as ~/.opk/auth_id where ~ maps to AddCmd.Username's
// home directory) is read instead.
//
// If successful, returns the parsed policy and filepath used to read the
// policy. Otherwise, a non-nil error is returned.
func (a *AddCmd) LoadPolicy() (*policy.Policy, string, error) {
// Try to read system policy first
systemPolicy, _, err := a.SystemPolicyLoader.LoadSystemPolicy()
if err != nil {
if errors.Is(err, os.ErrPermission) {
// If current process doesn't have permission, try reading the user
// policy file.
userPolicy, policyFilePath, err := a.HomePolicyLoader.LoadHomePolicy(a.Username, false)
if err != nil {
return nil, "", err
}
return userPolicy, policyFilePath, nil
} else {
// Non-permission error (e.g. system policy file missing or invalid
// permission bits set). Return error
return nil, "", err
}
}
return systemPolicy, policy.SystemDefaultPolicyPath, nil
}
// GetPolicyPath returns the path to the policy file that the current command
// will write to and a boolean to flag the path is for home policy.
// True means home policy, false means system policy.
func (a *AddCmd) GetPolicyPath(principal string, userEmail string, issuer string) (string, bool, error) {
// Try to read system policy first
_, _, err := a.SystemPolicyLoader.LoadSystemPolicy()
if err != nil {
if errors.Is(err, os.ErrPermission) {
// If current process doesn't have permission, try reading the user
// policy file.
policyFilePath, err := a.HomePolicyLoader.UserPolicyPath(a.Username)
if err != nil {
return "", false, err
}
return policyFilePath, false, nil
} else {
// Non-permission error (e.g. system policy file missing or invalid
// permission bits set). Return error
return "", false, err
}
}
return policy.SystemDefaultPolicyPath, true, nil
}
// Run adds a new allowed principal to the user whose email is equal to
// userEmail. The policy file is read and modified.
//
// If successful, returns the policy filepath updated. Otherwise, returns a
// non-nil error
func (a *AddCmd) Run(principal string, userEmail string, issuer string) (string, error) {
policyPath, useSystemPolicy, err := a.GetPolicyPath(principal, userEmail, issuer)
if err != nil {
return "", fmt.Errorf("failed to load policy: %w", err)
}
var policyLoader *policy.PolicyLoader
if useSystemPolicy {
policyLoader = a.SystemPolicyLoader.PolicyLoader
} else {
policyLoader = a.HomePolicyLoader.PolicyLoader
}
err = policyLoader.CreateIfDoesNotExist(policyPath)
if err != nil {
return "", fmt.Errorf("failed to create policy file: %w", err)
}
// Read current policy
currentPolicy, policyFilePath, err := a.LoadPolicy()
if err != nil {
return "", fmt.Errorf("failed to load current policy: %w", err)
}
// Update policy
currentPolicy.AddAllowedPrincipal(principal, userEmail, issuer)
// Dump contents back to disk
err = policyLoader.Dump(currentPolicy, policyFilePath)
if err != nil {
return "", fmt.Errorf("failed to write updated policy: %w", err)
}
return policyFilePath, nil
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"encoding/json"
"fmt"
"io"
"os/user"
"path/filepath"
"strings"
"github.com/openpubkey/opkssh/policy"
"github.com/openpubkey/opkssh/policy/files"
"github.com/spf13/afero"
)
// AuditCmd provides functionality to audit policy files against provider definitions
type AuditCmd struct {
Fs files.FileSystem
Out io.Writer
ErrOut io.Writer
ProviderLoader policy.ProviderLoader
CurrentUsername string
// Args
ProviderPath string // Custom provider file path
PolicyPath string // Custom policy file path
JsonOutput bool // Output results in JSON format
SkipUserPolicy bool // Skip auditing user policy file
}
// NewAuditCmd creates a new AuditCmd with default settings
func NewAuditCmd(out io.Writer, errOut io.Writer) *AuditCmd {
return &AuditCmd{
Fs: files.NewFileSystem(afero.NewOsFs()),
Out: out,
ErrOut: errOut,
ProviderLoader: policy.NewProviderFileLoader(),
CurrentUsername: getCurrentUsername(),
ProviderPath: policy.SystemDefaultProvidersPath,
PolicyPath: policy.SystemDefaultPolicyPath,
SkipUserPolicy: false,
}
}
func (a *AuditCmd) Audit(opksshVersion string) (*TotalResults, error) {
providerPath := a.ProviderPath
policyPath := a.PolicyPath
totalResults := &TotalResults{
Username: a.CurrentUsername,
OpkVersion: opksshVersion,
}
// Load providers first
providerPolicy, err := a.ProviderLoader.LoadProviderPolicy(providerPath)
if err != nil {
if strings.Contains(err.Error(), "permission denied") {
fmt.Fprint(a.ErrOut, "opkssh audit must be run as root, try `sudo opkssh audit`\n")
}
return nil, fmt.Errorf("failed to load providers (%s): %v", providerPath, err)
}
totalResults.ProviderFile = ProviderResults{
FilePath: providerPath,
}
// Create validator from provider policy
validator := policy.NewPolicyValidator(providerPolicy)
// Audit policy file
systemResults, exists, err := a.auditPolicyFileWithStatus(policyPath, files.RequiredPerms.SystemPolicy, validator)
if err != nil {
return nil, fmt.Errorf("failed to audit policy file: %v", err)
}
totalResults.SystemPolicyFile = *systemResults
if exists {
fmt.Fprintf(a.ErrOut, "\nvalidating %s...\n", policyPath)
if !a.JsonOutput {
for _, result := range systemResults.Rows {
a.printResult(result)
}
}
}
// Audit user policy files if not skipping
if !a.SkipUserPolicy {
homeDirs, err := a.enumerateUserHomeDirs()
if err != nil {
fmt.Fprintf(a.ErrOut, "warning: could not enumerate user home directories: %v\n", err)
} else {
for _, row := range homeDirs {
userPolicyPath := filepath.Join(row.HomeDir, ".opk", "auth_id")
userResults, userExists, err := a.auditPolicyFileWithStatus(userPolicyPath, files.RequiredPerms.HomePolicy, validator)
if err != nil {
fmt.Fprintf(a.ErrOut, "failed to audit user policy file at %s: %v\n", userPolicyPath, err)
totalResults.HomePolicyFiles = append(totalResults.HomePolicyFiles,
PolicyFileResult{FilePath: userPolicyPath, Error: err.Error()})
// Don't fail completely if user policy is unreadable
} else if userExists {
fmt.Fprintf(a.ErrOut, "\nvalidating %s...\n", userPolicyPath)
if !a.JsonOutput {
for _, result := range userResults.Rows {
a.printResult(result)
}
}
totalResults.HomePolicyFiles = append(totalResults.HomePolicyFiles, *userResults)
}
}
}
}
totalResults.SetOpenSSHVersion()
totalResults.SetOsInfo()
totalResults.SetOk()
return totalResults, nil
}
// Run executes the audit command returns an error if it can't perform the
// audit or if the audit finds errors or warnings in system configuration.
// The opksshVersion parameter is the current opkssh version string.
func (a *AuditCmd) Run(opksshVersion string) error {
totalResults, err := a.Audit(opksshVersion)
if err != nil {
return err
}
// Print summary only (results already printed above)
if len(totalResults.HomePolicyFiles) == 0 && len(totalResults.SystemPolicyFile.Rows) == 0 {
fmt.Fprint(a.ErrOut, "\nno policy entries to validate\n")
}
// Collect all validation results
allResults := []policy.ValidationRowResult{}
allResults = append(allResults, totalResults.SystemPolicyFile.Rows...)
for _, homePolicy := range totalResults.HomePolicyFiles {
allResults = append(allResults, homePolicy.Rows...)
}
summary := policy.CalculateSummary(allResults)
if a.JsonOutput {
jsonBytes, err := json.MarshalIndent(totalResults, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON output: %v", err)
} else {
fmt.Fprintln(a.Out, string(jsonBytes))
}
} else {
a.printSummary(summary)
}
if summary.HasErrors() {
return fmt.Errorf("audit completed and discovered errors")
}
return nil
}
// auditPolicyFileWithStatus validates all entries in a policy file and returns results, whether file exists, and any errors
func (a *AuditCmd) auditPolicyFileWithStatus(policyPath string, permInfo files.PermInfo, validator *policy.PolicyValidator) (*PolicyFileResult, bool, error) {
results := &PolicyFileResult{
FilePath: policyPath,
Rows: []policy.ValidationRowResult{},
}
// Use shared permission checking logic
permResult := CheckFilePermissions(a.Fs, policyPath, permInfo)
if !permResult.Exists {
return results, false, nil
}
if permResult.PermsErr != "" {
results.PermsError = permResult.PermsErr
}
// Report ACL problems to stderr
if permResult.ACLReport != nil && permResult.ACLErr == nil {
for _, problem := range permResult.ACLReport.Problems {
fmt.Fprintf(a.ErrOut, " ACL issue: %s\n", problem)
}
}
// Load policy file
content, err := a.Fs.ReadFile(policyPath)
if err != nil {
return nil, true, fmt.Errorf("failed to read policy file: %w", err)
}
rowDetailsList := files.ReadRowsWithDetails(content)
for i, rowDetails := range rowDetailsList {
lineNumber := i + 1
if rowDetails.Empty {
continue
}
if rowDetails.Error != nil {
result := policy.ValidationRowResult{
Status: policy.StatusError,
Reason: rowDetails.Error.Error(),
LineNumber: lineNumber,
}
results.Rows = append(results.Rows, result)
continue
}
// We break the table by rows and then feed each row as if it is its own table record the line number of error
p, problems := policy.FromTable([]byte(rowDetails.Content), policyPath)
if len(problems) > 0 {
result := policy.ValidationRowResult{
Status: policy.StatusError,
Reason: problems[0].ErrorMessage,
LineNumber: lineNumber,
}
results.Rows = append(results.Rows, result)
continue
}
for _, user := range p.Users {
// Each user entry maps to principals
for _, principal := range user.Principals {
result := validator.ValidateEntry(principal, user.IdentityAttribute, user.Issuer, lineNumber)
results.Rows = append(results.Rows, result)
}
}
}
return results, true, nil
}
// printResult prints a single validation result
func (a *AuditCmd) printResult(result policy.ValidationRowResult) {
var statusBadge string
switch result.Status {
case policy.StatusSuccess:
statusBadge = "[OK]"
case policy.StatusWarning:
statusBadge = "[WARN]"
case policy.StatusError:
statusBadge = "[ERR]"
}
statusStr := fmt.Sprintf("%-8s", string(result.Status))
fmt.Fprintf(a.Out, "%s %-8s: %s %s %s", statusBadge, statusStr, result.Principal, result.IdentityAttr, result.Issuer)
if result.Reason != "" {
fmt.Fprintf(a.Out, " (%s) ", result.Reason)
}
for _, hint := range result.Hints {
fmt.Fprintf(a.Out, " - %s ", hint)
}
fmt.Fprintf(a.Out, "\n")
}
// printSummary prints the validation summary
func (a *AuditCmd) printSummary(summary policy.ValidationSummary) {
fmt.Fprintf(a.Out, "\n=== SUMMARY ===\n")
fmt.Fprintf(a.Out, "Total Entries Tested: %d\n", summary.TotalTested)
fmt.Fprintf(a.Out, "Successful: %d\n", summary.Successful)
fmt.Fprintf(a.Out, "Warnings: %d\n", summary.Warnings)
fmt.Fprintf(a.Out, "Errors: %d\n", summary.Errors)
fmt.Fprintf(a.Out, "\nExit Code: %d", summary.GetExitCode())
if summary.GetExitCode() == 0 {
fmt.Fprintf(a.Out, " (no issues detected)\n")
} else if summary.Errors > 0 {
fmt.Fprintf(a.Out, " (errors detected)\n")
} else {
fmt.Fprintf(a.Out, " (warnings detected)\n")
}
}
// getCurrentUsername returns the current user's username
func getCurrentUsername() string {
u, err := user.Current()
if err != nil {
return ""
}
return u.Username
}
type userHomeEntry struct {
Username string
HomeDir string
}
// getHomeDirsFromEtcPasswd parses /etc/passwd and returns a list of usernames
// and their associated home directories. This is not sufficient for all home
// directories as it does not consider home directories specified by NSS.
func getHomeDirsFromEtcPasswd(etcPasswd string) []userHomeEntry {
entries := []userHomeEntry{}
for _, line := range strings.Split(etcPasswd, "\n") {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// /etc/passwd line is name:passwd:uid:gid:gecos:dir:shell
parts := strings.Split(line, ":")
if len(parts) < 7 {
continue
}
if parts[5] == "" {
continue
}
entry := userHomeEntry{Username: parts[0], HomeDir: parts[5]}
entries = append(entries, entry)
}
return entries
}
//go:build !windows
// +build !windows
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"fmt"
)
// enumerateUserHomeDirs returns the list of user home directories by reading
// /etc/passwd. This is the Unix implementation.
func (a *AuditCmd) enumerateUserHomeDirs() ([]userHomeEntry, error) {
passwdPath := "/etc/passwd"
exists, err := a.Fs.Exists(passwdPath)
if err != nil {
return nil, fmt.Errorf("failed to check /etc/passwd: %w", err)
}
if !exists {
return nil, fmt.Errorf("/etc/passwd not found (needed to enumerate user home policies)")
}
etcPasswdContent, err := a.Fs.ReadFile(passwdPath)
if err != nil {
return nil, fmt.Errorf("failed to read /etc/passwd: %w", err)
}
return getHomeDirsFromEtcPasswd(string(etcPasswdContent)), nil
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"github.com/openpubkey/opkssh/internal/sysdetails"
"github.com/openpubkey/opkssh/policy"
)
// ProviderResults records the results of auditing a provider file, e.g. /etc/opk/providers
type ProviderResults struct {
FilePath string `json:"file_path"`
// Error records any permission errors found on the provider file
Error string `json:"error"`
}
// PolicyFileResult records the results of auditing a policy file, e.g. /etc/opk/auth_id or ~/.opk/auth_id
type PolicyFileResult struct {
FilePath string `json:"file_path"`
// The validation results for each row in the policy file
Rows []policy.ValidationRowResult `json:"rows"`
// Error records any errors found in reading the policy file
Error string `json:"error"`
// PermsError records any permission errors found on the policy file
PermsError string `json:"perms_error"`
}
// TotalResults aggregates all results of the audit
type TotalResults struct {
// Overall status of the audit, true if the audit did not find any problems
Ok bool `json:"ok"`
// Username of the process that ran the audit
Username string `json:"username"`
ProviderFile ProviderResults `json:"providers_file"`
SystemPolicyFile PolicyFileResult `json:"system_policy"`
HomePolicyFiles []PolicyFileResult `json:"home_policy"`
OpkVersion string `json:"opk_version"`
OpenSSHVersion string `json:"openssh_version"`
OsInfo string `json:"os_info"`
}
func (t *TotalResults) SetOsInfo() {
t.OsInfo = string(sysdetails.DetectOS())
}
func (t *TotalResults) SetOpenSSHVersion() {
t.OpenSSHVersion = sysdetails.GetOpenSSHVersion()
}
func (t *TotalResults) SetOk() {
t.Ok = t.EvaluateOk()
}
func (t *TotalResults) EvaluateOk() bool {
if t.SystemPolicyFile.Error != "" || t.SystemPolicyFile.PermsError != "" {
return false
}
for _, row := range t.SystemPolicyFile.Rows {
if row.Status != policy.StatusSuccess {
return false
}
}
for _, homePolicy := range t.HomePolicyFiles {
if homePolicy.Error != "" || homePolicy.PermsError != "" {
return false
}
for _, row := range homePolicy.Rows {
if row.Status != policy.StatusSuccess {
return false
}
}
}
if t.ProviderFile.Error != "" {
return false
}
// No errors encountered
return true
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package config
import (
_ "embed"
"fmt"
"log"
"os"
"path/filepath"
"github.com/spf13/afero"
"gopkg.in/yaml.v3"
)
//go:embed default-client-config.yml
var DefaultClientConfig []byte
type ClientConfig struct {
DefaultProvider string `yaml:"default_provider"`
Providers []ProviderConfig `yaml:"providers"`
}
func NewClientConfig(c []byte) (*ClientConfig, error) {
var clientConfig ClientConfig
if err := yaml.Unmarshal(c, &clientConfig); err != nil {
return nil, err
}
return &clientConfig, nil
}
func (c *ClientConfig) GetProvidersMap() (map[string]ProviderConfig, error) {
return CreateProvidersMap(c.Providers)
}
// GetByIssuer looks up an OpenID Provider by its issuer URL. If there are
// multiple providers with the same issuer, it returns the first one found.
func (c *ClientConfig) GetByIssuer(issuer string) (*ProviderConfig, bool) {
for _, provider := range c.Providers {
if provider.Issuer == issuer {
return &provider, true
}
}
return nil, false
}
func ResolveClientConfigPath(configPath *string) error {
if *configPath == "" {
dir, dirErr := os.UserHomeDir()
if dirErr != nil {
return fmt.Errorf("failed to get user config dir: %w", dirErr)
}
*configPath = filepath.Join(dir, ".opk", "config.yml")
}
return nil
}
// GetClientConfigFromFile retrieves the client config from the configuration file at configPath.
// If configPath is not specified then the default configuration path is uses ~/.opk/config.yml
func GetClientConfigFromFile(configPath string, Fs afero.Fs) (*ClientConfig, error) {
if err := ResolveClientConfigPath(&configPath); err != nil {
return nil, err
}
var configBytes []byte
// Load the file from the filesystem
afs := &afero.Afero{Fs: Fs}
configBytes, err := afs.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
config, err := NewClientConfig(configBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
return config, nil
}
func CreateDefaultClientConfig(configPath string, Fs afero.Fs) error {
afs := &afero.Afero{Fs: Fs}
if err := afs.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := afs.WriteFile(configPath, DefaultClientConfig, 0o644); err != nil {
return fmt.Errorf("failed to write default config file: %w", err)
}
log.Printf("created client config file at %s", configPath)
return nil
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package config
import (
"fmt"
"os"
"strings"
"github.com/openpubkey/openpubkey/providers"
"gopkg.in/yaml.v3"
)
const (
WEBCHOOSER_ALIAS = "WEBCHOOSER"
OPKSSH_DEFAULT_ENVVAR = "OPKSSH_DEFAULT"
OPKSSH_PROVIDERS_ENVVAR = "OPKSSH_PROVIDERS"
)
type ProviderConfig struct {
AliasList []string `yaml:"alias"`
Issuer string `yaml:"issuer"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret,omitempty"`
Scopes []string `yaml:"scopes"`
AccessType string `yaml:"access_type,omitempty"`
Prompt string `yaml:"prompt,omitempty"`
RedirectURIs []string `yaml:"redirect_uris"`
// Optional field to enable the use of non-localhost redirect URI.
// This is an advanced option for embedding opkssh in server-side
// logic and should not be specified most of the time.
RemoteRedirectURI string `yaml:"remote_redirect_uri,omitempty"`
SendAccessToken bool `yaml:"send_access_token,omitempty"`
}
func (p *ProviderConfig) UnmarshalYAML(value *yaml.Node) error {
// We use tmp to handle lists as space-separated strings, e.g., scope: openid profile email offline_access.
var tmp struct {
AliasList string `yaml:"alias"`
Issuer string `yaml:"issuer"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
Scopes string `yaml:"scopes"`
AccessType string `yaml:"access_type"`
Prompt string `yaml:"prompt"`
RedirectURIs []string `yaml:"redirect_uris"`
// Optional field to enable the use of non-localhost redirect URI.
// This is an advanced option for embedding opkssh in server-side
// logic and should not be specified most of the time.
RemoteRedirectURI string `yaml:"remote_redirect_uri,omitempty"`
SendAccessToken bool `yaml:"send_access_token,omitempty"`
}
// Set default values
tmp.Scopes = "openid profile email"
tmp.AccessType = "offline"
tmp.Prompt = "consent"
tmp.RedirectURIs = []string{
"http://localhost:3000/login-callback",
"http://localhost:10001/login-callback",
"http://localhost:11110/login-callback",
}
if err := value.Decode(&tmp); err != nil {
return err
}
*p = ProviderConfig{
AliasList: strings.Fields(tmp.AliasList),
Issuer: tmp.Issuer,
ClientID: tmp.ClientID,
ClientSecret: tmp.ClientSecret,
Scopes: strings.Fields(tmp.Scopes),
AccessType: tmp.AccessType,
Prompt: tmp.Prompt,
RedirectURIs: tmp.RedirectURIs,
RemoteRedirectURI: tmp.RemoteRedirectURI,
SendAccessToken: tmp.SendAccessToken,
}
return nil
}
// TODO: Move this into OpenPubkey providers package
func DefaultProviderConfig() ProviderConfig {
return ProviderConfig{
AliasList: []string{},
Issuer: "",
ClientID: "",
ClientSecret: "",
Scopes: []string{"openid", "email"},
AccessType: "offline",
RedirectURIs: []string{
"http://localhost:3000/login-callback",
"http://localhost:10001/login-callback",
"http://localhost:11110/login-callback",
},
Prompt: "consent",
}
}
func GitHubProviderConfig() ProviderConfig {
return ProviderConfig{
AliasList: []string{"github"},
Issuer: "https://token.actions.githubusercontent.com",
// This is required, but is not used for this provider.
ClientID: "unused",
}
}
// NewProviderConfigFromString is a function to create the provider config from a string of the format
// {alias},{provider_url},{client_id},{client_secret},{scopes}
func NewProviderConfigFromString(configStr string, hasAlias bool) (ProviderConfig, error) {
parts := strings.Split(configStr, ",")
alias := ""
if hasAlias {
// If the config string has an alias, we need to remove it from the parts
alias = parts[0]
parts = parts[1:]
}
if len(parts) < 2 {
if hasAlias {
return ProviderConfig{}, fmt.Errorf("invalid provider config string. Expected format <alias>,<issuer>,<client_id> or <alias>,<issuer>,<client_id>,<client_secret> or <alias>,<issuer>,<client_id>,<client_secret>,<scopes>")
}
return ProviderConfig{}, fmt.Errorf("invalid provider config string. Expected format <issuer>,<client_id> or <issuer>,<client_id>,<client_secret> or <issuer>,<client_id>,<client_secret>,<scopes>")
}
providerConfig := DefaultProviderConfig()
providerConfig.AliasList = []string{alias}
providerConfig.Issuer = parts[0]
providerConfig.ClientID = parts[1]
if providerConfig.ClientID == "" {
return ProviderConfig{}, fmt.Errorf("invalid provider client-ID value got (%s)", providerConfig.ClientID)
}
if len(parts) > 2 {
providerConfig.ClientSecret = parts[2]
} else {
providerConfig.ClientSecret = ""
}
if len(parts) > 3 {
providerConfig.Scopes = strings.Split(parts[3], " ")
} else {
providerConfig.Scopes = []string{"openid", "email"}
}
if strings.HasPrefix(providerConfig.Issuer, "https://accounts.google.com") {
// The Google OP is strange in that it requires a client secret even if this is a public OIDC App.
// Despite its name the Google OP client secret is a public value.
if providerConfig.ClientSecret == "" {
if hasAlias {
return ProviderConfig{}, fmt.Errorf("invalid provider argument format. Expected format for google: <alias>,<issuer>,<client_id>,<client_secret>")
} else {
return ProviderConfig{}, fmt.Errorf("invalid provider argument format. Expected format for google: <issuer>,<client_id>,<client_secret>")
}
}
}
return providerConfig, nil
}
// NewProviderFromConfig is a function to create the provider from the config
func (p *ProviderConfig) ToProvider(openBrowser bool) (providers.OpenIdProvider, error) {
if p.Issuer == "" {
return nil, fmt.Errorf("invalid provider issuer value got (%s)", p.Issuer)
}
if !strings.HasPrefix(p.Issuer, "https://") {
return nil, fmt.Errorf("invalid provider issuer value. Expected issuer to start with 'https://' got (%s)", p.Issuer)
}
if p.ClientID == "" {
return nil, fmt.Errorf("invalid provider client-ID value got (%s)", p.ClientID)
}
var provider providers.OpenIdProvider
if strings.HasPrefix(p.Issuer, "https://accounts.google.com") {
opts := providers.GetDefaultGoogleOpOptions()
opts.Issuer = p.Issuer
opts.ClientID = p.ClientID
opts.ClientSecret = p.ClientSecret
opts.GQSign = false
if p.hasScopes() {
opts.Scopes = p.Scopes
}
opts.PromptType = p.Prompt
opts.AccessType = p.AccessType
opts.RedirectURIs = p.RedirectURIs
opts.RemoteRedirectURI = p.RemoteRedirectURI
opts.OpenBrowser = openBrowser
provider = providers.NewGoogleOpWithOptions(opts)
} else if strings.HasPrefix(p.Issuer, "https://login.microsoftonline.com") {
opts := providers.GetDefaultAzureOpOptions()
opts.Issuer = p.Issuer
opts.ClientID = p.ClientID
opts.GQSign = false
if p.hasScopes() {
opts.Scopes = p.Scopes
}
opts.PromptType = p.Prompt
opts.AccessType = p.AccessType
opts.RedirectURIs = p.RedirectURIs
opts.RemoteRedirectURI = p.RemoteRedirectURI
opts.OpenBrowser = openBrowser
provider = providers.NewAzureOpWithOptions(opts)
} else if strings.HasPrefix(p.Issuer, "https://gitlab.com") {
opts := providers.GetDefaultGitlabOpOptions()
opts.Issuer = p.Issuer
opts.ClientID = p.ClientID
opts.GQSign = false
if p.hasScopes() {
opts.Scopes = p.Scopes
}
opts.PromptType = p.Prompt
opts.AccessType = p.AccessType
opts.RedirectURIs = p.RedirectURIs
opts.RemoteRedirectURI = p.RemoteRedirectURI
opts.OpenBrowser = openBrowser
provider = providers.NewGitlabOpWithOptions(opts)
} else if p.Issuer == "https://issuer.hello.coop" {
opts := providers.GetDefaultHelloOpOptions()
opts.Issuer = p.Issuer
opts.ClientID = p.ClientID
opts.GQSign = false
if p.hasScopes() {
opts.Scopes = p.Scopes
}
opts.PromptType = p.Prompt
opts.AccessType = p.AccessType
opts.RedirectURIs = p.RedirectURIs
opts.RemoteRedirectURI = p.RemoteRedirectURI
opts.OpenBrowser = openBrowser
provider = providers.NewHelloOpWithOptions(opts)
} else if strings.HasPrefix(p.Issuer, "https://token.actions.githubusercontent.com") {
githubOp, err := providers.NewGithubOpFromEnvironment()
if err != nil {
return nil, fmt.Errorf("error creating github op: %w", err)
}
provider = githubOp
} else {
// Generic provider
opts := providers.GetDefaultStandardOpOptions(p.Issuer, p.ClientID)
opts.ClientSecret = p.ClientSecret
opts.PromptType = p.Prompt
opts.AccessType = p.AccessType
opts.RedirectURIs = p.RedirectURIs
opts.RemoteRedirectURI = p.RemoteRedirectURI
opts.GQSign = false
if p.hasScopes() {
opts.Scopes = p.Scopes
}
opts.OpenBrowser = openBrowser
provider = providers.NewStandardOpWithOptions(opts)
}
return provider, nil
}
func (p *ProviderConfig) hasScopes() bool {
return len(p.Scopes) > 0 && (len(p.Scopes) > 1 || p.Scopes[0] != "")
}
// GetProvidersConfigFromEnv is a function to retrieve the config from the env variables
// OPKSSH_DEFAULT can be set to an alias
// OPKSSH_PROVIDERS is a ; separated list of providers of the format <alias>,<issuer>,<client_id>,<client_secret>,<scopes>;<alias>,<issuer>,<client_id>,<client_secret>,<scopes>
func GetProvidersConfigFromEnv() ([]ProviderConfig, error) {
// Get the providers from the env variable
providerList, ok := os.LookupEnv(OPKSSH_PROVIDERS_ENVVAR)
if !ok || providerList == "" {
return nil, nil
}
if providerConfigList, err := ProvidersConfigListFromStrings(providerList); err != nil {
return nil, fmt.Errorf("error getting provider config from env: %w", err)
} else {
return providerConfigList, nil
}
}
func ProvidersConfigListFromStrings(providerList string) ([]ProviderConfig, error) {
providerConfigList := make([]ProviderConfig, 0)
for _, providerStr := range strings.Split(providerList, ";") {
providerConfig, err := NewProviderConfigFromString(providerStr, true)
if err != nil {
return nil, fmt.Errorf("error parsing provider config string: %w", err)
}
providerConfigList = append(providerConfigList, providerConfig)
}
return providerConfigList, nil
}
func CreateProvidersMap(providerConfigList []ProviderConfig) (map[string]ProviderConfig, error) {
providersConfig := make(map[string]ProviderConfig)
for _, providerConfig := range providerConfigList {
for _, alias := range providerConfig.AliasList {
// If alias already exists, return an error
if _, ok := providersConfig[alias]; ok {
return nil, fmt.Errorf("duplicate provider alias found: %s", alias)
}
providersConfig[alias] = providerConfig
}
}
return providersConfig, nil
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package config
import (
"os"
"gopkg.in/yaml.v3"
)
// ServerConfig struct to represent the /etc/opk/config.yml file that runs on the server that the user is SSHing into
type ServerConfig struct {
EnvVars map[string]string `yaml:"env_vars"`
DenyUsers []string `yaml:"deny_users"`
DenyEmails []string `yaml:"deny_emails"`
}
func NewServerConfig(c []byte) (*ServerConfig, error) {
var serverConfig ServerConfig
if err := yaml.Unmarshal(c, &serverConfig); err != nil {
return nil, err
}
return &serverConfig, nil
}
func (c *ServerConfig) SetEnvVars() error {
for k, v := range c.EnvVars {
if err := os.Setenv(k, v); err != nil {
return err
}
}
return nil
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
//go:build !windows
// +build !windows
package commands
import "os"
// IsElevated returns true if the current process is running as root on Unix-like systems.
func IsElevated() (bool, error) {
return os.Geteuid() == 0, nil
}
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/openpubkey/openpubkey/pktoken"
"golang.org/x/crypto/ssh"
)
type InspectCmd struct {
// KeyOrCert is the SSH key or certificate to be inspected.
KeyOrCert string
// Output is where output should be written to.
Output io.Writer
}
// NewInspectCmd creates a new InspectCmd instance with the provided arguments.
func NewInspectCmd(keyOrCert string, output io.Writer) *InspectCmd {
return &InspectCmd{
KeyOrCert: keyOrCert,
Output: output,
}
}
// printf formats a string to the configured output.
func (i *InspectCmd) printf(format string, a ...any) {
if _, err := fmt.Fprintf(i.Output, format, a...); err != nil {
// Fall back to stdout
i.printf(format, a...)
}
}
func (i *InspectCmd) Run() error {
// Check if the input is a file path
if _, err := os.Stat(i.KeyOrCert); err == nil {
// It's a file, read its contents
data, err := os.ReadFile(i.KeyOrCert)
if err != nil {
return fmt.Errorf("error reading input file: %v", err)
}
i.KeyOrCert = string(data)
}
// Trim whitespace and newlines
i.KeyOrCert = strings.TrimSpace(i.KeyOrCert)
// Parse the SSH key or certificate
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(i.KeyOrCert))
if err != nil {
return fmt.Errorf("failed to parse SSH key: %v", err)
}
// Check if it's a certificate
if cert, ok := pubKey.(*ssh.Certificate); ok {
i.inspectCertificate(cert)
} else {
// It's a regular public key
i.inspectPublicKey(pubKey)
}
return nil
}
func (i *InspectCmd) inspectCertificate(cert *ssh.Certificate) {
i.printf("--- SSH Certificate Information ---\n")
i.printf("%-18s %d\n", "Serial:", cert.Serial)
i.printf("%-18s %s\n", "Type:", certificateType(cert.CertType))
i.printf("%-18s %s\n", "Key ID:", cert.KeyId)
i.printf("%-18s %v\n", "Principals:", cert.ValidPrincipals)
i.printf("%-18s %s\n", "Valid After:", formatTime(cert.ValidAfter))
i.printf("%-18s %s\n", "Valid Before:", formatTime(cert.ValidBefore))
i.printf("%-18s %v\n", "Critical Options:", cert.CriticalOptions)
// Format extensions nicely
i.printf("Extensions:\n")
for key, value := range cert.Extensions {
if key == "openpubkey-pkt" {
i.printf(" %s: [PKToken data] %d bytes\n", key, len(value))
} else {
i.printf(" %s: %s\n", key, value)
}
}
// Extract openpubkey-pkt extension if it exists
pktStr, ok := cert.Extensions["openpubkey-pkt"]
if !ok {
i.printf("\nNo openpubkey-pkt extension found\n")
return
}
i.inspectPKToken(pktStr)
}
// formatTime converts a Unix timestamp to a readable date string
func formatTime(timestamp uint64) string {
if timestamp == 0 {
return "Not set"
}
if timestamp == 1<<64-1 {
return "Forever"
}
t := time.Unix(int64(timestamp), 0)
return t.Format(time.RFC3339)
}
func (i *InspectCmd) inspectPublicKey(pubKey ssh.PublicKey) {
i.printf("--- SSH Public Key Information ---\n")
i.printf("Type: %s\n", pubKey.Type())
// Get fingerprint
fingerprint := ssh.FingerprintSHA256(pubKey)
i.printf("Fingerprint: %s\n", fingerprint)
// Get marshal format
marshal := base64.StdEncoding.EncodeToString(pubKey.Marshal())
i.printf("Marshal (base64): %s...\n", marshal[:20])
}
func certificateType(certType uint32) string {
switch certType {
case ssh.UserCert:
return "User Certificate"
case ssh.HostCert:
return "Host Certificate"
}
return fmt.Sprintf("Unknown (%d)", certType)
}
func (i *InspectCmd) inspectPKToken(pktStr string) {
// Parse the PKToken
pkt, err := pktoken.NewFromCompact([]byte(pktStr))
if err != nil {
i.printf("Error parsing PKToken: %v\n", err)
return
}
// Print token structure and metadata
i.printf("\n--- PKToken Structure ---\n")
i.printf("Payload:\n")
i.printJSON(pkt.Payload)
// Print signature information
i.printf("\n--- Signature Information ---\n")
if pkt.Op != nil {
i.printf("Provider Signature (OP) exists\n")
hdrs := pkt.Op.ProtectedHeaders()
if hdrs != nil {
i.printJSONObject(hdrs)
}
}
if pkt.Cic != nil {
i.printf("Client Signature (CIC) exists\n")
hdrs := pkt.Cic.ProtectedHeaders()
if hdrs != nil {
i.printJSONObject(hdrs)
}
}
if pkt.Cos != nil {
i.printf("Cosigner Signature (COS) exists\n")
hdrs := pkt.Cos.ProtectedHeaders()
if hdrs != nil {
i.printJSONObject(hdrs)
}
}
// Print token metadata
i.printf("\n--- Token Metadata ---\n")
i.printTokenMetadata(pkt)
}
func (i *InspectCmd) printJSON(data []byte) {
var obj any
if err := json.Unmarshal(data, &obj); err != nil {
i.printf("Error unmarshalling JSON: %v\n", err)
i.printf("%s\n", string(data))
return
}
i.printJSONObject(obj)
}
func (i *InspectCmd) printJSONObject(obj any) {
pretty, err := json.MarshalIndent(obj, "", " ")
if err != nil {
i.printf("Error pretty-printing: %v\n", err)
i.printf("%v\n", obj)
return
}
i.printf("%s\n", string(pretty))
}
func (i *InspectCmd) printTokenMetadata(pkt *pktoken.PKToken) {
// Extract common token claims
if issuer, err := pkt.Issuer(); err == nil {
i.printf("%-19s %s\n", "Issuer:", issuer)
}
if aud, err := pkt.Audience(); err == nil {
i.printf("%-19s %s\n", "Audience:", aud)
}
if sub, err := pkt.Subject(); err == nil {
i.printf("%-19s %s\n", "Subject:", sub)
}
if identity, err := pkt.IdentityString(); err == nil {
i.printf("%-19s %s\n", "Identity:", identity)
}
// Print token hash (useful for identifying tokens)
if hash, err := pkt.Hash(); err == nil {
i.printf("%-19s %s\n", "Token Hash:", hash)
}
// Print provider algorithm if available
if alg, ok := pkt.ProviderAlgorithm(); ok {
i.printf("%-19s %s\n", "Provider Algorithm:", alg)
}
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/openpubkey/openpubkey/client"
"github.com/openpubkey/openpubkey/client/choosers"
"github.com/openpubkey/openpubkey/jose"
"github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/util"
"github.com/openpubkey/opkssh/commands/config"
"github.com/openpubkey/opkssh/sshcert"
"github.com/spf13/afero"
"github.com/thediveo/enumflag/v2"
"golang.org/x/crypto/ed25519"
"golang.org/x/crypto/ssh"
)
// KeyType is the algorithm to use for the user's key pair. This is used both by OpenPubkey as algorithm for upk (user public key) and by SSH for public key in the SSH certificate generated by opkssh.
type KeyType enumflag.Flag
const (
ECDSA KeyType = iota
ED25519
)
func (k KeyType) String() string {
switch k {
case ECDSA:
return "ecdsa"
case ED25519:
return "ed25519"
default:
return "unknown"
}
}
// DefaultSSHKeyFileNames are the file names ssh key pairs that opkssh may
// write to in ~/.ssh/ during login. These are used by both login and logout
// so that if a new key type is added, logout will automatically pick it up.
var DefaultSSHKeyFileNames = map[KeyType][]string{
ECDSA: {"id_ecdsa", "id_ecdsa_sk"},
ED25519: {"id_ed25519", "id_ed25519_sk"},
}
// LoginCmd represents the login command that performs OIDC authentication and generates SSH certificates.
type LoginCmd struct {
// Inputs
Fs afero.Fs
AutoRefreshArg bool // Automatically refresh PK token after login
ConfigPathArg string // Path to the client config file.
CreateConfigArg bool // Creates a client config file if it does not exist
ConfigureArg bool // Apply changes to ssh config and create ~/.ssh/opkssh directory
LogDirArg string // Directory to write output logs
SendAccessTokenArg bool // Send the Access Token as well as the PK Token in the SSH cert. The Access Token is used to call the userinfo endpoint to get claims not included in the ID Token
DisableBrowserOpenArg bool // Disable opening the browser. Useful for choosing the browser you want to use
PrintIdTokenArg bool // Print out the contents of the id_token. Useful for inspecting claims and troubleshooting
KeyPathArg string // Path where SSH private key is written
ProviderArg string // OpenID Provider specification in the format: <issuer>,<client_id> or <issuer>,<client_id>,<client_secret> or <issuer>,<client_id>,<client_secret>,<scopes>
ProviderAliasArg string
KeyTypeArg KeyType
PrintKeyArg bool // Print the raw private key and SSH cert to stdout instead of writing them to the filesystem
InspectCertArg bool // Display a human-readable inspection of the generated SSH certificate (public information only)
SSHConfigured bool
Verbosity int // Default verbosity is 0, 1 is verbose, 2 is debug
RemoteRedirectURI string
overrideProvider *providers.OpenIdProvider // Used in tests to override the provider to inject a mock provider
// State
Config *config.ClientConfig
// Outputs
pkt *pktoken.PKToken
signer crypto.Signer
alg jose.KeyAlgorithm
client *client.OpkClient
principals []string
// For testing
OutWriter io.Writer // Captures non-logged output that would normally be written to stdout
}
// NewLogin creates a new LoginCmd instance with the provided arguments.
func NewLogin(autoRefreshArg bool, configPathArg string, createConfigArg bool, configureArg bool, logDirArg string,
sendAccessTokenArg bool, disableBrowserOpenArg bool, printIdTokenArg bool,
providerArg string, printKeyArg bool, keyPathArg string, providerAliasArg string, keyTypeArg KeyType,
remoteRedirectUri string, inspectCertArg bool,
) *LoginCmd {
return &LoginCmd{
Fs: afero.NewOsFs(),
AutoRefreshArg: autoRefreshArg,
ConfigPathArg: configPathArg,
CreateConfigArg: createConfigArg,
ConfigureArg: configureArg,
LogDirArg: logDirArg,
SendAccessTokenArg: sendAccessTokenArg,
DisableBrowserOpenArg: disableBrowserOpenArg,
PrintIdTokenArg: printIdTokenArg,
KeyPathArg: keyPathArg,
ProviderArg: providerArg,
PrintKeyArg: printKeyArg,
InspectCertArg: inspectCertArg,
ProviderAliasArg: providerAliasArg,
KeyTypeArg: keyTypeArg,
RemoteRedirectURI: remoteRedirectUri,
}
}
func (l *LoginCmd) Run(ctx context.Context) error {
// If a log directory was provided, write any logs to a file in that directory AND stdout
if l.LogDirArg != "" {
logFilePath := filepath.Join(l.LogDirArg, "opkssh.log")
logFile, err := l.Fs.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o660)
if err != nil {
log.Printf("Failed to open log for writing: %v \n", err)
}
defer logFile.Close()
multiWriter := io.MultiWriter(os.Stdout, logFile)
log.SetOutput(multiWriter)
} else {
log.SetOutput(os.Stdout)
}
if l.Verbosity >= 2 {
log.Printf("DEBUG: running login command with args: %+v", *l)
}
// If the Config has been set in the struct don't replace it. This is useful for testing
if l.Config == nil {
if err := config.ResolveClientConfigPath(&l.ConfigPathArg); err != nil {
return err
}
if _, err := l.Fs.Stat(l.ConfigPathArg); err == nil {
if l.CreateConfigArg {
log.Printf("--create-config=true but config file already exists at %s", l.ConfigPathArg)
}
if client_config, err := config.GetClientConfigFromFile((l.ConfigPathArg), l.Fs); err != nil {
return err
} else {
l.Config = client_config
}
} else {
if l.CreateConfigArg {
return config.CreateDefaultClientConfig(l.ConfigPathArg, l.Fs)
} else {
log.Printf("failed to find client config file to generate a default config, run `opkssh login --create-config` to create a default config file")
}
l.Config, err = config.NewClientConfig(config.DefaultClientConfig)
if err != nil {
return fmt.Errorf("failed to parse default config file: %w", err)
}
}
}
if l.ConfigureArg {
err := l.configureSSH()
if err != nil {
return fmt.Errorf("failed to configure SSH: %w", err)
}
return nil
} else {
l.checkSSHConfigured()
}
if isGitHubEnvironment() {
l.Config.Providers = append(l.Config.Providers, config.GitHubProviderConfig())
}
var provider providers.OpenIdProvider
if l.overrideProvider != nil {
provider = *l.overrideProvider
} else {
op, chooser, err := l.determineProvider()
if err != nil {
return err
}
if chooser != nil {
provider, err = chooser.ChooseOp(ctx)
if err != nil {
return fmt.Errorf("error choosing provider: %w", err)
}
} else if op != nil {
provider = op
} else {
return fmt.Errorf("no provider found") // Either the provider or the chooser must be set. If this occurs we have a bug in the code.
}
}
// This arg is true if set, so if it false it hasn't been set and
// we should use the config value for the matching providing.
// If it is true we ignore the config
if !l.SendAccessTokenArg {
if opConfig, ok := l.Config.GetByIssuer(provider.Issuer()); !ok {
// This can happen if the provider is supplied via the command line or environment variables and thus not in the config
log.Printf("Warning: could not find issuer %s in client config providers\n", provider.Issuer())
} else {
l.SendAccessTokenArg = opConfig.SendAccessToken
}
}
// Execute login command
if l.AutoRefreshArg {
if providerRefreshable, ok := provider.(providers.RefreshableOpenIdProvider); ok {
err := l.LoginWithRefresh(ctx, providerRefreshable, l.PrintIdTokenArg, l.KeyPathArg)
if err != nil {
return fmt.Errorf("error logging in: %w", err)
}
} else {
return fmt.Errorf("supplied OpenID Provider (%v) does not support auto-refresh and auto-refresh argument set to true", provider.Issuer())
}
} else {
err := l.Login(ctx, provider, l.PrintIdTokenArg, l.KeyPathArg)
if err != nil {
return fmt.Errorf("error logging in: %w", err)
}
}
return nil
}
func (l *LoginCmd) configureSSH() error {
userhomeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user config dir: %v", err)
}
const includeDirective = "Include ~/.ssh/opkssh/config"
const opkSshDir = ".ssh/opkssh"
var userSshConfig = filepath.Join(userhomeDir, ".ssh/config")
var userOpkSshDir = filepath.Join(userhomeDir, opkSshDir)
var userOpkSshConfig = filepath.Join(userOpkSshDir, "config")
if _, err := l.Fs.Stat(userOpkSshConfig); err == nil {
log.Println("--configure but already configured")
}
log.Printf("Creating config directory at %s", userOpkSshDir)
afs := &afero.Afero{Fs: l.Fs}
err = afs.MkdirAll(userOpkSshDir, 0o0700)
if err != nil {
return fmt.Errorf("failed to create opkssh SSH directory: %w", err)
}
log.Printf("Creating config file at %s", userOpkSshConfig)
file, err := afs.OpenFile(userOpkSshConfig, os.O_CREATE, 0o0600)
if err != nil {
return fmt.Errorf("failed to create opkssh SSH directory: %w", err)
}
defer file.Close()
log.Printf("Adding include directive to SSH config at %s", "~/.ssh/config")
content, err := afs.ReadFile(userSshConfig)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to read SSH config file: %w", err)
}
if strings.Contains(string(content), includeDirective) {
log.Println("Found include directive file in SSH config, skipping...")
} else {
// construct new SSH config
content = slices.Concat([]byte(includeDirective+"\n\n"), content)
err = afs.WriteFile(userSshConfig, content, 0o0600)
if err != nil {
return fmt.Errorf("failed to write SSH config file: %w", err)
}
}
l.SSHConfigured = true
log.Println("Configured SSH identity directory")
return nil
}
func (l *LoginCmd) checkSSHConfigured() {
userhomeDir, err := os.UserHomeDir()
if err != nil {
log.Printf("Failed to get user config dir: %v", err)
return
}
const includeDirective = "Include ~/.ssh/opkssh/config"
const opkSshDir = ".ssh/opkssh"
var userSshConfig = filepath.Join(userhomeDir, ".ssh/config")
var userOpkSshDir = filepath.Join(userhomeDir, opkSshDir)
var userOpkSshConfig = filepath.Join(userOpkSshDir, "config")
afs := &afero.Afero{Fs: l.Fs}
content, err := afs.ReadFile(userSshConfig)
if err != nil {
// no user SSH config, could not have included ours
return
}
if !strings.Contains(string(content), includeDirective) {
// no include directive
return
}
_, err = afs.Stat(userOpkSshConfig)
if err != nil {
// opkssh ssh config missing
return
}
fmt.Println("OPK SSH identity directory is configured")
l.SSHConfigured = true
}
func (l *LoginCmd) determineProvider() (providers.OpenIdProvider, *choosers.WebChooser, error) {
openBrowser := !l.DisableBrowserOpenArg
var defaultProviderAlias string
var providerConfigs []config.ProviderConfig
var provider providers.OpenIdProvider
var err error
// If the user has supplied commandline arguments for the provider, short circuit and use providerArg
if l.ProviderArg != "" {
providerConfig, err := config.NewProviderConfigFromString(l.ProviderArg, false)
if err != nil {
return nil, nil, fmt.Errorf("error parsing provider argument: %w", err)
}
if l.RemoteRedirectURI != "" {
// Override the remote redirect URI
providerConfig.RemoteRedirectURI = l.RemoteRedirectURI
}
if provider, err = providerConfig.ToProvider(openBrowser); err != nil {
return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
} else {
return provider, nil, nil
}
}
// Set the default provider from the env variable if specified
defaultProviderEnv, _ := os.LookupEnv(config.OPKSSH_DEFAULT_ENVVAR)
providerConfigsEnv, err := config.GetProvidersConfigFromEnv()
if err != nil {
return nil, nil, fmt.Errorf("error getting provider config from env: %w", err)
}
if l.ProviderAliasArg != "" {
defaultProviderAlias = l.ProviderAliasArg
} else if defaultProviderEnv != "" {
defaultProviderAlias = defaultProviderEnv
} else if l.Config.DefaultProvider != "" {
defaultProviderAlias = l.Config.DefaultProvider
} else {
defaultProviderAlias = config.WEBCHOOSER_ALIAS
}
if providerConfigsEnv != nil {
providerConfigs = providerConfigsEnv
} else if len(l.Config.Providers) > 0 {
providerConfigs = l.Config.Providers
} else {
return nil, nil, fmt.Errorf("no providers specified")
}
if strings.ToUpper(defaultProviderAlias) != config.WEBCHOOSER_ALIAS {
providerMap, err := config.CreateProvidersMap(providerConfigs)
if err != nil {
return nil, nil, fmt.Errorf("error creating provider map: %w", err)
}
providerConfig, ok := providerMap[defaultProviderAlias]
if !ok {
return nil, nil, fmt.Errorf("error getting provider config for alias %s", defaultProviderAlias)
}
if l.RemoteRedirectURI != "" {
// Override the remote redirect URI
providerConfig.RemoteRedirectURI = l.RemoteRedirectURI
}
provider, err = providerConfig.ToProvider(openBrowser)
if err != nil {
return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
}
return provider, nil, nil
} else {
// If the default provider is WEBCHOOSER, we need to create a chooser and return it
var providerList []providers.BrowserOpenIdProvider
for _, providerConfig := range providerConfigs {
if l.RemoteRedirectURI != "" {
// Override the remote redirect URI
providerConfig.RemoteRedirectURI = l.RemoteRedirectURI
}
op, err := providerConfig.ToProvider(openBrowser)
if err != nil {
return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
}
providerList = append(providerList, op.(providers.BrowserOpenIdProvider))
}
chooser := choosers.NewWebChooser(
providerList, openBrowser,
)
return nil, chooser, nil
}
}
func (l *LoginCmd) login(ctx context.Context, provider providers.OpenIdProvider, printIdToken bool, seckeyPath string) (*LoginCmd, error) {
var err error
var alg jose.KeyAlgorithm
switch l.KeyTypeArg {
case ECDSA:
alg = jose.ES256
case ED25519:
alg = jose.EdDSA
default:
return nil, fmt.Errorf("unsupported key type (%s); use -t <%s|%s>", l.KeyTypeArg.String(), ECDSA.String(), ED25519.String())
}
signer, err := util.GenKeyPair(alg)
if err != nil {
return nil, fmt.Errorf("failed to generate keypair: %w", err)
}
opkClient, err := client.New(provider, client.WithSigner(signer, alg))
if err != nil {
return nil, err
}
pkt, err := opkClient.Auth(ctx)
if err != nil {
return nil, err
}
l.pkt = pkt
var accessToken []byte
if l.SendAccessTokenArg {
accessToken = opkClient.GetAccessToken()
if accessToken == nil {
return nil, fmt.Errorf("access token required but provider (%s) did not set access-token", opkClient.Op.Issuer())
}
}
// If principals field is empty sshd automatically rejects the SSH certificate.
// We use opkssh-wildcard as placeholder so that we can allow the OPK
// verifier to make this policy decision instead of sshd.
// See https://github.com/openpubkey/opkssh/pull/513
principals := []string{"opkssh-wildcard"}
certBytes, seckeySshPem, err := createSSHCertWithAccessToken(pkt, accessToken, signer, principals)
if err != nil {
return nil, fmt.Errorf("failed to generate SSH cert: %w", err)
}
// Write ssh secret key and public key to filesystem
if l.PrintKeyArg {
w := l.out()
fmt.Fprintln(w, string(certBytes)) // Base64 encoded SSH cert
fmt.Fprintln(w, string(seckeySshPem)) // SSH private key in OpenSSH native format
} else if seckeyPath != "" {
// If we have set seckeyPath then write it there
if err := l.writeKeys(seckeyPath, seckeyPath+"-cert.pub", seckeySshPem, certBytes); err != nil {
return nil, fmt.Errorf("failed to write SSH keys to filesystem: %w", err)
}
} else if l.SSHConfigured {
if err := l.writeKeysToOpkSSHDir(seckeySshPem, certBytes); err != nil {
return nil, fmt.Errorf("failed to write SSH keys to OPK SSH dir: %w", err)
}
} else {
// If keyPath isn't set then write it to the default location
if err := l.writeKeysToSSHDir(seckeySshPem, certBytes); err != nil {
return nil, fmt.Errorf("failed to write SSH keys to filesystem: %w", err)
}
}
if printIdToken {
idTokenStr, err := PrettyIdToken(*pkt)
if err != nil {
return nil, fmt.Errorf("failed to format ID Token: %w", err)
}
fmt.Fprintf(l.out(), "id_token:\n%s\n", idTokenStr)
}
if l.InspectCertArg {
inspect := NewInspectCmd(string(certBytes), l.out())
if err := inspect.Run(); err != nil {
return nil, fmt.Errorf("failed to inspect SSH cert: %w", err)
}
}
idStr, err := IdentityString(*pkt)
if err != nil {
return nil, fmt.Errorf("failed to parse ID Token: %w", err)
}
fmt.Printf("Keys generated for identity\n%s\n", idStr)
return &LoginCmd{
pkt: pkt,
signer: signer,
client: opkClient,
alg: alg,
principals: principals,
}, nil
}
// Login performs the OIDC login procedure and creates the SSH certs/keys in the
// default SSH key location.
func (l *LoginCmd) Login(ctx context.Context, provider providers.OpenIdProvider, printIdToken bool, seckeyPath string) error {
_, err := l.login(ctx, provider, printIdToken, seckeyPath)
return err
}
// LoginWithRefresh performs the OIDC login procedure, creates the SSH
// certs/keys in the default SSH key location, and continues to run and refresh
// the PKT (and create new SSH certs) indefinitely as its token expires. This
// function only returns if it encounters an error or if the supplied context is
// cancelled.
func (l *LoginCmd) LoginWithRefresh(ctx context.Context, provider providers.RefreshableOpenIdProvider, printIdToken bool, seckeyPath string) error {
if loginResult, err := l.login(ctx, provider, printIdToken, seckeyPath); err != nil {
return err
} else {
var claims struct {
Expiration int64 `json:"exp"`
}
if err := json.Unmarshal(loginResult.pkt.Payload, &claims); err != nil {
return err
}
for {
// Sleep until a minute before expiration to give us time to refresh
// the token and minimize any interruptions
untilExpired := time.Until(time.Unix(claims.Expiration, 0)) - time.Minute
log.Printf("Waiting for %v before attempting to refresh id_token...", untilExpired)
select {
case <-time.After(untilExpired):
log.Print("Refreshing id_token...")
case <-ctx.Done():
return ctx.Err()
}
refreshedPkt, err := loginResult.client.Refresh(ctx)
if err != nil {
return err
}
loginResult.pkt = refreshedPkt
var accessToken []byte
if l.SendAccessTokenArg {
accessToken = loginResult.client.GetAccessToken()
if accessToken == nil {
return fmt.Errorf("access token required but provider (%s) did not set access-token on refresh: %w", loginResult.client.Op.Issuer(), err)
}
}
certBytes, seckeySshPem, err := createSSHCertWithAccessToken(loginResult.pkt, accessToken, loginResult.signer, loginResult.principals)
if err != nil {
return fmt.Errorf("failed to generate SSH cert: %w", err)
}
// Write ssh secret key and public key to filesystem
if seckeyPath != "" {
// If we have set seckeyPath then write it there
if err := l.writeKeys(seckeyPath, seckeyPath+"-cert.pub", seckeySshPem, certBytes); err != nil {
return fmt.Errorf("failed to write SSH keys to filesystem: %w", err)
}
} else {
// If keyPath isn't set then write it to the default location
if err := l.writeKeysToSSHDir(seckeySshPem, certBytes); err != nil {
return fmt.Errorf("failed to write SSH keys to filesystem: %w", err)
}
}
comPkt, err := refreshedPkt.Compact()
if err != nil {
return err
}
payloadB64 := payloadFromCompactPkt(comPkt)
payload, err := base64.RawURLEncoding.DecodeString(string(payloadB64))
if err != nil {
return fmt.Errorf("refreshed ID token payload is not base64 encoded: %w", err)
}
if err = json.Unmarshal(payload, &claims); err != nil {
return fmt.Errorf("malformed refreshed ID token payload: %w", err)
}
}
}
}
func (l *LoginCmd) out() io.Writer {
if l.OutWriter != nil {
return l.OutWriter
}
return os.Stdout
}
func createSSHCert(pkt *pktoken.PKToken, signer crypto.Signer, principals []string) ([]byte, []byte, error) {
return createSSHCertWithAccessToken(pkt, nil, signer, principals)
}
func createSSHCertWithAccessToken(pkt *pktoken.PKToken, accessToken []byte, signer crypto.Signer, principals []string) ([]byte, []byte, error) {
cert, err := sshcert.New(pkt, accessToken, principals)
if err != nil {
return nil, nil, err
}
sshSigner, err := ssh.NewSignerFromSigner(signer)
if err != nil {
return nil, nil, err
}
var keyAlgos []string
switch signer.(type) {
case *ecdsa.PrivateKey:
keyAlgos = []string{ssh.KeyAlgoECDSA256}
case ed25519.PrivateKey:
keyAlgos = []string{ssh.KeyAlgoED25519}
default:
return nil, nil, fmt.Errorf("unsupported key type: %T", signer)
}
signerMas, err := ssh.NewSignerWithAlgorithms(sshSigner.(ssh.AlgorithmSigner), keyAlgos)
if err != nil {
return nil, nil, err
}
sshCert, err := cert.SignCert(signerMas)
if err != nil {
return nil, nil, err
}
certBytes := ssh.MarshalAuthorizedKey(sshCert)
// Remove newline character that MarshalAuthorizedKey() adds
certBytes = certBytes[:len(certBytes)-1]
seckeySsh, err := ssh.MarshalPrivateKey(signer, "openpubkey cert")
if err != nil {
return nil, nil, err
}
seckeySshBytes := pem.EncodeToMemory(seckeySsh)
return certBytes, seckeySshBytes, nil
}
func (l *LoginCmd) writeKeysToOpkSSHDir(secKeyPem []byte, certBytes []byte) error {
const (
opkSshPath = ".ssh/opkssh"
configFileName = "config"
)
userhomeDir, err := os.UserHomeDir()
if err != nil {
return err
}
opkSshUserPath := filepath.Join(userhomeDir, opkSshPath)
opkSshConfigPath := filepath.Join(opkSshUserPath, configFileName)
sshKeyName := l.makeSSHKeyFileName(l.pkt)
privKeyPath := filepath.Join(opkSshUserPath, sshKeyName)
pubKeyPath := filepath.Join(privKeyPath + "-cert.pub")
// get key comment
issuer, err := l.pkt.Issuer()
if err != nil {
issuer = "unknown"
}
audience, err := l.pkt.Audience()
if err != nil {
audience = "unknown"
}
comment := " openpubkey: " + issuer + " " + audience
// add key to config
afs := &afero.Afero{Fs: l.Fs}
configContent, err := afs.ReadFile(opkSshConfigPath)
if err != nil {
return fmt.Errorf("failed to read opk ssh config file (%s): %w", opkSshConfigPath, err)
}
if !strings.Contains(string(configContent), privKeyPath) {
configContent = slices.Concat(
[]byte("IdentityFile "+privKeyPath+"\n"),
configContent,
)
}
err = afs.WriteFile(opkSshConfigPath, configContent, 0600)
if err != nil {
return fmt.Errorf("failed to write opk ssh config file (%s): %w", opkSshConfigPath, err)
}
// write ssh key files
return l.writeKeysComment(privKeyPath, pubKeyPath, secKeyPem, certBytes, comment)
}
func (l *LoginCmd) writeKeysToSSHDir(seckeySshPem []byte, certBytes []byte) error {
homePath, err := os.UserHomeDir()
if err != nil {
return err
}
sshPath := filepath.Join(homePath, ".ssh")
// Make ~/.ssh if folder does not exist
err = l.Fs.MkdirAll(sshPath, os.ModePerm)
if err != nil {
return err
}
// For ssh to automatically find the key created by openpubkey when
// connecting, we use one of the default ssh key paths. However, the file
// might contain an existing key. We will overwrite the key if it was
// generated by openpubkey which we check by looking at the associated
// comment. If the comment is equal to "openpubkey", we overwrite the file
// with a new key.
keyFileNames, ok := DefaultSSHKeyFileNames[l.KeyTypeArg]
if !ok {
return fmt.Errorf("key type (%s) has no default output file name; use -i <filePath>", l.KeyTypeArg.String())
}
for _, keyFilename := range keyFileNames {
seckeyPath := filepath.Join(sshPath, keyFilename)
pubkeyPath := seckeyPath + "-cert.pub"
if !l.fileExists(seckeyPath) {
// If ssh key file does not currently exist, we don't have to worry about overwriting it
return l.writeKeys(seckeyPath, pubkeyPath, seckeySshPem, certBytes)
} else if !l.fileExists(pubkeyPath) {
continue
} else {
// If the ssh key file does exist, check if it was generated by openpubkey, if it was then it is safe to overwrite
afs := &afero.Afero{Fs: l.Fs}
sshPubkey, err := afs.ReadFile(pubkeyPath)
if err != nil {
log.Println("Failed to read:", pubkeyPath)
continue
}
_, comment, _, _, err := ssh.ParseAuthorizedKey(sshPubkey)
if err != nil {
log.Println("Failed to parse:", pubkeyPath)
continue
}
// If the key comment is "openpubkey" then we generated it
if comment == "openpubkey" {
return l.writeKeys(seckeyPath, pubkeyPath, seckeySshPem, certBytes)
}
}
}
return fmt.Errorf("no default ssh key file free for openpubkey")
}
func (l *LoginCmd) writeKeys(seckeyPath string, pubkeyPath string, seckeySshPem []byte, certBytes []byte) error {
// Write ssh secret key to filesystem
afs := &afero.Afero{Fs: l.Fs}
if err := afs.WriteFile(seckeyPath, seckeySshPem, 0o600); err != nil {
return err
}
fmt.Printf("Writing opk ssh public key to %s and corresponding secret key to %s\n", pubkeyPath, seckeyPath)
certBytes = append(certBytes, []byte(" openpubkey")...)
// Write ssh public key (certificate) to filesystem
return afs.WriteFile(pubkeyPath, certBytes, 0o644)
}
func (l *LoginCmd) writeKeysComment(seckeyPath string, pubkeyPath string, seckeySshPem []byte, certBytes []byte, pubKeyComment string) error {
// Write ssh secret key to filesystem
afs := &afero.Afero{Fs: l.Fs}
if err := afs.WriteFile(seckeyPath, seckeySshPem, 0o600); err != nil {
return err
}
fmt.Printf("Writing opk ssh public key to %s and corresponding secret key to %s\n", pubkeyPath, seckeyPath)
certBytes = append(certBytes, ' ')
certBytes = append(certBytes, pubKeyComment...)
// Write ssh public key (certificate) to filesystem
return afs.WriteFile(pubkeyPath, certBytes, 0o644)
}
func (l *LoginCmd) makeSSHKeyFileName(pkt *pktoken.PKToken) string {
regex := regexp.MustCompile(`[^a-zA-Z0-9_\-.]+`)
issuer, err := pkt.Issuer()
if err != nil {
issuer = "unknown"
}
issuer, _ = strings.CutPrefix(issuer, "https://")
audience, err := pkt.Audience()
if err != nil {
audience = "unknown"
}
// shorten clientID if it is too long
if len(audience) > 20 {
audience = audience[:20]
}
keyName := issuer + "-" + audience
keyName = regex.ReplaceAllString(keyName, "_")
return keyName
}
func (l *LoginCmd) fileExists(fPath string) bool {
_, err := l.Fs.Open(fPath)
return !errors.Is(err, os.ErrNotExist)
}
// IdentityString returns a string representation of the identity from the PK Token.
// e.g "Email, sub, issuer, audience"
func IdentityString(pkt pktoken.PKToken) (string, error) {
idt, err := oidc.NewJwt(pkt.OpToken)
if err != nil {
return "", err
}
claims := idt.GetClaims()
if claims.Email == "" {
return fmt.Sprintf(`WARNING: Email claim is missing from ID token. Policies based on email will not work.
Check if your client config (~/.opk/config.yml) has the correct scopes configured for this OpenID Provider.
Sub, issuer, audience:
%s %s %s`, claims.Subject, claims.Issuer, claims.Audience), nil
} else {
return fmt.Sprintf("Email, sub, issuer, audience: \n%s %s %s %s", claims.Email, claims.Subject, claims.Issuer, claims.Audience), nil
}
}
// PrettyIdToken returns a pretty-printed JSON representation of the ID Token claims.
func PrettyIdToken(pkt pktoken.PKToken) (string, error) {
idt, err := oidc.NewJwt(pkt.OpToken)
if err != nil {
return "", err
}
idtJson, err := json.MarshalIndent(idt.GetClaims(), "", " ")
if err != nil {
return "", err
}
return string(idtJson[:]), nil
}
func isGitHubEnvironment() bool {
return os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") != "" &&
os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") != ""
}
// payloadFromCompactPkt extracts the payload from a compact PK Token which
// is always the second part of the '.' separated string.
func payloadFromCompactPkt(compactPkt []byte) []byte {
parts := bytes.Split(compactPkt, []byte("."))
return parts[1]
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/spf13/afero"
"golang.org/x/crypto/ssh"
)
// LogoutCmd represents the logout command that removes opkssh-generated SSH keys and certificates.
type LogoutCmd struct {
Fs afero.Fs
KeyPathArg string // Optional: specific key path to remove
Verbosity int // Default verbosity is 0, 1 is verbose
OutWriter io.Writer
ErrWriter io.Writer
}
// NewLogoutCmd creates a new LogoutCmd instance.
func NewLogoutCmd(keyPathArg string) *LogoutCmd {
return &LogoutCmd{
Fs: afero.NewOsFs(),
KeyPathArg: keyPathArg,
}
}
func (l *LogoutCmd) out() io.Writer {
if l.OutWriter != nil {
return l.OutWriter
}
return os.Stdout
}
func (l *LogoutCmd) errOut() io.Writer {
if l.ErrWriter != nil {
return l.ErrWriter
}
return os.Stderr
}
// Run executes the logout command, removing opkssh-generated SSH keys.
func (l *LogoutCmd) Run() error {
if l.KeyPathArg != "" {
return l.removeSpecificKey(l.KeyPathArg)
}
removedCount := 0
// Remove keys from default SSH directory (~/.ssh/)
n, err := l.removeDefaultSSHDirKeys()
if err != nil {
return err
}
removedCount += n
// Remove keys from opkssh directory (~/.ssh/opkssh/)
n, err = l.removeOpkSSHDirKeys()
if err != nil {
return err
}
removedCount += n
if removedCount == 0 {
fmt.Fprintln(l.out(), "No opkssh keys found to remove")
} else {
fmt.Fprintf(l.out(), "Successfully removed %d opkssh key pair(s)\n", removedCount)
}
return nil
}
// removeSpecificKey removes a specific key pair given the private key path.
func (l *LogoutCmd) removeSpecificKey(seckeyPath string) error {
pubkeyPath := seckeyPath + "-cert.pub"
afs := &afero.Afero{Fs: l.Fs}
// Check if the cert file exists and was generated by openpubkey
pubKeyBytes, err := afs.ReadFile(pubkeyPath)
if err != nil {
return fmt.Errorf("could not read certificate file %s: %w", pubkeyPath, err)
}
if !isOpenpubkeyComment(pubKeyBytes) {
return fmt.Errorf("key at %s was not generated by opkssh", seckeyPath)
}
// Verify that the certificate matches the secret key before deleting
secKeyBytes, err := afs.ReadFile(seckeyPath)
if err != nil {
return fmt.Errorf("could not read secret key file %s: %w", seckeyPath, err)
}
if err := verifyKeyPairMatch(secKeyBytes, pubKeyBytes); err != nil {
return fmt.Errorf("key pair mismatch for %s: %w", seckeyPath, err)
}
if err := l.removeKeyPair(seckeyPath, pubkeyPath); err != nil {
return err
}
fmt.Fprintf(l.out(), "Successfully removed opkssh key pair: %s\n", seckeyPath)
return nil
}
// allDefaultSSHKeyFileNames returns all key file names from DefaultSSHKeyFileNames.
func allDefaultSSHKeyFileNames() []string {
var all []string
for _, names := range DefaultSSHKeyFileNames {
all = append(all, names...)
}
return all
}
// removeDefaultSSHDirKeys finds and removes opkssh-generated keys from ~/.ssh/.
func (l *LogoutCmd) removeDefaultSSHDirKeys() (int, error) {
homePath, err := os.UserHomeDir()
if err != nil {
return 0, fmt.Errorf("failed to get home directory: %w", err)
}
sshPath := filepath.Join(homePath, ".ssh")
removedCount := 0
afs := &afero.Afero{Fs: l.Fs}
for _, keyFilename := range allDefaultSSHKeyFileNames() {
seckeyPath := filepath.Join(sshPath, keyFilename)
pubkeyPath := seckeyPath + "-cert.pub"
// Check if the cert file exists
pubKeyBytes, err := afs.ReadFile(pubkeyPath)
if err != nil {
if l.Verbosity >= 1 {
fmt.Fprintf(l.errOut(), "Skipping %s: could not read certificate file\n", pubkeyPath)
}
continue // File doesn't exist or can't be read, skip
}
if !isOpenpubkeyComment(pubKeyBytes) {
if l.Verbosity >= 1 {
fmt.Fprintf(l.errOut(), "Skipping %s: not generated by opkssh\n", seckeyPath)
}
continue // Not generated by openpubkey, skip
}
// Verify that the certificate matches the secret key before deleting
secKeyBytes, err := afs.ReadFile(seckeyPath)
if err != nil {
fmt.Fprintf(l.errOut(), "Skipping %s: could not read secret key file\n", seckeyPath)
continue
}
if err := verifyKeyPairMatch(secKeyBytes, pubKeyBytes); err != nil {
fmt.Fprintf(l.errOut(), "Skipping %s: certificate does not match secret key\n", seckeyPath)
continue
}
if err := l.removeKeyPair(seckeyPath, pubkeyPath); err != nil {
return removedCount, err
}
fmt.Fprintf(l.out(), "Removed %s and %s\n", seckeyPath, pubkeyPath)
removedCount++
}
return removedCount, nil
}
// removeOpkSSHDirKeys finds and removes opkssh-generated keys from ~/.ssh/opkssh/.
func (l *LogoutCmd) removeOpkSSHDirKeys() (int, error) {
homePath, err := os.UserHomeDir()
if err != nil {
return 0, fmt.Errorf("failed to get home directory: %w", err)
}
opkSSHDir := filepath.Join(homePath, ".ssh", "opkssh")
afs := &afero.Afero{Fs: l.Fs}
exists, err := afs.DirExists(opkSSHDir)
if err != nil || !exists {
return 0, nil
}
entries, err := afs.ReadDir(opkSSHDir)
if err != nil {
return 0, nil
}
removedCount := 0
configPath := filepath.Join(opkSSHDir, "config")
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip config file and cert files (we handle them with their private key)
if name == "config" || strings.HasSuffix(name, "-cert.pub") {
continue
}
seckeyPath := filepath.Join(opkSSHDir, name)
pubkeyPath := seckeyPath + "-cert.pub"
// Check if the cert file exists
pubKeyBytes, err := afs.ReadFile(pubkeyPath)
if err != nil {
if l.Verbosity >= 1 {
fmt.Fprintf(l.errOut(), "Skipping %s: could not read certificate file\n", pubkeyPath)
}
continue
}
if !isOpenpubkeyComment(pubKeyBytes) {
if l.Verbosity >= 1 {
fmt.Fprintf(l.errOut(), "Skipping %s: not generated by opkssh\n", seckeyPath)
}
continue
}
// Verify that the certificate matches the secret key before deleting
secKeyBytes, err := afs.ReadFile(seckeyPath)
if err != nil {
fmt.Fprintf(l.errOut(), "Skipping %s: could not read secret key file\n", seckeyPath)
continue
}
if err := verifyKeyPairMatch(secKeyBytes, pubKeyBytes); err != nil {
fmt.Fprintf(l.errOut(), "Skipping %s: certificate does not match secret key\n", seckeyPath)
continue
}
if err := l.removeKeyPair(seckeyPath, pubkeyPath); err != nil {
return removedCount, err
}
// Remove the IdentityFile entry from the opkssh config
if err := l.removeFromOpkSSHConfig(configPath, seckeyPath); err != nil {
return removedCount, fmt.Errorf("failed to update opkssh config: %w", err)
}
fmt.Fprintf(l.out(), "Removed %s and %s\n", seckeyPath, pubkeyPath)
removedCount++
}
return removedCount, nil
}
// removeKeyPair removes both the private key and certificate files.
// The secret key is removed first so that if an error occurs removing the
// certificate, the secret key will not be left orphaned on disk.
func (l *LogoutCmd) removeKeyPair(seckeyPath string, pubkeyPath string) error {
// Remove secret key first to avoid leaving it orphaned if cert removal fails
if err := l.Fs.Remove(seckeyPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove key %s: %w", seckeyPath, err)
}
// Remove certificate file
if err := l.Fs.Remove(pubkeyPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove certificate %s: %w", pubkeyPath, err)
}
return nil
}
// removeFromOpkSSHConfig removes the IdentityFile line for the given key from the opkssh config file.
func (l *LogoutCmd) removeFromOpkSSHConfig(configPath string, seckeyPath string) error {
afs := &afero.Afero{Fs: l.Fs}
content, err := afs.ReadFile(configPath)
if err != nil {
return nil // Config file doesn't exist, nothing to clean up
}
identityLine := "IdentityFile " + seckeyPath
// Split handling both \r\n (Windows) and \n (Unix) line endings
normalized := strings.ReplaceAll(string(content), "\r\n", "\n")
lines := strings.Split(normalized, "\n")
var newLines []string
for _, line := range lines {
if strings.TrimSpace(line) != identityLine {
newLines = append(newLines, line)
}
}
newContent := strings.Join(newLines, "\n")
return afs.WriteFile(configPath, []byte(newContent), 0o600)
}
// verifyKeyPairMatch checks that the public key in the certificate corresponds
// to the given secret key. This prevents accidentally deleting a secret key
// when someone has overwritten the public key file with a different cert.
func verifyKeyPairMatch(secKeyBytes []byte, pubKeyBytes []byte) error {
secKey, err := ssh.ParsePrivateKey(secKeyBytes)
if err != nil {
return fmt.Errorf("failed to parse secret key: %w", err)
}
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes)
if err != nil {
return fmt.Errorf("failed to parse certificate: %w", err)
}
// If the pubKey is a certificate, extract the underlying key
cert, ok := pubKey.(*ssh.Certificate)
if !ok {
return fmt.Errorf("public key file does not contain a certificate")
}
// Compare the public key from the certificate with the public key derived from the secret key
secPubKey := secKey.PublicKey()
certPubKey := cert.Key
if secPubKey.Type() != certPubKey.Type() {
return fmt.Errorf("key type mismatch: secret key is %s, certificate key is %s", secPubKey.Type(), certPubKey.Type())
}
if string(secPubKey.Marshal()) != string(certPubKey.Marshal()) {
return fmt.Errorf("public key in certificate does not match secret key")
}
return nil
}
// isOpenpubkeyComment checks if an SSH public key file has an openpubkey-generated comment.
func isOpenpubkeyComment(pubKeyBytes []byte) bool {
_, comment, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes)
if err != nil {
return false
}
return strings.HasPrefix(comment, "openpubkey")
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"io/fs"
"github.com/openpubkey/opkssh/policy/files"
)
// PermCheckResult contains the results of checking permissions on a single file
// or directory. It is returned by CheckFilePermissions and consumed by both the
// audit and permissions commands.
type PermCheckResult struct {
// Path is the filesystem path that was checked.
Path string
// Exists is true if the file/directory was found on disk.
Exists bool
// PermsErr is non-empty when the mode or ownership check failed.
PermsErr string
// ACLReport contains detailed ACL information (owner, ACEs, problems).
// It is nil when no ACLVerifier was provided or the path does not exist.
ACLReport *files.ACLReport
// ACLErr is non-nil when VerifyACL itself returned an error.
ACLErr error
}
// CheckFilePermissions checks the existence, permission mode/ownership, and ACLs
// of the file at path. It centralises the permission-checking logic shared by
// the audit and permissions commands so that both report consistent results.
func CheckFilePermissions(
fsys files.FileSystem,
path string,
permInfo files.PermInfo,
) PermCheckResult {
result := PermCheckResult{Path: path}
// Check existence
exists, err := fsys.Exists(path)
if err != nil {
result.Exists = false
result.PermsErr = err.Error()
return result
}
if !exists {
result.Exists = false
return result
}
result.Exists = true
// Check file mode and ownership
if err := fsys.CheckPerm(path, []fs.FileMode{permInfo.Mode}, permInfo.Owner, permInfo.Group); err != nil {
result.PermsErr = err.Error()
}
// Check ACLs
report, err := fsys.VerifyACL(path, files.ExpectedACLFromPerm(permInfo))
result.ACLReport = &report
result.ACLErr = err
return result
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/openpubkey/opkssh/policy"
"github.com/openpubkey/opkssh/policy/files"
"github.com/openpubkey/opkssh/policy/plugins"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
// defaultConfirmPrompt reads a yes/no answer from stdin.
func defaultConfirmPrompt(prompt string, in io.Reader) (bool, error) {
fmt.Print(prompt)
r := bufio.NewReader(in)
s, err := r.ReadString('\n')
if err != nil {
return false, err
}
s = strings.TrimSpace(strings.ToLower(s))
return s == "y" || s == "yes", nil
}
// PermissionsCmd provides functionality to check and fix file permissions
type PermissionsCmd struct {
FileSystem files.FileSystem
Out io.Writer
ErrOut io.Writer
In io.Reader
IsElevatedFn func() (bool, error)
ConfirmPrompt func(string, io.Reader) (bool, error)
// Flags
DryRun bool
Yes bool
Verbose bool
JsonOutput bool
}
// NewPermissionsCmd creates a new PermissionsCmd with default settings
func NewPermissionsCmd(out io.Writer, errOut io.Writer) *PermissionsCmd {
return &PermissionsCmd{
FileSystem: files.NewFileSystem(afero.NewOsFs()),
Out: out,
ErrOut: errOut,
In: os.Stdin,
IsElevatedFn: IsElevated,
ConfirmPrompt: defaultConfirmPrompt,
}
}
// CobraCommand returns the cobra command tree for the permissions command.
func (p *PermissionsCmd) CobraCommand() *cobra.Command {
permissionsCmd := &cobra.Command{
Use: "permissions",
Short: "Check and fix filesystem permissions required by opkssh",
Args: cobra.NoArgs,
}
checkCmd := &cobra.Command{
Use: "check",
Short: "Verify permissions and ownership for opkssh files",
RunE: func(cmd *cobra.Command, args []string) error {
return p.Check()
},
}
checkCmd.Flags().BoolVarP(&p.JsonOutput, "json", "j", false, "Output results in JSON")
fixCmd := &cobra.Command{
Use: "fix",
Short: "Fix permissions and ownership for opkssh files (requires admin)",
RunE: func(cmd *cobra.Command, args []string) error {
return p.Fix()
},
}
fixCmd.Flags().BoolVar(&p.DryRun, "dry-run", false, "Don't modify anything; show planned changes")
fixCmd.Flags().BoolVarP(&p.Yes, "yes", "y", false, "Apply changes without confirmation")
fixCmd.Flags().BoolVarP(&p.Verbose, "verbose", "v", false, "Verbose output")
fixCmd.Flags().BoolVarP(&p.JsonOutput, "json", "j", false, "Output results in JSON")
installCmd := &cobra.Command{
Use: "install",
Short: "Idempotent installer-friendly permissions fix (non-interactive)",
RunE: func(cmd *cobra.Command, args []string) error {
// installers expect non-interactive behavior; force yes=true
p.Yes = true
return p.Fix()
},
}
installCmd.Flags().BoolVar(&p.DryRun, "dry-run", false, "Don't modify anything; show planned changes")
installCmd.Flags().BoolVarP(&p.Verbose, "verbose", "v", false, "Verbose output")
permissionsCmd.AddCommand(checkCmd)
permissionsCmd.AddCommand(fixCmd)
permissionsCmd.AddCommand(installCmd)
return permissionsCmd
}
// checkResult is the JSON-serializable result of a permissions check.
type checkResult struct {
Path string `json:"path"`
Exists bool `json:"exists"`
PermsErr string `json:"permsErr,omitempty"`
ACLErr string `json:"aclErr,omitempty"`
}
// Check verifies permissions and ownership for opkssh files.
func (p *PermissionsCmd) Check() error {
var problems []string
var results []checkResult
// checkACLResult is a helper that processes ACL findings from
// CheckFilePermissions and appends them to problems/checkResult.
checkACLResult := func(path string, result PermCheckResult, cr *checkResult) {
if result.ACLErr != nil {
problems = append(problems, fmt.Sprintf("%s: acl verify error: %v", path, result.ACLErr))
cr.ACLErr = result.ACLErr.Error()
} else if result.ACLReport != nil {
report := result.ACLReport
if report.OwnerSIDStr != "" {
fmt.Fprintf(p.Out, "%s: owner=%s ownerSID=%s mode=%o\n", path, report.Owner, report.OwnerSIDStr, report.Mode)
} else {
fmt.Fprintf(p.Out, "%s: owner=%s mode=%o\n", path, report.Owner, report.Mode)
}
if len(report.ACEs) > 0 {
fmt.Fprintln(p.Out, " ACEs:")
for _, a := range report.ACEs {
if a.PrincipalSIDStr != "" {
fmt.Fprintf(p.Out, " - %s [%s]: %s (%s) inherited=%v\n", a.Principal, a.PrincipalSIDStr, a.Type, a.Rights, a.Inherited)
} else {
fmt.Fprintf(p.Out, " - %s: %s (%s) inherited=%v\n", a.Principal, a.Type, a.Rights, a.Inherited)
}
}
}
for _, prob := range report.Problems {
fmt.Fprintln(p.Out, " ACL problem:", prob)
}
}
}
// System policy file — use shared permission check
sp := files.RequiredPerms.SystemPolicy
systemPolicy := policy.SystemDefaultPolicyPath
sysResult := CheckFilePermissions(p.FileSystem, systemPolicy, sp)
if !sysResult.Exists {
problems = append(problems, fmt.Sprintf("%s: file does not exist", systemPolicy))
results = append(results, checkResult{Path: systemPolicy, Exists: false})
} else {
cr := checkResult{Path: systemPolicy, Exists: true, PermsErr: sysResult.PermsErr}
if sysResult.PermsErr != "" {
problems = append(problems, fmt.Sprintf("%s: %s", systemPolicy, sysResult.PermsErr))
}
checkACLResult(systemPolicy, sysResult, &cr)
results = append(results, cr)
}
// Providers file
providersFile := policy.SystemDefaultProvidersPath
pp := files.RequiredPerms.Providers
provResult := CheckFilePermissions(p.FileSystem, providersFile, pp)
if !provResult.Exists {
// not fatal, but report
problems = append(problems, fmt.Sprintf("%s: file does not exist", providersFile))
results = append(results, checkResult{Path: providersFile, Exists: false})
} else {
cr := checkResult{Path: providersFile, Exists: true, PermsErr: provResult.PermsErr}
if provResult.PermsErr != "" {
problems = append(problems, fmt.Sprintf("%s: %s", providersFile, provResult.PermsErr))
}
checkACLResult(providersFile, provResult, &cr)
results = append(results, cr)
}
// Config file
configFile := filepath.Join(policy.GetSystemConfigBasePath(), "config.yml")
cp := files.RequiredPerms.Config
cfgResult := CheckFilePermissions(p.FileSystem, configFile, cp)
if cfgResult.Exists {
cr := checkResult{Path: configFile, Exists: true, PermsErr: cfgResult.PermsErr}
if cfgResult.PermsErr != "" {
problems = append(problems, fmt.Sprintf("%s: %s", configFile, cfgResult.PermsErr))
}
checkACLResult(configFile, cfgResult, &cr)
results = append(results, cr)
}
// Policy plugins dir
pluginsDir := filepath.Join(policy.GetSystemConfigBasePath(), "policy.d")
if _, err := p.FileSystem.Stat(pluginsDir); err != nil {
problems = append(problems, fmt.Sprintf("%s: %v", pluginsDir, err))
results = append(results, checkResult{Path: pluginsDir, Exists: false, PermsErr: err.Error()})
} else {
cr := checkResult{Path: pluginsDir, Exists: true}
// Check directory perms using plugin package expectations
if err := p.FileSystem.CheckPerm(pluginsDir, plugins.RequiredPolicyDirPerms(), files.RequiredPerms.PluginsDir.Owner, files.RequiredPerms.PluginsDir.Group); err != nil {
problems = append(problems, fmt.Sprintf("%s: %v", pluginsDir, err))
cr.PermsErr = err.Error()
}
results = append(results, cr)
}
if p.JsonOutput {
enc := json.NewEncoder(p.Out)
enc.SetIndent("", " ")
return enc.Encode(results)
}
if len(problems) > 0 {
for _, prob := range problems {
fmt.Fprintln(p.Out, "Problem:", prob)
}
return fmt.Errorf("permissions check failed: %d problems found", len(problems))
}
// Success: print nothing and return nil
return nil
}
// fixResult is the JSON-serializable result of a permissions fix.
type fixResult struct {
Planned []string `json:"planned"`
Errors []string `json:"errors,omitempty"`
DryRun bool `json:"dryRun"`
}
// Fix attempts to repair permissions/ownership for key paths.
func (p *PermissionsCmd) Fix() error {
// Planning phase: determine actions without performing them
var planned []string
sp := files.RequiredPerms.SystemPolicy
pv := files.RequiredPerms.Providers
pld := files.RequiredPerms.PluginsDir
pf := files.RequiredPerms.PluginFile
systemPolicy := policy.SystemDefaultPolicyPath
if _, err := p.FileSystem.Stat(systemPolicy); err != nil {
planned = append(planned, "create file: "+systemPolicy)
}
planned = append(planned, "chmod "+systemPolicy+" to "+sp.Mode.String())
plannedOwner := sp.Owner
if sp.Group != "" {
plannedOwner += ":" + sp.Group
}
planned = append(planned, "chown "+systemPolicy+" to "+plannedOwner)
providersFile := policy.SystemDefaultProvidersPath
if _, err := p.FileSystem.Stat(providersFile); err == nil {
planned = append(planned, "chmod "+providersFile+" to "+pv.Mode.String())
pvOwner := pv.Owner
if pv.Group != "" {
pvOwner += ":" + pv.Group
}
planned = append(planned, "chown "+providersFile+" to "+pvOwner)
}
configFile := filepath.Join(policy.GetSystemConfigBasePath(), "config.yml")
cp := files.RequiredPerms.Config
if _, err := p.FileSystem.Stat(configFile); err == nil {
planned = append(planned, "chmod "+configFile+" to "+cp.Mode.String())
cpOwner := cp.Owner
if cp.Group != "" {
cpOwner += ":" + cp.Group
}
planned = append(planned, "chown "+configFile+" to "+cpOwner)
}
pluginsDir := filepath.Join(policy.GetSystemConfigBasePath(), "policy.d")
if _, err := p.FileSystem.Stat(pluginsDir); err != nil {
planned = append(planned, "mkdir "+pluginsDir)
}
// include plugin files if present
if fi, err := p.FileSystem.Open(pluginsDir); err == nil {
entries, _ := fi.Readdir(-1)
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".yml") {
planned = append(planned, fmt.Sprintf("chmod %s to %04o", filepath.Join(pluginsDir, e.Name()), pf.Mode))
planned = append(planned, "chown "+filepath.Join(pluginsDir, e.Name())+" to "+pf.Owner)
}
}
fi.Close()
}
// If dry-run, just print planned actions
if p.DryRun {
if p.JsonOutput {
enc := json.NewEncoder(p.Out)
enc.SetIndent("", " ")
return enc.Encode(fixResult{Planned: planned, DryRun: true})
}
for _, a := range planned {
fmt.Fprintln(p.Out, "Action:", a)
}
fmt.Fprintln(p.Out, "dry-run complete")
return nil
}
// Require elevated privileges to perform fixes
elevated, err := p.IsElevatedFn()
if err != nil {
return fmt.Errorf("failed to determine elevation: %w", err)
}
if !elevated {
return fmt.Errorf("fix requires elevated privileges (run as root or Administrator)")
}
// Confirm with user unless --yes
if !p.Yes {
// show planned actions and ask
fmt.Fprintln(p.Out, "Planned actions:")
for _, a := range planned {
fmt.Fprintln(p.Out, " -", a)
}
ok, err := p.ConfirmPrompt("Apply these changes? [y/N]: ", p.In)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("aborted by user")
}
}
// Execution phase: perform actions
var errorsFound []string
// Create system policy file if missing
if _, err := p.FileSystem.Stat(systemPolicy); err != nil {
if f, err := p.FileSystem.CreateFile(systemPolicy); err != nil {
errorsFound = append(errorsFound, "create "+systemPolicy+": "+err.Error())
} else {
f.Close()
}
}
if err := p.FileSystem.Chmod(systemPolicy, sp.Mode); err != nil {
errorsFound = append(errorsFound, "chmod "+systemPolicy+": "+err.Error())
}
if err := p.FileSystem.Chown(systemPolicy, sp.Owner, sp.Group); err != nil {
errorsFound = append(errorsFound, "chown "+systemPolicy+": "+err.Error())
}
// Verify ACLs after changes and apply ACE fixes on Windows if needed
if runtime.GOOS == "windows" {
expected := files.ExpectedACLFromPerm(sp)
report, err := p.FileSystem.VerifyACL(systemPolicy, expected)
if err != nil {
errorsFound = append(errorsFound, "acl verify: "+err.Error())
} else {
for _, reqACE := range expected.RequiredACEs {
found := false
for _, a := range report.ACEs {
if a.Principal == reqACE.Principal && strings.Contains(a.Rights, reqACE.Rights) {
found = true
break
}
}
if !found {
ace := files.ACE{Principal: reqACE.Principal, Rights: reqACE.Rights, Type: reqACE.Type}
if sid, _, _ := files.ResolveAccountToSID(reqACE.Principal); len(sid) > 0 {
ace.PrincipalSID = sid
}
if err := p.FileSystem.ApplyACE(systemPolicy, ace); err != nil {
errorsFound = append(errorsFound, fmt.Sprintf("apply ACE %s:%s: %s", reqACE.Principal, reqACE.Rights, err.Error()))
}
}
}
}
}
// Providers file
if _, err := p.FileSystem.Stat(providersFile); err == nil {
if err := p.FileSystem.Chmod(providersFile, pv.Mode); err != nil {
errorsFound = append(errorsFound, "chmod "+providersFile+": "+err.Error())
}
if err := p.FileSystem.Chown(providersFile, pv.Owner, pv.Group); err != nil {
errorsFound = append(errorsFound, "chown "+providersFile+": "+err.Error())
}
}
// Config file
if _, err := p.FileSystem.Stat(configFile); err == nil {
if err := p.FileSystem.Chmod(configFile, cp.Mode); err != nil {
errorsFound = append(errorsFound, "chmod "+configFile+": "+err.Error())
}
if err := p.FileSystem.Chown(configFile, cp.Owner, cp.Group); err != nil {
errorsFound = append(errorsFound, "chown "+configFile+": "+err.Error())
}
}
// Plugins dir
if _, err := p.FileSystem.Stat(pluginsDir); err != nil {
if err := p.FileSystem.MkdirAll(pluginsDir, pld.Mode); err != nil {
errorsFound = append(errorsFound, "mkdir "+pluginsDir+": "+err.Error())
}
}
if fi, err := p.FileSystem.Open(pluginsDir); err == nil {
entries, _ := fi.Readdir(-1)
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".yml") {
path := filepath.Join(pluginsDir, e.Name())
if err := p.FileSystem.Chmod(path, pf.Mode); err != nil {
errorsFound = append(errorsFound, "chmod "+path+": "+err.Error())
}
if err := p.FileSystem.Chown(path, pf.Owner, pf.Group); err != nil {
errorsFound = append(errorsFound, "chown "+path+": "+err.Error())
}
// On Windows, ensure ACLs for plugin files as well
if runtime.GOOS == "windows" {
pfExpected := files.ExpectedACLFromPerm(pf)
if report, err := p.FileSystem.VerifyACL(path, pfExpected); err == nil {
for _, reqACE := range pfExpected.RequiredACEs {
found := false
for _, a := range report.ACEs {
if a.Principal == reqACE.Principal && strings.Contains(a.Rights, reqACE.Rights) {
found = true
break
}
}
if !found {
ace := files.ACE{Principal: reqACE.Principal, Rights: reqACE.Rights, Type: reqACE.Type}
if sid, _, _ := files.ResolveAccountToSID(reqACE.Principal); len(sid) > 0 {
ace.PrincipalSID = sid
}
if err := p.FileSystem.ApplyACE(path, ace); err != nil {
errorsFound = append(errorsFound, fmt.Sprintf("apply ACE %s:%s for %s: %s", reqACE.Principal, reqACE.Rights, path, err.Error()))
}
}
}
} else {
errorsFound = append(errorsFound, "acl verify for "+path+": "+err.Error())
}
}
}
}
fi.Close()
}
if p.JsonOutput {
enc := json.NewEncoder(p.Out)
enc.SetIndent("", " ")
return enc.Encode(fixResult{Planned: planned, Errors: errorsFound})
}
if len(errorsFound) > 0 {
for _, e := range errorsFound {
fmt.Fprintln(p.Out, "Error:", e)
}
return fmt.Errorf("fix completed with %d errors", len(errorsFound))
}
fmt.Fprintln(p.Out, "fix completed successfully")
return nil
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
//go:build linux || darwin
package commands
import (
"errors"
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"regexp"
"strconv"
"syscall"
"github.com/openpubkey/opkssh/policy/files"
)
// ReadHome is used to read the home policy file for the user with
// the specified username. This is used when opkssh is called by
// AuthorizedKeysCommand as the opksshuser and needs to use sudoer
// access to read the home policy file (`/home/<username>/opk/auth_id`).
// This function is only available on Linux and Darwin because it relies on
// syscall.Stat_t to determine the owner of the file.
func ReadHome(username string) ([]byte, error) {
if matched, _ := regexp.MatchString("^[a-z0-9_\\-.]+$", username); !matched {
return nil, fmt.Errorf("%s is not a valid linux username", username)
}
userObj, err := user.Lookup(username)
if err != nil {
return nil, fmt.Errorf("failed to find user %s", username)
}
homePolicyPath := filepath.Join(userObj.HomeDir, ".opk", "auth_id")
// Security critical: We reading this file as `sudo -u opksshuser`
// and opksshuser has elevated permissions to read any file whose
// path matches `/home/*/opk/auth_id`. We need to be cautious we do follow
// a symlink as it could be to a file the user is not permitted to read.
// This would not permit the user to read the file, but they might be able
// to determine the existence of the file. We use O_NOFOLLOW to prevent
// following symlinks.
file, err := os.OpenFile(homePolicyPath, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
if errors.Is(err, syscall.ELOOP) {
return nil, fmt.Errorf("home policy file %s is a symlink, symlink are unsafe in this context", homePolicyPath)
}
return nil, fmt.Errorf("failed to open %s, %v", homePolicyPath, err)
}
defer file.Close()
if fileInfo, err := file.Stat(); err != nil {
return nil, fmt.Errorf("failed to get info on file %s", homePolicyPath)
} else if stat, ok := fileInfo.Sys().(*syscall.Stat_t); !ok { // This syscall.Stat_t is doesn't work on Windows
return nil, fmt.Errorf("failed to stat file %s", homePolicyPath)
} else {
// We want to ensure that the file is owned by the correct user and has the correct permissions.
requiredOwnerUid := userObj.Uid
fileOwnerUID := strconv.FormatUint(uint64(stat.Uid), 10)
fileOwner, err := user.LookupId(fileOwnerUID)
if err != nil {
return nil, fmt.Errorf("failed to find username for UID %s for file %s", fileOwnerUID, homePolicyPath)
}
if fileOwnerUID != userObj.Uid || fileOwner.Username != username {
return nil, fmt.Errorf("unsafe file permissions on %s expected file owner %s (UID %s) got %s (UID %s)",
homePolicyPath, username, requiredOwnerUid, fileOwner.Username, fileOwnerUID)
}
if fileInfo.Mode().Perm() != files.ModeHomePerms {
return nil, fmt.Errorf("unsafe file permissions for %s got %o expected %o", homePolicyPath, fileInfo.Mode().Perm(), files.ModeHomePerms)
}
fileBytes, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read %s, %v", homePolicyPath, err)
}
return fileBytes, nil
}
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package commands
import (
"context"
"fmt"
"io/fs"
"net/http"
"strings"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/verifier"
"github.com/openpubkey/opkssh/commands/config"
"github.com/openpubkey/opkssh/policy"
"github.com/openpubkey/opkssh/policy/files"
"github.com/openpubkey/opkssh/sshcert"
"github.com/spf13/afero"
"golang.org/x/crypto/ssh"
)
// PolicyEnforcerFunc returns nil if the supplied PK token is permitted to login as
// username. Otherwise, an error is returned indicating the reason for rejection
type PolicyEnforcerFunc func(username string, pkt *pktoken.PKToken, userInfo string, sshCert string, keyType string, denyList policy.DenyList, extraArgs []string) error
// VerifyCmd provides functionality to verify OPK tokens contained in SSH
// certificates and authorize requests to SSH as a specific username using a
// configurable authorization system. It is designed to be used in conjunction
// with sshd's AuthorizedKeysCommand feature.
type VerifyCmd struct {
Fs afero.Fs
// PktVerifier is responsible for verifying the PK token
// contained in the SSH certificate
PktVerifier verifier.Verifier
// CheckPolicy determines whether the verified PK token is permitted to SSH as a
// specific user
CheckPolicy PolicyEnforcerFunc
// ConfigPathArg is the path to the server config file
ConfigPathArg string
// filePermChecker is used to check the file permissions of the config file
filePermChecker files.PermsChecker
// HTTPClient can be mocked using a roundtripper in tests
HttpClient *http.Client
// denyList is populated from ServerConfig after successful parsing
denyList policy.DenyList
}
// NewVerifyCmd creates a new VerifyCmd instance with the provided arguments.
func NewVerifyCmd(pktVerifier verifier.Verifier, checkPolicy PolicyEnforcerFunc, configPathArg string) *VerifyCmd {
fs := afero.NewOsFs()
return &VerifyCmd{
Fs: fs,
PktVerifier: pktVerifier,
CheckPolicy: checkPolicy,
ConfigPathArg: configPathArg,
filePermChecker: files.PermsChecker{
Fs: fs,
CmdRunner: files.ExecCmd,
},
}
}
// This function is called by the SSH server as the AuthorizedKeysCommand:
//
// By default, the following lines are added to the sshd_config at /etc/ssh/sshd_config.d/60-opk-ssh.conf:
//
// AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t
// AuthorizedKeysCommandUser opksshuser
//
// The parameters specified in the config map the parameters sent to the function below.
// We prepend "Arg" to specify which ones are arguments sent by sshd. They are:
//
// %u The username (requested principal) - userArg
// %k The base64-encoded public key for authentication - certB64Arg - the public key is also a certificate
// %t The public key type - typArg - in this case a certificate being used as a public key
//
// AuthorizedKeysCommand verifies the OPK PK token contained in the base64-encoded SSH pubkey;
// the pubkey is expected to be an SSH certificate. pubkeyType is used to
// determine how to parse the pubkey as one of the SSH certificate types.
//
// This function:
// 1. Verifying the PK token with the OP (OpenID Provider)
// 2. Enforcing policy by checking if the identity is allowed to assume
// the username (principal) requested.
//
// If all steps of verification succeed, then the expected authorized_keys file
// format string is returned (i.e. the expected line to produce on standard
// output when using sshd's AuthorizedKeysCommand feature). Otherwise, a non-nil
// error is returned.
func (v *VerifyCmd) AuthorizedKeysCommand(ctx context.Context, userArg string, typArg string, certB64Arg string, extraArgs []string) (string, error) {
// Parse the b64 pubkey and expect it to be an ssh certificate
cert, err := sshcert.NewFromAuthorizedKey(typArg, certB64Arg)
if err != nil {
return "", err
}
if pkt, err := cert.VerifySshPktCert(ctx, v.PktVerifier); err != nil { // Verify the PKT contained in the cert
return "", err
} else {
userInfo := ""
if accessToken := cert.GetAccessToken(); accessToken != "" {
if userInfoRet, err := v.UserInfoLookup(ctx, pkt, accessToken); err == nil {
// userInfo is optional so we should not fail if we can't access it
userInfo = userInfoRet
}
}
if err := v.CheckPolicy(userArg, pkt, userInfo, certB64Arg, typArg, v.denyList, extraArgs); err != nil {
return "", err
} else { // Success!
// sshd expects the public key in the cert, not the cert itself. This
// public key is key of the CA that signs the cert, in our setting there
// is no CA.
pubkeyBytes := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert.SshCert.SignatureKey)))
principals := strings.Join(cert.SshCert.ValidPrincipals, ",")
if principals != "" {
// Makes sshd trust the principals in the certificate.
// This is needed to emulate a principal wildcard in an SSH cert.
// OpenSSH intentionally broke the old way of doing SSH cert principal
// wildcards requiring OPKSSH to use this approach instead.
// See https://github.com/openpubkey/opkssh/pull/513
return fmt.Sprintf("cert-authority,principals=\"%s\" %s", principals, pubkeyBytes), nil
}
return fmt.Sprintf("cert-authority %s", pubkeyBytes), nil
}
}
}
// ReadFromServerConfig sets the environment variables specified in the server config file
// and assigns configured deny lists to VerifyCmd's denyList
func (v *VerifyCmd) ReadFromServerConfig() error {
var configBytes []byte
// Load the file from the filesystem
afs := &afero.Afero{Fs: v.Fs}
configBytes, err := afs.ReadFile(v.ConfigPathArg)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
err = v.filePermChecker.CheckPerm(v.ConfigPathArg, []fs.FileMode{0640}, "root", "opksshuser")
if err != nil {
return err
}
serverConfig, err := config.NewServerConfig(configBytes)
if err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
v.denyList = policy.DenyList{
Emails: serverConfig.DenyEmails,
Users: serverConfig.DenyUsers,
}
return serverConfig.SetEnvVars()
}
func (v *VerifyCmd) UserInfoLookup(ctx context.Context, pkt *pktoken.PKToken, accessToken string) (string, error) {
ui, err := verifier.NewUserInfoRequester(pkt, accessToken)
if err != nil {
return "", err
}
ui.HttpClient = v.HttpClient
return ui.Request(ctx)
}
// OpkPolicyEnforcerAuthFunc returns an opkssh policy.Enforcer that can be
// used in the opkssh verify command.
func OpkPolicyEnforcerFunc(username string) PolicyEnforcerFunc {
policyEnforcer := &policy.Enforcer{
PolicyLoader: policy.NewMultiPolicyLoader(username, policy.ReadWithSudoScript),
}
return policyEnforcer.CheckPolicy
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package sysdetails
import (
"log"
"os/exec"
"strings"
)
// getOpenSSHVersion attempts to get OpenSSH version using multiple fallback methods
func GetOpenSSHVersion() string {
// OS-specific package manager queries
osType := DetectOS()
log.Printf("Attempting OS-specific version detection for: %s", osType)
switch osType {
case OSTypeRHEL:
// For RedHat-based systems (CentOS, RHEL, Fedora)
cmd := exec.Command("/bin/sh", "-c", "version=$(/usr/bin/rpm -q --qf \"%{VERSION}\\n\" openssh-server 2>/dev/null | /bin/sed -E 's/^([0-9]+\\.[0-9]+).*/\\1/' | head -1); if [ -n \"$version\" ]; then /bin/echo \"OpenSSH_$version\"; fi")
if output, err := cmd.CombinedOutput(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
return strings.TrimSpace(string(output))
}
case OSTypeDebian:
// For Debian-based systems (Debian, Ubuntu)
cmd := exec.Command("/bin/sh", "-c", "version=$(/usr/bin/dpkg-query -W -f='${Version}\\n' openssh-server 2>/dev/null | /bin/sed -E 's/^[0-9]*:?([0-9]+\\.[0-9]+).*/\\1/' | head -1); if [ -n \"$version\" ]; then /bin/echo \"OpenSSH_$version\"; fi")
if output, err := cmd.CombinedOutput(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
return strings.TrimSpace(string(output))
}
case OSTypeArch:
// For Arch Linux
cmd := exec.Command("/bin/sh", "-c", "version=$(/usr/bin/pacman -Qi openssh 2>/dev/null | /usr/bin/awk '/^Version/ {print $3}' | /bin/sed -E 's/^([0-9]+\\.[0-9]+).*/\\1/' | head -1); if [ -n \"$version\" ]; then /bin/echo \"OpenSSH_$version\"; fi")
if output, err := cmd.CombinedOutput(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
return strings.TrimSpace(string(output))
}
case OSTypeSUSE:
// For SUSE-based systems
cmd := exec.Command("/bin/sh", "-c", "version=$(/usr/bin/rpm -q --qf \"%{VERSION}\\n\" openssh 2>/dev/null | /bin/sed -E 's/^([0-9]+\\.[0-9]+).*/\\1/' | head -1); if [ -n \"$version\" ]; then /bin/echo \"OpenSSH_$version\"; fi")
if output, err := cmd.CombinedOutput(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
return strings.TrimSpace(string(output))
}
case OSTypeWindows:
// For Windows, try ssh.exe in PATH
cmd := exec.Command("ssh.exe", "-V")
output, err := cmd.CombinedOutput()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
return strings.TrimSpace(string(output))
}
default:
log.Printf("Warning: Could not determine OpenSSH version using OS-specific methods for %s", osType)
}
// Try ssh -V (works on most systems)
cmd := exec.Command("ssh", "-V")
output, err := cmd.CombinedOutput()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
return strings.TrimSpace(string(output))
}
log.Println("Warning: Error executing ssh -V:", err)
// Try sshd -V as fallback
cmd = exec.Command("sshd", "-V")
output, err = cmd.CombinedOutput()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
return strings.TrimSpace(string(output))
}
log.Println("Warning: Error executing sshd -V:", err)
return ""
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package sysdetails
import (
"os"
"runtime"
"strings"
)
// OSType represents the operating system type
type OSType string
// Operating system constants
const (
OSTypeGeneric OSType = "generic"
OSTypeRHEL OSType = "rhel"
OSTypeDebian OSType = "debian"
OSTypeArch OSType = "arch"
OSTypeSUSE OSType = "suse"
OSTypeWindows OSType = "windows"
)
// DetectOS determines the type of operating system.
func DetectOS() OSType {
// Check for Windows using runtime.GOOS
if runtime.GOOS == "windows" {
return OSTypeWindows
}
// Check for RedHat-based systems
if _, err := os.Stat("/etc/redhat-release"); err == nil {
return OSTypeRHEL
}
// Check for Debian-based systems
if _, err := os.Stat("/etc/debian_version"); err == nil {
return OSTypeDebian
}
// Check for Arch Linux
if _, err := os.Stat("/etc/arch-release"); err == nil {
return OSTypeArch
}
// Check for SUSE Linux
if _, err := os.Stat("/etc/SuSE-release"); err == nil {
return OSTypeSUSE
}
if _, err := os.Stat("/etc/SUSE-brand"); err == nil {
return OSTypeSUSE
}
// Check for /etc/os-release which exists on most modern Linux systems
if content, err := os.ReadFile("/etc/os-release"); err == nil {
contentStr := string(content)
if strings.Contains(contentStr, "ID=rhel") ||
strings.Contains(contentStr, "ID=centos") ||
strings.Contains(contentStr, "ID=fedora") {
return OSTypeRHEL
}
if strings.Contains(contentStr, "ID=debian") ||
strings.Contains(contentStr, "ID=ubuntu") {
return OSTypeDebian
}
if strings.Contains(contentStr, "ID=arch") {
return OSTypeArch
}
if strings.Contains(contentStr, "ID=sles") ||
strings.Contains(contentStr, "ID=opensuse") {
return OSTypeSUSE
}
}
// Default to generic, if no specific OS type is detected.
return OSTypeGeneric
}
//go:build !windows
// +build !windows
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package main
// GetLogFilePath returns the path to the opkssh log file.
// On Unix-like systems, this is /var/log/opkssh.log
func GetLogFilePath() string {
return "/var/log/opkssh.log"
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
/*
OPKSSH is a command-line tool that allows users to authenticate with OpenID Connect providers and generate SSH keys for secure access to servers.
*/
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strings"
"syscall"
"text/tabwriter"
"github.com/openpubkey/opkssh/commands"
config "github.com/openpubkey/opkssh/commands/config"
"github.com/openpubkey/opkssh/internal/sysdetails"
"github.com/openpubkey/opkssh/policy"
"github.com/openpubkey/opkssh/policy/files"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
"github.com/thediveo/enumflag/v2"
"golang.org/x/mod/semver"
"golang.org/x/term"
)
var (
// These can be overridden at build time using ldflags. For example:
// go build -v -o /usr/local/bin/opkssh -ldflags "-X main.Version=version"
Version = "unversioned"
)
func main() {
os.Exit(run())
}
func run() int {
rootCmd := &cobra.Command{
SilenceUsage: true,
Use: "opkssh",
Short: "SSH with OpenPubkey",
Version: Version,
Long: `SSH with OpenPubkey
This program allows users to:
- Login and create SSH key pairs using their OpenID Connect identity
- Add policies to auth_id policy files
- Verify OpenPubkey SSH certificates for use with sshd's AuthorizedKeysCommand`,
Example: ` opkssh login
opkssh add root alice@example.com https://accounts.google.com`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
rootCmd.CompletionOptions.DisableDefaultCmd = true
addCmd := &cobra.Command{
SilenceUsage: true,
Use: "add <principal> <email|sub|group> <issuer>",
Short: "Appends new rule to the policy file",
Long: `Add appends a new policy entry in the auth_id policy file granting SSH access to the specified email or subscriber ID (sub) or group.
It first attempts to write to the system-wide file (/etc/opk/auth_id). If it lacks permissions to update this file it falls back to writing to the user-specific file (~/.opk/auth_id).
Arguments:
principal The target user account (requested principal).
email|sub|group Email address, subscriber ID or group authorized to assume this principal. If using an OIDC group, the argument needs to be in the format of oidc:groups:<groupId>.
issuer OpenID Connect provider (issuer) URL associated with the email/sub/group.
`,
Args: cobra.ExactArgs(3),
Example: ` opkssh add root alice@example.com https://accounts.google.com
opkssh add alice 103030642802723203118 https://accounts.google.com
opkssh add developer oidc:groups:developer https://accounts.google.com`,
RunE: func(cmd *cobra.Command, args []string) error {
inputPrincipal := args[0]
inputEmail := args[1]
inputIssuer := args[2]
// Convenience aliases to save user time (who is going to remember the hideous Azure issuer string)
switch inputIssuer {
case "google":
inputIssuer = "https://accounts.google.com"
case "azure", "microsoft":
inputIssuer = "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0"
case "gitlab":
inputIssuer = "https://gitlab.com"
case "hello":
inputIssuer = "https://issuer.hello.coop"
}
add := commands.AddCmd{
HomePolicyLoader: policy.NewHomePolicyLoader(),
SystemPolicyLoader: policy.NewSystemPolicyLoader(),
Username: inputPrincipal,
}
policyFilePath, err := add.Run(inputPrincipal, inputEmail, inputIssuer)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to add to policy: %v\n", err)
return err
}
fmt.Fprintf(os.Stdout, "Successfully added new policy to %s\n", policyFilePath)
return nil
},
}
rootCmd.AddCommand(addCmd)
inspectCmd := &cobra.Command{
SilenceUsage: true,
Use: "inspect <path>",
Short: "Inspect and view details of an opkssh generated SSH key",
Example: " opkssh inspect ~/.ssh/id_ecdsa_sk-cert.pub",
RunE: func(cmd *cobra.Command, args []string) error {
keyPathArg := args[0]
inspect := commands.NewInspectCmd(keyPathArg, cmd.OutOrStdout())
if err := inspect.Run(); err != nil {
log.Println("Error executing inspect command:", err)
return err
}
return nil
},
Args: cobra.ExactArgs(1),
}
rootCmd.AddCommand(inspectCmd)
var autoRefreshArg bool
var configPathArg string
var createConfigArg bool
var configureArg bool
var logDirArg string
var providerArg string
var sendAccessTokenArg bool
var disableBrowserOpenArg bool
var printIdTokenArg bool
var printKeyArg bool
var inspectCertArg bool
var verboseArg bool
var keyPathArg string
var keyTypeArg commands.KeyType
var remoteRedirectURIArg string
loginCmd := &cobra.Command{
SilenceUsage: true,
Use: "login [alias]",
Short: "Authenticate with an OpenID Provider to generate an SSH key for opkssh",
Long: `Login creates opkssh SSH keys
Login generates a key pair, then opens a browser to authenticate the user with the OpenID Provider. Upon successful authentication, opkssh creates an SSH public key (~/.ssh/id_ecdsa) containing the user's PK token. By default, this SSH key expires after 24 hours, after which the user must run "opkssh login" again to generate a new key.
Users can then SSH into servers configured to use opkssh as the AuthorizedKeysCommand. The server verifies the PK token and grants access if the token is valid and the user is authorized per the auth_id policy.
Arguments:
alias The provider alias to use. If not specified, the OPKSSH_DEFAULT provider will be used. The aliases are defined by the OPKSSH_PROVIDERS environment variable. The format is <alias>,<issuer>,<client_id>,<client_secret>,<scopes>
`,
Example: ` opkssh login
opkssh login google
opkssh login --provider=<issuer>,<client_id>,<client_secret>,<scopes>`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
cancel()
}()
var providerAliasArg string
if len(args) > 0 {
providerAliasArg = args[0]
}
if verboseArg {
inspectCertArg = true
}
login := commands.NewLogin(autoRefreshArg, configPathArg, createConfigArg, configureArg, logDirArg,
sendAccessTokenArg, disableBrowserOpenArg, printIdTokenArg, providerArg, printKeyArg, keyPathArg,
providerAliasArg, keyTypeArg, remoteRedirectURIArg, inspectCertArg)
if err := login.Run(ctx); err != nil {
log.Println("Error executing login command:", err)
return err
}
return nil
},
Args: cobra.MaximumNArgs(1),
}
// Define flags for login.
loginCmd.Flags().BoolVar(&autoRefreshArg, "auto-refresh", false, "Automatically refresh PK token after login")
loginCmd.Flags().StringVar(&configPathArg, "config-path", "", "Path to the client config file. Default: ~/.opk/config.yml on linux and %APPDATA%\\.opk\\config.yml on windows")
loginCmd.Flags().BoolVar(&createConfigArg, "create-config", false, "Creates a client config file if it does not exist")
loginCmd.Flags().BoolVar(&configureArg, "configure", false, "Apply changes to ssh config and create ~/.ssh/opkssh directory")
loginCmd.Flags().StringVar(&logDirArg, "log-dir", "", "Directory to write output logs")
loginCmd.Flags().BoolVar(&disableBrowserOpenArg, "disable-browser-open", false, "Set this flag to disable opening the browser. Useful for choosing the browser you want to use")
loginCmd.Flags().BoolVar(&printIdTokenArg, "print-id-token", false, "Set this flag to print out the contents of the id_token. Useful for inspecting claims")
loginCmd.Flags().BoolVar(&sendAccessTokenArg, "send-access-token", false, "Set this flag to send the Access Token as well as the PK Token in the SSH cert. The Access Token is used to call the userinfo endpoint to get claims not included in the ID Token")
loginCmd.Flags().StringVar(&providerArg, "provider", "", "OpenID Provider specification in the format: <issuer>,<client_id> or <issuer>,<client_id>,<client_secret> or <issuer>,<client_id>,<client_secret>,<scopes>")
loginCmd.Flags().BoolVarP(&printKeyArg, "print-key", "p", false, "Print the raw private key and SSH cert to stdout instead of writing them to the filesystem")
loginCmd.Flags().BoolVar(&inspectCertArg, "inspect-cert", false, "Print a human-readable inspection of the generated SSH certificate (public information only)")
loginCmd.Flags().BoolVarP(&verboseArg, "verbose", "v", false, "Enable verbose output")
loginCmd.Flags().StringVarP(&keyPathArg, "private-key-file", "i", "", "Path where private keys is written")
loginCmd.Flags().StringVar(&remoteRedirectURIArg, "remote-redirect-uri", "", "Remote redirect URI used for non-localhost redirects. This is an advanced option for embedding opkssh in server-side logic.")
loginCmd.Flags().VarP(enumflag.New(&keyTypeArg, "Key Type", map[commands.KeyType][]string{commands.ECDSA: {commands.ECDSA.String()}, commands.ED25519: {commands.ED25519.String()}}, enumflag.EnumCaseInsensitive), "key-type", "t", "Type of key to generate")
rootCmd.AddCommand(loginCmd)
var logoutKeyPathArg string
var logoutVerboseArg bool
logoutCmd := &cobra.Command{
SilenceUsage: true,
Use: "logout",
Short: "Remove opkssh-generated SSH keys and certificates",
Long: `Logout removes SSH keys and certificates that were generated by opkssh.
By default it searches the standard SSH key locations (~/.ssh/) and the opkssh identity directory (~/.ssh/opkssh/) for keys generated by opkssh and removes them.
Use the -i flag to remove a specific key pair.`,
Example: ` opkssh logout
opkssh logout -i ~/.ssh/id_ecdsa`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
logout := commands.NewLogoutCmd(logoutKeyPathArg)
if logoutVerboseArg {
logout.Verbosity = 1
}
if err := logout.Run(); err != nil {
log.Println("Error executing logout command:", err)
return err
}
return nil
},
}
logoutCmd.Flags().StringVarP(&logoutKeyPathArg, "private-key-file", "i", "", "Path to the specific private key to remove")
logoutCmd.Flags().BoolVarP(&logoutVerboseArg, "verbose", "v", false, "Print verbose output to stderr")
rootCmd.AddCommand(logoutCmd)
readhomeCmd := &cobra.Command{
SilenceUsage: true,
Use: "readhome <principal>",
Short: "Read the principal's home policy file",
Long: `Read the principal's policy file (/home/<principal>/.opk/auth_id).
You should not call this command directly. It is called by the opkssh verify command as part of the AuthorizedKeysCommand process to read the user's policy (principals) home file (~/.opk/auth_id) with sudoer permissions. This allows us to use an unprivileged user as the AuthorizedKeysCommand user.
`,
Args: cobra.ExactArgs(1),
Example: ` opkssh readhome alice`,
RunE: func(cmd *cobra.Command, args []string) error {
userArg := os.Args[2]
if fileBytes, err := commands.ReadHome(userArg); err != nil {
fmt.Fprintf(os.Stderr, "Failed to read user's home policy file: %v\n", err)
return err
} else {
fmt.Fprint(os.Stdout, string(fileBytes))
return nil
}
},
}
rootCmd.AddCommand(readhomeCmd)
var serverConfigPathArg string
verifyCmd := &cobra.Command{
SilenceUsage: true,
Use: "verify <principal> <cert> <key_type>",
Short: "Verify an SSH key (used by sshd AuthorizedKeysCommand)",
Long: `Verify extracts a PK token from a base64-encoded SSH certificate and verifies it against policy. It expects an allowed provider file at /etc/opk/providers and a user policy file at either /etc/opk/auth_id or ~/.opk/auth_id.
This command is intended to be called by sshd as an AuthorizedKeysCommand:
https://man.openbsd.org/sshd_config#AuthorizedKeysCommand
During installation, opkssh typically adds these lines to /etc/ssh/sshd_config:
AuthorizedKeysCommand /usr/local/bin/opkssh verify %%u %%k %%t
AuthorizedKeysCommandUser opksshuser
Where the tokens in /etc/ssh/sshd_config are defined as:
%%u Target username (requested principal)
%%k Base64-encoded SSH public key (SSH certificate) provided for authentication
%%t Public key type (SSH certificate format, e.g., ecdsa-sha2-nistp256-cert-v01@openssh.com)
Verification checks performed:
1. Ensures the PK token is properly formed, signed, and issued by the specified OpenID Provider (OP).
2. Confirms the PK token's issue (iss) and client ID (audience) are listed in the allowed provider file (/etc/opk/providers) and the token is not expired.
3. Validates the identity (email or sub) in the PK token against user policies (/etc/opk/auth_id or ~/.opk/auth_id) to ensure it can assume the requested username (principal).
If all checks pass, Verify authorizes the SSH connection.
Arguments:
principal Target username.
cert Base64-encoded SSH certificate.
key_type SSH certificate key type (e.g., ecdsa-sha2-nistp256-cert-v01@openssh.com)`,
Args: cobra.MinimumNArgs(3),
Example: ` opkssh verify root <base64-encoded-cert> ecdsa-sha2-nistp256-cert-v01@openssh.com`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
// Setup logger
logFilePath := GetLogFilePath()
logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660) // Owner and group can read/write
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err)
// It could be very difficult to figure out what is going on if the log file was deleted. Hopefully this message saves someone an hour of debugging.
if runtime.GOOS == "windows" {
fmt.Fprintf(os.Stderr, "Check if the log file exists at %v. If it does not, create it and ensure the account running sshd has read/write access (for example, via the file's Security properties or using icacls). The log directory may be under %%ProgramData%%.\n", logFilePath)
} else {
fmt.Fprintf(os.Stderr, "Check if log exists at %v, if it does not create it with permissions: chown root:opksshuser %v; chmod 660 %v\n", logFilePath, logFilePath, logFilePath)
}
} else {
defer logFile.Close()
log.SetOutput(logFile)
}
// Logs if using an unsupported OpenSSH version
checkOpenSSHVersion()
// The "AuthorizedKeysCommand" func is designed to be used by sshd and specified as an AuthorizedKeysCommand
// ref: https://man.openbsd.org/sshd_config#AuthorizedKeysCommand
log.Println(strings.Join(os.Args, " "))
userArg := args[0]
certB64Arg := args[1]
typArg := args[2]
extraArgs := args[3:]
providerPolicyPath := filepath.Join(policy.GetSystemConfigBasePath(), "providers")
providerPolicy, err := policy.NewProviderFileLoader().LoadProviderPolicy(providerPolicyPath)
if err != nil {
log.Printf("Failed to open %s: %v\n", providerPolicyPath, err)
return err
}
printConfigProblems()
log.Println("Providers loaded: ", providerPolicy.ToString())
pktVerifier, err := providerPolicy.CreateVerifier()
if err != nil {
log.Println("Failed to create pk token verifier (likely bad configuration):", err)
return err
}
v := commands.NewVerifyCmd(*pktVerifier, commands.OpkPolicyEnforcerFunc(userArg), serverConfigPathArg)
if err := v.ReadFromServerConfig(); err != nil {
log.Println("Failed to set environment variables in config:", err)
}
if authKey, err := v.AuthorizedKeysCommand(ctx, userArg, typArg, certB64Arg, extraArgs); err != nil {
log.Println("failed to verify:", err)
return err
} else {
log.Println("successfully verified")
// sshd is awaiting a specific line, which we print here. Printing anything else before or after will break our solution
fmt.Println(authKey)
return nil
}
},
}
defaultConfigPath := filepath.Join(policy.GetSystemConfigBasePath(), "config.yml")
verifyCmd.Flags().StringVar(&serverConfigPathArg, "config-path", defaultConfigPath, fmt.Sprintf("Path to the server config file. Default: %s", defaultConfigPath))
rootCmd.AddCommand(verifyCmd)
auditCmd := &cobra.Command{
SilenceUsage: true,
Use: "audit",
Short: "Validate policy file entries against provider definitions",
Long: `Audit validates all entries in the system policy file and user policy files against the provider definitions in the providers file. For complete audit details use the --json flag. Returns a non-zero exit code if any warnings or errors are found.
The audit command checks that:
- Each issuer in policy files is defined in the providers file
- The protocol (http:// or https://) exactly matches between policy and provider files
- The auth_id policy files do not throw parsing errors
Results are reported with the following status:
SUCCESS - Entry is valid
WARNING - Entry is valid but may cause problems
ERROR - Entry has issues (missing provider, protocol mismatch, etc.)
Exit code: 0 if all entries are valid, 1 if any warnings or errors are found.`,
Example: ` opkssh audit`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
audit := commands.NewAuditCmd(os.Stdout, os.Stderr)
// Apply command-line flags
providersFile, _ := cmd.Flags().GetString("providers-file")
if providersFile != "" {
audit.ProviderPath = providersFile
}
policyFile, _ := cmd.Flags().GetString("policy-file")
if policyFile != "" {
audit.PolicyPath = policyFile
}
skipUser, _ := cmd.Flags().GetBool("skip-user-policy")
audit.SkipUserPolicy = skipUser
audit.JsonOutput, _ = cmd.Flags().GetBool("json")
return audit.Run(Version)
},
}
auditCmd.Flags().String("providers-file", policy.SystemDefaultProvidersPath, "Path to providers file")
auditCmd.Flags().String("policy-file", policy.SystemDefaultPolicyPath, "Path to policy file")
auditCmd.Flags().Bool("skip-user-policy", runtime.GOOS == "windows", "Skip auditing user policy file (~/.opk/auth_id)")
auditCmd.Flags().BoolP("json", "j", false, "Output complete audit results in JSON")
rootCmd.AddCommand(auditCmd)
clientCmd := &cobra.Command{
Use: "client [subcommand]",
Short: "Interact with client configuration",
Example: ` opkssh client provider list`,
Args: cobra.ExactArgs(0),
}
providerCmd := &cobra.Command{
Use: "provider [subcommand]",
Short: "Interact with provider configuration",
Example: ` opkssh client provider list`,
Args: cobra.ExactArgs(0),
}
providerListCmd := &cobra.Command{
Use: "list",
Short: "List configured providers",
Example: ` opkssh client provider list`,
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
client_config, err := config.GetClientConfigFromFile(configPathArg, afero.NewOsFs())
if err != nil {
log.Fatal("Unable to load providers. ", err)
}
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
var w *tabwriter.Writer
if isTTY {
// Nice aligned table for TTY output
w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Alias\tIssuer")
fmt.Fprintln(w, "-----\t------")
} else {
// Simpler formatting for non-TTY (e.g., when piping to a file)
w = tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.DiscardEmptyColumns)
}
for _, p := range client_config.Providers {
for _, alias := range p.AliasList {
fmt.Fprintf(w, "%s\t%s\n", alias, p.Issuer)
}
}
w.Flush()
// and lets check it can be loaded into a map, after we print the contents
if _, err = config.CreateProvidersMap(client_config.Providers); err != nil {
log.Fatal("Unable to parse providers. ", err)
}
return nil
},
}
providerListCmd.Flags().StringVar(&configPathArg, "config-path", "", "Path to the client config file. Default: ~/.opk/config.yml on linux and %APPDATA%\\.opk\\config.yml on windows.")
providerCmd.AddCommand(providerListCmd)
clientCmd.AddCommand(providerCmd)
rootCmd.AddCommand(clientCmd)
// permissions command for checking and fixing file permissions/ACLs
permsCmd := commands.NewPermissionsCmd(os.Stdout, os.Stderr)
rootCmd.AddCommand(permsCmd.CobraCommand())
// genDocsCmd is a hidden command used as a helper for generating our
// command line reference documentation.
genDocsCmd := &cobra.Command{
Use: "gendocs <output_dir>",
Hidden: true,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := "./docs/cli/"
if len(args) > 1 {
path = args[1]
}
err := os.MkdirAll(path, 0775)
if err != nil {
return err
}
return doc.GenMarkdownTree(rootCmd, path)
},
}
rootCmd.AddCommand(genDocsCmd)
err := rootCmd.Execute()
if err != nil {
return 1
}
return 0
}
func printConfigProblems() {
problems := files.ConfigProblems().GetProblems()
if len(problems) > 0 {
log.Println("Warning: Encountered the following configuration problems:")
for _, problem := range problems {
log.Println(problem.String())
}
}
}
// OpenSSH used to impose a 4096-octet limit on the string buffers available to
// the percent_expand function. In October 2019 as part of the 8.1 release,
// that limit was removed. If you exceeded this amount it would fail with
// fatal: percent_expand: string too long
// The following two functions check whether the OpenSSH version on the
// system running the verifier is greater than or equal to 8.1;
// if not then prints a warning
func checkOpenSSHVersion() {
version := sysdetails.GetOpenSSHVersion()
if version == "" {
log.Println("Warning: Could not determine OpenSSH version")
return
}
if ok, _ := isOpenSSHVersion8Dot1OrGreater(version); !ok {
log.Println("Warning: OpenPubkey SSH requires OpenSSH v. 8.1 or greater")
}
}
func isOpenSSHVersion8Dot1OrGreater(opensshVersion string) (bool, error) {
// Extract version number from various formats:
// - "OpenSSH_9.5p1" -> "9.5"
// - "OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2" -> "9.5"
// First, get the part before comma (to handle LibreSSL suffix on Windows)
opensshVersion = strings.Split(opensshVersion, ",")[0]
// Try to extract version using regex that handles both Unix and Windows formats
// Matches: "OpenSSH_9.5", "OpenSSH_for_Windows_9.5", etc.
re, err := regexp.Compile(`OpenSSH[_a-zA-Z]*[_](\d+\.\d+)`)
if err != nil {
fmt.Println("Error compiling regex:", err)
return false, err
}
matches := re.FindStringSubmatch(opensshVersion)
if len(matches) < 2 {
// If regex didn't match, try a simpler approach: find any version pattern
simpleRe := regexp.MustCompile(`(\d+\.\d+)`)
matches = simpleRe.FindStringSubmatch(opensshVersion)
if len(matches) < 2 {
log.Printf("Invalid OpenSSH version format: %s", opensshVersion)
return false, fmt.Errorf("invalid OpenSSH version format: %s", opensshVersion)
}
}
version := "v" + matches[1] // semver requires that version strings start with 'v'
// OpenSSH doesn't use semantic versioning, but does use major.minor which after stripping the patch version can be compared using semver
if semver.Compare(version, "v8.1.0") >= 0 {
// if version is greater than or equal to v8.1.0
return true, nil
}
return false, nil
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package policy
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/opkssh/policy/plugins"
"golang.org/x/exp/slices"
)
const (
OIDC_CLAIMS = "oidc:"
OIDC_WILDCARD_EMAIL = "oidc-match-end:email:"
)
// DenyList represents the DenyLists in the server config
type DenyList struct {
Emails []string
Users []string
}
// Enforcer evaluates opkssh policy to determine if the desired principal is
// permitted
type Enforcer struct {
PolicyLoader Loader
}
// type for Identity Token checkedClaims
type checkedClaims struct {
Email string `json:"email"`
Sub string `json:"sub"`
ExtraClaims map[string][]string `json:"-"`
}
func (s *checkedClaims) UnmarshalJSON(data []byte) error {
// Avoid infinite recursion
type checkedClaimsAlias checkedClaims
var a checkedClaimsAlias
// Unmarshal the required claims
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*s = checkedClaims(a)
// Unmarshal everything else
var schema map[string]interface{}
err := json.Unmarshal([]byte(data), &schema)
if err != nil {
return err
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
s.ExtraClaims = make(map[string][]string, len(raw))
for k, v := range raw {
switch t := v.(type) {
case string:
s.ExtraClaims[k] = []string{t}
case []any:
// Turn all elements in a list into a string
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
} else {
out = append(out, fmt.Sprint(e))
}
}
s.ExtraClaims[k] = out
default:
// Turn numbers/bools etc into strings
s.ExtraClaims[k] = []string{fmt.Sprint(t)}
}
}
return nil
}
// GetPluginPolicyDir returns the default location for policy plugins.
// On Unix: /etc/opk/policy.d, On Windows: %ProgramData%\opk\policy.d
func GetPluginPolicyDir() string {
return filepath.Join(GetSystemConfigBasePath(), "policy.d")
}
// EscapedSplit splits a string by a separator while ignoring the separator in quoted sections.
// This is useful for strings that may contain the separator character as part of the string
// and not as a delimiter.
func EscapedSplit(s string, sep rune) []string {
quoted := false
a := strings.FieldsFunc(s, func(r rune) bool {
if r == '"' {
quoted = !quoted
}
return !quoted && r == sep
})
return a
}
// Validates that the server defined identity attribute matches the
// respective claim from the identity token
func validateClaim(claims *checkedClaims, user *User) bool {
// Should we match on the email claim?
if strings.HasPrefix(claims.Email, OIDC_WILDCARD_EMAIL) {
return false
}
// Should we match on an oidc claim?
if strings.HasPrefix(user.IdentityAttribute, OIDC_CLAIMS) {
oidcGroupSections := EscapedSplit(user.IdentityAttribute, ':')
oidcGroupsName := strings.Trim(oidcGroupSections[1], "\"")
return slices.Contains(
claims.ExtraClaims[oidcGroupsName],
oidcGroupSections[len(oidcGroupSections)-1],
)
}
// Should we match on the email wildcard claim?
wildCardEmailMatch := false
if strings.HasPrefix(user.IdentityAttribute, OIDC_WILDCARD_EMAIL) {
if strings.HasSuffix(strings.ToLower(claims.Email), strings.ToLower(user.IdentityAttribute[len(OIDC_WILDCARD_EMAIL):len(user.IdentityAttribute)])) {
wildCardEmailMatch = true
}
}
// email should be a case-insensitive check
// sub should be a case-sensitive check
return wildCardEmailMatch || strings.EqualFold(claims.Email, user.IdentityAttribute) || string(claims.Sub) == user.IdentityAttribute
}
// CheckPolicy loads opkssh policy and checks to see if there is a policy
// permitting access to principalDesired for the user identified by the PKT's
// email claim. Returns nil if access is granted. Otherwise, an error is
// returned.
//
// It is security critical to verify the pkt first before calling this function.
// This is because if this function is called first, a timing channel exists which
// allows an attacker check what identities and principals are allowed by the policy.F
func (p *Enforcer) CheckPolicy(principalDesired string, pkt *pktoken.PKToken, userInfoJson string, sshCert string, keyType string, denyList DenyList, extraArgs []string) error {
var claims checkedClaims
if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
return fmt.Errorf("error unmarshalling pk token payload: %w", err)
}
issuer, err := pkt.Issuer()
if err != nil {
return fmt.Errorf("error getting issuer from pk token: %w", err)
}
// Enforce deny list first
for _, email := range denyList.Emails {
if strings.EqualFold(claims.Email, email) {
return fmt.Errorf("denied email %s", email)
}
}
for _, user := range denyList.Users {
if strings.EqualFold(principalDesired, user) {
return fmt.Errorf("denied user %s", user)
}
}
pluginPolicy := plugins.NewPolicyPluginEnforcer()
pluginPolicyDir := GetPluginPolicyDir()
results, err := pluginPolicy.CheckPolicies(pluginPolicyDir, pkt, userInfoJson, principalDesired, sshCert, keyType, extraArgs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Println("Skipping policy plugins: no plugins found at " + pluginPolicyDir)
} else {
log.Printf("Error checking policy plugins: %v \n", err)
}
// Despite the error, we don't fail here because we still want to check
// the standard policy below. Policy plugins can only expand the set of
// allow set, not shrink it.
} else {
for _, result := range results {
commandRunStr := strings.Join(result.CommandRun, " ")
log.Printf("Policy plugin result, path: (%s), allowed: (%t), error: (%v), command_run: (%s), policyOutput: (%s)\n", result.Path, result.Allowed, result.Error, commandRunStr, result.PolicyOutput)
}
if results.Allowed() {
log.Printf("Access granted by policy plugin\n")
return nil
}
}
policy, source, err := p.PolicyLoader.Load()
if err != nil {
return fmt.Errorf("error loading policy: %w", err)
}
var userInfoClaims *checkedClaims
if userInfoJson != "" {
userInfoClaims = new(checkedClaims)
if err := json.Unmarshal([]byte(userInfoJson), userInfoClaims); err != nil {
return fmt.Errorf("error unmarshalling claims from userinfo endpoint: %w", err)
}
}
for _, user := range policy.Users {
// The underlying library checks idT.sub == userInfo.sub when we call the userinfo endpoint.
// We want to be extra sure so we also check it here as well.
if userInfoClaims != nil && claims.Sub != userInfoClaims.Sub {
return fmt.Errorf("userInfo sub claim (%s) does not match user policy sub claim (%s)", userInfoClaims.Sub, claims.Sub)
}
if issuer != user.Issuer {
continue
}
// if they are, then check if the desired principal is allowed
if !slices.Contains(user.Principals, principalDesired) {
continue
}
// check each entry to see if the user in the checkedClaims is included
if validateClaim(&claims, &user) {
// access granted
return nil
}
// check each entry to see if the user matches the userInfoClaims
if userInfoClaims != nil && validateClaim(userInfoClaims, &user) {
// access granted
return nil
}
}
return fmt.Errorf("no policy to allow %s with (issuer=%s) to assume %s, check policy config at %s", claims.Email, issuer, principalDesired, source.Source())
}
//go:build !windows
// +build !windows
package files
import (
"fmt"
"github.com/spf13/afero"
"os/user"
"strconv"
"syscall"
)
// UnixACLVerifier implements ACLVerifier for Unix-like systems.
type UnixACLVerifier struct {
Fs afero.Fs
}
func NewDefaultACLVerifier(fs afero.Fs) ACLVerifier {
return &UnixACLVerifier{Fs: fs}
}
func (u *UnixACLVerifier) VerifyACL(path string, expected ExpectedACL) (ACLReport, error) {
r := ACLReport{Path: path}
if u.Fs == nil {
u.Fs = afero.NewOsFs()
}
fi, err := u.Fs.Stat(path)
if err != nil {
// file doesn't exist or other stat error
r.Exists = false
r.Problems = append(r.Problems, fmt.Sprintf("open %s: %v", path, err))
return r, nil
}
r.Exists = true
// Mode bits
r.Mode = fi.Mode().Perm()
if expected.Mode != 0 {
if r.Mode != expected.Mode {
r.Problems = append(r.Problems, fmt.Sprintf("expected mode %o, got %o", expected.Mode, r.Mode))
}
}
// Owner lookup if available via Sys()
if statT, ok := fi.Sys().(*syscall.Stat_t); ok {
uid := strconv.FormatUint(uint64(statT.Uid), 10)
gid := strconv.FormatUint(uint64(statT.Gid), 10)
ownerName := ""
groupName := ""
if uobj, err := user.LookupId(uid); err == nil {
ownerName = uobj.Username
}
if gobj, err := user.LookupGroupId(gid); err == nil {
groupName = gobj.Name
}
r.Owner = ownerName
if expected.Owner != "" {
if ownerName == "" {
r.Problems = append(r.Problems, fmt.Sprintf("could not determine owner for %s (uid=%s)", path, uid))
} else if ownerName != expected.Owner {
r.Problems = append(r.Problems, fmt.Sprintf("expected owner (%s), got (%s)", expected.Owner, ownerName))
}
}
_ = groupName // currently not used in report
} else {
// Sys() not available (e.g., in-memory FS); only check owner if not specified
if expected.Owner != "" {
// we can't determine owner; report a problem
r.Problems = append(r.Problems, fmt.Sprintf("owner check requested but Sys() unavailable for %s", path))
}
}
return r, nil
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package files
import (
"strings"
"sync"
)
type ConfigProblem struct {
Filepath string
OffendingLine string
ErrorMessage string
Source string
}
func (e ConfigProblem) String() string {
return "encountered error: " + e.ErrorMessage + ", reading " + e.OffendingLine + " in " + e.Filepath
}
type ConfigLog struct {
log []ConfigProblem
logMutex sync.Mutex
}
func (c *ConfigLog) RecordProblem(entry ConfigProblem) {
c.logMutex.Lock()
defer c.logMutex.Unlock()
c.log = append(c.log, entry)
}
func (c *ConfigLog) GetProblems() []ConfigProblem {
c.logMutex.Lock()
defer c.logMutex.Unlock()
logCopy := make([]ConfigProblem, len(c.log))
copy(logCopy, c.log)
return logCopy
}
func (c *ConfigLog) NoProblems() bool {
c.logMutex.Lock()
defer c.logMutex.Unlock()
return len(c.log) == 0
}
func (c *ConfigLog) String() string {
// No mutex needed since GetLogs handles the mutex
logs := c.GetProblems()
logsStrings := []string{}
for _, log := range logs {
logsStrings = append(logsStrings, log.String())
}
return strings.Join(logsStrings, "\n")
}
func (c *ConfigLog) Clear() {
c.logMutex.Lock()
defer c.logMutex.Unlock()
c.log = []ConfigProblem{}
}
var (
singleton *ConfigLog
once sync.Once
)
func ConfigProblems() *ConfigLog {
once.Do(func() {
singleton = &ConfigLog{
log: []ConfigProblem{},
logMutex: sync.Mutex{},
}
})
return singleton
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package files
import (
"fmt"
"io/fs"
"path/filepath"
"github.com/spf13/afero"
)
// UserPolicyLoader contains methods to read/write the opkssh policy file from/to an
// arbitrary filesystem. All methods that read policy from the filesystem fail
// and return an error immediately if the permission bits are invalid.
type FileLoader struct {
Fs afero.Fs
RequiredPerm fs.FileMode
}
// CreateIfDoesNotExist creates a file at the given path if it does not exist.
func (l FileLoader) CreateIfDoesNotExist(path string) error {
exists, err := afero.Exists(l.Fs, path)
if err != nil {
return err
}
if !exists {
dirPath := filepath.Dir(path)
if err := l.Fs.MkdirAll(dirPath, 0750); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
file, err := l.Fs.Create(path)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
file.Close()
if err := l.Fs.Chmod(path, l.RequiredPerm); err != nil {
return fmt.Errorf("failed to set file permissions: %w", err)
}
}
return nil
}
// LoadFileAtPath validates that the file at path exists, can be read
// by the current process, and has the correct permission bits set. Parses the
// contents and returns the bytes if file permissions are valid and
// reading is successful; otherwise returns an error.
func (l *FileLoader) LoadFileAtPath(path string) ([]byte, error) {
// Check if file exists and we can access it
if _, err := l.Fs.Stat(path); err != nil {
return nil, fmt.Errorf("failed to describe the file at path: %w", err)
}
// Validate that file has correct permission bits set
if err := NewPermsChecker(l.Fs).CheckPerm(path, []fs.FileMode{l.RequiredPerm}, "", ""); err != nil {
return nil, fmt.Errorf("policy file has insecure permissions: %w", err)
}
// Read file contents
afs := &afero.Afero{Fs: l.Fs}
content, err := afs.ReadFile(path)
if err != nil {
return nil, err
}
return content, nil
}
// Dump writes the bytes in fileBytes to the filepath
func (l *FileLoader) Dump(fileBytes []byte, path string) error {
// Write to disk
if err := afero.WriteFile(l.Fs, path, fileBytes, l.RequiredPerm); err != nil {
return err
}
return nil
}
//go:build !windows
// +build !windows
package files
import "fmt"
// ResolveAccountToSID is a stub on non-Windows platforms.
func ResolveAccountToSID(name string) ([]byte, uint32, error) {
return nil, 0, fmt.Errorf("ResolveAccountToSID is only supported on Windows")
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package files
import (
"io/fs"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"github.com/spf13/afero"
)
// FilePermsOps provides an abstraction for creating files/directories and
// setting permissions in a platform-aware way.
type FilePermsOps interface {
MkdirAllWithPerm(path string, perm fs.FileMode) error
CreateFileWithPerm(path string) (afero.File, error)
WriteFileWithPerm(path string, data []byte, perm fs.FileMode) error
Chmod(path string, perm fs.FileMode) error
Stat(path string) (fs.FileInfo, error)
Chown(path string, owner string, group string) error
// ApplyACE applies a single ACE to the target path. On platforms that
// don't support ACE modifications, this may be a no-op or return nil.
ApplyACE(path string, ace ACE) error
}
// OsFilePermsOps is a default implementation that delegates to an afero.Fs
// for filesystem operations and uses os.Chown when required.
type OsFilePermsOps struct {
Fs afero.Fs
}
func NewDefaultFilePermsOps(fs afero.Fs) FilePermsOps {
if runtime.GOOS == "windows" {
// Prefer ACL-capable implementation on Windows
return NewWindowsACLFilePermsOps(fs)
}
return &OsFilePermsOps{Fs: fs}
}
func (o *OsFilePermsOps) MkdirAllWithPerm(path string, perm fs.FileMode) error {
return o.Fs.MkdirAll(path, perm)
}
func (o *OsFilePermsOps) CreateFileWithPerm(path string) (afero.File, error) {
// Ensure parent directory exists
dir := filepath.Dir(path)
if err := o.Fs.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
return o.Fs.Create(path)
}
func (o *OsFilePermsOps) WriteFileWithPerm(path string, data []byte, perm fs.FileMode) error {
return afero.WriteFile(o.Fs, path, data, perm)
}
func (o *OsFilePermsOps) Chmod(path string, perm fs.FileMode) error {
return o.Fs.Chmod(path, perm)
}
func (o *OsFilePermsOps) Stat(path string) (fs.FileInfo, error) {
return o.Fs.Stat(path)
}
func (o *OsFilePermsOps) Chown(path string, owner string, group string) error {
// If nothing requested, nothing to do
if owner == "" && group == "" {
return nil
}
// On Windows, mapping POSIX chown isn't meaningful; return nil
if runtime.GOOS == "windows" {
return nil
}
// Lookup uid/gid
var uid int
var gid int
if owner != "" {
uobj, err := user.Lookup(owner)
if err != nil {
return err
}
uid64, err := strconv.ParseInt(uobj.Uid, 10, 32)
if err != nil {
return err
}
uid = int(uid64)
}
if group != "" {
gobj, err := user.LookupGroup(group)
if err != nil {
return err
}
gid64, err := strconv.ParseInt(gobj.Gid, 10, 32)
if err != nil {
return err
}
gid = int(gid64)
}
return os.Chown(path, uid, gid)
}
func (o *OsFilePermsOps) ApplyACE(path string, ace ACE) error {
// POSIX: ACEs are not supported in this abstraction. No-op.
return nil
}
//go:build !windows
// +build !windows
package files
import (
"github.com/spf13/afero"
)
// NewWindowsACLFilePermsOps is a stub for non-Windows platforms and returns
// the default OsFilePermsOps so code that references this symbol compiles on
// all platforms.
func NewWindowsACLFilePermsOps(fs afero.Fs) FilePermsOps {
return &OsFilePermsOps{Fs: fs}
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package files
import (
"io/fs"
"github.com/spf13/afero"
)
// FileSystem abstracts all filesystem and permission operations needed by
// the permissions and audit commands. It combines file I/O, permission
// mutations, ownership management, and ACL verification into a single
// mockable interface that hides platform-specific details.
type FileSystem interface {
// Stat returns file info for the given path.
Stat(path string) (fs.FileInfo, error)
// Exists reports whether the path exists.
Exists(path string) (bool, error)
// Open opens a file for reading (e.g. directory listing via Readdir).
Open(path string) (afero.File, error)
// ReadFile reads the entire contents of a file.
ReadFile(path string) ([]byte, error)
// MkdirAll creates a directory and all parents with the given permission.
MkdirAll(path string, perm fs.FileMode) error
// CreateFile creates an empty file, creating parent directories as needed.
CreateFile(path string) (afero.File, error)
// WriteFile writes data to a file with the given permission.
WriteFile(path string, data []byte, perm fs.FileMode) error
// Chmod sets the permission mode bits on a path.
Chmod(path string, perm fs.FileMode) error
// Chown sets the owner and group on a path.
Chown(path string, owner string, group string) error
// ApplyACE applies a single access control entry to a path.
ApplyACE(path string, ace ACE) error
// CheckPerm verifies that the file at path has one of the required
// permission modes and, optionally, the expected owner and group.
CheckPerm(path string, requirePerm []fs.FileMode, requiredOwner string, requiredGroup string) error
// VerifyACL checks ACLs and ownership against expectations.
VerifyACL(path string, expected ExpectedACL) (ACLReport, error)
}
// defaultFileSystem implements FileSystem by delegating to existing
// platform-specific implementations.
type defaultFileSystem struct {
afs afero.Fs
ops FilePermsOps
checker *PermsChecker
acl ACLVerifier
}
// FileSystemOption configures a FileSystem created by NewFileSystem.
type FileSystemOption func(*defaultFileSystem)
// WithCmdRunner overrides the command runner used by the permission
// checker. This is useful in tests where the real "stat" command
// cannot be used against an in-memory filesystem.
func WithCmdRunner(runner func(string, ...string) ([]byte, error)) FileSystemOption {
return func(d *defaultFileSystem) {
d.checker.CmdRunner = runner
}
}
// NewFileSystem creates a FileSystem backed by an afero.Fs. It wires up
// the appropriate platform-specific implementations for permission
// operations, permission checking, and ACL verification.
func NewFileSystem(afs afero.Fs, opts ...FileSystemOption) FileSystem {
d := &defaultFileSystem{
afs: afs,
ops: NewDefaultFilePermsOps(afs),
checker: NewPermsChecker(afs),
acl: NewDefaultACLVerifier(afs),
}
for _, opt := range opts {
opt(d)
}
return d
}
func (d *defaultFileSystem) Stat(path string) (fs.FileInfo, error) {
return d.afs.Stat(path)
}
func (d *defaultFileSystem) Exists(path string) (bool, error) {
return afero.Exists(d.afs, path)
}
func (d *defaultFileSystem) Open(path string) (afero.File, error) {
return d.afs.Open(path)
}
func (d *defaultFileSystem) ReadFile(path string) ([]byte, error) {
return afero.ReadFile(d.afs, path)
}
func (d *defaultFileSystem) MkdirAll(path string, perm fs.FileMode) error {
return d.ops.MkdirAllWithPerm(path, perm)
}
func (d *defaultFileSystem) CreateFile(path string) (afero.File, error) {
return d.ops.CreateFileWithPerm(path)
}
func (d *defaultFileSystem) WriteFile(path string, data []byte, perm fs.FileMode) error {
return d.ops.WriteFileWithPerm(path, data, perm)
}
func (d *defaultFileSystem) Chmod(path string, perm fs.FileMode) error {
return d.ops.Chmod(path, perm)
}
func (d *defaultFileSystem) Chown(path string, owner string, group string) error {
return d.ops.Chown(path, owner, group)
}
func (d *defaultFileSystem) ApplyACE(path string, ace ACE) error {
return d.ops.ApplyACE(path, ace)
}
func (d *defaultFileSystem) CheckPerm(path string, requirePerm []fs.FileMode, requiredOwner string, requiredGroup string) error {
return d.checker.CheckPerm(path, requirePerm, requiredOwner, requiredGroup)
}
func (d *defaultFileSystem) VerifyACL(path string, expected ExpectedACL) (ACLReport, error) {
return d.acl.VerifyACL(path, expected)
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package files
import (
"fmt"
"io/fs"
"runtime"
)
// PermInfo describes the expected filesystem permissions for a given resource
// type used by opkssh. It centralises mode, ownership, and existence
// requirements so that they are defined once and consumed by permission
// checking, fixing and auditing code.
type PermInfo struct {
// Mode is the expected Unix permission bits (e.g. 0o640, 0o600, 0o750).
Mode fs.FileMode
// Owner is the expected owner of the file/directory (e.g. "root" on
// Linux, "Administrators" on Windows). An empty string means ownership
// is not checked/enforced.
Owner string
// Group is the expected group owner (e.g. "opksshuser"). An empty
// string means group ownership is not checked/enforced.
Group string
// MustExist indicates whether the resource is required to exist for the
// system to function correctly.
MustExist bool
}
// String returns a human-readable summary of the permission info.
func (p PermInfo) String() string {
s := fmt.Sprintf("mode=%o", p.Mode)
if p.Owner != "" {
s += " owner=" + p.Owner
}
if p.Group != "" {
s += " group=" + p.Group
}
if p.MustExist {
s += " (required)"
}
return s
}
// ExpectedACLFromPerm builds an ExpectedACL from a PermInfo.
func ExpectedACLFromPerm(pi PermInfo) ExpectedACL {
ea := ExpectedACL{
Owner: pi.Owner,
Mode: pi.Mode,
}
if runtime.GOOS == "windows" {
if pi.Owner != "" {
ea.RequiredACEs = append(ea.RequiredACEs, ExpectedACE{
Principal: pi.Owner, Rights: "GENERIC_ALL", Type: "allow",
})
}
if pi.Group != "" {
ea.RequiredACEs = append(ea.RequiredACEs, ExpectedACE{
Principal: pi.Group, Rights: "GENERIC_READ", Type: "allow",
})
}
}
return ea
}
//go:build !windows
// +build !windows
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package files
import (
"fmt"
"io/fs"
"strings"
)
// CheckPerm checks the file at the given path if it has the desired permissions.
// The argument requirePerm is a list to enable the caller to specify multiple
// permissions only one of which needs to match the permissions on the file.
// If the requiredOwner or requiredGroup are not empty then the function will also
// that the owner and group of the file match the requiredOwner and requiredGroup
// specified and fail if they do not.
func (u *PermsChecker) CheckPerm(path string, requirePerm []fs.FileMode, requiredOwner string, requiredGroup string) error {
fileInfo, err := u.Fs.Stat(path)
if err != nil {
return fmt.Errorf("failed to describe the file at path: %w", err)
}
mode := fileInfo.Mode()
// if the requiredOwner or requiredGroup are specified then run stat and check if they match
if requiredOwner != "" || requiredGroup != "" {
statOutput, err := u.CmdRunner("stat", "-c", "%U %G", path)
if err != nil {
return fmt.Errorf("failed to run stat: %w", err)
}
statOutputSplit := strings.Split(strings.TrimSpace(string(statOutput)), " ")
statOwner := statOutputSplit[0]
statGroup := statOutputSplit[1]
if len(statOutputSplit) != 2 {
return fmt.Errorf("expected stat command to return 2 values got %d", len(statOutputSplit))
}
if requiredOwner != "" {
if requiredOwner != statOwner {
return fmt.Errorf("expected owner (%s), got (%s)", requiredOwner, statOwner)
}
}
if requiredGroup != "" {
if requiredGroup != statGroup {
return fmt.Errorf("expected group (%s), got (%s)", requiredGroup, statGroup)
}
}
}
permMatch := false
requiredPermString := []string{}
for _, p := range requirePerm {
requiredPermString = append(requiredPermString, fmt.Sprintf("%o", p.Perm()))
if mode.Perm() == p {
permMatch = true
}
}
if !permMatch {
return fmt.Errorf("expected one of the following permissions [%s], got (%o)", strings.Join(requiredPermString, ", "), mode.Perm())
}
return nil
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package files
import (
"io/fs"
"os/exec"
"github.com/spf13/afero"
)
// ModeSystemPerms is the expected permission bits that should be set for opkssh
// system policy files (on Unix: /etc/opk/auth_id, /etc/opk/providers; on Windows: %ProgramData%\opk\auth_id, %ProgramData%\opk\providers).
// This mode means that only the owner of the file can write/read to the file, but the group which
// should be opksshuser can read the file.
const ModeSystemPerms = fs.FileMode(0o640)
// ModeHomePerms is the expected permission bits that should be set for opkssh
// user home policy files `~/.opk/auth_id`.
const ModeHomePerms = fs.FileMode(0o600)
// PermsChecker contains methods to check the ownership, group
// and file permissions of a file on a Unix-like system (or Windows).
type PermsChecker struct {
Fs afero.Fs
CmdRunner func(string, ...string) ([]byte, error)
}
func NewPermsChecker(fs afero.Fs) *PermsChecker {
return &PermsChecker{Fs: fs, CmdRunner: ExecCmd}
}
func ExecCmd(name string, arg ...string) ([]byte, error) {
cmd := exec.Command(name, arg...)
return cmd.CombinedOutput()
}
//go:build !windows
// +build !windows
package files
import "fmt"
// ConvertSidToString stub for non-Windows platforms.
func ConvertSidToString(sid []byte) (string, error) {
return "", fmt.Errorf("ConvertSidToString is only supported on Windows")
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package files
import (
"log"
"strings"
"github.com/kballard/go-shellquote"
)
type Table struct {
rows [][]string
}
// NewTable creates a new Table from the given content.
func NewTable(content []byte) *Table {
// Strip UTF-8 BOM if present
if len(content) >= 3 && content[0] == 0xEF && content[1] == 0xBB && content[2] == 0xBF {
content = content[3:]
}
table := [][]string{}
rows := strings.Split(string(content), "\n")
for _, row := range rows {
row := CleanRow(row)
if row == "" {
continue
}
columns, err := shellquote.Split(row)
if err != nil {
log.Printf("Unable to parse: %s. (%s), skipping...\n", row, err)
continue
}
table = append(table, columns)
}
return &Table{rows: table}
}
func CleanRow(row string) string {
// Remove comments
rowFixed := strings.Split(row, "#")[0]
// Skip empty rows
rowFixed = strings.TrimSpace(rowFixed)
return rowFixed
}
func (t *Table) AddRow(row ...string) {
t.rows = append(t.rows, row)
}
func (t Table) ToString() string {
var sb strings.Builder
for _, row := range t.rows {
sb.WriteString(shellquote.Join(row...) + "\n")
}
return sb.String()
}
func (t Table) ToBytes() []byte {
return []byte(t.ToString())
}
func (t Table) GetRows() [][]string {
return t.rows
}
type RowDetails struct {
Columns []string
Content string
Empty bool
Error error
}
// ReadRowsWithDetails reads rows from content, returning any parsing errors.
// Useful for auditing and finding configuration problems.
func ReadRowsWithDetails(content []byte) []RowDetails {
tableDetails := []RowDetails{}
rows := strings.Split(string(content), "\n")
for _, rowContent := range rows {
row := CleanRow(rowContent)
if row == "" {
tableDetails = append(tableDetails,
RowDetails{
Empty: true,
Content: rowContent,
})
continue
}
columns, err := shellquote.Split(row)
if err != nil {
tableDetails = append(tableDetails, RowDetails{
Error: err,
Content: rowContent,
})
log.Printf("Unable to parse: %s. (%s), skipping...\n", row, err)
continue
}
tableDetails = append(tableDetails, RowDetails{
Columns: columns,
Content: rowContent,
})
}
return tableDetails
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package policy
import (
"errors"
"log"
"strings"
)
var _ Loader = &MultiPolicyLoader{
LoaderScript: ReadWithSudoScript,
}
// FileSource implements policy.Source by returning a string that is expected to
// be a filepath
type FileSource string
func (s FileSource) Source() string {
return string(s)
}
func NewMultiPolicyLoader(username string, loader OptionalLoader) *MultiPolicyLoader {
return &MultiPolicyLoader{
HomePolicyLoader: NewHomePolicyLoader(),
SystemPolicyLoader: NewSystemPolicyLoader(),
LoaderScript: loader,
Username: username,
}
}
// MultiPolicyLoader implements policy.Loader by reading both the system default
// policy (root policy) and user policy (~/.opk/auth_id where ~ maps to
// Username's home directory)
type MultiPolicyLoader struct {
HomePolicyLoader *HomePolicyLoader
SystemPolicyLoader *SystemPolicyLoader
LoaderScript OptionalLoader
Username string
}
func (l *MultiPolicyLoader) Load() (*Policy, Source, error) {
policy := new(Policy)
// Try to load the root policy
rootPolicy, _, rootPolicyErr := l.SystemPolicyLoader.LoadSystemPolicy()
if rootPolicyErr != nil {
log.Println("warning: failed to load system default policy:", rootPolicyErr)
}
// Try to load the user policy
userPolicy, userPolicyFilePath, userPolicyErr := l.HomePolicyLoader.LoadHomePolicy(l.Username, true, l.LoaderScript)
if userPolicyErr != nil {
log.Println("warning: failed to load user policy:", userPolicyErr)
}
// Log warning if no error loading, but userPolicy is empty meaning that
// there are no valid entries
if userPolicyErr == nil && len(userPolicy.Users) == 0 {
log.Printf("warning: user policy %s has no valid user entries; an entry is considered valid if it gives %s access.", userPolicyFilePath, l.Username)
}
// Failed to read both policies. Return multi-error
if rootPolicy == nil && userPolicy == nil {
return nil, EmptySource{}, errors.Join(rootPolicyErr, userPolicyErr)
}
// TODO-Yuval: Optimize by merging duplicate entries instead of blindly
// appending
readPaths := []string{}
if rootPolicy != nil {
policy.Users = append(policy.Users, rootPolicy.Users...)
readPaths = append(readPaths, SystemDefaultPolicyPath)
}
if userPolicy != nil {
policy.Users = append(policy.Users, userPolicy.Users...)
readPaths = append(readPaths, userPolicyFilePath)
}
return policy, FileSource(strings.Join(readPaths, ", ")), nil
}
// ReadWithSudoScript is defined in platform-specific files:
// - multipolicyloader_unix.go for Unix/Linux systems (uses sudo)
// - multipolicyloader_windows.go for Windows (no sudo available)
//go:build !windows
// +build !windows
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package policy
import (
"fmt"
"os"
"os/exec"
)
// ReadWithSudoScript specifies additional way of loading the policy in the
// user's home directory (`~/.opk/auth_id`). This is needed when the
// AuthorizedKeysCommand user does not have privileges to transverse the user's
// home directory. Instead we call run a command which uses special
// sudoers permissions to read the policy file.
//
// Doing this is more secure than simply giving opkssh sudoer access because
// if there was an RCE in opkssh could be triggered an SSH request via
// AuthorizedKeysCommand, the new opkssh process we use to perform the read
// would not be compromised. Thus, the compromised opkssh process could not assume
// full root privileges.
func ReadWithSudoScript(h *HomePolicyLoader, username string) ([]byte, error) {
// opkssh readhome ensures the file is not a symlink and has the permissions/ownership.
// The default path is /usr/local/bin/opkssh
opkBin, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("error getting opkssh executable path: %w", err)
}
cmd := exec.Command("sudo", "-n", opkBin, "readhome", username)
homePolicyFileBytes, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("error reading %s home policy using command %v got output %v and err %v", username, cmd, string(homePolicyFileBytes), err)
}
return homePolicyFileBytes, nil
}
//go:build !windows
// +build !windows
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package policy
// GetSystemConfigBasePath returns the base path for system opkssh configuration.
// On Unix-like systems, this is /etc/opk
func GetSystemConfigBasePath() string {
return "/etc/opk"
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package plugins
import (
"bytes"
"encoding/base64"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/kballard/go-shellquote"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/opkssh/policy/files"
"github.com/spf13/afero"
"gopkg.in/yaml.v3"
)
const requiredPolicyPerms = fs.FileMode(0640)
var requiredPolicyDirPerms = []fs.FileMode{fs.FileMode(0700), fs.FileMode(0750), fs.FileMode(0755)}
var requiredPolicyCmdPerms = []fs.FileMode{fs.FileMode(0555), fs.FileMode(0755)}
// RequiredPolicyDirPerms returns the list of acceptable directory permission
// modes for the policy plugin directory. Exported for use by external code.
func RequiredPolicyDirPerms() []fs.FileMode {
return requiredPolicyDirPerms
}
type PluginResult struct {
Path string
PluginConfig PluginConfig
Error error
CommandRun []string
PolicyOutput string
Allowed bool
}
type PluginResults []*PluginResult
func (r PluginResults) Errors() (errs []error) {
for _, pluginResult := range r {
if pluginResult.Error != nil {
errs = append(errs, pluginResult.Error)
}
}
return errs
}
func (r PluginResults) Allowed() bool {
for _, pluginResult := range r {
if pluginResult.Allowed {
if pluginResult.PolicyOutput != "allow" {
// This uses a double-entry bookkeeping approach to catch
// security critical bugs.
// Allowed is only set to true if the policy plugin command
// returns exactly "allow" and we set PolicyOutput to the
// value that the policy plugin command returned. Thus if
// (PolicyOutput != "allow") AND (Allowed == true) something
// went epically wrong and we should panic.
// This should never happen.
panic(fmt.Sprintf("Danger!!! Policy plugin command (%s) returned 'allow' but the plugin command did not approve. If you encounter this, report this as a vulnerability.", pluginResult.Path))
}
return true
}
}
return false
}
type CmdExecutor func(name string, arg ...string) ([]byte, error)
func DefaultCmdExecutor(name string, arg ...string) ([]byte, error) {
return exec.Command(name, arg...).CombinedOutput()
}
type PolicyPluginEnforcer struct {
Fs afero.Fs
cmdExecutor CmdExecutor // This lets us mock command exec in unit tests
permChecker files.PermsChecker
}
func NewPolicyPluginEnforcer() *PolicyPluginEnforcer {
fs := afero.NewOsFs()
return &PolicyPluginEnforcer{
Fs: fs,
cmdExecutor: DefaultCmdExecutor,
permChecker: files.PermsChecker{
Fs: fs,
CmdRunner: files.ExecCmd,
},
}
}
// loadPlugins loads the plugin config files from the given directory.
func (p *PolicyPluginEnforcer) loadPlugins(dir string) (pluginResults PluginResults, err error) {
// Ensure the /opk/ssh/policy.d can only be written by root
if err := p.permChecker.CheckPerm(dir, requiredPolicyDirPerms, "root", ""); err != nil {
return nil, fmt.Errorf("policy plugin directory (%s) has insecure permissions: %w", dir, err)
}
filesFound, err := afero.ReadDir(p.Fs, dir)
if err != nil {
return nil, err
}
for _, entry := range filesFound {
path := filepath.Join(dir, entry.Name())
info, err := p.Fs.Stat(path)
if err != nil {
return nil, err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".yml") {
pluginResult := &PluginResult{}
pluginResults = append(pluginResults, pluginResult)
pluginResult.Path = path
if err := p.permChecker.CheckPerm(path, []fs.FileMode{requiredPolicyPerms}, "root", ""); err != nil {
pluginResult.Error = fmt.Errorf("policy plugin config file (%s) has insecure permissions: %w", path, err)
continue
}
file, err := afero.ReadFile(p.Fs, path)
if err != nil {
pluginResult.Error = fmt.Errorf("failed to read policy plugin config at (%s): %w", path, err)
continue
}
var cmd PluginConfig
if err := yaml.Unmarshal(file, &cmd); err != nil {
pluginResult.Error = fmt.Errorf("failed to parse YAML in policy plugin config at (%s): %w", path, err)
continue
}
if cmd.Name == "" {
pluginResult.Error = fmt.Errorf("policy plugin config missing required field 'name' in policy plugin config at (%s)", path)
continue
}
if cmd.Command == "" {
pluginResult.Error = fmt.Errorf("policy plugin config missing required field 'command' in policy plugin config at (%s): ", path)
continue
}
pluginResult.PluginConfig = cmd
}
}
return pluginResults, nil
}
// CheckPolicies loads the policies plugin configs in the directory dir
// and then runs the policy command specified in which policy plugin config
// to determine if the user is allowed to assume access as the given principal.
// It returns PluginResults for each plugin configs found in the policy
// plugin directory.
//
// Run PluginResults.Allowed() to determine if the user is allowed to
// assume access.
//
// CheckPolicies does not short circuit if a policy returns allow. This is to
// enable admins to do a test rollout of a new policy plugin without needing to
// disable the old policy plugin until they are sure the new policy plugin is
// working correctly.
func (p *PolicyPluginEnforcer) CheckPolicies(dir string, pkt *pktoken.PKToken, userInfoJson string, principal string, sshCert string, keyType string, extraArgs []string) (PluginResults, error) {
tokens, err := PopulatePluginEnvVars(pkt, userInfoJson, principal, sshCert, keyType, extraArgs)
if err != nil {
return nil, err
}
return p.checkPolicies(dir, tokens)
}
func (p *PolicyPluginEnforcer) checkPolicies(dir string, tokens map[string]string) (PluginResults, error) {
pluginResults, err := p.loadPlugins(dir)
if err != nil {
return nil, fmt.Errorf("failed to load policy commands: %w", err)
}
for _, pluginResult := range pluginResults {
// Only run the command in the plugin config if there was no error loading the plugin config
if pluginResult.Error == nil {
commandRun, output, err := p.executePolicyCommand(pluginResult.PluginConfig, tokens)
output = bytes.TrimSpace(output)
pluginResult.Error = err
pluginResult.PolicyOutput = string(output)
pluginResult.CommandRun = commandRun
if err != nil {
pluginResult.Error = fmt.Errorf("failed to run policy command %s got error (%w)", pluginResult.PluginConfig.Command, err)
continue
} else if string(output) != "allow" {
pluginResult.Allowed = false
} else {
pluginResult.Allowed = true
}
}
}
return pluginResults, nil
}
// executePolicyCommand executes the policy command with the provided tokens.
func (p *PolicyPluginEnforcer) executePolicyCommand(config PluginConfig, inputEnvVars map[string]string) ([]string, []byte, error) {
// Add PluginConfig to the tokens map for expansion
configJson, err := yaml.Marshal(config)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal config to JSON: %w", err)
}
inputEnvVars["OPKSSH_PLUGIN_CONFIG"] = base64.StdEncoding.EncodeToString(configJson)
// Ensure we don't use any environment variables as an input to
// the policy plugin command that this process inherited. We only
// want to pass values we set ourselves.
for _, envVar := range os.Environ() {
if strings.HasPrefix(envVar, "OPKSSH_PLUGIN_") {
os.Unsetenv(strings.Split(envVar, "=")[0])
}
}
for envK, envV := range inputEnvVars {
if err := os.Setenv(envK, envV); err != nil {
return nil, nil, fmt.Errorf("failed to set environment variable %s: %w", envK, err)
}
}
command, err := shellquote.Split(config.Command)
if err != nil {
return nil, nil, err
}
if err := p.permChecker.CheckPerm(command[0], requiredPolicyCmdPerms, "root", ""); err != nil {
if strings.Contains(err.Error(), "file does not exist") {
return nil, nil, err
} else {
return nil, nil, fmt.Errorf("policy plugin command (%s) has insecure permissions: %w", command[0], err)
}
}
output, err := p.cmdExecutor(command[0], command[1:]...)
return command, output, err
}
// b64 is a simple helper function to base64 encode a string.
func b64(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package plugins
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/openpubkey/openpubkey/pktoken"
)
func PopulatePluginEnvVars(pkt *pktoken.PKToken, userInfoJson string, principal string, sshCert string, keyType string, extraArgs []string) (map[string]string, error) {
pktCom, err := pkt.Compact()
if err != nil {
return nil, err
}
cicClaims, err := pkt.GetCicValues()
if err != nil {
return nil, err
}
upkJwk := cicClaims.PublicKey()
upkJson, err := json.Marshal(upkJwk)
if err != nil {
return nil, err
}
upkB64 := base64.StdEncoding.EncodeToString(upkJson)
type Claims struct {
Issuer string `json:"iss"`
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified *bool `json:"email_verified"`
Aud Audience `json:"aud"`
Exp *int64 `json:"exp"`
Nbf *int64 `json:"nbf"`
Iat *int64 `json:"iat"`
Jti string `json:"jti"`
Groups *[]string `json:"groups"`
}
var claims Claims
if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
return nil, fmt.Errorf("error unmarshalling pk token payload: %w", err)
}
groupsStr := ""
if claims.Groups != nil {
groupsStr = fmt.Sprintf(`["%s"]`, strings.Join(*claims.Groups, `","`))
}
emailVerifiedStr := ""
if claims.EmailVerified != nil {
emailVerifiedStr = fmt.Sprintf("%t", *claims.EmailVerified)
}
expStr := ""
if claims.Exp != nil {
expStr = fmt.Sprintf("%d", *claims.Exp)
}
nbfStr := ""
if claims.Nbf != nil {
nbfStr = fmt.Sprintf("%d", *claims.Nbf)
}
iatStr := ""
if claims.Iat != nil {
iatStr = fmt.Sprintf("%d", *claims.Iat)
}
extraArgsStr := ""
if len(extraArgs) > 0 {
extraArgsJson, err := json.Marshal(extraArgs)
if err != nil {
return nil, err
}
extraArgsStr = string(extraArgsJson)
}
tokens := map[string]string{
"OPKSSH_PLUGIN_U": principal,
"OPKSSH_PLUGIN_K": sshCert,
"OPKSSH_PLUGIN_T": keyType,
"OPKSSH_PLUGIN_ISS": claims.Issuer,
"OPKSSH_PLUGIN_SUB": claims.Sub,
"OPKSSH_PLUGIN_EMAIL": claims.Email,
"OPKSSH_PLUGIN_EMAIL_VERIFIED": emailVerifiedStr,
"OPKSSH_PLUGIN_AUD": string(claims.Aud),
"OPKSSH_PLUGIN_EXP": expStr,
"OPKSSH_PLUGIN_NBF": nbfStr,
"OPKSSH_PLUGIN_IAT": iatStr,
"OPKSSH_PLUGIN_JTI": claims.Jti,
"OPKSSH_PLUGIN_GROUPS": groupsStr,
"OPKSSH_PLUGIN_PAYLOAD": string(b64(string(pkt.Payload))), // base64-encoded ID Token payload
"OPKSSH_PLUGIN_UPK": string(upkB64), // base64-encoded JWK of the user's public key
"OPKSSH_PLUGIN_PKT": string(pktCom), // compact-encoded PK Token
"OPKSSH_PLUGIN_IDT": string(pkt.OpToken), // base64-encoded ID Token
"OPKSSH_PLUGIN_USERINFO": userInfoJson, // what the userinfo endpoint returned if an access token was supplied (by default this the empty string)
"OPKSSH_PLUGIN_EXTRA_ARGS": extraArgsStr,
}
return tokens, nil
}
type Audience string
func (a *Audience) UnmarshalJSON(data []byte) error {
var multi []string
if err := json.Unmarshal(data, &multi); err == nil {
*a = Audience(`["` + strings.Join(multi, `","`) + `"]`)
return nil
}
var single string
if err := json.Unmarshal(data, &single); err == nil {
*a = Audience(single)
return nil
} else {
return err
}
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package policy
import (
"fmt"
"log"
"strings"
"github.com/openpubkey/opkssh/policy/files"
)
// User is an opkssh policy user entry
type User struct {
// IdentityAttribute is a string that is either structured or unstructured.
// Structured: <IdentityProtocolMatching>:<Attribute>:<Value>
// E.g. `oidc:groups:ssh-users`
// Using the structured identifier allows the capability of constructing
// complex user matchers.
//
// Unstructured:
// This is older version that only works with OIDC Identity Tokens, with
// the claim being `email` or `sub`. The expected value is to be the user's
// email or the user's subscriber ID. The expected value used when comparing
// against an id_token's email claim Subscriber ID is a unique identifier
// for the user at the OpenID Provider
IdentityAttribute string
// Principals is a list of allowed principals
Principals []string
// Sub string
Issuer string
}
// Policy represents an opkssh policy
type Policy struct {
// Users is a list of all user entries in the policy
Users []User
}
// FromTable decodes whitespace delimited input into policy.Policy.
// Any problems encountered during parsing are returned. When verifying,
// these problems should be ignored so that a error on one line does not
// prevent all users from logging in.
func FromTable(input []byte, path string) (*Policy, []files.ConfigProblem) {
problems := []files.ConfigProblem{}
table := files.NewTable(input)
policy := &Policy{}
for _, row := range table.GetRows() {
// Error should not break everyone's ability to login, skip those rows
if len(row) != 3 {
configProblem := files.ConfigProblem{
Filepath: path,
OffendingLine: strings.Join(row, " "),
ErrorMessage: fmt.Sprintf("wrong number of arguments (expected=3, got=%d)", len(row)),
Source: "user policy file",
}
problems = append(problems, configProblem)
files.ConfigProblems().RecordProblem(configProblem)
continue
}
user := User{
Principals: []string{row[0]},
IdentityAttribute: row[1],
Issuer: row[2],
}
policy.Users = append(policy.Users, user)
}
return policy, problems
}
// AddAllowedPrincipal adds a new allowed principal to the user whose email is
// equal to userEmail. If no user can be found with the email userEmail, then a
// new user entry is added with an initial allowed principals list containing
// principal. No changes are made if the principal is already allowed for this
// user.
func (p *Policy) AddAllowedPrincipal(principal string, userEmail string, issuer string) {
var firstMatchingEntry *User // First entry that matches on userEmail AND issuer
for i := range p.Users {
// Search to see if the current user already has an entry that matches on userEmail AND issuer
user := &p.Users[i]
if user.IdentityAttribute == userEmail && user.Issuer == issuer {
if firstMatchingEntry == nil {
firstMatchingEntry = user
}
for _, p := range user.Principals {
if p == principal {
// If we find an entry that matches on userEmail AND issuer AND principal, nothing to add
log.Printf("User with email %s already has access under the principal %s, skipping...\n", userEmail, principal)
return // return early, attempting to add a duplicate policy, a policy which already exists
}
}
}
}
if firstMatchingEntry != nil {
// If we are here, then we found an entry where userEmail and user.Issuer match, but not the principal.
// Add the principal to that entries list of principals
firstMatchingEntry.Principals = append(firstMatchingEntry.Principals, principal)
log.Printf("Successfully added user with email %s with principal %s to the policy file\n", userEmail, principal)
return // Done, we added the principal to the existing user
}
// If we are here, then there is no row in the policy file that matches
// the userEmail and issuer.
newUser := User{
IdentityAttribute: userEmail,
Principals: []string{principal},
Issuer: issuer,
}
// Add the new user to the list of users in the policy
p.Users = append(p.Users, newUser)
log.Printf("Successfully added user with email %s with principal %s to the policy file\n", userEmail, principal)
}
// ToTable encodes the policy into a whitespace delimited table
func (p *Policy) ToTable() ([]byte, error) {
table := files.Table{}
for _, user := range p.Users {
for _, principal := range user.Principals {
table.AddRow(principal, user.IdentityAttribute, user.Issuer)
}
}
return table.ToBytes(), nil
}
// Source declares the minimal interface to describe the source of a fetched
// opkssh policy (i.e. where the policy is retrieved from)
type Source interface {
// Source returns a string describing the source of an opkssh policy. The
// returned value is empty if there is no information about its source
Source() string
}
var _ Source = &EmptySource{}
// EmptySource implements policy.Source and returns an empty string as the
// source
type EmptySource struct{}
func (EmptySource) Source() string { return "" }
// Loader declares the minimal interface to retrieve an opkssh policy from an
// arbitrary source
type Loader interface {
// Load fetches an opkssh policy and returns information describing its
// source. If an error occurs, all return values are nil except the error
// value
Load() (*Policy, Source, error)
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package policy
import (
"fmt"
"os/user"
"path/filepath"
"github.com/openpubkey/opkssh/policy/files"
"github.com/spf13/afero"
"golang.org/x/exp/slices"
)
// SystemDefaultPolicyPath is the default filepath where opkssh policy is
// defined. On Unix: /etc/opk/auth_id, On Windows: %ProgramData%\opk\auth_id
var SystemDefaultPolicyPath = filepath.Join(GetSystemConfigBasePath(), "auth_id")
// SystemDefaultProvidersPath is the default filepath where opkssh provider
// definitions are configured
var SystemDefaultProvidersPath = filepath.Join(GetSystemConfigBasePath(), "providers")
// UserLookup defines the minimal interface to lookup users on the current
// system
type UserLookup interface {
Lookup(username string) (*user.User, error)
}
// OsUserLookup implements the UserLookup interface by invoking the os/user
// library
type OsUserLookup struct{}
func NewOsUserLookup() UserLookup {
return &OsUserLookup{}
}
func (OsUserLookup) Lookup(username string) (*user.User, error) { return user.Lookup(username) }
// PolicyLoader contains methods to read/write the opkssh policy file from/to an
// arbitrary filesystem. All methods that read policy from the filesystem fail
// and return an error immediately if the permission bits are invalid.
type PolicyLoader struct {
FileLoader files.FileLoader
UserLookup UserLookup
}
func (l PolicyLoader) CreateIfDoesNotExist(path string) error {
return l.FileLoader.CreateIfDoesNotExist(path)
}
// LoadPolicyAtPath validates that the policy file at path exists, can be read
// by the current process, and has the correct permission bits set. Parses the
// contents and returns a policy.Policy if file permissions are valid and
// reading is successful; otherwise returns an error.
func (l *PolicyLoader) LoadPolicyAtPath(path string) (*Policy, error) {
content, err := l.FileLoader.LoadFileAtPath(path)
if err != nil {
return nil, err
}
policy, _ := FromTable(content, path)
return policy, nil
}
// Dump encodes the policy into file and writes the contents to the filepath
// path
func (l *PolicyLoader) Dump(policy *Policy, path string) error {
fileBytes, err := policy.ToTable()
if err != nil {
return err
}
// Write to disk
if err := l.FileLoader.Dump(fileBytes, path); err != nil {
return fmt.Errorf("failed to write to policy file %s: %w", path, err)
}
return nil
}
// NewSystemPolicyLoader returns an opkssh policy loader that uses the os library to
// read/write system policy from/to the filesystem.
func NewSystemPolicyLoader() *SystemPolicyLoader {
return &SystemPolicyLoader{
PolicyLoader: &PolicyLoader{
FileLoader: files.FileLoader{
Fs: afero.NewOsFs(),
RequiredPerm: files.ModeSystemPerms,
},
UserLookup: NewOsUserLookup(),
},
}
}
// SystemPolicyLoader contains methods to read/write the system wide opkssh policy file
// from/to a filesystem. All methods that read policy from the filesystem fail
// and return an error immediately if the permission bits are invalid.
type SystemPolicyLoader struct {
*PolicyLoader
}
// LoadSystemPolicy reads the opkssh policy at SystemDefaultPolicyPath.
// An error is returned if the file cannot be read or if the permissions bits
// are not correct.
func (s *SystemPolicyLoader) LoadSystemPolicy() (*Policy, Source, error) {
policy, err := s.LoadPolicyAtPath(SystemDefaultPolicyPath)
if err != nil {
return nil, EmptySource{}, fmt.Errorf("failed to read system default policy file %s: %w", SystemDefaultPolicyPath, err)
}
return policy, FileSource(SystemDefaultPolicyPath), nil
}
type OptionalLoader func(h *HomePolicyLoader, username string) ([]byte, error)
// HomePolicyLoader contains methods to read/write the opkssh policy file stored in
// `~/.opk/ssh` from/to a filesystem. All methods that read policy from the filesystem fail
// and return an error immediately if the permission bits are invalid.
type HomePolicyLoader struct {
*PolicyLoader
}
// NewHomePolicyLoader returns an opkssh policy loader that uses the os library to
// read/write policy from/to the user's home directory, e.g. `~/.opk/auth_id`,
func NewHomePolicyLoader() *HomePolicyLoader {
return &HomePolicyLoader{
PolicyLoader: &PolicyLoader{
FileLoader: files.FileLoader{
Fs: afero.NewOsFs(),
RequiredPerm: files.ModeHomePerms,
},
UserLookup: NewOsUserLookup(),
},
}
}
// LoadHomePolicy reads the user's opkssh policy at ~/.opk/auth_id (where ~
// maps to username's home directory) and returns the filepath read. An error is
// returned if the file cannot be read, if the permission bits are not correct,
// or if there is no user with username or has no home directory.
//
// If skipInvalidEntries is true, then invalid user entries are skipped and not
// included in the returned policy. A user policy's entry is considered valid if
// it gives username access. The returned policy is stripped of invalid entries.
// To specify an alternative Loader that will be used if we don't have sufficient
// permissions to read the policy file in the user's home directory, pass the
// alternative loader as the last argument.
func (h *HomePolicyLoader) LoadHomePolicy(username string, skipInvalidEntries bool, optLoader ...OptionalLoader) (*Policy, string, error) {
policyFilePath, err := h.UserPolicyPath(username)
if err != nil {
return nil, "", fmt.Errorf("error getting user policy path for user %s: %w", username, err)
}
policyBytes, userPolicyErr := h.FileLoader.LoadFileAtPath(policyFilePath)
if userPolicyErr != nil {
if len(optLoader) == 1 {
// Try to read using the optional loader
policyBytes, err = optLoader[0](h, username)
if err != nil {
return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, err)
}
} else if len(optLoader) > 1 {
return nil, "", fmt.Errorf("only one optional loaders allowed, got %d", len(optLoader))
} else {
return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, userPolicyErr)
}
}
policy, _ := FromTable(policyBytes, policyFilePath)
if skipInvalidEntries {
// Build valid user policy. Ignore user entries that give access to a
// principal not equal to the username where the policy file was read
// from.
validUserPolicy := new(Policy)
for _, user := range policy.Users {
if slices.Contains(user.Principals, username) {
// Build clean entry that only gives access to username
validUserPolicy.Users = append(validUserPolicy.Users, User{
IdentityAttribute: user.IdentityAttribute,
Principals: []string{username},
Issuer: user.Issuer,
})
}
}
return validUserPolicy, policyFilePath, nil
} else {
// Just return what we read
return policy, policyFilePath, nil
}
}
// UserPolicyPath returns the path to the user's opkssh policy file at
// ~/.opk/auth_id (Unix) or %USERPROFILE%\.opk\auth_id (Windows).
func (h *HomePolicyLoader) UserPolicyPath(username string) (string, error) {
user, err := h.UserLookup.Lookup(username)
if err != nil {
return "", fmt.Errorf("failed to lookup username %s: %w", username, err)
}
userHomeDirectory := user.HomeDir
if userHomeDirectory == "" {
return "", fmt.Errorf("user %s does not have a home directory", username)
}
policyFilePath := filepath.Join(userHomeDirectory, ".opk", "auth_id")
return policyFilePath, nil
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package policy
import (
"fmt"
"strings"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/verifier"
"github.com/openpubkey/opkssh/policy/files"
"github.com/spf13/afero"
)
type ProvidersRow struct {
Issuer string
ClientID string
ExpirationPolicy string
}
func (p ProvidersRow) GetExpirationPolicy() (verifier.ExpirationPolicy, error) {
switch p.ExpirationPolicy {
case "12h":
return verifier.ExpirationPolicies.MAX_AGE_12HOURS, nil
case "24h":
return verifier.ExpirationPolicies.MAX_AGE_24HOURS, nil
case "48h":
return verifier.ExpirationPolicies.MAX_AGE_48HOURS, nil
case "1week":
return verifier.ExpirationPolicies.MAX_AGE_1WEEK, nil
case "oidc":
return verifier.ExpirationPolicies.OIDC, nil
case "oidc_refreshed":
return verifier.ExpirationPolicies.OIDC_REFRESHED, nil
case "never":
return verifier.ExpirationPolicies.NEVER_EXPIRE, nil
default:
return verifier.ExpirationPolicy{}, fmt.Errorf("invalid expiration policy: %s", p.ExpirationPolicy)
}
}
func (p ProvidersRow) ToString() string {
return p.Issuer + " " + p.ClientID + " " + p.ExpirationPolicy
}
type ProviderPolicy struct {
rows []ProvidersRow
}
func (p *ProviderPolicy) AddRow(row ProvidersRow) {
p.rows = append(p.rows, row)
}
func (p *ProviderPolicy) GetRows() []ProvidersRow {
return p.rows
}
func (p *ProviderPolicy) CreateVerifier() (*verifier.Verifier, error) {
pvs := []verifier.ProviderVerifier{}
var expirationPolicy verifier.ExpirationPolicy
var err error
for _, row := range p.rows {
var provider verifier.ProviderVerifier
// TODO: We should handle this issuer matching in a more generic way
// oidc.local and localhost: are a test issuers
if row.Issuer == "https://accounts.google.com" ||
strings.HasPrefix(row.Issuer, "http://oidc.local") ||
strings.HasPrefix(row.Issuer, "http://localhost:") {
opts := providers.GetDefaultGoogleOpOptions()
opts.Issuer = row.Issuer
opts.ClientID = row.ClientID
provider = providers.NewGoogleOpWithOptions(opts)
} else if strings.HasPrefix(row.Issuer, "https://login.microsoftonline.com") {
opts := providers.GetDefaultAzureOpOptions()
opts.Issuer = row.Issuer
opts.ClientID = row.ClientID
provider = providers.NewAzureOpWithOptions(opts)
} else if row.Issuer == "https://gitlab.com" {
opts := providers.GetDefaultGitlabOpOptions()
opts.Issuer = row.Issuer
opts.ClientID = row.ClientID
provider = providers.NewGitlabOpWithOptions(opts)
} else if row.Issuer == "https://token.actions.githubusercontent.com" {
provider = providers.NewGithubOp(row.Issuer, "")
} else {
opts := providers.GetDefaultGoogleOpOptions()
opts.Issuer = row.Issuer
opts.ClientID = row.ClientID
provider = providers.NewGoogleOpWithOptions(opts)
}
expirationPolicy, err = row.GetExpirationPolicy()
if err != nil {
return nil, err
}
pv := verifier.ProviderVerifierExpires{
ProviderVerifier: provider,
Expiration: expirationPolicy,
}
pvs = append(pvs, pv)
}
if len(pvs) == 0 {
return nil, fmt.Errorf("no providers configured")
}
pktVerifier, err := verifier.NewFromMany(
pvs,
verifier.WithExpirationPolicy(expirationPolicy),
)
if err != nil {
return nil, err
}
return pktVerifier, nil
}
func (p ProviderPolicy) ToString() string {
var sb strings.Builder
for _, row := range p.rows {
sb.WriteString(row.ToString() + "\n")
}
return sb.String()
}
// ProviderLoader defines the interface for loading provider policies
type ProviderLoader interface {
LoadProviderPolicy(path string) (*ProviderPolicy, error)
}
type ProvidersFileLoader struct {
files.FileLoader
Path string
}
func NewProviderFileLoader() *ProvidersFileLoader {
return &ProvidersFileLoader{
FileLoader: files.FileLoader{
Fs: afero.NewOsFs(),
RequiredPerm: files.ModeSystemPerms,
},
}
}
func (o *ProvidersFileLoader) LoadProviderPolicy(path string) (*ProviderPolicy, error) {
content, err := o.LoadFileAtPath(path)
if err != nil {
return nil, err
}
policy := o.FromTable(content, path)
return policy, nil
}
// FromTable decodes whitespace delimited input into policy.Policy
func (o ProvidersFileLoader) ToTable(opPolicies ProviderPolicy) files.Table {
table := files.Table{}
for _, opPolicy := range opPolicies.rows {
table.AddRow(opPolicy.Issuer, opPolicy.ClientID, opPolicy.ExpirationPolicy)
}
return table
}
// FromTable decodes whitespace delimited input into policy.Policy
// Path is passed only for logging purposes
func (o *ProvidersFileLoader) FromTable(input []byte, path string) *ProviderPolicy {
table := files.NewTable(input)
policy := &ProviderPolicy{
rows: []ProvidersRow{},
}
for _, row := range table.GetRows() {
// Error should not break everyone's ability to login, skip those rows
if len(row) != 3 {
configProblem := files.ConfigProblem{
Filepath: path,
OffendingLine: strings.Join(row, " "),
ErrorMessage: fmt.Sprintf("wrong number of arguments (expected=3, got=%d)", len(row)),
Source: "providers policy file",
}
files.ConfigProblems().RecordProblem(configProblem)
continue
}
policyRow := ProvidersRow{
Issuer: row[0],
ClientID: row[1],
ExpirationPolicy: row[2], // TODO: Validate this so that we can determine the line number that has the error
}
policy.AddRow(policyRow)
}
return policy
}
// Copyright 2025 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package policy
import (
"fmt"
"strings"
)
// ValidationStatus represents the validation result status
type ValidationStatus string
const (
StatusSuccess ValidationStatus = "SUCCESS"
StatusWarning ValidationStatus = "WARNING"
StatusError ValidationStatus = "ERROR"
)
// ValidationRowResult represents the result of validating a single policy entry
type ValidationRowResult struct {
Status ValidationStatus `json:"status"`
Hints []string `json:"hints"`
Principal string `json:"principal"`
IdentityAttr string `json:"identity_attr"`
Issuer string `json:"issuer"`
Reason string `json:"reason"`
LineNumber int `json:"line_number"` // Line number in the policy file (1-indexed)
}
// PolicyValidator validates policy file entries against provider definitions
type PolicyValidator struct {
// issuerMap maps issuer URL to ProvidersRow
issuerMap map[string]ProvidersRow
}
// NewPolicyValidator creates a new PolicyValidator from a ProviderPolicy
func NewPolicyValidator(providerPolicy *ProviderPolicy) *PolicyValidator {
issuerMap := make(map[string]ProvidersRow)
for _, row := range providerPolicy.rows {
issuerMap[row.Issuer] = row
}
return &PolicyValidator{
issuerMap: issuerMap,
}
}
// ValidateEntry validates a single policy entry against the provider definitions
func (v *PolicyValidator) ValidateEntry(principal, identityAttr, issuer string, lineNumber int) ValidationRowResult {
result := ValidationRowResult{
Principal: principal,
IdentityAttr: identityAttr,
Hints: []string{},
Issuer: issuer,
LineNumber: lineNumber,
}
if issuer == "" {
result.Status = StatusError
result.Reason = "issuer is empty"
return result
}
// Check if issuer exists in providers (exact match)
_, exists := v.issuerMap[issuer]
if !exists {
result.Status = StatusError
result.Reason = "issuer not found in /etc/opk/providers"
// issuer in policy file has a trailing slash, but issuer in provider file does not have a trailing slash
if strings.HasSuffix(issuer, "/") {
if almostMatchingIssuer, exists := v.issuerMap[issuer[0:len(issuer)-1]]; exists {
result.Hints = append(result.Hints,
fmt.Sprintf("Remove the trailing slash from the issuer URL (%s) to match provider entry (%s)",
issuer, almostMatchingIssuer.Issuer))
return result
}
}
// issuer in policy file as is http, but issuer in provider is https
httpIssuer := strings.Replace(issuer, "http://", "https://", 1)
if almostMatchingIssuer, exists := v.issuerMap[httpIssuer]; exists {
result.Hints = append(result.Hints,
fmt.Sprintf("Change the scheme http:// of the issuer URL (%s) to match scheme https:// of provider (%s)",
issuer, almostMatchingIssuer.Issuer))
return result
}
// issuer in policy file as is https, but issuer in provider is http
httpsIssuer := strings.Replace(issuer, "https://", "http://", 1)
if almostMatchingIssuer, exists := v.issuerMap[httpsIssuer]; exists {
result.Hints = append(result.Hints,
fmt.Sprintf("Change the scheme https:// of the issuer URL (%s) to match scheme http:// of provider (%s)",
issuer, almostMatchingIssuer.Issuer))
return result
}
result.Hints = append(result.Hints,
fmt.Sprintf("Ensure the issuer URL (%s) is correct and matches an entry in /etc/opk/providers", issuer))
return result
}
if strings.HasSuffix(issuer, "/") {
result.Status = StatusError
result.Reason = fmt.Sprintf("issuer URI (%s) should not have a trailing slash /", issuer)
result.Hints = append(result.Hints, "Remove the trailing slash from the issuer URL in both the policy and provider files")
return result
}
// Issuer exists, entry is valid
result.Status = StatusSuccess
result.Reason = "issuer matches provider entry"
if !strings.HasPrefix(issuer, "https://") {
result.Status = StatusWarning
result.Reason = "issuer does not use https scheme"
result.Hints = append(result.Hints, "It is recommended to use https scheme for issuer URLs")
}
return result
}
// Summary holds aggregated statistics about validation results
type ValidationSummary struct {
TotalTested int
Successful int
Warnings int
Errors int
}
// HasErrors returns true if there are any errors or warnings
func (s *ValidationSummary) HasErrors() bool {
return s.Errors > 0 || s.Warnings > 0
}
// GetExitCode returns the appropriate exit code (0 for success, 1 for errors/warnings)
func (s *ValidationSummary) GetExitCode() int {
if s.HasErrors() {
return 1
}
return 0
}
// CalculateSummary calculates summary statistics from a list of validation results
func CalculateSummary(results []ValidationRowResult) ValidationSummary {
summary := ValidationSummary{
TotalTested: len(results),
}
for _, result := range results {
switch result.Status {
case StatusSuccess:
summary.Successful++
case StatusWarning:
summary.Warnings++
case StatusError:
summary.Errors++
}
}
return summary
}
// Copyright 2024 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package sshcert
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/verifier"
"golang.org/x/crypto/ssh"
)
type SshCertSmuggler struct {
SshCert *ssh.Certificate
}
func New(pkt *pktoken.PKToken, accessToken []byte, principals []string) (*SshCertSmuggler, error) {
// TODO: assumes email exists in ID Token,
// this will break for OPs like Azure that do not have email as a claim
var claims struct {
Email string `json:"email"`
}
if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
return nil, err
}
pubkeySsh, err := sshPubkeyFromPKT(pkt)
if err != nil {
return nil, err
}
pktCom, err := pkt.Compact()
if err != nil {
return nil, err
}
extensions := map[string]string{
"permit-X11-forwarding": "",
"permit-agent-forwarding": "",
"permit-port-forwarding": "",
"permit-pty": "",
"permit-user-rc": "",
"openpubkey-pkt": string(pktCom),
}
if accessToken != nil {
extensions["openpubkey-act"] = string(accessToken)
}
sshSmuggler := SshCertSmuggler{
SshCert: &ssh.Certificate{
Key: pubkeySsh,
CertType: ssh.UserCert,
KeyId: claims.Email,
ValidPrincipals: principals,
ValidBefore: ssh.CertTimeInfinity,
Permissions: ssh.Permissions{
Extensions: extensions,
},
},
}
return &sshSmuggler, nil
}
func NewFromAuthorizedKey(certType string, certB64 string) (*SshCertSmuggler, error) {
if certPubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certType + " " + certB64)); err != nil {
return nil, err
} else {
sshCert, ok := certPubkey.(*ssh.Certificate)
if !ok {
return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate (got type %T, cert type %q)", certPubkey, certType)
}
opkcert := &SshCertSmuggler{
SshCert: sshCert,
}
return opkcert, nil
}
}
func (s *SshCertSmuggler) SignCert(signerMas ssh.MultiAlgorithmSigner) (*ssh.Certificate, error) {
if err := s.SshCert.SignCert(rand.Reader, signerMas); err != nil {
return nil, err
}
return s.SshCert, nil
}
func (s *SshCertSmuggler) VerifyCaSig(caPubkey ssh.PublicKey) error {
certCopy := *(s.SshCert)
certCopy.Signature = nil
certBytes := certCopy.Marshal()
certBytes = certBytes[:len(certBytes)-4] // Drops signature length bytes (see crypto.ssh.certs.go)
return caPubkey.Verify(certBytes, s.SshCert.Signature)
}
func (s *SshCertSmuggler) GetPKToken() (*pktoken.PKToken, error) {
pktCom, ok := s.SshCert.Extensions["openpubkey-pkt"]
if !ok {
return nil, fmt.Errorf("cert is missing required openpubkey-pkt extension")
}
pkt, err := pktoken.NewFromCompact([]byte(pktCom))
if err != nil {
return nil, fmt.Errorf("openpubkey-pkt extension in cert failed deserialization: %w", err)
}
return pkt, nil
}
func (s *SshCertSmuggler) GetAccessToken() string {
// Generally we don't expect this to be set, but if it is, we return it
if accessToken, ok := s.SshCert.Extensions["openpubkey-act"]; ok {
return accessToken
}
return ""
}
func (s *SshCertSmuggler) VerifySshPktCert(ctx context.Context, pktVerifier verifier.Verifier) (*pktoken.PKToken, error) {
pkt, err := s.GetPKToken()
if err != nil {
return nil, fmt.Errorf("openpubkey-pkt extension in cert failed deserialization: %w", err)
}
ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
err = pktVerifier.VerifyPKToken(ctxWithTimeout, pkt)
if err != nil {
return nil, err
}
cic, err := pkt.GetCicValues()
if err != nil {
return nil, err
}
upk, err := jwk.Import(cic.PublicKey())
if err != nil {
return nil, err
}
cryptoCertKey := (s.SshCert.Key.(ssh.CryptoPublicKey)).CryptoPublicKey()
jwkCertKey, err := jwk.Import(cryptoCertKey)
if err != nil {
return nil, err
}
if jwk.Equal(jwkCertKey, upk) {
return pkt, nil
} else {
return nil, fmt.Errorf("public key 'upk' in PK Token does not match public key in certificate")
}
}
func sshPubkeyFromPKT(pkt *pktoken.PKToken) (ssh.PublicKey, error) {
cic, err := pkt.GetCicValues()
if err != nil {
return nil, err
}
upk := cic.PublicKey()
return ssh.NewPublicKey(upk)
}
// Copyright 2026 OpenPubkey
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
package testutil
import (
"fmt"
"os/user"
"github.com/openpubkey/opkssh/policy"
)
// ValidUser is a shared test fixture representing a valid OS user.
var ValidUser = &user.User{HomeDir: "/home/foo", Username: "foo"}
// MockUserLookup implements [policy.UserLookup] for testing.
// - Set [User] for a default user returned on any Lookup call.
// - Set [Error] to force every Lookup call to fail.
type MockUserLookup struct {
// User is returned on any call to Lookup() if Error is nil.
User *user.User
// Error, if non-nil, is returned on any call to Lookup().
Error error
}
var _ policy.UserLookup = &MockUserLookup{}
// Lookup implements [policy.UserLookup].
func (m *MockUserLookup) Lookup(username string) (*user.User, error) {
if m.Error != nil {
return nil, m.Error
}
if m.User != nil {
return m.User, nil
}
return nil, fmt.Errorf("user %q not found", username)
}