Read this first
Five findings in relaystr/ndk, the Dart SDK for Nostr. The worst is a licensing defect: a GPL-3.0-only crate compiled unconditionally into an MIT-licensed SDK. Next to it, a quantum-secure signer implementing the pre-standard round-3 CRYSTALS submission instead of FIPS 204, two memory-safety issues in the Rust FFI, and signing keys that could not be restored from a mnemonic. All five are fixed, merged in pull request 712 and 713, and released in ndk 0.9.0 on 20 August. The full review is at quantakrypto.com/audits/relaystr/ndk.
We started reading this SDK because of its post-quantum code. The finding that mattered most had nothing to do with cryptography, and a cryptographer reviewing only the algorithms would have walked straight past it.
A licence that travels
packages/ndk/rust depended on crystals-dilithium, which is GPL-3.0-only. NDK itself ships under MIT.
On its own that is a question for a lawyer. What made it a finding is that the dependency was not optional at build time. build.dart compiled it unconditionally, and the ordinary Schnorr verifier that every Nostr client needs lived in the same src/lib.rs as the quantum-secure signer. So an application that never touched a post-quantum function still linked the GPL-3.0-only object code, because there was no build in which it did not.
Statically linking GPL-3.0-only code makes the combined binary a derivative work. Every downstream application was therefore obliged to distribute under GPL-3.0, which is a hard problem for a closed-source consumer and is widely held to be incompatible with App Store distribution terms. An MIT badge on the repository said none of this.
The fix is a swap rather than a rewrite: fips204 is MIT OR Apache-2.0 and implements the standard. It closes the licence problem and the next finding at the same time.
A signer nobody else could verify
The quantum-secure signer implemented round-3 CRYSTALS-Dilithium, the pre-standard submission, rather than FIPS 204 ML-DSA. NIST changed the algorithm during standardisation. The keys and signatures the SDK produced were internally consistent and could not be verified by any standards-compliant implementation.
This is the failure mode that worries us most in post-quantum work, and we have now seen it twice in three reviews. Nothing is broken in the sense a test would catch. The code runs, the signatures verify against themselves, and the interoperability failure only appears when a second implementation shows up, which for a signing scheme is usually years after the keys were issued.
Three more from reading the same file
Two memory-safety defects and one key-recoverability gap turned up in the Rust FFI while we were reading it for the licence question.
- The buffer helper handed out a pointer from a
Vecand the free path reconstructed it withVec::from_raw_parts(data, len, len), which is undefined behaviour unless capacity equals length, and nothing aboutVecguarantees that. It held for every value passed at the time, so this was a latent trap rather than a live bug: the next contributor to build an output withpushwould have introduced heap corruption in the free path with no compiler diagnostic. - Freed secret-key buffers were released without being wiped, so a secret survived in the allocator, recoverable from a core dump, a swap file or a later heap read.
- Signing keys could not be restored from a mnemonic, which for a Nostr identity means an identity that cannot be recovered on a new device.
Fixed, merged, and this time released
All five were fixed upstream and merged on 18 August. On 20 August pub.dev published ndk 0.9.0, and we checked the published archive rather than the changelog: crystals-dilithium is gone and fips204 = "0.4.6" is in its place, the free path zeroizes before releasing, the buffer helper calls into_boxed_slice so capacity and length agree by construction, and the ML-DSA key seed now derives from the BIP-39 seed, domain-separated per algorithm and account.
That last detail is a design choice worth naming: the signing key is a sibling of the secp256k1 key rather than a child of it, so compromising the Nostr private key does not hand over the post-quantum one.
A note on the scan results
Our qScan run against the published package reports five results, all high, and none of them is a defect. Four are ECDH in the NIP-04 implementation and one is the secp256k1 dependency. Nostr identity is secp256k1 by protocol definition and NIP-04 is a published NIP, so a correct implementation shows exactly this.
We are publishing that number alongside the review because a scan result table without its reading is worse than no table. What it measures is the protocol's quantum exposure, not the SDK's workmanship.
The point
A post-quantum review that only reads the cryptography will miss the thing most likely to hurt the people shipping it. The licence of a crate, the build flag that makes it unconditional, and the standard a signer actually implements are all decisions taken outside the algorithm, and all three propagate to every downstream consumer without any of them being asked.
This review is now closed, which is the outcome we want and the rarer one: found, fixed, merged, and in a published release a reader can install today.
References
- The full review at quantakrypto.com, with every finding and its evidence
- Pull request 712 and 713 on github.com/relaystr/ndk
- ndk 0.9.0 on pub.dev
- FIPS 204 at csrc.nist.gov
If you ship an SDK with post-quantum code in it, the licence of your dependencies is part of your security surface. Talk to us if you want a second pair of eyes on yours.