-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtx.js
More file actions
179 lines (167 loc) · 8.3 KB
/
Copy pathtx.js
File metadata and controls
179 lines (167 loc) · 8.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
// Signed transitions — protocol level 1.
//
// The wire format of a signed transition IS a nostr event (NIP-01): kind
// per transition type, content = the RFC 8785-canonical intent params,
// pubkey = the actor's x-only key, standard id + BIP-340 sig. Deliberate
// consequences:
// * any NIP-07 signer (extensions, xlogin) signs transitions natively —
// no bespoke wallet code anywhere;
// * a transition is relayable over nostr infrastructure as-is, which is
// the transport story for federation (level 2).
//
// The actor signs INTENT, not chain position: {to, currency, amount} — not
// seq/prev/path. Ordering is the node's assertion (proven by the hash
// chain); authorship is the actor's (proven by the event signature); routing
// is the network's business (authorized by the intermediaries' own signed
// trustlines — Fugger's consent model). This split is what makes signing
// race-free for client-held keys: no round trip, nothing to re-sign when
// the chain moves.
//
// Replay protection: created_at freshness (±FRESH_SECS) + the node refuses
// an event id it has already applied (§ server).
import crypto from 'node:crypto';
import { schnorr } from '@noble/curves/secp256k1';
import { canonicalize } from './engine.js';
export const FRESH_SECS = 120;
/** Transition type ↔ nostr kind. One kind for set (create/update is derived
* by the node from whether the line existed). */
export const KINDS = {
'set-trustline': 8801,
'remove-trustline': 8802,
'send-payment': 8803,
settle: 8804,
};
export const KIND_TO_TYPE = Object.fromEntries(Object.entries(KINDS).map(([t, k]) => [k, t]));
const sha256hex = (data) => crypto.createHash('sha256').update(data).digest('hex');
/** NIP-01 event id: sha256 over the canonical serialization array. */
export function eventId(ev) {
return sha256hex(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content]));
}
/**
* The signable intent params for a transition type — the subset of entry
* params the ACTOR asserts. Node-added annotations (path, from) are excluded.
* Values must arrive pre-normalized (resolved peer URI, uppercase currency):
* signatures cover exact bytes, so the node validates rather than rewrites.
*/
export function intentOf(type, params) {
switch (type) {
case 'set-trustline':
case 'create-trustline': // entry-type aliases of the 8801 intent
case 'update-trustline':
return { peer: params.peer, currency: params.currency, limit: params.limit };
case 'remove-trustline':
return { peer: params.peer, currency: params.currency };
case 'send-payment':
return { to: params.to, currency: params.currency, amount: params.amount };
case 'settle':
return { peer: params.peer, currency: params.currency, amount: params.amount };
default:
return null;
}
}
/** Entry type → the kind its embedded event must carry. */
export function kindForEntryType(type) {
if (type === 'create-trustline' || type === 'update-trustline') return KINDS['set-trustline'];
return KINDS[type] ?? null;
}
/**
* Build + sign a transition event (server/test side — browsers use
* window.nostr.signEvent on the same shape).
* @param {string} privkeyHex 32-byte hex
* @param {'set-trustline'|'remove-trustline'|'send-payment'|'settle'} type
* @param {object} intentParams pre-normalized intent (see intentOf)
* @param {object} [opts] created_at override; auxRand (hex) for deterministic test vectors
*/
export function buildTxEvent(privkeyHex, type, intentParams, opts = {}) {
const kind = KINDS[type];
if (!kind) throw new Error(`unknown transition type ${type}`);
// A nonce tag makes byte-identical INTENTS yield distinct EVENTS — without
// it, two legitimate identical payments in the same second share an id,
// which the replay guard refuses and the audit reads as a double-apply
// (found by the concurrency soak). tags are covered by the NIP-01 id.
const nonce = opts.nonce === null ? null
: (opts.nonce ?? crypto.randomBytes(8).toString('hex'));
const ev = {
pubkey: Buffer.from(schnorr.getPublicKey(privkeyHex)).toString('hex'),
created_at: opts.created_at ?? Math.floor(Date.now() / 1000),
kind,
tags: nonce ? [['nonce', nonce]] : [],
content: canonicalize(intentParams),
};
ev.id = eventId(ev);
ev.sig = Buffer.from(
opts.auxRand
? schnorr.sign(ev.id, privkeyHex, Buffer.from(opts.auxRand, 'hex'))
: schnorr.sign(ev.id, privkeyHex),
).toString('hex');
return ev;
}
const HEX64 = /^[0-9a-f]{64}$/;
const HEX128 = /^[0-9a-f]{128}$/;
/** tags: empty, or exactly one ["nonce", <1-32 hex>] entry. */
function validTags(tags) {
if (!Array.isArray(tags)) return false;
if (tags.length === 0) return true;
return tags.length === 1 && Array.isArray(tags[0]) && tags[0].length === 2
&& tags[0][0] === 'nonce' && /^[0-9a-f]{1,32}$/.test(tags[0][1] || '');
}
/**
* Structurally + cryptographically verify a transition event.
* Returns { actor, type, intent } or { error } — never throws.
* Freshness is checked here; replay (seen ids) is the caller's ledger state.
*/
export function verifyTxEvent(ev, { now = Date.now() } = {}) {
if (!ev || typeof ev !== 'object') return { error: 'not an event' };
const type = KIND_TO_TYPE[ev.kind];
if (!type) return { error: `unknown kind ${ev.kind}` };
if (!HEX64.test(ev.pubkey || '')) return { error: 'bad pubkey' };
if (!HEX64.test(ev.id || '')) return { error: 'bad id' };
if (!HEX128.test(ev.sig || '')) return { error: 'bad sig encoding' };
if (!validTags(ev.tags)) return { error: 'tags must be [] or a single ["nonce", hex] tag' };
if (typeof ev.content !== 'string') return { error: 'bad shape' };
if (!Number.isInteger(ev.created_at)) return { error: 'bad created_at' };
if (Math.abs(Math.floor(now / 1000) - ev.created_at) > FRESH_SECS) {
return { error: `created_at outside ±${FRESH_SECS}s` };
}
let intent;
try { intent = JSON.parse(ev.content); } catch { return { error: 'content is not JSON' }; }
if (!intent || typeof intent !== 'object' || Array.isArray(intent)) return { error: 'content is not an object' };
// The content MUST be the canonical serialization of its own value —
// otherwise two byte-different contents could carry one meaning and the
// node's "seen id" replay check could be sidestepped.
if (canonicalize(intent) !== ev.content) return { error: 'content is not canonical (RFC 8785)' };
if (eventId(ev) !== ev.id) return { error: 'id mismatch' };
try {
if (!schnorr.verify(ev.sig, ev.id, ev.pubkey)) return { error: 'signature invalid' };
} catch { return { error: 'signature invalid' }; }
return { actor: `did:nostr:${ev.pubkey}`, type, intent };
}
/**
* Verify that a LOG ENTRY's embedded event authorizes that entry:
* kind matches the entry type, content equals the canonical intent derived
* from the entry params, pubkey matches the actor's key, sig verifies.
* `keyOf(actor)` maps a non-DID actor URI to its published pubkey (custodial
* accounts); did:nostr actors carry their key in the id.
* Freshness is NOT rechecked — it was enforced at apply time; history ages.
*/
export function verifyEntryEvent(entry, keyOf = () => null) {
const ev = entry.event;
if (!ev) return { signed: false };
const type = KIND_TO_TYPE[ev.kind];
if (!type) return { signed: true, valid: false, error: `unknown kind ${ev.kind}` };
if (!validTags(ev.tags)) return { signed: true, valid: false, error: 'bad tags' };
if (kindForEntryType(entry.type) !== ev.kind) {
return { signed: true, valid: false, error: 'kind does not match entry type' };
}
const expected = canonicalize(intentOf(entry.type, entry.params ?? {}));
if (ev.content !== expected) return { signed: true, valid: false, error: 'content does not match entry intent' };
const m = /^did:nostr:([0-9a-f]{64})$/.exec(entry.actor || '');
const key = m ? m[1] : keyOf(entry.actor);
if (!key) return { signed: true, valid: false, error: 'no key known for actor' };
if (ev.pubkey !== key) return { signed: true, valid: false, error: 'pubkey does not match actor' };
if (eventId(ev) !== ev.id) return { signed: true, valid: false, error: 'id mismatch' };
try {
if (!schnorr.verify(ev.sig, ev.id, ev.pubkey)) return { signed: true, valid: false, error: 'signature invalid' };
} catch { return { signed: true, valid: false, error: 'signature invalid' }; }
return { signed: true, valid: true };
}