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
7ae6becd8f
commit
3f622f7bf7
7 changed files with 726 additions and 0 deletions
|
|
@ -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