Closes F-10a. Nothing here fixes a present defect: on the pinned rand 0.8.5, rand::random() and thread_rng() both resolve to a ChaCha12 CSPRNG seeded from getrandom(2). What they lack is a STATED backend — it is fixed by dependency and build configuration rather than by the calling code, with no compile error if that changes. That is the structural shape behind the 2026-07-30 COLDCARD entropy defect, and here the blast radius includes Cashu blinded-key-exchange values, X3DH prekey material, session bearer tokens and a ChaCha20-Poly1305 nonce. Layer (a) — every production key, nonce and token draw now names rand::rngs::OsRng at its own call site. The mnemonic seam is bound to entropy::KeyGenRng, a SEALED allowlist whose supertrait lives in a private module, so the set of RNGs that can drive the master key hierarchy is exactly what one file says it is. This retires the false promise at seed.rs:656: rand::CryptoRng is a marker with no compiler-checked content, and the crate now contains zero impls of it. Layer (d) — key material and AEAD nonces of >=12 bytes run a degenerate-entropy predicate that refuses all-zero, all-identical and wrapping +/-1 counter draws. Nothing heuristic: no entropy estimator, no chi-squared. Each of the three shapes has a false-positive probability computable in closed form (3 * 2^-88 at 12 bytes, 3 * 2^-248 at 32), and a predicate whose false-positive rate cannot be computed cannot be argued safe on a key-generation path. There is deliberately no retry — a retry would paper over the broken RNG this exists to surface. Layer (e) — the kernel-CSPRNG readiness verdict at master-seed generation is now durable (backlog R-09). It was previously computed, logged and thrown away, so a node could never answer after the fact whether its keys were born from a seeded pool. The record holds a schema version, timestamp, verdict and event name — no entropy, no key bytes. Formats and wire shapes are proven unchanged rather than asserted: storage_crypto and the credential store each open a HARDCODED pre-migration ciphertext vector (a same-process round trip would pass even if the envelope had changed), the vector was produced by an independent RFC 8439 implementation so it pins the documented nonce||ciphertext format rather than this implementation's output, and the x3dh prekey bundle and bdhke values keep their field set and order. totp.rs migrates its SOURCE only: the % charset.len() reduction and the 32-char charset are untouched. The bias there is presently zero (32 divides 256) and fixing the latent bias is R-12, which stays deferred. Verified: cargo build clean; cargo test -p archipelago 1068 passed, 2 failed. Both failures are container::boot_reconciler timing tests (second_pass_fires_after_interval, shutdown_terminates_loop) in a file this change does not touch — pre-existing, not caused here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
137 lines
4.7 KiB
Rust
137 lines
4.7 KiB
Rust
//! Companion device tokens — long-lived bearer credentials minted from an
|
|
//! authenticated session, so the pairing QR can log a phone in without
|
|
//! carrying the admin password (which the browser never has anyway).
|
|
//!
|
|
//! Only the SHA-256 of each token is persisted (`device-tokens.json` in the
|
|
//! data dir); the plaintext is returned exactly once at mint time and rides
|
|
//! the QR as the `tok` param. Verification goes through `auth.login`'s
|
|
//! `token` param and is covered by the same login rate limiter as passwords.
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::path::{Path, PathBuf};
|
|
use tokio::fs;
|
|
|
|
const TOKENS_FILE: &str = "device-tokens.json";
|
|
|
|
/// Cap on stored tokens; re-pairing the same device name replaces its entry,
|
|
/// so this only limits the number of *distinct* device names.
|
|
const MAX_TOKENS: usize = 32;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DeviceToken {
|
|
pub name: String,
|
|
/// Hex SHA-256 of the plaintext token.
|
|
pub hash: String,
|
|
/// Unix seconds at mint time.
|
|
pub created: u64,
|
|
}
|
|
|
|
fn tokens_path(data_dir: &Path) -> PathBuf {
|
|
data_dir.join(TOKENS_FILE)
|
|
}
|
|
|
|
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
|
|
match fs::read(tokens_path(data_dir)).await {
|
|
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
|
|
let bytes = serde_json::to_vec_pretty(tokens)?;
|
|
fs::write(tokens_path(data_dir), bytes)
|
|
.await
|
|
.context("write device-tokens.json")
|
|
}
|
|
|
|
fn hash_hex(token: &str) -> String {
|
|
hex::encode(Sha256::digest(token.as_bytes()))
|
|
}
|
|
|
|
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
|
if a.len() != b.len() {
|
|
return false;
|
|
}
|
|
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
|
}
|
|
|
|
/// Mint a new token for `name`. An existing token with the same name is
|
|
/// replaced, so re-showing the pairing QR never piles up stale entries.
|
|
/// Returns the plaintext token — the only time it ever exists outside the QR.
|
|
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
|
|
// KEY-05: a device token is a bearer credential — its unpredictability is
|
|
// the whole of its security — so the source is named and the draw guarded.
|
|
let mut token_bytes = [0u8; 32];
|
|
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).map_err(|e| {
|
|
anyhow::anyhow!("Refusing to mint a device token from degenerate entropy: {e}")
|
|
})?;
|
|
let token = hex::encode(token_bytes);
|
|
|
|
let mut tokens = load(data_dir).await;
|
|
tokens.retain(|t| t.name != name);
|
|
if tokens.len() >= MAX_TOKENS {
|
|
tokens.remove(0);
|
|
}
|
|
tokens.push(DeviceToken {
|
|
name: name.to_string(),
|
|
hash: hash_hex(&token),
|
|
created: std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0),
|
|
});
|
|
save(data_dir, &tokens).await?;
|
|
Ok(token)
|
|
}
|
|
|
|
/// Verify a candidate token. Returns the device name it was minted for.
|
|
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
|
|
let candidate_hash = hash_hex(candidate);
|
|
load(data_dir)
|
|
.await
|
|
.iter()
|
|
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()))
|
|
.map(|t| t.name.clone())
|
|
}
|
|
|
|
/// List stored tokens (hashes only — plaintexts are unrecoverable).
|
|
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
|
|
load(data_dir).await
|
|
}
|
|
|
|
/// Remove the token minted for `name`. Returns whether one existed.
|
|
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
|
|
let mut tokens = load(data_dir).await;
|
|
let before = tokens.len();
|
|
tokens.retain(|t| t.name != name);
|
|
let removed = tokens.len() != before;
|
|
if removed {
|
|
save(data_dir, &tokens).await?;
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn mint_verify_replace_remove() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let token = create(dir.path(), "phone").await.unwrap();
|
|
assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone"));
|
|
assert!(verify(dir.path(), "not-a-token").await.is_none());
|
|
|
|
// Re-minting the same name invalidates the old token.
|
|
let token2 = create(dir.path(), "phone").await.unwrap();
|
|
assert!(verify(dir.path(), &token).await.is_none());
|
|
assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone"));
|
|
assert_eq!(list(dir.path()).await.len(), 1);
|
|
|
|
assert!(remove(dir.path(), "phone").await.unwrap());
|
|
assert!(verify(dir.path(), &token2).await.is_none());
|
|
}
|
|
}
|