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
|
|
@ -21,6 +21,7 @@ export 'src/social/nostr/nostr_sync_transport.dart';
|
||||||
export 'src/social/nostr/nostr_connection.dart';
|
export 'src/social/nostr/nostr_connection.dart';
|
||||||
export 'src/social/nostr/nostr_message_transport.dart';
|
export 'src/social/nostr/nostr_message_transport.dart';
|
||||||
export 'src/social/nostr/nostr_offer_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_profile_transport.dart';
|
||||||
export 'src/social/nostr/nostr_rating_transport.dart';
|
export 'src/social/nostr/nostr_rating_transport.dart';
|
||||||
export 'src/social/nostr/nostr_report_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/nostr/relay_pool.dart';
|
||||||
export 'src/social/offer.dart';
|
export 'src/social/offer.dart';
|
||||||
export 'src/social/offer_transport.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/profile_transport.dart';
|
||||||
export 'src/social/rating.dart';
|
export 'src/social/rating.dart';
|
||||||
export 'src/social/rating_transport.dart';
|
export 'src/social/rating_transport.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:nostr/nostr.dart' as nostr;
|
import 'package:nostr/nostr.dart' as nostr;
|
||||||
|
|
||||||
import '../message_transport.dart';
|
import '../message_transport.dart';
|
||||||
import 'nostr_channel.dart';
|
import 'nostr_channel.dart';
|
||||||
|
import 'nostr_plantare_transport.dart';
|
||||||
|
|
||||||
/// NIP-17 private-DM backend for [MessageTransport], on the shared
|
/// NIP-17 private-DM backend for [MessageTransport], on the shared
|
||||||
/// [NostrConnection]. The gift-wrap onion (rumor kind 14 → seal kind 13 → wrap
|
/// [NostrConnection]. The gift-wrap onion (rumor kind 14 → seal kind 13 → wrap
|
||||||
|
|
@ -53,6 +55,7 @@ class NostrMessageTransport implements MessageTransport {
|
||||||
giftWrap: wrap,
|
giftWrap: wrap,
|
||||||
recipientSecretKey: _conn.privateKeyHex,
|
recipientSecretKey: _conn.privateKeyHex,
|
||||||
);
|
);
|
||||||
|
if (_isPlantare(rumor.content)) return null; // handled by PlantareTransport
|
||||||
return PrivateMessage(
|
return PrivateMessage(
|
||||||
fromPubkey: rumor.pubkey,
|
fromPubkey: rumor.pubkey,
|
||||||
text: rumor.content,
|
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
|
@override
|
||||||
Future<void> close() => _conn.close();
|
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