588fed2aa9
A destructive op runs ONLY on a pinned-key-verified, nonce-fresh, in-window, host-bound, durable-id-bound operator signature. New cmd/felhom-opsign signs canonical OpBlobs offline via ssh-keygen -Y sign (hardware-ready); the signing key is never in the hub or agent. New internal/signedjobs runner verifies each queued blob through the gate and only on all-pass runs the WipeExecutor, which re-resolves the DURABLE device id + re-inspects (8C) before mkfs — closing the 8C data-bearing-wipe pending_signature gap. New storage durable-device resolution; authz.CanonicalBlob promoted to production. Real-crypto tests assert valid executes and forged/replay/expired/retarget/non-pinned are rejected (executor never called). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
3.4 KiB
Go
82 lines
3.4 KiB
Go
package authz
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CanonicalBlob builds the canonical OpBlob bytes (phase4 §2 field order: keys sorted at
|
|
// every level, no insignificant whitespace, no trailing newline, UTF-8). This is the SINGLE
|
|
// production source of the signed bytes — the operator signing CLI (cmd/felhom-opsign) and the
|
|
// in-Go test minting both call it, so the signer can NEVER drift from what the verifier expects
|
|
// (the verifier authenticates over the RAW received bytes, so these bytes ARE the contract).
|
|
//
|
|
// params is canonicalized internally (parsed + re-marshaled → object keys sorted, whitespace
|
|
// stripped) so the same op+params always yields identical bytes; "" → "{}". Returns an error
|
|
// only when params is not valid JSON.
|
|
func CanonicalBlob(op, hostID, guestID, keyID, nonce, paramsJSON string, issued, expires time.Time) ([]byte, error) {
|
|
params := strings.TrimSpace(paramsJSON)
|
|
if params == "" {
|
|
params = "{}"
|
|
}
|
|
var pv interface{}
|
|
if err := json.Unmarshal([]byte(params), &pv); err != nil {
|
|
return nil, fmt.Errorf("authz: params is not valid JSON: %w", err)
|
|
}
|
|
pc, err := json.Marshal(pv) // Go marshals object keys sorted, compact
|
|
if err != nil {
|
|
return nil, fmt.Errorf("authz: canonicalizing params: %w", err)
|
|
}
|
|
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, pc, guestID, hostID)), nil
|
|
}
|
|
|
|
// Target binds an op to a specific box (and optionally a guest) — the anti-retarget
|
|
// field. The §7 reference omitted the json tags; production needs them so the
|
|
// signed canonical bytes decode correctly.
|
|
type Target struct {
|
|
HostID string `json:"host_id"`
|
|
GuestID string `json:"guest_id"`
|
|
}
|
|
|
|
// OpBlob is the canonical signed object (phase4 §2). The signature covers the
|
|
// EXACT bytes of this object's canonical JSON (keys sorted at every level, no
|
|
// insignificant whitespace, no trailing newline, UTF-8) — produced by the
|
|
// operator CLI / hub, verified here over the raw received bytes.
|
|
type OpBlob struct {
|
|
Op string `json:"op"`
|
|
Target Target `json:"target"`
|
|
Params json.RawMessage `json:"params"`
|
|
Nonce string `json:"nonce"`
|
|
IssuedAt time.Time `json:"issued_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
KeyID string `json:"key_id"`
|
|
}
|
|
|
|
// VerifiedOp is the authenticated, parsed op returned on success — everything the
|
|
// reconcile layer (slice 4) needs to route and execute, not just the op string.
|
|
type VerifiedOp struct {
|
|
Op string // the operation, e.g. "guest_destroy"
|
|
HostID string // target host (== this agent's host)
|
|
// GuestID is non-empty for a guest-scoped op; the caller routes by it. "" =
|
|
// host-scoped op. The verifier does NOT need to know all guest ids.
|
|
GuestID string
|
|
Params json.RawMessage
|
|
Nonce string
|
|
IssuedAt time.Time
|
|
ExpiresAt time.Time
|
|
|
|
// KeyID is the blob's self-declared key id — ADVISORY / audit only, never an
|
|
// authz input. Authz is the key-material allow-list match (Signer below).
|
|
KeyID string
|
|
// Signer is the allow-listed key whose material matched the signature.
|
|
Signer AllowedSigner
|
|
// KeyIDMatchesSigner is false when the blob's advisory KeyID disagrees with
|
|
// the matched signer's id (a benign audit signal, not a rejection).
|
|
KeyIDMatchesSigner bool
|
|
}
|