1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package sshsig_test
4
5import (
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
21func seed(b byte) []byte { return bytes.Repeat([]byte{b}, ed25519.SeedSize) }
22
23func 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
44func 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
51func 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.
68func 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}