Independent Pi/OpenRouter review: z-ai/glm-5.3
Source: a10323dede4413fbf295916b8ad12e3dbad7514e. Status: completed.
Independent security review — attested-relay v2 (SOURCE_COMMIT a10323d)
Method note: Static review of the frozen snapshot only. I executed no tests, builds, or measurements; every test referenced below is marked not executed. The deployed binary is attested-relay-enclave (crates/enclave/src/v2_main.rs per the Dockerfile entrypoint); I treated the legacy tlproxy-enclave/main.rs path and the tlock/age/drand sealing in crates/common as unreachable in the deployed EIF (only /attested-relay-enclave is copied into the enclave image stage).
Verdict summary
| Property | Verdict |
|---|---|
| A. Content confidentiality vs intermediaries/operator before the intended delay | SUPPORTED CONDITIONALLY (cryptography + Nitro isolation + RandomX-primitive assumption; metadata excluded, see F1/F7) |
| B. Early epoch-key/plaintext recovery or use of a pre-solved puzzle without breaking RandomX | SUPPORTED CONDITIONALLY — no code path found; delay magnitude is calibration-relative, not a proven bound |
| Availability / retention under malicious parent | VIOLATED in the documented sense (parent can permanently close the service and lie about durability) — not a confidentiality claim, but in scope as a defect of the deployment |
| Post-release provenance of individual records | INSUFFICIENT EVIDENCE / known gap (documented; confirmed by code) |
| Session integrity vs malicious front end | VIOLATED (availability/integrity only, not content confidentiality) — see F4 |
Question A — early content recovery
SUPPORTED CONDITIONALLY. The path from request to disclosure is:
- Inner TLS (client→enclave,
v2_proxy.rs:42): outer actors see only ciphertext chunks; the enclave is the TLS endpoint. The client verifies a fresh, nonce-bound, PCR0-pinned Nitro document binding the actual peer SPKI (verify.py:131–142, 52–107) before any application data — replaying someone else's attestation does not authenticate an attacker's endpoint (_bound_policy,verify.py:110–128). - Upstream fetch inside the enclave, TLS-verified there (
v2_proxy.rs:139–159); the parent carries only bytes to IPs (transport.rs:135–141). - Audit record: canonical CBOR, sealed with XChaCha20Poly1305 under
HKDF(epoch_key, manifest.id)with AAD binding epoch, sequence, and full signed-manifest digest (timelock/src/lib.rs:73–124), then persisted as ciphertext only (v2_proxy.rs:104–107). epoch_keyexists only in enclave memory and in AEAD-wrapped form in the public puzzle.
I found no path by which an in-scope intermediary (excluding AWS, excluding side channels) obtains request/response plaintext before the epoch key is recovered by puzzle computation. The conditionality is dominated by three things the code cannot fix: (i) the RandomX-primitive assumption itself, (ii) the absence of any hardware-independent lower bound (F1), and (iii) unproven absence of microarchitectural or native-library memory disclosure (F6).
Content vs metadata: the parent and front end learn destination IPs, timing, sizes, connection counts, and SNI whenever ECH is unavailable or falls back (net.rs:96–124). Note that in the deployed v2 the Mullvad/WireGuard relay is not configured at all — v2_main.rs:156 constructs Net::new(transport, /*relay_required=*/ false, vec![]), and relay.rs:89–98 therefore routes every upstream directly through parent CONNECT tunnels. The parent thus sees every upstream IP today, not only "while the relay is down." This is disclosed in threatmodel §6 but is worth stating as a code-level fact: hostname hiding rests entirely on ECH uptake.
Question B — early key recovery / pre-solved puzzle (review emphasis)
B1. Wrapping construction (timelock/src/lib.rs)
The construction is sound under the primitive assumptions:
y_i = RandomX^iterations(s_i)with domain-separated, position-tagged inputs includingepoch,segment,iteration(lib.rs:238–245) — no cross-segment or cross-epoch input aliasing.wrapped_keys[i] = AEAD(HKDF(DOMAIN, y_i, ctx_i); s_{i+1} or epoch_key)withctx_ibinding epoch, segment, iterations,dataset_key, andkey_commitment(lib.rs:246–261, 365–387).- Solving is strictly serial:
s_{i+1}exists outside the enclave only under AEAD keyed byy_i, andy_irequires the full segment-i chain. Unlimited parallelism does not help within a puzzle. Generation is 7-way parallel (~1 reference-CPU-day wall,relay-v2.toml:2–6); solving is ~7 reference-CPU-days serial. This asymmetry is exactly the intended one and I found no way to invert or narrow it. key_commitment = SHA256(epoch_key)leaks nothing usable; final verification (lib.rs:493–497) prevents any wrong-key acceptance.- Nothing but
seed_1anddataset_key(both required for solving) is published;y_i,s_{2..7},epoch_keynever leave the enclave before the wraps are opened by computation. - Light vs Full RandomX modes produce identical outputs (confirmed by the identical upstream test vectors,
randomx.rs:124–139), so mode choice affects only speed, not solvability.
B2. Seeds and entropy
All secret material comes from direct NSM fills with zeroized destinations and no fallback (attest.rs:43–62, lib.rs:301–325; the test at lib.rs:580–609 shows a failing entropy call aborts before any expensive work). The operator cannot bias seed selection, cannot replay NSM entropy, and cannot inject a chosen manifest: there is no external input anywhere into puzzle material — generate_with_rng consumes only epoch counter, measured iterations, signer, mode, and NSM bytes.
B3. Checkpoints
Checkpoint (lib.rs:402–456) is a progress hint, not a trust object: validate() checks only self-consistency (its own checksum) plus bounds; a forged checkpoint with a self-consistent digest but wrong x produces a wrong y_i and fails the segment AEAD (lib.rs:262–284) or the final commitment. Two properties follow:
- Checkpoint poisoning cannot yield a valid wrong key (AEAD + commitment catch it).
- Correct checkpoints do allow skipping completed work — but producing a correct mid-chain
xrequires having done that work. This is the inherent "shared work" property: once anyone solves, everyone can have the key. That is consistent with the intended "public decryptability" semantics, but it means the delay is min-over-all-adversaries, not per-adversary.
B4. Epoch transitions and malicious parent scheduling
This is where I looked hardest. The relevant ordering (v2_epoch.rs):
- The next puzzle is fully generated while the current epoch serves, but its bytes leave the enclave only inside
activate(). - The generator blocks until NSM time reaches the previous epoch's expiry before publishing (
v2_epoch.rs:132–140), and when NSM time is unavailable it waits rather than publishing — early erasure by the monotonic watchdog cannot enable early future publication (v2_epoch.rs:107–110). activate()anchorsactivated_monotonic/activated_at_msbefore the first puzzle disclosure (v2_epoch.rs:82–83), so a parent withholding ACKs can extend nothing: either activation completes within the 60 s window with a lifetime anchored at disclosure time, or it fails and the puzzle is discarded (v2_epoch.rs:144–147). A discarded puzzle decrypts nothing (no records were ever sealed under it), so harvesting withheld-ACK puzzles gives the parent nothing but a signed artifact with no corresponding records.- The e2e test for exactly this rebasing scenario exists (
tests/test_v2_e2e.py:345–361) — not executed.
Remaining scheduling vectors and their outcomes:
| Vector | Outcome |
|---|---|
| Parent withholds record/puzzle ACKs | Activation fails → generator task returns permanently (v2_epoch.rs:146) → service closed until operator restart. Availability only (F3). |
| Parent ACKs without storing | Client receives success headers; artifacts may never exist publicly. Retention/provenance lie, documented (F2). Not early confidentiality. |
| Parent kills/restarts enclave | Keys lost (memory only); nothing reused; epoch counter restarts at a random 63-bit value (v2_epoch.rs:105) — no replay surface. |
| Parent throttles enclave CPUs / chooses CPU count at launch | Generation slower → longer delay, fail-closed gaps. Cannot shorten anything; generation parallelism is capped at 7 threads regardless of vCPU count. |
| Parent manipulates time | Time comes from NSM attestation timestamps (attest.rs:86–98), trusted via AWS assumption; no parent-reachable clock input exists. |
| Parent replays old host messages | publish opens a fresh connection per attempt and requires exact OK\n (v2_epoch.rs:57–74, transport.rs:35–39); frames are fresh bytes each time. No replay surface found. |
Operator runs dev mode (--dev) |
Attested policy reports mode: "dev", graviton5_verified: false; _bound_policy and client.py:155–158 reject. Only defeats non-verifying clients, who have no guarantee anyway. |
| Pre-solved puzzle induction | No input channel exists into manifest contents; the serving epoch is always the one just generated internally. No path found. |
B5. Leases, reuse, sequence numbers
- Expired-epoch leases:
dispatchclones the epoch Arc (v2_proxy.rs:82) and may finish sealing up torequest_timeout_seconds + 30 safter expiry. The lease is bounded only by these timeouts, not by an explicit expiry re-check — but since the puzzle was public for the whole serving period, holding the key slightly longer discloses nothing early. - No key reuse across epochs: fresh NSM
epoch_keyper generation; record AAD binds epoch +puzzle_id+ sequence, so epoch-N records cannot decrypt under epoch-M keys (lib.rs:125–156, tested atlib.rs:631–636). - Sequence exhaustion cannot wrap (
v2_proxy.rs:122–125,fetch_update+checked_add); nonce is a random 24-byte XChaCha nonce — collision negligible even with a per-epoch key.
Conclusion B: SUPPORTED CONDITIONALLY. Under the RandomX-primitive assumption and AWS/Nitro trust, the earliest any party — including the operator, who has no informational advantage — can decrypt an epoch's records is ~7 reference-CPU-days of serial work starting at puzzle publication.
Concrete findings
F1 — Delay is calibration-relative and publication-relative (design; disclosed but load-bearing).
relay-v2.toml:2–6 calibrates 43,768,124 iterations ≈ 1 reference-CPU-day per segment on Graviton5. Nothing in the code bounds an adversary's hash rate. An adversary with 10× the reference machine recovers keys ~17 h after publication. Additionally, delay runs from puzzle publication, not per request: with 24 h epochs, records sealed in the epoch's last hour become decryptable ~6 days after sealing (v2_epoch.rs:82–101 order: anchor → publish → serve for 24 h). If "approximately one week" is a hard requirement, the late-epoch shortfall (~14%) should be treated as a parameter, not a rounding error. Not a violation of the documented objective; it is the boundary of the objective itself.
F2 — Parent persistence ACK is a lie-able channel (documented; code-confirmed).
v2_epoch.rs:57–74 accepts a 3-byte OK\n from the untrusted parent as "publication acknowledged," and v2_proxy.rs:107–116 returns success headers (x-attested-relay-record) on the same basis. A malicious parent can ACK and discard, so the ciphertext, puzzle, and evidence may never be publicly retrievable even though the client was told a record name. Impact: retention/availability/provenance, never early confidentiality. The code comment and threatmodel §5 acknowledge this; the client (client.py) does not itself fetch-and-retain the puzzle artifact as a receipt. Suggested hardening: have the verifying client immediately fetch the named puzzle/record from the public archive and keep it.
F3 — Any activation/publication failure permanently kills the epoch generator (availability).
v2_epoch.rs:128 and :146 both return on a single failed generation or activation; nothing retries. A parent that withholds one ACK (or one that simply lets a transient occur) converts the service into a permanently closed relay until an operator restart — and the operator is assumed malicious, so "the operator restarts it" is not a mitigation. Impact: liveness DoS fully within parent capability; fail-closed, no confidentiality impact.
F4 — Relay session is a bearer token visible to the front end (integrity/availability).
The /relay transport (http_relay.rs:166–219) authenticates sessions solely by reqid (32–64 hex chars) carried in the URL. Cloudflare — in scope as malicious — observes every client's reqid, seq, ack and can therefore inject chosen bytes into a victim's inner TLS stream or race the victim's polls to consume responses. Impact boundary: the injected data is still inside the victim's inner TLS, so nothing about the victim's chosen content is revealed; the attacker gains DoS, request forgery attributed to the victim's audit trail, and response theft for requests it itself injected. This does not breach content confidentiality, but it is a concrete in-scope integrity defect. A per-session secret established inside inner TLS (or an HMAC over packets keyed by a TLS-derived secret) would close it.
F5 — Record nonces come from the kernel RNG, not directly from NSM.
lib.rs:103–104 uses rand::0.8 OsRng. The enclave mitigates this by double-reseeding the pinned 4.14.256 kernel CRNG from NSM before TLS/serving (attest.rs:67–82, 197–214), which is a thoughtful treatment, but it makes the kernel a link in the nonce chain that direct NSM fills would avoid. With XChaCha's 192-bit nonce, even a partially predictable nonce is not exploitable; severity: informational, conditional on kernel trust already implied by the seeding design.
F6 — Future epoch key resident in memory up to ~a day before publication (memory-handling exposure window).
v2_epoch.rs:118–151 generates epoch N+1 (including its epoch_key, GeneratedPuzzle) immediately after activating epoch N and holds it until N's expiry. This roughly doubles the window during which a side channel or memory-disclosure bug in native RandomX/TLS/aws-lc hands the adversary a key whose puzzle is not yet public — and worse, recovering the future key early would break the delay property for that epoch's records entirely. The Zeroizing wrappers cover Rust-side buffers only; RandomX scratchpads and aws-lc allocations are not zeroized. Within the stated assumptions (no side channels, Nitro isolation) this is not an attack; it is explicitly flagged because "memory handling" is a review target and this is the largest exposure surface the design itself creates.
F7 — No outbound relay configured: parent sees all upstream IPs today.
v2_main.rs:156 passes relay_required=false, empty relay list; relay.rs:85–109 then routes upstreams directly through parent CONNECT tunnels. Combined with SNI visibility on ECH fallback (net.rs:96–124), destination identification by the parent is the practical default. Documented (threatmodel §6), but the deployment currently does not exercise the WireGuard machinery at all; treat hostname-privacy as best-effort.
F8 — is_ech_error matches error strings (net.rs:253–256).
Behavior (fallback to plaintext SNI) depends on error-text fragments across library versions. Correctness/fragility nit; worst case is ECH retry logic misfiring (metadata only).
F9 — Vendored i18n-embed-fl patch is a supply-chain input to the measured binary.
Cargo.toml:35–38 patches a proc-macro crate from vendor/. It is justified (HashMap-order determinism) and ultimately measured by PCR0, but reproduction evidence proves only self-consistency of this source, not its safety. It deserves the same review scrutiny as application code; I was not given vendor/i18n-embed-fl/PATCHED.md.
F10 — NSM amplification via /v1/status (availability nit).
policy() calls trusted_time_ms_uncached() — a full NSM attestation — per status request, outside the attestations semaphore (v2_main.rs:73–98; the semaphore guards only /v1/attestation). Up to max_connections (128) concurrent unauthenticated inner-TLS callers can contend on the NSM. The deliberate uncached design is correct for admission; the missing bound is minor.
Strongest code-grounded reasons the attacks are blocked
- Serial wrap chain with domain-separated inputs (
lib.rs:238–284, 365–387): each seed is only obtainable through the previous segment's full chain; AEAD undery_imeans no partial-information shortcut exists short of breaking ChaCha20Poly1305/HKDF/RandomX. - No external input into puzzle material: the serving manifest is constructed exclusively from NSM entropy and internal counters; there is no code path by which a parent, client, or network input influences seeds, wraps, iterations (config is
include_str!-measured,v2_main.rs:137), or the epoch key. - Publication anchoring and fail-closed activation (
v2_epoch.rs:76–102, 132–147): puzzles become solvable no earlier than the previous epoch's NSM-time expiry, lifetimes are anchored before first disclosure, and withheld ACKs discard the puzzle rather than rebase it. - Attestation binding: fresh nonce + 5-minute window + PCR0 pin + SPKI-to-actual-peer binding (
verify.py:52–142) defeats replay and mis-termination of the inner TLS; signed policy pins the work parameters so a low-work puzzle requires a new PCR0, which clients reject. - Record binding: per-epoch HKDF key, AAD over epoch/sequence/manifest-id, checked-add sequence counter — no cross-epoch or replay decryption.
Missing evidence limiting the conclusions
- No executed verification. All Rust/Python tests, the e2e scenarios (including the delayed-ACK rebasing test), the CI reproduction run, and the Graviton5 calibration are taken as claims in files; I ran none of them.
- No hardware-independent timing proof, and none claimed: the "one week" figure is a single-machine calibration with an explicit "not a hardware speed bound" caveat (
relay-v2.toml:5). - Side channels and native code: aws-lc, rustls, RandomX C++, the pinned 4.14 kernel, and the FFI layer (
randomx.rs, including the hand-rolledunsafe impl Sync for Datasetjustified by upstream read-only sharing) are unreviewed here for memory-safety and timing leakage; Nitro attestation measures code, not behavior. - Not supplied:
crates/clisource,vendor/i18n-embed-fl/PATCHED.md,build/ci/build.sh/install-nitro.sh/verify.sh/evidence.py, the S3 uploader, and the measurements directories. I cannot independently assess the CI verification chain, the reproducibility claims, or retention configuration beyond the documentation's own admission that Object Lock is not enabled and per-record receipts are absent. Please supplybuild/ci/*if the reproduction-evidence chain should be reviewed as code rather than as documentation.
Would I rely on this for sensitive content under the explicit assumptions?
Conditionally yes for content confidentiality, with eyes open. Against the enumerated intermediaries (parent, front end, network, DNS, archive, other clients, external solvers), and trusting AWS/Nitro plus the RandomX primitive, I found no route to request/response plaintext before the puzzle is computable, and no route to make the service serve under a pre-solved puzzle. The reliance is bounded by: (a) the delay is only as strong as the slowest serious solver's hardware — treat "one week" as roughly 6 days for late-epoch records and as shrinking with hardware improvements; (b) the parent can silently destroy the archive story (F2) and permanently kill service (F3); (c) destination-level metadata is currently exposed in full (F7); (d) any memory-disclosure or side-channel bug in the sizeable native surface defeats everything, and the future-key residency window (F6) makes that the sharpest unproven edge. For content whose disclosure a week early would be catastrophic, the residual risks (b)–(d) argue for not relying on this alone; for content whose disclosure a week early is merely harmful, the construction, as far as the supplied source shows, does what the threat model honestly says it does — no more.