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>
82 lines
2.5 KiB
Go
82 lines
2.5 KiB
Go
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
|
|
}
|