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

@ -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';

View file

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

View file

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

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

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