tane/spike/block2_spike/lib/src/nip44.dart
vjrj 2cafc7fc12 spike(block2): de-risk NIP-17 messaging + validate shared-connection shape
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.
2026-07-10 02:11:54 +02:00

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)));
}