f0fee7e193
internal/authz: production form of the Phase-4 SSHSIG signing primitive. - Verifier.New/Verify with the LOCKED pipeline (namespace → allow-list by key material → crypto over RAW bytes → target → time → nonce LAST); each post-crypto stage rejects even with a valid sig; an invalid sig never burns a nonce. - SSHSIG framing via x/crypto/ssh (no hand-rolled crypto); key-type-agnostic (ed25519 / sk-ssh-ed25519 / rsa / ecdsa via pub.Verify). Fixed namespace felhom-op-v1. Typed errors. OpBlob (fixed host_id/guest_id tags) + VerifiedOp. - NonceStore: MemoryNonceStore + durable crash-safe FileNonceStore (fsync'd append log, replay-on-open, compaction, expiry-only pruning; survives restart). - config.AuthzConfig (nonce path + pinned operational/recovery signer keys). - Tests (14): real ssh-keygen fixture, per-stage rejection, nonce-not-burned, replay, persistence-across-restart, synthetic sk, byte-exactness. Dep: golang.org/x/crypto v0.52.0 (declares go 1.25 — the Phase-4 doc's "Go 1.24.4 / x/crypto v0.52.0" pairing doesn't build; build server upgraded to go1.26.0, backward-compatible). Version 0.1.0 -> 0.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
4.0 KiB
Go
108 lines
4.0 KiB
Go
package authz
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// Test helpers that MINT armored SSHSIGs in-Go (hermetic) — the inverse of the
|
|
// production framing. They reuse the production signedData()/sshsigBlob so a test
|
|
// can never drift from the verifier's notion of the signed bytes.
|
|
|
|
// canonicalBlob builds an op blob in the §2 canonical field order. (Self-consistent
|
|
// for the in-Go path: we sign exactly these bytes and verify the same bytes. The
|
|
// committed ssh-keygen fixture exercises real OpenSSH canonical interop.)
|
|
func canonicalBlob(op, hostID, guestID, keyID, nonce, paramsJSON string, issued, expires time.Time) []byte {
|
|
if paramsJSON == "" {
|
|
paramsJSON = "{}"
|
|
}
|
|
return []byte(fmt.Sprintf(
|
|
`{"expires_at":%q,"issued_at":%q,"key_id":%q,"nonce":%q,"op":%q,"params":%s,"target":{"guest_id":%q,"host_id":%q}}`,
|
|
expires.UTC().Format(time.RFC3339), issued.UTC().Format(time.RFC3339),
|
|
keyID, nonce, op, paramsJSON, guestID, hostID))
|
|
}
|
|
|
|
// mintArmor builds an armored SSHSIG over message, using sign to produce the inner
|
|
// ssh.Signature over the recomputed SSHSIG signed-data.
|
|
func mintArmor(t *testing.T, pubMarshaled []byte, namespace, hashName string, message []byte, sign func([]byte) ssh.Signature) []byte {
|
|
t.Helper()
|
|
sb := &sshsigBlob{Version: 1, PublicKey: string(pubMarshaled), Namespace: namespace, Reserved: "", HashAlgo: hashName}
|
|
signed, err := signedData(sb, message)
|
|
if err != nil {
|
|
t.Fatalf("signedData: %v", err)
|
|
}
|
|
sig := sign(signed)
|
|
sb.Signature = string(ssh.Marshal(&sig))
|
|
raw := append([]byte(sshsigMagic), ssh.Marshal(sb)...)
|
|
return pem.EncodeToMemory(&pem.Block{Type: "SSH SIGNATURE", Bytes: raw})
|
|
}
|
|
|
|
// newEd25519Signer returns an ssh.PublicKey + a sign closure for a fresh ed25519 key.
|
|
func newEd25519Signer(t *testing.T) (ssh.PublicKey, func([]byte) ssh.Signature) {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sshPub, err := ssh.NewPublicKey(pub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sign := func(signed []byte) ssh.Signature {
|
|
return ssh.Signature{Format: ssh.KeyAlgoED25519, Blob: ed25519.Sign(priv, signed)}
|
|
}
|
|
return sshPub, sign
|
|
}
|
|
|
|
// newSyntheticSKSigner emulates a FIDO2 sk-ssh-ed25519@openssh.com key with NO
|
|
// hardware (Phase 4 §5). It builds a spec-faithful sk public key and an sk-format
|
|
// signature: ed25519 over sha256(application)‖flags‖counter‖sha256(signed_data),
|
|
// sig.Blob = the raw ed25519 signature, sig.Rest = flags‖counter. It must verify
|
|
// through the UNCHANGED Verify path.
|
|
func newSyntheticSKSigner(t *testing.T) (ssh.PublicKey, func([]byte) ssh.Signature) {
|
|
t.Helper()
|
|
edPub, edPriv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const application = "ssh:"
|
|
skBlob := ssh.Marshal(struct {
|
|
Name string
|
|
KeyBytes []byte
|
|
Application string
|
|
}{"sk-ssh-ed25519@openssh.com", []byte(edPub), application})
|
|
skPub, err := ssh.ParsePublicKey(skBlob)
|
|
if err != nil {
|
|
t.Fatalf("parse synthetic sk pubkey: %v", err)
|
|
}
|
|
if skPub.Type() != "sk-ssh-ed25519@openssh.com" {
|
|
t.Fatalf("sk pubkey type = %q", skPub.Type())
|
|
}
|
|
|
|
sign := func(signed []byte) ssh.Signature {
|
|
const flagUserPresence = byte(0x01) // required, else Verify rejects
|
|
const counter = uint32(1)
|
|
appDigest := sha256.Sum256([]byte(application))
|
|
dataDigest := sha256.Sum256(signed)
|
|
// original = appDigest ‖ flags ‖ counter(BE) ‖ dataDigest (x/crypto layout)
|
|
var original []byte
|
|
original = append(original, appDigest[:]...)
|
|
original = append(original, flagUserPresence)
|
|
original = binary.BigEndian.AppendUint32(original, counter)
|
|
original = append(original, dataDigest[:]...)
|
|
edSig := ed25519.Sign(edPriv, original)
|
|
// sig.Rest = skFields{Flags, Counter} = flags ‖ counter(BE)
|
|
rest := append([]byte{flagUserPresence}, binary.BigEndian.AppendUint32(nil, counter)...)
|
|
return ssh.Signature{Format: "sk-ssh-ed25519@openssh.com", Blob: edSig, Rest: rest}
|
|
}
|
|
return skPub, sign
|
|
}
|