slice 10B: operator-signed destructive completion (offline key + signing CLI) (v0.16.0)

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>
This commit is contained in:
2026-06-10 20:14:16 +02:00
parent 8ecf8929fb
commit 588fed2aa9
19 changed files with 1523 additions and 68 deletions
+30
View File
@@ -2,9 +2,39 @@ 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.
+6 -10
View File
@@ -6,7 +6,6 @@ import (
"crypto/sha256"
"encoding/binary"
"encoding/pem"
"fmt"
"testing"
"time"
@@ -17,17 +16,14 @@ import (
// production framing. They reuse the production signedData()/sshsigBlob so a test
// can never drift from the verifier's notion of the signed bytes.
// canonicalBlob builds an op blob in the §2 canonical field order. (Self-consistent
// for the in-Go path: we sign exactly these bytes and verify the same bytes. The
// committed ssh-keygen fixture exercises real OpenSSH canonical interop.)
// canonicalBlob delegates to the production CanonicalBlob (so the in-Go test minting can never
// drift from the real signed-bytes path). Panics on a params error — tests pass valid JSON.
func canonicalBlob(op, hostID, guestID, keyID, nonce, paramsJSON string, issued, expires time.Time) []byte {
if paramsJSON == "" {
paramsJSON = "{}"
b, err := CanonicalBlob(op, hostID, guestID, keyID, nonce, paramsJSON, issued, expires)
if err != nil {
panic(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, paramsJSON, guestID, hostID))
return b
}
// mintArmor builds an armored SSHSIG over message, using sign to produce the inner