The Build Is Part of the Wallet
On July 31 we published an analysis of the COLDCARD entropy failure. It closed with a ten-question checklist for reviewing any wallet’s randomness path, and it argued that the review that matters follows the entropy budget end to end, from physical source to final key material, in the production build.
A checklist like that earns its keep when you run it on yourself.
The same afternoon, we ran it against Cryptograph’s own shipped artifacts, the built framework the App Store build links, rather than the source tree. Question three asks: can symbol resolution bind the caller to a different provider?
Ours could, and in the wallet-generation path it already had.
Our own question three failed
The claim under test was the one in our own article: wallet entropy on Apple Watch comes from SecRandomCopyBytes, through a five-line provider that aborts on any failure. Source review supported the claim. The provider file exists, defines strong random_buffer and random32 symbols, and is written to override the weak defaults in the vendored trezor-crypto library.
The binary did not support the claim. The provider file was never compiled into any Apple slice of the framework. The build’s source lists did not include it, and the Swift package manifest explicitly excluded it. With no strong definition present anywhere, the linker did what linkers do: the weak fallback in trezor-crypto satisfied the symbol. That fallback reads /dev/urandom.
The check that settled it was one command against the built archive:
$ nm -g -o libWalletCore-watch.a | grep _random_buffer
rand.c.o: T _random_buffer # the trezor fallback, the only definition
bip39.c.o: U _random_buffer # the mnemonic caller
No provider object in any slice. The disassembled fallback imported open, read, and abort, and carried the string /dev/urandom. It did not import SecRandomCopyBytes.
Two things are true about this finding, and both matter.
It was benign in effect. On Apple platforms /dev/urandom is served by the same kernel cryptographic random number generator that backs SecRandomCopyBytes, the one seeded from the Secure Enclave’s hardware entropy source. The fallback is also fail-closed: it aborts on open failure and on short reads. Every wallet ever generated received full-strength kernel entropy through a different door. No user needs to take any action, and no seed is affected.
It was identical in mechanism to the failure we had just written about. A source file expressed the intent. The build did not carry the intent into the binary. Symbol resolution silently substituted a different provider, and every layer above it kept working, so nothing looked wrong. COLDCARD’s substitute generator was weak and ours was strong, but that difference was luck, not design. Our June review of this path had recorded the opposite conclusion, an inference from reading source that was never checked against a built artifact. The article you are reading exists because we do not want to rely on luck twice.
Proof moved into the binary
The first fix was the obvious one: compile the provider into every Apple slice and rebuild. The more important change was to the definition of done. A claim about the entropy path is now considered true only when a verifier proves it against the built artifact.
That verifier now runs after every framework build, on every slice, before the artifact is accepted into the pinned dependency manifest. For each slice it proves three properties: the intended provider object is the only strong definition of the entropy symbols, that object imports SecRandomCopyBytes, and no object in the wallet-generation path contains the string /dev/urandom. It does not stop at archive membership, because an object can sit in an archive and still lose the link. A production-equivalent link probe confirms which object actually satisfies the symbol.
The verifier also carries a negative regression. It removes the provider object and requires the build to fail. A verifier that has never been watched failing is a verifier you are trusting on faith, which is the habit this whole episode exists to break.
Then the invalid build became impossible to link
Verification after the fact still leaves the invalid configuration representable. For a week, the wrong build was a thing that could exist and then get caught. The stronger position is that it cannot exist.
So the weak fallback is now compiled out entirely on Apple platforms. The guard lives in the source file itself rather than in build configuration, so a future refactor of source lists or build targets cannot quietly hand the fallback back to the linker. The Apple provider is now the only object in any Apple archive that can satisfy the entropy symbols, and omitting it produces an unresolved-symbol link failure, at the earliest stage the toolchain can fail.
Android got one provider, with no conditions in it
The Android entropy path had a different weakness: flexibility. The bridge into the native signing core chose its entropy source conditionally, using a Java-side secure random provider when a JVM was attached and other paths when one was not. Every branch was defensible. The number of branches was the problem, because every branch is a configuration that has to stay correct forever.
The bridge is gone. In its place is a single unconditional provider: the kernel’s getrandom(2) system call, with a retry on interruption, a loop on partial progress, and an abort on any failure. There is no JVM dependency, no fallback, and no configuration in which the provider behaves differently.
The Android verifier proves the same class of properties on the built shared library: the entropy symbols resolve to the new provider, getrandom appears as a dynamic import, and no runtime-lookup escape hatches remain in the entropy path. It then confirms that the stripped library actually packaged into the app is, segment for segment, the same code that was verified unstripped. The artifact that passes review and the artifact that ships are checked to be the same thing.
Every consumer of randomness got the same treatment
Wallet generation is the loudest consumer of randomness, not the only one. The audit that followed walked the rest of them.
Protocol challenges on Apple platforms now fail closed. A handful of call sites zero-initialized a buffer, requested random bytes, and continued on error, which meant an RNG failure could downgrade a challenge to zeros instead of stopping the operation. Randomness failures at those sites now stop the operation.
Deterministic-RNG and fixed-nonce test hooks were removed from shipping cryptographic APIs. Test seams belong in test builds. A shipping binary should not contain an API whose purpose is making randomness predictable.
Signature nonces were pinned and are now attested. Cryptograph’s signing core uses deterministic RFC 6979 nonces for ECDSA, which removes nonce randomness as a failure class for those signatures. That mode is now asserted by a probe against the shipped machine code of every slice, not assumed from a build flag.
The shielded-pool signing path got the same discipline: its randomness contract is now explicit and verified against the built artifact rather than described in a comment.
Checking upstream became routine
Reviewing an upstream project after an incident catches whatever that project got wrong. It says nothing about drift already sitting quietly in your own tree. So diffing against the upstream wallet-core project became a standing practice instead of a one-time response to COLDCARD, and the first pass under that practice turned up something unrelated to entropy generation.
The vendored BIP39 module cached the last four mnemonics, passphrases, and derived seeds it processed, in plaintext, in a static buffer that lived for the life of the watch app process. A function existed to clear that buffer. Nothing in our code called it.
The severity is modest. Reading that buffer requires reading process memory on an Apple Watch, already inside the boundary the rest of this architecture defends. The cache also bought nothing in how the wallet is actually used: its only benefit is skipping a repeat derivation when the identical mnemonic and passphrase construct a wallet twice in one process lifetime, which normal use does not do. Upstream reached the same conclusion in February and turned the cache off. We had not picked that change up until we started checking on a schedule instead of after the fact. We have now, and confirmed the retaining structure is absent from the compiled binary rather than trusting the source line alone.
The link step is an attack surface of its own
Following the entropy bytes through the build kept leading to the same place: the linker. So the wave widened to cover how the Android native signing library is linked at all.
The library links two Rust signer archives that share hundreds of global symbol names, almost all of them byte-identical compiler-runtime objects that Rust embeds in every static library. The old link handled this with a flag that suppresses duplicate-definition errors globally. That flag makes builds succeed. It also means the linker is free to resolve any contested symbol to whichever definition it encounters first, and nobody is checking which one won.
The flag is gone. A gate now scans both archives before linking and allows a duplicate only when the colliding members are byte-for-byte identical. The one real collision was given a single intentional owner, and the build proves the intended definition is the one in the linked output. The library’s exported symbol table is checked against an explicit allowlist, its runtime dependencies are checked as an exact set, and the build writes a provenance record naming the archive, object, and hash behind every exported symbol. The checks run again on the stripped artifacts, because stripping is one more transformation between the thing you verified and the thing you ship.
Auditing the auditors
A verifier that can fail open is worse than no verifier, because it manufactures confidence. So the verifiers themselves were audited, adversarially, on the assumption that their earlier passes were not trustworthy.
The audit rebuilt the evidence from the exact cached artifacts, re-proving every claim slice by slice. It ran the negative cases: a poisoned provider object, with the fallback’s telltale string embedded, must fail the check; a pre-guard archive with the provider deleted must fail to link. And it found a real defect. One verifier ran its checks through a shell pipeline that could swallow a failing step, letting the script report success when part of its evidence had not been produced. The verifier now fails when any step fails, and it carries fixtures that keep it honest about that.
The audit’s other discipline was refusing to let a skipped check read as a passed one. Where a behavioral probe could not run in the audit environment, the verdict says so explicitly and defers that evidence to the release process, rather than rounding partial proof up to a green checkmark.
The release process is where all of this now converges. Each release lane runs every verifier itself and writes a single machine-readable attestation: the source revision, the dependency manifest snapshot, each verifier’s result, and the hashes of both the verified artifacts and the uploaded bundle. The record also states what it does not claim, and it is written even when the answer is failure.
Several models, none trusted alone
The work described above was performed and reviewed by a panel of AI models, under human direction and with human sign-off. We wrote about this way of working in June, in Model, Interrupted. The wave after the COLDCARD advisory is the largest exercise of it so far: since our 1.2.0 release, several hundred commits across dozens of workstreams, with a standing rule that no change lands until a model other than its author has reviewed it adversarially. That rule produced 67 rounds of review fixes on this wave alone, from a rotating roster that currently includes Claude Opus, GPT, Gemini, and Kimi K3.
The roster is deliberate. Models from different labs fail differently. One is strong on local code mechanics, another on protocol reasoning, another is stubborn about threat models, and their disagreements are where the findings live.
Kimi K3 did two distinct jobs this wave. It implemented much of the binary verification work itself, including the artifact verifier and the audit of the verifiers described above. Separately, it ran an authorized defensive review of everything merged since our June audit baseline, re-verifying the earlier fixes at code level, tracing the trust boundaries added since, and re-running the dependency audits. An earlier white-hat evaluation under the same model had already produced a working proof of concept against the legacy Android recovery parser, where a forged version byte could truncate and silently strip the key-stretching pepper from a backup artifact. That parser now fails closed, with a regression test built from the proof of concept.
Fable, whose June review we described in Model, Interrupted, is back on the roster. Its pass this month was different in kind from the per-change reviews. It read the whole wave together, on the theory that individually sound changes can still disagree at the seams. That is where it found its two contributions: a defense-in-depth gate that one experimental transport enforced on the watch while its sibling transport enforced it only on the phone, and a manifest checker that accepted a dependency with no recorded artifact hash, a silent exception to exactly the pinning discipline the rest of the wave was building. Both are closed. Per-change review checks the pieces. This kind of pass checks the set.
No model’s finding is accepted on authority. Candidate issues are reproduced against the code, and fixes are verified against the built artifacts, before anything ships.
The lesson, one level up
Our July post argued that entropy is a systems property: every component can look correct while the system fails to preserve the property that matters. This month taught us the same thing about security claims themselves.
A claim lives in a sentence, in an audit note, in a source file. The wallet lives in a binary. Between the two sit the compiler, the preprocessor, the linker, the stripper, and the packaging step, and every one of them is capable of quietly deciding the question your review thought it had settled. COLDCARD’s build certified an intent its binary did not honor. Within hours of writing about it, we found the same shape in our own build.
So the checklist no longer lives in a blog post. It runs as code, against every artifact, on every build, with negative cases that prove it can still fail, and an attestation that records what it proved and what it did not. Wherever possible, the invalid configuration is made unrepresentable rather than detected, so there is nothing left for a refactor to silently select.
The trust boundary we described in July has not moved. Apple supplies the entropy, and we consume it through one supported API with no fallback of our own. What changed is the standard of evidence for every sentence in that description. The binary is where a claim becomes true or false, so the binary is where we now check.
The Cryptograph Team