From 40fe82dd956cf77ace0f2a270747689e34566783 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 20 Mar 2025 15:57:50 -0300 Subject: [PATCH] ssh: sign and verify Initial implementation of proposal https://github.com/golang/go/issues/68197. Want to make sure the API is all right before adding more tests. Also seeking feedback on how to best test this - is it OK to sign and verify in the same test, or do you have other ideas? (maybe a fixed rand reader?). --- ssh/sig.go | 134 ++++++++++++++++++++++++++++++++++++++++++++++++ ssh/sig_test.go | 28 ++++++++++ 2 files changed, 162 insertions(+) create mode 100644 ssh/sig.go create mode 100644 ssh/sig_test.go diff --git a/ssh/sig.go b/ssh/sig.go new file mode 100644 index 0000000000..8f8fa0b544 --- /dev/null +++ b/ssh/sig.go @@ -0,0 +1,134 @@ +package ssh + +import ( + "crypto/sha512" + "encoding/pem" + "fmt" + "io" +) + +// blob according to the SSHSIG protocol. +type blob struct { + Namespace string + Reserved string + HashAlgorithm string + Hash []byte +} + +// signedData according to the SSHSIG protocol. +type signedData struct { + MagicPreamble [6]byte + Version uint32 + PublicKey []byte + Namespace string + Reserved string + HashAlgorithm string + Signature []byte +} + +const ( + sigMagicPreamble = "SSHSIG" + sigVersion = 1 + signHashAlgorithm = "sha512" +) + +func createSignBlob(message []byte, namespace string) ([]byte, error) { + hash := sha512.New() + if _, err := hash.Write(message); err != nil { + return nil, err + } + return append([]byte(sigMagicPreamble), Marshal(blob{ + Namespace: namespace, + HashAlgorithm: signHashAlgorithm, + Hash: hash.Sum(nil), + })...), nil +} + +// Sign returns a detached SSH Signature for the provided message. +// +// The namespace is a domain-specific identifier for the context in which the +// signature will be used. It must match between the Sign and [Verify] calls. A +// fully-qualified suffix is recommended, e.g. "receiptV2@example.com". +// +// These signatures are compatible with those generated by "ssh-keygen -Y sign", +// and can be verified with [Verify] or "ssh-keygen -Y verify". The returned +// bytes are usually PEM encoded with [encoding/pem] and type "SSH SIGNATURE". +// +// If the Signer has an RSA PublicKey, it must also implement [AlgorithmSigner]. +// If it also implements [MultiAlgorithmSigner], the first algorithm returned by +// Algorithms will be used, otherwise "rsa-sha2-512" is used. +func Sign(s Signer, rand io.Reader, message []byte, namespace string) ([]byte, error) { + signer, ok := s.(AlgorithmSigner) + if !ok { + return nil, fmt.Errorf("invalid signer") + } + + algorithm := KeyAlgoRSASHA512 + multiSigner, ok := s.(MultiAlgorithmSigner) + if ok { + algorithm = multiSigner.Algorithms()[0] + } + + data, err := createSignBlob(message, namespace) + if err != nil { + return nil, err + } + + sig, err := signer.SignWithAlgorithm(rand, data, algorithm) + if err != nil { + return nil, err + } + + signedData := signedData{ + Version: sigVersion, + PublicKey: s.PublicKey().Marshal(), + Namespace: namespace, + HashAlgorithm: signHashAlgorithm, + Signature: Marshal(sig), + } + copy(signedData.MagicPreamble[:], []byte(sigMagicPreamble)) + + return pem.EncodeToMemory(&pem.Block{ + Type: "SSH SIGNATURE", + Bytes: Marshal(signedData), + }), nil +} + +// Verify verifies a detached SSH Signature for the provided message. +// +// The namespace is a domain-specific identifier for the context in which the +// signature will be used. It must match between the [Sign] and Verify calls. A +// fully-qualified suffix is recommended, e.g. "receiptV2@example.com". +// +// The provided signature is usually decoded from a PEM block of type "SSH +// SIGNATURE" using [encoding/pem]. +func Verify(pub PublicKey, message, signature []byte, namespace string) error { + var sig signedData + if err := Unmarshal(signature, &sig); err != nil { + return err + } + if sig.Version != sigVersion { + return fmt.Errorf("invalid version: %d", sig.Version) + } + if s := string(sig.MagicPreamble[:]); s != sigMagicPreamble { + return fmt.Errorf("invalid header: %s", s) + } + if sig.Namespace != namespace { + return fmt.Errorf("invalid namespace: %s", sig.Namespace) + } + if sig.HashAlgorithm != signHashAlgorithm { + return fmt.Errorf("invalid hash algorithm: %s", sig.HashAlgorithm) + } + + var sshSig Signature + if err := Unmarshal(sig.Signature, &sshSig); err != nil { + return err + } + + data, err := createSignBlob(message, namespace) + if err != nil { + return err + } + + return pub.Verify(data, &sshSig) +} diff --git a/ssh/sig_test.go b/ssh/sig_test.go new file mode 100644 index 0000000000..36af697b15 --- /dev/null +++ b/ssh/sig_test.go @@ -0,0 +1,28 @@ +package ssh + +import ( + "crypto/rand" + "encoding/pem" + "testing" +) + +func TestEd25519SignVerify(t *testing.T) { + signer, ok := testSigners["ed25519"] + if !ok { + t.Fatalf("cannot find signer: ed25519") + } + + const message = "gopher test message" + const namespace = "gopher@test" + + signature, err := Sign(signer, rand.Reader, []byte(message), namespace) + if err != nil { + t.Fatalf("could not sign: %v", err) + } + + block, _ := pem.Decode(signature) + err = Verify(signer.PublicKey(), []byte(message), block.Bytes, namespace) + if err != nil { + t.Fatalf("could not verify signature: %v", err) + } +}