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.
6package sshsig
7
8import (
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
22const (
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.
37type Signer struct {
38 signer ssh.Signer
39 pub domain.SigningPublicKey
40}
41
42var _ 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.
46func 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.
66func (s *Signer) PublicKey() domain.SigningPublicKey { return s.pub }
67
68// signedData is what the signature covers after the magic (PROTOCOL.sshsig).
69type 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.
77type 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.
87type 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.
94func (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.
116func 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}