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:
vjrj 2026-07-14 10:50:38 +02:00
parent 7ae6becd8f
commit 3f622f7bf7
7 changed files with 726 additions and 0 deletions

View file

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

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