Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions FSD/FSD-003_FEDERATION_IDENTITY_CODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,65 @@ hint(alias_hint) # 0x00 absent, else LP (display name only — NOT signed
hint(group_key_id) # 0x00 absent, else LP (family/community only)
```

## 3A. `fedcode` wire format (v3 — embedded owned nodes)

**Status:** normative, CIRISVerify#269. A strict **superset of v2**: everything
above, unchanged, plus a trailing node list under a bumped `CIRIS-V3-` prefix.

**Note the version number.** The proposal was titled "fedcode v2"; the wire
format has been at v2 since the kind-tagged code shipped, so the node-carrying
format is **v3**. Minting it as v2 would have collided with a live encoding.

### 3A.1 Why it exists

CC 5.4.6 names the population v2 cannot serve: *"phone-class peers that cannot
hold the full directory."* First contact, a QR across a table, an air-gapped
hand-off, a fresh install — none can derive anything from a `key_id` alone. A
v3 code carries what the directory would have supplied.

### 3A.2 Binary payload

```text
version(1) = 0x03
... all v2 fields, byte-identical, through hint(group_key_id) ...
node_count(1) # 1..=16
repeated node_count times:
LP(node_key_id) # 1-byte length prefix + UTF-8 bytes, 1..=255
transport_ed25519(32) # raw — the NODE's TRANSPORT key (see 3A.3)
```

### 3A.3 Constraints (all normative; a conforming impl MUST enforce each)

1. **The embedded key is the node's TRANSPORT Ed25519 — never the owner's
federation key, never the node's federation key.** Deriving a destination
from a federation key yields `sha256(fed)[..16]`, an explicit-hash
destination that categorically **cannot be announced**, so no peer can
self-learn a route to it. CIRISServer#335 is the production record of that
mistake: nodes primed the canonical at `1fc232535a…` while it served on
`81cabcf78a…`, every node reported `knows_peer=true`, and zero traces
arrived — after which the false rooting *prevented* recovery.
**Enforced at BOTH encoder and decoder**, because a code minted by another
implementation is exactly the case an encoder cannot police. A code whose
embedded transport key equals the owner's pubkey MUST be rejected.
2. **Only `kind = user` may embed nodes.** "The owner's nodes" is meaningless
for a node, and a group's destinations are group-scoped material a code MUST
NOT carry at all (CC 5.4.6, ruled in CIRISConstitution#91). Enforced at
encoder and decoder.
3. **`node_count` ≤ 16**, and the **total payload ≤ 1024 bytes** before base32.
The count alone does not bound the code — 16 × 255-byte ids exceeds what a
QR can render, defeating the hand-off the format exists for.
4. **Empty is valid and is the default.** A code with no nodes MUST encode as
**v2, byte-identically**, so nothing already issued moves. Only a non-empty
list emits `CIRIS-V3-`.
5. **Scope:** v3 carries lightnet facts only — federation-scope identity that
already announces publicly and carries no anonymity claim.

### 3A.4 Compatibility

v1 and v2 codes decode unchanged. A v3 decoder accepts all three prefixes; a
v2-only decoder rejects `CIRIS-V3-` outright rather than mis-parsing it, since
the prefix differs before any payload byte is read.

Then **CRC-16-CCITT** (poly `0x1021`, init `0xFFFF`) over the payload, appended
as 2 bytes **big-endian**. Then **RFC-4648 base32, no padding** (alphabet
`A–Z2–7`). Display form: prefix `CIRIS-V2-` + the base32 grouped into **4-char**
Expand Down
1 change: 1 addition & 0 deletions evidence/cc_impl.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,4 @@ UNASSIGNED CLM-scope-destination CIRISVerify src/ciris-crypto/src/scope_privacy.
5.4.6 CLM-announce-suppress CIRISVerify src/ciris-verify-core/src/announce_policy.rs#may_announce ciris-verify-core@v13.6.0
UNASSIGNED CLM-key-validity-window CIRISVerify src/ciris-verify-core/src/federation_self_record.rs#valid_until_in_envelope ciris-verify-core@v14.0.0
UNASSIGNED CLM-fedcode-owned-nodes CIRISVerify src/ciris-verify-core/src/fedcode.rs#OwnedNode ciris-verify-core@v14.1.0
UNASSIGNED CLM-keyring-rng-latch CIRISVerify src/ciris-keyring/src/lib.rs#mint_p256_signing_key ciris-keyring@v14.1.0
13 changes: 11 additions & 2 deletions src/ciris-keyring/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ keyring-storage = ["dep:keyring"]
# Post-quantum cryptography: enables ML-DSA-65 software signer + PqcSigner trait.
# Used by CIRISPersist's cold-path PQC fill-in flow (federation_keys etc.) so
# downstream consumers don't reach into the ml-dsa crate directly.
pqc-ml-dsa = ["dep:ciris-crypto"]
pqc-ml-dsa = ["ciris-crypto/pqc-ml-dsa", "ciris-crypto/ed25519", "ciris-crypto/self-enc", "ciris-crypto/hybrid-kex"]

[dependencies]
async-trait.workspace = true
Expand Down Expand Up @@ -83,7 +83,16 @@ keyring = { version = "3", optional = true }

# CIRIS cryptographic primitives — gated behind pqc-ml-dsa so the existing
# default ciris-keyring build doesn't pull in ml-dsa.
ciris-crypto = { path = "../ciris-crypto", version = "14", optional = true, default-features = false, features = ["pqc-ml-dsa", "ed25519", "self-enc", "hybrid-kex"] }
# NON-optional, deliberately (CIRISVerify#207 item 6). The keyring mints key
# SEEDS, and #74's "no weak key is ever produced" invariant lives in
# `ciris_crypto::random::fill` — the SP 800-90B health latch. Gating that
# behind an optional feature meant the default keyring build drew raw `OsRng`
# and the invariant simply did not hold for sealed federation keys.
#
# The base dependency is the `random` feature only, which is `[]` plus the
# `rand_core` this crate already pulls — so the light default stays light. The
# heavier PQC surface is still feature-gated below.
ciris-crypto = { path = "../ciris-crypto", version = "14", default-features = false, features = ["random"] }

# Platform-specific
[target.'cfg(target_os = "android")'.dependencies]
Expand Down
5 changes: 1 addition & 4 deletions src/ciris-keyring/src/keyring_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,7 @@ impl KeyringStorageSigner {

/// Generate a new key and store it in the keyring.
pub fn generate_and_store(&mut self) -> Result<(), KeyringError> {
use p256::ecdsa::SigningKey;
use rand_core::OsRng;

let signing_key = SigningKey::random(&mut OsRng);
let signing_key = crate::mint_p256_signing_key()?;
let key_bytes = signing_key.to_bytes();

self.store_key(&key_bytes)?;
Expand Down
122 changes: 122 additions & 0 deletions src/ciris-keyring/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,81 @@ pub mod platform;
pub mod keyring_storage;

pub use error::KeyringError;

/// Run the SP 800-90B startup health check if nothing has yet
/// (CIRISVerify#207 item 6).
///
/// `ciris_crypto::random::fill` only READS the latch, and an uninitialized
/// latch reads as healthy. The only production caller of
/// `run_startup_health_check` is `ciris-verify-ffi` — so a direct
/// `ciris-keyring` consumer (the documented standalone API, or a downstream
/// service that links the keyring without the FFI) could reach a mint having
/// never run the check, and the routing added for #207 would buy nothing.
///
/// `run_startup_health_check` latches through a `OnceLock`, so calling it here
/// is idempotent and costs one atomic load after the first mint.
///
/// # Errors
/// [`KeyringError::KeyGenerationFailed`] if the startup test fails — refusing
/// to mint is the fail-secure answer, and the whole point of #74.
pub(crate) fn ensure_rng_health_checked() -> Result<(), KeyringError> {
// An ALREADY-FAILED latch is decisive — do not re-run.
//
// `run_startup_health_check` is `get_or_init` + `store_state`, so calling
// it when the latch is already `Failed` re-runs the test and OVERWRITES
// the verdict. That would let a mint proceed off a fresh pass after the
// process had already latched a failure, which is exactly the latch's
// reason for existing: the verdict is sticky by design.
if ciris_crypto::rng_health::is_rng_failed() {
return Err(KeyringError::KeyGenerationFailed {
reason: "RNG health latch is FAILED; refusing to mint key material".to_string(),
});
}
match ciris_crypto::rng_health::run_startup_health_check() {
ciris_crypto::rng_health::RngHealth::Healthy => Ok(()),
ciris_crypto::rng_health::RngHealth::Failed { test, detail } => {
Err(KeyringError::KeyGenerationFailed {
reason: format!(
"SP 800-90B startup health check FAILED ({test}: {detail}); \
refusing to mint key material"
),
})
},
}
}

/// Mint a P-256 signing key from **latch-checked** randomness
/// (CIRISVerify#207 item 6 / #74).
///
/// `SigningKey::random(&mut OsRng)` draws straight from the OS RNG, bypassing
/// the SP 800-90B startup health latch that #74 added so *"no weak key is ever
/// produced"*. That invariant therefore held for `ciris-crypto`-constructed
/// keys and **not** for keyring-minted ones — which are the federation
/// identity keys, i.e. the ones that matter.
///
/// The bytes themselves go through [`ciris_crypto::random::fill`], rather than
/// merely probing the latch and then drawing unchecked, so the key material is
/// literally what the checked path produced.
///
/// # Errors
/// [`KeyringError::KeyGenerationFailed`] if the RNG health latch has tripped,
/// or (with probability under 2⁻³²) if the draw is not a valid P-256 scalar.
/// Refusing is the fail-secure answer in both cases: a retry loop around a
/// possibly-broken RNG is not an improvement.
pub fn mint_p256_signing_key() -> Result<p256::ecdsa::SigningKey, KeyringError> {
ensure_rng_health_checked()?;
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}"),
})?;
Comment on lines +211 to +214

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 👍 / 👎.

let key = p256::ecdsa::SigningKey::from_slice(&bytes).map_err(|e| {
KeyringError::KeyGenerationFailed {
reason: format!("random draw was not a valid P-256 scalar: {e}"),
}
})?;
Ok(key)
}

pub use hw_token::{
get_token_signer, hardware_class_table, resolve_hardware_class, HardwareClassRule, ProbedToken,
TokenInterface, GENERIC_EXTERNAL_TOKEN_CLASS,
Expand Down Expand Up @@ -231,3 +306,50 @@ pub fn get_platform_signer(alias: &str) -> Result<Box<dyn HardwareSigner>, Keyri
pub fn is_hardware_available() -> bool {
detect_hardware_type().has_hardware
}

#[cfg(test)]
mod rng_latch {
/// **CIRISVerify#207 item 6 / #74.** The keyring mints the federation
/// identity keys, and its mints drew raw `OsRng` — so "no weak key is
/// ever produced" held for `ciris-crypto` keys and not for these.
///
/// #74 proved that invariant with a per-primitive fail-secure test. This
/// is the one the keyring was missing.
#[test]
fn minting_refuses_on_a_tripped_rng_latch() {
use ciris_crypto::rng_health::{__force_health_for_test, RngHealth};

// `ciris-crypto`'s thread-local override is `#[cfg(test)]`, which is
// NOT active when it is compiled as this crate's dependency — so
// `__force_health_for_test` writes the PROCESS-GLOBAL latch.
//
// CI runs `cargo nextest`, which gives every test its own PROCESS, so
// the global is not shared and there is no race to serialize. An
// earlier revision added a module-local mutex for this; it protected
// nothing under nextest and implied a guarantee it did not provide, so
// it is gone. The `Restore` guard stays: under a plain `cargo test`
// this thread must not leave the latch tripped.
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
__force_health_for_test(RngHealth::Healthy);
}
}
let _restore = Restore;

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

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 👍 / 👎.

assert!(
matches!(
super::mint_p256_signing_key(),
Err(crate::KeyringError::KeyGenerationFailed { .. })
),
"a keyring mint MUST refuse when the RNG health latch has tripped"
);

__force_health_for_test(RngHealth::Healthy);
assert!(super::mint_p256_signing_key().is_ok());
}
}
14 changes: 12 additions & 2 deletions src/ciris-keyring/src/sealed_ed25519.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,18 @@ impl SealedEd25519Signer {
match adopt_seed {
Some(existing) => s.copy_from_slice(existing),
None => {
use rand_core::{OsRng, RngCore};
OsRng.fill_bytes(&mut s);
// #207 item 6 / #74: route key material through the SP 800-90B health
// latch. A raw `OsRng` draw here bypassed it, so "no weak key is ever
// produced" held for ciris-crypto keys and NOT for the keyring-sealed
// federation keys — the ones that actually matter.
crate::ensure_rng_health_checked()?;
ciris_crypto::random::fill(&mut s).map_err(|e| {
Comment on lines +91 to +92

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 👍 / 👎.

KeyringError::KeyGenerationFailed {
reason: format!(
"RNG health check failed; refusing to mint a seed: {e}"
),
}
})?;
},
}
storage.store(SEED_KEY_ID, &s)?;
Expand Down
11 changes: 9 additions & 2 deletions src/ciris-keyring/src/sealed_mldsa65.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,15 @@ impl SealedMlDsa65Signer {
match adopt_seed {
Some(existing) => s.copy_from_slice(existing),
None => {
use rand_core::{OsRng, RngCore};
OsRng.fill_bytes(&mut s);
// #207 item 6 / #74: key material goes through the SP 800-90B health
// latch. A raw `OsRng` draw bypassed it, so "no weak key is ever
// produced" held for ciris-crypto keys and NOT for keyring-minted ones.
crate::ensure_rng_health_checked()?;
ciris_crypto::random::fill(&mut s).map_err(|e| {
KeyringError::KeyGenerationFailed {
reason: format!("RNG health check failed; refusing to mint: {e}"),
}
})?;
},
}
storage.store(SEED_KEY_ID, &s)?;
Expand Down
18 changes: 12 additions & 6 deletions src/ciris-keyring/src/software.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ use ed25519_dalek::{
Signature as Ed25519Signature, Signer as Ed25519SignerTrait, SigningKey as Ed25519SigningKey,
};
use p256::ecdsa::{Signature, SigningKey};
use p256::elliptic_curve::rand_core::OsRng;

use crate::error::KeyringError;
use crate::signer::{HardwareSigner, KeyGenConfig};
Expand Down Expand Up @@ -142,7 +141,7 @@ impl SoftwareSigner {
alias = %alias,
"SoftwareSigner: generating new ECDSA P-256 key"
);
let key = SigningKey::random(&mut OsRng);
let key = crate::mint_p256_signing_key()?;

// Persist to disk
let key_bytes = key.to_bytes();
Expand Down Expand Up @@ -201,7 +200,7 @@ impl SoftwareSigner {

/// Generate a new random key and persist it.
pub fn generate_random_key(&mut self) -> Result<(), KeyringError> {
let key = SigningKey::random(&mut OsRng);
let key = crate::mint_p256_signing_key()?;

// Persist to disk
let key_bytes = key.to_bytes();
Expand Down Expand Up @@ -1520,9 +1519,16 @@ impl MutableEd25519Signer {
);

// Generate random 32-byte seed
use rand_core::{OsRng, RngCore};
let mut key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut key_bytes);
// #207 item 6 / #74: key material goes through the SP 800-90B health
// latch. A raw `OsRng` draw bypassed it, so "no weak key is ever
// produced" held for ciris-crypto keys and NOT for keyring-minted ones.
crate::ensure_rng_health_checked()?;
ciris_crypto::random::fill(&mut key_bytes).map_err(|e| {
KeyringError::KeyGenerationFailed {
reason: format!("RNG health check failed; refusing to mint: {e}"),
}
})?;

// Use import_key which handles hardware wrapping
self.import_key(&key_bytes)?;
Expand Down Expand Up @@ -2389,7 +2395,7 @@ mod tests {
async fn test_software_signer_sign_and_verify() {
use p256::ecdsa::signature::Verifier;

let signing_key = SigningKey::random(&mut OsRng);
let signing_key = SigningKey::random(&mut rand_core::OsRng);
let verifying_key = *signing_key.verifying_key();

let key_path = test_key_dir().join("test_sign.p256.key");
Expand Down
10 changes: 7 additions & 3 deletions src/ciris-keyring/src/transport_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,14 @@ impl TransportIdentityKeystore for BlobTransportKeystore {
}

fn generate_and_store(&self, key_id: &str) -> Result<(), KeyringError> {
use rand_core::{OsRng, RngCore};

let mut bytes = [0u8; TRANSPORT_IDENTITY_LEN];
OsRng.fill_bytes(&mut bytes);
// #207 item 6 / #74: key material goes through the SP 800-90B health
// latch. A raw `OsRng` draw bypassed it, so "no weak key is ever
// produced" held for ciris-crypto keys and NOT for keyring-minted ones.
crate::ensure_rng_health_checked()?;
ciris_crypto::random::fill(&mut bytes).map_err(|e| KeyringError::KeyGenerationFailed {
reason: format!("RNG health check failed; refusing to mint: {e}"),
})?;
let result = self.storage.store(key_id, &bytes[..]);
// Best-effort scrub of the transient buffer. The threat model this
// closes is at-rest exfil (the AV-17 carve-out concedes transient
Expand Down
11 changes: 10 additions & 1 deletion src/ciris-keyring/src/usb_wrapped_mldsa65.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,16 @@ impl UsbWrappedMlDsa65Signer {
let mut seed = [0u8; SEED_LEN];
match adopt_seed {
Some(s) => seed.copy_from_slice(s),
None => OsRng.fill_bytes(&mut seed),
// #207 item 6 / #74: a PQC seed is key material, so it goes
// through the SP 800-90B health latch rather than raw OsRng.
None => {
crate::ensure_rng_health_checked()?;
ciris_crypto::random::fill(&mut seed).map_err(|e| {
KeyringError::KeyGenerationFailed {
reason: format!("RNG health check failed; refusing to mint a seed: {e}"),
}
})?;
},
}

// The wrap key is derived from a deterministic Ed25519 signature. Prove
Expand Down
Loading
Loading