Files
felhom-agent/internal/authz/mint_test.go
T
admin f0fee7e193 feat(authz): operator signed-op verifier + durable nonce store (slice 2, v0.2.0)
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>
2026-06-08 15:23:02 +02:00

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
}