feat(plantare): PlantareTransport foundation in commons_core
The bilateral signed Plantaré's transport layer — the "formulario bilateral firmado" from plantare-bilateral.md — as a new interface alongside the other *Transport contracts, on the shared NostrConnection. - PlantarePledge: the canonical, byte-stable core (debtor/creditor keys, seed label, return kind, dates) plus the two Schnorr stubs. Any edit changes the signing hash, so an accept is always over an immutable payload. - PlantareCrypto: BIP-340 signing/verification over SHA-256 of the core, reusing the same primitive Nostr uses for events — a pubkey both identifies a party and verifies their stub. - PlantareTransport + NostrPlantareTransport: propose → accept → counter-sign (and decline), private end-to-end. Rides the existing NIP-17 gift wrap carrying a tagged JSON payload, so the relay sees only ciphertext and an ephemeral author. Offline QR/NFC is the same payload without a relay. - Message transport skips Plantaré payloads on the shared kind-1059 channel (and vice-versa), so moves never surface as chat. Tests: pure pledge/crypto + full handshake over a hermetic relay, including no-leak and no-chat-bleed. commons_core 101/101 green.
This commit is contained in:
parent
baa8867287
commit
516f1f50bc
7 changed files with 726 additions and 0 deletions
|
|
@ -21,6 +21,7 @@ export 'src/social/nostr/nostr_sync_transport.dart';
|
|||
export 'src/social/nostr/nostr_connection.dart';
|
||||
export 'src/social/nostr/nostr_message_transport.dart';
|
||||
export 'src/social/nostr/nostr_offer_transport.dart';
|
||||
export 'src/social/nostr/nostr_plantare_transport.dart';
|
||||
export 'src/social/nostr/nostr_profile_transport.dart';
|
||||
export 'src/social/nostr/nostr_rating_transport.dart';
|
||||
export 'src/social/nostr/nostr_report_transport.dart';
|
||||
|
|
@ -28,6 +29,8 @@ export 'src/social/nostr/nostr_trust_transport.dart';
|
|||
export 'src/social/nostr/relay_pool.dart';
|
||||
export 'src/social/offer.dart';
|
||||
export 'src/social/offer_transport.dart';
|
||||
export 'src/social/plantare.dart';
|
||||
export 'src/social/plantare_transport.dart';
|
||||
export 'src/social/profile_transport.dart';
|
||||
export 'src/social/rating.dart';
|
||||
export 'src/social/rating_transport.dart';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../message_transport.dart';
|
||||
import 'nostr_channel.dart';
|
||||
import 'nostr_plantare_transport.dart';
|
||||
|
||||
/// NIP-17 private-DM backend for [MessageTransport], on the shared
|
||||
/// [NostrConnection]. The gift-wrap onion (rumor kind 14 → seal kind 13 → wrap
|
||||
|
|
@ -53,6 +55,7 @@ class NostrMessageTransport implements MessageTransport {
|
|||
giftWrap: wrap,
|
||||
recipientSecretKey: _conn.privateKeyHex,
|
||||
);
|
||||
if (_isPlantare(rumor.content)) return null; // handled by PlantareTransport
|
||||
return PrivateMessage(
|
||||
fromPubkey: rumor.pubkey,
|
||||
text: rumor.content,
|
||||
|
|
@ -63,6 +66,19 @@ class NostrMessageTransport implements MessageTransport {
|
|||
}
|
||||
}
|
||||
|
||||
/// A Plantaré move gift-wrapped on the SAME kind-1059 channel is not chat —
|
||||
/// it's a tagged JSON payload handled by [NostrPlantareTransport]. Skip it so
|
||||
/// it never surfaces as a message.
|
||||
static bool _isPlantare(String content) {
|
||||
try {
|
||||
final decoded = jsonDecode(content);
|
||||
return decoded is Map &&
|
||||
decoded['t'] == NostrPlantareTransport.wireTag;
|
||||
} catch (_) {
|
||||
return false; // ordinary text
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => _conn.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../plantare.dart';
|
||||
import '../plantare_transport.dart';
|
||||
import 'nostr_channel.dart';
|
||||
|
||||
/// NIP-17 private-DM backend for [PlantareTransport], on the shared channel.
|
||||
///
|
||||
/// A Plantaré move rides the same gift-wrapped, NIP-44-encrypted envelope as a
|
||||
/// DM (rumor kind 14 → seal kind 13 → wrap kind 1059 signed by an ephemeral
|
||||
/// key), carrying a canonical JSON payload instead of chat text. This is the
|
||||
/// least new surface — it reuses the vetted onion and the existing connection —
|
||||
/// and gives the same metadata privacy (the relay sees only ciphertext and an
|
||||
/// ephemeral author). The payload is tagged [wireTag] so a plain-chat inbox can
|
||||
/// tell the two apart and skip these (and vice-versa).
|
||||
class NostrPlantareTransport implements PlantareTransport {
|
||||
NostrPlantareTransport(this._conn);
|
||||
|
||||
final NostrChannel _conn;
|
||||
|
||||
static const kindGiftWrap = 1059;
|
||||
|
||||
/// Discriminator on the encrypted payload so a co-tenant chat inbox on the
|
||||
/// same kind-1059 channel can ignore Plantaré moves.
|
||||
static const wireTag = 'plantare/v1';
|
||||
|
||||
@override
|
||||
Future<void> propose(PlantarePledge pledge) =>
|
||||
_sendPledge(PlantareMessageKind.proposed, pledge);
|
||||
|
||||
@override
|
||||
Future<void> accept(PlantarePledge pledge) =>
|
||||
_sendPledge(PlantareMessageKind.accepted, pledge);
|
||||
|
||||
@override
|
||||
Future<void> decline({
|
||||
required String toPubkey,
|
||||
required String pledgeId,
|
||||
String reason = '',
|
||||
}) =>
|
||||
_sendTo(toPubkey, <String, Object?>{
|
||||
't': wireTag,
|
||||
'kind': PlantareMessageKind.declined.name,
|
||||
'pledgeId': pledgeId,
|
||||
'reason': reason,
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<PlantareEnvelope> incoming() => _conn
|
||||
.subscribe(
|
||||
nostr.Filter(kinds: const [kindGiftWrap], pTags: [_conn.publicKeyHex]),
|
||||
)
|
||||
.asyncMap(_unwrap)
|
||||
.where((e) => e != null)
|
||||
.cast<PlantareEnvelope>();
|
||||
|
||||
/// Collects the incoming Plantaré moves up to EOSE (tests/one-shot).
|
||||
Future<List<PlantareEnvelope>> incomingUntilEose() async {
|
||||
final wraps = await _conn.reqOnce(
|
||||
nostr.Filter(kinds: const [kindGiftWrap], pTags: [_conn.publicKeyHex]),
|
||||
);
|
||||
final out = <PlantareEnvelope>[];
|
||||
for (final w in wraps) {
|
||||
final e = await _unwrap(w);
|
||||
if (e != null) out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> _sendPledge(
|
||||
PlantareMessageKind kind,
|
||||
PlantarePledge pledge,
|
||||
) async {
|
||||
final to = pledge.counterpartyOf(_conn.publicKeyHex);
|
||||
if (to == null) {
|
||||
throw ArgumentError('this identity is neither party to the pledge');
|
||||
}
|
||||
await _sendTo(to, <String, Object?>{
|
||||
't': wireTag,
|
||||
'kind': kind.name,
|
||||
'pledge': pledge.toJson(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendTo(String toPubkey, Map<String, Object?> payload) async {
|
||||
final wrap = await nostr.DirectMessage.create(
|
||||
message: jsonEncode(payload),
|
||||
authorSecretKey: _conn.privateKeyHex,
|
||||
recipientPubkey: toPubkey,
|
||||
);
|
||||
final r = await _conn.publish(wrap);
|
||||
if (!r.accepted) throw StateError('relay rejected plantare: ${r.message}');
|
||||
}
|
||||
|
||||
Future<PlantareEnvelope?> _unwrap(nostr.Event wrap) async {
|
||||
final nostr.Event rumor;
|
||||
try {
|
||||
rumor = await nostr.DirectMessage.parse(
|
||||
giftWrap: wrap,
|
||||
recipientSecretKey: _conn.privateKeyHex,
|
||||
);
|
||||
} catch (_) {
|
||||
return null; // not for us / wrong key / tampered
|
||||
}
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(rumor.content);
|
||||
} catch (_) {
|
||||
return null; // plain chat, not JSON — not ours
|
||||
}
|
||||
if (decoded is! Map || decoded['t'] != wireTag) return null;
|
||||
try {
|
||||
final kind = PlantareMessageKind.values.byName(decoded['kind'] as String);
|
||||
switch (kind) {
|
||||
case PlantareMessageKind.proposed:
|
||||
case PlantareMessageKind.accepted:
|
||||
return PlantareEnvelope(
|
||||
kind: kind,
|
||||
fromPubkey: rumor.pubkey,
|
||||
pledge:
|
||||
PlantarePledge.fromJson((decoded['pledge'] as Map).cast()),
|
||||
);
|
||||
case PlantareMessageKind.declined:
|
||||
return PlantareEnvelope(
|
||||
kind: kind,
|
||||
fromPubkey: rumor.pubkey,
|
||||
pledgeId: decoded['pledgeId'] as String?,
|
||||
reason: (decoded['reason'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
return null; // malformed payload
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => _conn.close();
|
||||
}
|
||||
264
packages/commons_core/lib/src/social/plantare.dart
Normal file
264
packages/commons_core/lib/src/social/plantare.dart
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:nostr/nostr.dart' show Schnorr;
|
||||
|
||||
/// How a Plantaré is promised back, mirroring the paper form's checkboxes
|
||||
/// (BAH-Semillero v4.0). [similar] means the default: open-pollinated · non-GMO
|
||||
/// · organically grown — surface that meaning, not just the word. [workHours] is
|
||||
/// time-as-currency ("un trabajo de N horas"). [other] is anything else, carried
|
||||
/// in the free-text description. Generic core type — no seed specifics beyond the
|
||||
/// name.
|
||||
enum ReturnKind { similar, workHours, other }
|
||||
|
||||
/// The three moves of the bilateral handshake: a proposer signs and [proposed],
|
||||
/// the counterparty counter-signs and [accepted], or [declined] it.
|
||||
enum PlantareMessageKind { proposed, accepted, declined }
|
||||
|
||||
/// One bilateral Plantaré — the digital form of the paper's two matching signed
|
||||
/// stubs ("partida doble"). Both parties end up holding the *same* record, each
|
||||
/// signed by both, so either can prove "recibí X, devolveré una cantidad similar
|
||||
/// antes de la fecha Y".
|
||||
///
|
||||
/// The debtor received the seed and owes a return; the creditor gave it. Which
|
||||
/// side is which is carried by [debtorKey]/[creditorKey], so no separate
|
||||
/// direction field is needed. The two signatures cover the *immutable core* —
|
||||
/// everything except the signatures themselves ([canonicalString]) — so any edit
|
||||
/// after proposing changes the [pledgeId] and forces a fresh proposal (accept is
|
||||
/// over an immutable hash).
|
||||
///
|
||||
/// Generic core type: it knows keys, a human seed [label] and a promise; it does
|
||||
/// NOT know the app's local `Variety`/`Movement` ids (those live app-side).
|
||||
class PlantarePledge extends Equatable {
|
||||
const PlantarePledge({
|
||||
required this.pledgeId,
|
||||
required this.debtorKey,
|
||||
required this.creditorKey,
|
||||
required this.madeOn,
|
||||
this.label,
|
||||
this.owedDescription,
|
||||
this.returnKind = ReturnKind.similar,
|
||||
this.workHours,
|
||||
this.dueBy,
|
||||
this.debtorSignature,
|
||||
this.creditorSignature,
|
||||
});
|
||||
|
||||
/// Stable id (proposer's UUIDv7). Part of the signed core, so it also pins the
|
||||
/// exact terms: a changed pledge is a different id.
|
||||
final String pledgeId;
|
||||
|
||||
/// Pubkey (hex) of who received the seed and owes a return.
|
||||
final String debtorKey;
|
||||
|
||||
/// Pubkey (hex) of who gave the seed.
|
||||
final String creditorKey;
|
||||
|
||||
/// When the promise was made.
|
||||
final DateTime madeOn;
|
||||
|
||||
/// Human name of the seed the promise is about (the counterparty may not have
|
||||
/// it catalogued, so we carry the label, not a local id). Optional.
|
||||
final String? label;
|
||||
|
||||
/// What's promised back in the grower's own words — backs [ReturnKind.other]
|
||||
/// and enriches the others.
|
||||
final String? owedDescription;
|
||||
|
||||
final ReturnKind returnKind;
|
||||
|
||||
/// Hours for [ReturnKind.workHours] (time-as-currency). Null otherwise.
|
||||
final double? workHours;
|
||||
|
||||
/// Optional return-by date — a gentle reminder matched to cultivation cycles,
|
||||
/// not a deadline.
|
||||
final DateTime? dueBy;
|
||||
|
||||
/// Schnorr signature (hex) of the debtor over [signingHash]. Null until signed.
|
||||
final String? debtorSignature;
|
||||
|
||||
/// Schnorr signature (hex) of the creditor over [signingHash]. Null until
|
||||
/// signed.
|
||||
final String? creditorSignature;
|
||||
|
||||
/// Whether both parties have signed — a fully-closed, provable deal.
|
||||
bool get isFullySigned =>
|
||||
debtorSignature != null && creditorSignature != null;
|
||||
|
||||
/// The other party to [me] (whichever key isn't mine), or null if [me] is
|
||||
/// neither — used by the transport to route without a separate recipient arg.
|
||||
String? counterpartyOf(String me) {
|
||||
if (me == debtorKey) return creditorKey;
|
||||
if (me == creditorKey) return debtorKey;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The immutable core, serialized deterministically (sorted keys, no
|
||||
/// signatures). Both parties sign the hash of exactly this string, so it must
|
||||
/// be byte-stable across devices and versions.
|
||||
String canonicalString() => jsonEncode(<String, Object?>{
|
||||
'v': 1,
|
||||
'pledgeId': pledgeId,
|
||||
'debtorKey': debtorKey,
|
||||
'creditorKey': creditorKey,
|
||||
'madeOn': madeOn.millisecondsSinceEpoch,
|
||||
'label': label,
|
||||
'owedDescription': owedDescription,
|
||||
'returnKind': returnKind.name,
|
||||
'workHours': workHours,
|
||||
'dueBy': dueBy?.millisecondsSinceEpoch,
|
||||
});
|
||||
|
||||
PlantarePledge copyWith({
|
||||
String? debtorSignature,
|
||||
String? creditorSignature,
|
||||
}) =>
|
||||
PlantarePledge(
|
||||
pledgeId: pledgeId,
|
||||
debtorKey: debtorKey,
|
||||
creditorKey: creditorKey,
|
||||
madeOn: madeOn,
|
||||
label: label,
|
||||
owedDescription: owedDescription,
|
||||
returnKind: returnKind,
|
||||
workHours: workHours,
|
||||
dueBy: dueBy,
|
||||
debtorSignature: debtorSignature ?? this.debtorSignature,
|
||||
creditorSignature: creditorSignature ?? this.creditorSignature,
|
||||
);
|
||||
|
||||
/// Full wire form (the canonical core plus whatever signatures exist so far).
|
||||
Map<String, Object?> toJson() => <String, Object?>{
|
||||
'pledgeId': pledgeId,
|
||||
'debtorKey': debtorKey,
|
||||
'creditorKey': creditorKey,
|
||||
'madeOn': madeOn.millisecondsSinceEpoch,
|
||||
'label': label,
|
||||
'owedDescription': owedDescription,
|
||||
'returnKind': returnKind.name,
|
||||
'workHours': workHours,
|
||||
'dueBy': dueBy?.millisecondsSinceEpoch,
|
||||
'debtorSignature': debtorSignature,
|
||||
'creditorSignature': creditorSignature,
|
||||
};
|
||||
|
||||
static PlantarePledge fromJson(Map<String, Object?> json) => PlantarePledge(
|
||||
pledgeId: json['pledgeId']! as String,
|
||||
debtorKey: json['debtorKey']! as String,
|
||||
creditorKey: json['creditorKey']! as String,
|
||||
madeOn:
|
||||
DateTime.fromMillisecondsSinceEpoch((json['madeOn']! as num).toInt()),
|
||||
label: json['label'] as String?,
|
||||
owedDescription: json['owedDescription'] as String?,
|
||||
returnKind: ReturnKind.values.byName(json['returnKind'] as String),
|
||||
workHours: (json['workHours'] as num?)?.toDouble(),
|
||||
dueBy: json['dueBy'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch((json['dueBy']! as num).toInt()),
|
||||
debtorSignature: json['debtorSignature'] as String?,
|
||||
creditorSignature: json['creditorSignature'] as String?,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
pledgeId,
|
||||
debtorKey,
|
||||
creditorKey,
|
||||
madeOn,
|
||||
label,
|
||||
owedDescription,
|
||||
returnKind,
|
||||
workHours,
|
||||
dueBy,
|
||||
debtorSignature,
|
||||
creditorSignature,
|
||||
];
|
||||
}
|
||||
|
||||
/// Signing/verification over a [PlantarePledge]'s canonical core. Each signature
|
||||
/// is a BIP-340 Schnorr signature (the same primitive Nostr uses for events)
|
||||
/// over the SHA-256 of [PlantarePledge.canonicalString], so a Nostr pubkey both
|
||||
/// identifies a party and verifies their stub.
|
||||
abstract final class PlantareCrypto {
|
||||
/// The 32-byte (hex) message both parties sign: SHA-256 of the canonical core.
|
||||
static Future<String> signingHash(PlantarePledge pledge) async {
|
||||
final digest =
|
||||
await Sha256().hash(utf8.encode(pledge.canonicalString()));
|
||||
return _toHex(digest.bytes);
|
||||
}
|
||||
|
||||
/// Signs [pledge]'s core with [secretKeyHex]; returns the hex signature. The
|
||||
/// caller stores it on the debtor or creditor slot depending on which key it
|
||||
/// belongs to.
|
||||
static Future<String> sign(
|
||||
PlantarePledge pledge,
|
||||
String secretKeyHex,
|
||||
) async =>
|
||||
Schnorr.sign(secretKey: secretKeyHex, message: await signingHash(pledge));
|
||||
|
||||
/// Verifies [signature] was made over [pledge]'s core by [publicKeyHex].
|
||||
static Future<bool> verify(
|
||||
PlantarePledge pledge,
|
||||
String publicKeyHex,
|
||||
String signature,
|
||||
) async {
|
||||
try {
|
||||
return Schnorr.verify(
|
||||
publicKey: publicKeyHex,
|
||||
message: await signingHash(pledge),
|
||||
signature: signature,
|
||||
);
|
||||
} catch (_) {
|
||||
return false; // malformed key/sig → not valid
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether both stubs verify against their respective keys — a provably closed
|
||||
/// deal, the strong anchor for a rating.
|
||||
static Future<bool> verifyBoth(PlantarePledge pledge) async {
|
||||
final d = pledge.debtorSignature;
|
||||
final c = pledge.creditorSignature;
|
||||
if (d == null || c == null) return false;
|
||||
return await verify(pledge, pledge.debtorKey, d) &&
|
||||
await verify(pledge, pledge.creditorKey, c);
|
||||
}
|
||||
|
||||
static String _toHex(List<int> bytes) {
|
||||
final sb = StringBuffer();
|
||||
for (final b in bytes) {
|
||||
sb.write(b.toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// One decrypted Plantaré move handed to the app: a [proposed] or [accepted]
|
||||
/// pledge, or a [declined] notice. [fromPubkey] is the authenticated sender
|
||||
/// (from the inner rumor, not the gift-wrap's ephemeral key).
|
||||
class PlantareEnvelope extends Equatable {
|
||||
const PlantareEnvelope({
|
||||
required this.kind,
|
||||
required this.fromPubkey,
|
||||
this.pledge,
|
||||
this.pledgeId,
|
||||
this.reason = '',
|
||||
});
|
||||
|
||||
final PlantareMessageKind kind;
|
||||
final String fromPubkey;
|
||||
|
||||
/// The pledge for [PlantareMessageKind.proposed]/[accepted]; null for a
|
||||
/// decline, which carries only [pledgeId] and [reason].
|
||||
final PlantarePledge? pledge;
|
||||
|
||||
/// The declined pledge's id (decline carries no full pledge).
|
||||
final String? pledgeId;
|
||||
final String reason;
|
||||
|
||||
/// The pledge id this envelope concerns, from whichever field holds it.
|
||||
String? get id => pledge?.pledgeId ?? pledgeId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [kind, fromPubkey, pledge, pledgeId, reason];
|
||||
}
|
||||
37
packages/commons_core/lib/src/social/plantare_transport.dart
Normal file
37
packages/commons_core/lib/src/social/plantare_transport.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import 'plantare.dart';
|
||||
|
||||
/// The bilateral-Plantaré contract over the shared connection: propose a signed
|
||||
/// stub, counter-sign it, or decline. Unlike ratings/certifications (public
|
||||
/// signed events), a Plantaré is PRIVATE to the two parties, so — like DMs and
|
||||
/// sync — it is encrypted end-to-end. The recipient is inferred from the
|
||||
/// pledge's debtor/creditor keys (whichever isn't this identity), so there is no
|
||||
/// separate address argument.
|
||||
///
|
||||
/// Handshake: propose → accept → counter-sign. A proposes (A's signature only,
|
||||
/// encrypted to B); B reviews and accepts (adds B's signature), sending the
|
||||
/// doubly-signed copy back; both persist the identical, fully-signed row.
|
||||
///
|
||||
/// Channel-agnostic: the default backend rides the existing NIP-17 encrypted DM,
|
||||
/// but the same canonical payload works device-to-device over QR/NFC (offline
|
||||
/// must fully work; relays only enrich).
|
||||
abstract interface class PlantareTransport {
|
||||
/// Sends a signed proposal (my signature only) to the pledge's counterparty.
|
||||
Future<void> propose(PlantarePledge pledge);
|
||||
|
||||
/// Counter-signs a received proposal and sends the fully-signed copy back.
|
||||
Future<void> accept(PlantarePledge pledge);
|
||||
|
||||
/// Declines a received proposal, notifying its proposer [toPubkey], optionally
|
||||
/// with a short [reason]. (A decline carries no pledge, so unlike propose/
|
||||
/// accept the recipient can't be inferred and is passed explicitly.)
|
||||
Future<void> decline({
|
||||
required String toPubkey,
|
||||
required String pledgeId,
|
||||
String reason,
|
||||
});
|
||||
|
||||
/// Incoming proposals / accepts / declines addressed to this identity, live.
|
||||
Stream<PlantareEnvelope> incoming();
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/mini_relay.dart';
|
||||
|
||||
/// The bilateral handshake over a hermetic in-process relay: A proposes a signed
|
||||
/// stub encrypted to B, B counter-signs and returns the doubly-signed copy, and
|
||||
/// the payload never leaks to the relay or into the plain-chat inbox.
|
||||
void main() {
|
||||
late MiniRelay relay;
|
||||
setUp(() async => relay = await MiniRelay.start());
|
||||
tearDown(() async => relay.stop());
|
||||
|
||||
Future<NostrIdentity> idFor(int fill) => NostrKeyDerivation.deriveFromSeed(
|
||||
Uint8List(32)..fillRange(0, 32, fill),
|
||||
);
|
||||
Future<NostrConnection> connFor(NostrIdentity id) =>
|
||||
NostrConnection.connect(relay.url, identity: id);
|
||||
|
||||
PlantarePledge proposalFrom(NostrIdentity creditor, NostrIdentity debtor) =>
|
||||
PlantarePledge(
|
||||
pledgeId: '018f-plantare-1',
|
||||
debtorKey: debtor.publicKeyHex,
|
||||
creditorKey: creditor.publicKeyHex,
|
||||
madeOn: DateTime.fromMillisecondsSinceEpoch(1700000000000),
|
||||
label: 'Tomate rosa de Barbastro',
|
||||
owedDescription: 'un puñado la próxima temporada',
|
||||
);
|
||||
|
||||
test('propose → accept: both hold the same doubly-signed pledge', () async {
|
||||
// Creditor (gave the seed) proposes; debtor (owes a return) accepts.
|
||||
final creditor = await idFor(1);
|
||||
final debtor = await idFor(2);
|
||||
final creditorT = NostrPlantareTransport(await connFor(creditor));
|
||||
final debtorT = NostrPlantareTransport(await connFor(debtor));
|
||||
|
||||
var pledge = proposalFrom(creditor, debtor);
|
||||
pledge = pledge.copyWith(
|
||||
creditorSignature:
|
||||
await PlantareCrypto.sign(pledge, creditor.privateKeyHex),
|
||||
);
|
||||
await creditorT.propose(pledge);
|
||||
|
||||
// Debtor receives the proposal, verifies the creditor's stub, counter-signs.
|
||||
final incoming = await debtorT.incomingUntilEose();
|
||||
expect(incoming, hasLength(1));
|
||||
expect(incoming.single.kind, PlantareMessageKind.proposed);
|
||||
expect(incoming.single.fromPubkey, creditor.publicKeyHex);
|
||||
final received = incoming.single.pledge!;
|
||||
expect(
|
||||
await PlantareCrypto.verify(
|
||||
received, creditor.publicKeyHex, received.creditorSignature!),
|
||||
isTrue);
|
||||
|
||||
final countersigned = received.copyWith(
|
||||
debtorSignature: await PlantareCrypto.sign(received, debtor.privateKeyHex),
|
||||
);
|
||||
await debtorT.accept(countersigned);
|
||||
|
||||
// Creditor gets the fully-signed copy back — a provably closed deal.
|
||||
final back = await creditorT.incomingUntilEose();
|
||||
expect(back, hasLength(1));
|
||||
expect(back.single.kind, PlantareMessageKind.accepted);
|
||||
expect(await PlantareCrypto.verifyBoth(back.single.pledge!), isTrue);
|
||||
|
||||
await creditorT.close();
|
||||
await debtorT.close();
|
||||
});
|
||||
|
||||
test('decline notifies the proposer with a reason', () async {
|
||||
final creditor = await idFor(1);
|
||||
final debtor = await idFor(2);
|
||||
final creditorT = NostrPlantareTransport(await connFor(creditor));
|
||||
final debtorT = NostrPlantareTransport(await connFor(debtor));
|
||||
|
||||
await debtorT.decline(
|
||||
toPubkey: creditor.publicKeyHex,
|
||||
pledgeId: '018f-plantare-1',
|
||||
reason: 'ya no me caben',
|
||||
);
|
||||
|
||||
final got = await creditorT.incomingUntilEose();
|
||||
expect(got, hasLength(1));
|
||||
expect(got.single.kind, PlantareMessageKind.declined);
|
||||
expect(got.single.pledgeId, '018f-plantare-1');
|
||||
expect(got.single.reason, 'ya no me caben');
|
||||
expect(got.single.pledge, isNull);
|
||||
|
||||
await creditorT.close();
|
||||
await debtorT.close();
|
||||
});
|
||||
|
||||
test('the relay sees only ciphertext, never the terms', () async {
|
||||
final creditor = await idFor(1);
|
||||
final debtor = await idFor(2);
|
||||
final creditorT = NostrPlantareTransport(await connFor(creditor));
|
||||
|
||||
var pledge = proposalFrom(creditor, debtor);
|
||||
pledge = pledge.copyWith(
|
||||
creditorSignature:
|
||||
await PlantareCrypto.sign(pledge, creditor.privateKeyHex),
|
||||
);
|
||||
await creditorT.propose(pledge);
|
||||
|
||||
final wire = jsonEncode(relay.eventsOfKind(1059));
|
||||
expect(wire, isNot(contains('Tomate rosa')));
|
||||
expect(wire, isNot(contains('plantare/v1')));
|
||||
// Gift-wrap author is ephemeral, not the creditor.
|
||||
expect(relay.eventsOfKind(1059).single['pubkey'],
|
||||
isNot(creditor.publicKeyHex));
|
||||
|
||||
await creditorT.close();
|
||||
});
|
||||
|
||||
test('a stranger cannot read a proposal addressed to someone else', () async {
|
||||
final creditor = await idFor(1);
|
||||
final debtor = await idFor(2);
|
||||
final stranger = await idFor(9);
|
||||
final creditorT = NostrPlantareTransport(await connFor(creditor));
|
||||
final strangerT = NostrPlantareTransport(await connFor(stranger));
|
||||
|
||||
var pledge = proposalFrom(creditor, debtor);
|
||||
pledge = pledge.copyWith(
|
||||
creditorSignature:
|
||||
await PlantareCrypto.sign(pledge, creditor.privateKeyHex),
|
||||
);
|
||||
await creditorT.propose(pledge);
|
||||
|
||||
expect(await strangerT.incomingUntilEose(), isEmpty);
|
||||
|
||||
await creditorT.close();
|
||||
await strangerT.close();
|
||||
});
|
||||
|
||||
test('plain chat and Plantaré moves do not bleed into each other', () async {
|
||||
final creditor = await idFor(1);
|
||||
final debtor = await idFor(2);
|
||||
final creditorPlantare = NostrPlantareTransport(await connFor(creditor));
|
||||
final debtorPlantare = NostrPlantareTransport(await connFor(debtor));
|
||||
final creditorChat = NostrMessageTransport(await connFor(creditor));
|
||||
final debtorChat = NostrMessageTransport(await connFor(debtor));
|
||||
|
||||
// A Plantaré proposal…
|
||||
var pledge = proposalFrom(creditor, debtor);
|
||||
pledge = pledge.copyWith(
|
||||
creditorSignature:
|
||||
await PlantareCrypto.sign(pledge, creditor.privateKeyHex),
|
||||
);
|
||||
await creditorPlantare.propose(pledge);
|
||||
// …and an ordinary chat line, both on the same kind-1059 channel.
|
||||
await creditorChat.send(toPubkey: debtor.publicKeyHex, text: 'hola!');
|
||||
|
||||
// The chat inbox sees only the chat line.
|
||||
final chat = await debtorChat.inboxUntilEose();
|
||||
expect(chat, hasLength(1));
|
||||
expect(chat.single.text, 'hola!');
|
||||
|
||||
// The Plantaré inbox sees only the proposal.
|
||||
final moves = await debtorPlantare.incomingUntilEose();
|
||||
expect(moves, hasLength(1));
|
||||
expect(moves.single.kind, PlantareMessageKind.proposed);
|
||||
|
||||
await creditorPlantare.close();
|
||||
await debtorPlantare.close();
|
||||
await creditorChat.close();
|
||||
await debtorChat.close();
|
||||
});
|
||||
}
|
||||
94
packages/commons_core/test/social/plantare_test.dart
Normal file
94
packages/commons_core/test/social/plantare_test.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// The pure model + crypto of the bilateral Plantaré: a canonical, byte-stable
|
||||
/// core; two Schnorr stubs that verify against their keys; and any edit changing
|
||||
/// the signing hash (so an accept is over an immutable payload).
|
||||
void main() {
|
||||
Future<NostrIdentity> idFor(int fill) => NostrKeyDerivation.deriveFromSeed(
|
||||
Uint8List(32)..fillRange(0, 32, fill),
|
||||
);
|
||||
|
||||
PlantarePledge pledgeFor(NostrIdentity debtor, NostrIdentity creditor) =>
|
||||
PlantarePledge(
|
||||
pledgeId: '018f-plantare-1',
|
||||
debtorKey: debtor.publicKeyHex,
|
||||
creditorKey: creditor.publicKeyHex,
|
||||
madeOn: DateTime.fromMillisecondsSinceEpoch(1700000000000),
|
||||
label: 'Tomate rosa de Barbastro',
|
||||
owedDescription: 'un puñado la próxima temporada',
|
||||
dueBy: DateTime.fromMillisecondsSinceEpoch(1730000000000),
|
||||
);
|
||||
|
||||
test('the canonical core is stable and excludes the signatures', () async {
|
||||
final debtor = await idFor(1);
|
||||
final creditor = await idFor(2);
|
||||
final bare = pledgeFor(debtor, creditor);
|
||||
final signed = bare.copyWith(debtorSignature: 'deadbeef' * 8);
|
||||
|
||||
// Signing hash ignores the signatures — both sides sign the same message.
|
||||
expect(await PlantareCrypto.signingHash(bare),
|
||||
await PlantareCrypto.signingHash(signed));
|
||||
});
|
||||
|
||||
test('each stub verifies against its own key, not the other', () async {
|
||||
final debtor = await idFor(1);
|
||||
final creditor = await idFor(2);
|
||||
final pledge = pledgeFor(debtor, creditor);
|
||||
|
||||
final dSig = await PlantareCrypto.sign(pledge, debtor.privateKeyHex);
|
||||
final cSig = await PlantareCrypto.sign(pledge, creditor.privateKeyHex);
|
||||
|
||||
expect(await PlantareCrypto.verify(pledge, debtor.publicKeyHex, dSig), isTrue);
|
||||
expect(
|
||||
await PlantareCrypto.verify(pledge, creditor.publicKeyHex, cSig), isTrue);
|
||||
// Cross-checking a stub against the wrong key fails.
|
||||
expect(
|
||||
await PlantareCrypto.verify(pledge, creditor.publicKeyHex, dSig), isFalse);
|
||||
|
||||
final closed =
|
||||
pledge.copyWith(debtorSignature: dSig, creditorSignature: cSig);
|
||||
expect(await PlantareCrypto.verifyBoth(closed), isTrue);
|
||||
});
|
||||
|
||||
test('editing the terms after signing invalidates the stub', () async {
|
||||
final debtor = await idFor(1);
|
||||
final creditor = await idFor(2);
|
||||
final pledge = pledgeFor(debtor, creditor);
|
||||
final dSig = await PlantareCrypto.sign(pledge, debtor.privateKeyHex);
|
||||
|
||||
// Same object, different terms → different hash → old signature is void.
|
||||
final edited = PlantarePledge(
|
||||
pledgeId: pledge.pledgeId,
|
||||
debtorKey: pledge.debtorKey,
|
||||
creditorKey: pledge.creditorKey,
|
||||
madeOn: pledge.madeOn,
|
||||
label: pledge.label,
|
||||
owedDescription: 'DOS puñados', // changed
|
||||
);
|
||||
expect(await PlantareCrypto.verify(edited, debtor.publicKeyHex, dSig), isFalse);
|
||||
});
|
||||
|
||||
test('wire JSON round-trips every field including signatures', () async {
|
||||
final debtor = await idFor(1);
|
||||
final creditor = await idFor(2);
|
||||
final pledge = pledgeFor(debtor, creditor).copyWith(
|
||||
debtorSignature: 'a' * 128,
|
||||
creditorSignature: 'b' * 128,
|
||||
);
|
||||
final back = PlantarePledge.fromJson(pledge.toJson());
|
||||
expect(back, pledge);
|
||||
});
|
||||
|
||||
test('counterpartyOf resolves the other key, null for a stranger', () async {
|
||||
final debtor = await idFor(1);
|
||||
final creditor = await idFor(2);
|
||||
final stranger = await idFor(9);
|
||||
final pledge = pledgeFor(debtor, creditor);
|
||||
expect(pledge.counterpartyOf(debtor.publicKeyHex), creditor.publicKeyHex);
|
||||
expect(pledge.counterpartyOf(creditor.publicKeyHex), debtor.publicKeyHex);
|
||||
expect(pledge.counterpartyOf(stranger.publicKeyHex), isNull);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue