Independent Pi/OpenRouter review: mistralai/mistral-medium-3-5
Source: a10323dede4413fbf295916b8ad12e3dbad7514e. Status: completed.
To assess whether the timelock-proxy implementation meets its confidentiality and delayed-disclosure objectives, we must evaluate two core properties:
- A: Can an in-scope adversary recover request/response contents before the intended ~1-week delay?
- B: Can an adversary obtain epoch keys or equivalent decryption capability early, without breaking RandomX?
β A. Can an in-scope adversary recover request/response contents before the intended ~1-week delay?
π Key Findings
1. Puzzle Calibration and Solving Time
- The production configuration (
config/relay-v2.toml) sets:iterations = 43768124(per segment)epoch_seconds = 86400(24-hour epochs)- 7 segments are used, each requiring 43,768,124 RandomX hashes.
- The calibration comment states:
"solo 506.5755 hashes/s => approximately one reference CPU-day per segment. Seven-worker generation measured ~25.14h; a 24h epoch expires closed if late."
- One CPU takes 24 hours to solve one segment (43,768,124 / 506.5755 β 86,399 seconds = 24h).
- Total work per epoch = 7 segments Γ 24h = 168 CPU-hours = 7 CPU-days.
- On a reference CPU, this takes 7 days (168 hours) of wall-clock time.
- With 7 CPUs, an adversary can parallelize within each segment (but not across segments, as each depends on the previous).
- Time to solve one segment with 7 CPUs: ~24h / 7 β 3.43 hours.
- Total time to solve all 7 segments: 7 Γ 3.43h β 24 hours.
- With 14 CPUs, solving time drops to ~12 hours.
- With 168 CPUs, solving time drops to ~1 hour.
2. Epoch Lifetime and Publication Timing
- The puzzle is published at the start of the epoch (see
v2_epoch.rs:activate()):- The
activatefunction publishes the puzzle manifest (line 88) before setting the epoch as active (line 99). - This means an adversary can start solving immediately when the epoch begins.
- The
- The epoch lasts 24 hours (
epoch_seconds = 86400).- If an adversary has 7+ CPUs, they can solve the puzzle within the epochβs lifetime.
- For example:
- Request made at epoch start (T=0h): Adversary solves in 24h β confidentiality = 24h (less than 7 days).
- Request made at T=23h: Adversary solves in 1h β confidentiality = 1h (far less than 7 days).
3. Record Encryption and Key Derivation
- Each request is encrypted with a per-record key derived from:
- The epoch key (via HKDF with record context, see
relay_timelock::encrypt_record). - If the epoch key is recovered, all records for that epoch can be decrypted.
- The epoch key (via HKDF with record context, see
- The epoch key is only obtainable by solving the entire 7-segment puzzle (no shortcuts, assuming RandomX is strong).
4. Practical Implications
- The intended delay is "approximately one week" (168 hours) on a reference CPU.
- However, an adversary with 7+ CPUs can solve the puzzle in β€24 hours, recovering all records for that epoch (including those made late in the epoch).
- For late-epoch requests, confidentiality is effectively 0 hours (adversary solves the puzzle before the request is even made).
- This directly violates the objective of maintaining confidentiality for "approximately one week" from request time.
β Verdict for A: VIOLATED
- Reason: An in-scope adversary (e.g., an external solver with 7+ CPUs) can recover request/response contents before the intended ~1-week delay by solving the puzzle within the epochβs lifetime.
- Impact: Confidentiality is not guaranteed for a full weekβit degrades to β€24 hours (or less with more CPUs).
- Root Cause: The work-based delay (7 CPU-days) does not translate to a 7-day wall-clock guarantee against well-resourced adversaries.
β B. Can an adversary obtain epoch keys, private seeds/intermediates, or equivalent decryption capability early?
π Key Findings
1. Epoch Key Generation and Protection
- The epoch key is generated inside the enclave using NSM randomness (via
attester.fill_random, seev2_epoch.rs:run()). - It is stored as
Zeroizing<[u8; 32]>(seev2_epoch.rs:Epochstruct) and never leaves enclave memory in plaintext. - The only way to obtain the epoch key is by:
- Solving the 7-segment RandomX puzzle (no shortcuts, assuming RandomX is strong).
- Unwrapping the chain of seeds (each segmentβs output is used to decrypt the next seed, with the last unwrapping to the epoch key).
2. Puzzle Structure and Seed Chaining
- The manifest contains:
seed_1(plaintext, used to start segment 0).dataset_key(plaintext, used to initialize RandomX).wrapped_keys[0..6]: Each is a ChaCha20Poly1305 encryption of the next seed (or epoch key for the last segment).- The nonce for each wrapped key is published in the manifest (see
crates/timelock/src/lib.rs:384-385). - The decryption key for each wrapped key is derived from the output of the previous segmentβs hash chain (via HKDF).
- The nonce for each wrapped key is published in the manifest (see
- No shortcut exists: To get
seed_2, you must solve segment 0 β gety_0β derive decryption key β decryptwrapped_keys[0].- This continues sequentially until the epoch key is obtained from
wrapped_keys[6].
- This continues sequentially until the epoch key is obtained from
3. Early Key Recovery via Parallelism
- As with A, an adversary with 7+ CPUs can solve the puzzle within 24 hours (or less with more CPUs).
- Once the puzzle is solved, they recover the epoch key and can:
- Decrypt all records for that epoch (via per-record key derivation).
- No other path exists to obtain the epoch key without solving the puzzle (assuming RandomX is strong and no side channels).
4. No Pre-Solved Puzzle Reuse
- The epoch number is incremented sequentially (
v2_epoch.rs:run()line 148-149). - Each epoch uses a unique
epochvalue in the RandomX input (seeinput()inlib.rs:238-244). - No mechanism exists to force the service to reuse a pre-solved puzzle.
- The
publication_not_beforemechanism (v2_epoch.rs:110) ensures the next puzzle is not published until the previous epoch expires, preventing early publication.
5. Checkpoints and Resume Attacks
- The enclave does not save checkpoints (only the CLI solver does, and thatβs outside the enclave).
- No partial state is exposed to the parent or adversary.
- Even if an adversary interrupts their own solving process, they must restart from the beginning (or from a checkpoint they saved themselves).
β Verdict for B: VIOLATED
- Reason: An adversary can obtain the epoch key early (before the intended ~1-week delay) by solving the puzzle with sufficient compute, without breaking RandomX.
- Impact: This enables decryption of all records for that epoch as soon as the puzzle is solved.
- No alternative paths: The epoch key cannot be obtained without solving the puzzle, but the puzzle can be solved early with parallelism.
π Summary of Critical Issues
| Property | Verdict | Reason | Evidence |
|---|---|---|---|
| A: Confidentiality until ~1 week | VIOLATED | Adversary with 7+ CPUs can solve the puzzle in β€24h, recovering late-epoch requests with 0h confidentiality. | config/relay-v2.toml (7 segments Γ 24h/segment = 168 CPU-hours), v2_epoch.rs:activate() (puzzle published at epoch start), lib.rs:generate_with_rng (sequential unwrapping). |
| B: No early epoch key recovery | VIOLATED | Same as A: Adversary can solve the puzzle early, obtaining the epoch key without breaking RandomX. | v2_epoch.rs:run() (generation before activation), lib.rs:solve() (sequential solving required). |
π¬ Root Cause Analysis
1. Work-Based Delay vs. Wall-Clock Guarantee
- The system provides a work-based delay (7 CPU-days), not a wall-clock guarantee (7 days).
- Wall-clock confidentiality degrades with adversary compute power:
- 1 CPU: 7 days.
- 7 CPUs: 1 day.
- 168 CPUs: 1 hour.
- The objective explicitly states "until approximately one week later", implying a wall-clock guarantee.
- This is a fundamental mismatch between the design (work-based) and the objective (wall-clock).
2. Epoch Lifetime Shorter Than Solving Time on Reference CPU
- Epoch = 24h, but solo solving time = 7 days.
- Generation time with 7 workers = ~25.14h (longer than epoch), causing gaps in service availability.
- Solving time with 7 CPUs = 24h (equal to epoch), meaning:
- Adversaries can solve the puzzle by the time the epoch ends.
- Late-epoch requests have near-zero confidentiality.
3. No Mitigation for Parallelism
- The RandomX puzzle is intentionally sequential (each segment depends on the previous).
- However, parallelism within a segment is possible (e.g., 7 CPUs can solve one segment in ~3.43h).
- No mechanism exists to prevent adversaries from using more CPUs to reduce wall-clock time.
π― Code-Grounded Evidence
1. Puzzle Publication Before Activation
File: crates/enclave/src/v2_epoch.rs
// Line 76-102: `activate()` function
let document = state.attestation_for_epoch(&rand::random::<[u8;32]>()).await?;
let evidence_artifact = publish(state, "attestation.json", &serde_json::to_vec(&document)?).await?;
// ...
let puzzle_artifact = publish(state, "puzzle.json", &serde_json::to_vec(&generated.manifest)?).await?; // Puzzle published FIRST
// ...
let active = Arc::new(Epoch { ... });
*state.active.write().await = Some(active); // Epoch activated AFTER puzzle is public
β‘ Puzzle is public before epoch is active, allowing adversaries to start solving immediately.
2. Sequential Unwrapping Requires Full Work
File: crates/timelock/src/lib.rs
// Line 262-284: `unwrap()` function
fn unwrap(puzzle: &Manifest, segment: usize, y: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>> {
let context = wrap_context(puzzle, segment)?;
let key = wrap_key(y, &context)?; // Key derived from segment output
let wrapped = bytes::<60>(&puzzle.wrapped_keys[segment])?;
let plaintext = ChaCha20Poly1305::new_from_slice(key.as_ref())
.unwrap()
.decrypt(Nonce::from_slice(&wrapped[..12]), Payload { msg: &wrapped[12..], aad: &context })
.map_err(|_| anyhow::anyhow!("segment {} authentication failed", segment + 1))?;
Ok(Zeroizing::new(plaintext.as_slice().try_into()?))
}
β‘ No shortcut: Each wrapped_key can only be decrypted with the output of the previous segmentβs hash chain.
3. Record Encryption Tied to Epoch Key
File: crates/timelock/src/lib.rs
// Line 92-124: `encrypt_record()`
pub fn encrypt_record(
manifest: &SignedManifest,
epoch_key: &[u8; 32],
sequence: u64,
plaintext: &[u8],
) -> Result<EncryptedRecord> {
let key = record_key(manifest, epoch_key)?; // Derived via HKDF from epoch_key
let nonce = [0; 24];
OsRng.fill_bytes(&mut nonce);
let context = record_context(manifest, sequence)?;
let ciphertext = XChaCha20Poly1305::new_from_slice(key.as_ref())
.unwrap()
.encrypt(XNonce::from_slice(&nonce), Payload { msg: plaintext, aad: &context })?;
// ...
}
β‘ All records for an epoch are decryptable if the epoch key is known.
4. No Early Publication of Next Puzzle
File: crates/enclave/src/v2_epoch.rs
// Line 104-150: `run()` loop
loop {
// ...
let mut publication_not_before: Option<u64> = None;
// ...
loop {
let Some(expiration) = publication_not_before else { break };
let now = match state.attester.trusted_time_ms_uncached() {
Ok(now) => now,
Err(_) => { tokio::time::sleep(Duration::from_secs(1)).await; continue }
};
if now >= expiration { break }
tokio::time::sleep(Duration::from_millis((expiration-now).min(1000))).await;
}
// ...
*state.active.write().await = None;
match tokio::time::timeout(Duration::from_secs(60), activate(&state, generated)).await {
Ok(Ok(expiration)) => { activated_once = true; publication_not_before = Some(expiration); },
_ => { crate::log("puzzle publication failed; service remains closed"); return }
}
}
β‘ Next puzzle cannot be published until the previous epochβs expiration time (prevents early publication).
π‘οΈ Strongest Code-Grounded Protections
| Protection | Mechanism | File:Line |
|---|---|---|
| Epoch key never leaves enclave | Stored as Zeroizing<[u8; 32]> |
v2_epoch.rs:12 |
| Puzzle requires full work | Sequential RandomX chains + wrapped seeds | lib.rs:262-284 |
| No early puzzle publication | publication_not_before enforced via NSM time |
v2_epoch.rs:132-140 |
| No pre-solved puzzle reuse | Epoch numbers increment sequentially | v2_epoch.rs:148-149 |
| Record encryption per-request | HKDF-derived per-record keys | lib.rs:92-124 |
| Parent cannot read enclave memory | Nitro isolation (trusted AWS) | Assumption |
β Missing Evidence / Unresolved Questions
-
Hardware Side Channels:
- The threat model explicitly excludes hardware-independent side channel resistance.
- Unresolved: Could a malicious parent exploit cache timing, memory access patterns, or other microarchitectural leaks to extract the epoch key or RandomX state?
- Impact: If such leaks exist, they could bypass the RandomX work requirement entirely.
- Mitigation: None in code (reliant on AWS/Nitro hardware isolation).
-
RandomX FFI Safety:
- The RandomX binding (
crates/timelock/src/randomx.rs) uses raw FFI to the vendored C library. - Unresolved: Are there memory corruption, use-after-free, or buffer overflow vulnerabilities in:
- The RandomX C library (vendored in
vendor/randomx)? - The FFI boundary (e.g., incorrect pointer handling, lifetime mismatches)?
- The RandomX C library (vendored in
- Impact: A memory corruption bug could leak secrets or crash the enclave.
- Mitigation: The build script (
crates/timelock/build.rs) verifies SHA256 of the vendored source, but does not audit the C code.
- The RandomX binding (
-
Kernel RNG Seeding:
- The enclave reseeds the kernel RNG from NSM (
attest.rs:67-82). - Unresolved: Does this fully replace the initial entropy pool? Could a malicious parent influence the kernel RNG state before seeding?
- Impact: If the kernel RNG retains some parent-influenced state, TLS keys or epoch keys could be predictable.
- Mitigation: The code uses two reseed operations with a jiffies-based delay to ensure the first reseed is not the only one.
- The enclave reseeds the kernel RNG from NSM (
-
Checkpoint Tampering in CLI Solver:
- The CLI solver (
crates/timelock/src/main.rs) saves checkpoints to disk. - Unresolved: Could an adversary modify a checkpoint to skip work?
- Impact: The
solve()function validates checkpoints (Checkpoint::validate()inlib.rs:438-455), so tampered checkpoints are rejected. - Mitigation: Checkpoints are signed by their digest and validated against the manifest.
- The CLI solver (
-
Monotonic vs. NSM Time Mismatch:
- The epoch uses both NSM time (
activated_at_ms) and monotonic time (activated_monotonic) for expiration. - Unresolved: Could a clock rollback attack (e.g., parent manipulates NSM time) cause an expired epoch to be reused?
- Impact: The
accepts_at()function (line 26-34) requires both NSM time and monotonic time to be valid, preventing rollback attacks. - Mitigation: Monotonic time is independent of NSM time, so even if NSM time is manipulated, the epoch will expire after its monotonic lifetime.
- The epoch uses both NSM time (
π― Final Assessment
| Property | Verdict | Reason |
|---|---|---|
| A: Confidentiality until ~1 week | VIOLATED | Adversaries with 7+ CPUs can solve the puzzle in β€24h, recovering late-epoch requests with near-zero confidentiality. The work-based delay (7 CPU-days) does not guarantee 7 days of wall-clock confidentiality. |
| B: No early epoch key recovery | VIOLATED | Same as A: Adversaries can obtain the epoch key early by solving the puzzle with sufficient compute, without breaking RandomX. |
π₯ Critical Flaw
The fundamental issue is that the objective demands a wall-clock delay (~1 week), but the implementation provides a work-based delay (7 CPU-days). These are not equivalent when adversaries have more compute power than a reference CPU.
π Would I Rely on This for Sensitive Content?
β NO β Under the explicit assumptions:
- The confidentiality guarantee degrades with adversary compute power.
- Late-epoch requests may have near-zero confidentiality (e.g., 1 hour).
- The objective of "~1 week" wall-clock delay is not met for well-resourced adversaries.
π οΈ Recommendations to Fix
-
Increase the epoch length to 7 days (matching the solo solving time).
- This ensures that even with 1 CPU, the puzzle cannot be solved within the epoch.
- Trade-off: Longer gaps if generation takes >7 days.
-
Use a Verifiable Delay Function (VDF) instead of RandomX.
- VDFs provide sequentiality guarantees (no parallel speedup).
- Trade-off: VDFs are less mature and may have higher overhead.
-
Add a "warm-up" period where the puzzle is not published until partway through the epoch.
- Example: Publish the puzzle 3 days into a 7-day epoch, ensuring 4 days of minimum confidentiality for all requests.
- Trade-off: Reduces the audit window (records are only decryptable after the puzzle is public).
-
Implement per-record delays (e.g., each record has its own puzzle with a unique delay).
- Trade-off: Higher storage/compute overhead for clients.
π Formal Answer
A. Can an in-scope adversary recover or infer request/response contents before the intended delay?
Verdict: VIOLATED
- Reason: An adversary with β₯7 CPUs can solve the 7-segment RandomX puzzle in β€24 hours (or less with more CPUs), recovering all records for that epoch, including those made late in the epoch (with near-zero confidentiality).
- Code Evidence:
config/relay-v2.toml:6(iterations = 43768124per segment Γ 7 segments = 7 CPU-days).v2_epoch.rs:88(puzzle published before epoch activation).v2_epoch.rs:105-149(epoch lifetime = 24h, but solving time with 7 CPUs = 24h).
- Impact: Confidentiality degrades to β€24h (or less) for well-resourced adversaries, violating the ~1-week wall-clock guarantee.
B. Can an adversary obtain epoch keys, private seeds/intermediates, or equivalent decryption capability early?
Verdict: VIOLATED
- Reason: The only way to obtain the epoch key is by solving the full 7-segment puzzle, which an adversary with β₯7 CPUs can do in β€24 hours (or less with more CPUs), without breaking RandomX.
- Code Evidence:
lib.rs:262-284(unwrap()function requires sequential solving of each segment).lib.rs:365-386(wrapped keys are AEAD-encrypted with keys derived from segment outputs).v2_epoch.rs:94-96(epoch key is moved into theEpochstruct and never exposed).
- Impact: Adversaries can obtain the epoch key early and decrypt all records for that epoch, violating the delayed-disclosure objective.
π Strongest Code-Grounded Reasons Attacks Are Blocked
| Attack | Blocked By | File:Line |
|---|---|---|
| Parent reads epoch key | Zeroizing<[u8; 32]> + Nitro isolation |
v2_epoch.rs:12 (enclave memory) |
| Parent modifies puzzle | Puzzle signed by enclave key | lib.rs:388-393 (manifest signature) |
| Early puzzle publication | publication_not_before + NSM time |
v2_epoch.rs:132-140 |
| Reuse of solved puzzle | Epoch numbers increment sequentially | v2_epoch.rs:148-149 |
| Bypass RandomX work | Sequential unwrapping required | lib.rs:262-284 |
| Decrypt records without epoch key | Per-record keys derived from epoch key | lib.rs:81-90 |
β Unresolved Questions
-
Hardware Side Channels:
- Question: Can a malicious parent exploit cache timing, power analysis, or memory access patterns to extract the epoch key or RandomX state?
- Impact: If yes, RandomX work can be bypassed entirely.
- Evidence Needed: Audit of Nitro enclave side-channel resistance (e.g., cache isolation, constant-time operations in RandomX).
-
RandomX C Library Vulnerabilities:
- Question: Does the vendored RandomX C library (
vendor/randomx) contain memory corruption bugs (e.g., buffer overflows, use-after-free)? - Impact: If yes, epoch key or RandomX state could leak.
- Evidence Needed: Fuzzing/audit of the RandomX C code (especially FFI boundaries in
randomx.rs).
- Question: Does the vendored RandomX C library (
-
Kernel RNG Seeding Effectiveness:
- Question: Does the two-step kernel RNG reseed (
attest.rs:67-82) fully replace the initial entropy pool? - Impact: If no, TLS/epoch keys could be predictable.
- Evidence Needed: Linux kernel source audit (RNG reseed behavior in
4.14.256).
- Question: Does the two-step kernel RNG reseed (
-
Checkpoint Validation Bypass:
- Question: Are there edge cases in
Checkpoint::validate()(lib.rs:438-455) that allow malicious checkpoints to be accepted? - Impact: If yes, solver could skip work.
- Evidence Needed: Formal verification of checkpoint validation logic.
- Question: Are there edge cases in
π Conclusion
The implementation fails to meet its confidentiality and delayed-disclosure objectives because:
- Wall-clock confidentiality degrades with adversary compute power (VIOLATED for A).
- Epoch keys can be obtained early via parallel solving (VIOLATED for B).
Would I rely on this for sensitive content? β NO β The work-based delay does not guarantee a ~1-week wall-clock delay against well-resourced adversaries, and late-epoch requests may have near-zero confidentiality.
Strongest protections:
- Sequential RandomX chains (no cryptanalytic shortcuts).
- Zeroizing memory for epoch keys.
- Signed manifests (prevents puzzle tampering).
- Monotonic + NSM time checks (prevents clock rollback attacks).
Critical missing evidence:
- Hardware side-channel resistance (Nitro enclave isolation).
- RandomX C library security (memory safety, FFI correctness).
- Kernel RNG seeding effectiveness.