feat(licence): CONSPECT-3 device proof-of-possession (PoP) on the heartbeat client (ADR-I007) - #182
Merged
Merged
Conversation
Conspect API v0.9.0 now enforces device proof-of-possession on the device-mutating ops; the merged renew-only heartbeat client (account-JWT only, no PoP) is rejected pop-invalid, so leases can no longer renew. ADR-I007 records the client-side design: coset 0.4.2 for COSE_Sign1 (deny-clean, zero new transitive deps — ciborium-only, already in-graph); Ed25519 device keypair generated + durably persisted at the cli boundary (the leaf crate stays no-RNG/no-I/O — Ed25519 signing is deterministic); a pure DeviceSigner seam + byte-exact canonical PoP pre-image + COSE header builder in the leaf crate; the DPoP-nonce lifecycle (cold-start /challenge + steady-state nextNonce, kept separate from the durable idempotency nonce); and fail-closed-everywhere never-off-air semantics (inv #1/#10). Spec-correct + unit-tested; live-server validation flagged as a required rule-26 follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-image (CONSPECT-3) The pure-crypto half of device-PoP (ADR-I007): the canonical PoP pre-image (htm | htu | sha256(body) | instance_id | nonce | iat as deterministic CBOR, mirroring canonical_key_preimage) and the Conspect-Device-PoP header value (base64 COSE_Sign1, EdDSA, payload = the pre-image). Verified the only way it can be locally — build with a known device key, then INDEPENDENTLY verify the produced COSE_Sign1 against the matching public key over the exact reconstructed pre-image (an honest self-check, NOT live-server validation). Adds the coset 0.4.2 (Apache-2.0; ciborium-only closure already in-graph => zero new crates) + sha2 (already in the p256 closure) deps under the heartbeat feature. RED: unresolved imports canonical_pop_preimage / pop_header_value / DeviceSigner / PopError — the API does not exist yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ONSPECT-3)
Implements the pure-crypto half of device-PoP (ADR-I007): canonical_pop_preimage
(deterministic-CBOR map(6) over htm|htu|sha256(body)|instance_id|nonce|iat,
nonce as raw 32 bytes, iat epoch seconds), the DeviceSigner seam (Ed25519
sign + public point; signing is deterministic so no RNG in the leaf crate),
PopError, and pop_header_value building a standard-base64 untagged COSE_Sign1
(alg=EdDSA, payload=pre-image) via coset.
All 6 pure-crypto tests pass: the pre-image is byte-exact, the produced
COSE_Sign1 self-verifies against the device public key over the exact
pre-image, alg is EdDSA, and a changed body changes the proof.
Test-data fix: the RED commit's NONCE_HEX was 65 chars (copied verbatim from
the spec's own DeviceChallenge example, which violates its own ^[0-9a-f]{64}$
pattern — the spec examples are illustrative/unreliable). Corrected to a
genuine 64-char nonce; the implementation correctly rejects the 65-char value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sed (CONSPECT-3) Wires device-PoP into the HeartbeatClient loop (ADR-I007): - HeartbeatRequest gains the required nonce field; HeartbeatResponse gains nextNonce; new DeviceChallenge type; LicenceServer gains fetch_challenge + a pop_header param on heartbeat (and heartbeat now takes the exact serialized body bytes, sent verbatim, so device + server hash byte-identical bodies). - HeartbeatClient holds a DeviceSigner + the PoP nonce state; run_once obtains the nonce (cold-start /challenge, steady-state nextNonce), serializes the body once, signs the COSE_Sign1 over sha256(body)+pre-image, sends the header, and remembers nextNonce. HeartbeatConfig gains api_base so the signed htu is the real request URI. - New HeartbeatError::Pop fails closed: any PoP failure (nonce expired/missing, /challenge unreachable, signing error, no signer) skips the cycle and keeps last-good — never off air, never panics (inv #1/#10). RED->GREEN: tests/heartbeat_pop_loop.rs (cold-start fetches one challenge then heartbeats with a verified PoP; steady-state reuses nextNonce and skips the round-trip; unreachable /challenge + pop-invalid + no-binding all keep last-good) all pass; the fake verifies the proof against the device public key. The existing 22 heartbeat_client tests pass with a bound test signer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires device-PoP at the cli boundary (ADR-I007): - DeviceKeyStore: generate-once + durably-persist (crash-durable, 0600) the per-instance Ed25519 device keypair beside the lease state, reload on restart for a stable identity, and impl DeviceSigner (Ed25519 signing is deterministic — the only RNG is the one-time keygen here, never in the leaf crate). Fail-closed on a present-but-corrupt key (never silently regenerates a NEW identity that would break server-side key continuity). - ConspectHttpServer: implements fetch_challenge (GET /v0/devices/licence/ challenge?orgId=, org-id percent-encoded) and the new heartbeat that POSTs the EXACT body bytes verbatim (no re-serialisation drift) with the Conspect-Device-PoP header. - spawn_heartbeat: loads/generates the device key (fail-closed -> don't spawn a heartbeat that would be rejected pop-required every cycle) and constructs the client via with_nonce_and_signer; heartbeat_config carries api_base so the signed htu matches the transport URL byte-for-byte. RED->GREEN: device_key_store_* tests (generate-once-then-reload, 0600 perms, verifiable signing with the persisted key, fail-closed on corrupt) all pass. The CONSPECT-2/3 never-off-air chaos gate (heartbeat_never_off_air) still passes with the new trait. cli heartbeat suite: 98 lib + all integration tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Satisfies `cargo clippy -p multiview-licence --features heartbeat --all-targets -D warnings` and the same for multiview-cli (the #179 lesson: the feature-gated clippy leg lints code default clippy never sees): - backtick `COSE_Sign1` in lib doc comments; add `PoP` to clippy.toml `doc-valid-idents` (`..` preserves the built-in list) so the domain acronym is prose, not a missing-backticks item, across the workspace. - the new PoP test files relax the test-only lints (cast_*, doc_markdown, items_after_statements) matching the existing fake/mod.rs set; convert three match-return-false to let-else in the fake's verify_pop. No behaviour change. fmt + both --features heartbeat clippy legs + the default workspace clippy are green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review guidance: explicitly state how the client-generated device keypair interacts with the pre-existing MULTIVIEW_LICENCE_DEVICE_KEY placeholder. The keypair is client-generated + persisted on first boot (SSH-host-key style); its public key is devicePublicKey and its RFC 7638 thumbprint is the lease cnf_jkt. This SUPERSEDES the captured-but-unused MULTIVIEW_LICENCE_DEVICE_KEY placeholder (a public-key STRING that cannot sign a PoP — never the credential), which is now inert for PoP + a deprecation candidate. No operator-provisioned device key is supported (no secure private-key handoff via env/config; the SSH-host-key model needs none); a future pre-seeded-image path would drop a device-key.ed25519 seed into the lease dir before first boot, but that is out of scope here. Docs-only — no code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s, ADR-I007) The Codex 3-lens panel flagged the CLI device-key lifecycle (the same crash-durable/atomic class #177's FileNonceStore already solved). RED tests: - generate yields 0600 even when a broad-perm device-key.tmp pre-exists (secret exposure: mode(0600)-only-on-create on a fixed temp leaks the seed) — FAILS. - load fails closed on a weak-perm (0644) existing key (a world-readable signing secret must not be trusted as the identity) — FAILS. - concurrent first-boot yields one durable winner whose seed == every returned signer's (the #177 atomic-install race, reintroduced) — FAILS. - persist leaves the durable seed == the signer's key (regression-pin for the swallowed parent-dir fsync; the happy round-trip the durable protocol must keep). - (loop) an already-expired fresh /challenge nonce is not signed/sent (the held-vs-fresh expiry-check asymmetry) — fake gains set_challenge_expired. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-I007) Addresses the Codex 3-lens panel majors on the CLI device-key lifecycle, matching #177's proven crash-durable/atomic FileNonceStore protocol: 1. Secret exposure: persist now uses a UNIQUE temp opened O_EXCL (create_new) + an explicit set_permissions(0600) (belt-and-braces over a restrictive umask), so a pre-existing broad-perm temp can't be reused and the final seed is always 0600 — verified by a 0644-stale-temp RED test. 2. Weak-perm key: load_existing stats the mode and FAILS CLOSED on anything but 0600 (a world-readable 0644 signing secret is refused, never trusted) — RED test with a valid 32-byte seed at 0644. 3. Crash-durability: the parent-dir fsync Result is PROPAGATED (was swallowed) — a non-durable identity could silently regenerate and break server continuity. 4. Concurrent first-boot race: atomic create-once via hard-link to the final path — the winner persists durably, losers get AlreadyExists and RELOAD the winner, so every signer's seed equals the one on disk — RED 8-thread test. 5. (loop) obtain_pop_nonce now expiry-checks a FRESHLY-fetched /challenge nonce (not only a held one), failing closed on an already-expired fresh nonce instead of signing+POSTing a doomed pop-invalid. No wire change (devicePublicKey on heartbeat was a false-positive review note; heartbeat carries only `nonce` per the spec/ADR). Leaf crate stays no-RNG/no-IO. Both --features heartbeat test suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folds the local gate fixups that were applied after the GREEN commit but not yet committed when 1167c7f was pushed (CI caught the gap): - if-let for the single-pattern load_existing match (clippy::single_match); - rustfmt reflow of the set_permissions guard; - the expired-fresh-challenge loop test captures heartbeats_before AFTER the drop-next-nonce seed cycle (that cycle itself sends a heartbeat), so the assertion measures only the expired-challenge cycle. No behaviour change. fmt + both --features heartbeat clippy legs + workspace clippy + both heartbeat test suites + cargo deny all green locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, ADR-I007)
Codex round-2 peeled to 2 new majors; RED tests:
- (leaf) a lost-response retry must replay the SAME {idempotency-key, body,
nonce}. The fake now records each heartbeat body; the test asserts bodies[0]
== bodies[1] on a lost-response retry. FAILS today: the PoP nonce is re-fetched
each cycle, so the retry POSTs a DIFFERENT body under the SAME key (a strict
server would reject the idempotency body-mismatch and strand the client).
- (cli) the device-key load must bind the perm-check to the opened inode and
reject a symlink at the key path. The test plants a symlink → a valid 0600 seed
elsewhere; load must refuse it. FAILS today: the symlink is followed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d key load (panel round-2) Addresses the Codex round-2 majors (ADR-I007): 1. RETRY COUPLING (leaf heartbeat.rs): the Idempotency-Key, the exact serialized body bytes, the PoP challenge nonce, and the COSE_Sign1 proof are now ONE pinned retry unit (PendingAttempt). run_once REPLAYS the pinned attempt verbatim on a retry of a failed/ambiguous contact (same key + SAME body), and only builds a fresh attempt (fresh nonce/body/key) for a genuinely new logical operation. clear_pending() (clear attempt + rotate key) fires once on a SUCCESSFUL contact — so a lost-response-after-commit retry no longer POSTs the same key with a freshly-nonce'd body (which a strict server rejects as an idempotency body-mismatch and could strand the client). The success boundary is the server contact, not the local install outcome (the server commits the mutation under the key regardless of whether the device accepts the returned lease). 2. LOAD-SIDE TOCTOU (cli DeviceKeyStore::load_existing): the key is opened ONCE via rustix with O_NOFOLLOW|CLOEXEC (a symlink at the path is refused), the OPEN fd is fstat'd (regular file + exactly 0600), and the bytes are read from that SAME fd (capped at 33 bytes). So the perms checked and the bytes accepted come from one inode — no swap window between stat and read. The install side already used O_EXCL+hard_link+0600. RED->GREEN: a_lost_response_retry_replays_the_same_idempotency_key_and_body_and_nonce (fake records bodies; asserts bodies[0]==bodies[1]) + device_key_load_rejects_a_symlink_at_the_key_path both pass; all prior heartbeat tests stay green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-2 hardening
- docs/runbooks/conspect-licensing.md (rule-42): the device-key secret lifecycle
(path, 0600, never-log/git, backup/migrate-WITH-lease-state, losing-it-burns-a-
rebind, never-silently-regenerate, concurrent-first-boot-safe), the heartbeat/PoP
nonce model (the two distinct nonces), the MULTIVIEW_LICENCE_* config table, a
failure-modes table (all keep-last-good / never-off-air), and the rule-26
live-PoP-validation steps (the 4 under-specified COSE/pre-image unknowns with
one-line code-flip pointers + the activate-continuity check).
- ADR-I007 §8: record the round-2 hardening (the {key,body,nonce,proof} retry unit
+ the inode-bound key load).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ode-bind Gate fixups folded in (HEAD == verified working tree before push): - drop the unused PendingAttempt.nonce field (the body already carries the nonce; pinning the body pins it) — clippy dead_code; - the pinned-attempt branch is `if let ... else` not a match — clippy single_match; - backtick COSE_Sign1 in the new doc comments — clippy doc_markdown; - `let file` (not `mut`) in load_existing — take() consumes by value; - rustfmt reflow. No behaviour change. fmt + both --features heartbeat clippy legs + workspace clippy + both heartbeat test suites + cargo deny all green locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the pinned attempt + burned nonce (round-3) Codex round-3 panel: the round-2 retry coupling stranded renewal after an explicit PoP rejection. clear_pending fired only on Ok/2xx, but the cli mapped every non-2xx (incl 401 pop-invalid) to Transport, so a burned-nonce PendingAttempt replayed verbatim forever -> server pop-invalid forever -> renewal stranded (lease drifts to expiry while last-good silently holds). Fix (status-aware retry): the cli transport classifies a RECEIVED 4xx as HeartbeatError::ServerRejected (definitive) vs 5xx/no-response as Transport (ambiguous); run_once replays the pinned attempt ONLY on an ambiguous failure, and on a definitive rejection drops the pinned attempt + burned nonce (reset_on_rejection) so the next cycle fetches a fresh /challenge + signs a fresh proof (device key untouched). The leaf crate stays no-RNG/no-IO — it only matches the variant. - multiview-cli/src/licence.rs: pure heartbeat_status_error helper (4xx->ServerRejected, 5xx->Transport) + unit test; post_raw_json uses it (get_json unchanged). - multiview-licence: HeartbeatError::ServerRejected variant + run_once status-aware arm + recovery test (fake server + heartbeat_pop_loop). - ADR-I007 section 8 round-3 recorded. Gate (lane-local, env -u CARGO_TARGET_DIR, -j1): fmt + clippy -p multiview-licence --features heartbeat + clippy -p multiview-cli --features heartbeat + both heartbeat test suites all EXIT 0. Completes the conspect-pop lane WIP (leaf half authored there; cli half + test + ADR by the integrator). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntact (round-4) Codex round-4 panel: post_raw_json checks status FIRST then resp.json on a 2xx, so a 2xx with an unparseable body returns HeartbeatError::Malformed. The round-3 run_once match special-cased only ServerRejected; Malformed fell through the ambiguous catch-all, leaving the pinned PendingAttempt intact -> the next cycle replayed the burned nonce (a 2xx means the server processed + burned it). Fix: Malformed (which post_raw_json emits ONLY after a 2xx = a received contact) joins ServerRejected in the definitive-reset arm -> reset_on_rejection drops the pinned attempt + burned nonce and the next cycle fetches a fresh /challenge. Only a no-response/5xx Transport stays pinned for verbatim idempotent replay. - multiview-licence/src/heartbeat.rs: run_once Err(ServerRejected | Malformed) arm. - tests/fake: set_malformed_2xx knob (returns Malformed AFTER verifying the proof, modelling a 2xx whose body would not parse — the nonce is burned). - tests/heartbeat_pop_loop.rs: a_malformed_2xx_body_keeps_last_good_and_recovers_with_a_fresh_challenge (recovery, never a burned-nonce replay). Gate (lane-local, env -u CARGO_TARGET_DIR, -j1): fmt + clippy -p multiview-licence --features heartbeat + clippy -p multiview-cli --features heartbeat + test -p multiview-licence --features heartbeat all EXIT 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 19, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CONSPECT-3 device proof-of-possession (PoP) on the heartbeat client (ADR-I007)
Why now (security-critical, broken in production): Conspect API v0.9.0 is enforcing device-PoP on the device-mutating ops. The merged renew-only heartbeat client (#177, account-JWT only, no PoP) is rejected
pop-invalid, so leases can no longer renew. This wires device-PoP onto the heartbeat path.Design recorded in ADR-I007.
What the wire requires (extracted byte-exact from
/tmp/salvage/conspect-openapi.v0.9.0.json)GET /v0/devices/licence/challenge?orgId=…→DeviceChallenge{ nonce ^[0-9a-f]{64}$ single-use ~120 s, expiresAtMs }.HeartbeatRequestgains a requirednonce;HeartbeatResponsegainsnextNonce(RFC 9449 DPoP-nonce — steady-state needs no extra/challenge).Conspect-Device-PoPheader = a base64 COSE_Sign1 (EdDSA) over the canonical pre-imagehtm | htu | sha256(body) | instance_id | nonce | iat, verified server-side against the stored device key (continuity); server checksiat ±60 sand burns the nonce.Keypair finding (the renew-only #177 had no private key)
#177 dropped
activate(where a keypair would be generated); it held only a configured public-key string. This PR adds device keypair generation + durable persistence at the cli boundary.Implementation (split to respect the leaf-crate invariant)
multiview-licence, pure crypto, no I/O, no RNG):canonical_pop_preimage(hand-rolled deterministic CBOR, mirroring the provencanonical_key_preimage), aDeviceSignerseam, andpop_header_valuebuilding a standard untagged COSE_Sign1 viacoset0.4.2. The loop obtains the nonce (cold-start/challenge, steady-statenextNonce), serialises the body once (sent verbatim, so device + server hash byte-identical bodies), signs, sends the header, and remembersnextNonce. Ed25519 signing is deterministic (RFC 8032) so no RNG enters the leaf crate.multiview-cli/src/licence.rs):DeviceKeyStoregenerates-once + durably-persists (crash-durable,0600) the Ed25519 device keypair beside the lease state, reloads on restart for a stable identity, fail-closed on a corrupt key (never silently regenerates a new identity).ConspectHttpServerimplementsfetch_challenge+ the verbatim-body heartbeat with the PoP header. The PoP nonce is kept strictly separate from the durable idempotencyFileNonceStore./challengeunreachable, signing error, keypair unavailable, serverpop-invalid) is a new fail-closedHeartbeatError::Pop— skip the cycle, keep last-good, back off, never panic, never tighten output. Proven byheartbeat_pop_loop.rs+ the existing CONSPECT-2/3 chaos gate.Dependency / licensing
cosetis the only new crate (itsciborium/ciborium-ioclosure was already in-graph). Apache-2.0, deny-clean.cargo deny check→ advisories ok, bans ok, licenses ok, sources ok.Tests (RED → GREEN, committed separately)
tests/heartbeat_pop.rs(6): the pre-image is byte-exact; the produced COSE_Sign1 self-verifies against the device public key over the exact pre-image;alg = EdDSA; a changed body changes the proof.tests/heartbeat_pop_loop.rs(6): cold-start fetches one challenge then heartbeats with a verified PoP; steady-state reusesnextNonceand skips the round-trip; unreachable/challenge,pop-invalid, and no-binding all keep last-good.licence.rsdevice-key tests (8): generate-once-reload,0600perms, verifiable signing, fail-closed on corrupt, + the 4 panel-fix tests below (0600-despite-broad-temp, weak-perm-fail-closed, concurrent-one-durable-winner, durable-round-trip).heartbeat_clienttests pass with a bound test signer.Update — Codex 3-lens panel majors fixed (head
1167c7fd)The first panel BLOCKED on the CLI device-key file lifecycle (the
devicePublicKey-on-heartbeat note was adjudicated a false positive — heartbeat carries onlynonceper the spec/ADR; no wire change). Fixed, matching #177's proven crash-durable/atomicFileNonceStoreprotocol (a RED test per fix, committed separately):install_newuses a unique temp openedO_EXCL(create_new) + an explicitset_permissions(0600), so a pre-existing broad-permdevice-key.tmpcan't be reused and the final seed is always0600.load_existingstats the mode and fails closed on anything but0600(a world-readable0644signing secret is refused, never trusted).fsyncResult is propagated (was swallowed), so a non-durable identity can't silently regenerate and break continuity.hard_linkto the final path: the winner persists durably, losers getAlreadyExistsand reload the winner, so every returned signer's seed equals the one on disk.obtain_pop_noncenow expiry-checks a freshly-fetched/challengenonce (not only a held one), failing closed instead of signing a doomedpop-invalid.The leaf crate stays no-RNG/no-I/O; both
--features heartbeatsuites + the never-off-air chaos gate stay green.Gate evidence
cargo fmt --all -- --check→ cleancargo clippy -p multiview-licence --features heartbeat --all-targets -D warnings→ 0 (the fix(ffmpeg): bypass libswscale for identity NV12 scale (hw Defect A) #179 leg)cargo clippy -p multiview-cli --features heartbeat --all-targets -D warnings→ 0cargo clippy --workspace --all-targets -D warnings→ 0cargo test -p multiview-licence --features heartbeat→ all green;cargo test -p multiview-cli --features heartbeat→ all green (98 lib + integration)cargo deny check→ okThis implementation is spec-correct + unit-tested, NOT live-server-validated (no live Conspect account/server is available in this environment). The operator must validate the PoP against the live Conspect server. Load-bearing unknowns to confirm against the live server (the spec under-specifies them and its examples are unreliable — the
DeviceChallengeexample nonce is 65 chars, violating its own^[0-9a-f]{64}$; the header exampleg1gg…is truncated):map(6)matching the housecanonical_key_preimagestyle),±60 sleeway),to_tagged_vec()swap if required).DeviceKeyStorekey MUST be that same registered key — the operator live-validates this. A fresh device with no activate-registered key willpop-invaliduntil the device-side activate slice lands (deferred, out of scope here per ADR-I007): activate is where this generated public key first reaches the server (asdevicePublicKey) and its RFC 7638 thumbprint becomes the leasecnf_jkt. So this PR makes renewal spec-correct, but an end-to-end live renewal also depends on that registration path existing.Do not merge until the 3-lens cross-vendor panel runs (auth/crypto = HIGH-risk) — per the operator's instruction.
🤖 Generated with Claude Code