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>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"hash"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// SSHSIG framing — ported verbatim-in-spirit from phase4-signing-findings.md §7.
|
||||
// The only manual work is SSHSIG *framing*; all crypto and key-type dispatch is
|
||||
// x/crypto/ssh's (pub.Verify dispatches on the key's own algorithm, which is what
|
||||
// makes the verifier key-type-agnostic — ed25519 / sk-ssh-ed25519 / rsa / ecdsa).
|
||||
// No hand-rolled crypto.
|
||||
|
||||
const sshsigMagic = "SSHSIG"
|
||||
|
||||
// sshsigBlob is the binary SSHSIG body (after the 6-byte magic). Field order is
|
||||
// the SSH wire order — do not reorder.
|
||||
type sshsigBlob struct {
|
||||
Version uint32
|
||||
PublicKey string
|
||||
Namespace string
|
||||
Reserved string
|
||||
HashAlgo string
|
||||
Signature string
|
||||
}
|
||||
|
||||
func hashByName(n string) (hash.Hash, error) {
|
||||
switch n {
|
||||
case "sha256":
|
||||
return sha256.New(), nil
|
||||
case "sha512":
|
||||
return sha512.New(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: unsupported SSHSIG hash %q", ErrMalformed, n)
|
||||
}
|
||||
|
||||
// parseArmoredSSHSIG decodes the `-----BEGIN SSH SIGNATURE-----` armor into the
|
||||
// SSHSIG body: pem.Decode → strip the literal 6-byte magic (not length-prefixed)
|
||||
// → ssh.Unmarshal.
|
||||
func parseArmoredSSHSIG(armored []byte) (*sshsigBlob, error) {
|
||||
block, _ := pem.Decode(armored)
|
||||
if block == nil || block.Type != "SSH SIGNATURE" {
|
||||
return nil, fmt.Errorf("%w: not an SSH SIGNATURE armor", ErrMalformed)
|
||||
}
|
||||
if len(block.Bytes) < len(sshsigMagic) || string(block.Bytes[:len(sshsigMagic)]) != sshsigMagic {
|
||||
return nil, fmt.Errorf("%w: missing SSHSIG magic", ErrMalformed)
|
||||
}
|
||||
var sb sshsigBlob
|
||||
if err := ssh.Unmarshal(block.Bytes[len(sshsigMagic):], &sb); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrMalformed, err)
|
||||
}
|
||||
if sb.Version != 1 {
|
||||
return nil, fmt.Errorf("%w: bad SSHSIG version %d", ErrMalformed, sb.Version)
|
||||
}
|
||||
return &sb, nil
|
||||
}
|
||||
|
||||
// signedData recomputes the bytes the signature actually covers, per the SSHSIG
|
||||
// spec: "SSHSIG" || ssh.Marshal(namespace, reserved, hash_algorithm, H(message)),
|
||||
// where H is the named hash. The message is the RAW received blob bytes — the
|
||||
// verifier never canonicalizes (the canonical form is the signer's contract).
|
||||
func signedData(sb *sshsigBlob, msg []byte) ([]byte, error) {
|
||||
h, err := hashByName(sb.HashAlgo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.Write(msg)
|
||||
md := h.Sum(nil)
|
||||
body := ssh.Marshal(struct {
|
||||
Namespace string
|
||||
Reserved string
|
||||
HashAlgo string
|
||||
Hash []byte
|
||||
}{sb.Namespace, sb.Reserved, sb.HashAlgo, md})
|
||||
return append([]byte(sshsigMagic), body...), nil
|
||||
}
|
||||
Reference in New Issue
Block a user