detached 4023100

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +228 -2
M go.mod +2 -2
@@ -21,7 +21,9 @@ require (
2121 github.com/spf13/cobra v1.10.2
2222 github.com/stretchr/testify v1.12.1
2323 github.com/yuin/goldmark v1.8.6
24+ golang.org/x/crypto v0.57.0
2425 golang.org/x/sys v0.49.0
26+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
2527 google.golang.org/protobuf v1.36.12
2628 )
2729
@@ -143,7 +145,6 @@ require (
143145 go.uber.org/multierr v1.11.0 // indirect
144146 go.uber.org/zap v1.28.0 // indirect
145147 go.yaml.in/yaml/v3 v3.0.5 // indirect
146- golang.org/x/crypto v0.57.0 // indirect
147148 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
148149 golang.org/x/mod v0.41.0 // indirect
149150 golang.org/x/net v0.59.0 // indirect
@@ -152,7 +153,6 @@ require (
152153 golang.org/x/text v0.42.0 // indirect
153154 golang.org/x/tools v0.51.0 // indirect
154155 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
156156 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
157157 gopkg.in/yaml.v3 v3.0.1 // indirect
158158 mvdan.cc/xurls/v2 v2.6.0 // indirect
@@ -21,7 +21,9 @@ require (
21 github.com/spf13/cobra v1.10.221 github.com/spf13/cobra v1.10.2
22 github.com/stretchr/testify v1.12.122 github.com/stretchr/testify v1.12.1
23 github.com/yuin/goldmark v1.8.623 github.com/yuin/goldmark v1.8.6
24+ golang.org/x/crypto v0.57.0
24 golang.org/x/sys v0.49.025 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.1227 google.golang.org/protobuf v1.36.12
26 )28 )
27 29
@@ -143,7 +145,6 @@ require (
143 go.uber.org/multierr v1.11.0 // indirect145 go.uber.org/multierr v1.11.0 // indirect
144 go.uber.org/zap v1.28.0 // indirect146 go.uber.org/zap v1.28.0 // indirect
145 go.yaml.in/yaml/v3 v3.0.5 // indirect147 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 // indirect148 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
148 golang.org/x/mod v0.41.0 // indirect149 golang.org/x/mod v0.41.0 // indirect
149 golang.org/x/net v0.59.0 // indirect150 golang.org/x/net v0.59.0 // indirect
@@ -152,7 +153,6 @@ require (
152 golang.org/x/text v0.42.0 // indirect153 golang.org/x/text v0.42.0 // indirect
153 golang.org/x/tools v0.51.0 // indirect154 golang.org/x/tools v0.51.0 // indirect
154 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect155 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 // indirect156 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
157 gopkg.in/yaml.v3 v3.0.1 // indirect157 gopkg.in/yaml.v3 v3.0.1 // indirect
158 mvdan.cc/xurls/v2 v2.6.0 // indirect158 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close