Skip to content

fix: keyring key mints go through the SP 800-90B latch (#207 item 6); name the steward-key drift (#176) - #271

Merged
emooreatx merged 3 commits into
mainfrom
fix/207-rng-latch-and-176-diagnosis
Sep 1, 2026
Merged

fix: keyring key mints go through the SP 800-90B latch (#207 item 6); name the steward-key drift (#176)#271
emooreatx merged 3 commits into
mainfrom
fix/207-rng-latch-and-176-diagnosis

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

The second pair for the 14.1.0 cut (#224 and #269 are already on main, untagged).


#207 item 6 — the #74 invariant did not hold where it matters most

v5.6.0 made keygen fail-secure so "no weak key is ever produced". But ciris-keyring's own mints drew raw OsRng, bypassing the SP 800-90B health latch.

So the invariant held for ciris-crypto-constructed keys and not for keyring-sealed ones — which are the federation identity keys. The gap was exactly inverted from where the guarantee was needed.

Every key-material draw now routes through ciris_crypto::random::fill:

site what it mints
sealed_ed25519 Ed25519 seed
sealed_mldsa65 ML-DSA-65 seed
usb_wrapped_mldsa65 portable PQC seed
transport_identity transport identity
software wrapper key + 2 P-256
keyring_storage P-256

mint_p256_signing_key puts the bytes through the latch rather than probing it and then drawing unchecked, so the key material is literally what the checked path produced.

The structural part

This required making ciris-crypto a non-optional keyring dependency, at the random feature only. It was optional behind pqc-ml-dsa — so the default keyring build had no latch at all. The gap wasn't an oversight at one call site; it was unreachable by construction.

Incidental draws are deliberately untouched — a temp-dir suffix and a TPM attestation nonce are not key material, and routing them would blur what the rule is for.

Proven by a fail-secure test: the one #74 gave every ciris-crypto primitive and the keyring never had. It uses the already-public __force_health_for_test hatch — I tried exposing test_support across the crate boundary first and reverted it, because dev-dependency features don't propagate that way.


#176 — the title says build record; the failing decode is the steward key

validation::query_https_sourceget_steward_keyStewardKeyResponse, which requires classical. Two months of that message pointing people at build records.

The parse itself cannot be fixed here. The registry sends the multi-steward shape; verify's modern parser for it requires a response_signature the registry does not send; and relaxing that would mean silently accepting unsigned trust-root material. That's a cross-repo contract decision (CIRISRegistry#133), so this doesn't paper over it.

What is fixable is the misdirection. The error now names the endpoint, the drift, the tracking issue, and "NOT a build-record problem and NOT a network problem". An unrelated parse error passes through untouched, asserted by test.

A correction to my own backlog report: I said this half was unfixed. v13.3.0 had already fixed the "HTTPS unreachable" mislabel on this path — I checked the build-record path rather than the one that actually fails.


1399 workspace green, clippy clean, doc clean, all guards + self-test pass.

Merging this completes the four-fix 14.1.0 (#224, #269, #207-6, #176).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FZPhbXWTzykcbotEX9BN2p

… name the steward-key drift (#176)

#207 item 6 -- the #74 invariant did not hold where it matters most.

  v5.6.0 made keygen fail-secure so "no weak key is ever produced". But
  ciris-keyring's OWN mints drew raw OsRng, bypassing the SP 800-90B health
  latch. The invariant therefore held for ciris-crypto-constructed keys and
  NOT for keyring-sealed ones -- which are the federation identity keys.

  Every key-material draw now routes through ciris_crypto::random::fill: the
  sealed Ed25519 seed, the sealed ML-DSA-65 seed, the USB-wrapped PQC seed,
  the transport identity, the software wrapper key, and three P-256 mints via
  a new mint_p256_signing_key.

  That helper puts the BYTES through the latch rather than probing it and then
  drawing unchecked, so the key material is literally what the checked path
  produced.

  This required making ciris-crypto a NON-optional keyring dependency, at the
  `random` feature only. Gating the latch behind pqc-ml-dsa meant the DEFAULT
  keyring build had no latch at all -- the gap was structural, not incidental.

  Incidental draws are deliberately untouched: a temp-dir suffix and a TPM
  attestation nonce are not key material, and routing them would blur what the
  rule is for.

  Proven by a fail-secure test -- the one #74 gave every ciris-crypto
  primitive and the keyring never had. It uses the already-public
  __force_health_for_test hatch; exposing test_support across the crate
  boundary was tried first and reverted, because dev-dependency features do
  not propagate that way.

#176 -- the title says build record; the failing decode is the steward key.

  validation::query_https_source -> get_steward_key -> StewardKeyResponse,
  which requires `classical`. Two months of that message pointing people at
  build records.

  The parse itself cannot be fixed here. The registry sends the multi-steward
  shape; verify's modern parser for it REQUIRES a response_signature the
  registry does not send; and relaxing that would mean silently accepting
  unsigned trust-root material. That is a cross-repo contract decision
  (CIRISRegistry#133), so this does not paper over it.

  What IS fixable is the misdirection: the error now names the endpoint, the
  drift, the tracking issue, and "NOT a build-record problem and NOT a network
  problem". An unrelated parse error passes through untouched, asserted by
  test.

  Note v13.3.0 already fixed the "HTTPS unreachable" mislabel on this path; I
  reported this half as unfixed during the backlog pass because I checked the
  build-record path rather than the one that actually fails.

1399 workspace green, clippy clean, doc clean, guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZPhbXWTzykcbotEX9BN2p

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ec0aa49a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +168 to +171
let mut bytes = [0u8; 32];
ciris_crypto::random::fill(&mut bytes).map_err(|e| KeyringError::KeyGenerationFailed {
reason: format!("RNG health check failed; refusing to mint a P-256 key: {e}"),
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize the RNG latch before relying on it

ciris_crypto::random::fill only reads the existing health verdict and explicitly treats the uninitialized state as healthy; the only production call to run_startup_health_check() found repo-wide is in ciris-verify-ffi. Consequently, direct ciris-keyring consumers such as the documented standalone API and downstream services can reach this mint without ever running the startup test, so these changed key-generation paths still draw from OsRng without the promised SP 800-90B check. Ensure the latch is initialized by the keyring before any mint, or make initialization an enforced constructor/API requirement.

Useful? React with 👍 / 👎.

Comment on lines +289 to +292
__force_health_for_test(RngHealth::Failed {
test: ciris_crypto::rng_health::TEST_REPETITION_COUNT,
detail: "forced for the keyring fail-secure test".to_string(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the forced RNG failure local to this test

When ciris-crypto is compiled as a dependency of the keyring unit-test target, its cfg(test) thread-local override is disabled, so this call to __force_health_for_test writes the process-global latch. While this test runs, any parallel keyring test that generates a key can therefore fail spuriously with KeyGenerationFailed; the local Restore only limits the duration and does not prevent the race. Use a dependency API that provides a thread-local test override, or serialize every keyring test that can consult the latch.

Useful? React with 👍 / 👎.

Comment thread src/ciris-verify-core/src/https.rs Outdated
Comment on lines +542 to +544
fn diagnose_steward_key_drift(detail: &str) -> String {
if detail.contains("missing field `classical`") || detail.contains("missing field `pqc`") {
format!(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify the response shape before naming the known drift

Any response missing either root field is labeled as the specific multi-steward contract drift even though the serde error contains no evidence that stewards or verification_policy was present. For example, a malformed single-steward response that still has classical but accidentally omits pqc will now be reported as CIRISRegistry#133 and claim a DNS-only degradation, sending operators toward the wrong incident. Inspect the response shape before attaching this diagnosis, or limit the helper to evidence that uniquely identifies the known payload.

Useful? React with 👍 / 👎.

Comment thread src/ciris-verify-core/src/https.rs Outdated
Comment on lines +250 to +253
VerifyError::ResponseSchemaMismatch {
url: url.clone(),
status: status.as_u16(),
detail: e.to_string(),
detail: diagnose_steward_key_drift(&e.to_string()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the steward-key diagnosis on mobile

On Android and iOS, get_steward_key still delegates directly to mobile_http::get_json, which converts this same serde failure into the generic HttpsError("JSON parse error: ..."); only the desktop reqwest branch calls the new diagnostic and returns ResponseSchemaMismatch. Thus mobile operators encountering the known registry payload continue to receive neither the contract-drift explanation nor the correct structured error, despite using the same /v1/steward-key API. Route the mobile parse failure through the same diagnosis and error variant.

Useful? React with 👍 / 👎.

Review found 11 across both PRs. One of them meant #224's fix did not work.

DEAD CODE -- VerifiedJson could never fire on a real manifest.

  ciris-build-tool hashes the WRAPPER `{"files": {...}}` (register.rs:601);
  my branch hashed the bare map. So the "try the other construction" fix --
  the entire point of #224 -- would have classified every real manifest as
  Unrecognized while appearing to work. My test passed because it hashed the
  same wrong shape: a fixture agreeing with the code instead of the producer,
  which is the exact failure mode that let the v10.4.0 preimage bug ship.

Also fixed:

  - malformed digests are a hard failure again ("sha256:" alone passed the
    emptiness check and fell through to Unrecognized).
  - manifest_hash_check propagates to FileCheckSummary and the CLI JSON;
    without it an Unrecognized hash reads as an ordinary pass.
  - the v3 decoder enforces kind == User -- a scanned code is untrusted input
    and a foreign impl can mint a CRC-valid non-user one.
  - the fedcode payload is bounded by SIZE (1024 bytes), not only node count;
    16 x 255-byte ids is >7KB encoded, which QrCode::new refuses.
  - owned_nodes appear in `fedcode show` JSON and human output.
  - FSD-003 documents the v3 wire format normatively.
  - the keyring RNG latch is INITIALIZED, not just read. Only the FFI called
    run_startup_health_check, and an uninitialized latch reads healthy -- so a
    direct keyring consumer could mint having never run it.
  - the latch test serializes; ciris-crypto's thread-local override is
    cfg(test) and inactive across the crate boundary.
  - the steward-key diagnosis requires EVIDENCE of the multi-steward shape.
  - mobile gets the same diagnosis instead of a generic HttpsError.

1400 workspace green, clippy clean, doc clean, guards + self-test pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZPhbXWTzykcbotEX9BN2p

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d97de99f02

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +91 to +92
crate::ensure_rng_health_checked()?;
ciris_crypto::random::fill(&mut s).map_err(|e| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run the latch before initializing TPM storage

With the default tpm-plugin feature, a first-time open_or_create calls open_existing and create_platform_storage before reaching this check; PluginTpmSecureBlobStorage::new_with_plugin then mints and persists its master key using raw OsRng at storage/tpm_plugin_storage.rs:123-127. If the RNG is stuck, the subsequent startup sample rejects the identity seed but leaves the predictable TPM master on disk, and a later healthy process reuses it to protect the eventual identity. Run the health check before any storage constructor capable of minting key material, including the adopted-seed path.

Useful? React with 👍 / 👎.

Comment on lines +447 to +448
#[serde(default = "crate::security::file_integrity::manifest_hash_check_default")]
pub manifest_hash_check: crate::security::file_integrity::ManifestHashCheck,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid adding a required public field in a minor release

This 14.1 release adds a field to the exhaustive public FileCheckSummary struct, so downstream Rust code constructing it with a struct literal now fails with a missing-field error. #[serde(default)] only preserves deserialization compatibility and does not help source compatibility. Defer this shape change to a major release or expose the measurement without adding a required field to this public struct.

Useful? React with 👍 / 👎.

Comment on lines +559 to +562
let looks_multi_steward =
body.contains("\"stewards\"") || body.contains("\"verification_policy\"");
if looks_multi_steward
&& (detail.contains("missing field `classical`") || detail.contains("missing field `pqc`"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict drift detection to root response fields

The fresh evidence in this revision is that the new guard uses raw substring searches rather than inspecting the JSON root: a malformed single-steward response can contain a nested stewards property or merely mention "stewards" in a string and, when pqc is missing, still be labeled as CIRISRegistry#133. Because mobile now applies this helper to every GET endpoint, an unrelated response is also eligible for the steward-key diagnosis. Parse the body as a JSON object and require the expected root-level multi-steward fields before attaching this incident-specific message.

Useful? React with 👍 / 👎.

Comment on lines +416 to +421
if out.len() > MAX_PAYLOAD_BYTES {
return Err(FedCodeError::Malformed(format!(
"encoded payload is {} bytes, over the {MAX_PAYLOAD_BYTES}-byte budget — \
the code would not render as a scannable QR",
out.len()
)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the v3 size cap from rejecting valid v2 codes

This unconditional cap also applies when owned_nodes is empty and the payload remains v2. A previously valid v2 value with a 255-byte key_id and three 255-byte hints is about 1,090 payload bytes, so encode now returns Malformed even though each field satisfies the existing v2 limit and the change promises byte-identical v2 behavior. Apply the new aggregate budget only to the node-carrying v3 format, or explicitly version the breaking v2 restriction.

Useful? React with 👍 / 👎.

Comment on lines 509 to 511
let count = usize::from(*payload.get(offset).ok_or_else(trunc)?);
offset += 1;
if count > MAX_OWNED_NODES {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the v3 payload bounds while decoding

A foreign implementation can produce a CRC-valid v3 code whose count is within 16 but whose node IDs push the payload over 1,024 bytes, and this decoder accepts it because the new size budget is checked only by the encoder. It also accepts count == 0, despite the v3 format specifying 1..=16 and reserving empty lists for byte-identical v2 encoding. Validate both the payload length and the lower count bound here so nonconforming v3 inputs are rejected consistently.

Useful? React with 👍 / 👎.

Comment on lines +173 to +176
serde_json::from_str::<T>(&text).map_err(|e| VerifyError::ResponseSchemaMismatch {
url: url.to_string(),
status: 200,
detail: crate::https::diagnose_steward_key_drift(&e.to_string(), &text),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the actual mobile response status

A successful ureq call can return any 2xx status, but this path discards the response before recording its status and reports every decode failure as HTTP 200. For example, an empty 204 response or a malformed 206 response is surfaced as a 200 contract drift, giving operators and callers incorrect evidence about what the endpoint returned. Capture response.status() before consuming the body and store that value in ResponseSchemaMismatch.

Useful? React with 👍 / 👎.

…cted nothing

CI caught a real tension between two of my own review fixes, via the test I
added for one of them.

`ensure_rng_health_checked` called `run_startup_health_check`, which is
`get_or_init` + `store_state` -- so on an already-`Failed` latch it RE-RAN the
test and OVERWROTE the verdict. A mint could then proceed off a fresh pass
after the process had already latched a failure, which defeats the latch's
whole reason for existing: the verdict is sticky by design.

Now an already-failed latch short-circuits before any re-run.

My local `cargo test --lib` was green because this only fails under nextest,
which CI uses and which gives every test its own PROCESS. That also makes the
module-local mutex I added last commit useless -- there is no shared global to
serialize -- so it is removed rather than left implying a protection it does
not provide. The Restore guard stays, for plain `cargo test`.

Verified with `cargo nextest run --workspace` this time, not `--lib`:
1478/1478 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZPhbXWTzykcbotEX9BN2p
@emooreatx
emooreatx merged commit fb9e69a into main Sep 1, 2026
27 checks passed
@emooreatx
emooreatx deleted the fix/207-rng-latch-and-176-diagnosis branch September 1, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant