Extends the Block 2 spike to attack the highest-risk unbuilt piece (private messaging) and to prove the Q2 architecture recommendation with running code: - NostrConnection: one shared socket+key+sign+REQ/EOSE lifecycle. Refactored NostrOfferTransport onto it; added NostrMessageTransport — two thin contracts on ONE connection (the 'one connection, three interfaces' shape). - NIP-44 v2 encryption (secp256k1 ECDH -> ChaCha20 + HMAC, length-hiding pad). - NIP-17/NIP-59 gift-wrap: rumor(14) -> seal(13) -> wrap(1059, ephemeral key). Alice->Bob DM round-trips through the relay: encrypted, sender authenticated, and metadata-private (relay/eavesdropper sees only ciphertext, an ephemeral author and the recipient p-tag — Alice stays hidden). Findings updated: NIP-17 risk drops Medium-High -> Medium (hardening, not feasibility); next de-risking target is the web of trust. NIP-44 not yet vector-verified (noted). 20 tests green, analyzer clean, Block 1 untouched.
142 lines
4.9 KiB
Dart
142 lines
4.9 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:pointycastle/export.dart';
|
|
|
|
import 'hkdf.dart';
|
|
|
|
/// NIP-44 v2 payload encryption (secp256k1 ECDH → conversation key → ChaCha20 +
|
|
/// HMAC-SHA256, with length-hiding padding). This is the crypto NIP-17 private
|
|
/// DMs ride on. Spike-grade: follows the NIP-44 *construction* faithfully but is
|
|
/// NOT cross-checked against the reference test vectors — see findings §4. Good
|
|
/// enough to prove the messaging flow and architecture round-trip.
|
|
class Nip44 {
|
|
static final _params = ECCurve_secp256k1();
|
|
|
|
/// secp256k1 field prime.
|
|
static final BigInt _p = BigInt.parse(
|
|
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F',
|
|
radix: 16,
|
|
);
|
|
|
|
static final _salt = ascii.encode('nip44-v2');
|
|
|
|
/// ECDH conversation key shared by [privHexA] and x-only [pubHexB]:
|
|
/// `HKDF-Extract(salt="nip44-v2", ikm = (d·P).x)`. Symmetric: A→B and B→A
|
|
/// yield the same key.
|
|
static Uint8List conversationKey(String privHexA, String pubHexB) {
|
|
final d = _bi(privHexA);
|
|
final point = _liftX(_bi(pubHexB));
|
|
final shared = (point * d)!;
|
|
final x = shared.x!.toBigInteger()!;
|
|
return hkdfExtract(salt: _salt, ikm: _to32(x));
|
|
}
|
|
|
|
/// Encrypts [plaintext] under [convKey]; returns base64 NIP-44 payload.
|
|
static String encrypt(Uint8List convKey, String plaintext, {Uint8List? nonce}) {
|
|
final n = nonce ?? _randomBytes(32);
|
|
final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76);
|
|
final chachaKey = keys.sublist(0, 32);
|
|
final chachaNonce = keys.sublist(32, 44);
|
|
final hmacKey = keys.sublist(44, 76);
|
|
|
|
final padded = _pad(utf8.encode(plaintext));
|
|
final ciphertext = _chacha20(chachaKey, chachaNonce, padded);
|
|
final mac = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes;
|
|
|
|
return base64.encode([2, ...n, ...ciphertext, ...mac]);
|
|
}
|
|
|
|
/// Decrypts a base64 NIP-44 payload under [convKey]. Throws on version/MAC
|
|
/// failure (so an eavesdropper with the wrong key cannot read it).
|
|
static String decrypt(Uint8List convKey, String payload) {
|
|
final raw = base64.decode(payload);
|
|
if (raw[0] != 2) throw const FormatException('unsupported nip44 version');
|
|
final n = raw.sublist(1, 33);
|
|
final ciphertext = raw.sublist(33, raw.length - 32);
|
|
final mac = raw.sublist(raw.length - 32);
|
|
|
|
final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76);
|
|
final chachaKey = keys.sublist(0, 32);
|
|
final chachaNonce = keys.sublist(32, 44);
|
|
final hmacKey = keys.sublist(44, 76);
|
|
|
|
final expected = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes;
|
|
if (!_constantTimeEquals(expected, mac)) {
|
|
throw const FormatException('nip44 MAC mismatch (wrong key or tampered)');
|
|
}
|
|
final padded = _chacha20(chachaKey, chachaNonce, Uint8List.fromList(ciphertext));
|
|
final len = (padded[0] << 8) | padded[1];
|
|
return utf8.decode(padded.sublist(2, 2 + len));
|
|
}
|
|
|
|
// --- padding (NIP-44 length hiding) ---
|
|
|
|
static Uint8List _pad(List<int> plaintext) {
|
|
final unpadded = plaintext.length;
|
|
final paddedLen = _calcPaddedLen(unpadded);
|
|
final buf = Uint8List(2 + paddedLen);
|
|
buf[0] = (unpadded >> 8) & 0xff;
|
|
buf[1] = unpadded & 0xff;
|
|
buf.setRange(2, 2 + unpadded, plaintext);
|
|
return buf;
|
|
}
|
|
|
|
static int _calcPaddedLen(int len) {
|
|
if (len <= 32) return 32;
|
|
final nextPower = 1 << ((len - 1).bitLength);
|
|
final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8;
|
|
return chunk * ((len - 1) ~/ chunk + 1);
|
|
}
|
|
|
|
// --- primitives ---
|
|
|
|
static Uint8List _chacha20(List<int> key, List<int> nonce, Uint8List input) {
|
|
final engine = ChaCha7539Engine()
|
|
..init(
|
|
true,
|
|
ParametersWithIV(
|
|
KeyParameter(Uint8List.fromList(key)),
|
|
Uint8List.fromList(nonce),
|
|
),
|
|
);
|
|
final out = Uint8List(input.length);
|
|
engine.processBytes(input, 0, input.length, out, 0);
|
|
return out;
|
|
}
|
|
|
|
/// BIP340 lift_x: reconstruct the curve point with even y from x-only [x].
|
|
static ECPoint _liftX(BigInt x) {
|
|
final ySq = (x.modPow(BigInt.from(3), _p) + BigInt.from(7)) % _p;
|
|
var y = ySq.modPow((_p + BigInt.one) ~/ BigInt.from(4), _p);
|
|
if (y.isOdd) y = _p - y;
|
|
return _params.curve.createPoint(x, y);
|
|
}
|
|
|
|
static BigInt _bi(String hex) => BigInt.parse(hex, radix: 16);
|
|
|
|
static Uint8List _to32(BigInt v) {
|
|
final out = Uint8List(32);
|
|
var x = v;
|
|
for (var i = 31; i >= 0; i--) {
|
|
out[i] = (x & BigInt.from(0xff)).toInt();
|
|
x = x >> 8;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
static bool _constantTimeEquals(List<int> a, List<int> b) {
|
|
if (a.length != b.length) return false;
|
|
var diff = 0;
|
|
for (var i = 0; i < a.length; i++) {
|
|
diff |= a[i] ^ b[i];
|
|
}
|
|
return diff == 0;
|
|
}
|
|
|
|
static final _rng = Random.secure();
|
|
static Uint8List _randomBytes(int n) =>
|
|
Uint8List.fromList(List.generate(n, (_) => _rng.nextInt(256)));
|
|
}
|