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 }