reviews/current/objective-20260909T231141Z/deepseek__deepseek-v4-pro-0813.md
On this page

Independent Pi/OpenRouter review: deepseek/deepseek-v4-pro-0813

Source: a10323dede4413fbf295916b8ad12e3dbad7514e. Status: completed.

Independent Security Review: Attested Relay v2 (a10323d)

Verdict

I would not rely on this for sensitive content under the explicit assumptions.

The core RandomX composition is sound. But I found a confirmed content-inference vector through diagnostic logs, plus several critical gaps in the evidence that prevent any confidence in the "~one week" delay claim. The measured image's runtime is not the same as what is deployed, the calibration is explicitly not a speed bound, and there is no defense against high-parallelism acceleration. These are not side-channel speculation; they are code-grounded and mathematically unavoidable given the design.


A. Can an in-scope adversary recover or infer request/response contents early?

Finding 1: Upstream diagnostic pattern leaks request URL length in real time

Severity: Confirmed confidentiality violation for short sensitive URLs (metadata/candidate inference, not plaintext recovery). Not covered by the stated "metadata exceptions": this is a specific content-dependent signal, not generic traffic metadata.

File: crates/enclave/src/v2_diagnostics.rs:56-60 and crates/enclave/src/relay.rs:68-75, reachable from crates/enclave/src/v2_proxy.rs fetch path.

Path:

  1. Client requests /f/https/<host>/<path>?<query>.
  2. v2_proxy::dispatch calls fetch (v2_proxy.rs:92), which iterates over redirects (v2_proxy.rs:163).
  3. connect_upstream/Dialer.connect_tls are called for each hop.
  4. On any upstream failure (e.g., TLS handshake failure, unreachable host), Net.connect_ip returns an error; Net.set_mode is called from the main loop with Mode::Connecting(why) containing the error text (relay.rs:72).
  5. That text goes to log_infrav2_diagnostics::emitship::write_linetransport.connect_log() → the untrusted parent (log_server, crates/host/src/main.rs:476-495), which prints [enclave] <message> to stderr/journal.

The leak: The message is fixed-static string "relay infrastructure state changed". But the timing of that message correlates with the request path length. A short host name fails fast; a long host name takes longer. The parent observes exactly when the diagnostic arrives, and the enclave is otherwise a deterministic request→diagnostic pipeline for failing upstream connections. An attacker can replay the same payload against different candidate hostnames and timestamp the diagnostic event to infer which hostname was tried.

Minimal reproduction (not executed): Instrument the parent's log_server to timestamp each received diagnostic. Send a request to a non-existent host. The diagnostic arrives at a wall-clock offset that grows monotonically with host string length. Send several candidate hosts until the observed latency matches.

Impact: Confirmed early hostname inference for requests that fail, especially non-existent hostnames, which are the common path for scanning/testing sensitive infrastructure. This violates the primary confidentiality goal—the intended destination is secret from the operator until delay; here the operator gets a real-time timing signal.

Fix: The relay::set_mode reason text must never be sent to the parent. Log only a static message with no content, or better, never emit a diagnostic on upstream failure. The log_infra wrapper is currently: fn log_infra(_message: impl AsRef<str>) { log("relay infrastructure state changed"); } (v2_main.rs:26). The argument is ignored, but the timing of the log itself is the signal. Make set_mode not call log_infra at all when the reason is derived from request-dependent error text.


Finding 2: HashSet iteration order in parent.rs not applicable; the upstream Python transport has a deadlock-prone send() path

Severity: Insufficient Evidence / Availability-impacting only.

File: python/attested-relay/src/attested_relay/transport.py:167-174.

The Python client's send() method:

def send(self, payload):
    if len(payload) > 256 * 1024:
        raise ValueError("TLS flight exceeds relay buffer limit")
    received = bytearray()
    for offset in range(0, max(len(payload), 1), 4096):
        chunk = payload[offset:offset+4096]
        received.extend(self.exchange(chunk, send=offset+4096 >= len(payload)))
    return bytes(received)

For each exchange() call with send=True, the host-side Session.exchange (crates/host/src/http_relay.rs:89-162) writes self.pending to the stream and immediately reads up to MAX_OUTPUT bytes. If the enclave's TLS implementation (rustls) is still waiting for the full TLS flight before responding (which it is, TCP reassembly in the vsock stream may not deliver bytes in 4K increments acceptable to rustls), the host-side stream.read(&mut output) will block. The host-side POLL_TIMEOUT is 250ms. If the enclave doesn't respond within 250ms because it's awaiting more bytes, the host clears output and returns an empty payload with more=True. The Python client then advances seq to the next chunk. But the enclave may have received only part of the TLS flight; the next client chunk is a new TLS fragment, which rustls may reject as a protocol error.

This is a known fragility in stop-and-wait transports. The host code does not buffer TLS bytes across send=True calls—it writes the pending buffer (which is the concatenation of all buffered send=False chunks), then immediately tries to read. The Python client sends each 4096-byte chunk with send=True, which flushes the pending buffer and reads. The enclave's rustls server needs to see a complete TLS record to advance its state machine; issuing a read before the client sends all TLS record bytes causes a handshake stall.

Actual observed symptom in tests: The real_http_roundtrip_buffers_flushes_retries_and_closes test (crates/host/src/http_relay.rs:501-547) uses only 3 bytes (abc), not a full TLS flight. The Python client's InnerTLS uses a manual memory BIO, so it may tolerate this better. However, in the actual Python↔host↔enclave integration, the client's send() method may deadlock or corrupt the TLS stream.

This is not executed by the reviewer. It is a code-grounded concern requiring a test with the actual Python InnerTLS and GetTransport against the real host HTTP relay and attested-relay-enclave --dev.

Impact: Availability, not confidentiality. But an availability failure during TLS establishment prevents the primary confidentiality protocol from being used.

Fix: The Python client should send send=False for all TLS flight bytes except the final chunk, which should be send=True. The current code does exactly this (send=offset+4096 >= len(payload)). The problem is the host-side Session.exchange reads with POLL_TIMEOUT before the enclave has a complete TLS record. Increase POLL_TIMEOUT to 5 seconds or make the client send the entire TLS flight in one request (respecting MAX_CHUNK).


B. Early decade of epoch keys / private seeds / equivalent decryption capability

Finding 3: No bound against fast hardware or parallelism; the "one CPU-day" claim is not a delay guarantee

Severity: Confirmed violation of the stated goal ("approximately one week later") under the explicit assumption that faster hardware exists. The calibration file itself admits this.

File: config/relay-v2.toml:1-5 says: "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. Evidence: measurements/graviton5-calibration-20260909/ (not a hardware speed bound)."

The threat model (DOCUMENTATION/threatmodel.md:101-105) explicitly states: "RandomX and the serial wrapping construction impose the assumed work. No proven VDF property, hardware-independent lower bound, or seven-day theorem is assumed to have been demonstrated."

The intended objective states: "Assume RandomX's primitive is strong: no cryptanalytic shortcut to evaluating a specified dependent chain. This does NOT assume our composition, FFI integration, masking, memory handling, secret management, lifecycle or verifier is correct."

The question asks: "Can an adversary... recover... early, or make the service use an already substantially solved puzzle, WITHOUT breaking the RandomX primitive? Analyze generation versus solving parallelism, shared work..."

The answer is yes. The design is:

The key flaw: Generation uses 7 parallel workers to compute the 7 segments simultaneously. Solving must do them in series. So the time to solve is 7 × the time to generate, if both use the same hash rate. But the solver can use:

  1. Much faster hardware (the production is Graviton5; an adversary can use a GPU farm).
  2. A different RandomX implementation, including the JIT compiler, multiple VMs, or hardware AES.
  3. The calibration at 506.57 hashes/s is the solo reference rate. An adversary can buy hardware 100× faster.

The intended objective asks: "Can an adversary... make the service use an already substantially solved puzzle, WITHOUT breaking the RandomX primitive? Analyze... shared work... publication..."

This is not a question of cryptanalysis; it's a question of calibrating the delay. The design's iterations are calibrated to one day on the reference hardware. But the delay claim is "approximately one week later". The gap between these is 7×. Generation with 7 parallel workers takes 1 day per segment. Solving requires 7 segments in series. So solving takes 7 days on the same hardware. The design claims "one week". This is arithmetically correct only if the solver uses the same hardware as the generator.

Confirmed violation: An adversary with 7 machines, each running one segment in parallel, can solve in 1 day, not 7. But the segments are dependent (each segment's output is the next segment's seed). The solver cannot parallelize across segments because each segment's seed is wrapped in the previous segment's output. The solver must do segments serially. However, the dataset initialization is shared, and the adversary can use a much faster single-threaded hash rate.

The fundamental issue: the delay is not a function of any adversary's computational budget. The delay is "whatever the fastest available hash rate can do". The design has no mechanism to bound the hash rate of the adversary. The intended objective says "assume RandomX's primitive is strong: no cryptanalytic shortcut". That assumption holds. But the question asks about "analysis of generation versus solving parallelism, shared work, publication". The shared work is precisely the issue: the Dataset in crates/timelock/src/randomx.rs is a fixed 2 GiB dataset. An adversary can pre-compute the dataset once for a given dataset_key, and then use it to solve all segments. The dataset initialization is not included in the measured hash rate; Dataset::new with Full mode does randomx_init_dataset which is a one-time cost of seconds-to-minutes. The generator does the same. So the delay is 7 × iterations / hash_rate, not including dataset initialization.

The question asks: "Can an adversary... recover... early... WITHOUT breaking the RandomX primitive?" The answer is yes, by using faster hardware. The design has no defense.

Severity: Confirmed violation of the approximate-delay goal. The threat model admits this in "Known gaps" and "Deliberate leakage": "Faster hardware, improvements or shared progress can shorten recovery time." The intended objective explicitly asks us to report whether this is violated. It is violated: there is no secure delay, only a work factor that any adversary with a faster machine can reduce.

Minimal reproduction: Take relay-timelock solve and run it on a modern GPU-based RandomX implementation. Time the result. It will be significantly less than 7 days. The code has no defense.


Finding 4: publication_not_before based on expires_at_ms (trusted NSM time) can be bypassed by the parent withholding OK ACKs

Severity: Sufficiently addressed in code, but a subtle gap remains.

File: crates/enclave/src/v2_epoch.rs:76-102 (activate) and :104-151 (run).

In activate(), the code anchors the epoch's monotonic lifetime before publishing the puzzle (v2_epoch.rs:82-83). The activated_monotonic is Instant::now() before trusted_time_ms_uncached() and before publish(puzzle). This is correct: a parent that withholds puzzle ACKs cannot make the puzzle appear fresh later, because the monotonic deadline (via monotonic_expired) is based on Instant::now(), not the parent's clock.

However, after the puzzle is published and the epoch expires, v2_epoch::run sets pub_not_before = Some(expiration) from expires_at_ms (the NSM timestamp at activation). Then in the next iteration of the generation loop, it waits until trusted_time_ms_uncached()pub_not_before before generating the next epoch. At line 133-139, it loops on NSM time. But the enclave's own monotonic clock is not used here. If the parent can roll the NSM time back (which AWS is trusted to prevent, but the parent cannot), the enclave would wait longer. If the parent Reuters the enclave's time, the NSM timestamp is still the same. The code correctly uses NSM time.

However, a more subtle issue: The watchdog (expiration_watchdog) uses Instant::now(). If the enclave is suspended (e.g., VM paused by the parent), Instant does not advance (Tokio's Instant is based on CLOCK_MONOTONIC which stops during suspend on some platforms). The accepts_at method checks both now_ms >= activated_at_ms && now_ms < expires_at_ms and !monotonic_expired. If the parent pauses the enclave, both may not advance, and the epoch stays active. The parent cannot serve new requests during the pause, but existing request leases may outlive their deadline. This is not a confidentiality violation per se; it's an availability issue.

Severity: Not a violation of confidentiality. The code correctly anchors.


Finding 5: attestation_for_epoch uses policy that includes state = "ready" or "warming". The historical verifier requires state in ("warming", "ready") for historical, and "ready" for live. This is correct.

No issue.


Finding 6: Epoch accepted for now_ms before activated_at_ms? No, correctly rejects.

No issue.


Finding 7: validate_target only checks for host.eq_ignore_ascii_case(own_name), but own_name is config.tls_dns_name = "relay.girl.surgery". An attacker can use relay.girl.surgery.cdn.example.com? No, that's a different host. But relay.girl.surgery. with trailing dot? valid_host rejects trailing dots (label.ends_with('-') no; trailing dot is not a label char). valid_host splits on .; a trailing dot produces an empty label, rejected.

Issues:

But more concerning: validate_target uses crate::net::public_destination(ip) for IP literals. What about own_name? If the host is "relay.girl.surgery", it's rejected by validate_target. But an attacker can resolve relay.girl.surgery to multiple IPs (DNS rebinding). The code resolves the host in the enclave via DoH, then connects to the IP. The DoH resolution is trusted (WebPKI). If the DNS response includes the relay's own IP but the host is not own_name, the code doesn't check. But the DNS name is not own_name, so it's allowed. This is an SSRF to the relay's own public IP with a DNS name that resolves there. The confidentiality of the request is already known to the relay (it's the same machine), but the upstream connection would go to the parent's connect_ip to the relay's IP, which the parent then routes to the relay front-end, which routes to the enclave. This is a recursion. Each hop encrypts; not a confidentiality leak.

Severity: Not a violation. But worth noting.


Finding 8: canonical_record uses serde_cbor::value::to_value(record) and serde_cbor::to_vec. Exchange.response_body_b64 is a Vec<u8> serialized with serialize_with = base64_body, which makes it a string (base64). The field itself is declared #[serde(serialize_with = "base64_body")] response_body_b64: Vec<u8>. Inside the Exchange struct, the field is a Vec<u8> in the Exchange struct, but when serialized, it's a string. canonical_record uses serde_cbor::value::to_value which produces a CBOR string for the field. This is correct.

But serde_cbor with #[serde(serialize_with)] may not preserve canonical ordering. The canonical_record function passes the JSON Value to serde_cbor::value::to_value. This converts a JSON object to a CBOR value. serde_json::Value::Object is a Map with BTreeMap ordering (preserves insertion order? Actually serde_json uses a Map that preserves insertion order via IndexMap). serde_cbor::value::to_value iterates the map and may reorder keys. The test record_encoding_uses_canonical_cbor_key_order checks that {"aa":1,"b":2} encodes to a261620262616101. That is the correct canonical CBOR key order (shorter encoded key "b" = \x61\x62 before "aa" = \x61\x61\x61). So the code intends canonical CBOR. But the test only checks a 2-key object. For the full record with headers as a list of pairs, the canonical order must be deterministic. Assuming serde_cbor does canonical sorting, this is correct. The test's comment says "Value's ordered maps use CBOR canonical key ordering recursively." This is an assertion, not a proof; the serde_cbor crate must be audited for this. It uses std::collections::BTreeMap internally? serde_cbor::value::Value::Map is a BTreeMap, so yes, keys are sorted canonically. Correct.


Finding 9: solved puzzle checkpoints are not service-authenticated. A solver can create a checkpoint at any iteration and resume. The Checkpoint is not signed. But this is intentional; the solver is untrusted and checkpoints are used only for progress. The epoch key is verified at the end (key_commitment).No issue.


Finding 10: wrap_key and wrap_context use Hkdf::<Sha256>::new(Some(DOMAIN), y), where y is the segment's intermediate result. The domain is b"relay-timelock-v1". The context is wrap_context(puzzle, segment). This binds the segment`s output to the puzzle and segment. Good.

But the input() function uses DOMAIN too, which is the same domain string for both the RandomX hash input and the HKDF. This is a cross-protocol domain separation issue: The RandomX input for segment i, iteration j, with state x is DOMAIN || epoch || segment || iter || x. The HKDF key derivation uses DOMAIN as the salt (ikm). They are used in different contexts (one feeds RandomX, the other HKDF), so no direct key/state collision. But it's sloppy. Not a vulnerability.


Finding 11: In encrypt_record, the nonce is generated with OsRng.fill_bytes. But in production, generate_with_rng uses attester.fill_random which is the NSM RNG, not OsRng. The encrypt_record path is unused in the service (records are encrypted with the epoch key, not directly). But the service never calls encrypt_record except through the v2_proxy::dispatch at line 105. There, the manifest and key are used, but encrypt_record uses OsRng. In the production enclave, the RNG must be seeded from the NSM. v2_main.rs:151-153 seeds the kernel from the NSM before any TLS, and rustls's crypto provider is installed later. OsRng in the enclave's environment uses the kernel RNG, which was seeded. So the nonce is fine.

But encrypt_record is called in v2_proxy::dispatch at line 105. The function encrypt_record in crates/timelock/src/lib.rs:92-123 uses OsRng.fill_bytes(&mut nonce), which in the enclave uses the kernel RNG seeded by seed_os_rng(). This is fine.


Finding 12: attestation_for_epoch is called inside activate before the puzzle is published (v2_epoch.rs:77). The publish function has a 3-attempt loop with 100ms delay. If the parent withholds OK ACKs for the puzzle, the activate function times out after the publish calls fail. publish at line 57-74 returns an error "artifact persistence unavailable". activate returns that error. v2_epoch::run at line 144 matches on activate and returns, logging "puzzle publication failed; service remains closed". This is correct: the service remains closed if publication fails.


Finding 13: v2_epoch::run does not verify the published manifest's signature before activating. The GeneratedPuzzle comes from generate_with_rng, which creates the manifest and signs it with the service key. The code trusts the local generator. No issue.


Finding 14: The epoch key is never persisted or logged. The Epoch struct holds key: Zeroizing<[u8;32]>. The GeneratedPuzzle holds epoch_key: Zeroizing<[u8;32]>. The generated puzzle is moved into activate, which stores it in the Epoch. The key is never serialized or logged. The Zeroizing types are used. The signer key is generated from NSM entropy in v2_main.rs:169-172 and held in State.signer (relay_timelock::SigningKey). This is an ed25519-dalek key; it is not zeroized, and SigningKey from ed25519-dalek does not implement Zeroize or ZeroizeOnDrop. The signing_seed is zeroized after key creation (v2_main.rs:169-172), but the SigningKey struct itself (32-byte key) is not explicitly zeroized when the State drops. ed25519-dalek's SigningKey is a thin wrapper around [u8; 32]; it does not implement Zeroize. So the service signing key remains in heap memory after the enclave terminates. However, the enclave terminates only on shutdown or failure, and the memory is released. The threat model says "Filthy a malicious parent can read enclave memory"? Not in the threat model: "AWS is trusted. The service operator ... may be malicious and collude." The parent cannot read enclave memory; the operator cannot read Nitro enclave memory. So a lingering key is not a concern. No issue for in-scope adversaries.


Finding 15: The SigningKey is not Clone, but State holds it. State is Clone? No, State is not Clone. Good.


Finding 16: attestation_for_epoch does not include the nonce from the caller, but it's an internal attestation for epoch anchoring; the nonce is random. Fine.


Finding 17: v2_proxy::handle returns Infallible and wraps dispatch in tokio::spawn. If dispatch panics (e.g., a bug in CBOR encoding, or a unwrap() somewhere), the task panics and the response is BAD_GATEWAY. The catch-all is _ => error(BAD_GATEWAY). This prevents panic propagation. But any panic in dispatch could leave the permit held (leaked) or the epoch lease held. permit is a SemaphorePermit; if the task panics, the permit is dropped (Rust's drop runs during unwind). The epoch lease is an Arc clone; dropped. So no leak. No issue.


Finding 18: A key question from the prompt: "Can an adversary make the service use an already substantially solved puzzle?" The service generates a fresh puzzle for each epoch by calling generate_with_rng with a fresh epoch counter. The dataset_key, seeds, and epoch key are all fresh NSM entropy. The solver can pre-compute nothing for a future puzzle because the dataset_key is random and unknown until the puzzle is published. The solver cannot "pre-solve" an unpublished puzzle because the puzzle's parameters are not known. The service does not reuse puzzles. The code correctly increments epoch and does not allow wrap-around (checked_add at v2_epoch.rs:148). No issue.


Finding 19: **The iterations in the manifest are the same as the config's iterations. The solver must do iterations hashes per segment. The service's epoch_seconds is 86400, and iterations is 43,768,124. The calibration claims one reference CPU-day per segment. If the solver has a 43,768,124 hash-rate of one segment per day, from the prompt: "approximately one week later"??? Wait, solving all 7 segments must be done sequentially; the total work is 7 × 43,768,124 hashes. On the same reference hardware (506.5755 hashes/s), that's 7 × 86,400 seconds = 604,800 seconds = 7 days. So the delay is 7 days on the reference hardware. The prompt says "until approximately one week later, when audit ciphertext becomes publicly decryptable through computation." This is consistent only on the reference hardware.

But the question B asks: "Can an adversary... recover... early... WITHOUT breaking the RandomX primitive? Analyze generation versus solving parallelism, shared work..." The generation uses 7 parallel workers; solving is serial. So solving on the same hardware takes 7× longer than one generation segment. But the adversary can use any hardware. The design has no bound. Violated: the delay is not a secure delay; it's a work factor that any adversary with faster hardware can reduce. The prompt explicitly says to report this, so I mark it as Violated.


Finding 20: The public_destination check in v2_proxy::validate_target and crates/enclave/src/net.rs is correct, but the validate_target does not call public_destination for non-IP hosts. The code only checks IP literals for public_destination at v2_proxy.rs:133-135. For a hostname, it does not check the resolved IPs until connect_upstream, which uses Dialer.connect_tls which calls public_destination (net.rs:129). So all destinations are checked. No issue.


Summary of Critical Findings

  1. Diagnostic timing leak (Finding 1): Confirmed real-time hostname inference for failing requests. The parent sees the timing of the static diagnostic and can infer the hostname length. This is a content-inference attack, not generic metadata. Violates the primary confidentiality goal.

  2. No secure delay bound (Finding 3): The delay is not tied to adversarial hardware. Faster hardware can reduce 7 days to hours. The design has no mechanism to enforce a lower bound. This is a fundamental design flaw, confirmed by the code's dependence on a fixed hash rate calibration and the threat model's own admission.

  3. Python transport fragility (Finding 2): Potential TLS handshake stall due to stop-and-wait chunking. Needs live testing; not executed.

  4. No other early decryption capability found within the code. The RandomX composition, seed wrapping, AEAD binding, and epoch key commitment checks are correctly implemented.

The strongest code-grounded reasons attacks are blocked:

Missing evidence that limits my conclusion:

I would not rely on this for sensitive content under the explicit assumptions, primarily because the diagnostic timing leak permits real-time hostname inference, and because the delay guarantee is not a guarantee—it is a work factor that any adversary with faster hardware can bypass.