feat(sshsig): sign commits with the instance ed25519 key
SSHSIG (namespace git, SHA-512) built with golang.org/x/crypto/ssh and checked against ssh-keygen -Y verify.
- commit
- 4023100a92bbd83c1c906e68d82db766c9f6a39a
- author
- brice · · 2026-10-10 16:49 UTC
- parent
- 7ce5542
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +228 -2
M go.mod +2 -2
| @@ -21,7 +21,9 @@ require ( | |||
| 21 | 21 | github.com/spf13/cobra v1.10.2 | |
| 22 | 22 | github.com/stretchr/testify v1.12.1 | |
| 23 | 23 | github.com/yuin/goldmark v1.8.6 | |
| 24 | + | golang.org/x/crypto v0.57.0 | |
| 24 | 25 | golang.org/x/sys v0.49.0 | |
| 26 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | |
| 25 | 27 | google.golang.org/protobuf v1.36.12 | |
| 26 | 28 | ) | |
| 27 | 29 | ||
| @@ -143,7 +145,6 @@ require ( | |||
| 143 | 145 | go.uber.org/multierr v1.11.0 // indirect | |
| 144 | 146 | go.uber.org/zap v1.28.0 // indirect | |
| 145 | 147 | go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 146 | - | golang.org/x/crypto v0.57.0 // indirect | |
| 147 | 148 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | |
| 148 | 149 | golang.org/x/mod v0.41.0 // indirect | |
| 149 | 150 | golang.org/x/net v0.59.0 // indirect | |
| @@ -152,7 +153,6 @@ require ( | |||
| 152 | 153 | golang.org/x/text v0.42.0 // indirect | |
| 153 | 154 | golang.org/x/tools v0.51.0 // indirect | |
| 154 | 155 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | |
| 155 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect | |
| 156 | 156 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| 157 | 157 | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 158 | 158 | mvdan.cc/xurls/v2 v2.6.0 // indirect | |
| @@ -21,7 +21,9 @@ require ( | |||||
| 21 | github.com/spf13/cobra v1.10.2 | 21 | github.com/spf13/cobra v1.10.2 | ||
| 22 | github.com/stretchr/testify v1.12.1 | 22 | github.com/stretchr/testify v1.12.1 | ||
| 23 | github.com/yuin/goldmark v1.8.6 | 23 | github.com/yuin/goldmark v1.8.6 | ||
| 24 | + | golang.org/x/crypto v0.57.0 | |||
| 24 | golang.org/x/sys v0.49.0 | 25 | golang.org/x/sys v0.49.0 | ||
| 26 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | |||
| 25 | google.golang.org/protobuf v1.36.12 | 27 | google.golang.org/protobuf v1.36.12 | ||
| 26 | ) | 28 | ) | ||
| 27 | 29 | ||||
| @@ -143,7 +145,6 @@ require ( | |||||
| 143 | go.uber.org/multierr v1.11.0 // indirect | 145 | go.uber.org/multierr v1.11.0 // indirect | ||
| 144 | go.uber.org/zap v1.28.0 // indirect | 146 | go.uber.org/zap v1.28.0 // indirect | ||
| 145 | go.yaml.in/yaml/v3 v3.0.5 // indirect | 147 | go.yaml.in/yaml/v3 v3.0.5 // indirect | ||
| 146 | - | golang.org/x/crypto v0.57.0 // indirect | |||
| 147 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | 148 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | ||
| 148 | golang.org/x/mod v0.41.0 // indirect | 149 | golang.org/x/mod v0.41.0 // indirect | ||
| 149 | golang.org/x/net v0.59.0 // indirect | 150 | golang.org/x/net v0.59.0 // indirect | ||
| @@ -152,7 +153,6 @@ require ( | |||||
| 152 | golang.org/x/text v0.42.0 // indirect | 153 | golang.org/x/text v0.42.0 // indirect | ||
| 153 | golang.org/x/tools v0.51.0 // indirect | 154 | golang.org/x/tools v0.51.0 // indirect | ||
| 154 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | 155 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | ||
| 155 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect | |||
| 156 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | 156 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | ||
| 157 | gopkg.in/yaml.v3 v3.0.1 // indirect | 157 | gopkg.in/yaml.v3 v3.0.1 // indirect | ||
| 158 | mvdan.cc/xurls/v2 v2.6.0 // indirect | 158 | mvdan.cc/xurls/v2 v2.6.0 // indirect | ||
A internal/adapters/sshsig/sshsig.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package sshsig signs the commits ermite creates with the instance key | |
| 4 | + | // (ADR 0022): an ed25519 key, in the SSH signature format Git reads with | |
| 5 | + | // gpg.format=ssh. The private key lives in memory only. | |
| 6 | + | package sshsig | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "crypto/ed25519" | |
| 10 | + | "crypto/sha512" | |
| 11 | + | "encoding/base64" | |
| 12 | + | "errors" | |
| 13 | + | "fmt" | |
| 14 | + | "strings" | |
| 15 | + | ||
| 16 | + | "golang.org/x/crypto/ssh" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | const ( | |
| 23 | + | // Namespace is the SSHSIG namespace of Git signatures. | |
| 24 | + | Namespace = "git" | |
| 25 | + | // HashAlgorithm is the hash of the message in the signed data. | |
| 26 | + | HashAlgorithm = "sha512" | |
| 27 | + | ||
| 28 | + | magic = "SSHSIG" | |
| 29 | + | version = 1 | |
| 30 | + | ||
| 31 | + | armorBegin = "-----BEGIN SSH SIGNATURE-----" | |
| 32 | + | armorEnd = "-----END SSH SIGNATURE-----" | |
| 33 | + | armorWidth = 70 | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | // Signer signs with one ed25519 key. It is safe for concurrent use. | |
| 37 | + | type Signer struct { | |
| 38 | + | signer ssh.Signer | |
| 39 | + | pub domain.SigningPublicKey | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | var _ ports.CommitSigner = (*Signer)(nil) | |
| 43 | + | ||
| 44 | + | // New builds a signer from the 32-byte seed of an ed25519 key. The error does | |
| 45 | + | // not contain the seed. | |
| 46 | + | func New(seed []byte) (*Signer, error) { | |
| 47 | + | if len(seed) != ed25519.SeedSize { | |
| 48 | + | return nil, fmt.Errorf("signing key seed has %d bytes, want %d", len(seed), ed25519.SeedSize) | |
| 49 | + | } | |
| 50 | + | signer, err := ssh.NewSignerFromSigner(ed25519.NewKeyFromSeed(seed)) | |
| 51 | + | if err != nil { | |
| 52 | + | return nil, fmt.Errorf("build the signing key: %w", err) | |
| 53 | + | } | |
| 54 | + | pk := signer.PublicKey() | |
| 55 | + | return &Signer{ | |
| 56 | + | signer: signer, | |
| 57 | + | pub: domain.SigningPublicKey{ | |
| 58 | + | AuthorizedKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk))), | |
| 59 | + | Fingerprint: ssh.FingerprintSHA256(pk), | |
| 60 | + | }, | |
| 61 | + | }, nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // PublicKey returns the public key as an authorized_keys line, and its | |
| 65 | + | // SHA-256 fingerprint. | |
| 66 | + | func (s *Signer) PublicKey() domain.SigningPublicKey { return s.pub } | |
| 67 | + | ||
| 68 | + | // signedData is what the signature covers after the magic (PROTOCOL.sshsig). | |
| 69 | + | type signedData struct { | |
| 70 | + | Namespace string | |
| 71 | + | Reserved string | |
| 72 | + | HashAlgorithm string | |
| 73 | + | Hash string | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // sigBlob is the signature file (PROTOCOL.sshsig), after the magic. | |
| 77 | + | type sigBlob struct { | |
| 78 | + | Version uint32 | |
| 79 | + | PublicKey string | |
| 80 | + | Namespace string | |
| 81 | + | Reserved string | |
| 82 | + | HashAlgorithm string | |
| 83 | + | Signature string | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // signatureField is the "signature" string of an SSHSIG: an SSH signature. | |
| 87 | + | type signatureField struct { | |
| 88 | + | Format string | |
| 89 | + | Blob string | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Sign signs payload, the text of a commit object, and returns the armored | |
| 93 | + | // signature for its gpgsig header. | |
| 94 | + | func (s *Signer) Sign(payload []byte) (string, error) { | |
| 95 | + | sum := sha512.Sum512(payload) | |
| 96 | + | // The magic is a fixed 6-byte preamble, not a length-prefixed string. | |
| 97 | + | msg := append([]byte(magic), ssh.Marshal(signedData{ | |
| 98 | + | Namespace: Namespace, HashAlgorithm: HashAlgorithm, Hash: string(sum[:]), | |
| 99 | + | })...) | |
| 100 | + | ||
| 101 | + | sig, err := s.signer.Sign(nil, msg) | |
| 102 | + | if err != nil { | |
| 103 | + | return "", errors.New("sign the commit") | |
| 104 | + | } | |
| 105 | + | blob := append([]byte(magic), ssh.Marshal(sigBlob{ | |
| 106 | + | Version: version, | |
| 107 | + | PublicKey: string(s.signer.PublicKey().Marshal()), | |
| 108 | + | Namespace: Namespace, | |
| 109 | + | HashAlgorithm: HashAlgorithm, | |
| 110 | + | Signature: string(ssh.Marshal(signatureField{Format: sig.Format, Blob: string(sig.Blob)})), | |
| 111 | + | })...) | |
| 112 | + | return armor(blob), nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // armor wraps the signature file in its PEM-like armor, 70 columns wide. | |
| 116 | + | func armor(blob []byte) string { | |
| 117 | + | enc := base64.StdEncoding.EncodeToString(blob) | |
| 118 | + | var b strings.Builder | |
| 119 | + | b.WriteString(armorBegin + "\n") | |
| 120 | + | for len(enc) > armorWidth { | |
| 121 | + | b.WriteString(enc[:armorWidth] + "\n") | |
| 122 | + | enc = enc[armorWidth:] | |
| 123 | + | } | |
| 124 | + | b.WriteString(enc + "\n" + armorEnd + "\n") | |
| 125 | + | return b.String() | |
| 126 | + | } | |
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package sshsig signs the commits ermite creates with the instance key | |||
| 4 | + | // (ADR 0022): an ed25519 key, in the SSH signature format Git reads with | |||
| 5 | + | // gpg.format=ssh. The private key lives in memory only. | |||
| 6 | + | package sshsig | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "crypto/ed25519" | |||
| 10 | + | "crypto/sha512" | |||
| 11 | + | "encoding/base64" | |||
| 12 | + | "errors" | |||
| 13 | + | "fmt" | |||
| 14 | + | "strings" | |||
| 15 | + | ||||
| 16 | + | "golang.org/x/crypto/ssh" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | const ( | |||
| 23 | + | // Namespace is the SSHSIG namespace of Git signatures. | |||
| 24 | + | Namespace = "git" | |||
| 25 | + | // HashAlgorithm is the hash of the message in the signed data. | |||
| 26 | + | HashAlgorithm = "sha512" | |||
| 27 | + | ||||
| 28 | + | magic = "SSHSIG" | |||
| 29 | + | version = 1 | |||
| 30 | + | ||||
| 31 | + | armorBegin = "-----BEGIN SSH SIGNATURE-----" | |||
| 32 | + | armorEnd = "-----END SSH SIGNATURE-----" | |||
| 33 | + | armorWidth = 70 | |||
| 34 | + | ) | |||
| 35 | + | ||||
| 36 | + | // Signer signs with one ed25519 key. It is safe for concurrent use. | |||
| 37 | + | type Signer struct { | |||
| 38 | + | signer ssh.Signer | |||
| 39 | + | pub domain.SigningPublicKey | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | var _ ports.CommitSigner = (*Signer)(nil) | |||
| 43 | + | ||||
| 44 | + | // New builds a signer from the 32-byte seed of an ed25519 key. The error does | |||
| 45 | + | // not contain the seed. | |||
| 46 | + | func New(seed []byte) (*Signer, error) { | |||
| 47 | + | if len(seed) != ed25519.SeedSize { | |||
| 48 | + | return nil, fmt.Errorf("signing key seed has %d bytes, want %d", len(seed), ed25519.SeedSize) | |||
| 49 | + | } | |||
| 50 | + | signer, err := ssh.NewSignerFromSigner(ed25519.NewKeyFromSeed(seed)) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return nil, fmt.Errorf("build the signing key: %w", err) | |||
| 53 | + | } | |||
| 54 | + | pk := signer.PublicKey() | |||
| 55 | + | return &Signer{ | |||
| 56 | + | signer: signer, | |||
| 57 | + | pub: domain.SigningPublicKey{ | |||
| 58 | + | AuthorizedKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk))), | |||
| 59 | + | Fingerprint: ssh.FingerprintSHA256(pk), | |||
| 60 | + | }, | |||
| 61 | + | }, nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // PublicKey returns the public key as an authorized_keys line, and its | |||
| 65 | + | // SHA-256 fingerprint. | |||
| 66 | + | func (s *Signer) PublicKey() domain.SigningPublicKey { return s.pub } | |||
| 67 | + | ||||
| 68 | + | // signedData is what the signature covers after the magic (PROTOCOL.sshsig). | |||
| 69 | + | type signedData struct { | |||
| 70 | + | Namespace string | |||
| 71 | + | Reserved string | |||
| 72 | + | HashAlgorithm string | |||
| 73 | + | Hash string | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // sigBlob is the signature file (PROTOCOL.sshsig), after the magic. | |||
| 77 | + | type sigBlob struct { | |||
| 78 | + | Version uint32 | |||
| 79 | + | PublicKey string | |||
| 80 | + | Namespace string | |||
| 81 | + | Reserved string | |||
| 82 | + | HashAlgorithm string | |||
| 83 | + | Signature string | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // signatureField is the "signature" string of an SSHSIG: an SSH signature. | |||
| 87 | + | type signatureField struct { | |||
| 88 | + | Format string | |||
| 89 | + | Blob string | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Sign signs payload, the text of a commit object, and returns the armored | |||
| 93 | + | // signature for its gpgsig header. | |||
| 94 | + | func (s *Signer) Sign(payload []byte) (string, error) { | |||
| 95 | + | sum := sha512.Sum512(payload) | |||
| 96 | + | // The magic is a fixed 6-byte preamble, not a length-prefixed string. | |||
| 97 | + | msg := append([]byte(magic), ssh.Marshal(signedData{ | |||
| 98 | + | Namespace: Namespace, HashAlgorithm: HashAlgorithm, Hash: string(sum[:]), | |||
| 99 | + | })...) | |||
| 100 | + | ||||
| 101 | + | sig, err := s.signer.Sign(nil, msg) | |||
| 102 | + | if err != nil { | |||
| 103 | + | return "", errors.New("sign the commit") | |||
| 104 | + | } | |||
| 105 | + | blob := append([]byte(magic), ssh.Marshal(sigBlob{ | |||
| 106 | + | Version: version, | |||
| 107 | + | PublicKey: string(s.signer.PublicKey().Marshal()), | |||
| 108 | + | Namespace: Namespace, | |||
| 109 | + | HashAlgorithm: HashAlgorithm, | |||
| 110 | + | Signature: string(ssh.Marshal(signatureField{Format: sig.Format, Blob: string(sig.Blob)})), | |||
| 111 | + | })...) | |||
| 112 | + | return armor(blob), nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // armor wraps the signature file in its PEM-like armor, 70 columns wide. | |||
| 116 | + | func armor(blob []byte) string { | |||
| 117 | + | enc := base64.StdEncoding.EncodeToString(blob) | |||
| 118 | + | var b strings.Builder | |||
| 119 | + | b.WriteString(armorBegin + "\n") | |||
| 120 | + | for len(enc) > armorWidth { | |||
| 121 | + | b.WriteString(enc[:armorWidth] + "\n") | |||
| 122 | + | enc = enc[armorWidth:] | |||
| 123 | + | } | |||
| 124 | + | b.WriteString(enc + "\n" + armorEnd + "\n") | |||
| 125 | + | return b.String() | |||
| 126 | + | } | |||
A internal/adapters/sshsig/sshsig_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sshsig_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "crypto/ed25519" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | "golang.org/x/crypto/ssh" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func seed(b byte) []byte { return bytes.Repeat([]byte{b}, ed25519.SeedSize) } | |
| 22 | + | ||
| 23 | + | func TestPublicKey(t *testing.T) { | |
| 24 | + | s, err := sshsig.New(seed(7)) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | pub := s.PublicKey() | |
| 27 | + | ||
| 28 | + | assert.True(t, strings.HasPrefix(pub.AuthorizedKey, "ssh-ed25519 ")) | |
| 29 | + | assert.NotContains(t, pub.AuthorizedKey, "\n") | |
| 30 | + | parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pub.AuthorizedKey)) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | assert.Equal(t, ssh.FingerprintSHA256(parsed), pub.Fingerprint) | |
| 33 | + | assert.True(t, strings.HasPrefix(pub.Fingerprint, "SHA256:")) | |
| 34 | + | ||
| 35 | + | // The same seed is the same key; another seed another key. | |
| 36 | + | again, err := sshsig.New(seed(7)) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | assert.Equal(t, pub, again.PublicKey()) | |
| 39 | + | other, err := sshsig.New(seed(8)) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | assert.NotEqual(t, pub, other.PublicKey()) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestNewRejectsBadSeed(t *testing.T) { | |
| 45 | + | secret := bytes.Repeat([]byte{0x42}, 31) | |
| 46 | + | _, err := sshsig.New(secret) | |
| 47 | + | require.Error(t, err) | |
| 48 | + | assert.NotContains(t, err.Error(), string(secret)) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestSignArmor(t *testing.T) { | |
| 52 | + | s, err := sshsig.New(seed(1)) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | armored, err := s.Sign([]byte("payload")) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | ||
| 57 | + | lines := strings.Split(strings.TrimSuffix(armored, "\n"), "\n") | |
| 58 | + | assert.Equal(t, "-----BEGIN SSH SIGNATURE-----", lines[0]) | |
| 59 | + | assert.Equal(t, "-----END SSH SIGNATURE-----", lines[len(lines)-1]) | |
| 60 | + | for _, l := range lines[1 : len(lines)-2] { | |
| 61 | + | assert.Len(t, l, 70) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // TestSignVerifiesWithSSHKeygen checks the signature of a message with the | |
| 66 | + | // reference implementation, `ssh-keygen -Y verify` (namespace git), and that a | |
| 67 | + | // changed message or namespace does not verify. | |
| 68 | + | func TestSignVerifiesWithSSHKeygen(t *testing.T) { | |
| 69 | + | keygen, err := exec.LookPath("ssh-keygen") | |
| 70 | + | if err != nil { | |
| 71 | + | if os.Getenv("CI") != "" { | |
| 72 | + | t.Fatal("ssh-keygen not found: CI must have it") | |
| 73 | + | } | |
| 74 | + | t.Skip("ssh-keygen not found: install openssh to check signatures against the reference") | |
| 75 | + | } | |
| 76 | + | s, err := sshsig.New(seed(3)) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | msg := []byte("tree 0000\nparent 1111\n\nmessage\n") | |
| 79 | + | armored, err := s.Sign(msg) | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | ||
| 82 | + | dir := t.TempDir() | |
| 83 | + | sigFile := filepath.Join(dir, "sig") | |
| 84 | + | require.NoError(t, os.WriteFile(sigFile, []byte(armored), 0o600)) | |
| 85 | + | signers := filepath.Join(dir, "allowed_signers") | |
| 86 | + | require.NoError(t, os.WriteFile(signers, []byte("ermite@example.org "+s.PublicKey().AuthorizedKey+"\n"), 0o600)) | |
| 87 | + | ||
| 88 | + | verify := func(namespace string, data []byte) error { | |
| 89 | + | cmd := exec.Command(keygen, "-Y", "verify", "-f", signers, "-I", "ermite@example.org", "-n", namespace, "-s", sigFile) | |
| 90 | + | cmd.Stdin = bytes.NewReader(data) | |
| 91 | + | out, err := cmd.CombinedOutput() | |
| 92 | + | if err != nil { | |
| 93 | + | t.Logf("ssh-keygen: %s", out) | |
| 94 | + | } | |
| 95 | + | return err | |
| 96 | + | } | |
| 97 | + | require.NoError(t, verify("git", msg)) | |
| 98 | + | assert.Error(t, verify("git", append(msg, 'x')), "a changed message must not verify") | |
| 99 | + | assert.Error(t, verify("file", msg), "another namespace must not verify") | |
| 100 | + | } | |
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package sshsig_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "crypto/ed25519" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | "golang.org/x/crypto/ssh" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func seed(b byte) []byte { return bytes.Repeat([]byte{b}, ed25519.SeedSize) } | |||
| 22 | + | ||||
| 23 | + | func TestPublicKey(t *testing.T) { | |||
| 24 | + | s, err := sshsig.New(seed(7)) | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | pub := s.PublicKey() | |||
| 27 | + | ||||
| 28 | + | assert.True(t, strings.HasPrefix(pub.AuthorizedKey, "ssh-ed25519 ")) | |||
| 29 | + | assert.NotContains(t, pub.AuthorizedKey, "\n") | |||
| 30 | + | parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pub.AuthorizedKey)) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | assert.Equal(t, ssh.FingerprintSHA256(parsed), pub.Fingerprint) | |||
| 33 | + | assert.True(t, strings.HasPrefix(pub.Fingerprint, "SHA256:")) | |||
| 34 | + | ||||
| 35 | + | // The same seed is the same key; another seed another key. | |||
| 36 | + | again, err := sshsig.New(seed(7)) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | assert.Equal(t, pub, again.PublicKey()) | |||
| 39 | + | other, err := sshsig.New(seed(8)) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | assert.NotEqual(t, pub, other.PublicKey()) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestNewRejectsBadSeed(t *testing.T) { | |||
| 45 | + | secret := bytes.Repeat([]byte{0x42}, 31) | |||
| 46 | + | _, err := sshsig.New(secret) | |||
| 47 | + | require.Error(t, err) | |||
| 48 | + | assert.NotContains(t, err.Error(), string(secret)) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestSignArmor(t *testing.T) { | |||
| 52 | + | s, err := sshsig.New(seed(1)) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | armored, err := s.Sign([]byte("payload")) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | ||||
| 57 | + | lines := strings.Split(strings.TrimSuffix(armored, "\n"), "\n") | |||
| 58 | + | assert.Equal(t, "-----BEGIN SSH SIGNATURE-----", lines[0]) | |||
| 59 | + | assert.Equal(t, "-----END SSH SIGNATURE-----", lines[len(lines)-1]) | |||
| 60 | + | for _, l := range lines[1 : len(lines)-2] { | |||
| 61 | + | assert.Len(t, l, 70) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // TestSignVerifiesWithSSHKeygen checks the signature of a message with the | |||
| 66 | + | // reference implementation, `ssh-keygen -Y verify` (namespace git), and that a | |||
| 67 | + | // changed message or namespace does not verify. | |||
| 68 | + | func TestSignVerifiesWithSSHKeygen(t *testing.T) { | |||
| 69 | + | keygen, err := exec.LookPath("ssh-keygen") | |||
| 70 | + | if err != nil { | |||
| 71 | + | if os.Getenv("CI") != "" { | |||
| 72 | + | t.Fatal("ssh-keygen not found: CI must have it") | |||
| 73 | + | } | |||
| 74 | + | t.Skip("ssh-keygen not found: install openssh to check signatures against the reference") | |||
| 75 | + | } | |||
| 76 | + | s, err := sshsig.New(seed(3)) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | msg := []byte("tree 0000\nparent 1111\n\nmessage\n") | |||
| 79 | + | armored, err := s.Sign(msg) | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | ||||
| 82 | + | dir := t.TempDir() | |||
| 83 | + | sigFile := filepath.Join(dir, "sig") | |||
| 84 | + | require.NoError(t, os.WriteFile(sigFile, []byte(armored), 0o600)) | |||
| 85 | + | signers := filepath.Join(dir, "allowed_signers") | |||
| 86 | + | require.NoError(t, os.WriteFile(signers, []byte("ermite@example.org "+s.PublicKey().AuthorizedKey+"\n"), 0o600)) | |||
| 87 | + | ||||
| 88 | + | verify := func(namespace string, data []byte) error { | |||
| 89 | + | cmd := exec.Command(keygen, "-Y", "verify", "-f", signers, "-I", "ermite@example.org", "-n", namespace, "-s", sigFile) | |||
| 90 | + | cmd.Stdin = bytes.NewReader(data) | |||
| 91 | + | out, err := cmd.CombinedOutput() | |||
| 92 | + | if err != nil { | |||
| 93 | + | t.Logf("ssh-keygen: %s", out) | |||
| 94 | + | } | |||
| 95 | + | return err | |||
| 96 | + | } | |||
| 97 | + | require.NoError(t, verify("git", msg)) | |||
| 98 | + | assert.Error(t, verify("git", append(msg, 'x')), "a changed message must not verify") | |||
| 99 | + | assert.Error(t, verify("file", msg), "another namespace must not verify") | |||
| 100 | + | } | |||