Skip to content

Commit 4100dfa

Browse files
committed
fix!: replace GPL-3.0-only crystals-dilithium with fips204 (ML-DSA)
The signer used the `crystals-dilithium` crate, which implements the round-3 CRYSTALS submission. NIST changed the algorithm during standardisation, so Dilithium and ML-DSA are different schemes with different wire formats. Keys and signatures produced here could therefore be verified only by this code — no FIPS 204 implementation can read them, and vice versa. For a signature scheme whose entire purpose is letting someone else check your work, that is a defect rather than a preference. `fips204` also resolves a licensing problem. `crystals-dilithium` is GPL-3.0-only; this package is MIT and builds as cdylib/staticlib, and `hook/build.dart` compiles it unconditionally for every dependent app — with the ordinary Schnorr verifier living in the same library, so even apps that never touch post-quantum code link it. Statically linking GPL-3.0-only code makes the combined binary a derivative work, which obliges every downstream app to ship under GPL-3.0. That is a hard problem for closed-source consumers, and GPL-3.0 is also widely held incompatible with App Store distribution terms. `fips204` is MIT OR Apache-2.0 and imposes none of it. Also adds seed derivation. Keys were random-only, so a signing key could never be restored from a mnemonic — losing it lost the identity permanently. `qs_derive_keypair_from_seed` derives from a 64-byte BIP-39 seed via HKDF-SHA256 with `nip-pqc/v1/ml-dsa-<level>/<account>`, making the key a sibling of the secp256k1 key rather than a child: breaking secp256k1 does not reach it, and one mnemonic restores both. A 32-byte secp256k1 private key is rejected as input, because deriving from it would be circular. `level` now takes the ML-DSA numbers (44/65/87). The Dilithium values 2/3/5 are rejected rather than remapped, so an un-updated caller fails loudly instead of silently receiving different security properties than it asked for. Existing Dilithium keys do not carry over; they could never have interoperated, and this is shipped as an experimental signer. Two safety fixes found while auditing this code: - `write_buffer` leaked a Vec recording only its length, while `qs_free_buffer` reconstructed it with `Vec::from_raw_parts(data, len, len)`. That is undefined behaviour whenever capacity exceeds length. It holds for every value passed today, so this was a latent trap rather than a live bug — the next contributor building an output with `push` or `extend` would have introduced heap corruption in the free path with no compiler diagnostic. `into_boxed_slice` reallocates to the exact size. - `qs_free_buffer` now zeroizes before deallocating. These buffers carry secret keys, and a freed-but-unwiped secret is recoverable from a core dump or swap file. Tests pin an ML-DSA-87 public key byte-for-byte against `@noble/post-quantum` from the same derived seed, which is the interoperability the previous implementation could not have satisfied. BREAKING CHANGE: `level` now takes the ML-DSA parameter numbers (44/65/87) instead of the Dilithium ones (2/3/5), and the old values are rejected rather than remapped. Existing quantum-secure keys and signatures do not carry over: they were round-3 CRYSTALS-Dilithium, which no FIPS 204 implementation can read. The quantum-secure signer is experimental and this is the only way to make it interoperable.
1 parent 4e28d2e commit 4100dfa

10 files changed

Lines changed: 460 additions & 277 deletions

packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/// Quantum-secure event signer with platform-specific implementations.
22
///
33
/// On native platforms (Android, iOS, Linux, macOS, Windows), this uses FFI
4-
/// to call Rust code for CRYSTALS-Dilithium signing.
4+
/// to call Rust code for ML-DSA (FIPS 204) signing.
55
///
66
/// On web platforms, this exports a stub that throws [UnsupportedError].
77
library;

packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_native.dart

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import '../../../domain_layer/entities/pending_signer_request.dart';
99
import '../../../domain_layer/repositories/event_signer.dart';
1010
import '../../../src/rust_lib.dart' as rust_lib;
1111

12-
/// Holds a Dilithium keypair (public key + full keypair bytes for signing).
12+
/// Holds an ML-DSA keypair (public key + full keypair bytes for signing).
1313
class QsKeypair {
1414
final Uint8List publicKeyBytes;
1515
final Uint8List keypairBytes;
@@ -23,9 +23,9 @@ class QsKeypair {
2323
}
2424

2525
/// An implementation of [EventSigner] that uses quantum-secure
26-
/// CRYSTALS-Dilithium signatures via native Rust FFI.
26+
/// ML-DSA (FIPS 204) signatures via native Rust FFI.
2727
class QsRustEventSigner implements EventSigner {
28-
/// The Dilithium security level (2, 3, or 5).
28+
/// The ML-DSA parameter set: 44, 65 or 87.
2929
final int level;
3030

3131
late final QsKeypair _keypair;
@@ -36,7 +36,7 @@ class QsRustEventSigner implements EventSigner {
3636
/// Creates a [QsRustEventSigner] from an existing [QsKeypair].
3737
///
3838
/// Use [QsRustEventSigner.generate] to create a new keypair first.
39-
QsRustEventSigner({required QsKeypair keypair, this.level = 2})
39+
QsRustEventSigner({required QsKeypair keypair, this.level = 87})
4040
: _keypair = keypair;
4141

4242
@override
@@ -48,21 +48,22 @@ class QsRustEventSigner implements EventSigner {
4848
@override
4949
Iterable<String> get signerTransportRelayUrls => const <String>[];
5050

51-
/// Generates a new Dilithium keypair.
51+
/// Generates a new ML-DSA keypair.
5252
/// Its only added here for testing purposes!
5353
///
54-
/// [level] selects the security level: 2 (~AES-128), 3 (~AES-192), or 5 (~AES-256).
54+
/// [level] selects the ML-DSA parameter set: 44 (~AES-128), 65 (~AES-192), or
55+
/// 87 (~AES-256, the CNSA 2.0 set). The old Dilithium values 2/3/5 are rejected.
5556
///
5657
/// Returns a [QsKeypair] that can be stored and later passed to the constructor.
5758
///
5859
/// Throws [StateError] if key generation fails.
5960
///
6061
/// Example:
6162
/// ```dart
62-
/// final keypair = QsRustEventSigner.generateKeypair(level: 2);
63-
/// final signer = QsRustEventSigner(keypair: keypair, level: 2);
63+
/// final keypair = QsRustEventSigner.generateKeypair(level: 87);
64+
/// final signer = QsRustEventSigner(keypair: keypair, level: 87);
6465
/// ```
65-
static QsKeypair generateKeypair({int level = 2}) {
66+
static QsKeypair generateKeypair({int level = 87}) {
6667
final outPk = calloc<rust_lib.QsBuffer>();
6768
final outSk = calloc<rust_lib.QsBuffer>();
6869

@@ -71,7 +72,7 @@ class QsRustEventSigner implements EventSigner {
7172

7273
if (result != 1) {
7374
throw StateError(
74-
'Failed to generate Dilithium keypair at level $level',
75+
'Failed to generate ML-DSA keypair at level $level',
7576
);
7677
}
7778

@@ -126,7 +127,7 @@ class QsRustEventSigner implements EventSigner {
126127
);
127128

128129
if (result != 1) {
129-
throw StateError('Failed to sign event with Dilithium');
130+
throw StateError('Failed to sign event with ML-DSA');
130131
}
131132

132133
final sigLen = outSig.ref.len;

packages/ndk/lib/data_layer/repositories/signers/qs_rust_event_signer_stub.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class QsKeypair {
2424
class QsRustEventSigner implements EventSigner {
2525
final int level;
2626

27-
QsRustEventSigner({required QsKeypair keypair, this.level = 2});
27+
QsRustEventSigner({required QsKeypair keypair, this.level = 87});
2828

2929
@override
3030
bool get requiresInteractiveSigning => false;
@@ -35,7 +35,7 @@ class QsRustEventSigner implements EventSigner {
3535
@override
3636
Iterable<String> get signerTransportRelayUrls => const <String>[];
3737

38-
static QsKeypair generateKeypair({int level = 2}) {
38+
static QsKeypair generateKeypair({int level = 87}) {
3939
throw UnsupportedError(
4040
'QsRustEventSigner is not available on this platform. '
4141
'FFI is not supported on web.',

packages/ndk/lib/data_layer/repositories/verifiers/qs_rust_event_verifier.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/// Quantum-secure event verifier with platform-specific implementations.
22
///
33
/// On native platforms (Android, iOS, Linux, macOS, Windows), this uses FFI
4-
/// to call Rust code for CRYSTALS-Dilithium verification.
4+
/// to call Rust code for ML-DSA (FIPS 204) verification.
55
///
66
/// On web platforms, this exports a stub that throws [UnsupportedError].
77
library;

packages/ndk/lib/data_layer/repositories/verifiers/qs_rust_event_verifier_native.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ import '../../../domain_layer/repositories/event_verifier.dart';
88
import '../../../src/rust_lib.dart' as rust_lib;
99

1010
/// An implementation of [EventVerifier] that uses quantum-secure
11-
/// CRYSTALS-Dilithium signatures via native Rust FFI.
11+
/// ML-DSA (FIPS 204) signatures via native Rust FFI.
1212
class QsRustEventVerifier implements EventVerifier {
13-
/// The Dilithium security level (2, 3, or 5).
13+
/// The ML-DSA parameter set: 44, 65 or 87.
1414
final int level;
1515

1616
/// Creates a new instance of [QsRustEventVerifier].
1717
///
1818
/// [level] defaults to 2 (NIST Security Level 2, ~AES-128).
19-
QsRustEventVerifier({this.level = 2});
19+
QsRustEventVerifier({this.level = 87});
2020

2121
@override
2222
Future<bool> verify(Nip01Event event) async {

packages/ndk/lib/data_layer/repositories/verifiers/qs_rust_event_verifier_stub.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import '../../../domain_layer/repositories/event_verifier.dart';
77
class QsRustEventVerifier implements EventVerifier {
88
final int level;
99

10-
QsRustEventVerifier({this.level = 2});
10+
QsRustEventVerifier({this.level = 87});
1111

1212
@override
1313
Future<bool> verify(Nip01Event event) {

packages/ndk/lib/src/rust_lib.dart

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,17 @@ final class QsBuffer extends Struct {
1616
/// Verifies a Nostr event signature.
1717
/// Returns 1 if valid, 0 if invalid.
1818
@Native<
19-
Int32 Function(
20-
Pointer<Utf8>, // eventIdHex
21-
Pointer<Utf8>, // pubKeyHex
22-
Uint64, // createdAt
23-
Uint32, // kind
24-
Pointer<Pointer<Utf8>>, // tagsData
25-
Pointer<Uint32>, // tagsLengths
26-
Uint32, // tagsCount
27-
Pointer<Utf8>, // content
28-
Pointer<Utf8>, // signatureHex
29-
)
30-
>(symbol: 'verify_nostr_event')
19+
Int32 Function(
20+
Pointer<Utf8>, // eventIdHex
21+
Pointer<Utf8>, // pubKeyHex
22+
Uint64, // createdAt
23+
Uint32, // kind
24+
Pointer<Pointer<Utf8>>, // tagsData
25+
Pointer<Uint32>, // tagsLengths
26+
Uint32, // tagsCount
27+
Pointer<Utf8>, // content
28+
Pointer<Utf8>, // signatureHex
29+
)>(symbol: 'verify_nostr_event')
3130
external int verifyNostrEventNative(
3231
Pointer<Utf8> eventIdHex,
3332
Pointer<Utf8> pubKeyHex,
@@ -40,47 +39,55 @@ external int verifyNostrEventNative(
4039
Pointer<Utf8> signatureHex,
4140
);
4241

43-
// ── Quantum-Secure Dilithium bindings ──────────────────────────────────
42+
// ── Quantum-Secure ML-DSA (FIPS 204) bindings ──────────────────────────
43+
//
44+
// These were CRYSTALS-Dilithium. NIST altered the algorithm during
45+
// standardisation, so Dilithium keys and signatures are not interoperable with
46+
// FIPS 204 ML-DSA. [level] now takes the ML-DSA parameter numbers - 44, 65 or 87 -
47+
// and the old Dilithium values 2, 3 and 5 are rejected rather than remapped, so a
48+
// caller that was not updated fails loudly instead of silently getting different
49+
// security properties than it asked for.
4450

4551
/// Frees a QsBuffer previously returned by the Rust library.
4652
@Native<Void Function(QsBuffer)>(symbol: 'qs_free_buffer')
4753
external void qsFreeBuffer(QsBuffer buf);
4854

49-
/// Generates a Dilithium keypair.
55+
/// Generates a random ML-DSA keypair.
5056
///
51-
/// [level]: security level (2, 3, or 5).
57+
/// Prefer [qsDeriveKeypairFromSeed] for anything representing an identity: a
58+
/// random key cannot be restored from a mnemonic, so losing it loses the identity.
59+
///
60+
/// [level]: ML-DSA parameter set (44, 65, or 87).
5261
/// [outPk], [outSk]: pointers to QsBuffer structs that will be filled.
5362
/// Returns 1 on success, 0 on failure.
5463
@Native<
55-
Int32 Function(
56-
Uint32, // level
57-
Pointer<QsBuffer>, // outPk
58-
Pointer<QsBuffer>, // outSk
59-
)
60-
>(symbol: 'qs_generate_keypair')
64+
Int32 Function(
65+
Uint32, // level
66+
Pointer<QsBuffer>, // outPk
67+
Pointer<QsBuffer>, // outSk
68+
)>(symbol: 'qs_generate_keypair')
6169
external int qsGenerateKeypair(
6270
int level,
6371
Pointer<QsBuffer> outPk,
6472
Pointer<QsBuffer> outSk,
6573
);
6674

67-
/// Signs a message with a Dilithium secret key.
75+
/// Signs a message with an ML-DSA secret key (empty FIPS 204 context).
6876
///
69-
/// [level]: security level (2, 3, or 5).
77+
/// [level]: ML-DSA parameter set (44, 65, or 87).
7078
/// [skPtr]/[skLen]: secret key bytes.
7179
/// [msgPtr]/[msgLen]: message bytes.
7280
/// [outSig]: pointer to QsBuffer that will receive the signature.
7381
/// Returns 1 on success, 0 on failure.
7482
@Native<
75-
Int32 Function(
76-
Uint32, // level
77-
Pointer<Uint8>, // skPtr
78-
IntPtr, // skLen
79-
Pointer<Uint8>, // msgPtr
80-
IntPtr, // msgLen
81-
Pointer<QsBuffer>, // outSig
82-
)
83-
>(symbol: 'qs_sign')
83+
Int32 Function(
84+
Uint32, // level
85+
Pointer<Uint8>, // skPtr
86+
IntPtr, // skLen
87+
Pointer<Uint8>, // msgPtr
88+
IntPtr, // msgLen
89+
Pointer<QsBuffer>, // outSig
90+
)>(symbol: 'qs_sign')
8491
external int qsSign(
8592
int level,
8693
Pointer<Uint8> skPtr,
@@ -90,21 +97,20 @@ external int qsSign(
9097
Pointer<QsBuffer> outSig,
9198
);
9299

93-
/// Verifies a Dilithium signature.
100+
/// Verifies an ML-DSA signature (empty FIPS 204 context).
94101
///
95-
/// [level]: security level (2, 3, or 5).
102+
/// [level]: ML-DSA parameter set (44, 65, or 87).
96103
/// Returns 1 if valid, 0 if invalid.
97104
@Native<
98-
Int32 Function(
99-
Uint32, // level
100-
Pointer<Uint8>, // pkPtr
101-
IntPtr, // pkLen
102-
Pointer<Uint8>, // msgPtr
103-
IntPtr, // msgLen
104-
Pointer<Uint8>, // sigPtr
105-
IntPtr, // sigLen
106-
)
107-
>(symbol: 'qs_verify')
105+
Int32 Function(
106+
Uint32, // level
107+
Pointer<Uint8>, // pkPtr
108+
IntPtr, // pkLen
109+
Pointer<Uint8>, // msgPtr
110+
IntPtr, // msgLen
111+
Pointer<Uint8>, // sigPtr
112+
IntPtr, // sigLen
113+
)>(symbol: 'qs_verify')
108114
external int qsVerify(
109115
int level,
110116
Pointer<Uint8> pkPtr,
@@ -114,3 +120,30 @@ external int qsVerify(
114120
Pointer<Uint8> sigPtr,
115121
int sigLen,
116122
);
123+
124+
/// Derives an ML-DSA keypair deterministically from a 64-byte BIP-39 seed.
125+
///
126+
/// The key is a sibling of the secp256k1 key derived from the same mnemonic, not a
127+
/// child of it, so one mnemonic restores both and breaking secp256k1 does not reach
128+
/// this key.
129+
///
130+
/// [seedPtr]/[seedLen] must be a 64-byte BIP-39 seed. Passing a 32-byte secp256k1
131+
/// private key is rejected: deriving from it would be circular.
132+
/// Returns 1 on success, 0 on failure.
133+
@Native<
134+
Int32 Function(
135+
Uint32, // level
136+
Pointer<Uint8>, // seedPtr
137+
IntPtr, // seedLen
138+
Uint32, // account
139+
Pointer<QsBuffer>, // outPk
140+
Pointer<QsBuffer>, // outSk
141+
)>(symbol: 'qs_derive_keypair_from_seed')
142+
external int qsDeriveKeypairFromSeed(
143+
int level,
144+
Pointer<Uint8> seedPtr,
145+
int seedLen,
146+
int account,
147+
Pointer<QsBuffer> outPk,
148+
Pointer<QsBuffer> outSk,
149+
);

0 commit comments

Comments
 (0)