Files
felhom-agent/internal/authz/sshsig.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

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
}