diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 44db581..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -testdata/dos-lines eol=crlf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..65791f9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57b9540..5813222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,26 @@ on: [push, pull_request] name: Test +permissions: + contents: read jobs: lint: runs-on: ubuntu-latest steps: - name: Install Go - uses: WillAbides/setup-go-faster@main + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: - go-version: 1.21.x - - uses: actions/checkout@v4 + go-version: 1.26.x + cache: false + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: path: './src/github.com/kevinburke/ssh_config' # staticcheck needs this for GOPATH - run: | - echo "GO111MODULE=off" >> $GITHUB_ENV - echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV - echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV + { + echo "GO111MODULE=on" + echo "GOPATH=$GITHUB_WORKSPACE" + echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" + } >> "$GITHUB_ENV" - name: Run tests run: make lint working-directory: './src/github.com/kevinburke/ssh_config' @@ -23,20 +28,23 @@ jobs: test: strategy: matrix: - go-version: [1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x] + go-version: [1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, 1.23.x, 1.24.x, 1.25.x, 1.26.x] runs-on: ubuntu-latest steps: - name: Install Go - uses: WillAbides/setup-go-faster@main + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: ${{ matrix.go-version }} - - uses: actions/checkout@v4 + cache: false + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: path: './src/github.com/kevinburke/ssh_config' - run: | - echo "GO111MODULE=off" >> $GITHUB_ENV - echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV - echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV + { + echo "GO111MODULE=off" + echo "GOPATH=$GITHUB_WORKSPACE" + echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" + } >> "$GITHUB_ENV" - name: Run tests with race detector on run: make race-test working-directory: './src/github.com/kevinburke/ssh_config' diff --git a/.gitignore b/.gitignore index e69de29..46620af 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +/coverage.out diff --git a/AUTHORS.txt b/AUTHORS.txt index 311aeb1..7510398 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -1,9 +1,13 @@ Carlos A Becker +Claude Opus 4.6 Dustin Spicuzza Eugene Terentev Kevin Burke Mark Nevill +Neil Williams Scott Lessans Sergey Lukjanov +Simon Josefsson Wayne Ashley Berry santosh653 <70637961+santosh653@users.noreply.github.com> +sio2boss diff --git a/CHANGELOG.md b/CHANGELOG.md index d32a3f5..a61e119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,89 @@ # Changes -## Version 1.2 +## Version 1.7 (unreleased) +Update default values to match current openssh-portable (previously based on +OpenSSH 7.4p1 from 2016). + +Breaking changes: + +- Remove `Cipher` default (SSH protocol 1 only, deprecated in openssh-portable) +- Remove `ChallengeResponseAuthentication` default (alias for `KbdInteractiveAuthentication`) +- Remove `CompressionLevel` default (unsupported in openssh-portable) +- Remove `Protocol` default (silently ignored in openssh-portable) +- Remove `RhostsRSAAuthentication` default (SSH protocol 1 only, unsupported) +- Remove `RSAAuthentication` default (SSH protocol 1 only, unsupported) +- Remove `UsePrivilegedPort` default (deprecated in openssh-portable) +- Remove `IdentityFile` default of `~/.ssh/identity` (SSH protocol 1 only) +- Change `CheckHostIP` default from `"yes"` to `"no"` +- Change `UpdateHostKeys` default from `"no"` to `"yes"` +- Change `Ciphers` default to remove CBC ciphers +- Change `KexAlgorithms` default to add post-quantum algorithms and remove SHA1 variants +- Change `HostKeyAlgorithms` default to add sk-*, webauthn-*, rsa-sha2-* and remove ssh-rsa +- Change `HostbasedKeyTypes` default (same as `HostKeyAlgorithms`) +- Change `PubkeyAcceptedKeyTypes` default (same as `HostKeyAlgorithms`) +- Change `ForwardX11Timeout` default from `"20m"` to `"1200"` (same duration, now in seconds) +- Rename `defaultProtocol2Identities` to `defaultIdentityFiles` +- Remove `~/.ssh/id_dsa` from default identity files +- Remove `ForwardAgent` from strict yes/no validation (now also accepts a socket path) +- Remove `CompressionLevel` from uint validation + +Other changes: + +- Add `ControlPersist` default (`"no"`) +- Add `RequestTTY` default (`"auto"`) +- Add `SessionType` default (`"default"`) +- Add `CASignatureAlgorithms` default +- Add `HostbasedAcceptedAlgorithms` default (new name for `HostbasedKeyTypes`) +- Add `PubkeyAcceptedAlgorithms` default (new name for `PubkeyAcceptedKeyTypes`) +- Add `~/.ssh/id_ecdsa_sk` and `~/.ssh/id_ed25519_sk` to default identity files + +## Version 1.6 (released February 16, 2026) + +- Support `~` as the user's home directory in `Include` directives, matching +the behavior described in ssh_config(5). Thanks to Neil Williams for the report +(#31). + +- Strip surrounding double quotes from parsed values. OpenSSH allows values +like `IdentityFile "/path/to/file"`, but Get/GetAll previously returned the +quotes as literal characters. Quotes are now stripped from the returned value +while preserving the original text for faithful roundtripping via String() and +MarshalText(). Thanks to Furkan Türkal for the report (#61). + +- Default to a space before `#` in end-of-line comments. When a Host or KV is +created programmatically with an EOLComment, the output previously had no space +before the `#` (e.g. `Host foo#comment`). A single space is now inserted by +default. Thanks to Yonghui Cheng for the report (#50). + +## Version 1.5 (released February 14, 2026) + +- Implement Match support. Most of the Match spec is implemented, including +`Match host`, `Match originalhost`, `Match user`, `Match localuser`, and `Match +all`. `Match exec` is not yet implemented. + +- Add SECURITY.md + +- Add Dependabot configuration + +## Version 1.4 (released August 19, 2025) + +- Remove .gitattributes file (which was used to test different line endings, and +caused issues in some build environments). Store tests/dos-lines as CRLF in git +directly instead. + +## Version 1.3 (released February 20, 2025) + +- Add go.mod file (although this project has no dependencies). + +- config: add UserSettings.ConfigFinder + +- Various updates to CI and build environment + +## Version 1.2 (released March 31, 2022) + +- config: add DecodeBytes to directly read a byte array. + +- Strip trailing whitespace from Host declarations and key/value pairs. Previously, if a Host declaration or a value had trailing whitespace, that whitespace would have been included as part of the value. This led to unexpected consequences. For example: @@ -17,3 +99,5 @@ unintuitive. Instead, we strip the trailing whitespace in the configuration, which leads to more intuitive behavior. + +- Add fuzz tests. diff --git a/Makefile b/Makefile index 02e15ec..aa946eb 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,10 @@ BUMP_VERSION := $(GOPATH)/bin/bump_version -STATICCHECK := $(GOPATH)/bin/staticcheck WRITE_MAILMAP := $(GOPATH)/bin/write_mailmap -$(STATICCHECK): - go get honnef.co/go/tools/cmd/staticcheck - -lint: $(STATICCHECK) +lint: go vet ./... - $(STATICCHECK) + go run honnef.co/go/tools/cmd/staticcheck@latest ./... + go run github.com/kevinburke/differ@latest gofmt -s -w . test: @# the timeout helps guard against infinite recursion @@ -16,6 +13,10 @@ test: race-test: go test -timeout=500ms -race ./... +coverage: + go test -trimpath -timeout=250ms -coverprofile=coverage.out -covermode=atomic ./... + go tool cover -func=coverage.out + $(BUMP_VERSION): go get -u github.com/kevinburke/bump_version diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..adc2c5a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,63 @@ +# ssh_config security policy + +## Supported Versions + +As of September 2025, we're not aware of any security problems with ssh_config, +past or present. That said, we recommend always using the latest version of +ssh_config, and of the Go programming language, to ensure you have the most +recent security fixes. + +## Reporting a Vulnerability + +We take security vulnerabilities seriously. If you discover a security vulnerability in ssh_config, please report it responsibly by following these steps: + +### How to Report + +Please follow the instructions outlined here to report a vulnerability +privately: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability + +If these are insufficient - it is not hard to find Kevin's contact information +on the Internet. + +### What to Include + +When reporting a vulnerability, please include a clear description of the vulnerability, steps to reproduce the issue, the potential impact, as well as any fixes you might have. + +### Response Timeline + +I'll try to acknowledge and patch the issue as quickly as possible. + +Security advisories for this project will be published through: +- GitHub Security Advisories on this repository +- an Issue on this repository +- The project's release notes +- Go vulnerability databases + +If you are using `ssh_config` and would like to be on a "pre-release" +distribution list for coordinating releases, please contact Kevin directly. + +### Security Considerations + +When using ssh_config, please be aware of these security considerations. + +#### File System Access + +This library reads SSH configuration files from the file system. Try to ensure +proper file permissions on SSH config files (typically 600 or 644), and be +cautious when parsing config files from untrusted sources. + +#### Input Validation + +The parser handles user-provided SSH configuration data. While we try our best +to parse the data appropriately, malformed configuration files could potentially +cause issues. Please try to validate and sanitize any configuration data from +external sources. + +#### Dependencies + +This project does not have any third party dependencies. Please try to keep your +Go version up to date. + +## Acknowledgments + +We appreciate security researchers and users who responsibly disclose vulnerabilities. Contributors who report valid security issues will be acknowledged in our security advisories (unless they prefer to remain anonymous). diff --git a/config.go b/config.go index 4816e67..6365b41 100644 --- a/config.go +++ b/config.go @@ -8,7 +8,7 @@ // the host name to match on ("example.com"), and the second argument is the key // you want to retrieve ("Port"). The keywords are case insensitive. // -// port := ssh_config.Get("myhost", "Port") +// port := ssh_config.Get("myhost", "Port") // // You can also manipulate an SSH config file and then print it or write it back // to disk. @@ -24,9 +24,6 @@ // // // Write the cfg back to disk: // fmt.Println(cfg.String()) -// -// BUG: the Match directive is currently unsupported; parsing a config with -// a Match directive will trigger an error. package ssh_config import ( @@ -43,7 +40,7 @@ import ( "sync" ) -const version = "1.2" +const version = "1.6.0" var _ = version @@ -388,9 +385,6 @@ func (c *Config) Get(alias, key string) (string, error) { case *KV: // "keys are case insensitive" per the spec lkey := strings.ToLower(t.Key) - if lkey == "match" { - panic("can't handle Match directives") - } if lkey == lowerKey { return t.Value, nil } @@ -423,9 +417,6 @@ func (c *Config) GetAll(alias, key string) ([]string, error) { case *KV: // "keys are case insensitive" per the spec lkey := strings.ToLower(t.Key) - if lkey == "match" { - panic("can't handle Match directives") - } if lkey == lowerKey { all = append(all, t.Value) } @@ -470,6 +461,9 @@ type Pattern struct { // String prints the string representation of the pattern. func (p Pattern) String() string { + if p.not { + return "!" + p.str + } return p.str } @@ -528,7 +522,7 @@ func NewPattern(s string) (*Pattern, error) { return &Pattern{str: s, regex: r, not: negated}, nil } -// Host describes a Host directive and the keywords that follow it. +// Host describes a Host or Match directive and the keywords that follow it. type Host struct { // A list of host patterns that should match this host. Patterns []*Pattern @@ -543,6 +537,11 @@ type Host struct { leadingSpace int // TODO: handle spaces vs tabs here. // The file starts with an implicit "Host *" declaration. implicit bool + // isMatch is true if this block was created by a Match directive. + isMatch bool + // matchKeyword stores the original text after "Match" (e.g. "Host" or + // "all") so we can round-trip correctly. + matchKeyword string } // Matches returns true if the Host matches for the given alias. For @@ -574,20 +573,43 @@ func (h *Host) String() string { //lint:ignore S1002 I prefer to write it this way if h.implicit == false { buf.WriteString(strings.Repeat(" ", int(h.leadingSpace))) - buf.WriteString("Host") - if h.hasEquals { - buf.WriteString(" = ") + if h.isMatch { + buf.WriteString("Match") + if h.hasEquals { + buf.WriteString(" = ") + } else { + buf.WriteString(" ") + } + buf.WriteString(h.matchKeyword) + if !strings.EqualFold(h.matchKeyword, "all") { + buf.WriteString(" ") + for i, pat := range h.Patterns { + buf.WriteString(pat.String()) + if i < len(h.Patterns)-1 { + buf.WriteString(" ") + } + } + } } else { - buf.WriteString(" ") - } - for i, pat := range h.Patterns { - buf.WriteString(pat.String()) - if i < len(h.Patterns)-1 { + buf.WriteString("Host") + if h.hasEquals { + buf.WriteString(" = ") + } else { buf.WriteString(" ") } + for i, pat := range h.Patterns { + buf.WriteString(pat.String()) + if i < len(h.Patterns)-1 { + buf.WriteString(" ") + } + } } - buf.WriteString(h.spaceBeforeComment) if h.EOLComment != "" { + if h.spaceBeforeComment != "" { + buf.WriteString(h.spaceBeforeComment) + } else { + buf.WriteByte(' ') + } buf.WriteByte('#') buf.WriteString(h.EOLComment) } @@ -617,6 +639,9 @@ type KV struct { hasEquals bool leadingSpace int // Space before the key. TODO handle spaces vs tabs. position Position + // rawValue preserves the original value text (including surrounding double + // quotes, if any) so that String() can roundtrip the config file faithfully. + rawValue string } // Pos returns k's Position. @@ -633,9 +658,20 @@ func (k *KV) String() string { if k.hasEquals { equals = " = " } - line := strings.Repeat(" ", int(k.leadingSpace)) + k.Key + equals + k.Value + k.spaceAfterValue + val := k.Value + if k.rawValue != "" { + val = k.rawValue + } + line := strings.Repeat(" ", int(k.leadingSpace)) + k.Key + equals + val if k.Comment != "" { + if k.spaceAfterValue != "" { + line += k.spaceAfterValue + } else { + line += " " + } line += "#" + k.Comment + } else { + line += k.spaceAfterValue } return line } @@ -732,6 +768,8 @@ func NewInclude(directives []string, hasEquals bool, pos Position, comment strin path = directives[i] } else if system { path = filepath.Join("/etc/ssh", directives[i]) + } else if strings.HasPrefix(directives[i], "~/") { + path = filepath.Join(homedir(), directives[i][2:]) } else { path = filepath.Join(homedir(), ".ssh", directives[i]) } @@ -829,7 +867,7 @@ func init() { func newConfig() *Config { return &Config{ Hosts: []*Host{ - &Host{ + { implicit: true, Patterns: []*Pattern{matchAll}, Nodes: make([]Node, 0), diff --git a/config_test.go b/config_test.go index 11b203d..558fc51 100644 --- a/config_test.go +++ b/config_test.go @@ -110,23 +110,81 @@ func TestGetIdentities(t *testing.T) { if err != nil { t.Errorf("expected nil err, got %v", err) } - if len(val) != len(defaultProtocol2Identities) { + if len(val) != len(defaultIdentityFiles) { // TODO: return the right values here. log.Printf("expected defaults, got %v", val) } else { - for i, v := range defaultProtocol2Identities { + for i, v := range defaultIdentityFiles { if val[i] != v { t.Errorf("invalid %d in val, expected %s got %s", i, v, val[i]) } } } + // "protocol1" host sets Protocol 1, but Protocol is ignored in modern + // OpenSSH (only SSH2 exists). No IdentityFile is set for this host, so + // the result is empty (IdentityFile has no single default value). val, err = us.GetAllStrict("protocol1", "IdentityFile") if err != nil { t.Errorf("expected nil err, got %v", err) } - if len(val) != 1 || val[0] != "~/.ssh/identity" { - t.Errorf("expected [\"~/.ssh/identity\"], got %v", val) + if len(val) != 0 { + t.Errorf("expected [], got %v", val) + } +} + +func TestGetQuotedValues(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/quoted-identities"), + } + + val, err := us.GetStrict("hasquotedidentity", "IdentityFile") + if err != nil { + t.Fatal(err) + } + want := "/Users/testuser/.ssh/quoted_key" + if val != want { + t.Errorf("IdentityFile with quotes: got %q, want %q", val, want) + } + + val, err = us.GetStrict("hasquotedhostname", "HostName") + if err != nil { + t.Fatal(err) + } + want = "example.com" + if val != want { + t.Errorf("HostName with quotes: got %q, want %q", val, want) + } + + val, err = us.GetStrict("hasunquotedidentity", "IdentityFile") + if err != nil { + t.Fatal(err) + } + want = "/Users/testuser/.ssh/unquoted_key" + if val != want { + t.Errorf("IdentityFile without quotes: got %q, want %q", val, want) + } + + // Verify roundtripping preserves quotes in the output + f, err := os.Open("testdata/quoted-identities") + if err != nil { + t.Fatal(err) + } + defer f.Close() + cfg, err := Decode(f) + if err != nil { + t.Fatal(err) + } + out, err := cfg.MarshalText() + if err != nil { + t.Fatal(err) + } + original, err := os.ReadFile("testdata/quoted-identities") + if err != nil { + t.Fatal(err) + } + if string(out) != string(original) { + t.Errorf("roundtrip mismatch:\ngot:\n%s\nwant:\n%s", out, original) } } @@ -349,6 +407,32 @@ func TestIncludeString(t *testing.T) { } } +var shellIncludeFile = []byte(` +# This host should not exist, so we can use it for test purposes / it won't +# interfere with any other configurations. +Host kevinburke.ssh_config.test.example.com + Port 4567 +`) + +func TestIncludeShellHomeDirectory(t *testing.T) { + if testing.Short() { + t.Skip("skipping fs write in short mode") + } + testPath := filepath.Join(homedir(), "kevinburke-ssh-config-shell-include") + err := os.WriteFile(testPath, shellIncludeFile, 0644) + if err != nil { + t.Skipf("couldn't write SSH config file: %v", err.Error()) + } + defer os.Remove(testPath) + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/include-shell"), + } + val := us.Get("kevinburke.ssh_config.test.example.com", "Port") + if val != "4567" { + t.Errorf("expected to find Port=4567 in included file, got %q", val) + } +} + var matchTests = []struct { in []string alias string @@ -388,16 +472,14 @@ func TestMatches(t *testing.T) { } } -func TestMatchUnsupported(t *testing.T) { - us := &UserSettings{ - userConfigFinder: testConfigFinder("testdata/match-directive"), - } - - _, err := us.GetStrict("test.test", "Port") +func TestMatchExecUnsupported(t *testing.T) { + config := `Match Exec "echo hello" + Port 2222` + _, err := Decode(strings.NewReader(config)) if err == nil { - t.Fatal("expected Match directive to error, didn't") + t.Fatal("expected Match Exec to error, didn't") } - if !strings.Contains(err.Error(), "ssh_config: Match directive parsing is unsupported") { + if !strings.Contains(err.Error(), "ssh_config: Match Exec is not supported") { t.Errorf("wrong error: %v", err) } } @@ -456,6 +538,33 @@ func TestNoTrailingNewline(t *testing.T) { } } +func TestEOLCommentSpacing(t *testing.T) { + // Reproduces issue #50: programmatically created Host with EOLComment + // should have a space before the '#', not "Host foo#comment". + pattern, err := NewPattern("example") + if err != nil { + t.Fatal(err) + } + host := &Host{ + Patterns: []*Pattern{pattern}, + Nodes: []Node{ + &KV{Key: " Hostname", Value: "1.2.3.4"}, + }, + } + host.EOLComment = "my comment" + got := host.String() + if !strings.Contains(got, "Host example #my comment") { + t.Errorf("expected space before comment, got %q", got) + } + + // Same issue for KV: programmatically created KV with Comment + kv := &KV{Key: " Port", Value: "22", Comment: "ssh port"} + got = kv.String() + if !strings.Contains(got, "22 #ssh port") { + t.Errorf("expected space before KV comment, got %q", got) + } +} + func TestCustomFinder(t *testing.T) { us := &UserSettings{} us.ConfigFinder(func() string { diff --git a/match_test.go b/match_test.go new file mode 100644 index 0000000..a957842 --- /dev/null +++ b/match_test.go @@ -0,0 +1,599 @@ +package ssh_config + +import ( + "strings" + "testing" +) + +func TestMatchHostBasic(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-host"), + } + + val := us.Get("dev.example.com", "Port") + if val != "2222" { + t.Errorf("expected Port=2222 for dev.example.com, got %q", val) + } + val = us.Get("dev.example.com", "User") + if val != "admin" { + t.Errorf("expected User=admin for dev.example.com, got %q", val) + } + val = us.Get("dev.example.com", "IdentityFile") + if val != "~/.ssh/prod_key" { + t.Errorf("expected IdentityFile=~/.ssh/prod_key, got %q", val) + } +} + +func TestMatchHostNoMatch(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-host"), + systemConfigFinder: nullConfigFinder, + } + + // "other.com" doesn't match *.example.com, should fall back to defaults + val := us.Get("other.com", "Port") + if val != "22" { + t.Errorf("expected default Port=22 for other.com, got %q", val) + } + val = us.Get("other.com", "User") + if val != "" { + t.Errorf("expected empty User for other.com, got %q", val) + } +} + +func TestMatchHostNegation(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-host-negation"), + } + + // dev.example.com matches *.example.com and is not excluded by + // !*.test.example.com, so the Match block applies. + val := us.Get("dev.example.com", "Port") + if val != "2222" { + t.Errorf("expected Port=2222 for dev.example.com, got %q", val) + } + val = us.Get("dev.example.com", "User") + if val != "prod" { + t.Errorf("expected User=prod for dev.example.com, got %q", val) + } + + // dev.test.example.com matches !*.test.example.com negation, so the + // Match block should NOT apply. The Host block should match instead. + val = us.Get("dev.test.example.com", "Port") + if val != "22" { + t.Errorf("expected Port=22 for dev.test.example.com (negated), got %q", val) + } + val = us.Get("dev.test.example.com", "User") + if val != "default" { + t.Errorf("expected User=default for dev.test.example.com (negated), got %q", val) + } +} + +func TestMatchAll(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-all"), + systemConfigFinder: nullConfigFinder, + } + + // "special" matches the explicit Host block first + val := us.Get("special", "Port") + if val != "1111" { + t.Errorf("expected Port=1111 for special, got %q", val) + } + + // "special" should also get User from the Match all block + val = us.Get("special", "User") + if val != "matchuser" { + t.Errorf("expected User=matchuser for special, got %q", val) + } + + // An arbitrary host should match "Match all" + val = us.Get("anything.example.com", "Port") + if val != "4567" { + t.Errorf("expected Port=4567 for anything.example.com via Match all, got %q", val) + } + val = us.Get("anything.example.com", "User") + if val != "matchuser" { + t.Errorf("expected User=matchuser for anything.example.com via Match all, got %q", val) + } +} + +func TestMatchMixed(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-mixed"), + systemConfigFinder: nullConfigFinder, + } + + // "bastion" matches the explicit Host block + val := us.Get("bastion", "Port") + if val != "22" { + t.Errorf("expected Port=22 for bastion, got %q", val) + } + val = us.Get("bastion", "User") + if val != "root" { + t.Errorf("expected User=root for bastion, got %q", val) + } + + // app.prod.example.com matches "Match Host *.prod.example.com" and + // also "Host *.example.com". First match wins for each key. + val = us.Get("app.prod.example.com", "Port") + if val != "2222" { + t.Errorf("expected Port=2222 for app.prod.example.com, got %q", val) + } + val = us.Get("app.prod.example.com", "User") + if val != "deploy" { + t.Errorf("expected User=deploy for app.prod.example.com, got %q", val) + } + + // app.staging.example.com matches "Host *.example.com" (Port 80) + // first, then "Match Host *.staging.example.com" (Port 3333). + // SSH semantics: first match wins per key. + val = us.Get("app.staging.example.com", "Port") + if val != "80" { + t.Errorf("expected Port=80 for app.staging.example.com (Host block first), got %q", val) + } + + // plain.example.com matches "Host *.example.com" only + val = us.Get("plain.example.com", "Port") + if val != "80" { + t.Errorf("expected Port=80 for plain.example.com, got %q", val) + } + val = us.Get("plain.example.com", "User") + if val != "webuser" { + t.Errorf("expected User=webuser for plain.example.com, got %q", val) + } + + // unknown host matches only "Match all" + val = us.Get("unknown.host", "User") + if val != "fallback" { + t.Errorf("expected User=fallback for unknown.host via Match all, got %q", val) + } +} + +func TestMatchMixedGetAll(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-mixed"), + systemConfigFinder: nullConfigFinder, + } + + // app.prod.example.com should get both IdentityFiles from the Match Host + // block, plus the one from Match all. + vals := us.GetAll("app.prod.example.com", "IdentityFile") + want := []string{"~/.ssh/prod_key1", "~/.ssh/prod_key2", "~/.ssh/default_key"} + if len(vals) != len(want) { + t.Fatalf("GetAll IdentityFile for app.prod.example.com: got %d values %v, want %d values %v", len(vals), vals, len(want), want) + } + for i := range want { + if vals[i] != want[i] { + t.Errorf("GetAll IdentityFile[%d]: got %q, want %q", i, vals[i], want[i]) + } + } +} + +func TestMatchDirectiveInline(t *testing.T) { + tests := []struct { + name string + config string + alias string + key string + wantVal string + wantErr string + }{ + { + name: "basic match host", + config: `Match Host *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match host no match", + config: `Match Host *.example.com + Port 2222`, + alias: "test.other.com", + key: "Port", + wantVal: "", + }, + { + name: "match all", + config: `Match all + Port 9999`, + alias: "anything", + key: "Port", + wantVal: "9999", + }, + { + name: "match host multiple patterns", + config: `Match Host *.example.com *.example.org + Port 2222`, + alias: "test.example.org", + key: "Port", + wantVal: "2222", + }, + { + name: "match host with comment", + config: `Match Host *.example.com # Production servers + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "empty match should error", + config: `Match + Port 2222`, + wantErr: "Match directive requires", + }, + { + name: "match host case insensitive", + config: `Match HOST *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match host mixed case", + config: `Match HoSt *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match all uppercase", + config: `Match ALL + Port 9999`, + alias: "anything", + key: "Port", + wantVal: "9999", + }, + { + name: "match keyword itself case insensitive", + config: `MATCH Host *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match host extra spaces between patterns", + config: `Match Host *.example.com *.example.org + Port 2222`, + alias: "test.example.org", + key: "Port", + wantVal: "2222", + }, + { + name: "match host trailing spaces", + config: "Match Host *.example.com \n Port 2222", + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match host leading spaces on match line", + config: ` Match Host *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "host before match host, same pattern", + config: `Host *.example.com + Port 1111 + +Match Host *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "1111", // Host block appears first, wins + }, + { + name: "match host before host, same pattern", + config: `Match Host *.example.com + Port 2222 + +Host *.example.com + Port 1111`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", // Match block appears first, wins + }, + { + name: "match host provides key not in host", + config: `Host *.example.com + Port 1111 + +Match Host *.example.com + User admin`, + alias: "test.example.com", + key: "User", + wantVal: "admin", // Not set in Host, comes from Match + }, + { + name: "match host negation excludes", + config: `Match Host *.example.com !staging.example.com + Port 2222`, + alias: "staging.example.com", + key: "Port", + wantVal: "", + }, + { + name: "match host negation allows", + config: `Match Host *.example.com !staging.example.com + Port 2222`, + alias: "prod.example.com", + key: "Port", + wantVal: "2222", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Decode(strings.NewReader(tt.config)) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + got, err := cfg.Get(tt.alias, tt.key) + if err != nil { + t.Fatalf("unexpected Get error: %v", err) + } + if got != tt.wantVal { + t.Errorf("Get(%q, %q) = %q, want %q", tt.alias, tt.key, got, tt.wantVal) + } + }) + } +} + +func TestMatchUnsupportedCriteria(t *testing.T) { + // Every Match criterion from the ssh_config manpage that we don't + // support, plus case variations and the special Exec case. + tests := []struct { + name string + config string + wantErr string + }{ + // Exec gets its own error message because it's a security concern. + { + name: "exec lowercase", + config: "Match exec \"echo hello\"\n Port 22", + wantErr: "ssh_config: Match Exec is not supported", + }, + { + name: "exec uppercase", + config: "Match EXEC \"echo hello\"\n Port 22", + wantErr: "ssh_config: Match Exec is not supported", + }, + { + name: "exec mixed case", + config: "Match ExEc \"echo hello\"\n Port 22", + wantErr: "ssh_config: Match Exec is not supported", + }, + { + name: "exec with complex command", + config: "Match Exec \"test -f /etc/ssh/flag\"\n Port 22", + wantErr: "ssh_config: Match Exec is not supported", + }, + // All other unsupported criteria. + { + name: "user", + config: "Match User admin\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "user uppercase", + config: "Match USER admin\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "localuser", + config: "Match LocalUser kevin\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "localuser uppercase", + config: "Match LOCALUSER kevin\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "originalhost", + config: "Match OriginalHost *.example.com\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "originalhost uppercase", + config: "Match ORIGINALHOST *.example.com\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "canonical", + config: "Match canonical\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "final", + config: "Match final\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "tagged", + config: "Match Tagged mytag\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "localnetwork", + config: "Match LocalNetwork 192.168.1.0/24\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "completely bogus criterion", + config: "Match Bogus value\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + // Match Host with no patterns after it. + { + name: "match host with no patterns", + config: "Match Host\n Port 22", + wantErr: "ssh_config: Match Host requires at least one pattern", + }, + // Match Host followed by only whitespace. + { + name: "match host only whitespace after", + config: "Match Host \n Port 22", + wantErr: "ssh_config: Match Host requires at least one pattern", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Decode(strings.NewReader(tt.config)) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestMatchDirectiveGetAll(t *testing.T) { + config := `Match Host *.prod.example.com + IdentityFile ~/.ssh/prod_key1 + IdentityFile ~/.ssh/prod_key2 + +Match all + IdentityFile ~/.ssh/default_key` + + cfg, err := Decode(strings.NewReader(config)) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + vals, err := cfg.GetAll("app.prod.example.com", "IdentityFile") + if err != nil { + t.Fatalf("unexpected GetAll error: %v", err) + } + want := []string{"~/.ssh/prod_key1", "~/.ssh/prod_key2", "~/.ssh/default_key"} + if len(vals) != len(want) { + t.Fatalf("GetAll returned %d values %v, want %d values %v", len(vals), vals, len(want), want) + } + for i := range want { + if vals[i] != want[i] { + t.Errorf("GetAll[%d] = %q, want %q", i, vals[i], want[i]) + } + } +} + +func TestMatchStringRoundTrip(t *testing.T) { + tests := []struct { + name string + config string + }{ + { + name: "match host", + config: `Match Host *.example.com + Port 2222 +`, + }, + { + name: "match all", + config: `Match all + Port 4567 +`, + }, + { + name: "match host with comment", + config: `Match Host *.example.com # production + Port 2222 +`, + }, + { + name: "match host multiple patterns", + config: `Match Host *.example.com *.example.org + Port 2222 +`, + }, + { + name: "match ALL uppercase round-trip", + config: `Match ALL + Port 4567 +`, + }, + { + name: "match All mixed case round-trip", + config: `Match All + Port 4567 +`, + }, + { + name: "mixed host and match", + config: `Host bastion + Port 22 + +Match Host *.example.com + Port 2222 + +Match all + User fallback +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Decode(strings.NewReader(tt.config)) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + got := cfg.String() + if got != tt.config { + t.Errorf("round-trip mismatch:\ngot:\n%s\nwant:\n%s", got, tt.config) + } + }) + } +} + +func TestMatchFileRoundTrip(t *testing.T) { + for _, filename := range []string{ + "testdata/match-host", + "testdata/match-all", + "testdata/match-mixed", + "testdata/match-host-negation", + } { + data := loadFile(t, filename) + cfg, err := Decode(strings.NewReader(string(data))) + if err != nil { + t.Fatalf("%s: unexpected parse error: %v", filename, err) + } + got := cfg.String() + if got != string(data) { + t.Errorf("%s: round-trip mismatch:\ngot:\n%s\nwant:\n%s", filename, got, string(data)) + } + } +} + +// TestMatchExistingDirectiveFile tests that the existing testdata/match-directive +// file (which contains "Match all") now parses successfully. +func TestMatchExistingDirectiveFile(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-directive"), + } + val := us.Get("anyhost", "Port") + if val != "4567" { + t.Errorf("expected Port=4567 via Match all, got %q", val) + } +} diff --git a/parser.go b/parser.go index 2b1e718..c524d0e 100644 --- a/parser.go +++ b/parser.go @@ -21,9 +21,9 @@ type sshParser struct { type sshParserStateFn func() sshParserStateFn // Formats and panics an error message based on a token -func (p *sshParser) raiseErrorf(tok *token, msg string, args ...interface{}) { +func (p *sshParser) raiseErrorf(tok *token, msg string) { // TODO this format is ugly - panic(tok.Position.String() + ": " + fmt.Sprintf(msg, args...)) + panic(tok.Position.String() + ": " + msg) } func (p *sshParser) raiseError(tok *token, err error) { @@ -105,9 +105,7 @@ func (p *sshParser) parseKV() sshParserStateFn { comment = tok.val } if strings.ToLower(key.val) == "match" { - // https://github.com/kevinburke/ssh_config/issues/6 - p.raiseErrorf(val, "ssh_config: Match directive parsing is unsupported") - return nil + return p.parseMatch(val, hasEquals, comment) } if strings.ToLower(key.val) == "host" { strPatterns := strings.Split(val.val, " ") @@ -118,7 +116,7 @@ func (p *sshParser) parseKV() sshParserStateFn { } pat, err := NewPattern(strPatterns[i]) if err != nil { - p.raiseErrorf(val, "Invalid host pattern: %v", err) + p.raiseErrorf(val, fmt.Sprintf("Invalid host pattern: %v", err)) return nil } patterns = append(patterns, pat) @@ -144,7 +142,7 @@ func (p *sshParser) parseKV() sshParserStateFn { return nil } if err != nil { - p.raiseErrorf(val, "Error parsing Include directive: %v", err) + p.raiseErrorf(val, fmt.Sprintf("Error parsing Include directive: %v", err)) return nil } lastHost.Nodes = append(lastHost.Nodes, inc) @@ -152,9 +150,14 @@ func (p *sshParser) parseKV() sshParserStateFn { } shortval := strings.TrimRightFunc(val.val, unicode.IsSpace) spaceAfterValue := val.val[len(shortval):] + unquoted := shortval + if len(shortval) >= 2 && shortval[0] == '"' && shortval[len(shortval)-1] == '"' { + unquoted = shortval[1 : len(shortval)-1] + } kv := &KV{ Key: key.val, - Value: shortval, + Value: unquoted, + rawValue: shortval, spaceAfterValue: spaceAfterValue, Comment: comment, hasEquals: hasEquals, @@ -165,6 +168,73 @@ func (p *sshParser) parseKV() sshParserStateFn { return p.parseStart } +func (p *sshParser) parseMatch(val *token, hasEquals bool, comment string) sshParserStateFn { + // val.val contains everything after "Match ", e.g. "Host *.example.com" + // or "all". + trimmed := strings.TrimRightFunc(val.val, unicode.IsSpace) + spaceBeforeComment := val.val[len(trimmed):] + fields := strings.Fields(trimmed) + if len(fields) == 0 { + p.raiseErrorf(val, "ssh_config: Match directive requires at least one criterion") + return nil + } + criterion := strings.ToLower(fields[0]) + + switch criterion { + case "all": + // "Match all" is equivalent to "Host *" — matches everything. + p.config.Hosts = append(p.config.Hosts, &Host{ + Patterns: []*Pattern{matchAll}, + Nodes: make([]Node, 0), + EOLComment: comment, + spaceBeforeComment: spaceBeforeComment, + hasEquals: hasEquals, + isMatch: true, + matchKeyword: fields[0], // preserve original case + }) + return p.parseStart + + case "host": + patterns := make([]*Pattern, 0) + for _, s := range fields[1:] { + if s == "" { + continue + } + pat, err := NewPattern(s) + if err != nil { + p.raiseErrorf(val, fmt.Sprintf("Invalid host pattern: %v", err)) + return nil + } + patterns = append(patterns, pat) + } + if len(patterns) == 0 { + p.raiseErrorf(val, "ssh_config: Match Host requires at least one pattern") + return nil + } + p.config.Hosts = append(p.config.Hosts, &Host{ + Patterns: patterns, + Nodes: make([]Node, 0), + EOLComment: comment, + spaceBeforeComment: spaceBeforeComment, + hasEquals: hasEquals, + isMatch: true, + matchKeyword: fields[0], // preserve original case + }) + return p.parseStart + + case "exec": + // Match Exec runs arbitrary commands. Supporting it would allow + // untrusted SSH config files to execute code on the parsing + // machine. Reject it explicitly. + p.raiseErrorf(val, "ssh_config: Match Exec is not supported") + return nil + + default: + p.raiseErrorf(val, fmt.Sprintf("ssh_config: unsupported Match criterion %q", criterion)) + return nil + } +} + func (p *sshParser) parseComment() sshParserStateFn { comment := p.getToken() lastHost := p.config.Hosts[len(p.config.Hosts)-1] diff --git a/testdata/dos-lines b/testdata/dos-lines index 1001df4..8cb11cd 100644 --- a/testdata/dos-lines +++ b/testdata/dos-lines @@ -1,10 +1,10 @@ -# Config file with dos line endings -Host wap - HostName wap.example.org - Port 22 - User root - KexAlgorithms diffie-hellman-group1-sha1 - -Host wap2 - HostName 8.8.8.8 - User google +# Config file with dos line endings +Host wap + HostName wap.example.org + Port 22 + User root + KexAlgorithms diffie-hellman-group1-sha1 + +Host wap2 + HostName 8.8.8.8 + User google diff --git a/testdata/include-shell b/testdata/include-shell new file mode 100644 index 0000000..e719328 --- /dev/null +++ b/testdata/include-shell @@ -0,0 +1,2 @@ +Host kevinburke.ssh_config.test.example.com + Include ~/kevinburke-ssh-config-shell-include diff --git a/testdata/match-all b/testdata/match-all new file mode 100644 index 0000000..9d510b3 --- /dev/null +++ b/testdata/match-all @@ -0,0 +1,6 @@ +Host special + Port 1111 + +Match all + Port 4567 + User matchuser diff --git a/testdata/match-host b/testdata/match-host new file mode 100644 index 0000000..7d10326 --- /dev/null +++ b/testdata/match-host @@ -0,0 +1,9 @@ +Match Host *.example.com + Port 2222 + User admin + IdentityFile ~/.ssh/prod_key + +Host *.example.com + Port 22 + User developer + IdentityFile ~/.ssh/dev_key diff --git a/testdata/match-host-negation b/testdata/match-host-negation new file mode 100644 index 0000000..d0d7212 --- /dev/null +++ b/testdata/match-host-negation @@ -0,0 +1,7 @@ +Match Host *.example.com !*.test.example.com + Port 2222 + User prod + +Host *.example.com + Port 22 + User default diff --git a/testdata/match-mixed b/testdata/match-mixed new file mode 100644 index 0000000..28d571f --- /dev/null +++ b/testdata/match-mixed @@ -0,0 +1,21 @@ +Host bastion + Port 22 + User root + +Match Host *.prod.example.com + Port 2222 + User deploy + IdentityFile ~/.ssh/prod_key1 + IdentityFile ~/.ssh/prod_key2 + +Host *.example.com + Port 80 + User webuser + +Match Host *.staging.example.com + Port 3333 + User staging + +Match all + User fallback + IdentityFile ~/.ssh/default_key diff --git a/testdata/quoted-identities b/testdata/quoted-identities new file mode 100644 index 0000000..8401f15 --- /dev/null +++ b/testdata/quoted-identities @@ -0,0 +1,9 @@ + +Host hasquotedidentity + IdentityFile "/Users/testuser/.ssh/quoted_key" + +Host hasquotedhostname + HostName "example.com" + +Host hasunquotedidentity + IdentityFile /Users/testuser/.ssh/unquoted_key diff --git a/validators.go b/validators.go index 5977f90..8c5da1c 100644 --- a/validators.go +++ b/validators.go @@ -10,7 +10,12 @@ import ( // the keyword is "Port". Default returns the empty string if the keyword has no // default, or if the keyword is unknown. Keyword matching is case-insensitive. // -// Default values are provided by OpenSSH_7.4p1 on a Mac. +// Default values are sourced from the openssh-portable source code. To +// validate or update these defaults, check fill_default_options() in +// readconf.c and the algorithm lists in myproposal.h: +// +// https://github.com/openssh/openssh-portable/blob/master/readconf.c +// https://github.com/openssh/openssh-portable/blob/master/myproposal.h func Default(keyword string) string { return defaults[strings.ToLower(keyword)] } @@ -19,13 +24,11 @@ func Default(keyword string) string { var yesnos = map[string]bool{ strings.ToLower("BatchMode"): true, strings.ToLower("CanonicalizeFallbackLocal"): true, - strings.ToLower("ChallengeResponseAuthentication"): true, strings.ToLower("CheckHostIP"): true, strings.ToLower("ClearAllForwardings"): true, strings.ToLower("Compression"): true, strings.ToLower("EnableSSHKeysign"): true, strings.ToLower("ExitOnForwardFailure"): true, - strings.ToLower("ForwardAgent"): true, strings.ToLower("ForwardX11"): true, strings.ToLower("ForwardX11Trusted"): true, strings.ToLower("GatewayPorts"): true, @@ -38,18 +41,14 @@ var yesnos = map[string]bool{ strings.ToLower("PasswordAuthentication"): true, strings.ToLower("PermitLocalCommand"): true, strings.ToLower("PubkeyAuthentication"): true, - strings.ToLower("RhostsRSAAuthentication"): true, - strings.ToLower("RSAAuthentication"): true, strings.ToLower("StreamLocalBindUnlink"): true, strings.ToLower("TCPKeepAlive"): true, strings.ToLower("UseKeychain"): true, - strings.ToLower("UsePrivilegedPort"): true, strings.ToLower("VisualHostKey"): true, } var uints = map[string]bool{ strings.ToLower("CanonicalizeMaxDots"): true, - strings.ToLower("CompressionLevel"): true, // 1 to 9 strings.ToLower("ConnectionAttempts"): true, strings.ToLower("ConnectTimeout"): true, strings.ToLower("NumberOfPasswordPrompts"): true, @@ -80,30 +79,51 @@ func validate(key, val string) error { return nil } +// defaultPKAlg is the default value for HostKeyAlgorithms, +// HostbasedAcceptedAlgorithms, and PubkeyAcceptedAlgorithms. +// Sourced from KEX_DEFAULT_PK_ALG in myproposal.h. +var defaultPKAlg = strings.Join([]string{ + "ssh-ed25519-cert-v01@openssh.com", + "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521-cert-v01@openssh.com", + "sk-ssh-ed25519-cert-v01@openssh.com", + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "webauthn-sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "rsa-sha2-512-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "sk-ssh-ed25519@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + "webauthn-sk-ecdsa-sha2-nistp256@openssh.com", + "rsa-sha2-512", + "rsa-sha2-256", +}, ",") + var defaults = map[string]string{ - strings.ToLower("AddKeysToAgent"): "no", - strings.ToLower("AddressFamily"): "any", - strings.ToLower("BatchMode"): "no", - strings.ToLower("CanonicalizeFallbackLocal"): "yes", - strings.ToLower("CanonicalizeHostname"): "no", - strings.ToLower("CanonicalizeMaxDots"): "1", - strings.ToLower("ChallengeResponseAuthentication"): "yes", - strings.ToLower("CheckHostIP"): "yes", - // TODO is this still the correct cipher - strings.ToLower("Cipher"): "3des", - strings.ToLower("Ciphers"): "chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,aes128-cbc,aes192-cbc,aes256-cbc", + strings.ToLower("AddKeysToAgent"): "no", + strings.ToLower("AddressFamily"): "any", + strings.ToLower("BatchMode"): "no", + strings.ToLower("CanonicalizeFallbackLocal"): "yes", + strings.ToLower("CanonicalizeHostname"): "no", + strings.ToLower("CanonicalizeMaxDots"): "1", + strings.ToLower("CheckHostIP"): "no", + strings.ToLower("Ciphers"): "chacha20-poly1305@openssh.com,aes128-gcm@openssh.com,aes256-gcm@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr", strings.ToLower("ClearAllForwardings"): "no", strings.ToLower("Compression"): "no", - strings.ToLower("CompressionLevel"): "6", strings.ToLower("ConnectionAttempts"): "1", strings.ToLower("ControlMaster"): "no", + strings.ToLower("ControlPersist"): "no", strings.ToLower("EnableSSHKeysign"): "no", strings.ToLower("EscapeChar"): "~", strings.ToLower("ExitOnForwardFailure"): "no", strings.ToLower("FingerprintHash"): "sha256", strings.ToLower("ForwardAgent"): "no", strings.ToLower("ForwardX11"): "no", - strings.ToLower("ForwardX11Timeout"): "20m", + strings.ToLower("ForwardX11Timeout"): "1200", strings.ToLower("ForwardX11Trusted"): "no", strings.ToLower("GatewayPorts"): "no", strings.ToLower("GlobalKnownHostsFile"): "/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2", @@ -112,19 +132,24 @@ var defaults = map[string]string{ strings.ToLower("HashKnownHosts"): "no", strings.ToLower("HostbasedAuthentication"): "no", - strings.ToLower("HostbasedKeyTypes"): "ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,ssh-rsa", - strings.ToLower("HostKeyAlgorithms"): "ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,ssh-rsa", + strings.ToLower("CASignatureAlgorithms"): "ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,webauthn-sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256", + + // HostbasedAcceptedAlgorithms and HostbasedKeyTypes (obsolete alias) + // both default to KEX_DEFAULT_PK_ALG. + strings.ToLower("HostbasedAcceptedAlgorithms"): defaultPKAlg, + strings.ToLower("HostbasedKeyTypes"): defaultPKAlg, + + strings.ToLower("HostKeyAlgorithms"): defaultPKAlg, // HostName has a dynamic default (the value passed at the command line). strings.ToLower("IdentitiesOnly"): "no", - strings.ToLower("IdentityFile"): "~/.ssh/identity", // IPQoS has a dynamic default based on interactive or non-interactive // sessions. strings.ToLower("KbdInteractiveAuthentication"): "yes", - strings.ToLower("KexAlgorithms"): "curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1", + strings.ToLower("KexAlgorithms"): "mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256", strings.ToLower("LogLevel"): "INFO", strings.ToLower("MACs"): "umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1", @@ -135,25 +160,28 @@ var defaults = map[string]string{ strings.ToLower("Port"): "22", strings.ToLower("PreferredAuthentications"): "gssapi-with-mic,hostbased,publickey,keyboard-interactive,password", - strings.ToLower("Protocol"): "2", strings.ToLower("ProxyUseFdpass"): "no", - strings.ToLower("PubkeyAcceptedKeyTypes"): "ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,ssh-rsa", - strings.ToLower("PubkeyAuthentication"): "yes", - strings.ToLower("RekeyLimit"): "default none", - strings.ToLower("RhostsRSAAuthentication"): "no", - strings.ToLower("RSAAuthentication"): "yes", + + // PubkeyAcceptedAlgorithms and PubkeyAcceptedKeyTypes (obsolete alias) + // both default to KEX_DEFAULT_PK_ALG. + strings.ToLower("PubkeyAcceptedAlgorithms"): defaultPKAlg, + strings.ToLower("PubkeyAcceptedKeyTypes"): defaultPKAlg, + + strings.ToLower("PubkeyAuthentication"): "yes", + strings.ToLower("RekeyLimit"): "default none", + strings.ToLower("RequestTTY"): "auto", strings.ToLower("ServerAliveCountMax"): "3", strings.ToLower("ServerAliveInterval"): "0", + strings.ToLower("SessionType"): "default", strings.ToLower("StreamLocalBindMask"): "0177", strings.ToLower("StreamLocalBindUnlink"): "no", strings.ToLower("StrictHostKeyChecking"): "ask", strings.ToLower("TCPKeepAlive"): "yes", strings.ToLower("Tunnel"): "no", strings.ToLower("TunnelDevice"): "any:any", - strings.ToLower("UpdateHostKeys"): "no", + strings.ToLower("UpdateHostKeys"): "yes", strings.ToLower("UseKeychain"): "no", - strings.ToLower("UsePrivilegedPort"): "no", strings.ToLower("UserKnownHostsFile"): "~/.ssh/known_hosts ~/.ssh/known_hosts2", strings.ToLower("VerifyHostKeyDNS"): "no", @@ -161,12 +189,14 @@ var defaults = map[string]string{ strings.ToLower("XAuthLocation"): "/usr/X11R6/bin/xauth", } -// these identities are used for SSH protocol 2 -var defaultProtocol2Identities = []string{ - "~/.ssh/id_dsa", +// defaultIdentityFiles are the default IdentityFile values. +// Sourced from fill_default_options() in readconf.c. +var defaultIdentityFiles = []string{ + "~/.ssh/id_rsa", "~/.ssh/id_ecdsa", + "~/.ssh/id_ecdsa_sk", "~/.ssh/id_ed25519", - "~/.ssh/id_rsa", + "~/.ssh/id_ed25519_sk", } // these directives support multiple items that can be collected