├── .gitignore ├── .editorconfig ├── contrib ├── sshd │ └── 10-ssh-tpm-agent.conf ├── services │ ├── user │ │ ├── ssh-tpm-agent.socket │ │ └── ssh-tpm-agent.service │ ├── system │ │ ├── ssh-tpm-agent.socket │ │ ├── ssh-tpm-genkeys.service │ │ └── ssh-tpm-agent.service │ └── hierarchy_keys │ │ ├── ssh-tpm-hierarchy-genkeys.service │ │ ├── ssh-tpm-agent@.socket │ │ └── ssh-tpm-agent@.service ├── contrib_test.go └── contrib.go ├── cmd ├── ssh-tpm-agent │ ├── testdata │ │ └── script │ │ │ ├── agent_hierarchy_keys.txt │ │ │ ├── agent_password.txt │ │ │ └── agent.txt │ ├── main_test.go │ └── main.go ├── ssh-tpm-keygen │ ├── testdata │ │ └── script │ │ │ └── keygen.txt │ └── main.go ├── ssh-tpm-hostkeys │ └── main.go ├── scripts_test.go └── ssh-tpm-add │ └── main.go ├── man ├── ssh-tpm-hostkeys.1.adoc ├── ssh-tpm-add.1.adoc ├── ssh-tpm-keygen.1.adoc └── ssh-tpm-agent.1.adoc ├── .github └── workflows │ ├── test.yml │ └── build.yml ├── internal ├── keyring │ ├── key.go │ ├── keyring.go │ ├── keyring_test.go │ ├── threadkeyring_test.go │ └── threadkeyring.go ├── lsm │ └── lsm.go └── keytest │ └── keytest.go ├── LICENSE ├── go.mod ├── Makefile ├── key ├── signer.go ├── hierarchy_keys_test.go ├── key.go └── hierarchy_keys.go ├── agent ├── client.go ├── gocrypto.go ├── agent_test.go └── agent.go ├── utils ├── tpm.go └── utils.go ├── askpass └── askpass.go ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | *.tar.gz* 3 | releases/* 4 | man/*.1 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | 4 | [*.adoc] 5 | indent_size = 2 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /contrib/sshd/10-ssh-tpm-agent.conf: -------------------------------------------------------------------------------- 1 | # This enables TPM sealed host keys 2 | 3 | HostKeyAgent /var/tmp/ssh-tpm-agent.sock 4 | 5 | HostKey /etc/ssh/ssh_tpm_host_ecdsa_key.pub 6 | HostKey /etc/ssh/ssh_tpm_host_rsa_key.pub 7 | -------------------------------------------------------------------------------- /contrib/services/user/ssh-tpm-agent.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SSH TPM agent socket 3 | Documentation=man:ssh-agent(1) man:ssh-add(1) man:ssh(1) 4 | 5 | [Socket] 6 | ListenStream=%t/ssh-tpm-agent.sock 7 | SocketMode=0600 8 | Service=ssh-tpm-agent.service 9 | 10 | [Install] 11 | WantedBy=sockets.target 12 | -------------------------------------------------------------------------------- /contrib/services/system/ssh-tpm-agent.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SSH TPM agent socket 3 | Documentation=man:ssh-agent(1) man:ssh-add(1) man:ssh(1) 4 | 5 | [Socket] 6 | ListenStream=/var/tmp/ssh-tpm-agent.sock 7 | SocketMode=0600 8 | Service=ssh-tpm-agent.service 9 | 10 | [Install] 11 | WantedBy=sockets.target 12 | -------------------------------------------------------------------------------- /contrib/services/hierarchy_keys/ssh-tpm-hierarchy-genkeys.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SSH TPM Key Generation 3 | ConditionPathExists=|!/etc/ssh/ssh_tpm_host_ecdsa_key.pub 4 | ConditionPathExists=|!/etc/ssh/ssh_tpm_host_rsa_key.pub 5 | 6 | [Service] 7 | ExecStart=/usr/bin/ssh-tpm-keygen -A --hierarchy %i 8 | Type=oneshot 9 | RemainAfterExit=yes 10 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-agent/testdata/script/agent_hierarchy_keys.txt: -------------------------------------------------------------------------------- 1 | # Create hierarchy keys 2 | exec ssh-tpm-agent -d --no-load --hierarchy owner &agent& 3 | # Wait for key generation. 2 seconds'ish 4 | exec sleep 2s 5 | exec ssh-add -l 6 | stdout 'EqZ4 Owner hierarchy key \(RSA\)' 7 | stdout 'lCPg Owner hierarchy key \(ECDSA\)' 8 | exec ls 9 | 10 | # TODO: Signing test 11 | -------------------------------------------------------------------------------- /contrib/services/hierarchy_keys/ssh-tpm-agent@.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SSH TPM agent socket 3 | Documentation=man:ssh-agent(1) man:ssh-add(1) man:ssh(1) 4 | 5 | [Socket] 6 | ListenStream=/var/tmp/ssh-tpm-agent.sock 7 | SocketMode=0600 8 | Service=ssh-tpm-agent@%i.service 9 | 10 | [Install] 11 | WantedBy=sockets.target 12 | DefaultInstance=endorsement 13 | -------------------------------------------------------------------------------- /contrib/services/system/ssh-tpm-genkeys.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SSH TPM Key Generation 3 | ConditionPathExists=|!/etc/ssh/ssh_tpm_host_ecdsa_key.tpm 4 | ConditionPathExists=|!/etc/ssh/ssh_tpm_host_ecdsa_key.pub 5 | ConditionPathExists=|!/etc/ssh/ssh_tpm_host_rsa_key.tpm 6 | ConditionPathExists=|!/etc/ssh/ssh_tpm_host_rsa_key.pub 7 | 8 | [Service] 9 | ExecStart=/usr/bin/ssh-tpm-keygen -A 10 | Type=oneshot 11 | RemainAfterExit=yes 12 | -------------------------------------------------------------------------------- /contrib/services/user/ssh-tpm-agent.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | ConditionEnvironment=!SSH_AGENT_PID 3 | Description=ssh-tpm-agent service 4 | Documentation=man:ssh-agent(1) man:ssh-add(1) man:ssh(1) 5 | Requires=ssh-tpm-agent.socket 6 | 7 | [Service] 8 | Environment=SSH_TPM_AUTH_SOCK=%t/ssh-tpm-agent.sock 9 | ExecStart={{.GoBinary}} 10 | PassEnvironment=SSH_AGENT_PID 11 | SuccessExitStatus=2 12 | Type=simple 13 | 14 | [Install] 15 | Also=ssh-agent.socket 16 | -------------------------------------------------------------------------------- /contrib/services/system/ssh-tpm-agent.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | ConditionEnvironment=!SSH_AGENT_PID 3 | Description=ssh-tpm-agent service 4 | Documentation=man:ssh-agent(1) man:ssh-add(1) man:ssh(1) 5 | Wants=ssh-tpm-genkeys.service 6 | After=ssh-tpm-genkeys.service 7 | After=network.target 8 | After=sshd.target 9 | Requires=ssh-tpm-agent.socket 10 | 11 | [Service] 12 | ExecStart=/usr/bin/ssh-tpm-agent --key-dir /etc/ssh 13 | PassEnvironment=SSH_AGENT_PID 14 | KillMode=process 15 | Restart=always 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /contrib/contrib_test.go: -------------------------------------------------------------------------------- 1 | package contrib 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestUserServices(t *testing.T) { 8 | m := EmbeddedUserServices() 9 | if len(m) != 2 { 10 | t.Fatalf("invalid number of entries") 11 | } 12 | } 13 | 14 | func TestSystemServices(t *testing.T) { 15 | m := EmbeddedSystemServices() 16 | if len(m) != 3 { 17 | t.Fatalf("invalid number of entries") 18 | } 19 | } 20 | 21 | func TestSshdConfig(t *testing.T) { 22 | m := EmbeddedSshdConfig() 23 | if len(m) != 1 { 24 | t.Fatalf("invalid number of entries") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /contrib/services/hierarchy_keys/ssh-tpm-agent@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | ConditionEnvironment=!SSH_AGENT_PID 3 | Description=ssh-tpm-agent service 4 | Documentation=man:ssh-agent(1) man:ssh-add(1) man:ssh(1) 5 | Wants=ssh-tpm-genkeys@%i.service 6 | After=ssh-tpm-genkeys@%i.service 7 | After=network.target 8 | After=sshd.target 9 | Requires=ssh-tpm-agent@%i.socket 10 | 11 | [Service] 12 | ExecStart=/usr/bin/ssh-tpm-agent --key-dir /etc/ssh --hierarchy %i 13 | PassEnvironment=SSH_AGENT_PID 14 | KillMode=process 15 | Restart=always 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | DefaultInstance=endorsement 20 | -------------------------------------------------------------------------------- /contrib/contrib.go: -------------------------------------------------------------------------------- 1 | package contrib 2 | 3 | import ( 4 | "embed" 5 | "path" 6 | ) 7 | 8 | //go:embed services/* 9 | var services embed.FS 10 | 11 | //go:embed sshd/* 12 | var sshd embed.FS 13 | 14 | func readPath(f embed.FS, s string) map[string][]byte { 15 | ret := map[string][]byte{} 16 | files, _ := f.ReadDir(s) 17 | for _, file := range files { 18 | b, _ := f.ReadFile(path.Join(s, file.Name())) 19 | ret[file.Name()] = b 20 | } 21 | return ret 22 | } 23 | 24 | func EmbeddedUserServices() map[string][]byte { 25 | return readPath(services, "services/user") 26 | } 27 | 28 | func EmbeddedSystemServices() map[string][]byte { 29 | return readPath(services, "services/system") 30 | } 31 | 32 | func EmbeddedSshdConfig() map[string][]byte { 33 | return readPath(sshd, "sshd") 34 | } 35 | -------------------------------------------------------------------------------- /man/ssh-tpm-hostkeys.1.adoc: -------------------------------------------------------------------------------- 1 | = ssh-tpm-hostkeys (1) 2 | :doctype: manpage 3 | :manmanual: ssh-tpm-hostkeys manual 4 | 5 | == Name 6 | 7 | ssh-tpm-hostkeys - ssh-tpm-agent hostkey utility 8 | 9 | == Synopsis 10 | 11 | *ssh-tpm-hostkeys* 12 | 13 | *ssh-tpm-hostkeys* *--install-system-units* 14 | 15 | *ssh-tpm-hostkeys* *--install-sshd-config* 16 | 17 | == Description 18 | 19 | *ssh-tpm-hostkeys* displays the system host keys, and can install relevant 20 | systemd units and sshd configuration to use TPM backed host keys. 21 | 22 | == Options 23 | 24 | *--install-system-units*:: 25 | Installs systemd system units for using ssh-tpm-agent as a hostkey agent. 26 | 27 | *--install-sshd-config*:: 28 | Installs sshd configuration for the ssh-tpm-agent socket. 29 | 30 | == See Also 31 | 32 | *ssh-tpm-agent*(1), *ssh-agent*(1) 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go tests 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | jobs: 6 | test: 7 | name: Test 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | go: [1.24.x] 12 | os: [ubuntu-latest] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - name: Install Go ${{ matrix.go }} 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: ${{ matrix.go }} 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | with: 22 | fetch-depth: 0 23 | - name: Run tests 24 | run: go test ./... 25 | - name: Run go vet 26 | run: go vet ./... 27 | - name: staticcheck 28 | uses: dominikh/staticcheck-action@v1.3.1 29 | with: 30 | install-go: false 31 | -------------------------------------------------------------------------------- /internal/keyring/key.go: -------------------------------------------------------------------------------- 1 | package keyring 2 | 3 | import ( 4 | "github.com/awnumar/memcall" 5 | "golang.org/x/sys/unix" 6 | ) 7 | 8 | // Key is a boxed byte slice where we allocate the underlying memory with memcall 9 | type Key struct { 10 | b []byte 11 | } 12 | 13 | func (k *Key) Read() []byte { 14 | if k == nil { 15 | return []byte{} 16 | } 17 | return k.b 18 | } 19 | 20 | func (k *Key) Free() error { 21 | return memcall.Free(k.b) 22 | } 23 | 24 | func ReadKeyIntoMemory(id int) (*Key, error) { 25 | sz, err := unix.KeyctlBuffer(unix.KEYCTL_READ, int(id), nil, 0) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | buffer, err := memcall.Alloc(sz) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if _, err = unix.KeyctlBuffer(unix.KEYCTL_READ, int(id), buffer, 0); err != nil { 36 | return nil, err 37 | } 38 | 39 | if err := memcall.Lock(buffer); err != nil { 40 | return nil, err 41 | } 42 | 43 | return &Key{buffer}, nil 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 ssh-tpm-agent Authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-agent/testdata/script/agent_password.txt: -------------------------------------------------------------------------------- 1 | # Create an askpass binary 2 | exec go build -o askpass-test askpass.go 3 | exec ./askpass-test passphrase 4 | 5 | # Env 6 | env SSH_ASKPASS=./askpass-test 7 | env SSH_ASKPASS_REQUIRE=force 8 | 9 | # ssh sign file with password 10 | env _ASKPASS_PASSWORD=12345 11 | exec ssh-tpm-agent -d --no-load &agent& 12 | exec ssh-tpm-keygen -N $_ASKPASS_PASSWORD 13 | exec ssh-tpm-add 14 | stdout id_ecdsa.tpm 15 | exec ssh-add -l 16 | stdout ECDSA 17 | exec ssh-keygen -Y sign -n file -f .ssh/id_ecdsa.pub file_to_sign.txt 18 | stdin file_to_sign.txt 19 | exec ssh-keygen -Y check-novalidate -n file -f .ssh/id_ecdsa.pub -s file_to_sign.txt.sig 20 | exists file_to_sign.txt.sig 21 | exec ssh-add -D 22 | rm file_to_sign.txt.sig 23 | rm .ssh/id_ecdsa.tpm .ssh/id_ecdsa.pub 24 | 25 | -- file_to_sign.txt -- 26 | Hello World 27 | 28 | -- go.mod -- 29 | module example.com/askpass 30 | 31 | -- askpass.go -- 32 | package main 33 | 34 | import ( 35 | "fmt" 36 | "os" 37 | "strings" 38 | ) 39 | 40 | func main() { 41 | if strings.Contains(os.Args[1], "passphrase") { 42 | fmt.Println(os.Getenv("_ASKPASS_PASSWORD")) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/foxboron/ssh-tpm-agent 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/awnumar/memcall v0.4.0 9 | github.com/foxboron/go-tpm-keyfiles v0.0.0-20250318194951-cba49fbf70fa 10 | github.com/foxboron/ssh-tpm-ca-authority v0.0.0-20240831163633-e92b30331d2d 11 | github.com/google/go-tpm v0.9.3 12 | github.com/google/go-tpm-tools v0.4.4 13 | github.com/landlock-lsm/go-landlock v0.0.0-20241014143150-479ddab4c04c 14 | github.com/rogpeppe/go-internal v1.13.1 15 | golang.org/x/crypto v0.36.0 16 | golang.org/x/sys v0.31.0 17 | golang.org/x/term v0.30.0 18 | golang.org/x/text v0.23.0 19 | ) 20 | 21 | require ( 22 | github.com/coreos/go-oidc/v3 v3.12.0 // indirect 23 | github.com/go-jose/go-jose/v3 v3.0.3 // indirect 24 | github.com/go-jose/go-jose/v4 v4.0.2 // indirect 25 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect 26 | github.com/segmentio/ksuid v1.0.4 // indirect 27 | github.com/sigstore/sigstore v1.8.15 // indirect 28 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect 29 | golang.org/x/oauth2 v0.26.0 // indirect 30 | golang.org/x/tools v0.22.0 // indirect 31 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.70 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /internal/lsm/lsm.go: -------------------------------------------------------------------------------- 1 | package lsm 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "github.com/foxboron/ssh-tpm-agent/askpass" 8 | "github.com/landlock-lsm/go-landlock/landlock" 9 | ) 10 | 11 | var rules []landlock.Rule 12 | 13 | func HasLandlock() bool { 14 | _, ok := os.LookupEnv("SSH_TPM_LANDLOCK") 15 | return ok 16 | } 17 | 18 | func RestrictAdditionalPaths(r ...landlock.Rule) { 19 | rules = append(rules, r...) 20 | } 21 | 22 | func RestrictAgentFiles() { 23 | RestrictAdditionalPaths( 24 | // Probably what we need to do for most askpass binaries 25 | landlock.RWDirs( 26 | "/usr/lib", 27 | ).IgnoreIfMissing(), 28 | // Default Go paths 29 | landlock.ROFiles( 30 | "/proc/sys/net/core/somaxconn", 31 | "/etc/localtime", 32 | "/dev/null", 33 | ), 34 | // We almost always want to read the TPM 35 | landlock.RWFiles( 36 | "/dev/tpm0", 37 | "/dev/tpmrm0", 38 | ), 39 | // Ensure we can read+exec askpass binaries 40 | landlock.ROFiles( 41 | askpass.SSH_ASKPASS_DEFAULTS..., 42 | ).IgnoreIfMissing(), 43 | ) 44 | } 45 | 46 | func Restrict() error { 47 | if !HasLandlock() { 48 | return nil 49 | } 50 | slog.Debug("sandboxing with landlock") 51 | for _, r := range rules { 52 | slog.Debug("landlock", slog.Any("rule", r)) 53 | } 54 | landlock.V5.BestEffort().RestrictNet() 55 | return landlock.V5.BestEffort().RestrictPaths(rules...) 56 | } 57 | -------------------------------------------------------------------------------- /internal/keyring/keyring.go: -------------------------------------------------------------------------------- 1 | package keyring 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "golang.org/x/sys/unix" 8 | ) 9 | 10 | var ( 11 | SessionKeyring *Keyring = &Keyring{ringid: unix.KEY_SPEC_SESSION_KEYRING} 12 | ) 13 | 14 | type Keyring struct { 15 | ringid int 16 | } 17 | 18 | func (ring *Keyring) CreateKeyring() (*Keyring, error) { 19 | id, err := unix.KeyctlJoinSessionKeyring("ssh-tpm-agent") 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return &Keyring{ringid: id}, nil 25 | } 26 | 27 | func (k *Keyring) AddKey(name string, b []byte) error { 28 | slog.Debug("addkey", slog.String("name", name)) 29 | _, err := unix.AddKey("user", name, b, k.ringid) 30 | if err != nil { 31 | return fmt.Errorf("failed add-key: %v", err) 32 | } 33 | return nil 34 | } 35 | 36 | func (k *Keyring) ReadKey(name string) (*Key, error) { 37 | slog.Debug("readkey", slog.String("name", name)) 38 | id, err := unix.RequestKey("user", name, "", k.ringid) 39 | if err != nil { 40 | return nil, err 41 | } 42 | b, err := ReadKeyIntoMemory(id) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return b, err 47 | } 48 | 49 | func (k *Keyring) RemoveKey(name string) error { 50 | slog.Debug("removekey", slog.String("name", name)) 51 | id, err := unix.RequestKey("user", name, "", k.ringid) 52 | if err != nil { 53 | return fmt.Errorf("failed remove-key: %v", err) 54 | } 55 | _, err = unix.KeyctlInt(unix.KEYCTL_UNLINK, id, k.ringid, 0, 0) 56 | return err 57 | } 58 | -------------------------------------------------------------------------------- /man/ssh-tpm-add.1.adoc: -------------------------------------------------------------------------------- 1 | = ssh-tpm-add(1) 2 | :doctype: manpage 3 | :manmanual: ssh-tpm-add manual 4 | 5 | == Name 6 | 7 | ssh-tpm-add - adds private keys to the *ssh-tpm-agent* 8 | 9 | == Synopsis 10 | 11 | *ssh-tpm-add* 12 | 13 | *ssh-tpm-add* [__PATH__ ...] 14 | 15 | == Description 16 | 17 | *ssh-tpm-add* adds TPM wrapped private keys to *ssh-tpm-agent*(1). Any specified keys as arguments are added to the running agent. 18 | 19 | It requires the environment variable *SSH_TPM_AUTH_SOCK* to point at an active UNIX domain socket with an agent listening. 20 | 21 | If no files are given it will try to load the default keys *~/.ssh/id_ecdsa.tpm* and *~/.ssh/id_rsa.tpm*. 22 | 23 | == Environment 24 | *SSH_TPM_AUTH_SOCK*:: 25 | Identifies the path of a unix-domain socket for communication with the agent. 26 | + 27 | Default to _/var/tmp/ssh-tpm-agent.sock_. 28 | 29 | == Files 30 | 31 | _~/ssh/id_rsa.tpm_:: 32 | _~/ssh/id_ecdsa.tpm_:: 33 | Contains the ssh private keys used by *ssh-tpm-agent*. They are TPM 2.0 TSS key files and securely wrapped by the TPM. They can be shared publicly as they can only be used by the TPM they where created on. However it is probably better to not do that. 34 | 35 | _~/ssh/id_rsa.pub_:: 36 | _~/ssh/id_ecdsa.pub_:: 37 | Contains the ssh public keys. These can be shared publicly, and is the same format as the ones created by *ssh-keygen*(1). 38 | 39 | == See Also 40 | *ssh-add*(1), *ssh-agent*(1), *ssh*(1), *ssh-tpm-keygen*(1), *ssh-keygen*(1) 41 | 42 | == Notes, standards and other 43 | https://www.hansenpartnership.com/draft-bottomley-tpm2-keys.html[ASN.1 Specification for TPM 2.0 Key Files] 44 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-keygen/testdata/script/keygen.txt: -------------------------------------------------------------------------------- 1 | # Check we can create ecdsa keys 2 | exec ssh-tpm-keygen 3 | exists .ssh/id_ecdsa.pub 4 | exists .ssh/id_ecdsa.tpm 5 | rm .ssh 6 | 7 | # Check that we can create RSA keys 8 | exec ssh-tpm-keygen -t rsa 9 | exists .ssh/id_rsa.pub 10 | exists .ssh/id_rsa.tpm 11 | rm .ssh 12 | 13 | # Check if we can give it a new name 14 | stdin save_name.txt 15 | exec ssh-tpm-keygen 16 | exists .ssh/new_name.tpm 17 | exists .ssh/new_name.pub 18 | rm .ssh 19 | 20 | # Change passphrase 21 | exec ssh-tpm-keygen -N 1234 22 | exec ssh-tpm-keygen -p -N 1234 -P 12345 -f .ssh/id_ecdsa.tpm 23 | stdout 'new passphrase' 24 | rm .ssh 25 | 26 | # Create ssh key and import as TSS keys 27 | exec ssh-keygen -t ecdsa -f id_ecdsa -N '' 28 | exec ssh-tpm-keygen --import id_ecdsa -f id_ecdsa_tpm 29 | exists id_ecdsa 30 | exists id_ecdsa.pub 31 | exists id_ecdsa_tpm.tpm 32 | 33 | # Wrap a key with an EK and import the key 34 | getekcert 35 | exists srk.pem 36 | exec ssh-keygen -t ecdsa -b 256 -N '' -f ./ecdsa.key 37 | exec ssh-tpm-keygen --wrap-with srk.pem --wrap ecdsa.key -f wrapped_id_ecdsa 38 | exec ssh-tpm-keygen --import ./wrapped_id_ecdsa.tpm -f unwrapped_id_ecdsa 39 | exists unwrapped_id_ecdsa.tpm 40 | 41 | # Create hostkeys 42 | exec mkdir -p test/etc/ssh 43 | exec ssh-tpm-keygen -A -f test 44 | exists test/etc/ssh/ssh_tpm_host_rsa_key.tpm 45 | exists test/etc/ssh/ssh_tpm_host_ecdsa_key.tpm 46 | rm test 47 | 48 | # Create hierarchy hostkeys 49 | exec mkdir -p test/etc/ssh 50 | exec ssh-tpm-keygen -A -f test --hierarchy owner 51 | exists test/etc/ssh/ssh_tpm_host_rsa_key.pub 52 | exists test/etc/ssh/ssh_tpm_host_ecdsa_key.pub 53 | rm test 54 | 55 | -- save_name.txt -- 56 | .ssh/new_name 57 | -------------------------------------------------------------------------------- /internal/keyring/keyring_test.go: -------------------------------------------------------------------------------- 1 | package keyring 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "syscall" 7 | "testing" 8 | ) 9 | 10 | func TestSaveAndGetData(t *testing.T) { 11 | keyring, err := SessionKeyring.CreateKeyring() 12 | if err != nil { 13 | t.Fatalf("failed getting keyring: %v", err) 14 | } 15 | 16 | b := []byte("test string") 17 | if err := keyring.AddKey("test", b); err != nil { 18 | t.Fatalf("err: %v", err) 19 | } 20 | 21 | bb, err := keyring.ReadKey("test") 22 | if err != nil { 23 | t.Fatalf("err: %v", err) 24 | } 25 | if !bytes.Equal(b, bb.Read()) { 26 | t.Fatalf("strings not equal") 27 | } 28 | } 29 | 30 | func TestNoKey(t *testing.T) { 31 | keyring, err := SessionKeyring.CreateKeyring() 32 | if err != nil { 33 | t.Fatalf("failed getting keyring: %v", err) 34 | } 35 | _, err = keyring.ReadKey("this.key.does.not.exist") 36 | if !errors.Is(err, syscall.ENOKEY) { 37 | t.Fatalf("err: %v", err) 38 | } 39 | } 40 | 41 | func TestRemoveKey(t *testing.T) { 42 | keyring, err := SessionKeyring.CreateKeyring() 43 | if err != nil { 44 | t.Fatalf("failed getting keyring: %v", err) 45 | } 46 | b := []byte("test string") 47 | if err := keyring.AddKey("test-2", b); err != nil { 48 | t.Fatalf("err: %v", err) 49 | } 50 | 51 | bb, err := keyring.ReadKey("test-2") 52 | if err != nil { 53 | t.Fatalf("err: %v", err) 54 | } 55 | if !bytes.Equal(b, bb.Read()) { 56 | t.Fatalf("strings not equal") 57 | } 58 | 59 | if err = keyring.RemoveKey("test-2"); err != nil { 60 | t.Fatalf("failed removing key: %v", err) 61 | } 62 | _, err = keyring.ReadKey("test-2") 63 | if !errors.Is(err, syscall.ENOKEY) { 64 | t.Fatalf("we can still read the key") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/keyring/threadkeyring_test.go: -------------------------------------------------------------------------------- 1 | package keyring 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "syscall" 8 | "testing" 9 | ) 10 | 11 | var ( 12 | ctx = context.Background() 13 | ) 14 | 15 | func TestSaveAndGetDataThreaded(t *testing.T) { 16 | keyring, err := NewThreadKeyring(ctx, SessionKeyring) 17 | if err != nil { 18 | t.Fatalf("failed getting keyring: %v", err) 19 | } 20 | 21 | b := []byte("test string") 22 | if err := keyring.AddKey("test", b); err != nil { 23 | t.Fatalf("err: %v", err) 24 | } 25 | 26 | bb, err := keyring.ReadKey("test") 27 | if err != nil { 28 | t.Fatalf("err: %v", err) 29 | } 30 | if !bytes.Equal(b, bb.Read()) { 31 | t.Fatalf("strings not equal") 32 | } 33 | } 34 | 35 | func TestNoKeyThreaded(t *testing.T) { 36 | keyring, err := NewThreadKeyring(ctx, SessionKeyring) 37 | if err != nil { 38 | t.Fatalf("failed getting keyring: %v", err) 39 | } 40 | _, err = keyring.ReadKey("this.key.does.not.exist") 41 | if !errors.Is(err, syscall.ENOKEY) { 42 | t.Fatalf("err: %v", err) 43 | } 44 | } 45 | 46 | func TestRemoveKeyThreaded(t *testing.T) { 47 | keyring, err := NewThreadKeyring(ctx, SessionKeyring) 48 | if err != nil { 49 | t.Fatalf("failed getting keyring: %v", err) 50 | } 51 | b := []byte("test string") 52 | if err := keyring.AddKey("test-2", b); err != nil { 53 | t.Fatalf("err: %v", err) 54 | } 55 | 56 | bb, err := keyring.ReadKey("test-2") 57 | if err != nil { 58 | t.Fatalf("err: %v", err) 59 | } 60 | if !bytes.Equal(b, bb.Read()) { 61 | t.Fatalf("strings not equal") 62 | } 63 | 64 | if err = keyring.RemoveKey("test-2"); err != nil { 65 | t.Fatalf("failed removing key: %v", err) 66 | } 67 | _, err = keyring.ReadKey("test-2") 68 | if !errors.Is(err, syscall.ENOKEY) { 69 | t.Fatalf("we can still read the key") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-hostkeys/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net" 8 | "os" 9 | 10 | "github.com/foxboron/ssh-tpm-agent/utils" 11 | sshagent "golang.org/x/crypto/ssh/agent" 12 | ) 13 | 14 | var Version string 15 | 16 | const usage = `Usage: 17 | ssh-tpm-hostkeys 18 | ssh-tpm-hostkeys --install-system-units 19 | 20 | Options: 21 | --install-system-units Installs systemd system units for using ssh-tpm-agent 22 | as a hostkey agent. 23 | --install-sshd-config Installs sshd configuration for the ssh-tpm-agent socket. 24 | 25 | Display host keys.` 26 | 27 | func main() { 28 | flag.Usage = func() { 29 | fmt.Println(usage) 30 | } 31 | 32 | var ( 33 | installSystemUnits bool 34 | installSshdConfig bool 35 | ) 36 | 37 | flag.BoolVar(&installSystemUnits, "install-system-units", false, "install systemd system units") 38 | flag.BoolVar(&installSshdConfig, "install-sshd-config", false, "install sshd config") 39 | flag.Parse() 40 | 41 | if installSystemUnits { 42 | if err := utils.InstallHostkeyUnits(); err != nil { 43 | log.Fatal(err) 44 | } 45 | 46 | fmt.Println("Enable with: systemctl enable --now ssh-tpm-agent.socket") 47 | os.Exit(0) 48 | } 49 | if installSshdConfig { 50 | if err := utils.InstallSshdConf(); err != nil { 51 | log.Fatal(err) 52 | } 53 | os.Exit(0) 54 | } 55 | 56 | socket := "/var/tmp/ssh-tpm-agent.sock" 57 | if socket == "" { 58 | fmt.Println("Can't find any ssh-tpm-agent socket.") 59 | os.Exit(1) 60 | } 61 | 62 | conn, err := net.Dial("unix", socket) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | defer conn.Close() 67 | 68 | client := sshagent.NewClient(conn) 69 | 70 | keys, err := client.List() 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | 75 | for _, k := range keys { 76 | fmt.Println(k.String()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX := /usr/local 2 | BINDIR := $(PREFIX)/bin 3 | LIBDIR := $(PREFIX)/lib 4 | SHRDIR := $(PREFIX)/share 5 | MANDIR := $(PREFIX)/share/man 6 | BINS = $(filter-out %_test.go,$(notdir $(wildcard cmd/*))) 7 | TAG = $(shell git describe --abbrev=0 --tags) 8 | VERSION = $(shell git describe --abbrev=7 | sed 's/-/./g;s/^v//;') 9 | 10 | MANPAGES = \ 11 | man/ssh-tpm-hostkeys.1 \ 12 | man/ssh-tpm-agent.1 \ 13 | man/ssh-tpm-keygen.1 \ 14 | man/ssh-tpm-add.1 15 | 16 | all: man build 17 | build: $(BINS) 18 | man: $(MANPAGES) 19 | 20 | .PHONY: $(addprefix bin/,$(BINS)) 21 | $(addprefix bin/,$(BINS)): 22 | go build -buildmode=pie -trimpath -o $@ ./cmd/$(@F) 23 | 24 | # TODO: Needs to be better written 25 | $(BINS): $(addprefix bin/,$(BINS)) 26 | 27 | 28 | .PHONY: install 29 | install: $(BINS) 30 | @for bin in $(BINS); do \ 31 | install -Dm755 "bin/$$bin" -t '$(DESTDIR)$(BINDIR)'; \ 32 | done; 33 | for manfile in $(MANPAGES); do \ 34 | install -Dm644 "$$manfile" -t '$(DESTDIR)$(MANDIR)/man'"$${manfile##*.}"; \ 35 | done; 36 | @install -dm755 $(DESTDIR)$(LIBDIR)/systemd/system 37 | @install -dm755 $(DESTDIR)$(LIBDIR)/systemd/user 38 | @DESTDIR=$(DESTDIR) PREFIX=$(PREFIX) bin/ssh-tpm-hostkeys --install-system-units 39 | @TEMPLATE_BINARY=$(BINDIR)/ssh-tpm-agent DESTDIR=$(DESTDIR) PREFIX=$(PREFIX) bin/ssh-tpm-agent --install-user-units --install-system 40 | 41 | .PHONY: lint 42 | lint: 43 | go vet ./... 44 | staticcheck ./... 45 | 46 | .PHONY: test 47 | test: 48 | go test -v ./... 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf bin/ 53 | rm -f $(MANPAGES) 54 | 55 | sign-release: 56 | gh release download $(TAG) 57 | gpg --sign ssh-tpm-agent-$(TAG)-linux-amd64.tar.gz 58 | gpg --sign ssh-tpm-agent-$(TAG)-linux-arm64.tar.gz 59 | gpg --sign ssh-tpm-agent-$(TAG)-linux-arm.tar.gz 60 | bash -c "gh release upload $(TAG) ssh-tpm-agent-$(TAG)*.gpg" 61 | 62 | man/%: man/%.adoc Makefile 63 | asciidoctor -b manpage -amansource="ssh-tpm-agent $(VERSION)" -amanversion="$(VERSION)" $< 64 | -------------------------------------------------------------------------------- /key/signer.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "crypto" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | 10 | "github.com/google/go-tpm/tpm2" 11 | "github.com/google/go-tpm/tpm2/transport" 12 | 13 | keyfile "github.com/foxboron/go-tpm-keyfiles" 14 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 15 | ) 16 | 17 | // Shim for keyfile.TPMKeySigner 18 | // We need access to the SSHTPMKey to change the userauth for caching 19 | type SSHKeySigner struct { 20 | *keyfile.TPMKeySigner 21 | key SSHTPMKeys 22 | keyring *keyring.ThreadKeyring 23 | tpm func() transport.TPMCloser 24 | ownerauth func() ([]byte, error) 25 | } 26 | 27 | var _ crypto.Signer = &SSHKeySigner{} 28 | 29 | func (t *SSHKeySigner) Sign(r io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { 30 | var b []byte 31 | var err error 32 | switch key := t.key.(type) { 33 | case *HierSSHTPMKey: 34 | var digestalg tpm2.TPMAlgID 35 | switch opts.HashFunc() { 36 | case crypto.SHA256: 37 | digestalg = tpm2.TPMAlgSHA256 38 | case crypto.SHA384: 39 | digestalg = tpm2.TPMAlgSHA384 40 | case crypto.SHA512: 41 | digestalg = tpm2.TPMAlgSHA512 42 | default: 43 | return nil, fmt.Errorf("%s is not a supported hashing algorithm", opts.HashFunc()) 44 | } 45 | b, err = key.Sign(t.tpm(), []byte(nil), []byte(nil), digest, digestalg) 46 | case *SSHTPMKey: 47 | b, err = t.TPMKeySigner.Sign(r, digest, opts) 48 | default: 49 | return nil, fmt.Errorf("this should not happen") 50 | } 51 | 52 | if errors.Is(err, tpm2.TPMRCAuthFail) { 53 | slog.Debug("removed cached userauth for key", slog.Any("err", err), slog.String("desc", t.key.GetDescription())) 54 | t.keyring.RemoveKey(t.key.Fingerprint()) 55 | } 56 | return b, err 57 | } 58 | 59 | func NewSSHKeySigner(k SSHTPMKeys, keyring *keyring.ThreadKeyring, ownerAuth func() ([]byte, error), tpm func() transport.TPMCloser, auth func(*keyfile.TPMKey) ([]byte, error)) *SSHKeySigner { 60 | return &SSHKeySigner{ 61 | TPMKeySigner: keyfile.NewTPMKeySigner(k.GetTPMKey(), ownerAuth, tpm, auth), 62 | keyring: keyring, 63 | tpm: tpm, 64 | ownerauth: ownerAuth, 65 | key: k, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /key/hierarchy_keys_test.go: -------------------------------------------------------------------------------- 1 | package key_test 2 | 3 | import ( 4 | "crypto" 5 | "io" 6 | "testing" 7 | 8 | keyfile "github.com/foxboron/go-tpm-keyfiles" 9 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 10 | "github.com/foxboron/ssh-tpm-agent/key" 11 | "github.com/foxboron/ssh-tpm-agent/utils" 12 | "github.com/google/go-tpm/tpm2" 13 | "github.com/google/go-tpm/tpm2/transport" 14 | "github.com/google/go-tpm/tpm2/transport/simulator" 15 | ) 16 | 17 | func TestHierKey(t *testing.T) { 18 | tpm, err := utils.GetFixedSim() 19 | if err != nil { 20 | t.Fatalf("%v", err) 21 | } 22 | defer tpm.Close() 23 | 24 | hkey, err := key.CreateHierarchyKey(tpm, tpm2.TPMAlgECC, tpm2.TPMRHOwner, "") 25 | if err != nil { 26 | t.Fatalf("%v", err) 27 | } 28 | defer hkey.FlushHandle(tpm) 29 | 30 | if hkey.Fingerprint() != "SHA256:8kry+y93GpsJYho0GoIUpC6Ja7KFHajgqqXPTadlCPg" { 31 | t.Fatalf("ssh key fingerprint does not match") 32 | } 33 | } 34 | 35 | func TestHierKeySigning(t *testing.T) { 36 | tpm, err := simulator.OpenSimulator() 37 | if err != nil { 38 | t.Fatalf("%v", err) 39 | } 40 | defer tpm.Close() 41 | 42 | hkey, err := key.CreateHierarchyKey(tpm, tpm2.TPMAlgECC, tpm2.TPMRHOwner, "") 43 | if err != nil { 44 | t.Fatalf("%v", err) 45 | } 46 | defer hkey.FlushHandle(tpm) 47 | 48 | h := crypto.SHA256.New() 49 | h.Write([]byte("message")) 50 | b := h.Sum(nil) 51 | 52 | _, err = hkey.Sign(tpm, []byte(nil), []byte(nil), b[:], tpm2.TPMAlgSHA256) 53 | if err != nil { 54 | t.Fatalf("%v", err) 55 | } 56 | } 57 | 58 | func TestHierKeySigner(t *testing.T) { 59 | tpm, err := simulator.OpenSimulator() 60 | if err != nil { 61 | t.Fatalf("%v", err) 62 | } 63 | defer tpm.Close() 64 | 65 | hkey, err := key.CreateHierarchyKey(tpm, tpm2.TPMAlgECC, tpm2.TPMRHOwner, "") 66 | if err != nil { 67 | t.Fatalf("%v", err) 68 | } 69 | defer hkey.FlushHandle(tpm) 70 | signer := hkey.Signer(&keyring.ThreadKeyring{}, 71 | func() ([]byte, error) { return []byte(nil), nil }, 72 | func() transport.TPMCloser { return tpm }, 73 | func(_ *keyfile.TPMKey) ([]byte, error) { return []byte(nil), nil }, 74 | ) 75 | h := crypto.SHA256.New() 76 | h.Write([]byte("message")) 77 | b := h.Sum(nil) 78 | _, err = signer.Sign((io.Reader)(nil), b[:], crypto.SHA256) 79 | if err != nil { 80 | t.Fatalf("message") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload binaries 2 | on: 3 | release: 4 | types: [published] 5 | push: 6 | pull_request: 7 | permissions: 8 | contents: read 9 | jobs: 10 | build: 11 | name: Build binaries 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | include: 16 | - {GOOS: linux, GOARCH: amd64} 17 | - {GOOS: linux, GOARCH: arm, GOARM: 6} 18 | - {GOOS: linux, GOARCH: arm64} 19 | steps: 20 | - name: Install Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: 1.x 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | - name: Build binary 29 | run: | 30 | cp LICENSE "$RUNNER_TEMP/LICENSE" 31 | echo -e "\n---\n" >> "$RUNNER_TEMP/LICENSE" 32 | curl -L "https://go.dev/LICENSE?m=text" >> "$RUNNER_TEMP/LICENSE" 33 | VERSION="$(git describe --tags 2> /dev/null || echo "WIP")" 34 | DIR="$(mktemp -d)" 35 | mkdir "$DIR/ssh-tpm-agent" 36 | cp "$RUNNER_TEMP/LICENSE" "$DIR/ssh-tpm-agent" 37 | go build -o "$DIR/ssh-tpm-agent" -ldflags "-X main.Version=$VERSION" -trimpath ./cmd/... 38 | tar -cvzf "ssh-tpm-agent-$VERSION-$GOOS-$GOARCH.tar.gz" -C "$DIR" ssh-tpm-agent 39 | env: 40 | CGO_ENABLED: 0 41 | GOOS: ${{ matrix.GOOS }} 42 | GOARCH: ${{ matrix.GOARCH }} 43 | GOARM: ${{ matrix.GOARM }} 44 | - name: Upload workflow artifacts 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: ssh-tpm-agent-binaries-${{ matrix.GOOS }}-${{ matrix.GOARCH }} 48 | path: ssh-tpm-agent-* 49 | upload: 50 | name: Upload release binaries 51 | if: github.event_name == 'release' 52 | needs: build 53 | permissions: 54 | contents: write 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Download workflow artifacts 58 | uses: actions/download-artifact@v4 59 | with: 60 | pattern: ssh-tpm-agent-binaries-* 61 | merge-multiple: true 62 | - name: Upload release artifacts 63 | run: gh release upload "$GITHUB_REF_NAME" ssh-tpm-agent-* 64 | env: 65 | GH_REPO: ${{ github.repository }} 66 | GH_TOKEN: ${{ github.token }} 67 | -------------------------------------------------------------------------------- /agent/client.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "errors" 5 | 6 | keyfile "github.com/foxboron/go-tpm-keyfiles" 7 | "github.com/foxboron/ssh-tpm-agent/key" 8 | "golang.org/x/crypto/ssh" 9 | sshagent "golang.org/x/crypto/ssh/agent" 10 | ) 11 | 12 | // type AddedKey struct { 13 | // PrivateKey *keyfile.TPMKey 14 | // Certificate *ssh.Certificate 15 | // Comment string 16 | // LifetimeSecs uint32 17 | // ConfirmBeforeUse bool 18 | // ConstraintExtensions []sshagent.ConstraintExtension 19 | // } 20 | 21 | type TPMKeyMsg struct { 22 | Type string `sshtype:"17|25"` 23 | PrivateKey []byte 24 | CertBytes []byte 25 | Constraints []byte `ssh:"rest"` 26 | } 27 | 28 | func MarshalTPMKeyMsg(cert *sshagent.AddedKey) []byte { 29 | var req []byte 30 | var constraints []byte 31 | 32 | if secs := cert.LifetimeSecs; secs != 0 { 33 | constraints = append(constraints, ssh.Marshal(constrainLifetimeAgentMsg{secs})...) 34 | } 35 | 36 | if cert.ConfirmBeforeUse { 37 | constraints = append(constraints, agentConstrainConfirm) 38 | } 39 | 40 | var certBytes []byte 41 | if cert.Certificate != nil { 42 | certBytes = cert.Certificate.Marshal() 43 | } 44 | 45 | switch k := cert.PrivateKey.(type) { 46 | case *keyfile.TPMKey: 47 | req = ssh.Marshal(TPMKeyMsg{ 48 | Type: "TPMKEY", 49 | PrivateKey: k.Bytes(), 50 | CertBytes: certBytes, 51 | Constraints: constraints, 52 | }) 53 | case *key.SSHTPMKey: 54 | req = ssh.Marshal(TPMKeyMsg{ 55 | Type: "TPMKEY", 56 | PrivateKey: k.Bytes(), 57 | CertBytes: certBytes, 58 | Constraints: constraints, 59 | }) 60 | 61 | } 62 | return req 63 | } 64 | 65 | func ParseTPMKeyMsg(req []byte) (*key.SSHTPMKey, error) { 66 | var ( 67 | k TPMKeyMsg 68 | tpmkey *key.SSHTPMKey 69 | err error 70 | ) 71 | 72 | if err := ssh.Unmarshal(req, &k); err != nil { 73 | return nil, err 74 | } 75 | 76 | if len(k.PrivateKey) != 0 { 77 | tpmkey, err = key.Decode(k.PrivateKey) 78 | if err != nil { 79 | return nil, err 80 | } 81 | } 82 | 83 | if len(k.CertBytes) != 0 { 84 | pubKey, err := ssh.ParsePublicKey(k.CertBytes) 85 | if err != nil { 86 | return nil, err 87 | } 88 | cert, ok := pubKey.(*ssh.Certificate) 89 | if !ok { 90 | return nil, errors.New("agent: bad tpm thing") 91 | } 92 | tpmkey.Certificate = cert 93 | } 94 | 95 | return tpmkey, nil 96 | } 97 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-agent/testdata/script/agent.txt: -------------------------------------------------------------------------------- 1 | # Ensure we can run the agent 2 | exec ssh-tpm-agent -d --no-load &agent& 3 | exec sleep .2s 4 | exec ssh-tpm-keygen 5 | exec ssh-tpm-keygen -t rsa 6 | exec ssh-tpm-add 7 | stdout id_ecdsa.tpm 8 | stdout id_rsa.tpm 9 | exec ssh-add -l 10 | stdout ECDSA 11 | stdout RSA 12 | exec ssh-add -D 13 | 14 | 15 | # ssh sign file - ecdsa 16 | exec ssh-tpm-add .ssh/id_ecdsa.tpm 17 | exec ssh-add -l 18 | stdout ECDSA 19 | exec ssh-keygen -Y sign -n file -f .ssh/id_ecdsa.pub file_to_sign.txt 20 | stdin file_to_sign.txt 21 | exec ssh-keygen -Y check-novalidate -n file -f .ssh/id_ecdsa.pub -s file_to_sign.txt.sig 22 | exists file_to_sign.txt.sig 23 | exec ssh-add -D 24 | rm file_to_sign.txt.sig 25 | 26 | 27 | # ssh sign file - rsa 28 | exec ssh-tpm-add .ssh/id_rsa.tpm 29 | exec ssh-add -l 30 | stdout RSA 31 | exec ssh-keygen -Y sign -n file -f .ssh/id_rsa.pub file_to_sign.txt 32 | stdin file_to_sign.txt 33 | exec ssh-keygen -Y check-novalidate -n file -f .ssh/id_rsa.pub -s file_to_sign.txt.sig 34 | exists file_to_sign.txt.sig 35 | rm file_to_sign.txt.sig 36 | exec ssh-add -D 37 | 38 | 39 | # ssh create a certificate - ecdsa 40 | exec ssh-keygen -t ecdsa -f id_ca -N '' 41 | exec ssh-keygen -s id_ca -n fox -I 'cert' -z '0001' .ssh/id_ecdsa.pub 42 | exists .ssh/id_ecdsa-cert.pub 43 | exec ssh-tpm-add .ssh/id_ecdsa.tpm 44 | stdout id_ecdsa.tpm 45 | stdout id_ecdsa-cert.pub 46 | exec ssh-add -l 47 | stdout \(ECDSA\) 48 | stdout \(ECDSA-CERT\) 49 | exec ssh-keygen -Y sign -n file -f .ssh/id_ecdsa-cert.pub file_to_sign.txt 50 | stdin file_to_sign.txt 51 | exec ssh-keygen -Y check-novalidate -n file -f .ssh/id_ecdsa-cert.pub -s file_to_sign.txt.sig 52 | exists file_to_sign.txt.sig 53 | rm file_to_sign.txt.sig 54 | exec ssh-add -D 55 | rm id_ca id_ca.pub 56 | 57 | 58 | # ssh create a certificate - rsa 59 | exec ssh-keygen -t rsa -f id_ca -N '' 60 | exec ssh-keygen -s id_ca -n fox -I 'cert' -z '0001' .ssh/id_rsa.pub 61 | exists .ssh/id_rsa-cert.pub 62 | exec ssh-tpm-add .ssh/id_rsa.tpm 63 | exec ssh-add -l 64 | stdout \(RSA\) 65 | stdout \(RSA-CERT\) 66 | exec ssh-keygen -Y sign -n file -f .ssh/id_rsa-cert.pub file_to_sign.txt 67 | stdin file_to_sign.txt 68 | exec ssh-keygen -Y check-novalidate -n file -f .ssh/id_rsa-cert.pub -s file_to_sign.txt.sig 69 | exists file_to_sign.txt.sig 70 | rm file_to_sign.txt.sig 71 | exec ssh-add -D 72 | rm id_ca id_ca.pub 73 | 74 | 75 | -- file_to_sign.txt -- 76 | Hello World 77 | -------------------------------------------------------------------------------- /agent/gocrypto.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | // Code taken from crypto/x/ssh/agent 4 | 5 | const ( 6 | // 3.7 Key constraint identifiers 7 | agentConstrainLifetime = 1 8 | agentConstrainConfirm = 2 9 | // Constraint extension identifier up to version 2 of the protocol. A 10 | // backward incompatible change will be required if we want to add support 11 | // for SSH_AGENT_CONSTRAIN_MAXSIGN which uses the same ID. 12 | // agentConstrainExtensionV00 = 3 13 | // Constraint extension identifier in version 3 and later of the protocol. 14 | // agentConstrainExtension = 255 15 | ) 16 | 17 | // type constrainExtensionAgentMsg struct { 18 | // ExtensionName string `sshtype:"255|3"` 19 | // ExtensionDetails []byte 20 | 21 | // // Rest is a field used for parsing, not part of message 22 | // Rest []byte `ssh:"rest"` 23 | // } 24 | 25 | // 3.7 Key constraint identifiers 26 | type constrainLifetimeAgentMsg struct { 27 | LifetimeSecs uint32 `sshtype:"1"` 28 | } 29 | 30 | // func parseConstraints(constraints []byte) (lifetimeSecs uint32, confirmBeforeUse bool, extensions []sshagent.ConstraintExtension, err error) { 31 | // for len(constraints) != 0 { 32 | // switch constraints[0] { 33 | // case agentConstrainLifetime: 34 | // lifetimeSecs = binary.BigEndian.Uint32(constraints[1:5]) 35 | // constraints = constraints[5:] 36 | // case agentConstrainConfirm: 37 | // confirmBeforeUse = true 38 | // constraints = constraints[1:] 39 | // // case agentConstrainExtension, agentConstrainExtensionV00: 40 | // // var msg constrainExtensionAgentMsg 41 | // // if err = ssh.Unmarshal(constraints, &msg); err != nil { 42 | // // return 0, false, nil, err 43 | // // } 44 | // // extensions = append(extensions, sshagent.ConstraintExtension{ 45 | // // ExtensionName: msg.ExtensionName, 46 | // // ExtensionDetails: msg.ExtensionDetails, 47 | // // }) 48 | // // constraints = msg.Rest 49 | // default: 50 | // return 0, false, nil, fmt.Errorf("unknown constraint type: %d", constraints[0]) 51 | // } 52 | // } 53 | // return 54 | // } 55 | 56 | // func setConstraints(key *key.SSHTPMKey, constraintBytes []byte) error { 57 | // lifetimeSecs, confirmBeforeUse, constraintExtensions, err := parseConstraints(constraintBytes) 58 | // if err != nil { 59 | // return err 60 | // } 61 | 62 | // key.LifetimeSecs = lifetimeSecs 63 | // key.ConfirmBeforeUse = confirmBeforeUse 64 | // key.ConstraintExtensions = constraintExtensions 65 | // return nil 66 | // } 67 | -------------------------------------------------------------------------------- /internal/keyring/threadkeyring.go: -------------------------------------------------------------------------------- 1 | package keyring 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | "sync" 7 | ) 8 | 9 | // ThreadKeyring runs Keyring in a dedicated OS Thread 10 | type ThreadKeyring struct { 11 | wg sync.WaitGroup 12 | addkey chan *addkeyMsg 13 | removekey chan *removekeyMsg 14 | readkey chan *readkeyMsg 15 | } 16 | 17 | type addkeyMsg struct { 18 | name string 19 | key []byte 20 | cb chan error 21 | } 22 | 23 | type removekeyMsg struct { 24 | name string 25 | cb chan error 26 | } 27 | 28 | type readkeyRet struct { 29 | key *Key 30 | err error 31 | } 32 | 33 | type readkeyMsg struct { 34 | name string 35 | cb chan *readkeyRet 36 | } 37 | 38 | func (tk *ThreadKeyring) Wait() { 39 | tk.wg.Wait() 40 | } 41 | 42 | func (tk *ThreadKeyring) AddKey(name string, key []byte) error { 43 | cb := make(chan error) 44 | tk.addkey <- &addkeyMsg{name, key, cb} 45 | return <-cb 46 | } 47 | 48 | func (tk *ThreadKeyring) RemoveKey(name string) error { 49 | cb := make(chan error) 50 | tk.removekey <- &removekeyMsg{name, cb} 51 | return <-cb 52 | } 53 | 54 | func (tk *ThreadKeyring) ReadKey(name string) (*Key, error) { 55 | cb := make(chan *readkeyRet) 56 | tk.readkey <- &readkeyMsg{name, cb} 57 | ret := <-cb 58 | if ret.err != nil { 59 | return nil, ret.err 60 | } 61 | return ret.key, nil 62 | } 63 | 64 | func NewThreadKeyring(ctx context.Context, keyring *Keyring) (*ThreadKeyring, error) { 65 | var err error 66 | var tk ThreadKeyring 67 | 68 | tk.addkey = make(chan *addkeyMsg) 69 | tk.removekey = make(chan *removekeyMsg) 70 | tk.readkey = make(chan *readkeyMsg) 71 | 72 | // Channel for initialization to prevent Data Race 73 | errCh := make(chan error, 1) 74 | 75 | tk.wg.Add(1) 76 | go func() { 77 | var ak *Keyring 78 | runtime.LockOSThread() 79 | ak, err = keyring.CreateKeyring() 80 | if err != nil { 81 | errCh <- err 82 | return 83 | } 84 | errCh <- nil 85 | for { 86 | select { 87 | case msg := <-tk.addkey: 88 | msg.cb <- ak.AddKey(msg.name, msg.key) 89 | case msg := <-tk.readkey: 90 | key, err := ak.ReadKey(msg.name) 91 | msg.cb <- &readkeyRet{key, err} 92 | case msg := <-tk.removekey: 93 | msg.cb <- ak.RemoveKey(msg.name) 94 | case <-ctx.Done(): 95 | return 96 | } 97 | } 98 | }() 99 | 100 | // Wait for initialization to complete 101 | if err := <-errCh; err != nil { 102 | return nil, err 103 | } 104 | 105 | return &tk, err 106 | } 107 | -------------------------------------------------------------------------------- /utils/tpm.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | "path" 8 | "sync" 9 | 10 | "github.com/google/go-tpm/tpm2" 11 | "github.com/google/go-tpm/tpm2/transport" 12 | "github.com/google/go-tpm/tpm2/transport/linuxtpm" 13 | "github.com/google/go-tpm/tpm2/transport/simulator" 14 | "github.com/google/go-tpm/tpmutil" 15 | 16 | sim "github.com/google/go-tpm-tools/simulator" 17 | ) 18 | 19 | // shadow the unexported interface from go-tpm 20 | type handle interface { 21 | HandleValue() uint32 22 | KnownName() *tpm2.TPM2BName 23 | } 24 | 25 | // Helper to flush handles 26 | func FlushHandle(tpm transport.TPM, h handle) { 27 | flushSrk := tpm2.FlushContext{FlushHandle: h} 28 | flushSrk.Execute(tpm) 29 | } 30 | 31 | var swtpmPath = "/var/tmp/ssh-tpm-agent" 32 | 33 | // TPM represents a connection to a TPM simulator. 34 | type TPMCloser struct { 35 | transport io.ReadWriteCloser 36 | } 37 | 38 | // Send implements the TPM interface. 39 | func (t *TPMCloser) Send(input []byte) ([]byte, error) { 40 | return tpmutil.RunCommandRaw(t.transport, input) 41 | } 42 | 43 | // Close implements the TPM interface. 44 | func (t *TPMCloser) Close() error { 45 | return t.transport.Close() 46 | } 47 | 48 | var ( 49 | once sync.Once 50 | s transport.TPMCloser 51 | ) 52 | 53 | func GetFixedSim() (transport.TPMCloser, error) { 54 | var ss *sim.Simulator 55 | var err error 56 | once.Do(func() { 57 | ss, err = sim.GetWithFixedSeedInsecure(123456) 58 | s = &TPMCloser{ss} 59 | }) 60 | return s, err 61 | } 62 | 63 | var cache struct { 64 | sync.Once 65 | tpm transport.TPMCloser 66 | err error 67 | } 68 | 69 | // Smaller wrapper for getting the correct TPM instance 70 | func TPM(f bool) (transport.TPMCloser, error) { 71 | cache.Do(func() { 72 | if f || os.Getenv("SSH_TPM_AGENT_SWTPM") != "" { 73 | if _, err := os.Stat(swtpmPath); errors.Is(err, os.ErrNotExist) { 74 | os.MkdirTemp(path.Dir(swtpmPath), path.Base(swtpmPath)) 75 | } 76 | cache.tpm, cache.err = simulator.OpenSimulator() 77 | } else if f || os.Getenv("_SSH_TPM_AGENT_SIMULATOR") != "" { 78 | // Implements an insecure fixed thing 79 | cache.tpm, cache.err = GetFixedSim() 80 | } else { 81 | cache.tpm, cache.err = linuxtpm.Open("/dev/tpmrm0") 82 | } 83 | }) 84 | return cache.tpm, cache.err 85 | } 86 | 87 | func EnvSocketPath(socketPath string) string { 88 | // Find a default socket name from ssh-tpm-agent.service 89 | if val, ok := os.LookupEnv("SSH_TPM_AUTH_SOCK"); ok && socketPath == "" { 90 | return val 91 | } 92 | 93 | dir := os.Getenv("XDG_RUNTIME_DIR") 94 | if dir == "" { 95 | dir = "/var/tmp" 96 | } 97 | return path.Join(dir, "ssh-tpm-agent.sock") 98 | } 99 | -------------------------------------------------------------------------------- /cmd/scripts_test.go: -------------------------------------------------------------------------------- 1 | package script_tests 2 | 3 | import ( 4 | "encoding/pem" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "testing" 11 | "time" 12 | 13 | keyfile "github.com/foxboron/go-tpm-keyfiles" 14 | "github.com/foxboron/go-tpm-keyfiles/pkix" 15 | "github.com/foxboron/ssh-tpm-agent/utils" 16 | "github.com/google/go-tpm/tpm2" 17 | "github.com/rogpeppe/go-internal/testscript" 18 | ) 19 | 20 | func ScriptsWithPath(t *testing.T, path string) { 21 | t.Parallel() 22 | tmp := t.TempDir() 23 | c := exec.Command("go", "build", "-buildmode=pie", "-o", tmp, "../cmd/...") 24 | out, err := c.CombinedOutput() 25 | if err != nil { 26 | t.Fatal(string(out)) 27 | } 28 | testscript.Run(t, testscript.Params{ 29 | Deadline: time.Now().Add(5 * time.Second), 30 | Setup: func(e *testscript.Env) error { 31 | e.Setenv("PATH", tmp+string(filepath.ListSeparator)+e.Getenv("PATH")) 32 | e.Vars = append(e.Vars, "_SSH_TPM_AGENT_SIMULATOR=1") 33 | e.Vars = append(e.Vars, fmt.Sprintf("SSH_AUTH_SOCK=%s/agent.sock", e.WorkDir)) 34 | e.Vars = append(e.Vars, fmt.Sprintf("SSH_TPM_AUTH_SOCK=%s/agent.sock", e.WorkDir)) 35 | e.Vars = append(e.Vars, fmt.Sprintf("HOME=%s", e.WorkDir)) 36 | return nil 37 | }, 38 | Dir: path, 39 | Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ 40 | // Create an EK certificate from our fixed seed simulator 41 | "getekcert": func(ts *testscript.TestScript, neg bool, args []string) { 42 | tpm, err := utils.GetFixedSim() 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | defer tpm.Close() 47 | rsp, err := tpm2.CreatePrimary{ 48 | PrimaryHandle: tpm2.AuthHandle{ 49 | Handle: tpm2.TPMRHOwner, 50 | Auth: tpm2.PasswordAuth([]byte(nil)), 51 | }, 52 | InSensitive: tpm2.TPM2BSensitiveCreate{ 53 | Sensitive: &tpm2.TPMSSensitiveCreate{ 54 | UserAuth: tpm2.TPM2BAuth{ 55 | Buffer: []byte(nil), 56 | }, 57 | }, 58 | }, 59 | InPublic: tpm2.New2B(keyfile.ECCSRK_H2_Template), 60 | }.Execute(tpm) 61 | if err != nil { 62 | log.Fatalf("failed creating primary key: %v", err) 63 | } 64 | keyfile.FlushHandle(tpm, rsp.ObjectHandle) 65 | srkPublic, err := rsp.OutPublic.Contents() 66 | if err != nil { 67 | log.Fatalf("failed getting srk public content: %v", err) 68 | } 69 | b, err := pkix.FromTPMPublic(srkPublic) 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | if err := os.WriteFile(ts.MkAbs("srk.pem"), 74 | pem.EncodeToMemory(&pem.Block{ 75 | Type: "PUBLIC KEY", 76 | Bytes: b, 77 | }), 0o664); err != nil { 78 | log.Fatal(err) 79 | } 80 | }, 81 | }, 82 | }) 83 | } 84 | 85 | func TestAgent(t *testing.T) { 86 | ScriptsWithPath(t, "ssh-tpm-agent/testdata/script") 87 | } 88 | 89 | func TestKeygen(t *testing.T) { 90 | ScriptsWithPath(t, "ssh-tpm-keygen/testdata/script") 91 | } 92 | 93 | // func TestAdd(t *testing.T) { 94 | // ScriptsWithPath(t, "ssh-tpm-add/testdata/script") 95 | // } 96 | 97 | // func TestHostkeys(t *testing.T) { 98 | // ScriptsWithPath(t, "ssh-tpm-hostkeys/testdata/script") 99 | // } 100 | -------------------------------------------------------------------------------- /key/key.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | keyfile "github.com/foxboron/go-tpm-keyfiles" 9 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 10 | "github.com/google/go-tpm/tpm2" 11 | "github.com/google/go-tpm/tpm2/transport" 12 | "golang.org/x/crypto/ssh" 13 | 14 | "golang.org/x/crypto/ssh/agent" 15 | ) 16 | 17 | var ( 18 | ErrOldKey = errors.New("old format on key") 19 | ) 20 | 21 | type SSHTPMKeys interface { 22 | Signer(*keyring.ThreadKeyring, func() ([]byte, error), func() transport.TPMCloser, func(*keyfile.TPMKey) ([]byte, error)) *SSHKeySigner 23 | GetDescription() string 24 | Fingerprint() string 25 | AuthorizedKey() []byte 26 | AgentKey() *agent.Key 27 | GetTPMKey() *keyfile.TPMKey 28 | } 29 | 30 | // SSHTPMKey is a wrapper for TPMKey implementing the ssh.PublicKey specific parts 31 | type SSHTPMKey struct { 32 | *keyfile.TPMKey 33 | PublicKey *ssh.PublicKey 34 | Certificate *ssh.Certificate 35 | } 36 | 37 | func WrapTPMKey(k *keyfile.TPMKey) (*SSHTPMKey, error) { 38 | pubkey, err := k.PublicKey() 39 | if err != nil { 40 | return nil, err 41 | } 42 | sshkey, err := ssh.NewPublicKey(pubkey) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return &SSHTPMKey{k, &sshkey, nil}, nil 47 | } 48 | 49 | func NewSSHTPMKey(tpm transport.TPMCloser, alg tpm2.TPMAlgID, bits int, ownerauth []byte, fn ...keyfile.TPMKeyOption) (*SSHTPMKey, error) { 50 | k, err := keyfile.NewLoadableKey( 51 | tpm, alg, bits, ownerauth, fn..., 52 | ) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return WrapTPMKey(k) 57 | } 58 | 59 | // This assumes we are just getting a local PK. 60 | func NewImportedSSHTPMKey(tpm transport.TPMCloser, pk any, ownerauth []byte, fn ...keyfile.TPMKeyOption) (*SSHTPMKey, error) { 61 | sess := keyfile.NewTPMSession(tpm) 62 | srkHandle, srkPub, err := keyfile.CreateSRK(sess, tpm2.TPMRHOwner, ownerauth) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed creating SRK: %v", err) 65 | } 66 | sess.SetSalted(srkHandle.Handle, *srkPub) 67 | defer sess.FlushHandle() 68 | 69 | k, err := keyfile.NewImportablekey( 70 | srkPub, pk, fn...) 71 | if err != nil { 72 | return nil, fmt.Errorf("failed failed creating importable key: %v", err) 73 | } 74 | k, err = keyfile.ImportTPMKey(tpm, k, ownerauth) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed turning imported key to loadable key: %v", err) 77 | } 78 | pubkey, err := k.PublicKey() 79 | if err != nil { 80 | return nil, err 81 | } 82 | sshkey, err := ssh.NewPublicKey(pubkey) 83 | if err != nil { 84 | return nil, err 85 | } 86 | return &SSHTPMKey{k, &sshkey, nil}, nil 87 | } 88 | 89 | func (k *SSHTPMKey) Fingerprint() string { 90 | return ssh.FingerprintSHA256(*k.PublicKey) 91 | } 92 | 93 | func (k *SSHTPMKey) AuthorizedKey() []byte { 94 | return []byte(fmt.Sprintf("%s %s\n", strings.TrimSpace(string(ssh.MarshalAuthorizedKey(*k.PublicKey))), k.Description)) 95 | } 96 | 97 | func (k *SSHTPMKey) GetDescription() string { 98 | return k.Description 99 | } 100 | 101 | func (k *SSHTPMKey) AgentKey() *agent.Key { 102 | if k.Certificate != nil { 103 | return &agent.Key{ 104 | Format: k.Certificate.Type(), 105 | Blob: k.Certificate.Marshal(), 106 | Comment: k.Description, 107 | } 108 | } 109 | 110 | return &agent.Key{ 111 | Format: (*k.PublicKey).Type(), 112 | Blob: (*k.PublicKey).Marshal(), 113 | Comment: k.Description, 114 | } 115 | } 116 | 117 | func (k *SSHTPMKey) GetTPMKey() *keyfile.TPMKey { 118 | return k.TPMKey 119 | } 120 | 121 | func (k *SSHTPMKey) Signer(keyring *keyring.ThreadKeyring, ownerAuth func() ([]byte, error), tpm func() transport.TPMCloser, auth func(*keyfile.TPMKey) ([]byte, error)) *SSHKeySigner { 122 | return NewSSHKeySigner(k, keyring, ownerAuth, tpm, auth) 123 | } 124 | 125 | func Decode(b []byte) (*SSHTPMKey, error) { 126 | k, err := keyfile.Decode(b) 127 | if err != nil { 128 | return nil, err 129 | } 130 | return WrapTPMKey(k) 131 | } 132 | -------------------------------------------------------------------------------- /internal/keytest/keytest.go: -------------------------------------------------------------------------------- 1 | package keytest 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/rsa" 9 | "net" 10 | "path" 11 | "testing" 12 | 13 | keyfile "github.com/foxboron/go-tpm-keyfiles" 14 | "github.com/foxboron/ssh-tpm-agent/agent" 15 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 16 | "github.com/foxboron/ssh-tpm-agent/key" 17 | "github.com/google/go-tpm/tpm2" 18 | "github.com/google/go-tpm/tpm2/transport" 19 | "golang.org/x/crypto/ssh" 20 | sshagent "golang.org/x/crypto/ssh/agent" 21 | ) 22 | 23 | // Represents the type for MkKey and mkImportableKey 24 | type KeyFunc func(*testing.T, transport.TPMCloser, tpm2.TPMAlgID, int, []byte, string) (*key.SSHTPMKey, error) 25 | 26 | func MkRSA(t *testing.T, bits int) rsa.PrivateKey { 27 | t.Helper() 28 | pk, err := rsa.GenerateKey(rand.Reader, bits) 29 | if err != nil { 30 | t.Fatalf("failed to generate rsa key: %v", err) 31 | } 32 | return *pk 33 | } 34 | 35 | func MkECDSA(t *testing.T, a elliptic.Curve) ecdsa.PrivateKey { 36 | t.Helper() 37 | pk, err := ecdsa.GenerateKey(a, rand.Reader) 38 | if err != nil { 39 | t.Fatalf("failed to generate ecdsa key: %v", err) 40 | } 41 | return *pk 42 | } 43 | 44 | // Test helper for CreateKey 45 | func MkKey(t *testing.T, tpm transport.TPMCloser, keytype tpm2.TPMAlgID, bits int, pin []byte, comment string) (*key.SSHTPMKey, error) { 46 | t.Helper() 47 | return key.NewSSHTPMKey(tpm, keytype, bits, []byte(""), 48 | keyfile.WithUserAuth(pin), 49 | keyfile.WithDescription(comment), 50 | ) 51 | } 52 | 53 | func MkCertificate(t *testing.T, ca crypto.Signer) KeyFunc { 54 | t.Helper() 55 | return func(t *testing.T, tpm transport.TPMCloser, keytype tpm2.TPMAlgID, bits int, pin []byte, comment string) (*key.SSHTPMKey, error) { 56 | k, err := MkKey(t, tpm, keytype, bits, pin, comment) 57 | if err != nil { 58 | t.Fatalf("message") 59 | } 60 | 61 | signer, err := ssh.NewSignerFromKey(ca) 62 | if err != nil { 63 | t.Fatalf("unable to generate signer from key: %v", err) 64 | } 65 | mas, err := ssh.NewSignerWithAlgorithms(signer.(ssh.AlgorithmSigner), []string{ssh.KeyAlgoECDSA256}) 66 | if err != nil { 67 | t.Fatalf("unable to create signer with algorithms: %v", err) 68 | } 69 | 70 | k.Certificate = &ssh.Certificate{ 71 | Key: *k.PublicKey, 72 | CertType: ssh.UserCert, 73 | } 74 | if err := k.Certificate.SignCert(rand.Reader, mas); err != nil { 75 | t.Fatalf("unable to sign certificate: %v", err) 76 | } 77 | 78 | return k, nil 79 | } 80 | } 81 | 82 | // Helper to make an importable key 83 | func MkImportableKey(t *testing.T, tpm transport.TPMCloser, keytype tpm2.TPMAlgID, bits int, pin []byte, comment string) (*key.SSHTPMKey, error) { 84 | t.Helper() 85 | var pk any 86 | switch keytype { 87 | case tpm2.TPMAlgECC: 88 | switch bits { 89 | case 256: 90 | pk = MkECDSA(t, elliptic.P256()) 91 | case 384: 92 | pk = MkECDSA(t, elliptic.P384()) 93 | case 521: 94 | pk = MkECDSA(t, elliptic.P521()) 95 | } 96 | case tpm2.TPMAlgRSA: 97 | pk = MkRSA(t, bits) 98 | } 99 | return key.NewImportedSSHTPMKey(tpm, pk, []byte(""), 100 | keyfile.WithUserAuth(pin), 101 | keyfile.WithDescription(comment)) 102 | } 103 | 104 | // Give us some random bytes 105 | func MustRand(size int) []byte { 106 | b := make([]byte, size) 107 | if _, err := rand.Read(b); err != nil { 108 | panic(err) 109 | } 110 | 111 | return b 112 | } 113 | 114 | func NewTestAgent(t *testing.T, tpm transport.TPMCloser) *agent.Agent { 115 | unixList, err := net.ListenUnix("unix", &net.UnixAddr{Net: "unix", Name: path.Join(t.TempDir(), "socket")}) 116 | if err != nil { 117 | t.Fatalf("failed listening: %v", err) 118 | } 119 | return agent.NewAgent(unixList, 120 | []sshagent.ExtendedAgent{}, 121 | func() *keyring.ThreadKeyring { return &keyring.ThreadKeyring{} }, 122 | func() transport.TPMCloser { return tpm }, 123 | func() ([]byte, error) { return []byte(""), nil }, 124 | func(_ key.SSHTPMKeys) ([]byte, error) { 125 | return []byte(""), nil 126 | }, 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "html/template" 7 | "io/fs" 8 | "os" 9 | "path" 10 | 11 | "github.com/foxboron/ssh-tpm-agent/contrib" 12 | "github.com/google/go-tpm/tpm2" 13 | ) 14 | 15 | func SSHDir() string { 16 | dirname, err := os.UserHomeDir() 17 | if err != nil { 18 | panic("$HOME is not defined") 19 | } 20 | 21 | return path.Join(dirname, ".ssh") 22 | } 23 | 24 | func FileExists(s string) bool { 25 | _, err := os.Stat(s) 26 | 27 | return !errors.Is(err, fs.ErrNotExist) 28 | } 29 | 30 | // This is the sort of things I swore I'd never write. 31 | // but here we are. 32 | func fmtSystemdInstallPath() string { 33 | DESTDIR := "" 34 | if val, ok := os.LookupEnv("DESTDIR"); ok { 35 | DESTDIR = val 36 | } 37 | 38 | PREFIX := "/usr/" 39 | if val, ok := os.LookupEnv("PREFIX"); ok { 40 | PREFIX = val 41 | } 42 | 43 | return path.Join(DESTDIR, PREFIX, "lib/systemd") 44 | } 45 | 46 | // Installs user units to the target system. 47 | // It will either place the files under $HOME/.config/systemd/user or if global 48 | // is supplied (through --install-system) into system user directories. 49 | // 50 | // Passing the env TEMPLATE_BINARY will use /usr/bin/ssh-tpm-agent for the 51 | // binary in the service 52 | func InstallUserUnits(global bool) error { 53 | if global || os.Getuid() == 0 { // If ran as root, install global system units 54 | return installUnits(path.Join(fmtSystemdInstallPath(), "/user/"), contrib.EmbeddedUserServices()) 55 | } 56 | 57 | dirname, err := os.UserHomeDir() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | return installUnits(path.Join(dirname, ".config/systemd/user"), contrib.EmbeddedUserServices()) 63 | } 64 | 65 | func InstallHostkeyUnits() error { 66 | return installUnits(path.Join(fmtSystemdInstallPath(), "/system/"), contrib.EmbeddedSystemServices()) 67 | } 68 | 69 | func installUnits(installPath string, files map[string][]byte) (err error) { 70 | execPath := os.Getenv("TEMPLATE_BINARY") 71 | if execPath == "" { 72 | execPath, err = os.Executable() 73 | if err != nil { 74 | return err 75 | } 76 | } 77 | 78 | if !FileExists(installPath) { 79 | if err := os.MkdirAll(installPath, 0o750); err != nil { 80 | return fmt.Errorf("creating service installation directory: %w", err) 81 | } 82 | } 83 | 84 | for name := range files { 85 | servicePath := path.Join(installPath, name) 86 | if FileExists(servicePath) { 87 | fmt.Printf("%s exists. Not installing units.\n", servicePath) 88 | return nil 89 | } 90 | } 91 | 92 | for name, data := range files { 93 | servicePath := path.Join(installPath, name) 94 | 95 | f, err := os.OpenFile(servicePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) 96 | if err != nil { 97 | return err 98 | } 99 | defer f.Close() 100 | 101 | t := template.Must(template.New("service").Parse(string(data))) 102 | if err = t.Execute(f, &map[string]string{ 103 | "GoBinary": execPath, 104 | }); err != nil { 105 | return err 106 | } 107 | 108 | fmt.Printf("Installed %s\n", servicePath) 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func InstallSshdConf() error { 115 | // If ran as root, install sshd config 116 | if uid := os.Getuid(); uid != 0 { 117 | return fmt.Errorf("needs to be run as root") 118 | } 119 | 120 | sshdConfInstallPath := "/etc/ssh/sshd_config.d/" 121 | 122 | if !FileExists(sshdConfInstallPath) { 123 | return nil 124 | } 125 | 126 | files := contrib.EmbeddedSshdConfig() 127 | for name := range files { 128 | ff := path.Join(sshdConfInstallPath, name) 129 | if FileExists(ff) { 130 | fmt.Printf("%s exists. Not installing sshd config.\n", ff) 131 | return nil 132 | } 133 | } 134 | for name, data := range files { 135 | ff := path.Join(sshdConfInstallPath, name) 136 | if err := os.WriteFile(ff, data, 0o644); err != nil { 137 | return fmt.Errorf("failed writing sshd conf: %v", err) 138 | } 139 | fmt.Printf("Installed %s\n", ff) 140 | } 141 | fmt.Println("Restart sshd: systemd restart sshd") 142 | return nil 143 | } 144 | 145 | func GetParentHandle(ph string) (tpm2.TPMHandle, error) { 146 | switch ph { 147 | case "endoresement", "e": 148 | return tpm2.TPMRHEndorsement, nil 149 | case "null", "n": 150 | return tpm2.TPMRHNull, nil 151 | case "plattform", "p": 152 | return tpm2.TPMRHPlatform, nil 153 | case "owner", "o": 154 | fallthrough 155 | default: 156 | return tpm2.TPMRHOwner, nil 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-add/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/foxboron/ssh-tpm-agent/agent" 14 | "github.com/foxboron/ssh-tpm-agent/internal/lsm" 15 | "github.com/foxboron/ssh-tpm-agent/key" 16 | "github.com/foxboron/ssh-tpm-agent/utils" 17 | "github.com/foxboron/ssh-tpm-ca-authority/client" 18 | "github.com/google/go-tpm/tpm2/transport/linuxtpm" 19 | "github.com/landlock-lsm/go-landlock/landlock" 20 | "golang.org/x/crypto/ssh" 21 | sshagent "golang.org/x/crypto/ssh/agent" 22 | ) 23 | 24 | var Version string 25 | 26 | const usage = `Usage: 27 | ssh-tpm-add [FILE ...] 28 | ssh-tpm-add --ca [URL] --user [USER] --host [HOSTNAME] 29 | 30 | Options for CA provisioning: 31 | --ca URL URL to the CA authority for CA key provisioning. 32 | --user USER Username of the ssh server user. 33 | --host HOSTNAME Hostname of the ssh server. 34 | 35 | Add a sealed TPM key to ssh-tpm-agent. Allows CA key provisioning with the --ca 36 | option. 37 | 38 | Example: 39 | $ ssh-tpm-add id_rsa.tpm` 40 | 41 | func main() { 42 | flag.Usage = func() { 43 | fmt.Println(usage) 44 | } 45 | 46 | var caURL, host, user string 47 | 48 | flag.StringVar(&caURL, "ca", "", "ca authority") 49 | flag.StringVar(&host, "host", "", "ssh hot") 50 | flag.StringVar(&user, "user", "", "remote ssh user") 51 | flag.Parse() 52 | 53 | socket := utils.EnvSocketPath("") 54 | if socket == "" { 55 | fmt.Println("Can't find any ssh-tpm-agent socket.") 56 | os.Exit(1) 57 | } 58 | 59 | lsm.RestrictAdditionalPaths(landlock.RWFiles(socket)) 60 | 61 | var ignorefile bool 62 | var paths []string 63 | if len(os.Args) == 1 { 64 | sshdir := utils.SSHDir() 65 | paths = []string{ 66 | fmt.Sprintf("%s/id_ecdsa.tpm", sshdir), 67 | fmt.Sprintf("%s/id_rsa.tpm", sshdir), 68 | } 69 | ignorefile = true 70 | } else if len(os.Args) != 1 { 71 | paths = os.Args[1:] 72 | } 73 | 74 | lsm.RestrictAdditionalPaths( 75 | // RW on socket 76 | landlock.RWFiles(socket), 77 | // RW on files we should encode/decode 78 | landlock.RWFiles(paths...), 79 | ) 80 | 81 | if err := lsm.Restrict(); err != nil { 82 | log.Fatal(err) 83 | } 84 | 85 | conn, err := net.Dial("unix", socket) 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | defer conn.Close() 90 | 91 | if caURL != "" && host != "" { 92 | c := client.NewClient(caURL) 93 | rwc, err := linuxtpm.Open("/dev/tpmrm0") 94 | if err != nil { 95 | log.Fatal(err) 96 | } 97 | k, cert, err := c.GetKey(rwc, user, host) 98 | if err != nil { 99 | log.Fatal(err) 100 | } 101 | 102 | sshagentclient := sshagent.NewClient(conn) 103 | addedkey := sshagent.AddedKey{ 104 | PrivateKey: k, 105 | Comment: k.Description, 106 | Certificate: cert, 107 | } 108 | 109 | _, err = sshagentclient.Extension(agent.SSH_TPM_AGENT_ADD, agent.MarshalTPMKeyMsg(&addedkey)) 110 | if err != nil { 111 | log.Fatal(err) 112 | } 113 | fmt.Printf("Identity added from CA authority: %s\n", caURL) 114 | os.Exit(0) 115 | } 116 | 117 | for _, path := range paths { 118 | b, err := os.ReadFile(path) 119 | if err != nil { 120 | if ignorefile { 121 | continue 122 | } 123 | log.Fatal(err) 124 | } 125 | 126 | k, err := key.Decode(b) 127 | if err != nil { 128 | log.Fatal(err) 129 | } 130 | 131 | client := sshagent.NewClient(conn) 132 | 133 | if _, err = client.Extension(agent.SSH_TPM_AGENT_ADD, agent.MarshalTPMKeyMsg( 134 | &sshagent.AddedKey{ 135 | PrivateKey: k, 136 | Comment: k.Description, 137 | }, 138 | )); err != nil { 139 | log.Fatal(err) 140 | } 141 | fmt.Printf("Identity added: %s (%s)\n", path, k.Description) 142 | 143 | certStr := fmt.Sprintf("%s-cert.pub", strings.TrimSuffix(path, filepath.Ext(path))) 144 | if _, err := os.Stat(certStr); !errors.Is(err, os.ErrNotExist) { 145 | b, err := os.ReadFile(certStr) 146 | if err != nil { 147 | log.Fatal(err) 148 | } 149 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey(b) 150 | if err != nil { 151 | log.Fatal("failed parsing ssh certificate") 152 | } 153 | 154 | cert, ok := pubKey.(*ssh.Certificate) 155 | if !ok { 156 | log.Fatal("failed parsing ssh certificate") 157 | } 158 | if _, err = client.Extension(agent.SSH_TPM_AGENT_ADD, agent.MarshalTPMKeyMsg( 159 | &sshagent.AddedKey{ 160 | PrivateKey: k, 161 | Certificate: cert, 162 | Comment: k.Description, 163 | }, 164 | )); err != nil { 165 | log.Fatal(err) 166 | } 167 | fmt.Printf("Identity added: %s (%s)\n", certStr, k.Description) 168 | } 169 | 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /askpass/askpass.go: -------------------------------------------------------------------------------- 1 | package askpass 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "os" 10 | "os/exec" 11 | "strings" 12 | "syscall" 13 | 14 | "golang.org/x/sys/unix" 15 | "golang.org/x/term" 16 | ) 17 | 18 | // Most of this is copied from OpenSSH readpassphrase. 19 | 20 | // State for the ReadPassphrase function 21 | type ReadPassFlags uint8 22 | 23 | const ( 24 | RP_ECHO = 1 << iota /* echo stuff or something 8 */ 25 | RP_ALLOW_STDIN /* Allow stdin and not askpass */ 26 | RP_ALLOW_EOF /* not used */ 27 | RP_USE_ASKPASS /* Use SSH_ASKPASS */ 28 | RP_ASK_PERMISSION /* Ask for permission, yes/no prompt */ 29 | RP_NEWLINE /* Print newline after answer. */ 30 | RPP_ECHO_OFF /* Turn off echo (default). */ 31 | RPP_ECHO_ON /* Leave echo on. */ 32 | RPP_REQUIRE_TTY /* Fail if there is no tty. */ 33 | RPP_FORCELOWER /* Force input to lower case. */ 34 | RPP_FORCEUPPER /* Force input to upper case. */ 35 | RPP_SEVENBIT /* Strip the high bit from input. */ 36 | RPP_STDIN /* Read from stdin, not /dev/tty */ 37 | ) 38 | 39 | var ( 40 | ErrNoAskpass = errors.New("system does not have an askpass program") 41 | 42 | // Default ASKPASS programs 43 | SSH_ASKPASS_DEFAULTS = []string{ 44 | "/usr/lib/ssh/x11-ssh-askpass", 45 | "/usr/lib/ssh/gnome-ssh-askpass3", 46 | "/usr/lib/ssh/gnome-ssh-askpass", 47 | "/usr/libexec/openssh/gnome-ssh-askpass", 48 | "/usr/bin/ksshaskpass", 49 | "/usr/libexec/seahorse/ssh-askpass", 50 | "/usr/lib/seahorse/ssh-askpass", 51 | } 52 | ) 53 | 54 | func findAskPass() (string, error) { 55 | for _, s := range SSH_ASKPASS_DEFAULTS { 56 | if _, err := os.Stat(s); errors.Is(err, os.ErrNotExist) { 57 | continue 58 | } 59 | return s, nil 60 | } 61 | return "", ErrNoAskpass 62 | } 63 | 64 | func isTerminal(fd uintptr) bool { 65 | _, err := unix.IoctlGetTermios(int(fd), unix.TCGETS) 66 | return err == nil 67 | } 68 | 69 | func ReadPassphrase(prompt string, flags ReadPassFlags) ([]byte, error) { 70 | var allow_askpass bool 71 | var use_askpass bool 72 | var askpass_hint string 73 | 74 | if _, ok := os.LookupEnv("DISPLAY"); ok { 75 | allow_askpass = true 76 | } else if _, ok2 := os.LookupEnv("WAYLAND_DISPLAY"); ok2 { 77 | allow_askpass = true 78 | } 79 | 80 | if s, ok := os.LookupEnv("SSH_ASKPASS_REQUIRE"); ok { 81 | switch s { 82 | case "force": 83 | use_askpass = true 84 | allow_askpass = true 85 | case "prefer": 86 | use_askpass = allow_askpass 87 | case "never": 88 | allow_askpass = false 89 | } 90 | } 91 | 92 | if use_askpass { 93 | slog.Debug("requested to askpass") 94 | } else if (flags & RP_USE_ASKPASS) != 0 { 95 | use_askpass = true 96 | } else if (flags & RP_ALLOW_STDIN) != 0 { 97 | if !isTerminal(os.Stdout.Fd()) { 98 | slog.Debug("stdin is not a tty") 99 | use_askpass = true 100 | } 101 | } 102 | 103 | if use_askpass && allow_askpass { 104 | if (flags & RP_ASK_PERMISSION) != 0 { 105 | askpass_hint = "confirm" 106 | } 107 | return SshAskPass(prompt, askpass_hint) 108 | } 109 | 110 | // If we want to echo stuff, we read directly from stdin 111 | // using bufio.NewReader. 112 | if (flags & RPP_ECHO_ON) != 0 { 113 | fmt.Printf("%s", prompt) 114 | reader := bufio.NewReader(os.Stdin) 115 | input, err := reader.ReadString('\n') 116 | if err != nil { 117 | return []byte(""), nil 118 | } 119 | return []byte(strings.TrimSpace(input)), nil 120 | } 121 | // Then we are defaulting to TTY prompt 122 | fmt.Printf("%s", prompt) 123 | pin, err := term.ReadPassword(int(syscall.Stdin)) 124 | if err != nil { 125 | return []byte{}, nil 126 | } 127 | if (flags & RP_NEWLINE) != 0 { 128 | fmt.Println("") 129 | } 130 | return pin, nil 131 | } 132 | 133 | func SshAskPass(prompt, hint string) ([]byte, error) { 134 | var askpass string 135 | var err error 136 | if s, ok := os.LookupEnv("SSH_ASKPASS"); ok { 137 | askpass = s 138 | } else if s, _ := exec.LookPath("ssh-askpass"); s != "" { 139 | askpass = s 140 | } else { 141 | askpass, err = findAskPass() 142 | if err != nil { 143 | return nil, err 144 | } 145 | } 146 | 147 | if hint != "" { 148 | os.Setenv("SSH_ASKPASS_PROMPT", hint) 149 | } 150 | out, err := exec.Command(askpass, prompt).Output() 151 | switch hint { 152 | case "confirm": 153 | // TODO: Ugly and needs a rework 154 | var exerr *exec.ExitError 155 | if errors.As(err, &exerr) { 156 | if exerr.ExitCode() != 0 { 157 | return []byte("no"), nil 158 | } 159 | } else { 160 | return []byte("yes"), nil 161 | } 162 | } 163 | 164 | if err != nil { 165 | return []byte{}, err 166 | } 167 | return bytes.TrimSpace(out), nil 168 | } 169 | 170 | // AskPremission runs SSH_ASKPASS in with SSH_ASKPASS_PROMPT=confirm set as env 171 | // it will expect exit code 0 or !0 and return 'yes' and 'no' respectively. 172 | func AskPermission() (bool, error) { 173 | a, err := ReadPassphrase("Confirm touch", RP_USE_ASKPASS|RP_ASK_PERMISSION) 174 | if err != nil { 175 | return false, err 176 | } 177 | if bytes.Equal(a, []byte("yes")) { 178 | return true, nil 179 | } else if bytes.Equal(a, []byte("no")) { 180 | return false, nil 181 | } 182 | return false, nil 183 | } 184 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-agent/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "fmt" 9 | "log" 10 | "net" 11 | "path" 12 | "testing" 13 | "time" 14 | 15 | "github.com/foxboron/ssh-tpm-agent/agent" 16 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 17 | "github.com/foxboron/ssh-tpm-agent/internal/keytest" 18 | "github.com/foxboron/ssh-tpm-agent/key" 19 | "github.com/google/go-tpm/tpm2" 20 | "github.com/google/go-tpm/tpm2/transport" 21 | "github.com/google/go-tpm/tpm2/transport/simulator" 22 | "golang.org/x/crypto/ssh" 23 | sshagent "golang.org/x/crypto/ssh/agent" 24 | ) 25 | 26 | func newSSHKey() ssh.Signer { 27 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 28 | if err != nil { 29 | panic(err) 30 | } 31 | signer, err := ssh.NewSignerFromSigner(key) 32 | if err != nil { 33 | panic(err) 34 | } 35 | return signer 36 | } 37 | 38 | func setupServer(listener net.Listener, clientKey ssh.PublicKey) (hostkey ssh.PublicKey, msgSent chan bool) { 39 | hostSigner := newSSHKey() 40 | msgSent = make(chan bool) 41 | 42 | srvStart := make(chan bool) 43 | 44 | authorizedKeysMap := map[string]bool{} 45 | authorizedKeysMap[string(clientKey.Marshal())] = true 46 | 47 | config := &ssh.ServerConfig{ 48 | PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { 49 | if authorizedKeysMap[string(pubKey.Marshal())] { 50 | return &ssh.Permissions{ 51 | Extensions: map[string]string{ 52 | "pubkey-fp": ssh.FingerprintSHA256(pubKey), 53 | }, 54 | }, nil 55 | } 56 | return nil, fmt.Errorf("unknown public key for %q", c.User()) 57 | }, 58 | } 59 | 60 | config.AddHostKey(hostSigner) 61 | 62 | go func() { 63 | close(srvStart) 64 | 65 | nConn, err := listener.Accept() 66 | if err != nil { 67 | log.Fatal("failed to accept incoming connection: ", err) 68 | } 69 | 70 | _, chans, reqs, err := ssh.NewServerConn(nConn, config) 71 | if err != nil { 72 | log.Fatal("failed to handshake: ", err) 73 | } 74 | 75 | go ssh.DiscardRequests(reqs) 76 | 77 | for newChannel := range chans { 78 | if newChannel.ChannelType() != "session" { 79 | newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") 80 | continue 81 | } 82 | channel, requests, err := newChannel.Accept() 83 | if err != nil { 84 | log.Fatalf("Could not accept channel: %v", err) 85 | } 86 | 87 | go func(in <-chan *ssh.Request) { 88 | for req := range in { 89 | req.Reply(req.Type == "shell", nil) 90 | } 91 | }(requests) 92 | 93 | channel.Write([]byte("connected")) 94 | 95 | // Need to figure out something better 96 | time.Sleep(time.Millisecond * 100) 97 | close(msgSent) 98 | 99 | channel.Close() 100 | } 101 | }() 102 | 103 | // Waiting until the server has started 104 | <-srvStart 105 | 106 | return hostSigner.PublicKey(), msgSent 107 | } 108 | 109 | func runSSHAuth(t *testing.T, keytype tpm2.TPMAlgID, bits int, pin []byte, keyfn keytest.KeyFunc) { 110 | t.Parallel() 111 | tpm, err := simulator.OpenSimulator() 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | defer tpm.Close() 116 | 117 | k, err := keyfn(t, tpm, keytype, bits, pin, "") 118 | if err != nil { 119 | t.Fatalf("failed creating key: %v", err) 120 | } 121 | 122 | listener, err := net.Listen("tcp", "127.0.0.1:0") 123 | if err != nil { 124 | log.Fatal("failed to listen for connection: ", err) 125 | } 126 | 127 | hostkey, msgSent := setupServer(listener, *k.PublicKey) 128 | defer listener.Close() 129 | 130 | socket := path.Join(t.TempDir(), "socket") 131 | 132 | unixList, err := net.ListenUnix("unix", &net.UnixAddr{Net: "unix", Name: socket}) 133 | if err != nil { 134 | log.Fatalln("Failed to listen on UNIX socket:", err) 135 | } 136 | defer unixList.Close() 137 | 138 | ag := agent.NewAgent(unixList, 139 | []sshagent.ExtendedAgent{}, 140 | // Keyring Callback 141 | func() *keyring.ThreadKeyring { return &keyring.ThreadKeyring{} }, 142 | // TPM Callback 143 | func() transport.TPMCloser { return tpm }, 144 | // Owner password 145 | func() ([]byte, error) { return []byte(""), nil }, 146 | // PIN Callback 147 | func(_ key.SSHTPMKeys) ([]byte, error) { 148 | return pin, nil 149 | }, 150 | ) 151 | defer ag.Stop() 152 | 153 | if err := ag.AddKey(k); err != nil { 154 | t.Fatalf("failed saving key: %v", err) 155 | } 156 | 157 | sshClient := &ssh.ClientConfig{ 158 | User: "username", 159 | Auth: []ssh.AuthMethod{ 160 | ssh.PublicKeysCallback(ag.Signers), 161 | }, 162 | HostKeyCallback: ssh.FixedHostKey(hostkey), 163 | } 164 | 165 | client, err := ssh.Dial("tcp", listener.Addr().String(), sshClient) 166 | if err != nil { 167 | t.Fatal("Failed to dial: ", err) 168 | } 169 | defer client.Close() 170 | 171 | session, err := client.NewSession() 172 | if err != nil { 173 | t.Fatal("Failed to create session: ", err) 174 | } 175 | defer session.Close() 176 | 177 | var b bytes.Buffer 178 | session.Stdout = &b 179 | session.Shell() 180 | session.Wait() 181 | 182 | <-msgSent 183 | if b.String() != "connected" { 184 | t.Fatalf("failed to connect") 185 | } 186 | } 187 | 188 | func TestSSHAuth(t *testing.T) { 189 | for _, c := range []struct { 190 | name string 191 | alg tpm2.TPMAlgID 192 | bits int 193 | }{ 194 | { 195 | "ecdsa p256 - agent", 196 | tpm2.TPMAlgECC, 197 | 256, 198 | }, 199 | { 200 | "ecdsa p384 - agent", 201 | tpm2.TPMAlgECC, 202 | 384, 203 | }, 204 | { 205 | "ecdsa p521 - agent", 206 | tpm2.TPMAlgECC, 207 | 521, 208 | }, 209 | { 210 | "rsa - agent", 211 | tpm2.TPMAlgRSA, 212 | 2048, 213 | }, 214 | } { 215 | t.Run(c.name+" - tpm key", func(t *testing.T) { 216 | runSSHAuth(t, c.alg, c.bits, []byte(""), keytest.MkKey) 217 | }) 218 | t.Run(c.name+" - imported key", func(t *testing.T) { 219 | runSSHAuth(t, c.alg, c.bits, []byte(""), keytest.MkImportableKey) 220 | }) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /agent/agent_test.go: -------------------------------------------------------------------------------- 1 | package agent_test 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "errors" 8 | "log" 9 | "net" 10 | "path" 11 | "testing" 12 | 13 | "github.com/foxboron/ssh-tpm-agent/agent" 14 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 15 | "github.com/foxboron/ssh-tpm-agent/internal/keytest" 16 | "github.com/foxboron/ssh-tpm-agent/key" 17 | "github.com/google/go-tpm/tpm2" 18 | "github.com/google/go-tpm/tpm2/transport" 19 | "github.com/google/go-tpm/tpm2/transport/simulator" 20 | "golang.org/x/crypto/ssh" 21 | sshagent "golang.org/x/crypto/ssh/agent" 22 | ) 23 | 24 | func TestAddKey(t *testing.T) { 25 | tpm, err := simulator.OpenSimulator() 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | defer tpm.Close() 30 | 31 | socket := path.Join(t.TempDir(), "socket") 32 | unixList, err := net.ListenUnix("unix", &net.UnixAddr{Net: "unix", Name: socket}) 33 | 34 | if err != nil { 35 | log.Fatalln("Failed to listen on UNIX socket:", err) 36 | } 37 | defer unixList.Close() 38 | 39 | ag := agent.NewAgent(unixList, 40 | []sshagent.ExtendedAgent{}, 41 | // Keyring callback 42 | func() *keyring.ThreadKeyring { return &keyring.ThreadKeyring{} }, 43 | // TPM Callback 44 | func() transport.TPMCloser { return tpm }, 45 | // Owner password 46 | func() ([]byte, error) { return []byte(""), nil }, 47 | // PIN Callback 48 | func(_ key.SSHTPMKeys) ([]byte, error) { return []byte(""), nil }, 49 | ) 50 | defer ag.Stop() 51 | 52 | conn, err := net.Dial("unix", socket) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | defer conn.Close() 57 | 58 | client := sshagent.NewClient(conn) 59 | 60 | k, err := key.NewSSHTPMKey(tpm, tpm2.TPMAlgECC, 256, []byte("")) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | addedkey := sshagent.AddedKey{ 66 | PrivateKey: k, 67 | Certificate: nil, 68 | Comment: k.Description, 69 | } 70 | 71 | _, err = client.Extension(agent.SSH_TPM_AGENT_ADD, agent.MarshalTPMKeyMsg(&addedkey)) 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | } 76 | 77 | func TestSigning(t *testing.T) { 78 | tpm, err := simulator.OpenSimulator() 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | defer tpm.Close() 83 | 84 | ca := keytest.MkECDSA(t, elliptic.P256()) 85 | for _, c := range []struct { 86 | text string 87 | alg tpm2.TPMAlgID 88 | bits int 89 | f keytest.KeyFunc 90 | wanterr error 91 | }{ 92 | { 93 | text: "sign key", 94 | alg: tpm2.TPMAlgECC, 95 | bits: 256, 96 | f: keytest.MkKey, 97 | }, 98 | { 99 | text: "sign key cert", 100 | alg: tpm2.TPMAlgECC, 101 | bits: 256, 102 | f: keytest.MkCertificate(t, &ca), 103 | }, 104 | } { 105 | 106 | t.Run(c.text, func(t *testing.T) { 107 | k, err := c.f(t, tpm, c.alg, c.bits, []byte(""), "") 108 | if err != nil { 109 | t.Fatalf("failed key import: %v", err) 110 | } 111 | 112 | ag := keytest.NewTestAgent(t, tpm) 113 | defer ag.Stop() 114 | 115 | if err := ag.AddKey(k); err != nil { 116 | t.Fatalf("failed saving key: %v", err) 117 | } 118 | 119 | // Shim the certificate if there is one 120 | var sshkey ssh.PublicKey 121 | if k.Certificate != nil { 122 | sshkey = k.Certificate 123 | } else { 124 | sshkey = *k.PublicKey 125 | } 126 | 127 | _, err = ag.Sign(sshkey, []byte("test")) 128 | if !errors.Is(err, c.wanterr) { 129 | t.Fatalf("failed signing: %v", err) 130 | } 131 | }) 132 | } 133 | } 134 | 135 | func TestRemoveCertFromProxy(t *testing.T) { 136 | tpm, err := simulator.OpenSimulator() 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | defer tpm.Close() 141 | 142 | caEcdsa, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 143 | if err != nil { 144 | t.Fatalf("failed creating CA key") 145 | } 146 | 147 | for _, c := range []struct { 148 | text string 149 | alg tpm2.TPMAlgID 150 | bits int 151 | f keytest.KeyFunc 152 | wanterr error 153 | numkeys int 154 | }{ 155 | { 156 | text: "sign key", 157 | alg: tpm2.TPMAlgECC, 158 | bits: 256, 159 | f: keytest.MkKey, 160 | numkeys: 0, 161 | }, 162 | { 163 | text: "sign key cert", 164 | alg: tpm2.TPMAlgECC, 165 | bits: 256, 166 | f: keytest.MkCertificate(t, caEcdsa), 167 | numkeys: 1, 168 | }, 169 | } { 170 | 171 | t.Run(c.text, func(t *testing.T) { 172 | k, err := c.f(t, tpm, c.alg, c.bits, []byte(""), "") 173 | if err != nil { 174 | t.Fatalf("failed key import: %v", err) 175 | } 176 | 177 | proxyagent := keytest.NewTestAgent(t, tpm) 178 | defer proxyagent.Stop() 179 | 180 | testagent := keytest.NewTestAgent(t, tpm) 181 | defer testagent.Stop() 182 | 183 | if err := testagent.AddKey(k); err != nil { 184 | t.Fatalf("failed saving key: %v", err) 185 | } 186 | 187 | if k.Certificate != nil { 188 | // If we have a certificate, include 189 | // the key without the certificate 190 | c := *k 191 | c.Certificate = nil 192 | if err := testagent.AddKey(&c); err != nil { 193 | t.Fatalf("failed saving key: %v", err) 194 | } 195 | } 196 | 197 | // Add testagent to proxyagent 198 | // We'll try to remove the key from testagent. 199 | proxyagent.AddProxyAgent(testagent) 200 | 201 | // Shim the certificate if there is one 202 | var sshkey ssh.PublicKey 203 | if k.Certificate != nil { 204 | sshkey = k.Certificate 205 | } else { 206 | sshkey = *k.PublicKey 207 | } 208 | 209 | if err := proxyagent.Remove(sshkey); err != nil { 210 | t.Fatalf("failed to remove key: %v", err) 211 | } 212 | 213 | // Check the key doesn't exist in the proxy nor the agent 214 | proxysl, err := proxyagent.List() 215 | if err != nil { 216 | t.Fatalf("%v", err) 217 | } 218 | if len(proxysl) != c.numkeys { 219 | t.Fatalf("still keys in the agent. Should be 0") 220 | } 221 | 222 | sl, err := testagent.List() 223 | if err != nil { 224 | t.Fatalf("%v", err) 225 | } 226 | if len(sl) != c.numkeys { 227 | t.Fatalf("still keys in the agent. Should be 0") 228 | } 229 | }) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /key/hierarchy_keys.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | keyfile "github.com/foxboron/go-tpm-keyfiles" 8 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 9 | "github.com/google/go-tpm/tpm2" 10 | "github.com/google/go-tpm/tpm2/transport" 11 | "golang.org/x/crypto/cryptobyte" 12 | "golang.org/x/crypto/cryptobyte/asn1" 13 | ) 14 | 15 | var ( 16 | ECCSRK_H10_Template = tpm2.TPMTPublic{ 17 | Type: tpm2.TPMAlgECC, 18 | NameAlg: tpm2.TPMAlgSHA256, 19 | ObjectAttributes: tpm2.TPMAObject{ 20 | FixedTPM: true, 21 | FixedParent: true, 22 | SensitiveDataOrigin: true, 23 | UserWithAuth: true, 24 | AdminWithPolicy: false, 25 | SignEncrypt: true, 26 | Decrypt: true, 27 | }, 28 | AuthPolicy: tpm2.TPM2BDigest{ 29 | Buffer: []byte{ 30 | 0xCA, 0x3D, 0x0A, 0x99, 0xA2, 0xB9, 31 | 0x39, 0x06, 0xF7, 0xA3, 0x34, 0x24, 32 | 0x14, 0xEF, 0xCF, 0xB3, 0xA3, 0x85, 33 | 0xD4, 0x4C, 0xD1, 0xFD, 0x45, 0x90, 34 | 0x89, 0xD1, 0x9B, 0x50, 0x71, 0xC0, 35 | 0xB7, 0xA0, 36 | }, 37 | }, 38 | Parameters: tpm2.NewTPMUPublicParms( 39 | tpm2.TPMAlgECC, 40 | &tpm2.TPMSECCParms{ 41 | CurveID: tpm2.TPMECCNistP256, 42 | Scheme: tpm2.TPMTECCScheme{ 43 | Scheme: tpm2.TPMAlgNull, 44 | }, 45 | }, 46 | ), 47 | Unique: tpm2.NewTPMUPublicID( 48 | tpm2.TPMAlgECC, 49 | &tpm2.TPMSECCPoint{ 50 | X: tpm2.TPM2BECCParameter{ 51 | Buffer: make([]byte, 0), 52 | }, 53 | Y: tpm2.TPM2BECCParameter{ 54 | Buffer: make([]byte, 0), 55 | }, 56 | }, 57 | ), 58 | } 59 | 60 | RSASRK_H9_Template = tpm2.TPMTPublic{ 61 | Type: tpm2.TPMAlgRSA, 62 | NameAlg: tpm2.TPMAlgSHA256, 63 | ObjectAttributes: tpm2.TPMAObject{ 64 | FixedTPM: true, 65 | FixedParent: true, 66 | SensitiveDataOrigin: true, 67 | UserWithAuth: true, 68 | AdminWithPolicy: false, 69 | SignEncrypt: true, 70 | Decrypt: true, 71 | }, 72 | AuthPolicy: tpm2.TPM2BDigest{ 73 | Buffer: []byte{ 74 | 0xCA, 0x3D, 0x0A, 0x99, 0xA2, 0xB9, 75 | 0x39, 0x06, 0xF7, 0xA3, 0x34, 0x24, 76 | 0x14, 0xEF, 0xCF, 0xB3, 0xA3, 0x85, 77 | 0xD4, 0x4C, 0xD1, 0xFD, 0x45, 0x90, 78 | 0x89, 0xD1, 0x9B, 0x50, 0x71, 0xC0, 79 | 0xB7, 0xA0, 80 | }, 81 | }, 82 | Parameters: tpm2.NewTPMUPublicParms( 83 | tpm2.TPMAlgRSA, 84 | &tpm2.TPMSRSAParms{ 85 | Scheme: tpm2.TPMTRSAScheme{ 86 | Scheme: tpm2.TPMAlgNull, 87 | }, 88 | KeyBits: 2048, 89 | }, 90 | ), 91 | Unique: tpm2.NewTPMUPublicID( 92 | tpm2.TPMAlgRSA, 93 | &tpm2.TPM2BPublicKeyRSA{Buffer: make([]byte, 0)}, 94 | ), 95 | } 96 | ) 97 | 98 | type HierSSHTPMKey struct { 99 | *SSHTPMKey 100 | handle *tpm2.AuthHandle 101 | name tpm2.TPM2BName 102 | } 103 | 104 | // from crypto/ecdsa 105 | func addASN1IntBytes(b *cryptobyte.Builder, bytes []byte) { 106 | for len(bytes) > 0 && bytes[0] == 0 { 107 | bytes = bytes[1:] 108 | } 109 | if len(bytes) == 0 { 110 | b.SetError(errors.New("invalid integer")) 111 | return 112 | } 113 | b.AddASN1(asn1.INTEGER, func(c *cryptobyte.Builder) { 114 | if bytes[0]&0x80 != 0 { 115 | c.AddUint8(0) 116 | } 117 | c.AddBytes(bytes) 118 | }) 119 | } 120 | 121 | // from crypto/ecdsa 122 | func encodeSignature(r, s []byte) ([]byte, error) { 123 | var b cryptobyte.Builder 124 | b.AddASN1(asn1.SEQUENCE, func(b *cryptobyte.Builder) { 125 | addASN1IntBytes(b, r) 126 | addASN1IntBytes(b, s) 127 | }) 128 | return b.Bytes() 129 | } 130 | 131 | func (h *HierSSHTPMKey) Sign(tpm transport.TPMCloser, _, auth, digest []byte, digestalgo tpm2.TPMAlgID) ([]byte, error) { 132 | rsp, err := keyfile.TPMSign(tpm, h.handle, digest, digestalgo, h.KeySize(), h.KeyAlgo()) 133 | if err != nil { 134 | return nil, err 135 | } 136 | switch h.KeyAlgo() { 137 | case tpm2.TPMAlgECC: 138 | eccsig, err := rsp.Signature.ECDSA() 139 | if err != nil { 140 | return nil, fmt.Errorf("failed getting signature: %v", err) 141 | } 142 | return encodeSignature(eccsig.SignatureR.Buffer, eccsig.SignatureS.Buffer) 143 | case tpm2.TPMAlgRSA: 144 | rsassa, err := rsp.Signature.RSASSA() 145 | if err != nil { 146 | return nil, fmt.Errorf("failed getting rsassa signature") 147 | } 148 | return rsassa.Sig.Buffer, nil 149 | } 150 | return nil, fmt.Errorf("failed returning signature") 151 | } 152 | 153 | func (h *HierSSHTPMKey) FlushHandle(tpm transport.TPMCloser) { 154 | if h.handle != nil { 155 | keyfile.FlushHandle(tpm, *h.handle) 156 | } 157 | } 158 | 159 | func (h *HierSSHTPMKey) Signer(keyring *keyring.ThreadKeyring, ownerAuth func() ([]byte, error), tpm func() transport.TPMCloser, auth func(*keyfile.TPMKey) ([]byte, error)) *SSHKeySigner { 160 | return NewSSHKeySigner(h, keyring, ownerAuth, tpm, auth) 161 | } 162 | 163 | func CreateHierarchyKey(tpm transport.TPMCloser, keytype tpm2.TPMAlgID, hier tpm2.TPMHandle, desc string) (*HierSSHTPMKey, error) { 164 | var tmpl tpm2.TPMTPublic 165 | switch keytype { 166 | case tpm2.TPMAlgECC: 167 | tmpl = ECCSRK_H10_Template 168 | case tpm2.TPMAlgRSA: 169 | tmpl = RSASRK_H9_Template 170 | } 171 | 172 | srk := tpm2.CreatePrimary{ 173 | PrimaryHandle: tpm2.AuthHandle{ 174 | Handle: hier, 175 | Auth: tpm2.PasswordAuth(nil), 176 | }, 177 | InSensitive: tpm2.TPM2BSensitiveCreate{ 178 | Sensitive: &tpm2.TPMSSensitiveCreate{ 179 | UserAuth: tpm2.TPM2BAuth{ 180 | Buffer: []byte(nil), 181 | }, 182 | }, 183 | }, 184 | InPublic: tpm2.New2B(tmpl), 185 | } 186 | 187 | rsp, err := srk.Execute(tpm) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | var tpmkey keyfile.TPMKey 193 | tpmkey.AddOptions( 194 | keyfile.WithUserAuth([]byte(nil)), 195 | keyfile.WithPubkey(rsp.OutPublic), 196 | keyfile.WithDescription(desc), 197 | ) 198 | 199 | wkey, err := WrapTPMKey(&tpmkey) 200 | if err != nil { 201 | return nil, err 202 | } 203 | 204 | return &HierSSHTPMKey{ 205 | SSHTPMKey: wkey, 206 | handle: &tpm2.AuthHandle{ 207 | Handle: rsp.ObjectHandle, 208 | Name: rsp.Name, 209 | Auth: tpm2.PasswordAuth(nil), 210 | }, 211 | name: rsp.Name, 212 | }, nil 213 | } 214 | -------------------------------------------------------------------------------- /man/ssh-tpm-keygen.1.adoc: -------------------------------------------------------------------------------- 1 | = ssh-tpm-keygen(1) 2 | :doctype: manpage 3 | :manmanual: ssh-tpm-keygen manual 4 | 5 | == Name 6 | 7 | ssh-tpm-keygen - ssh-tpm-agent key creation utility 8 | 9 | == Synopsis 10 | 11 | *ssh-tpm-keygen* [_OPTIONS_]... 12 | 13 | *ssh-tpm-keygen* *--wrap* __PATH__ *--wrap-with* __PATH__ 14 | 15 | *ssh-tpm-keygen* *--import* __PATH__ 16 | 17 | *ssh-tpm-keygen* *--print-pubkey* __PATH__ 18 | 19 | *ssh-tpm-keygen* *--supported* 20 | 21 | *ssh-tpm-keygen* *-p* [*-f* __keyfile__] [*-P* __old passphrase__] [*-N* __new passphrase__] 22 | 23 | *ssh-tpm-keygen* *-A* [*-f* __path prefix__] [*--hierarchy* __hierarchy__] 24 | 25 | == Description 26 | 27 | *ssh-tpm-keygen* is a program that allows the creation of TPM wrapped keys for *ssh-tpm-agent*. 28 | 29 | == Options 30 | 31 | *-A*:: 32 | Generate host keys for all key types (rsa and ecdsa). 33 | 34 | *-b* __BITS__:: 35 | Number of bits in the key to create. 36 | - rsa: 2048 (default) 37 | - ecdsa: 256 (default) | 384 | 521 38 | 39 | *-C* __COMMENT__ :: 40 | Provide a comment with the key. 41 | 42 | *-f* __PATH__:: 43 | Output keyfile path. 44 | 45 | *-N* __PASSPHRASE__ :: 46 | Passphrase for the key. 47 | 48 | *-o*, *--owner-password* __PASSPHRASE__ :: 49 | Ask for the owner password. 50 | 51 | *-t* [__ecdsa__ | __rsa__]:: 52 | Specify the type of key to create. Defaults to ecdsa 53 | 54 | *-I*, *--import* __PATH__:: 55 | Import existing key into ssh-tpm-agent. 56 | 57 | *--parent-handle* __HIERARCHY__:: 58 | Parent for the TPM key. Can be a hierarchy or a persistent handle. 59 | + 60 | Available hierarchies: 61 | - owner, o (default) 62 | - endorsement, e 63 | - null, n 64 | - platform, p 65 | 66 | *--print-pubkey* __PATH__:: 67 | Print the public key given a TPM private key. 68 | 69 | *--supported*:: 70 | List the supported key types of the TPM. 71 | 72 | *--hierarchy* __HIERARCHY__:: 73 | Create a public key. Can only be used with *-A*. 74 | + 75 | See *Hierarchy Keys* in *ssh-tpm-agent*(1) for usage. 76 | + 77 | Available hierarchies: 78 | + 79 | - owner, o 80 | - endorsement, e 81 | - null, n 82 | - platform, p 83 | 84 | *--wrap* __PATH__:: 85 | A SSH key to wrap for import on remote machine. 86 | 87 | *--wrap-with* __PATH__:: 88 | Parent key to wrap the SSH key with. 89 | 90 | == Examples 91 | 92 | === Key creation 93 | 94 | Create a key with *ssh-tpm-keygen*. 95 | 96 | $ ssh-tpm-keygen 97 | Generating a sealed public/private ecdsa key pair. 98 | Enter file in which to save the key (/home/user/.ssh/id_ecdsa): 99 | Enter passphrase (empty for no passphrase): 100 | Enter same passphrase again: 101 | Your identification has been saved in /home/user/.ssh/id_ecdsa.tpm 102 | Your public key has been saved in /home/user/.ssh/id_ecdsa.pub 103 | The key fingerprint is: 104 | SHA256:NCMJJ2La+q5tGcngQUQvEOJP3gPH8bMP98wJOEMV564 105 | The key's randomart image is the color of television, tuned to a dead channel. 106 | 107 | $ cat /home/user/.ssh/id_ecdsa.pub 108 | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOTOsMXyjTc1wiQSKhRiNhKFsHJNLzLk2r4foXPLQYKR0tuXIBMTQuMmc7OiTgNMvIjMrcb9adgGdT3s+GkNi1g= 109 | 110 | === Import existing key 111 | 112 | Useful if you want to back up the key to a remote secure storage while using the key day-to-day from the TPM. 113 | 114 | Create a key, or use an existing one. 115 | 116 | $ ssh-keygen -t ecdsa -f id_ecdsa 117 | Generating public/private ecdsa key pair. 118 | Enter passphrase (empty for no passphrase): 119 | Enter same passphrase again: 120 | Your identification has been saved in id_ecdsa 121 | Your public key has been saved in id_ecdsa.pub 122 | The key fingerprint is: 123 | SHA256:bDn2EpX6XRX5ADXQSuTq+uUyia/eV3Z6MW+UtxjnXvU user@localhost 124 | The key's randomart image is: 125 | +---[ECDSA 256]---+ 126 | | .+=o..| 127 | | o. oo.| 128 | | o... .o| 129 | | . + .. ..| 130 | | S . . o| 131 | | o * . oo=*| 132 | | ..+.oo=+E| 133 | | .++o...o=| 134 | | .++++. .+ | 135 | +----[SHA256]-----+ 136 | 137 | Import the key using the `--import` switch. 138 | 139 | $ ssh-tpm-keygen --import id_ecdsa 140 | Sealing an existing public/private ecdsa key pair. 141 | Enter passphrase (empty for no passphrase): 142 | Enter same passphrase again: 143 | Your identification has been saved in id_ecdsa.tpm 144 | The key fingerprint is: 145 | SHA256:bDn2EpX6XRX5ADXQSuTq+uUyia/eV3Z6MW+UtxjnXvU 146 | The key's randomart image is the color of television, tuned to a dead channel. 147 | 148 | === Create and Wrap private key for client machine on remote srver 149 | 150 | On the client side create one a primary key under an hierarchy. This example 151 | will use the owner hierarchy with an SRK. 152 | 153 | The output file `srk.pem` needs to be transferred to the remote end which 154 | creates the key. This could be done as part of client provisioning. 155 | 156 | $ tpm2_createprimary -C o -G ecc -g sha256 -c prim.ctx -a 'restricted|decrypt|fixedtpm|fixedparent|sensitivedataorigin|userwithauth|noda' -f pem -o srk.pem 157 | 158 | On the remote end we create a p256 ssh key, with no password, and wrap it with 159 | `ssh-tpm-keygen` with the `srk.pem` from the client side. 160 | 161 | $ ssh-keygen -t ecdsa -b 256 -N "" -f ./ecdsa.key 162 | 163 | OR with openssl 164 | 165 | $ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out ecdsa.key 166 | 167 | Wrap with ssh-tpm-keygen 168 | 169 | $ ssh-tpm-keygen --wrap-with srk.pub --wrap ecdsa.key -f wrapped_id_ecdsa 170 | 171 | On the client side we can unwrap `wrapped_id_ecdsa` to a loadable key. 172 | 173 | $ ssh-tpm-keygen --import ./wrapped_id_ecdsa.tpm --output id_ecdsa.tpm 174 | $ ssh-tpm-add id_ecdsa.tpm 175 | 176 | 177 | == Files 178 | 179 | _~/ssh/id_rsa.tpm_:: 180 | _~/ssh/id_ecdsa.tpm_:: 181 | Contains the ssh private keys used by *ssh-tpm-agent*. They are TPM 2.0 TSS key files and securely wrapped by the TPM. They can be shared publicly as they can only be used by the TPM they where created on. However it is probably better to not do that. 182 | 183 | _~/ssh/id_rsa.pub_:: 184 | _~/ssh/id_ecdsa.pub_:: 185 | Contains the ssh public keys. These can be shared publicly, and is the same format as the ones created by *ssh-keygen*(1). 186 | 187 | == See Also 188 | *ssh-agent*(1), *ssh*(1), *ssh-tpm-keygen*(1), *ssh-keygen*(1) 189 | 190 | == Notes, standards and other 191 | https://www.hansenpartnership.com/draft-bottomley-tpm2-keys.html[ASN.1 Specification for TPM 2.0 Key Files] 192 | -------------------------------------------------------------------------------- /man/ssh-tpm-agent.1.adoc: -------------------------------------------------------------------------------- 1 | = ssh-tpm-agent(1) 2 | :doctype: manpage 3 | :manmanual: ssh-tpm-agent manual 4 | 5 | == Name 6 | 7 | ssh-tpm-agent - ssh-agent for TPM 2.0 keys 8 | 9 | == Synopsis 10 | 11 | *ssh-tpm-agent* [_OPTIONS_] 12 | 13 | *ssh-tpm-agent* *--print-socket* 14 | 15 | *ssh-tpm-agent* *--install-user-units* 16 | 17 | == Description 18 | 19 | *ssh-tpm-agent* is a program that created keys utilizing a Trusted Platform 20 | Module (TPM) to enable wrapped private keys for public key authentication. 21 | 22 | == Options 23 | 24 | *-l* _PATH_:: 25 | Path of the UNIX socket to open 26 | + 27 | Defaults to _$XDG_RUNTIME_DIR/ssh-tpm-agent.sock_. 28 | 29 | *-A* _PATH_:: 30 | Fallback ssh-agent sockets for additional key lookup. 31 | 32 | *--print-socket*:: 33 | Prints the socket to STDIN. 34 | 35 | *--key-dir* _PATH_:: 36 | Path of the directory to look for TPM sealed keys in. 37 | + 38 | Defaults to _~/.ssh_. 39 | 40 | *--no-load*:: 41 | Do not load TPM sealed keys from _~/.ssh_ by default. 42 | 43 | *-o, --owner-password*:: 44 | Ask for the owner password. 45 | 46 | *--no-cache*:: 47 | The agent will not cache key passwords. 48 | 49 | *--hierarchy* __HIERARCHY__:: 50 | Preload hierarchy keys into the agent. 51 | + 52 | See *Hierarchy Keys* for more information. 53 | + 54 | Available hierarchies: 55 | + 56 | - owner, o 57 | - endorsement, e 58 | - null, n 59 | - platform, p 60 | 61 | *-d*:: 62 | Enable debug logging. 63 | 64 | *--install-user-units*:: 65 | Installs systemd system units and sshd configs for using ssh-tpm-agent as a hostkey agent. 66 | 67 | *--swtpm*:: 68 | Stores keys inside a swtpm instance instead of the actual TPM. This is not a security feature and your keys are not stored securely. 69 | + 70 | Can also be enabled with the environment variable *SSH_TPM_AGENT_SWTPM*. 71 | 72 | == Examples 73 | 74 | === Normal agent usage 75 | *ssh-tpm-agent* can be used as a dropin replacement to ssh-agent and works the 76 | same way. 77 | 78 | $ ssh-tpm-keygen 79 | # Add ~/.ssh/id_ecdsa.pub to your Github accounts 80 | $ ssh-tpm-agent & 81 | $ export SSH_AUTH_SOCK=$(ssh-tpm-agent --print-socket) 82 | $ ssh git@github.com 83 | 84 | See *ssh-tpm-keygen*(1) for keygen usage. 85 | 86 | === Agent fallback support 87 | *ssh-tpm-agent* supports fallback to different ssh-agent. Agents can be 88 | added with the _-A_ switch. This will cause *ssh-tpm-agent* to fan-out to all 89 | available agents for keys. 90 | 91 | This is practical if you have multiple keys from different agent implementations 92 | but want to rely on one socket. 93 | 94 | # Start the usual ssh-agent 95 | $ eval $(ssh-agent) 96 | 97 | # Create a strong RSA key 98 | $ ssh-keygen -t rsa -b 4096 -f id_rsa -C ssh-agent 99 | ... 100 | The key fingerprint is: 101 | SHA256:zLSeyU/6NKHGEvyZLA866S1jGqwdwdAxRFff8Z2N1i0 ssh-agent 102 | 103 | $ ssh-add id_rsa 104 | Identity added: id_rsa (ssh-agent) 105 | 106 | # Print looonnggg key 107 | $ ssh-add -L 108 | ssh-rsa AAAAB3NzaC1yc[...]8TWynQ== ssh-agent 109 | 110 | # Create key on the TPM 111 | $ ssh-tpm-keygen -C ssh-tpm-agent 112 | Generating a sealed public/private ecdsa key pair. 113 | Enter file in which to save the key (/home/user/.ssh/id_ecdsa): 114 | Enter passphrase (empty for no passphrase): 115 | Confirm passphrase: 116 | Your identification has been saved in /home/user/.ssh/id_ecdsa.tpm 117 | Your public key has been saved in /home/user/.ssh/id_ecdsa.pub 118 | The key fingerprint is: 119 | SHA256:PoQyuzOpEBLqT+xtP0dnvyBVL6UQTiQeCWN/EXIxPOo 120 | The key's randomart image is the color of television, tuned to a dead channel. 121 | 122 | # Start ssh-tpm-agent with a proxy socket 123 | $ ssh-tpm-agent -A "${SSH_AUTH_SOCK}" & 124 | 125 | $ export SSH_AUTH_SOCK="$(ssh-tpm-agent --print-socket)" 126 | 127 | # ssh-tpm-agent is proxying the keys from ssh-agent 128 | $ ssh-add -L 129 | ssh-rsa AAAAB3NzaC1yc[...]8TWynQ== ssh-agent 130 | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNo[...]q4whro= ssh-tpm-agent 131 | 132 | === Hostkeys usage 133 | *ssh-tpm-agent* can also be used to serve host keys for an ssh server. 134 | *ssh-tpm-hostkeys* has convenient flags to help install systemd configurations 135 | and services to the system. This will create a system socket for ssh-tpm-agent 136 | under _/var/tmp/ssh-tpm-agent.sock_. 137 | 138 | $ sudo ssh-tpm-keygen -A 139 | 2023/09/03 17:03:08 INFO Generating new ECDSA host key 140 | 2023/09/03 17:03:08 INFO Wrote /etc/ssh/ssh_tpm_host_ecdsa_key.tpm 141 | 2023/09/03 17:03:08 INFO Generating new RSA host key 142 | 2023/09/03 17:03:15 INFO Wrote /etc/ssh/ssh_tpm_host_rsa_key.tpm 143 | 144 | $ sudo ssh-tpm-hostkeys --install-system-units 145 | Installed /usr/lib/systemd/system/ssh-tpm-agent.service 146 | Installed /usr/lib/systemd/system/ssh-tpm-agent.socket 147 | Installed /usr/lib/systemd/system/ssh-tpm-genkeys.service 148 | Enable with: systemctl enable --now ssh-tpm-agent.socket 149 | 150 | $ sudo ssh-tpm-hostkeys --install-sshd-config 151 | Installed /etc/ssh/sshd_config.d/10-ssh-tpm-agent.conf 152 | Restart sshd: systemd restart sshd 153 | 154 | $ systemctl enable --now ssh-tpm-agent.socket 155 | $ systemd restart sshd 156 | 157 | $ sudo ssh-tpm-hostkeys 158 | ecdsa-sha2-nistp256 AAAAE2V[...]YNwqWY0= root@localhost 159 | ssh-rsa AAAAB3NzaC1ycA[...]N1Jg3fLQKSe7f root@localhost 160 | 161 | $ ssh-keyscan -t ecdsa localhost 162 | # localhost:22 SSH-2.0-OpenSSH_9.4 163 | localhost ecdsa-sha2-nistp256 AAAAE2V[...]YNwqWY0= 164 | 165 | Alternatively one can omit the embedded install flags and just include a drop-in 166 | configuration for sshd under /etc/ssh/sshd_config.d with the following content. 167 | 168 | HostKeyAgent /var/tmp/ssh-tpm-agent.sock 169 | HostKey /etc/ssh/ssh_tpm_host_ecdsa_key.pub 170 | HostKey /etc/ssh/ssh_tpm_host_rsa_key.pub 171 | 172 | === Hierarchy keys 173 | 174 | TPMs are capable of creating static keys utilizing the top-level hierarchies. 175 | This enables the user to create keys that are available for the lifetime of the 176 | device, for the current owner of the device, or the current session of the 177 | device. These keys do not leave the TPM, like other keys created by 178 | *ssh-tpm-keygen*, and can always be recreated. 179 | 180 | These keys can be preloaded into *ssh-tpm-agent*. 181 | 182 | $ ssh-tpm-agent --hierarchy owner & 183 | $ export SSH_AUTH_SOCK="$(ssh-tpm-agent --print-socket)" 184 | $ ssh-add -l 185 | 2048 SHA256:yt7A20tcRnzgaD2ATgAXSNWy9sP6wznysp3SkoK3Gj8 Owner hierarchy key (RSA) 186 | 256 SHA256:PmEsMeh/DwFP04iUaWLNeX4maMR6r1vfqw1BbbdFjIg Owner hierarchy key (ECDSA) 187 | 188 | For usage with `sshd` the public part of these keys can be created by combining 189 | _-A_ with _--hierarchy_. 190 | 191 | $ ssh-tpm-keygen -A --hierarchy owner 192 | 2025/03/10 21:57:08 INFO Generating new hierarcy host key algorithm=RSA hierarchy=owner 193 | 2025/03/10 21:57:10 INFO Wrote public key filename=/etc/ssh/ssh_tpm_host_rsa_key.pub 194 | 2025/03/10 21:57:10 INFO Generating new hierarcy host key algorithm=ECDSA hierarchy=owner 195 | 2025/03/10 21:57:10 INFO Wrote public key filename=/etc/ssh/ssh_tpm_host_ecdsa_key.pub 196 | 197 | These files can be used with _HostKey_ as normal in _ssh_config_. 198 | 199 | The different key hierarchies have different properties and lifetimes. 200 | 201 | _endorsement_ hierarchy stores keys created for the lifetime of the device. This 202 | hierarchy should not change during the lifetime of the device. 203 | 204 | _owner_ hierarchy stores keys created for the device owner. These keys will be 205 | rotated when *tpm2_clear*(1) is issued on the platform, which should be done 206 | when the device gets a new owner. 207 | 208 | _null_ hierarchy stores keys created for the current session. The session should 209 | be a power cycle of the devices. 210 | 211 | *Note:* This feature is _experimental_. *ssh-tpm-agent* keeps the TPM objects 212 | loaded while running. Some TPM devices run out of memory if you attempt to use 213 | the hierarchy keys with the usual keys created by *ssh-tpm-keygen*. 214 | 215 | == Environment 216 | *SSH_TPM_AUTH_SOCK*:: 217 | Identifies the path of a unix-domain socket for communication with the agent. 218 | + 219 | Default to _/var/tmp/ssh-tpm-agent.sock_. 220 | 221 | *SSH_ASKPASS*:: 222 | If *ssh-tpm-agent*, and other binaries, needs to read a password it will default 223 | to using the terminal if it can. If there is no terminal available it will fall 224 | back to calling the binary *SSH_ASKPASS* point at. 225 | + 226 | See *ssh*(1) under *ENVIRONMENT* for more information. 227 | 228 | *SSH_ASKPASS_REQUIRE*:: 229 | Allows control of the use of the askpass program. 230 | Valid values are: 231 | * *never* ensures *ssh* will never try to use the askpass program. 232 | * *prefer* will prefer to use the askpass program. 233 | * *force* will ensure all passphrase inputs will be using the askpass program. 234 | 235 | + 236 | See *ssh*(1) under *ENVIRONMENT* for more information. 237 | 238 | *SSH_TPM_AGENT_SWTPM*:: 239 | Specify if *ssh-tpm-agent* should use the swtpm backend or not. Accepts any non-empty value as true. 240 | 241 | *SSH_TPM_LANDLOCK*:: 242 | If set then *ssh-tpm-agent*, and the other binaries, will enforce the landlock sandbox where applicable. 243 | + 244 | Disabled by default. 245 | + 246 | See *landlock*(7) for more information. 247 | 248 | 249 | == Files 250 | 251 | _~/ssh/id_rsa.tpm_:: 252 | _~/ssh/id_ecdsa.tpm_:: 253 | Contains the ssh private keys used by *ssh-tpm-agent*. They are TPM 2.0 TSS key files and securely wrapped by the TPM. They can be shared publicly as they can only be used by the TPM they where created on. However it is probably better to not do that. 254 | 255 | _~/ssh/id_rsa.pub_:: 256 | _~/ssh/id_ecdsa.pub_:: 257 | Contains the ssh public keys. These can be shared publicly, and is the same format as the ones created by *ssh-keygen*(1). 258 | 259 | _/run/user/$UID/ssh-tpm-agent.sock_:: 260 | The default user *ssh-tpm-agent* UNIX socket path. Used by induvidual users. 261 | 262 | _/var/tmp/ssh-tpm-agent.sock_:: 263 | The default system *ssh-tpm-agent* UNIX socket path. Used for host keys and the system. 264 | 265 | == See Also 266 | *ssh-agent*(1), *ssh*(1), *ssh-tpm-keygen*(1), *ssh-keygen*(1) 267 | 268 | == Notes, standards and other 269 | https://www.hansenpartnership.com/draft-bottomley-tpm2-keys.html[ASN.1 Specification for TPM 2.0 Key Files] 270 | 271 | https://linderud.dev/blog/store-ssh-keys-inside-the-tpm-ssh-tpm-agent/[Store ssh keys inside the TPM: ssh-tpm-agent] 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SSH agent for TPM 2 | ================= 3 | 4 | `ssh-tpm-agent` is a ssh-agent compatible agent that allows keys to be created 5 | by the Trusted Platform Module (TPM) for authentication towards ssh servers. 6 | 7 | TPM sealed keys are private keys created inside the Trusted Platform Module 8 | (TPM) and sealed in `.tpm` suffixed files. They are bound to the hardware they 9 | are produced on and can't be transferred to other machines. 10 | 11 | This allows you to utilize a native client instead of having to side load 12 | existing PKCS11 libraries into the ssh-agent and/or ssh client. 13 | 14 | The project uses [TPM 2.0 Key Files](https://www.hansenpartnership.com/draft-bottomley-tpm2-keys.html) 15 | implemented through the [`go-tpm-keyfiles`](https://github.com/Foxboron/go-tpm-keyfiles) project. 16 | 17 | # Features 18 | 19 | * A working `ssh-agent`. 20 | * Create shielded ssh keys on the TPM. 21 | * Creation of remotely wrapped SSH keys for import. 22 | * PIN support, dictionary attack protection from the TPM allows you to use low entropy PINs instead of passphrases. 23 | * TPM session encryption. 24 | * Proxy support towards other `ssh-agent` servers for fallbacks. 25 | 26 | # SWTPM support 27 | 28 | Instead of utilizing the TPM directly, you can use `--swtpm` or `export 29 | SSH_TPM_AGENT_SWTPM=1` to create an identity backed by 30 | [swtpm](https://github.com/stefanberger/swtpm) which will be stored under 31 | `/var/tmp/ssh-tpm-agent`. 32 | 33 | Note that `swtpm` provides no security properties and should only be used for 34 | testing. 35 | 36 | ## Installation 37 | 38 | The simplest way of installing this plugin is by running the following: 39 | 40 | ```bash 41 | go install github.com/foxboron/ssh-tpm-agent/cmd/...@latest 42 | ``` 43 | 44 | Alternatively download the [pre-built binaries](https://github.com/Foxboron/ssh-tpm-agent/releases). 45 | 46 | # Usage 47 | 48 | ```bash 49 | # Create key 50 | $ ssh-tpm-keygen 51 | Generating a sealed public/private ecdsa key pair. 52 | Enter file in which to save the key (/home/fox/.ssh/id_ecdsa): 53 | Enter passphrase (empty for no passphrase): 54 | Enter same passphrase again: 55 | Your identification has been saved in /home/fox/.ssh/id_ecdsa.tpm 56 | Your public key has been saved in /home/fox/.ssh/id_ecdsa.pub 57 | The key fingerprint is: 58 | SHA256:NCMJJ2La+q5tGcngQUQvEOJP3gPH8bMP98wJOEMV564 59 | The key's randomart image is the color of television, tuned to a dead channel. 60 | 61 | $ cat /home/fox/.ssh/id_ecdsa.pub 62 | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOTOsMXyjTc1wiQSKhRiNhKFsHJNLzLk2r4foXPLQYKR0tuXIBMTQuMmc7OiTgNMvIjMrcb9adgGdT3s+GkNi1g= 63 | 64 | # Using the socket 65 | $ ssh-tpm-agent -l /var/tmp/tpm.sock 66 | 67 | $ export SSH_AUTH_SOCK="$(ssh-tpm-agent --print-socket)" 68 | 69 | $ ssh git@github.com 70 | ``` 71 | 72 | **Note:** For `ssh-tpm-agent` you can specify the TPM owner password using the 73 | command line flags `-o` or `--owner-password`, which are preferred. 74 | Alternatively, you can use the environment variable 75 | `SSH_TPM_AGENT_OWNER_PASSWORD`. 76 | 77 | ### Import existing key 78 | 79 | Useful if you want to back up the key to a remote secure storage while using the key day-to-day from the TPM. 80 | 81 | ```bash 82 | # Create a key, or use an existing one 83 | $ ssh-keygen -t ecdsa -f id_ecdsa 84 | Generating public/private ecdsa key pair. 85 | Enter passphrase (empty for no passphrase): 86 | Enter same passphrase again: 87 | Your identification has been saved in id_ecdsa 88 | Your public key has been saved in id_ecdsa.pub 89 | The key fingerprint is: 90 | SHA256:bDn2EpX6XRX5ADXQSuTq+uUyia/eV3Z6MW+UtxjnXvU fox@framework 91 | The key's randomart image is: 92 | +---[ECDSA 256]---+ 93 | | .+=o..| 94 | | o. oo.| 95 | | o... .o| 96 | | . + .. ..| 97 | | S . . o| 98 | | o * . oo=*| 99 | | ..+.oo=+E| 100 | | .++o...o=| 101 | | .++++. .+ | 102 | +----[SHA256]-----+ 103 | 104 | # Import the key 105 | $ ssh-tpm-keygen --import id_ecdsa 106 | Sealing an existing public/private ecdsa key pair. 107 | Enter passphrase (empty for no passphrase): 108 | Enter same passphrase again: 109 | Your identification has been saved in id_ecdsa.tpm 110 | The key fingerprint is: 111 | SHA256:bDn2EpX6XRX5ADXQSuTq+uUyia/eV3Z6MW+UtxjnXvU 112 | The key's randomart image is the color of television, tuned to a dead channel. 113 | ``` 114 | 115 | ### Install user service 116 | 117 | Socket activated services allow you to start `ssh-tpm-agent` when it's needed by your system. 118 | 119 | ```bash 120 | # Using the socket 121 | $ ssh-tpm-agent --install-user-units 122 | Installed /home/fox/.config/systemd/user/ssh-tpm-agent.socket 123 | Installed /home/fox/.config/systemd/user/ssh-tpm-agent.service 124 | Enable with: systemctl --user enable --now ssh-tpm-agent.socket 125 | 126 | $ systemctl --user enable --now ssh-tpm-agent.socket 127 | 128 | $ export SSH_AUTH_SOCK="$(ssh-tpm-agent --print-socket)" 129 | 130 | $ ssh git@github.com 131 | ``` 132 | 133 | 134 | ### Proxy support 135 | 136 | ```bash 137 | # Start the usual ssh-agent 138 | $ eval $(ssh-agent) 139 | 140 | # Create a strong RSA key 141 | $ ssh-keygen -t rsa -b 4096 -f id_rsa -C ssh-agent 142 | ... 143 | The key fingerprint is: 144 | SHA256:zLSeyU/6NKHGEvyZLA866S1jGqwdwdAxRFff8Z2N1i0 ssh-agent 145 | 146 | $ ssh-add id_rsa 147 | Identity added: id_rsa (ssh-agent) 148 | 149 | # Print looonnggg key 150 | $ ssh-add -L 151 | ssh-rsa AAAAB3NzaC1yc[...]8TWynQ== ssh-agent 152 | 153 | # Create key on the TPM 154 | $ ssh-tpm-keygen -C ssh-tpm-agent 155 | Generating a sealed public/private ecdsa key pair. 156 | Enter file in which to save the key (/home/fox/.ssh/id_ecdsa): 157 | Enter passphrase (empty for no passphrase): 158 | Confirm passphrase: 159 | Your identification has been saved in /home/fox/.ssh/id_ecdsa.tpm 160 | Your public key has been saved in /home/fox/.ssh/id_ecdsa.pub 161 | The key fingerprint is: 162 | SHA256:PoQyuzOpEBLqT+xtP0dnvyBVL6UQTiQeCWN/EXIxPOo 163 | The key's randomart image is the color of television, tuned to a dead channel. 164 | 165 | # Start ssh-tpm-agent with a proxy socket 166 | $ ssh-tpm-agent -A "${SSH_AUTH_SOCK}" & 167 | 168 | $ export SSH_AUTH_SOCK="$(ssh-tpm-agent --print-socket)" 169 | 170 | # ssh-tpm-agent is proxying the keys from ssh-agent 171 | $ ssh-add -L 172 | ssh-rsa AAAAB3NzaC1yc[...]8TWynQ== ssh-agent 173 | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNo[...]q4whro= ssh-tpm-agent 174 | ``` 175 | 176 | ### ssh-tpm-add 177 | 178 | ```bash 179 | $ ssh-tpm-agent --no-load & 180 | 2023/08/12 13:40:50 Listening on /run/user/1000/ssh-tpm-agent.sock 181 | 182 | $ export SSH_AUTH_SOCK="$(ssh-tpm-agent --print-socket)" 183 | 184 | $ ssh-add -L 185 | The agent has no identities. 186 | 187 | $ ssh-tpm-add $HOME/.ssh/id_ecdsa.tpm 188 | Identity added: /home/user/.ssh/id_ecdsa.tpm 189 | 190 | $ ssh-add -L 191 | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJCxqisGa9IUNh4Ik3kwihrDouxP7S5Oun2hnzTvFwktszaibJruKLJMxHqVYnNwKD9DegCNwUN1qXCI/UOwaSY= test 192 | ``` 193 | 194 | ### Create and Wrap private key for client machine on remote server 195 | 196 | On the client side create one a primary key under an hierarchy. This example 197 | will use the owner hierarchy with an SRK. 198 | 199 | The output file `srk.pem` needs to be transferred to the remote end which 200 | creates the key. This could be done as part of client provisioning. 201 | 202 | ```bash 203 | $ tpm2_createprimary -C o -G ecc -g sha256 -c prim.ctx -a 'restricted|decrypt|fixedtpm|fixedparent|sensitivedataorigin|userwithauth|noda' -f pem -o srk.pem 204 | ``` 205 | 206 | On the remote end we create a p256 ssh key, with no password, and wrap it with 207 | `ssh-tpm-keygen` with the `srk.pem` from the client side. 208 | 209 | ```bash 210 | $ ssh-keygen -t ecdsa -b 256 -N "" -f ./ecdsa.key 211 | # OR with openssl 212 | $ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out ecdsa.key 213 | 214 | # Wrap with ssh-tpm-keygen 215 | $ ssh-tpm-keygen --wrap-with srk.pub --wrap ecdsa.key -f wrapped_id_ecdsa 216 | ``` 217 | 218 | On the client side we can unwrap `wrapped_id_ecdsa` to a loadable key. 219 | 220 | ```bash 221 | $ ssh-tpm-keygen --import ./wrapped_id_ecdsa.tpm -f id_ecdsa.tpm 222 | $ ssh-tpm-add id_ecdsa.tpm 223 | ``` 224 | 225 | ### ssh-tpm-hostkey 226 | 227 | `ssh-tpm-agent` also supports storing host keys inside the TPM. 228 | 229 | ```bash 230 | $ sudo ssh-tpm-keygen -A 231 | 2023/09/03 17:03:08 INFO Generating new ECDSA host key 232 | 2023/09/03 17:03:08 INFO Wrote /etc/ssh/ssh_tpm_host_ecdsa_key.tpm 233 | 2023/09/03 17:03:08 INFO Generating new RSA host key 234 | 2023/09/03 17:03:15 INFO Wrote /etc/ssh/ssh_tpm_host_rsa_key.tpm 235 | 236 | $ sudo ssh-tpm-hostkeys --install-system-units 237 | Installed /usr/lib/systemd/system/ssh-tpm-agent.service 238 | Installed /usr/lib/systemd/system/ssh-tpm-agent.socket 239 | Installed /usr/lib/systemd/system/ssh-tpm-genkeys.service 240 | Enable with: systemctl enable --now ssh-tpm-agent.socket 241 | 242 | $ sudo ssh-tpm-hostkeys --install-sshd-config 243 | Installed /etc/ssh/sshd_config.d/10-ssh-tpm-agent.conf 244 | Restart sshd: systemd restart sshd 245 | 246 | $ systemctl enable --now ssh-tpm-agent.socket 247 | $ systemd restart sshd 248 | 249 | $ sudo ssh-tpm-hostkeys 250 | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCLDH2xMDIGb26Q3Fa/kZDuPvzLzfAH6CkNs0wlaY2AaiZT2qJkWI05lMDm+mf+wmDhhgQlkJAHmyqgzYNwqWY0= root@framework 251 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAoMPsv5tEpTDFw34ltkF45dTHAPl4aLu6HigBkNnIzsuWqJxhjN6JK3vaV3eXBzy8/UJxo/R0Ml9/DRzFK8cccdIRT1KQtg8xIikRReZ0usdeqTC+wLpW/KQqgBLZ1PphRINxABWReqlnbtPVBfj6wKlCVNLEuTfzi1oAMj3KXOBDcTTB2UBLcwvTFg6YnbTjrpxY83Y+3QIZNPwYqd7r6k+e/ncUl4zgCvvxhoojGxEM3pjQIaZ0Him0yT6OGmCGFa7XIRKxwBSv9HtyHf5psgI+X5A2NV2JW2xeLhV2K1+UXmKW4aXjBWKSO08lPSWZ6/5jQTGN1Jg3fLQKSe7f root@framework 252 | 253 | $ ssh-keyscan -t ecdsa localhost 254 | # localhost:22 SSH-2.0-OpenSSH_9.4 255 | localhost ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCLDH2xMDIGb26Q3Fa/kZDuPvzLzfAH6CkNs0wlaY2AaiZT2qJkWI05lMDm+mf+wmDhhgQlkJAHmyqgzYNwqWY0= 256 | ``` 257 | 258 | # ssh-config 259 | 260 | It is possible to use the public keys created by `ssh-tpm-keygen` inside ssh 261 | configurations. 262 | 263 | The below example uses `ssh-tpm-agent` and also passes the public key to ensure 264 | not all identities are leaked from the agent. 265 | 266 | ```sshconfig 267 | Host example.com 268 | IdentityAgent $SSH_AUTH_SOCK 269 | 270 | Host * 271 | IdentityAgent /run/user/1000/ssh-tpm-agent.sock 272 | IdentityFile ~/.ssh/id_ecdsa.pub 273 | ``` 274 | 275 | ## License 276 | 277 | Licensed under the MIT license. See [LICENSE](LICENSE) or https://opensource.org/licenses/MIT 278 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-agent/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "log/slog" 10 | "net" 11 | "os" 12 | "os/signal" 13 | "path/filepath" 14 | "slices" 15 | "syscall" 16 | 17 | "github.com/foxboron/ssh-tpm-agent/agent" 18 | "github.com/foxboron/ssh-tpm-agent/askpass" 19 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 20 | "github.com/foxboron/ssh-tpm-agent/internal/lsm" 21 | "github.com/foxboron/ssh-tpm-agent/key" 22 | "github.com/foxboron/ssh-tpm-agent/utils" 23 | "github.com/google/go-tpm/tpm2/transport" 24 | "github.com/landlock-lsm/go-landlock/landlock" 25 | sshagent "golang.org/x/crypto/ssh/agent" 26 | "golang.org/x/term" 27 | ) 28 | 29 | var Version string 30 | 31 | const usage = `Usage: 32 | ssh-tpm-agent [OPTIONS] 33 | ssh-tpm-agent -l [PATH] 34 | ssh-tpm-agent --install-user-units 35 | 36 | Options: 37 | -l PATH Path of the UNIX socket to open, defaults to 38 | $XDG_RUNTIME_DIR/ssh-tpm-agent.sock. 39 | 40 | -A PATH Fallback ssh-agent sockets for additional key lookup. 41 | 42 | --print-socket Prints the socket to STDIN. 43 | 44 | --key-dir PATH Path of the directory to look for TPM sealed keys in, 45 | defaults to $HOME/.ssh 46 | 47 | --no-load Do not load TPM sealed keys by default. 48 | 49 | -o, --owner-password Ask for the owner password. 50 | 51 | --no-cache The agent will not cache key passwords. 52 | 53 | 54 | --hierarchy HIERARCHY Preload the agent with a hierarchy key. 55 | owner, o (default) 56 | endorsement, e 57 | null, n 58 | platform, p 59 | 60 | -d Enable debug logging. 61 | 62 | --install-user-units Installs systemd user units for using ssh-tpm-agent 63 | as a service. 64 | 65 | ssh-tpm-agent is a program that loads TPM sealed keys for public key 66 | authentication. It is an ssh-agent(1) compatible program and can be used for 67 | ssh(1) authentication. 68 | 69 | TPM sealed keys are private keys created inside the Trusted Platform Module 70 | (TPM) and sealed in .tpm suffixed files. They are bound to the hardware they 71 | where produced on and can't be transferred to other machines. 72 | 73 | Use ssh-tpm-keygen to create new keys. 74 | 75 | The agent loads all TPM sealed keys from $HOME/.ssh, unless --key-dir is 76 | specified. 77 | 78 | Example: 79 | $ ssh-tpm-agent & 80 | $ export SSH_AUTH_SOCK=$(ssh-tpm-agent --print-socket) 81 | $ ssh git@github.com` 82 | 83 | type SocketSet struct { 84 | Value []string 85 | } 86 | 87 | func (s SocketSet) String() string { 88 | return "set" 89 | } 90 | 91 | func (s *SocketSet) Set(p string) error { 92 | if !slices.Contains(s.Value, p) { 93 | s.Value = append(s.Value, p) 94 | } 95 | return nil 96 | } 97 | 98 | func (s SocketSet) Type() string { 99 | return "[PATH]" 100 | } 101 | 102 | func NewSocketSet(allowed []string, d string) *SocketSet { 103 | return &SocketSet{ 104 | Value: []string{}, 105 | } 106 | } 107 | 108 | func main() { 109 | flag.Usage = func() { 110 | fmt.Println(usage) 111 | } 112 | 113 | var ( 114 | socketPath, keyDir string 115 | swtpmFlag, printSocketFlag bool 116 | installUserUnits, system, noLoad bool 117 | askOwnerPassword, debugMode bool 118 | noCache bool 119 | hierarchy string 120 | ) 121 | 122 | var sockets SocketSet 123 | 124 | flag.StringVar(&socketPath, "l", func(s string) string { return utils.EnvSocketPath(s) }(socketPath), "path of the UNIX socket to listen on") 125 | flag.Var(&sockets, "A", "fallback ssh-agent sockets") 126 | flag.BoolVar(&swtpmFlag, "swtpm", false, "use swtpm instead of actual tpm") 127 | flag.BoolVar(&printSocketFlag, "print-socket", false, "print path of UNIX socket to stdout") 128 | flag.StringVar(&keyDir, "key-dir", "", "path of the directory to look for keys in") 129 | flag.BoolVar(&installUserUnits, "install-user-units", false, "install systemd user units") 130 | flag.BoolVar(&system, "install-system", false, "install systemd user units") 131 | flag.BoolVar(&noLoad, "no-load", false, "don't load TPM sealed keys") 132 | flag.BoolVar(&askOwnerPassword, "o", false, "ask for the owner password") 133 | flag.BoolVar(&askOwnerPassword, "owner-password", false, "ask for the owner password") 134 | flag.BoolVar(&debugMode, "d", false, "debug mode") 135 | flag.BoolVar(&noCache, "no-cache", false, "do not cache key passwords") 136 | flag.StringVar(&hierarchy, "hierarchy", "", "hierarchy for the created key") 137 | flag.Parse() 138 | 139 | opts := &slog.HandlerOptions{ 140 | Level: slog.LevelInfo, 141 | } 142 | 143 | if debugMode { 144 | opts.Level = slog.LevelDebug 145 | } 146 | 147 | logger := slog.New(slog.NewTextHandler(os.Stdout, opts)) 148 | 149 | slog.SetDefault(logger) 150 | 151 | if installUserUnits { 152 | if err := utils.InstallUserUnits(system); err != nil { 153 | log.Fatal(err) 154 | fmt.Println(err.Error()) 155 | os.Exit(1) 156 | } 157 | 158 | fmt.Println("Enable with: systemctl --user enable --now ssh-tpm-agent.socket") 159 | os.Exit(0) 160 | } 161 | 162 | if socketPath == "" { 163 | flag.Usage() 164 | os.Exit(1) 165 | } 166 | 167 | if printSocketFlag { 168 | fmt.Println(socketPath) 169 | os.Exit(0) 170 | } 171 | 172 | if keyDir == "" { 173 | keyDir = utils.SSHDir() 174 | } 175 | 176 | if term.IsTerminal(int(os.Stdin.Fd())) { 177 | slog.Info("Warning: ssh-tpm-agent is meant to run as a background daemon.") 178 | slog.Info("Running multiple instances is likely to lead to conflicts.") 179 | slog.Info("Consider using a systemd service.") 180 | } 181 | 182 | var agents []sshagent.ExtendedAgent 183 | 184 | for _, s := range sockets.Value { 185 | lsm.RestrictAdditionalPaths(landlock.RWFiles(s)) 186 | conn, err := net.Dial("unix", s) 187 | if err != nil { 188 | slog.Error(err.Error()) 189 | os.Exit(1) 190 | } 191 | agents = append(agents, sshagent.NewClient(conn)) 192 | } 193 | 194 | // Ensure we can rw socket path 195 | lsm.RestrictAdditionalPaths(landlock.RWFiles(socketPath)) 196 | listener, err := createListener(socketPath) 197 | if err != nil { 198 | slog.Error("creating listener", slog.String("error", err.Error())) 199 | os.Exit(1) 200 | } 201 | 202 | // TODO: Ensure the agent also uses thix context 203 | ctx, cancel := context.WithCancel(context.Background()) 204 | defer cancel() 205 | 206 | agentkeyring, err := keyring.NewThreadKeyring(ctx, keyring.SessionKeyring) 207 | if err != nil { 208 | log.Fatal(err) 209 | } 210 | 211 | // We need to pre-read all the keys before we run landlock 212 | var keys []key.SSHTPMKeys 213 | if !noLoad { 214 | keys, err = agent.LoadKeys(keyDir) 215 | if err != nil { 216 | log.Fatalf("can't preload keys from ~/.ssh: %v", err) 217 | } 218 | } 219 | 220 | // Try to landlock everything before we run the agent 221 | lsm.RestrictAgentFiles() 222 | if err := lsm.Restrict(); err != nil { 223 | log.Fatal(err) 224 | } 225 | 226 | agent := agent.NewAgent(listener, agents, 227 | 228 | // Keyring Callback 229 | func() *keyring.ThreadKeyring { 230 | return agentkeyring 231 | }, 232 | 233 | // TPM Callback 234 | func() (tpm transport.TPMCloser) { 235 | // the agent will close the TPM after this is called 236 | tpm, err := utils.TPM(swtpmFlag) 237 | if err != nil { 238 | log.Fatal(err) 239 | } 240 | return tpm 241 | }, 242 | 243 | // Owner password 244 | func() ([]byte, error) { 245 | if askOwnerPassword { 246 | return askpass.ReadPassphrase("Enter owner password for TPM", askpass.RP_USE_ASKPASS) 247 | } else { 248 | ownerPassword := os.Getenv("SSH_TPM_AGENT_OWNER_PASSWORD") 249 | return []byte(ownerPassword), nil 250 | } 251 | }, 252 | 253 | // PIN Callback with caching 254 | // SSHKeySigner in signer/signer.go resets this value if 255 | // we get a TPMRCAuthFail 256 | func(key key.SSHTPMKeys) ([]byte, error) { 257 | auth, err := agentkeyring.ReadKey(key.Fingerprint()) 258 | switch { 259 | case errors.Is(err, syscall.ENOENT): 260 | slog.Warn("kernel is missing the keyctl executable helpers. Please install the keyutils package to use the agent with caching.") 261 | fallthrough 262 | case errors.Is(err, syscall.ENOKEY) || errors.Is(err, syscall.EACCES): 263 | keyInfo := fmt.Sprintf("Enter passphrase for (%s): ", key.GetDescription()) 264 | // TODO: askpass should box the byte slice 265 | userauth, err := askpass.ReadPassphrase(keyInfo, askpass.RP_USE_ASKPASS) 266 | fmt.Println(err) 267 | if !noCache && err == nil { 268 | slog.Debug("caching userauth for key in keyring", slog.String("fp", key.Fingerprint())) 269 | if err := agentkeyring.AddKey(key.Fingerprint(), userauth); err != nil { 270 | return nil, err 271 | } 272 | } 273 | return userauth, err 274 | case err == nil: 275 | slog.Debug("providing cached userauth for key", slog.String("fp", key.Fingerprint())) 276 | // TODO: This is not great, but easier for now 277 | return auth.Read(), nil 278 | } 279 | return nil, fmt.Errorf("failed getting pin for key: %w", err) 280 | }, 281 | ) 282 | 283 | // Signal handling 284 | c := make(chan os.Signal, 1) 285 | signal.Notify(c, syscall.SIGHUP) 286 | signal.Notify(c, syscall.SIGINT) 287 | go func() { 288 | for range c { 289 | agent.Stop() 290 | } 291 | }() 292 | 293 | if !noLoad { 294 | agent.LoadKeys(keys) 295 | } 296 | 297 | if hierarchy != "" { 298 | if err := agent.AddHierarchyKeys(hierarchy); err != nil { 299 | log.Fatal(err) 300 | } 301 | } 302 | 303 | agent.Wait() 304 | } 305 | 306 | func createListener(socketPath string) (*net.UnixListener, error) { 307 | if _, ok := os.LookupEnv("LISTEN_FDS"); ok { 308 | f := os.NewFile(uintptr(3), "ssh-tpm-agent.socket") 309 | 310 | fListener, err := net.FileListener(f) 311 | if err != nil { 312 | return nil, err 313 | } 314 | 315 | listener, ok := fListener.(*net.UnixListener) 316 | if !ok { 317 | return nil, fmt.Errorf("socket-activation file descriptor isn't an unix socket") 318 | } 319 | 320 | slog.Info("Activated agent by socket") 321 | return listener, nil 322 | } 323 | 324 | _ = os.Remove(socketPath) 325 | 326 | if err := os.MkdirAll(filepath.Dir(socketPath), 0o770); err != nil { 327 | return nil, fmt.Errorf("creating UNIX socket directory: %w", err) 328 | } 329 | 330 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Net: "unix", Name: socketPath}) 331 | if err != nil { 332 | return nil, err 333 | } 334 | 335 | slog.Info("Listening on socket", slog.String("path", socketPath)) 336 | return listener, nil 337 | } 338 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= 2 | github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= 3 | github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo= 4 | github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/foxboron/go-tpm-keyfiles v0.0.0-20250318194951-cba49fbf70fa h1:2wXSGCPVpdFEi+SjcY1+SY0A0a1nbFzz/3HYuirLpX0= 9 | github.com/foxboron/go-tpm-keyfiles v0.0.0-20250318194951-cba49fbf70fa/go.mod h1:uAyTlAUxchYuiFjTHmuIEJ4nGSm7iOPaGcAyA81fJ80= 10 | github.com/foxboron/ssh-tpm-ca-authority v0.0.0-20240831163633-e92b30331d2d h1:hz0L1k0eZgHkJIgFj3Uyd0LSn7UXIwWJq9Xjj/8iGJM= 11 | github.com/foxboron/ssh-tpm-ca-authority v0.0.0-20240831163633-e92b30331d2d/go.mod h1:7BQDgpVVyISJ9W4O52KCbdBgQuujnZG2Ytuep1ya5NE= 12 | github.com/foxboron/swtpm_test v0.0.0-20230726224112-46aaafdf7006 h1:50sW4r0PcvlpG4PV8tYh2RVCapszJgaOLRCS2subvV4= 13 | github.com/foxboron/swtpm_test v0.0.0-20230726224112-46aaafdf7006/go.mod h1:eIXCMsMYCaqq9m1KSSxXwQG11krpuNPGP3k0uaWrbas= 14 | github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= 15 | github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 16 | github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= 17 | github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= 18 | github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= 19 | github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= 20 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 21 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 22 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 23 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 24 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 25 | github.com/google/go-configfs-tsm v0.2.2 h1:YnJ9rXIOj5BYD7/0DNnzs8AOp7UcvjfTvt215EWcs98= 26 | github.com/google/go-configfs-tsm v0.2.2/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGTR/JUg13RfwkgRo= 27 | github.com/google/go-sev-guest v0.9.3 h1:GOJ+EipURdeWFl/YYdgcCxyPeMgQUWlI056iFkBD8UU= 28 | github.com/google/go-sev-guest v0.9.3/go.mod h1:hc1R4R6f8+NcJwITs0L90fYWTsBpd1Ix+Gur15sqHDs= 29 | github.com/google/go-tdx-guest v0.3.1 h1:gl0KvjdsD4RrJzyLefDOvFOUH3NAJri/3qvaL5m83Iw= 30 | github.com/google/go-tdx-guest v0.3.1/go.mod h1:/rc3d7rnPykOPuY8U9saMyEps0PZDThLk/RygXm04nE= 31 | github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc= 32 | github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 33 | github.com/google/go-tpm-tools v0.4.4 h1:oiQfAIkc6xTy9Fl5NKTeTJkBTlXdHsxAofmQyxBKY98= 34 | github.com/google/go-tpm-tools v0.4.4/go.mod h1:T8jXkp2s+eltnCDIsXR84/MTcVU9Ja7bh3Mit0pa4AY= 35 | github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ= 36 | github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ= 37 | github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= 38 | github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 39 | github.com/landlock-lsm/go-landlock v0.0.0-20241014143150-479ddab4c04c h1:nwPp7v5drD5P9tUDUGF5P6Mrg7qb/oCEJBmmjlMIwG0= 40 | github.com/landlock-lsm/go-landlock v0.0.0-20241014143150-479ddab4c04c/go.mod h1:RSub3ourNF8Hf+swvw49Catm3s7HVf4hzdFxDUnEzdA= 41 | github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= 42 | github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= 43 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= 44 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= 45 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 46 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 50 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 51 | github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= 52 | github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= 53 | github.com/sigstore/sigstore v1.8.15 h1:9HHnZmxjPQSTPXTCZc25HDxxSTWwsGMh/ZhWZZ39maU= 54 | github.com/sigstore/sigstore v1.8.15/go.mod h1:+Wa5mrG6A+Gss516YC9owy10q3IazqIRe0y1EoQRHHM= 55 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= 56 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 57 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 58 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 59 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 60 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 61 | github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= 62 | github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= 63 | github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= 64 | github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= 65 | github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= 66 | github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= 67 | github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= 68 | github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= 69 | github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= 70 | github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= 71 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 72 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 73 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 74 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 75 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 76 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 77 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 78 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 79 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 80 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 81 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 82 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 83 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 84 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 85 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 86 | golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= 87 | golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 88 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 89 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 90 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 91 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 92 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 93 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 95 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 100 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 101 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 102 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 103 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 104 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 105 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 106 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 107 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 108 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 109 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 110 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 111 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 112 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 113 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 114 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 115 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 116 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 117 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 118 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 119 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 120 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 121 | golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= 122 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 123 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 124 | google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= 125 | google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 126 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 127 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 128 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 129 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 130 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.70 h1:HsB2G/rEQiYyo1bGoQqHZ/Bvd6x1rERQTNdPr1FyWjI= 131 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.70/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= 132 | -------------------------------------------------------------------------------- /agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "log" 11 | "log/slog" 12 | "net" 13 | "os" 14 | "path/filepath" 15 | "slices" 16 | "strings" 17 | "sync" 18 | "time" 19 | 20 | keyfile "github.com/foxboron/go-tpm-keyfiles" 21 | "github.com/foxboron/ssh-tpm-agent/internal/keyring" 22 | "github.com/foxboron/ssh-tpm-agent/key" 23 | "github.com/foxboron/ssh-tpm-agent/utils" 24 | "github.com/google/go-tpm/tpm2" 25 | "github.com/google/go-tpm/tpm2/transport" 26 | "golang.org/x/crypto/ssh" 27 | "golang.org/x/crypto/ssh/agent" 28 | "golang.org/x/text/cases" 29 | "golang.org/x/text/language" 30 | ) 31 | 32 | var ( 33 | ErrOperationUnsupported = errors.New("operation unsupported") 34 | ErrNoMatchPrivateKeys = errors.New("no private keys match the requested public key") 35 | ) 36 | 37 | var SSH_TPM_AGENT_ADD = "tpm-add-key" 38 | 39 | type Agent struct { 40 | mu sync.Mutex 41 | tpm func() transport.TPMCloser 42 | op func() ([]byte, error) 43 | pin func(key.SSHTPMKeys) ([]byte, error) 44 | listener *net.UnixListener 45 | quit chan interface{} 46 | wg sync.WaitGroup 47 | keyring func() *keyring.ThreadKeyring 48 | keys []key.SSHTPMKeys 49 | hierkeys []*key.HierSSHTPMKey 50 | agents []agent.ExtendedAgent 51 | } 52 | 53 | var _ agent.ExtendedAgent = &Agent{} 54 | 55 | func (a *Agent) Extension(extensionType string, contents []byte) ([]byte, error) { 56 | slog.Debug("called extensions") 57 | switch extensionType { 58 | case SSH_TPM_AGENT_ADD: 59 | slog.Debug("runnning extension", slog.String("type", extensionType)) 60 | return a.AddTPMKey(contents) 61 | } 62 | return nil, agent.ErrExtensionUnsupported 63 | } 64 | 65 | func (a *Agent) AddTPMKey(addedkey []byte) ([]byte, error) { 66 | slog.Debug("called addtpmkey") 67 | a.mu.Lock() 68 | defer a.mu.Unlock() 69 | 70 | k, err := ParseTPMKeyMsg(addedkey) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | // delete the key if it already exists in the list 76 | // it may have been loaded with no certificate or an old certificate 77 | a.keys = slices.DeleteFunc(a.keys, func(kk key.SSHTPMKeys) bool { 78 | return bytes.Equal(k.AgentKey().Marshal(), kk.AgentKey().Marshal()) 79 | }) 80 | 81 | a.keys = append(a.keys, k) 82 | 83 | return []byte(""), nil 84 | } 85 | 86 | func (a *Agent) AddProxyAgent(es agent.ExtendedAgent) error { 87 | // TODO: Write this up as an extension 88 | slog.Debug("called addproxyagent") 89 | a.mu.Lock() 90 | defer a.mu.Unlock() 91 | a.agents = append(a.agents, es) 92 | return nil 93 | } 94 | 95 | func (a *Agent) Close() error { 96 | slog.Debug("called close") 97 | // Flush hierarchy keys 98 | for _, k := range a.hierkeys { 99 | k.FlushHandle(a.tpm()) 100 | } 101 | a.Stop() 102 | return nil 103 | } 104 | 105 | func (a *Agent) signers() ([]ssh.Signer, error) { 106 | var signers []ssh.Signer 107 | 108 | for _, agent := range a.agents { 109 | l, err := agent.Signers() 110 | if err != nil { 111 | slog.Info("failed getting Signers from agent", slog.String("error", err.Error())) 112 | continue 113 | } 114 | signers = append(signers, l...) 115 | } 116 | 117 | for _, k := range a.keys { 118 | s, err := ssh.NewSignerFromSigner(k.Signer( 119 | a.keyring(), a.op, a.tpm, 120 | func(_ *keyfile.TPMKey) ([]byte, error) { 121 | // Shimming the function to get the correct type 122 | return a.pin(k) 123 | }), 124 | ) 125 | if err != nil { 126 | return nil, fmt.Errorf("failed to prepare signer: %w", err) 127 | } 128 | signers = append(signers, s) 129 | } 130 | return signers, nil 131 | } 132 | 133 | func (a *Agent) Signers() ([]ssh.Signer, error) { 134 | slog.Debug("called signers") 135 | a.mu.Lock() 136 | defer a.mu.Unlock() 137 | return a.signers() 138 | } 139 | 140 | func (a *Agent) List() ([]*agent.Key, error) { 141 | slog.Debug("called list") 142 | var agentKeys []*agent.Key 143 | 144 | a.mu.Lock() 145 | defer a.mu.Unlock() 146 | 147 | // Our keys first, then proxied agents 148 | for _, k := range a.keys { 149 | agentKeys = append(agentKeys, k.AgentKey()) 150 | } 151 | 152 | for _, agent := range a.agents { 153 | l, err := agent.List() 154 | if err != nil { 155 | slog.Info("failed getting list from agent", slog.String("error", err.Error())) 156 | continue 157 | } 158 | agentKeys = append(agentKeys, l...) 159 | } 160 | 161 | return agentKeys, nil 162 | } 163 | 164 | func (a *Agent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { 165 | slog.Debug("called signwithflags") 166 | a.mu.Lock() 167 | defer a.mu.Unlock() 168 | signers, err := a.signers() 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | var wantKey []byte 174 | wantKey = key.Marshal() 175 | alg := key.Type() 176 | 177 | // Unwrap the ssh.Certificate PublicKey 178 | if strings.Contains(alg, "cert") { 179 | parsedCert, err := ssh.ParsePublicKey(wantKey) 180 | if err != nil { 181 | return nil, err 182 | } 183 | cert, ok := parsedCert.(*ssh.Certificate) 184 | if ok { 185 | wantKey = cert.Key.Marshal() 186 | alg = cert.Key.Type() 187 | } 188 | } 189 | 190 | switch { 191 | case alg == ssh.KeyAlgoRSA && flags&agent.SignatureFlagRsaSha256 != 0: 192 | alg = ssh.KeyAlgoRSASHA256 193 | case alg == ssh.KeyAlgoRSA && flags&agent.SignatureFlagRsaSha512 != 0: 194 | alg = ssh.KeyAlgoRSASHA512 195 | } 196 | 197 | for _, s := range signers { 198 | if !bytes.Equal(s.PublicKey().Marshal(), wantKey) { 199 | continue 200 | } 201 | return s.(ssh.AlgorithmSigner).SignWithAlgorithm(rand.Reader, data, alg) 202 | } 203 | 204 | slog.Debug("trying to sign as proxy...") 205 | for _, agent := range a.agents { 206 | signers, err := agent.Signers() 207 | if err != nil { 208 | slog.Info("failed getting signers from agent", slog.String("error", err.Error())) 209 | continue 210 | } 211 | for _, s := range signers { 212 | if !bytes.Equal(s.PublicKey().Marshal(), wantKey) { 213 | continue 214 | } 215 | return s.(ssh.AlgorithmSigner).SignWithAlgorithm(rand.Reader, data, alg) 216 | } 217 | } 218 | 219 | return nil, ErrNoMatchPrivateKeys 220 | } 221 | 222 | func (a *Agent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { 223 | slog.Debug("called sign") 224 | return a.SignWithFlags(key, data, 0) 225 | } 226 | 227 | func (a *Agent) serveConn(c net.Conn) { 228 | if err := agent.ServeAgent(a, c); err != io.EOF { 229 | slog.Info("Agent client connection ended unsuccessfully", slog.String("error", err.Error())) 230 | } 231 | } 232 | 233 | func (a *Agent) Wait() { 234 | a.wg.Wait() 235 | } 236 | 237 | func (a *Agent) Stop() { 238 | close(a.quit) 239 | a.listener.Close() 240 | a.wg.Wait() 241 | } 242 | 243 | func (a *Agent) serve() { 244 | defer a.wg.Done() 245 | for { 246 | c, err := a.listener.AcceptUnix() 247 | if err != nil { 248 | type temporary interface { 249 | Temporary() bool 250 | Error() string 251 | } 252 | if err, ok := err.(temporary); ok && err.Temporary() { 253 | slog.Info("Temporary Accept failure, sleeping 1s", slog.String("error", err.Error())) 254 | time.Sleep(1 * time.Second) 255 | continue 256 | } 257 | select { 258 | case <-a.quit: 259 | return 260 | default: 261 | slog.Error("Failed to accept connections", slog.String("error", err.Error())) 262 | } 263 | } 264 | a.wg.Add(1) 265 | go func() { 266 | a.serveConn(c) 267 | a.wg.Done() 268 | }() 269 | } 270 | } 271 | 272 | func (a *Agent) AddKey(k *key.SSHTPMKey) error { 273 | slog.Debug("called addkey") 274 | a.keys = append(a.keys, k) 275 | return nil 276 | } 277 | 278 | func (a *Agent) LoadKeys(keys []key.SSHTPMKeys) { 279 | slog.Debug("called loadkeys") 280 | a.mu.Lock() 281 | defer a.mu.Unlock() 282 | a.keys = append(a.keys, keys...) 283 | } 284 | 285 | func (a *Agent) AddHierarchyKeys(hier string) error { 286 | tpm := a.tpm() 287 | h, err := utils.GetParentHandle(hier) 288 | if err != nil { 289 | log.Fatal(err) 290 | } 291 | for n, t := range map[string]struct { 292 | alg tpm2.TPMAlgID 293 | }{ 294 | "rsa": {alg: tpm2.TPMAlgRSA}, 295 | "ecdsa": {alg: tpm2.TPMAlgECC}, 296 | } { 297 | slog.Info("hierarchy key", slog.String("algorithm", strings.ToUpper(n)), slog.String("hierarchy", hier)) 298 | hkey, err := key.CreateHierarchyKey(tpm, t.alg, h, fmt.Sprintf("%s hierarchy key", cases.Title(language.Und, cases.NoLower).String(hier))) 299 | if err != nil { 300 | return err 301 | } 302 | a.mu.Lock() 303 | a.hierkeys = append(a.hierkeys, hkey) 304 | a.keys = append(a.keys, hkey) 305 | a.mu.Unlock() 306 | } 307 | return nil 308 | } 309 | 310 | func (a *Agent) Add(key agent.AddedKey) error { 311 | // This just proxies the Add call to all proxied agents 312 | // First to accept gets the key! 313 | slog.Debug("called add") 314 | for _, agent := range a.agents { 315 | if err := agent.Add(key); err == nil { 316 | return nil 317 | } 318 | } 319 | return nil 320 | } 321 | 322 | func (a *Agent) Remove(sshkey ssh.PublicKey) error { 323 | slog.Debug("called remove") 324 | a.mu.Lock() 325 | defer a.mu.Unlock() 326 | 327 | var found bool 328 | a.keys = slices.DeleteFunc(a.keys, func(k key.SSHTPMKeys) bool { 329 | if bytes.Equal(sshkey.Marshal(), k.AgentKey().Marshal()) { 330 | slog.Debug("deleting key from ssh-tpm-agent", 331 | slog.String("fingerprint", ssh.FingerprintSHA256(sshkey)), 332 | slog.String("type", sshkey.Type()), 333 | ) 334 | found = true 335 | return true 336 | } 337 | return false 338 | }) 339 | 340 | if found { 341 | return nil 342 | } 343 | 344 | for _, agent := range a.agents { 345 | lkeys, err := agent.List() 346 | if err != nil { 347 | slog.Debug("agent returned err on List()", slog.Any("err", err)) 348 | continue 349 | } 350 | 351 | for _, k := range lkeys { 352 | if !bytes.Equal(k.Marshal(), sshkey.Marshal()) { 353 | continue 354 | } 355 | if err := agent.Remove(sshkey); err != nil { 356 | slog.Debug("agent returned err on Remove()", slog.Any("err", err)) 357 | } 358 | slog.Debug("deleting key from an proxy agent", 359 | slog.String("fingerprint", ssh.FingerprintSHA256(sshkey)), 360 | slog.String("type", sshkey.Type()), 361 | ) 362 | return nil 363 | } 364 | } 365 | slog.Debug("could not find key in any proxied agent", 366 | slog.String("fingerprint", ssh.FingerprintSHA256(sshkey)), 367 | slog.String("type", sshkey.Type()), 368 | ) 369 | return fmt.Errorf("key not found") 370 | } 371 | 372 | func (a *Agent) RemoveAll() error { 373 | slog.Debug("called removeall") 374 | a.mu.Lock() 375 | defer a.mu.Unlock() 376 | 377 | a.keys = []key.SSHTPMKeys{} 378 | 379 | for _, agent := range a.agents { 380 | if err := agent.RemoveAll(); err == nil { 381 | return nil 382 | } 383 | } 384 | return nil 385 | } 386 | 387 | func (a *Agent) Lock(passphrase []byte) error { 388 | slog.Debug("called lock") 389 | return ErrOperationUnsupported 390 | } 391 | 392 | func (a *Agent) Unlock(passphrase []byte) error { 393 | slog.Debug("called unlock") 394 | return ErrOperationUnsupported 395 | } 396 | 397 | func LoadKeys(keyDir string) ([]key.SSHTPMKeys, error) { 398 | keyDir, err := filepath.EvalSymlinks(keyDir) 399 | if err != nil { 400 | return nil, err 401 | } 402 | 403 | var keys []key.SSHTPMKeys 404 | 405 | walkFunc := func(path string, d fs.DirEntry, err error) error { 406 | if err != nil { 407 | return err 408 | } 409 | 410 | if d.IsDir() { 411 | return nil 412 | } 413 | 414 | if !strings.HasSuffix(path, ".tpm") { 415 | slog.Debug("skipping key: does not have .tpm suffix", slog.String("name", path)) 416 | return nil 417 | } 418 | 419 | f, err := os.ReadFile(path) 420 | if err != nil { 421 | return fmt.Errorf("failed reading %s", path) 422 | } 423 | 424 | k, err := key.Decode(f) 425 | if err != nil { 426 | if errors.Is(err, key.ErrOldKey) { 427 | slog.Info("TPM key is in an old format. Will not load it.", slog.String("key_path", path), slog.String("error", err.Error())) 428 | } else { 429 | slog.Debug("not a TPM sealed key", slog.String("key_path", path), slog.String("error", err.Error())) 430 | } 431 | return nil 432 | } 433 | 434 | keys = append(keys, k) 435 | slog.Debug("added TPM key", slog.String("name", path)) 436 | 437 | certStr := fmt.Sprintf("%s-cert.pub", strings.TrimSuffix(path, filepath.Ext(path))) 438 | if _, err := os.Stat(certStr); !errors.Is(err, os.ErrNotExist) { 439 | b, err := os.ReadFile(certStr) 440 | if err != nil { 441 | return err 442 | } 443 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey(b) 444 | if err != nil { 445 | return err 446 | } 447 | 448 | cert, ok := pubKey.(*ssh.Certificate) 449 | if !ok { 450 | return err 451 | } 452 | c := *k 453 | c.Certificate = cert 454 | keys = append(keys, &c) 455 | slog.Debug("added certificate", slog.String("name", path)) 456 | } 457 | return nil 458 | } 459 | 460 | err = filepath.WalkDir(keyDir, walkFunc) 461 | return keys, err 462 | } 463 | 464 | func NewAgent(listener *net.UnixListener, agents []agent.ExtendedAgent, keyring func() *keyring.ThreadKeyring, tpmFetch func() transport.TPMCloser, ownerPassword func() ([]byte, error), pin func(key.SSHTPMKeys) ([]byte, error)) *Agent { 465 | a := &Agent{ 466 | agents: agents, 467 | tpm: tpmFetch, 468 | op: ownerPassword, 469 | listener: listener, 470 | pin: pin, 471 | quit: make(chan interface{}), 472 | keys: []key.SSHTPMKeys{}, 473 | hierkeys: []*key.HierSSHTPMKey{}, 474 | keyring: keyring, 475 | } 476 | 477 | a.wg.Add(1) 478 | go a.serve() 479 | return a 480 | } 481 | -------------------------------------------------------------------------------- /cmd/ssh-tpm-keygen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "errors" 10 | "flag" 11 | "fmt" 12 | "log" 13 | "log/slog" 14 | "os" 15 | "os/user" 16 | "path" 17 | "slices" 18 | "strings" 19 | "sync" 20 | 21 | keyfile "github.com/foxboron/go-tpm-keyfiles" 22 | tpmpkix "github.com/foxboron/go-tpm-keyfiles/pkix" 23 | "github.com/foxboron/ssh-tpm-agent/askpass" 24 | "github.com/foxboron/ssh-tpm-agent/key" 25 | "github.com/foxboron/ssh-tpm-agent/utils" 26 | "github.com/google/go-tpm/tpm2" 27 | "github.com/google/go-tpm/tpm2/transport" 28 | "golang.org/x/crypto/ssh" 29 | ) 30 | 31 | var Version string 32 | 33 | const usage = `Usage: 34 | ssh-tpm-keygen 35 | ssh-tpm-keygen --wrap keyfile --wrap-with keyfile 36 | ssh-tpm-keygen --import keyfile 37 | ssh-tpm-keygen --print-pubkey keyfile 38 | ssh-tpm-keygen --supported 39 | ssh-tpm-keygen -p [-f keyfile] [-N new_passphrase] [-P old_passphrase] 40 | ssh-tpm-keygen -A [-f path] [--hierarchy hierarchy] 41 | 42 | Options: 43 | -o, --owner-password Ask for the owner password. 44 | -C Provide a comment with the key. 45 | -f Output keyfile. 46 | -N passphrase for the key. 47 | -t ecdsa | rsa Specify the type of key to create. Defaults to ecdsa 48 | -b bits Number of bits in the key to create. 49 | rsa: 2048 (default) 50 | ecdsa: 256 (default) | 384 | 521 51 | -p Change keyfile passphrase 52 | -P Old passphrase for keyfile 53 | -I, --import PATH Import existing key into ssh-tpm-agent. 54 | -A Generate host keys for all key types (rsa and ecdsa). 55 | --hierarchy HIERARCHY Hierarchy to create the persistent public key under. 56 | Only useable with -A. 57 | owner, o (default) 58 | endorsement, e 59 | null, n 60 | platform, p 61 | --parent-handle Parent for the TPM key. Can be a hierarchy or a 62 | persistent handle. 63 | owner, o (default) 64 | endorsement, e 65 | null, n 66 | platform, p 67 | --print-pubkey Print the public key given a TPM private key. 68 | --supported List the supported keys of the TPM. 69 | --wrap PATH A SSH key to wrap for import on remote machine. 70 | --wrap-with PATH Parent key to wrap the SSH key with. 71 | 72 | Generate new TPM sealed keys for ssh-tpm-agent. 73 | 74 | TPM sealed keys are private keys created inside the Trusted Platform Module 75 | (TPM) and sealed in .tpm suffixed files. They are bound to the hardware they 76 | where produced on and can't be transferred to other machines. 77 | 78 | Example: 79 | $ ssh-tpm-keygen 80 | Generating a sealed public/private ecdsa key pair. 81 | Enter file in which to save the key (/home/user/.ssh/id_ecdsa): 82 | Enter passphrase (empty for no passphrase): 83 | Enter same passphrase again: 84 | Your identification has been saved in /home/user/.ssh/id_ecdsa.tpm 85 | Your public key has been saved in /home/user/.ssh/id_ecdsa.pub 86 | The key fingerprint is: 87 | SHA256:NCMJJ2La+q5tGcngQUQvEOJP3gPH8bMP98wJOEMV564 88 | The key's randomart image is the color of television, tuned to a dead channel.` 89 | 90 | func getPin() ([]byte, error) { 91 | for { 92 | pin1, err := askpass.ReadPassphrase("Enter passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 93 | if err != nil { 94 | return nil, err 95 | } 96 | pin2, err := askpass.ReadPassphrase("Enter same passphrase again: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 97 | if err != nil { 98 | return nil, err 99 | } 100 | if !bytes.Equal(pin1, pin2) { 101 | fmt.Println("Passphrases do not match. Try again.") 102 | continue 103 | } 104 | return pin1, nil 105 | } 106 | } 107 | 108 | func getOwnerPassword() ([]byte, error) { 109 | return askpass.ReadPassphrase("Enter owner password: ", askpass.RP_ALLOW_STDIN) 110 | } 111 | 112 | func doHostKeys(tpm transport.TPMCloser, outputFile string, ownerPassword []byte, hierarchy string) { 113 | // Mimics the `ssh-keygen -A -f ./something` behaviour 114 | outputPath := "/etc/ssh" 115 | if outputFile != "" { 116 | outputPath = path.Join(outputFile, outputPath) 117 | } 118 | 119 | for n, t := range map[string]struct { 120 | alg tpm2.TPMAlgID 121 | bits int 122 | }{ 123 | "rsa": {alg: tpm2.TPMAlgRSA, bits: 2048}, 124 | "ecdsa": {alg: tpm2.TPMAlgECC, bits: 256}, 125 | } { 126 | filename := fmt.Sprintf("ssh_tpm_host_%s_key", n) 127 | privatekeyFilename := path.Join(outputPath, filename+".tpm") 128 | pubkeyFilename := path.Join(outputPath, filename+".pub") 129 | 130 | if utils.FileExists(privatekeyFilename) || utils.FileExists(pubkeyFilename) { 131 | continue 132 | } 133 | 134 | if hierarchy != "" { 135 | slog.Info("Generating new hierarcy host key", slog.String("algorithm", strings.ToUpper(n)), slog.String("hierarchy", hierarchy)) 136 | h, err := utils.GetParentHandle(hierarchy) 137 | if err != nil { 138 | log.Fatal(err) 139 | } 140 | hkey, err := key.CreateHierarchyKey(tpm, t.alg, h, defaultComment()) 141 | if err != nil { 142 | log.Fatal(err) 143 | } 144 | if err := os.WriteFile(pubkeyFilename, hkey.AuthorizedKey(), 0o600); err != nil { 145 | log.Fatal(err) 146 | } 147 | slog.Info("Wrote public key", slog.String("filename", pubkeyFilename)) 148 | } else { 149 | slog.Info("Generating new host key", slog.String("algorithm", strings.ToUpper(n))) 150 | k, err := keyfile.NewLoadableKey(tpm, t.alg, t.bits, ownerPassword, 151 | keyfile.WithDescription(defaultComment()), 152 | ) 153 | if err != nil { 154 | log.Fatal(err) 155 | } 156 | 157 | sshkey, err := key.WrapTPMKey(k) 158 | if err != nil { 159 | log.Fatal(err) 160 | } 161 | 162 | if err := os.WriteFile(pubkeyFilename, sshkey.AuthorizedKey(), 0o600); err != nil { 163 | log.Fatal(err) 164 | } 165 | 166 | if err := os.WriteFile(privatekeyFilename, sshkey.Bytes(), 0o600); err != nil { 167 | log.Fatal(err) 168 | } 169 | slog.Info("Wrote private key", slog.String("filename", privatekeyFilename)) 170 | } 171 | } 172 | } 173 | 174 | func doChangePin(tpm transport.TPMCloser, passphrase, keyPin, ownerPassword []byte, filename, outputFile string) error { 175 | filename = filename + ".tpm" 176 | if outputFile != "" { 177 | filename = outputFile 178 | } 179 | 180 | b, err := os.ReadFile(filename) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | k, err := key.Decode(b) 186 | if err != nil { 187 | return err 188 | } 189 | 190 | if k.Description != "" { 191 | fmt.Printf("Key has comment '%s'\n", k.Description) 192 | } 193 | if outputFile == "" { 194 | f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which the key is (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 195 | if err != nil { 196 | return err 197 | } 198 | filename = string(f) 199 | } 200 | 201 | if len(passphrase) == 0 { 202 | passphrase, err = askpass.ReadPassphrase("Enter old passphrase: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 203 | if err != nil { 204 | return err 205 | } 206 | } 207 | 208 | if len(keyPin) == 0 { 209 | keyPin, err = askpass.ReadPassphrase("Enter new passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 210 | if err != nil { 211 | return err 212 | } 213 | newPin, err := askpass.ReadPassphrase("Enter same passphrase: ", askpass.RP_ALLOW_STDIN) 214 | if err != nil { 215 | return err 216 | } 217 | if !bytes.Equal(keyPin, newPin) { 218 | log.Fatal("Passphrases do not match. Try again.") 219 | } 220 | fmt.Println() 221 | } 222 | 223 | if err := keyfile.ChangeAuth(tpm, ownerPassword, k.TPMKey, keyPin, passphrase); err != nil { 224 | log.Fatal("Failed changing passphrase on the key.") 225 | } 226 | 227 | if err := os.WriteFile(filename, k.Bytes(), 0o600); err != nil { 228 | return err 229 | } 230 | 231 | fmt.Println("Your identification has been saved with the new passphrase.") 232 | return nil 233 | } 234 | 235 | func doWrapWith(supportedECCBitsizes []int, wrap, wrapWith string, keyParentHandle tpm2.TPMHandle, comment, outputFile string) { 236 | pem, err := os.ReadFile(wrap) 237 | if err != nil { 238 | log.Fatal(err) 239 | } 240 | 241 | wrapperFile, err := os.ReadFile(wrapWith) 242 | if err != nil { 243 | log.Fatal(err) 244 | } 245 | 246 | parentPublic, err := tpmpkix.ToTPMPublic(wrapperFile) 247 | if err != nil { 248 | log.Fatalf("wrapper-with does not contain a valid parent TPMTPublic: %v", err) 249 | } 250 | 251 | fmt.Println("Wrapping an existing public/private ecdsa key pair for import.") 252 | 253 | var kerr *ssh.PassphraseMissingError 254 | var rawKey any 255 | 256 | rawKey, err = ssh.ParseRawPrivateKey(pem) 257 | if errors.As(err, &kerr) { 258 | for { 259 | pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 260 | if err != nil { 261 | log.Fatal(err) 262 | } 263 | rawKey, err = ssh.ParseRawPrivateKeyWithPassphrase(pem, pin) 264 | if err == nil { 265 | break 266 | } else if errors.Is(err, x509.IncorrectPasswordError) { 267 | fmt.Println("Wrong passphrase, try again.") 268 | continue 269 | } else { 270 | log.Fatal(err) 271 | } 272 | } 273 | } 274 | 275 | // Because go-tpm-keyfiles expects a pointer at some point we deserialize the pointer 276 | var pk crypto.PrivateKey 277 | 278 | switch key := rawKey.(type) { 279 | case *ecdsa.PrivateKey: 280 | if !slices.Contains(supportedECCBitsizes, key.Params().BitSize) { 281 | log.Fatalf("invalid ecdsa key length: TPM does not support %v bits", key.Params().BitSize) 282 | } 283 | pk = *key 284 | case *rsa.PrivateKey: 285 | if key.N.BitLen() != 2048 { 286 | log.Fatal("can only support 2048 bit RSA") 287 | } 288 | pk = *key 289 | default: 290 | log.Fatal("unsupported key type") 291 | } 292 | 293 | k, err := keyfile.NewImportablekey(parentPublic, pk, 294 | keyfile.WithDescription(comment), 295 | keyfile.WithParent(keyParentHandle), 296 | ) 297 | if err != nil { 298 | log.Fatal(err) 299 | } 300 | 301 | privatekeyFilename := outputFile + ".tpm" 302 | pubkeyFilename := outputFile + ".pub" 303 | 304 | if err := os.WriteFile(privatekeyFilename, k.Bytes(), 0o600); err != nil { 305 | log.Fatal(err) 306 | } 307 | 308 | // Write out the public key 309 | sshkey, err := key.WrapTPMKey(k) 310 | if err != nil { 311 | log.Fatal(err) 312 | } 313 | if err := os.WriteFile(pubkeyFilename, sshkey.AuthorizedKey(), 0o600); err != nil { 314 | log.Fatal(err) 315 | } 316 | } 317 | 318 | func defaultComment() string { 319 | cache.Do(func() { 320 | cache.u, cache.err = user.Current() 321 | if cache.err != nil { 322 | return 323 | } 324 | cache.host, cache.err = os.Hostname() 325 | }) 326 | if cache.err != nil { 327 | slog.Error(cache.err.Error()) 328 | os.Exit(1) 329 | return "" 330 | } 331 | return cache.u.Username + "@" + cache.host 332 | } 333 | 334 | var cache struct { 335 | sync.Once 336 | u *user.User 337 | host string 338 | err error 339 | } 340 | 341 | var eccBitCache struct { 342 | sync.Once 343 | bits []int 344 | } 345 | 346 | func supportedECCBitsizes(tpm transport.TPMCloser) []int { 347 | eccBitCache.Do(func() { 348 | eccBitCache.bits = keyfile.SupportedECCAlgorithms(tpm) 349 | }) 350 | return eccBitCache.bits 351 | } 352 | 353 | func checkFile(f string) error { 354 | if utils.FileExists(f) { 355 | fmt.Printf("%s already exists.\n", f) 356 | s, err := askpass.ReadPassphrase("Overwrite (y/n)? ", askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 357 | if err != nil { 358 | return err 359 | } 360 | if !bytes.Equal(s, []byte("y")) { 361 | return nil 362 | } 363 | } 364 | return nil 365 | } 366 | 367 | func doImportKey(tpm transport.TPMCloser, keyParentHandle tpm2.TPMHandle, ownerPassword []byte, keyPin string, pem []byte, filename string) (*key.SSHTPMKey, error) { 368 | var toImportKey any 369 | var kerr *ssh.PassphraseMissingError 370 | var rawKey any 371 | var err error 372 | 373 | rawKey, err = ssh.ParseRawPrivateKey(pem) 374 | if errors.As(err, &kerr) { 375 | for { 376 | pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 377 | if err != nil { 378 | return nil, err 379 | } 380 | rawKey, err = ssh.ParseRawPrivateKeyWithPassphrase(pem, pin) 381 | if err == nil { 382 | break 383 | } else if errors.Is(err, x509.IncorrectPasswordError) { 384 | fmt.Println("Wrong passphrase, try again.") 385 | continue 386 | } else { 387 | return nil, err 388 | } 389 | } 390 | } 391 | 392 | switch key := rawKey.(type) { 393 | case *ecdsa.PrivateKey: 394 | toImportKey = *key 395 | if !slices.Contains(supportedECCBitsizes(tpm), key.Params().BitSize) { 396 | log.Fatalf("invalid ecdsa key length: TPM does not support %v bits", key.Params().BitSize) 397 | } 398 | case *rsa.PrivateKey: 399 | if key.N.BitLen() != 2048 { 400 | log.Fatal("can only support 2048 bit RSA") 401 | } 402 | toImportKey = *key 403 | default: 404 | log.Fatal("unsupported key type") 405 | } 406 | 407 | pubPem, err := os.ReadFile(filename + ".pub") 408 | if err != nil { 409 | log.Fatalf("can't find corresponding public key: %v", err) 410 | } 411 | _, comment, _, _, err := ssh.ParseAuthorizedKey(pubPem) 412 | if err != nil { 413 | log.Fatal("can't parse public key", err) 414 | } 415 | 416 | var pin []byte 417 | if keyPin != "" { 418 | pin = []byte(keyPin) 419 | } else { 420 | pinInput, err := getPin() 421 | if err != nil { 422 | log.Fatal(err) 423 | } 424 | pin = []byte(pinInput) 425 | } 426 | 427 | k, err := key.NewImportedSSHTPMKey(tpm, toImportKey, ownerPassword, 428 | keyfile.WithParent(keyParentHandle), 429 | keyfile.WithUserAuth(pin), 430 | keyfile.WithDescription(comment)) 431 | if err != nil { 432 | return nil, err 433 | } 434 | return k, nil 435 | } 436 | 437 | func doImportWrappedKey(tpm transport.TPMCloser, ownerPassword, pem []byte) (*key.SSHTPMKey, error) { 438 | tpmkey, err := keyfile.Decode(pem) 439 | if errors.Is(err, keyfile.ErrNotTPMKey) { 440 | log.Fatal("This shouldn't happen") 441 | } 442 | tkey, err := keyfile.ImportTPMKey(tpm, tpmkey, ownerPassword) 443 | if err != nil { 444 | return nil, err 445 | } 446 | k, err := key.WrapTPMKey(tkey) 447 | if err != nil { 448 | return nil, err 449 | } 450 | return k, nil 451 | } 452 | 453 | func doCreateSSHKey(tpm transport.TPMCloser, ownerPassword []byte, keyPin string, keyParentHandle tpm2.TPMHandle, keyType string, comment string, bits int) (*key.SSHTPMKey, error) { 454 | var tpmkeyType tpm2.TPMAlgID 455 | switch keyType { 456 | case "ecdsa": 457 | tpmkeyType = tpm2.TPMAlgECC 458 | case "rsa": 459 | tpmkeyType = tpm2.TPMAlgRSA 460 | } 461 | 462 | var pin []byte 463 | if keyPin != "" { 464 | pin = []byte(keyPin) 465 | } else { 466 | pinInput, err := getPin() 467 | if err != nil { 468 | log.Fatal(err) 469 | } 470 | pin = []byte(pinInput) 471 | } 472 | 473 | k, err := key.NewSSHTPMKey(tpm, tpmkeyType, bits, ownerPassword, 474 | keyfile.WithParent(keyParentHandle), 475 | keyfile.WithUserAuth(pin), 476 | keyfile.WithDescription(comment), 477 | ) 478 | if err != nil { 479 | return nil, err 480 | } 481 | return k, nil 482 | } 483 | 484 | func main() { 485 | flag.Usage = func() { 486 | fmt.Println(usage) 487 | } 488 | 489 | var ( 490 | askOwnerPassword bool 491 | comment, outputFile, keyPin, passphrase string 492 | keyType, importKey string 493 | bits int 494 | swtpmFlag, hostKeys, changePin bool 495 | listsupported bool 496 | printPubkey string 497 | parentHandle, wrap, wrapWith string 498 | hierarchy string 499 | ) 500 | 501 | flag.BoolVar(&askOwnerPassword, "o", false, "ask for the owner password") 502 | flag.BoolVar(&askOwnerPassword, "owner-password", false, "ask for the owner password") 503 | flag.StringVar(&comment, "C", defaultComment(), "provide a comment, default to user@host") 504 | flag.StringVar(&outputFile, "f", "", "output keyfile") 505 | flag.StringVar(&keyPin, "N", "", "new passphrase for the key") 506 | flag.StringVar(&keyType, "t", "ecdsa", "key to create") 507 | flag.IntVar(&bits, "b", 0, "number of bits") 508 | flag.StringVar(&importKey, "I", "", "import key") 509 | flag.StringVar(&importKey, "import", "", "import key") 510 | flag.BoolVar(&changePin, "p", false, "change passphrase") 511 | flag.StringVar(&passphrase, "P", "", "old passphrase") 512 | flag.BoolVar(&swtpmFlag, "swtpm", false, "use swtpm instead of actual tpm") 513 | flag.BoolVar(&hostKeys, "A", false, "generate host keys") 514 | flag.BoolVar(&listsupported, "supported", false, "list tpm caps") 515 | flag.StringVar(&printPubkey, "print-pubkey", "", "print tpm pubkey") 516 | flag.StringVar(&wrap, "wrap", "", "wrap key") 517 | flag.StringVar(&wrapWith, "wrap-with", "", "wrap with key") 518 | flag.StringVar(&parentHandle, "parent-handle", "owner", "parent handle for the key") 519 | flag.StringVar(&hierarchy, "hierarchy", "", "hierarchy for the created key") 520 | 521 | flag.Parse() 522 | 523 | tpm, err := utils.TPM(swtpmFlag) 524 | if err != nil { 525 | log.Fatal(err) 526 | } 527 | defer tpm.Close() 528 | 529 | if bits == 0 { 530 | if keyType == "ecdsa" { 531 | bits = 256 532 | } 533 | if keyType == "rsa" { 534 | bits = 2048 535 | } 536 | } 537 | 538 | supportedECCBitsizes := keyfile.SupportedECCAlgorithms(tpm) 539 | 540 | if printPubkey != "" { 541 | f, err := os.ReadFile(printPubkey) 542 | if err != nil { 543 | log.Fatalf("failed reading TPM key %s: %v", printPubkey, err) 544 | } 545 | 546 | k, err := key.Decode(f) 547 | if err != nil { 548 | log.Fatal(err) 549 | } 550 | fmt.Print(string(k.AuthorizedKey())) 551 | os.Exit(0) 552 | } 553 | 554 | if listsupported { 555 | fmt.Printf("ecdsa bit lengths:") 556 | for _, alg := range supportedECCBitsizes { 557 | fmt.Printf(" %d", alg) 558 | } 559 | fmt.Println("\nrsa bit lengths: 2048") 560 | os.Exit(0) 561 | } 562 | 563 | // Ask for owner password 564 | var ownerPassword []byte 565 | if askOwnerPassword { 566 | ownerPassword, err = getOwnerPassword() 567 | if err != nil { 568 | log.Fatal(err) 569 | } 570 | } else { 571 | ownerPassword = []byte("") 572 | } 573 | 574 | // Generate host keys 575 | if hostKeys { 576 | doHostKeys(tpm, outputFile, ownerPassword, hierarchy) 577 | os.Exit(0) 578 | } 579 | 580 | var filename string 581 | var privatekeyFilename string 582 | var pubkeyFilename string 583 | 584 | // TODO: Support custom handles 585 | var keyParentHandle tpm2.TPMHandle 586 | if parentHandle != "" { 587 | keyParentHandle, err = utils.GetParentHandle(parentHandle) 588 | if err != nil { 589 | log.Fatal(err) 590 | } 591 | } 592 | 593 | // Create ~/.ssh if it doesn't exist 594 | if !utils.FileExists(utils.SSHDir()) { 595 | if err := os.Mkdir(utils.SSHDir(), 0o700); err != nil { 596 | log.Fatalf("Could not create directory %s", utils.SSHDir()) 597 | os.Exit(1) 598 | } 599 | } 600 | 601 | // Wrapping of keyfile for import 602 | if wrap != "" { 603 | if wrapWith == "" { 604 | log.Fatal("--wrap needs --wrap-with") 605 | } 606 | 607 | if outputFile == "" { 608 | log.Fatal("Specify output filename with --output/-o") 609 | } 610 | 611 | doWrapWith(supportedECCBitsizes, wrap, wrapWith, keyParentHandle, comment, outputFile) 612 | os.Exit(0) 613 | } 614 | 615 | switch keyType { 616 | case "ecdsa": 617 | filename = "id_ecdsa" 618 | if !slices.Contains(supportedECCBitsizes, bits) { 619 | log.Fatalf("invalid ecdsa key length: TPM does not support %v bits", bits) 620 | } 621 | case "rsa": 622 | filename = "id_rsa" 623 | } 624 | 625 | if outputFile != "" { 626 | filename = outputFile 627 | } else { 628 | filename = path.Join(utils.SSHDir(), filename) 629 | } 630 | 631 | if changePin { 632 | if err := doChangePin(tpm, []byte(passphrase), []byte(keyPin), ownerPassword, filename, outputFile); err != nil { 633 | log.Fatal(err) 634 | } 635 | os.Exit(0) 636 | } 637 | 638 | // If we don't need to write a public key 639 | var writePubKey bool 640 | 641 | var k *key.SSHTPMKey 642 | 643 | if importKey != "" { 644 | pem, err := os.ReadFile(importKey) 645 | if err != nil { 646 | log.Fatal(err) 647 | } 648 | if _, err := keyfile.Decode(pem); !errors.Is(err, keyfile.ErrNotTPMKey) { 649 | fmt.Println("Importing a wrapped public/private key pair.") 650 | k, err = doImportWrappedKey(tpm, ownerPassword, pem) 651 | if err != nil { 652 | log.Fatal(err) 653 | } 654 | writePubKey = true 655 | } else { 656 | // Import a ssh key if it's not a TPM key 657 | fmt.Println("Sealing an existing public/private key pair.") 658 | k, err = doImportKey(tpm, keyParentHandle, ownerPassword, keyPin, pem, importKey) 659 | if err != nil { 660 | log.Fatal(err) 661 | } 662 | writePubKey = false 663 | } 664 | } else { 665 | // Else create a normal key 666 | fmt.Printf("Generating a sealed public/private %s key pair.\n", keyType) 667 | if outputFile == "" { 668 | f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which to save the key (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 669 | if err != nil { 670 | log.Fatal(err) 671 | } 672 | filenameInput := string(f) 673 | if filenameInput != "" { 674 | filename = strings.TrimSuffix(filenameInput, ".tpm") 675 | } 676 | } else { 677 | filename = outputFile 678 | } 679 | k, err = doCreateSSHKey(tpm, ownerPassword, keyPin, keyParentHandle, keyType, comment, bits) 680 | if err != nil { 681 | log.Fatal(err) 682 | } 683 | writePubKey = true 684 | } 685 | 686 | privatekeyFilename = filename + ".tpm" 687 | if err := checkFile(privatekeyFilename); err != nil { 688 | log.Fatal(err) 689 | } 690 | 691 | pubkeyFilename = filename + ".pub" 692 | if err := checkFile(pubkeyFilename); err != nil { 693 | log.Fatal(err) 694 | } 695 | 696 | if err := os.WriteFile(privatekeyFilename, k.Bytes(), 0o600); err != nil { 697 | log.Fatal(err) 698 | } 699 | fmt.Printf("Your identification has been saved in %s\n", privatekeyFilename) 700 | if writePubKey { 701 | if err := os.WriteFile(pubkeyFilename, k.AuthorizedKey(), 0o600); err != nil { 702 | log.Fatal(err) 703 | } 704 | fmt.Printf("Your public key has been saved in %s\n", pubkeyFilename) 705 | } 706 | fmt.Printf("The key fingerprint is:\n") 707 | fmt.Println(k.Fingerprint()) 708 | fmt.Println("The key's randomart image is the color of television, tuned to a dead channel.") 709 | } 710 | --------------------------------------------------------------------------------