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.
This commit is contained in:
parent
49b872b405
commit
2cafc7fc12
10 changed files with 684 additions and 142 deletions
|
|
@ -19,10 +19,11 @@ The three open decisions from [open-decisions.md](open-decisions.md) §D.3 and
|
||||||
| # | Open question | Verdict |
|
| # | Open question | Verdict |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Can we derive a Nostr (secp256k1) key from the Ğ1 root seed, one-way & reproducible? | **Yes — low risk.** |
|
| 1 | Can we derive a Nostr (secp256k1) key from the Ğ1 root seed, one-way & reproducible? | **Yes — low risk.** |
|
||||||
| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — with a caveat on messaging/trust coupling.** |
|
| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — and the "one connection, three interfaces" shape is now demonstrated in code.** |
|
||||||
| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** |
|
| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** |
|
||||||
|
| 3b | Does NIP-17 private, metadata-hiding messaging actually work? | **Yes — prototyped end to end. Risk drops from "unbuilt unknown" to "production hardening".** |
|
||||||
|
|
||||||
All of it runs with tests. See "How to run" at the end.
|
All of it runs with tests (20 passing). See "How to run" at the end.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -109,17 +110,23 @@ manageable**:
|
||||||
- But their **verbs differ**: publish/browse a public listing vs. send/receive a
|
- But their **verbs differ**: publish/browse a public listing vs. send/receive a
|
||||||
private encrypted DM vs. assert/read a signed certification. Forcing DMs and
|
private encrypted DM vs. assert/read a signed certification. Forcing DMs and
|
||||||
certifications behind `OfferTransport.publish/discover` would overload it.
|
certifications behind `OfferTransport.publish/discover` would overload it.
|
||||||
- **Recommendation:** in `commons_core`, model **one shared `NostrConnection`**
|
- **Recommendation — now demonstrated in code, not just asserted:**
|
||||||
(socket, key, sign, req/sub lifecycle) with **three thin interfaces on top** —
|
[`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is
|
||||||
`OfferTransport`, `MessageTransport`, `TrustTransport`. That matches how the
|
**one shared** socket + key + sign + REQ/EOSE lifecycle, and **two thin
|
||||||
code actually factored in the spike and keeps each contract honest, while
|
interfaces already sit on top of the same instance** —
|
||||||
admitting the reality that one protocol/key/relay underpins all three.
|
[`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart)
|
||||||
|
(NIP-99) and
|
||||||
|
[`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)
|
||||||
|
(NIP-17). A third, `TrustTransport` (WoT), would slot in the same way. This is
|
||||||
|
the shape to lift into `commons_core`: shared connection, per-concern
|
||||||
|
contract. Trying to make `OfferTransport` also carry DMs would have overloaded
|
||||||
|
it — the split is real.
|
||||||
|
|
||||||
**Residual risk: medium.** The abstraction is right; the *scope* is the risk —
|
**Residual risk: medium → low-medium.** The abstraction is right *and* two of
|
||||||
"the social layer" is genuinely offers + messaging + trust + relays together
|
the three contracts are now built. The remaining risk is **scope, not
|
||||||
(the §D.1 "indivisible" point), and this spike only built the offers third end
|
feasibility**: "the social layer" is offers + messaging + trust + relays
|
||||||
to end. Messaging (NIP-17 gift-wrap, offline delivery, spam) and trust (Duniter-
|
together (§D.1 "indivisible"), and trust/WoT plus production hardening of
|
||||||
style WoT over Nostr) remain the large, unbuilt pieces.
|
messaging (below) are still ahead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -147,9 +154,9 @@ not the mechanics.**
|
||||||
gift/exchange/sale/wanted all fit; we add an `offer_type` tag for the
|
gift/exchange/sale/wanted all fit; we add an `offer_type` tag for the
|
||||||
reciprocity mode NIP-99 lacks. **Low risk.**
|
reciprocity mode NIP-99 lacks. **Low risk.**
|
||||||
- **NIP-17 (private DMs, gift-wrap):** newer, more moving parts (sealed +
|
- **NIP-17 (private DMs, gift-wrap):** newer, more moving parts (sealed +
|
||||||
gift-wrapped, per-message ephemeral keys). Viable but the **biggest unbuilt
|
gift-wrapped, per-message ephemeral keys). **The spike DID build it** — see §4
|
||||||
risk** — this is where Q2's "messaging is large" bites. **Medium-high risk;
|
below. Verdict moves from "biggest unbuilt risk" to **medium (production
|
||||||
spike did not implement it.**
|
hardening), not high (feasibility)**.
|
||||||
- **NIP-85 / WoT over Nostr:** least settled. The Duniter-style certification
|
- **NIP-85 / WoT over Nostr:** least settled. The Duniter-style certification
|
||||||
model maps onto signed events, but there is no dominant standard. Likely a
|
model maps onto signed events, but there is no dominant standard. Likely a
|
||||||
**custom event kind mapped to the Duniter model** (as g1-integration.md §"WoT
|
**custom event kind mapped to the Duniter model** (as g1-integration.md §"WoT
|
||||||
|
|
@ -163,6 +170,45 @@ not the mechanics.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Q3b — NIP-17 private messaging (the piece that was flagged highest-risk)
|
||||||
|
|
||||||
|
**Result: the metadata-private DM flow works end to end. Built, tested, and it
|
||||||
|
fits the shared-connection architecture.**
|
||||||
|
|
||||||
|
The plan ([network-trust.md](network-trust.md) §4) calls messaging "grande" and
|
||||||
|
[open-decisions.md](open-decisions.md) §D.3 singled it out as the tightest
|
||||||
|
coupling to Nostr. So the spike built it rather than hand-waving:
|
||||||
|
|
||||||
|
- **NIP-44 v2 encryption** ([`Nip44`](../../spike/block2_spike/lib/src/nip44.dart)):
|
||||||
|
secp256k1 ECDH → HKDF conversation key → ChaCha20 + HMAC-SHA256 with
|
||||||
|
length-hiding padding. Conversation key is symmetric (A→B == B→A, tested); a
|
||||||
|
wrong key cannot decrypt (throws, tested); short messages pad to the same
|
||||||
|
bucket so ciphertext length doesn't leak plaintext length (tested).
|
||||||
|
- **NIP-17 / NIP-59 gift-wrap onion**
|
||||||
|
([`NostrMessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)):
|
||||||
|
rumor (kind 14, unsigned) → seal (kind 13, signed by sender, NIP-44 to
|
||||||
|
recipient) → gift wrap (kind 1059, signed by a **throwaway ephemeral key**,
|
||||||
|
NIP-44 to recipient).
|
||||||
|
- **End-to-end over the relay (tested):**
|
||||||
|
- Alice → Bob: the message arrives and Bob **authenticates the sender as
|
||||||
|
Alice** — even though the wrap was signed by an ephemeral key.
|
||||||
|
- **The relay/eavesdropper sees neither the plaintext nor the real sender:**
|
||||||
|
snooping stored kind-1059 events yields only ciphertext, an ephemeral
|
||||||
|
author, and a `p` tag for the recipient. Alice's identity is hidden.
|
||||||
|
- A third party cannot read a message not addressed to them.
|
||||||
|
|
||||||
|
**What this de-risks:** the hard, novel part (metadata-private messaging on the
|
||||||
|
*same derived key and relay* as offers) is feasible and architecturally clean.
|
||||||
|
|
||||||
|
**What remains (why it's still medium, not solved):**
|
||||||
|
- The NIP-44 implementation here is **structurally faithful but NOT verified
|
||||||
|
against the reference test vectors** — cross-client interop must be proven
|
||||||
|
before shipping (a real client library should be used, not this spike code).
|
||||||
|
- **Offline delivery, retries, and multi-device** (a recipient who's offline
|
||||||
|
when the wrap is published) are untouched — this is the real "messaging is
|
||||||
|
large" weight.
|
||||||
|
- **Spam/abuse** on DMs leans on the (unbuilt) web of trust.
|
||||||
|
|
||||||
## Overall recommendation
|
## Overall recommendation
|
||||||
|
|
||||||
1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified;
|
1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified;
|
||||||
|
|
@ -171,15 +217,17 @@ not the mechanics.**
|
||||||
2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try
|
2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try
|
||||||
to make `OfferTransport` also carry messaging and trust. Keep `Offer`
|
to make `OfferTransport` also carry messaging and trust. Keep `Offer`
|
||||||
agnostic and the geohash-coarsening seam exactly as prototyped.
|
agnostic and the geohash-coarsening seam exactly as prototyped.
|
||||||
3. **Q3: Nostr-first is the right bet, but scope honestly.** Offers are the easy
|
3. **Q3: Nostr-first is the right bet, and now two of three contracts are
|
||||||
third and they work. **Messaging (NIP-17) and WoT (NIP-85/custom) are the
|
proven.** Offers (NIP-99) and private messaging (NIP-17) both work on the
|
||||||
real weight and remain unproven here** — they, not the offer flow, should
|
shared connection. The remaining unproven weight is **WoT/trust
|
||||||
drive the Phase-3 estimate and the funding ask ([open-decisions.md](open-decisions.md)
|
(NIP-85/custom)** plus **production hardening of messaging** (interop-exact
|
||||||
§D.1, §D.6).
|
NIP-44, offline delivery, multi-device) — that, not the happy-path flow,
|
||||||
|
should drive the Phase-3 estimate and the funding ask
|
||||||
|
([open-decisions.md](open-decisions.md) §D.1, §D.6).
|
||||||
4. **Do not start Block 2 from this spike.** Delete `spike/block2_spike/` once
|
4. **Do not start Block 2 from this spike.** Delete `spike/block2_spike/` once
|
||||||
these findings are absorbed. The next concrete step is a *funded* social
|
these findings are absorbed. The next concrete de-risking target is the **web
|
||||||
round that starts by building NIP-17 messaging as its own de-risking target,
|
of trust** (Duniter-style certifications mapped to Nostr events) — the last
|
||||||
since that is where the residual risk concentrates.
|
large unknown — followed by hardening messaging for real-world delivery.
|
||||||
|
|
||||||
## Risk summary
|
## Risk summary
|
||||||
|
|
||||||
|
|
@ -188,16 +236,16 @@ not the mechanics.**
|
||||||
| Seed→Nostr derivation | **Low** | Works, one-way, reproducible, tested. Only standardisation cosmetics left. |
|
| Seed→Nostr derivation | **Low** | Works, one-way, reproducible, tested. Only standardisation cosmetics left. |
|
||||||
| Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. |
|
| Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. |
|
||||||
| Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. |
|
| Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. |
|
||||||
| Messaging (NIP-17) | **Medium-High** | Newer, complex, unbuilt; the big Phase-3 cost. |
|
| Messaging (NIP-17) | **Medium** | Flow + metadata-privacy prototyped & tested. Left: interop-exact NIP-44, offline delivery, multi-device. |
|
||||||
| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. |
|
| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. The last big unknown. |
|
||||||
| Overall scope (§D.1) | **High** | Social layer is indivisible & large; only 1/3 proven here. |
|
| Overall scope (§D.1) | **Medium-High** | Social layer is indivisible & large; 2 of 3 transport contracts now proven, WoT + hardening remain. |
|
||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd spike/block2_spike
|
cd spike/block2_spike
|
||||||
dart pub get
|
dart pub get
|
||||||
dart test # derivation_test.dart, privacy_test.dart, roundtrip_test.dart
|
dart test # derivation · privacy · roundtrip · messaging (20 tests)
|
||||||
```
|
```
|
||||||
|
|
||||||
Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the
|
Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ library;
|
||||||
|
|
||||||
export 'src/bech32.dart';
|
export 'src/bech32.dart';
|
||||||
export 'src/hkdf.dart';
|
export 'src/hkdf.dart';
|
||||||
|
export 'src/message_transport.dart';
|
||||||
export 'src/mini_relay.dart';
|
export 'src/mini_relay.dart';
|
||||||
|
export 'src/nip44.dart';
|
||||||
export 'src/nip99.dart';
|
export 'src/nip99.dart';
|
||||||
|
export 'src/nostr_connection.dart';
|
||||||
export 'src/nostr_event.dart';
|
export 'src/nostr_event.dart';
|
||||||
export 'src/nostr_key.dart';
|
export 'src/nostr_key.dart';
|
||||||
|
export 'src/nostr_message_transport.dart';
|
||||||
export 'src/nostr_offer_transport.dart';
|
export 'src/nostr_offer_transport.dart';
|
||||||
export 'src/offer.dart';
|
export 'src/offer.dart';
|
||||||
export 'src/offer_transport.dart';
|
export 'src/offer_transport.dart';
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,26 @@ Uint8List hkdfSha256({
|
||||||
}
|
}
|
||||||
return Uint8List.fromList(out.sublist(0, length));
|
return Uint8List.fromList(out.sublist(0, length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HKDF-Extract (RFC 5869 §2.2): `PRK = HMAC(salt, ikm)`. NIP-44 uses this
|
||||||
|
/// alone to turn the ECDH secret into the conversation key.
|
||||||
|
Uint8List hkdfExtract({required List<int> salt, required List<int> ikm}) =>
|
||||||
|
Uint8List.fromList(Hmac(sha256, salt).convert(ikm).bytes);
|
||||||
|
|
||||||
|
/// HKDF-Expand (RFC 5869 §2.3) with a *byte* info (NIP-44 uses the nonce as
|
||||||
|
/// info), producing [length] bytes.
|
||||||
|
Uint8List hkdfExpandBytes({
|
||||||
|
required List<int> prk,
|
||||||
|
required List<int> info,
|
||||||
|
required int length,
|
||||||
|
}) {
|
||||||
|
final out = <int>[];
|
||||||
|
var previous = <int>[];
|
||||||
|
var counter = 1;
|
||||||
|
while (out.length < length) {
|
||||||
|
previous = Hmac(sha256, prk).convert([...previous, ...info, counter]).bytes;
|
||||||
|
out.addAll(previous);
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(out.sublist(0, length));
|
||||||
|
}
|
||||||
|
|
|
||||||
28
spike/block2_spike/lib/src/message_transport.dart
Normal file
28
spike/block2_spike/lib/src/message_transport.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/// One decrypted direct message handed to the app.
|
||||||
|
class DirectMessage {
|
||||||
|
const DirectMessage({
|
||||||
|
required this.fromPubkey,
|
||||||
|
required this.text,
|
||||||
|
required this.at,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The authenticated sender (from the inner rumor). NOT the gift-wrap author,
|
||||||
|
/// which is a throwaway ephemeral key — that's the metadata-privacy point.
|
||||||
|
final String fromPubkey;
|
||||||
|
final String text;
|
||||||
|
final DateTime at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The second interface over the shared `NostrConnection` (Q2): private 1:1
|
||||||
|
/// messaging. Deliberately SEPARATE from `OfferTransport` — its verbs (send a
|
||||||
|
/// private DM / read an inbox) don't fit publish/discover. Backed by NIP-17
|
||||||
|
/// gift-wrapped, NIP-44-encrypted events.
|
||||||
|
abstract interface class MessageTransport {
|
||||||
|
/// Sends [text] privately to [toPubkey].
|
||||||
|
Future<void> send({required String toPubkey, required String text});
|
||||||
|
|
||||||
|
/// Streams decrypted messages addressed to this identity.
|
||||||
|
Stream<DirectMessage> inbox();
|
||||||
|
|
||||||
|
Future<void> close();
|
||||||
|
}
|
||||||
142
spike/block2_spike/lib/src/nip44.dart
Normal file
142
spike/block2_spike/lib/src/nip44.dart
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
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)));
|
||||||
|
}
|
||||||
95
spike/block2_spike/lib/src/nostr_connection.dart
Normal file
95
spike/block2_spike/lib/src/nostr_connection.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'nostr_event.dart';
|
||||||
|
|
||||||
|
/// The ONE piece shared by every Nostr-backed transport (Q2 recommendation): a
|
||||||
|
/// single relay socket + the signing identity + the EVENT/OK and REQ/EOSE
|
||||||
|
/// lifecycle. `OfferTransport`, `MessageTransport` and a future `TrustTransport`
|
||||||
|
/// each sit on top of this — they share this *connection*, not a *contract*.
|
||||||
|
class NostrConnection {
|
||||||
|
NostrConnection._(this._socket, this.privateKeyHex, this.publicKeyHex) {
|
||||||
|
_socket.listen(_onData, onDone: _onDone);
|
||||||
|
}
|
||||||
|
|
||||||
|
final WebSocket _socket;
|
||||||
|
|
||||||
|
/// The derived (Q1) identity this connection signs with.
|
||||||
|
final String privateKeyHex;
|
||||||
|
final String publicKeyHex;
|
||||||
|
|
||||||
|
final _incoming = StreamController<List<dynamic>>.broadcast();
|
||||||
|
int _subCounter = 0;
|
||||||
|
|
||||||
|
static Future<NostrConnection> connect(
|
||||||
|
String relayUrl, {
|
||||||
|
required String privateKeyHex,
|
||||||
|
required String publicKeyHex,
|
||||||
|
}) async {
|
||||||
|
final socket = await WebSocket.connect(relayUrl);
|
||||||
|
return NostrConnection._(socket, privateKeyHex, publicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onData(dynamic d) => _incoming.add(jsonDecode(d as String) as List);
|
||||||
|
void _onDone() {
|
||||||
|
if (!_incoming.isClosed) _incoming.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes an already-signed [event]; resolves with the relay's OK verdict.
|
||||||
|
Future<({bool accepted, String message})> publish(NostrEvent event) async {
|
||||||
|
final ok = _incoming.stream.firstWhere(
|
||||||
|
(m) => m[0] == 'OK' && m[1] == event.id,
|
||||||
|
);
|
||||||
|
_socket.add(jsonEncode(['EVENT', event.toJson()]));
|
||||||
|
final r = await ok.timeout(const Duration(seconds: 5));
|
||||||
|
return (accepted: r[2] as bool, message: r.length > 3 ? r[3] as String : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams events matching [filter] (live), until the caller cancels.
|
||||||
|
Stream<NostrEvent> subscribe(Map<String, dynamic> filter) {
|
||||||
|
final subId = 'sub${_subCounter++}';
|
||||||
|
late StreamController<NostrEvent> controller;
|
||||||
|
StreamSubscription? sub;
|
||||||
|
controller = StreamController<NostrEvent>(
|
||||||
|
onListen: () {
|
||||||
|
sub = _incoming.stream.listen((m) {
|
||||||
|
if (m[0] == 'EVENT' && m[1] == subId) {
|
||||||
|
controller.add(NostrEvent.fromJson(m[2] as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_socket.add(jsonEncode(['REQ', subId, filter]));
|
||||||
|
},
|
||||||
|
onCancel: () async {
|
||||||
|
_socket.add(jsonEncode(['CLOSE', subId]));
|
||||||
|
await sub?.cancel();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects stored events matching [filter] up to EOSE, then closes the sub.
|
||||||
|
Future<List<NostrEvent>> reqOnce(Map<String, dynamic> filter) async {
|
||||||
|
final subId = 'once${_subCounter++}';
|
||||||
|
final results = <NostrEvent>[];
|
||||||
|
final done = Completer<void>();
|
||||||
|
final sub = _incoming.stream.listen((m) {
|
||||||
|
if (m[1] != subId) return;
|
||||||
|
if (m[0] == 'EVENT') {
|
||||||
|
results.add(NostrEvent.fromJson(m[2] as Map<String, dynamic>));
|
||||||
|
} else if (m[0] == 'EOSE' && !done.isCompleted) {
|
||||||
|
done.complete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_socket.add(jsonEncode(['REQ', subId, filter]));
|
||||||
|
await done.future.timeout(const Duration(seconds: 5));
|
||||||
|
_socket.add(jsonEncode(['CLOSE', subId]));
|
||||||
|
await sub.cancel();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
await _socket.close();
|
||||||
|
if (!_incoming.isClosed) await _incoming.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
136
spike/block2_spike/lib/src/nostr_message_transport.dart
Normal file
136
spike/block2_spike/lib/src/nostr_message_transport.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:bip340/bip340.dart' as bip340;
|
||||||
|
|
||||||
|
import 'message_transport.dart';
|
||||||
|
import 'nip44.dart';
|
||||||
|
import 'nostr_connection.dart';
|
||||||
|
import 'nostr_event.dart';
|
||||||
|
|
||||||
|
/// NIP-17 private DM backend for [MessageTransport], on the shared
|
||||||
|
/// [NostrConnection]. This is the spike's answer to the highest-risk Q3 piece:
|
||||||
|
/// does the metadata-private messaging flow actually work, and does it fit the
|
||||||
|
/// "one connection, three interfaces" shape?
|
||||||
|
///
|
||||||
|
/// The NIP-17 / NIP-59 gift-wrap onion:
|
||||||
|
/// rumor (kind 14, UNSIGNED) — the real message; author = real sender
|
||||||
|
/// seal (kind 13, signed by sender) — NIP-44(rumor) to recipient
|
||||||
|
/// wrap (kind 1059, signed by a throwaway EPHEMERAL key) — NIP-44(seal)
|
||||||
|
/// The relay and any observer see only the wrap: a random author, a `p` tag for
|
||||||
|
/// the recipient, and opaque ciphertext. The real sender is hidden until the
|
||||||
|
/// recipient unwraps.
|
||||||
|
class NostrMessageTransport implements MessageTransport {
|
||||||
|
NostrMessageTransport(this._conn);
|
||||||
|
|
||||||
|
final NostrConnection _conn;
|
||||||
|
|
||||||
|
static const kindRumor = 14;
|
||||||
|
static const kindSeal = 13;
|
||||||
|
static const kindGiftWrap = 1059;
|
||||||
|
|
||||||
|
final _rng = Random.secure();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> send({required String toPubkey, required String text}) async {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
// 1. Rumor — the real, UNSIGNED message from the real sender.
|
||||||
|
final rumor = NostrEvent(
|
||||||
|
pubkey: _conn.publicKeyHex,
|
||||||
|
createdAt: now,
|
||||||
|
kind: kindRumor,
|
||||||
|
tags: [
|
||||||
|
['p', toPubkey],
|
||||||
|
],
|
||||||
|
content: text,
|
||||||
|
);
|
||||||
|
rumor.id = rumor.computeId(); // id but no signature (NIP-59)
|
||||||
|
|
||||||
|
// 2. Seal — sender-signed, NIP-44 to recipient.
|
||||||
|
final sealKey = Nip44.conversationKey(_conn.privateKeyHex, toPubkey);
|
||||||
|
final seal = NostrEvent(
|
||||||
|
pubkey: _conn.publicKeyHex,
|
||||||
|
createdAt: now,
|
||||||
|
kind: kindSeal,
|
||||||
|
tags: const [],
|
||||||
|
content: Nip44.encrypt(sealKey, jsonEncode(rumor.toJson())),
|
||||||
|
)..signWith(_conn.privateKeyHex);
|
||||||
|
|
||||||
|
// 3. Gift wrap — signed by a THROWAWAY ephemeral key, NIP-44 to recipient.
|
||||||
|
final eph = _ephemeralKey();
|
||||||
|
final wrapKey = Nip44.conversationKey(eph.priv, toPubkey);
|
||||||
|
final wrap = NostrEvent(
|
||||||
|
pubkey: eph.pub,
|
||||||
|
createdAt: now,
|
||||||
|
kind: kindGiftWrap,
|
||||||
|
tags: [
|
||||||
|
['p', toPubkey],
|
||||||
|
],
|
||||||
|
content: Nip44.encrypt(wrapKey, jsonEncode(seal.toJson())),
|
||||||
|
)..signWith(eph.priv);
|
||||||
|
|
||||||
|
final r = await _conn.publish(wrap);
|
||||||
|
if (!r.accepted) {
|
||||||
|
throw StateError('relay rejected gift wrap: ${r.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<DirectMessage> inbox() {
|
||||||
|
final filter = {
|
||||||
|
'kinds': [kindGiftWrap],
|
||||||
|
'#p': [_conn.publicKeyHex],
|
||||||
|
};
|
||||||
|
return _conn.subscribe(filter).map(_unwrap).where((m) => m != null).cast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect the inbox up to EOSE (tests/metrics).
|
||||||
|
Future<List<DirectMessage>> inboxUntilEose() async {
|
||||||
|
final wraps = await _conn.reqOnce({
|
||||||
|
'kinds': [kindGiftWrap],
|
||||||
|
'#p': [_conn.publicKeyHex],
|
||||||
|
});
|
||||||
|
return wraps.map(_unwrap).whereType<DirectMessage>().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectMessage? _unwrap(NostrEvent wrap) {
|
||||||
|
try {
|
||||||
|
// Unwrap with our key + the ephemeral (wrap) author.
|
||||||
|
final wrapKey = Nip44.conversationKey(_conn.privateKeyHex, wrap.pubkey);
|
||||||
|
final seal = NostrEvent.fromJson(
|
||||||
|
jsonDecode(Nip44.decrypt(wrapKey, wrap.content)) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
// Open the seal with our key + the seal (real sender) author.
|
||||||
|
final sealKey = Nip44.conversationKey(_conn.privateKeyHex, seal.pubkey);
|
||||||
|
final rumor = NostrEvent.fromJson(
|
||||||
|
jsonDecode(Nip44.decrypt(sealKey, seal.content)) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
// Authenticity: the seal is signed by the sender, and the inner rumor
|
||||||
|
// must claim the SAME author. Otherwise someone re-wrapped a foreign seal.
|
||||||
|
if (rumor.pubkey != seal.pubkey || !seal.verify()) return null;
|
||||||
|
return DirectMessage(
|
||||||
|
fromPubkey: rumor.pubkey,
|
||||||
|
text: rumor.content,
|
||||||
|
at: DateTime.fromMillisecondsSinceEpoch(rumor.createdAt * 1000),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null; // not for us / wrong key / tampered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
({String priv, String pub}) _ephemeralKey() {
|
||||||
|
final bytes = Uint8List.fromList(
|
||||||
|
List.generate(32, (_) => _rng.nextInt(256)),
|
||||||
|
);
|
||||||
|
final priv = bytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return (priv: priv, pub: bip340.getPublicKey(priv));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() => _conn.close();
|
||||||
|
}
|
||||||
|
|
@ -1,152 +1,71 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'nip99.dart';
|
import 'nip99.dart';
|
||||||
import 'nostr_event.dart';
|
import 'nostr_connection.dart';
|
||||||
import 'offer.dart';
|
import 'offer.dart';
|
||||||
import 'offer_transport.dart';
|
import 'offer_transport.dart';
|
||||||
|
|
||||||
/// Nostr NIP-99 backend for [OfferTransport], over a single relay connection.
|
/// Nostr NIP-99 backend for [OfferTransport], on a shared [NostrConnection]
|
||||||
///
|
/// (Q2/Q3). Signs offers with the derived key (Q1) and publishes them as
|
||||||
/// Signs every offer with the caller's derived Nostr key (Q1) and publishes it
|
/// kind-30402 classified listings; discovery is a REQ filtered by kind + `#g`.
|
||||||
/// as a kind-30402 classified listing (Q2/Q3). Discovery is a REQ filtered by
|
|
||||||
/// kind + `#g` geohash tag. This is the "one protocol" probe for Q3: it works,
|
|
||||||
/// but notice it needs a live socket, an OK handshake, and EOSE handling — the
|
|
||||||
/// same plumbing NIP-17 messaging and NIP-85 trust would each reuse.
|
|
||||||
class NostrOfferTransport implements OfferTransport {
|
class NostrOfferTransport implements OfferTransport {
|
||||||
NostrOfferTransport._(this._socket, this._privateKeyHex, this._pubkeyHex) {
|
NostrOfferTransport(this._conn);
|
||||||
_socket.listen(_onData, onDone: _onDone);
|
|
||||||
}
|
|
||||||
|
|
||||||
final WebSocket _socket;
|
final NostrConnection _conn;
|
||||||
final String _privateKeyHex;
|
|
||||||
final String _pubkeyHex;
|
|
||||||
final Nip99Codec _codec = Nip99Codec();
|
final Nip99Codec _codec = Nip99Codec();
|
||||||
|
|
||||||
final _incoming = StreamController<List<dynamic>>.broadcast();
|
/// Convenience: open a dedicated connection for this transport.
|
||||||
int _subCounter = 0;
|
|
||||||
|
|
||||||
/// Connects to [relayUrl] and returns a transport signing as [pubkeyHex]
|
|
||||||
/// (BIP340 x-only) with [privateKeyHex]. Both come from `NostrKey`.
|
|
||||||
static Future<NostrOfferTransport> connect(
|
static Future<NostrOfferTransport> connect(
|
||||||
String relayUrl, {
|
String relayUrl, {
|
||||||
required String privateKeyHex,
|
required String privateKeyHex,
|
||||||
required String pubkeyHex,
|
required String pubkeyHex,
|
||||||
}) async {
|
}) async =>
|
||||||
final socket = await WebSocket.connect(relayUrl);
|
NostrOfferTransport(await NostrConnection.connect(
|
||||||
return NostrOfferTransport._(socket, privateKeyHex, pubkeyHex);
|
relayUrl,
|
||||||
}
|
privateKeyHex: privateKeyHex,
|
||||||
|
publicKeyHex: pubkeyHex,
|
||||||
|
));
|
||||||
|
|
||||||
void _onData(dynamic data) => _incoming.add(jsonDecode(data as String) as List);
|
Map<String, dynamic> _filter(DiscoveryQuery q) => {
|
||||||
void _onDone() {
|
'kinds': [Nip99Codec.kindActive],
|
||||||
if (!_incoming.isClosed) _incoming.close();
|
'#g': [q.geohashPrefix],
|
||||||
}
|
'limit': q.limit,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PublishResult> publish(Offer offer) async {
|
Future<PublishResult> publish(Offer offer) async {
|
||||||
final event = _codec.encode(
|
final event = _codec.encode(
|
||||||
offer,
|
offer,
|
||||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
);
|
)..signWith(_conn.privateKeyHex);
|
||||||
event.signWith(_privateKeyHex);
|
final r = await _conn.publish(event);
|
||||||
|
|
||||||
final ok = _incoming.stream.firstWhere(
|
|
||||||
(m) => m[0] == 'OK' && m[1] == event.id,
|
|
||||||
);
|
|
||||||
_socket.add(jsonEncode(['EVENT', event.toJson()]));
|
|
||||||
final response = await ok.timeout(const Duration(seconds: 5));
|
|
||||||
return PublishResult(
|
return PublishResult(
|
||||||
accepted: response[2] as bool,
|
accepted: r.accepted,
|
||||||
transportRef: event.id,
|
transportRef: event.id,
|
||||||
message: response.length > 3 ? response[3] as String : '',
|
message: r.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<Offer> discover(DiscoveryQuery query) {
|
Stream<Offer> discover(DiscoveryQuery query) => _conn.subscribe(_filter(query)).map(_codec.decode).where(
|
||||||
final subId = 'sub${_subCounter++}';
|
(o) => query.types.isEmpty || query.types.contains(o.type),
|
||||||
final filter = <String, dynamic>{
|
|
||||||
'kinds': [Nip99Codec.kindActive],
|
|
||||||
'#g': [query.geohashPrefix],
|
|
||||||
'limit': query.limit,
|
|
||||||
};
|
|
||||||
|
|
||||||
late StreamController<Offer> controller;
|
|
||||||
controller = StreamController<Offer>(
|
|
||||||
onListen: () => _socket.add(jsonEncode(['REQ', subId, filter])),
|
|
||||||
onCancel: () {
|
|
||||||
_socket.add(jsonEncode(['CLOSE', subId]));
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final sub = _incoming.stream.listen((m) {
|
/// Collect matches up to EOSE (for tests/metrics).
|
||||||
if (m[0] == 'EVENT' && m[1] == subId) {
|
|
||||||
final offer = _codec.decode(
|
|
||||||
NostrEvent.fromJson(m[2] as Map<String, dynamic>),
|
|
||||||
);
|
|
||||||
if (query.types.isEmpty || query.types.contains(offer.type)) {
|
|
||||||
controller.add(offer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// EOSE arrives here too; we keep the stream open for live offers.
|
|
||||||
});
|
|
||||||
controller.onCancel = () {
|
|
||||||
_socket.add(jsonEncode(['CLOSE', subId]));
|
|
||||||
sub.cancel();
|
|
||||||
};
|
|
||||||
return controller.stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience for tests/metrics: collect matches up to EOSE, then stop.
|
|
||||||
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
|
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
|
||||||
final subId = 'once${_subCounter++}';
|
final events = await _conn.reqOnce(_filter(query));
|
||||||
final filter = <String, dynamic>{
|
return events
|
||||||
'kinds': [Nip99Codec.kindActive],
|
.map(_codec.decode)
|
||||||
'#g': [query.geohashPrefix],
|
.where((o) => query.types.isEmpty || query.types.contains(o.type))
|
||||||
'limit': query.limit,
|
.toList();
|
||||||
};
|
|
||||||
final results = <Offer>[];
|
|
||||||
final done = Completer<void>();
|
|
||||||
final sub = _incoming.stream.listen((m) {
|
|
||||||
if (m[1] != subId) return;
|
|
||||||
if (m[0] == 'EVENT') {
|
|
||||||
final offer = _codec.decode(
|
|
||||||
NostrEvent.fromJson(m[2] as Map<String, dynamic>),
|
|
||||||
);
|
|
||||||
if (query.types.isEmpty || query.types.contains(offer.type)) {
|
|
||||||
results.add(offer);
|
|
||||||
}
|
|
||||||
} else if (m[0] == 'EOSE' && !done.isCompleted) {
|
|
||||||
done.complete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_socket.add(jsonEncode(['REQ', subId, filter]));
|
|
||||||
await done.future.timeout(const Duration(seconds: 5));
|
|
||||||
_socket.add(jsonEncode(['CLOSE', subId]));
|
|
||||||
await sub.cancel();
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> retract(String offerId) async {
|
Future<void> retract(String offerId) async {
|
||||||
// NIP-09 deletion request referencing the addressable coordinate.
|
// A NIP-09 deletion of the addressable coordinate would go here; the OK-path
|
||||||
final event = NostrEvent(
|
// is identical to publish. Omitted from the spike surface.
|
||||||
pubkey: _pubkeyHex,
|
|
||||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
||||||
kind: 5,
|
|
||||||
tags: [
|
|
||||||
['a', '${Nip99Codec.kindActive}:$_pubkeyHex:$offerId'],
|
|
||||||
],
|
|
||||||
content: 'offer retracted',
|
|
||||||
);
|
|
||||||
event.signWith(_privateKeyHex);
|
|
||||||
_socket.add(jsonEncode(['EVENT', event.toJson()]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() => _conn.close();
|
||||||
await _socket.close();
|
|
||||||
if (!_incoming.isClosed) await _incoming.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ dependencies:
|
||||||
# Throwaway crypto/transport deps — would NOT be added like this to prod.
|
# Throwaway crypto/transport deps — would NOT be added like this to prod.
|
||||||
crypto: ^3.0.0 # sha256 + hmac for HKDF and event ids
|
crypto: ^3.0.0 # sha256 + hmac for HKDF and event ids
|
||||||
bip340: ^0.2.0 # secp256k1 x-only pubkeys + BIP340 Schnorr (Nostr signatures)
|
bip340: ^0.2.0 # secp256k1 x-only pubkeys + BIP340 Schnorr (Nostr signatures)
|
||||||
|
pointycastle: ^3.9.0 # secp256k1 ECDH + ChaCha20 for NIP-44 (messaging spike)
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
test: ^1.25.6
|
test: ^1.25.6
|
||||||
|
|
|
||||||
146
spike/block2_spike/test/messaging_test.dart
Normal file
146
spike/block2_spike/test/messaging_test.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:block2_spike/block2_spike.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// Q3 (hardest piece) — NIP-17 private messaging: does the gift-wrap flow work
|
||||||
|
/// end to end, stay unreadable to the relay, and hide the sender's identity?
|
||||||
|
void main() {
|
||||||
|
NostrKey keyFor(int fill) =>
|
||||||
|
NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
|
||||||
|
|
||||||
|
group('NIP-44 encryption', () {
|
||||||
|
test('conversation key is symmetric (A→B == B→A)', () {
|
||||||
|
final a = keyFor(1);
|
||||||
|
final b = keyFor(2);
|
||||||
|
final ab = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
|
||||||
|
final ba = Nip44.conversationKey(b.privateKeyHex, a.publicKeyHex);
|
||||||
|
expect(ab, ba);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips text, and a wrong key cannot decrypt', () {
|
||||||
|
final a = keyFor(1);
|
||||||
|
final b = keyFor(2);
|
||||||
|
final eve = keyFor(9);
|
||||||
|
final key = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
|
||||||
|
final payload = Nip44.encrypt(key, 'tengo tomate rosa, ¿te va bien?');
|
||||||
|
expect(Nip44.decrypt(key, payload), 'tengo tomate rosa, ¿te va bien?');
|
||||||
|
|
||||||
|
final eveKey = Nip44.conversationKey(eve.privateKeyHex, b.publicKeyHex);
|
||||||
|
expect(() => Nip44.decrypt(eveKey, payload), throwsFormatException);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ciphertext length hides the exact plaintext length (padding)', () {
|
||||||
|
final a = keyFor(1);
|
||||||
|
final b = keyFor(2);
|
||||||
|
final key = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
|
||||||
|
final short = base64.decode(Nip44.encrypt(key, 'hi')).length;
|
||||||
|
final alsoShort = base64.decode(Nip44.encrypt(key, 'hello!')).length;
|
||||||
|
// Both pad to the same 32-byte bucket → equal payload sizes.
|
||||||
|
expect(short, alsoShort);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NIP-17 gift-wrapped DM over the relay', () {
|
||||||
|
late MiniRelay relay;
|
||||||
|
setUp(() async => relay = await MiniRelay.start());
|
||||||
|
tearDown(() async => relay.stop());
|
||||||
|
|
||||||
|
test('Alice → Bob: message arrives, sender is authenticated', () async {
|
||||||
|
final alice = keyFor(1);
|
||||||
|
final bob = keyFor(2);
|
||||||
|
|
||||||
|
final aliceConn = await NostrConnection.connect(
|
||||||
|
relay.url,
|
||||||
|
privateKeyHex: alice.privateKeyHex,
|
||||||
|
publicKeyHex: alice.publicKeyHex,
|
||||||
|
);
|
||||||
|
final bobConn = await NostrConnection.connect(
|
||||||
|
relay.url,
|
||||||
|
privateKeyHex: bob.privateKeyHex,
|
||||||
|
publicKeyHex: bob.publicKeyHex,
|
||||||
|
);
|
||||||
|
final aliceMsg = NostrMessageTransport(aliceConn);
|
||||||
|
final bobMsg = NostrMessageTransport(bobConn);
|
||||||
|
|
||||||
|
await aliceMsg.send(
|
||||||
|
toPubkey: bob.publicKeyHex,
|
||||||
|
text: '¿cambiamos semilla de calabaza?',
|
||||||
|
);
|
||||||
|
|
||||||
|
final inbox = await bobMsg.inboxUntilEose();
|
||||||
|
expect(inbox, hasLength(1));
|
||||||
|
expect(inbox.single.text, '¿cambiamos semilla de calabaza?');
|
||||||
|
// Authenticated as Alice, even though the wrap was signed by an ephemeral.
|
||||||
|
expect(inbox.single.fromPubkey, alice.publicKeyHex);
|
||||||
|
|
||||||
|
await aliceMsg.close();
|
||||||
|
await bobMsg.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the relay/eavesdropper sees neither plaintext nor the real sender',
|
||||||
|
() async {
|
||||||
|
final alice = keyFor(1);
|
||||||
|
final bob = keyFor(2);
|
||||||
|
final aliceConn = await NostrConnection.connect(
|
||||||
|
relay.url,
|
||||||
|
privateKeyHex: alice.privateKeyHex,
|
||||||
|
publicKeyHex: alice.publicKeyHex,
|
||||||
|
);
|
||||||
|
await NostrMessageTransport(aliceConn).send(
|
||||||
|
toPubkey: bob.publicKeyHex,
|
||||||
|
text: 'SECRET-PLAINTEXT-MARKER',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Snoop the relay's stored events directly (as a relay operator would).
|
||||||
|
final snoop = await NostrConnection.connect(
|
||||||
|
relay.url,
|
||||||
|
privateKeyHex: keyFor(7).privateKeyHex,
|
||||||
|
publicKeyHex: keyFor(7).publicKeyHex,
|
||||||
|
);
|
||||||
|
final wraps = await snoop.reqOnce({'kinds': [1059]});
|
||||||
|
expect(wraps, hasLength(1));
|
||||||
|
final wire = jsonEncode(wraps.single.toJson());
|
||||||
|
expect(wire, isNot(contains('SECRET-PLAINTEXT-MARKER')));
|
||||||
|
// The wrap's author is the ephemeral key, NOT Alice.
|
||||||
|
expect(wraps.single.pubkey, isNot(alice.publicKeyHex));
|
||||||
|
// Only the recipient is addressable (the `p` tag), never the sender.
|
||||||
|
expect(wraps.single.tag('p'), bob.publicKeyHex);
|
||||||
|
|
||||||
|
await aliceConn.close();
|
||||||
|
await snoop.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a third party cannot unwrap a message not addressed to them',
|
||||||
|
() async {
|
||||||
|
final alice = keyFor(1);
|
||||||
|
final bob = keyFor(2);
|
||||||
|
final mallory = keyFor(5);
|
||||||
|
final aliceConn = await NostrConnection.connect(
|
||||||
|
relay.url,
|
||||||
|
privateKeyHex: alice.privateKeyHex,
|
||||||
|
publicKeyHex: alice.publicKeyHex,
|
||||||
|
);
|
||||||
|
await NostrMessageTransport(aliceConn).send(
|
||||||
|
toPubkey: bob.publicKeyHex,
|
||||||
|
text: 'solo para Bob',
|
||||||
|
);
|
||||||
|
// Mallory subscribes to 1059 with no p-filter and tries to unwrap.
|
||||||
|
final malloryConn = await NostrConnection.connect(
|
||||||
|
relay.url,
|
||||||
|
privateKeyHex: mallory.privateKeyHex,
|
||||||
|
publicKeyHex: mallory.publicKeyHex,
|
||||||
|
);
|
||||||
|
final malloryMsg = NostrMessageTransport(malloryConn);
|
||||||
|
final got = await malloryMsg.inboxUntilEose();
|
||||||
|
// Her inbox filters on her own `p` tag, so the relay never even hands her
|
||||||
|
// Bob's wrap; and the eavesdropper test proves that even raw snooping
|
||||||
|
// yields only ciphertext. Two layers, same outcome: she sees nothing.
|
||||||
|
expect(got, isEmpty, reason: 'not addressed to Mallory (p-tag scoping)');
|
||||||
|
|
||||||
|
await aliceConn.close();
|
||||||
|
await malloryConn.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue