@ftptech/canton-agent-wallet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +134 -0
- package/dist/canton-hash.d.ts +61 -0
- package/dist/canton-hash.d.ts.map +1 -0
- package/dist/canton-hash.js +108 -0
- package/dist/canton-hash.js.map +1 -0
- package/dist/cli-args.d.ts +31 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +56 -0
- package/dist/cli-args.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +123 -0
- package/dist/cli.js.map +1 -0
- package/dist/hash-binding.d.ts +40 -0
- package/dist/hash-binding.d.ts.map +1 -0
- package/dist/hash-binding.js +20 -0
- package/dist/hash-binding.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +26 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +38 -0
- package/dist/keys.js.map +1 -0
- package/dist/onboard.d.ts +12 -0
- package/dist/onboard.d.ts.map +1 -0
- package/dist/onboard.js +152 -0
- package/dist/onboard.js.map +1 -0
- package/dist/pay.d.ts +16 -0
- package/dist/pay.d.ts.map +1 -0
- package/dist/pay.js +19 -0
- package/dist/pay.js.map +1 -0
- package/dist/relay-client.d.ts +128 -0
- package/dist/relay-client.d.ts.map +1 -0
- package/dist/relay-client.js +67 -0
- package/dist/relay-client.js.map +1 -0
- package/dist/relay-signer.d.ts +33 -0
- package/dist/relay-signer.d.ts.map +1 -0
- package/dist/relay-signer.js +44 -0
- package/dist/relay-signer.js.map +1 -0
- package/dist/store.d.ts +15 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +33 -0
- package/dist/store.js.map +1 -0
- package/dist/trusted-dso.d.ts +33 -0
- package/dist/trusted-dso.d.ts.map +1 -0
- package/dist/trusted-dso.js +36 -0
- package/dist/trusted-dso.js.map +1 -0
- package/dist/tx.d.ts +102 -0
- package/dist/tx.d.ts.map +1 -0
- package/dist/tx.js +328 -0
- package/dist/tx.js.map +1 -0
- package/dist/verify-prepared.d.ts +361 -0
- package/dist/verify-prepared.d.ts.map +1 -0
- package/dist/verify-prepared.js +2235 -0
- package/dist/verify-prepared.js.map +1 -0
- package/dist/withdraw.d.ts +18 -0
- package/dist/withdraw.d.ts.map +1 -0
- package/dist/withdraw.js +31 -0
- package/dist/withdraw.js.map +1 -0
- package/package.json +33 -0
|
@@ -0,0 +1,2235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-before-sign — defend the agent's self-custody key against a malicious
|
|
3
|
+
* or compromised relay.
|
|
4
|
+
*
|
|
5
|
+
* THREAT MODEL
|
|
6
|
+
* ------------
|
|
7
|
+
* The agent builds a `TransferFactory_Transfer` (sender / receiver / amount /
|
|
8
|
+
* instrumentId) and asks the relay to PREPARE it. The relay returns an opaque
|
|
9
|
+
* `preparedTransaction` (a base64 Canton `PreparedTransaction` protobuf) plus
|
|
10
|
+
* the `hash` the agent is expected to sign. The agent then signs that hash with
|
|
11
|
+
* its own key. README/docs promise self-custody: "the relay never holds the key
|
|
12
|
+
* and cannot move the agent's funds". But if the agent signs the relay-returned
|
|
13
|
+
* hash BLINDLY, a compromised relay can prepare a DIFFERENT transfer (swap the
|
|
14
|
+
* receiver, inflate the amount, change the instrument) and hand back its hash —
|
|
15
|
+
* the agent's signature would then authorize the attacker's transfer. Blind
|
|
16
|
+
* signing breaks the self-custody guarantee.
|
|
17
|
+
*
|
|
18
|
+
* APPROACH (STRUCTURAL decode, schema-pinned, zero-dependency)
|
|
19
|
+
* ------------------------------------------------------------
|
|
20
|
+
* We do NOT substring-scan the blob. We decode the Canton `PreparedTransaction`
|
|
21
|
+
* protobuf STRUCTURALLY, descending by *field number* through exactly the path
|
|
22
|
+
* Canton serializes (the field numbers are taken from the published Ledger API
|
|
23
|
+
* `.proto`s — see FIELD MAP below). We reach the `Exercise` node that runs the
|
|
24
|
+
* transfer choice, read its `chosen_value` (the choice-argument `Value` tree),
|
|
25
|
+
* and pull the transfer's sender / receiver / amount / instrumentId.id from
|
|
26
|
+
* their REAL typed positions:
|
|
27
|
+
*
|
|
28
|
+
* - a recipient/sender is a Daml `Party`, serialized as `Value.party`
|
|
29
|
+
* (oneof tag 7) — never `Value.text`. An attacker cannot disguise a
|
|
30
|
+
* redirect as a text field.
|
|
31
|
+
* - the amount is a Daml `Numeric`, serialized as `Value.numeric` (oneof
|
|
32
|
+
* tag 6) — at the transfer record's amount position, not "somewhere".
|
|
33
|
+
*
|
|
34
|
+
* We then compare the EXTRACTED values to the caller's INTENT (sender ==
|
|
35
|
+
* wallet.party, receiver == the intended payTo, amount == the EXACT requested
|
|
36
|
+
* amount, instrumentId.id == the caller-known instrument). Crucially we NEVER
|
|
37
|
+
* trust a value taken from the relay's prepared bytes (or its resolve response)
|
|
38
|
+
* as a whitelist — the recipient is pinned to caller intent by exact equality
|
|
39
|
+
* at its structural position, so a relay-supplied admin/party can never widen
|
|
40
|
+
* what we will sign. Every legal Canton party-id form is accepted (dots,
|
|
41
|
+
* non-hex namespaces, spaces) because we identify parties by their protobuf
|
|
42
|
+
* type, not by a regex.
|
|
43
|
+
*
|
|
44
|
+
* As an independent backstop we also assert that NO party value anywhere in the
|
|
45
|
+
* choice argument is a *recipient/sender* other than {wallet.party, intended
|
|
46
|
+
* payTo} (the instrument admin is allowed ONLY at its `instrumentId.admin`
|
|
47
|
+
* position). Any relay-injected extra leg paying a third party shows up as a
|
|
48
|
+
* foreign party and is rejected.
|
|
49
|
+
*
|
|
50
|
+
* UNAMBIGUOUS DECODE (no first-vs-last-wins divergence). Every field we read is
|
|
51
|
+
* NON-REPEATED in the schema, so we reject ANY duplicate occurrence of it
|
|
52
|
+
* (`lenFieldUnique`) and reject any `Value` that sets a oneof member more than
|
|
53
|
+
* once or sets two different members (`assertSingleValueMember`), plus duplicate
|
|
54
|
+
* record-field labels. The protobuf wire format keeps the LAST occurrence of a
|
|
55
|
+
* non-repeated field/oneof member, and Canton's ScalaPB parser follows that; a
|
|
56
|
+
* hand-rolled FIRST-wins reader would otherwise let an attacker hide an inflated
|
|
57
|
+
* amount (or swapped receiver/choice) as a second occurrence past a decoy first
|
|
58
|
+
* one. We fail closed on the ambiguity instead of guessing.
|
|
59
|
+
*
|
|
60
|
+
* HASH BINDING (we must sign the hash OF the bytes we validated). Validating the
|
|
61
|
+
* bytes is necessary but not sufficient: a compromised relay can return honest
|
|
62
|
+
* bytes paired with the hash of a DIFFERENT transaction and swap the bytes it
|
|
63
|
+
* forwards to the participant. So `assertHashBinding` (wired in tx.ts) REQUIRES
|
|
64
|
+
* the relay-returned hash to equal a locally-recomputed hash of the validated
|
|
65
|
+
* bytes (or an explicit, off-by-default opt-in to trust the relay) and refuses
|
|
66
|
+
* otherwise — the Canton Ledger API itself mandates recomputing the hash when
|
|
67
|
+
* the preparing participant is untrusted. Any swap of receiver/amount/instrument
|
|
68
|
+
* changes the bytes, fails its exact-equality check here, AND changes the
|
|
69
|
+
* recomputed hash. Fail-closed in every case.
|
|
70
|
+
*
|
|
71
|
+
* FIELD MAP (com.daml.ledger.api.v2 Ledger API protos)
|
|
72
|
+
* ----------------------------------------------------
|
|
73
|
+
* PreparedTransaction .transaction = 1 (DamlTransaction)
|
|
74
|
+
* .metadata = 2 (Metadata)
|
|
75
|
+
* Metadata .submitter_info = 2 (SubmitterInfo)
|
|
76
|
+
* Metadata.SubmitterInfo .act_as (repeated) = 1 (string party)
|
|
77
|
+
* DamlTransaction .nodes (repeated) = 3 (Node)
|
|
78
|
+
* DamlTransaction.Node .v1 = 1000 (transaction.v1.Node)
|
|
79
|
+
* transaction.v1.Node .exercise = 3 (Exercise)
|
|
80
|
+
* transaction.v1.Exercise .template_id = 4 (Identifier)
|
|
81
|
+
* .choice_id = 9 (string)
|
|
82
|
+
* .chosen_value = 10 (Value)
|
|
83
|
+
* Value (oneof sum) .numeric = 6 (string)
|
|
84
|
+
* .party = 7 (string)
|
|
85
|
+
* .text = 8 (string)
|
|
86
|
+
* .optional = 10 (Optional)
|
|
87
|
+
* .list = 11 (List)
|
|
88
|
+
* .text_map = 12 (TextMap)
|
|
89
|
+
* .gen_map = 13 (GenMap)
|
|
90
|
+
* .record = 14 (Record)
|
|
91
|
+
* .variant = 15 (Variant)
|
|
92
|
+
* Record .fields (repeated) = 2 (RecordField)
|
|
93
|
+
* RecordField .label = 1 (string)
|
|
94
|
+
* .value = 2 (Value)
|
|
95
|
+
* List .elements (repeated) = 1 (Value)
|
|
96
|
+
* Optional .value = 1 (Value)
|
|
97
|
+
* Variant .value = 3 (Value)
|
|
98
|
+
* TextMap .entries (repeated) = 1 (TextMap.Entry)
|
|
99
|
+
* TextMap.Entry .key = 1 (string)
|
|
100
|
+
* .value = 2 (Value)
|
|
101
|
+
* GenMap .entries (repeated) = 1 (GenMap.Entry)
|
|
102
|
+
* GenMap.Entry .key = 1 (Value)
|
|
103
|
+
* .value = 2 (Value)
|
|
104
|
+
*/
|
|
105
|
+
import { createHash, createPublicKey, timingSafeEqual } from "node:crypto";
|
|
106
|
+
import { decodeTopologyTransaction } from "@canton-network/core-tx-visualizer";
|
|
107
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
108
|
+
* Generic protobuf wire reader (proto3, schema-driven by the caller).
|
|
109
|
+
* Wire types: 0=varint, 1=64-bit, 2=length-delimited, 5=32-bit. 3/4 (groups)
|
|
110
|
+
* are obsolete and rejected (we cannot skip them without a schema).
|
|
111
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
112
|
+
const WIRE_VARINT = 0;
|
|
113
|
+
const WIRE_64 = 1;
|
|
114
|
+
const WIRE_LEN = 2;
|
|
115
|
+
const WIRE_32 = 5;
|
|
116
|
+
function readVarint(buf, pos) {
|
|
117
|
+
let shift = 0;
|
|
118
|
+
let value = 0;
|
|
119
|
+
while (pos < buf.length) {
|
|
120
|
+
const b = buf[pos++];
|
|
121
|
+
value += (b & 0x7f) * 2 ** shift;
|
|
122
|
+
if ((b & 0x80) === 0)
|
|
123
|
+
return { value, pos };
|
|
124
|
+
shift += 7;
|
|
125
|
+
if (shift > 63)
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
throw new PreparedDecodeError("truncated or malformed varint");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Decode one protobuf message into its fields. Repeated fields appear multiple
|
|
132
|
+
* times in the returned array (we never silently collapse them). Unknown field
|
|
133
|
+
* numbers are still parsed (and skipped by callers) so an honest-but-evolved
|
|
134
|
+
* encoding is tolerated; only structurally impossible bytes throw.
|
|
135
|
+
*/
|
|
136
|
+
function decodeMessage(buf) {
|
|
137
|
+
const out = [];
|
|
138
|
+
let pos = 0;
|
|
139
|
+
while (pos < buf.length) {
|
|
140
|
+
const { value: tag, pos: p1 } = readVarint(buf, pos);
|
|
141
|
+
pos = p1;
|
|
142
|
+
const field = Math.floor(tag / 8);
|
|
143
|
+
const wire = tag & 0x7;
|
|
144
|
+
if (field === 0)
|
|
145
|
+
throw new PreparedDecodeError("invalid field number 0");
|
|
146
|
+
if (wire === WIRE_LEN) {
|
|
147
|
+
const { value: len, pos: p2 } = readVarint(buf, pos);
|
|
148
|
+
pos = p2;
|
|
149
|
+
if (len < 0 || pos + len > buf.length) {
|
|
150
|
+
throw new PreparedDecodeError("length-delimited field overruns buffer");
|
|
151
|
+
}
|
|
152
|
+
out.push({ field, wire, bytes: buf.subarray(pos, pos + len) });
|
|
153
|
+
pos += len;
|
|
154
|
+
}
|
|
155
|
+
else if (wire === WIRE_VARINT) {
|
|
156
|
+
const start = pos;
|
|
157
|
+
const { value, pos: p2 } = readVarint(buf, pos);
|
|
158
|
+
pos = p2;
|
|
159
|
+
out.push({ field, wire, varint: value, varintBytes: buf.subarray(start, p2) });
|
|
160
|
+
}
|
|
161
|
+
else if (wire === WIRE_64) {
|
|
162
|
+
if (pos + 8 > buf.length)
|
|
163
|
+
throw new PreparedDecodeError("64-bit field overruns buffer");
|
|
164
|
+
pos += 8;
|
|
165
|
+
out.push({ field, wire });
|
|
166
|
+
}
|
|
167
|
+
else if (wire === WIRE_32) {
|
|
168
|
+
if (pos + 4 > buf.length)
|
|
169
|
+
throw new PreparedDecodeError("32-bit field overruns buffer");
|
|
170
|
+
pos += 4;
|
|
171
|
+
out.push({ field, wire });
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
throw new PreparedDecodeError(`unsupported/obsolete wire type ${wire}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/** All length-delimited submessages for a given field number, in order. */
|
|
180
|
+
function lenFields(fields, field) {
|
|
181
|
+
return fields
|
|
182
|
+
.filter((f) => f.field === field && f.wire === WIRE_LEN && f.bytes !== undefined)
|
|
183
|
+
.map((f) => f.bytes);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Strict accessor for a NON-REPEATED length-delimited field: returns its single
|
|
187
|
+
* submessage, `undefined` if absent, and THROWS if it occurs more than once.
|
|
188
|
+
*
|
|
189
|
+
* Why this exists (BYPASS: amount-inflation via duplicate oneof / field):
|
|
190
|
+
* the protobuf wire format mandates LAST-occurrence-wins for a non-repeated
|
|
191
|
+
* scalar or `oneof` member, and Canton's spec-conformant ScalaPB parser (which
|
|
192
|
+
* the participant runs on `execute` to recompute the V2 hash and interpret the
|
|
193
|
+
* transaction) follows that rule. A naive hand-rolled reader that takes the
|
|
194
|
+
* FIRST occurrence (our old `lenField`) would diverge: an attacker can place
|
|
195
|
+
* `Value.numeric` twice inside the SAME amount `Value` — a decoy "1.0" first,
|
|
196
|
+
* the real "9999.0" second — and the first-wins reader sees the decoy while the
|
|
197
|
+
* participant executes the inflated amount. There is no legitimate reason for a
|
|
198
|
+
* non-repeated field to appear twice, so we FAIL CLOSED on any duplicate rather
|
|
199
|
+
* than guess which occurrence the participant will honour. This makes our decode
|
|
200
|
+
* unambiguous and forces it to agree with the spec parser for everything we
|
|
201
|
+
* read (amount/party/text/record/label/value).
|
|
202
|
+
*/
|
|
203
|
+
function lenFieldUnique(fields, field, what) {
|
|
204
|
+
const all = lenFields(fields, field);
|
|
205
|
+
if (all.length > 1) {
|
|
206
|
+
throw new PreparedDecodeError(`non-repeated field ${field} (${what}) appears ${all.length} times — ` +
|
|
207
|
+
`ambiguous encoding (last-occurrence-wins on the participant), refusing to sign`);
|
|
208
|
+
}
|
|
209
|
+
return all[0];
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Number of length-delimited occurrences of a field (for duplicate detection on
|
|
213
|
+
* scalar `Value` oneof members, which must each appear at most once).
|
|
214
|
+
*/
|
|
215
|
+
function countLenField(fields, field) {
|
|
216
|
+
return lenFields(fields, field).length;
|
|
217
|
+
}
|
|
218
|
+
/** Decode a length-delimited field as UTF-8 (strict). */
|
|
219
|
+
function utf8(bytes) {
|
|
220
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
221
|
+
}
|
|
222
|
+
/** All WIRE_VARINT occurrences of a field number, in order. */
|
|
223
|
+
function varintFields(fields, field) {
|
|
224
|
+
return fields.filter((f) => f.field === field && f.wire === WIRE_VARINT && f.varintBytes !== undefined);
|
|
225
|
+
}
|
|
226
|
+
/** Number of WIRE_VARINT occurrences of a field (duplicate-oneof detection). */
|
|
227
|
+
function countVarintField(fields, field) {
|
|
228
|
+
return varintFields(fields, field).length;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Re-decode a raw varint byte-sequence as a BigInt — full int64 precision, no
|
|
232
|
+
* `2**shift` float loss. proto3 int64 is up to 10 bytes (64 data bits). We cap
|
|
233
|
+
* the data shift at 63 bits and fail closed on anything longer/malformed rather
|
|
234
|
+
* than silently truncating. (`readVarint` already validated framing during
|
|
235
|
+
* decode; this re-reads the SAME bytes precisely.)
|
|
236
|
+
*/
|
|
237
|
+
function varintToBigInt(bytes) {
|
|
238
|
+
let value = 0n;
|
|
239
|
+
let shift = 0n;
|
|
240
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
241
|
+
const b = bytes[i];
|
|
242
|
+
value += BigInt(b & 0x7f) << shift;
|
|
243
|
+
if ((b & 0x80) === 0)
|
|
244
|
+
return value;
|
|
245
|
+
shift += 7n;
|
|
246
|
+
if (shift > 63n)
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
throw new PreparedDecodeError("truncated or over-long varint");
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Strict accessor for a NON-REPEATED varint field (Daml Int / Time): returns its
|
|
253
|
+
* single value as a BigInt, `undefined` if absent, and THROWS if it occurs more
|
|
254
|
+
* than once. Same fail-closed rationale as `lenFieldUnique`: the wire format is
|
|
255
|
+
* last-occurrence-wins for a non-repeated scalar, so a duplicate would let a
|
|
256
|
+
* decoy-first/real-second split diverge our read from the participant's. There
|
|
257
|
+
* is no legitimate reason for nonce/expiresAt to appear twice.
|
|
258
|
+
*/
|
|
259
|
+
function varintFieldUnique(fields, field, what) {
|
|
260
|
+
const all = varintFields(fields, field);
|
|
261
|
+
if (all.length > 1) {
|
|
262
|
+
throw new PreparedDecodeError(`non-repeated varint field ${field} (${what}) appears ${all.length} times — ` +
|
|
263
|
+
`ambiguous encoding (last-occurrence-wins on the participant), refusing to sign`);
|
|
264
|
+
}
|
|
265
|
+
const only = all[0];
|
|
266
|
+
if (only === undefined)
|
|
267
|
+
return undefined;
|
|
268
|
+
return varintToBigInt(only.varintBytes);
|
|
269
|
+
}
|
|
270
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
271
|
+
* Daml `Value` (the choice-argument tree). We only need the leaves that carry
|
|
272
|
+
* security-relevant data: party (recipients), numeric (amount), text (e.g.
|
|
273
|
+
* instrument id). We descend record / list / optional / variant containers.
|
|
274
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
275
|
+
// Value oneof tags (com.daml.ledger.api.v2.Value.Sum).
|
|
276
|
+
// unit=1 bool=2 int64=3 date=4 timestamp=5 numeric=6 party=7 text=8
|
|
277
|
+
// contract_id=9 optional=10 list=11 text_map=12 gen_map=13 record=14
|
|
278
|
+
// variant=15 enum=16
|
|
279
|
+
const V_UNIT = 1; // Value.unit (Daml `()`; google.protobuf.Empty). Non-party leaf.
|
|
280
|
+
const V_BOOL = 2; // Value.bool. Varint. Non-party leaf.
|
|
281
|
+
const V_INT64 = 3; // Value.int64 (Daml `Int`, e.g. TransferCommand.nonce). Varint.
|
|
282
|
+
const V_DATE = 4; // Value.date (Daml `Date`; days since epoch). Varint. Non-party leaf.
|
|
283
|
+
const V_TIMESTAMP = 5; // Value.timestamp (Daml `Time`, e.g. expiresAt). Varint µs.
|
|
284
|
+
const V_NUMERIC = 6;
|
|
285
|
+
const V_PARTY = 7;
|
|
286
|
+
const V_TEXT = 8;
|
|
287
|
+
const V_CONTRACT_ID = 9; // Value.contract_id (e.g. transfer.inputHoldingCids). Non-party leaf.
|
|
288
|
+
const V_OPTIONAL = 10;
|
|
289
|
+
const V_LIST = 11;
|
|
290
|
+
const V_TEXT_MAP = 12; // Value.text_map (TextMap; string keys, Value values)
|
|
291
|
+
const V_GEN_MAP = 13; // Value.gen_map (GenMap; Value keys AND Value values)
|
|
292
|
+
const V_RECORD = 14;
|
|
293
|
+
const V_VARIANT = 15;
|
|
294
|
+
const V_ENUM = 16; // Value.enum (Identifier + constructor; carries no Party). Non-party leaf.
|
|
295
|
+
/**
|
|
296
|
+
* The COMPLETE com.daml.ledger.api.v2.interactive `Value` oneof member set. We
|
|
297
|
+
* fail closed (refuse to sign) on ANY `Value` that sets a member outside this
|
|
298
|
+
* set: the leaf reader + the foreign-party backstop only recognize these, so an
|
|
299
|
+
* unknown/future member's subtree would be silently dropped — and could hide a
|
|
300
|
+
* foreign recipient party. Mirrors the codebase's fail-closed stance on unknown
|
|
301
|
+
* node versions / node types.
|
|
302
|
+
*/
|
|
303
|
+
const COMPLETE_VALUE_MEMBERS = new Set([
|
|
304
|
+
V_UNIT,
|
|
305
|
+
V_BOOL,
|
|
306
|
+
V_INT64,
|
|
307
|
+
V_DATE,
|
|
308
|
+
V_TIMESTAMP,
|
|
309
|
+
V_NUMERIC,
|
|
310
|
+
V_PARTY,
|
|
311
|
+
V_TEXT,
|
|
312
|
+
V_CONTRACT_ID,
|
|
313
|
+
V_OPTIONAL,
|
|
314
|
+
V_LIST,
|
|
315
|
+
V_TEXT_MAP,
|
|
316
|
+
V_GEN_MAP,
|
|
317
|
+
V_RECORD,
|
|
318
|
+
V_VARIANT,
|
|
319
|
+
V_ENUM,
|
|
320
|
+
]);
|
|
321
|
+
// Record / RecordField / List / Optional / Variant inner field numbers.
|
|
322
|
+
const RECORD_FIELDS = 2;
|
|
323
|
+
const RF_LABEL = 1;
|
|
324
|
+
const RF_VALUE = 2;
|
|
325
|
+
const LIST_ELEMENTS = 1;
|
|
326
|
+
const OPTIONAL_VALUE = 1;
|
|
327
|
+
const VARIANT_VALUE = 3;
|
|
328
|
+
// GenMap / TextMap inner field numbers. Both wrap a repeated `Entry` at field 1;
|
|
329
|
+
// GenMap.Entry has Value key=1 + Value value=2, TextMap.Entry has string key=1 +
|
|
330
|
+
// Value value=2 (so only TextMap *values* can carry a party leaf).
|
|
331
|
+
const MAP_ENTRIES = 1;
|
|
332
|
+
const ENTRY_KEY = 1;
|
|
333
|
+
const ENTRY_VALUE = 2;
|
|
334
|
+
const MAX_DEPTH = 64;
|
|
335
|
+
/** Decode a `Value`'s record fields (in order). Throws if it is not a record. */
|
|
336
|
+
function recordEntries(value) {
|
|
337
|
+
assertSingleValueMember(value);
|
|
338
|
+
const v = decodeMessage(value);
|
|
339
|
+
const rec = lenFieldUnique(v, V_RECORD, "Value.record");
|
|
340
|
+
if (rec === undefined)
|
|
341
|
+
throw new PreparedDecodeError("expected a Value.record");
|
|
342
|
+
const recFields = decodeMessage(rec);
|
|
343
|
+
const entries = lenFields(recFields, RECORD_FIELDS).map((rf) => {
|
|
344
|
+
const f = decodeMessage(rf);
|
|
345
|
+
// A RecordField's label and value are each non-repeated; reject duplicates
|
|
346
|
+
// so a last-wins parser cannot disagree with us about a field's value.
|
|
347
|
+
const labelBytes = lenFieldUnique(f, RF_LABEL, "RecordField.label");
|
|
348
|
+
const valBytes = lenFieldUnique(f, RF_VALUE, "RecordField.value");
|
|
349
|
+
if (valBytes === undefined)
|
|
350
|
+
throw new PreparedDecodeError("record field missing value");
|
|
351
|
+
return { label: labelBytes ? utf8(labelBytes) : "", value: valBytes };
|
|
352
|
+
});
|
|
353
|
+
// Reject duplicate NON-EMPTY labels: by-label lookup would pick the first
|
|
354
|
+
// while a last-wins consumer keying by label would pick the last (decoy/real
|
|
355
|
+
// split). Empty labels (normalized encodings) are positional, so allowed.
|
|
356
|
+
const seen = new Set();
|
|
357
|
+
for (const e of entries) {
|
|
358
|
+
if (e.label === "")
|
|
359
|
+
continue;
|
|
360
|
+
if (seen.has(e.label)) {
|
|
361
|
+
throw new PreparedDecodeError(`record contains duplicate field label ${JSON.stringify(e.label)} — ` +
|
|
362
|
+
`ambiguous encoding, refusing to sign`);
|
|
363
|
+
}
|
|
364
|
+
seen.add(e.label);
|
|
365
|
+
}
|
|
366
|
+
return entries;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* A Daml `Value` is a `oneof sum` — at most one member may be set. A
|
|
370
|
+
* spec-conformant parser keeps the LAST member it sees within a oneof, so if an
|
|
371
|
+
* attacker sets two members (or the same member twice) our by-type reads could
|
|
372
|
+
* pick a different one than the participant. Enforce that the scalar/structural
|
|
373
|
+
* members we ever read each occur AT MOST ONCE and that no two DIFFERENT members
|
|
374
|
+
* are present simultaneously. Fail closed on any ambiguity.
|
|
375
|
+
*/
|
|
376
|
+
function assertSingleValueMember(value) {
|
|
377
|
+
const v = decodeMessage(value);
|
|
378
|
+
// Length-delimited oneof members.
|
|
379
|
+
const lenMembers = [
|
|
380
|
+
{ tag: V_NUMERIC, what: "Value.numeric" },
|
|
381
|
+
{ tag: V_PARTY, what: "Value.party" },
|
|
382
|
+
{ tag: V_TEXT, what: "Value.text" },
|
|
383
|
+
{ tag: V_RECORD, what: "Value.record" },
|
|
384
|
+
{ tag: V_OPTIONAL, what: "Value.optional" },
|
|
385
|
+
{ tag: V_LIST, what: "Value.list" },
|
|
386
|
+
{ tag: V_TEXT_MAP, what: "Value.text_map" },
|
|
387
|
+
{ tag: V_GEN_MAP, what: "Value.gen_map" },
|
|
388
|
+
{ tag: V_VARIANT, what: "Value.variant" },
|
|
389
|
+
];
|
|
390
|
+
// Varint oneof members (Daml Int / Time). Counted separately because they use
|
|
391
|
+
// wire type 0, not length-delimited; a duplicate int64 (decoy nonce first,
|
|
392
|
+
// real nonce second) must be caught with the SAME fail-closed rigor.
|
|
393
|
+
const varintMembers = [
|
|
394
|
+
{ tag: V_INT64, what: "Value.int64" },
|
|
395
|
+
{ tag: V_TIMESTAMP, what: "Value.timestamp" },
|
|
396
|
+
];
|
|
397
|
+
let present = 0;
|
|
398
|
+
for (const m of lenMembers) {
|
|
399
|
+
const count = countLenField(v, m.tag);
|
|
400
|
+
if (count > 1) {
|
|
401
|
+
throw new PreparedDecodeError(`Value oneof member ${m.what} set ${count} times — ambiguous encoding ` +
|
|
402
|
+
`(last-occurrence-wins on the participant), refusing to sign`);
|
|
403
|
+
}
|
|
404
|
+
if (count === 1)
|
|
405
|
+
present++;
|
|
406
|
+
}
|
|
407
|
+
for (const m of varintMembers) {
|
|
408
|
+
const count = countVarintField(v, m.tag);
|
|
409
|
+
if (count > 1) {
|
|
410
|
+
throw new PreparedDecodeError(`Value oneof member ${m.what} set ${count} times — ambiguous encoding ` +
|
|
411
|
+
`(last-occurrence-wins on the participant), refusing to sign`);
|
|
412
|
+
}
|
|
413
|
+
if (count === 1)
|
|
414
|
+
present++;
|
|
415
|
+
}
|
|
416
|
+
if (present > 1) {
|
|
417
|
+
throw new PreparedDecodeError("Value sets more than one oneof member — ambiguous encoding, refusing to sign");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Read a leaf scalar (party/numeric/text) out of a `Value`, if it is one.
|
|
422
|
+
*
|
|
423
|
+
* Hardened against the duplicate-oneof amount-inflation bypass: we first assert
|
|
424
|
+
* the `Value` sets at most one oneof member, each at most once
|
|
425
|
+
* (`assertSingleValueMember`), then read that single member with the strict
|
|
426
|
+
* unique accessor. A `Value` carrying `Value.numeric` twice (decoy "1.0" then
|
|
427
|
+
* real "9999.0") — which a last-wins ScalaPB parser would read as the inflated
|
|
428
|
+
* amount — is rejected here BEFORE any amount comparison, instead of silently
|
|
429
|
+
* taking the first (decoy) occurrence. Fail closed.
|
|
430
|
+
*/
|
|
431
|
+
function leafOf(value) {
|
|
432
|
+
assertSingleValueMember(value);
|
|
433
|
+
const v = decodeMessage(value);
|
|
434
|
+
const party = lenFieldUnique(v, V_PARTY, "Value.party");
|
|
435
|
+
if (party !== undefined)
|
|
436
|
+
return { kind: "party", value: utf8(party) };
|
|
437
|
+
const numeric = lenFieldUnique(v, V_NUMERIC, "Value.numeric");
|
|
438
|
+
if (numeric !== undefined)
|
|
439
|
+
return { kind: "numeric", value: utf8(numeric) };
|
|
440
|
+
const text = lenFieldUnique(v, V_TEXT, "Value.text");
|
|
441
|
+
if (text !== undefined)
|
|
442
|
+
return { kind: "text", value: utf8(text) };
|
|
443
|
+
// Daml Int / Time leaves (e.g. TransferCommand.nonce / expiresAt). Read as
|
|
444
|
+
// BigInt-precise decimal strings via the unique varint accessor.
|
|
445
|
+
const int64 = varintFieldUnique(v, V_INT64, "Value.int64");
|
|
446
|
+
// proto3 stores a negative int64 as the 64-bit two's-complement varint
|
|
447
|
+
// (10 bytes, top data bit set). Sign-interpret so a negative nonce decodes as
|
|
448
|
+
// a negative value (the sanity check rejects it) rather than ~1.8e19, AND so
|
|
449
|
+
// exact-equality nonce comparison agrees with the participant's signed read.
|
|
450
|
+
if (int64 !== undefined)
|
|
451
|
+
return { kind: "int64", value: asSignedInt64(int64).toString() };
|
|
452
|
+
// Timestamp µs are always non-negative in practice; keep the raw (unsigned)
|
|
453
|
+
// value — Canton Time is post-epoch, so a "negative" timestamp would be a
|
|
454
|
+
// pre-1970 microsecond count we'd reject on the past-expiry check anyway.
|
|
455
|
+
const timestamp = varintFieldUnique(v, V_TIMESTAMP, "Value.timestamp");
|
|
456
|
+
if (timestamp !== undefined)
|
|
457
|
+
return { kind: "timestamp", value: timestamp.toString() };
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
/** Interpret an unsigned 64-bit varint value as a signed two's-complement
|
|
461
|
+
* int64. Values >= 2^63 map to their negative counterpart. */
|
|
462
|
+
function asSignedInt64(u) {
|
|
463
|
+
return u >= 1n << 63n ? u - (1n << 64n) : u;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Fail closed on a `Value` that sets any oneof member outside the COMPLETE known
|
|
467
|
+
* set. The leaf reader + the backstop's container descent recognize only the
|
|
468
|
+
* standard members, so an unknown/future member's subtree would be silently
|
|
469
|
+
* dropped — and could hide a foreign recipient party. Refuse rather than guess.
|
|
470
|
+
*/
|
|
471
|
+
function assertKnownValueMembers(value) {
|
|
472
|
+
for (const f of decodeMessage(value)) {
|
|
473
|
+
if (!COMPLETE_VALUE_MEMBERS.has(f.field)) {
|
|
474
|
+
throw new PreparedDecodeError(`Daml Value sets an unknown oneof member (field ${f.field}) — refusing to sign ` +
|
|
475
|
+
`(cannot prove the member carries no foreign recipient party)`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Recursively collect every party-typed leaf in a `Value` tree, EXCEPT those
|
|
481
|
+
* sitting inside an `instrumentId`-shaped sub-record (admin party at a
|
|
482
|
+
* non-recipient position). Used for the foreign-recipient backstop. We descend
|
|
483
|
+
* every Value container that can hold a party leaf: records, lists, optionals,
|
|
484
|
+
* variants, and BOTH map shapes — GenMap (party can hide in a Value key OR a
|
|
485
|
+
* Value value) and TextMap (string keys cannot be parties, so only values are
|
|
486
|
+
* descended). Skipping the map oneof members would let a relay smuggle an extra
|
|
487
|
+
* recipient inside a GenMap and slip past the backstop (defense-in-depth: the
|
|
488
|
+
* receiver itself must still be a plain Party at the transfer.receiver position,
|
|
489
|
+
* which extractTransfer pins, so this is not a known redirect exploit). The
|
|
490
|
+
* caller excludes the instrument admin from the recipient set without trusting
|
|
491
|
+
* its value. Fails closed on any unknown Value oneof member (assertKnownValueMembers).
|
|
492
|
+
*/
|
|
493
|
+
function collectPartyLeaves(value, out, depth) {
|
|
494
|
+
if (depth > MAX_DEPTH)
|
|
495
|
+
throw new PreparedDecodeError("Value nesting too deep");
|
|
496
|
+
// Fail closed on any Value oneof member outside the complete known set BEFORE
|
|
497
|
+
// we walk it: the leaf reader + container descent below recognize only the
|
|
498
|
+
// standard members, so an unknown/future member's subtree would otherwise be
|
|
499
|
+
// silently dropped — and could hide a foreign recipient party from this
|
|
500
|
+
// backstop. (Known non-party scalars — unit/bool/date/contract_id/enum — are
|
|
501
|
+
// in the set: leafOf returns undefined for them and no container matches, so
|
|
502
|
+
// they are correctly ignored, not rejected.)
|
|
503
|
+
assertKnownValueMembers(value);
|
|
504
|
+
const leaf = leafOf(value);
|
|
505
|
+
if (leaf) {
|
|
506
|
+
if (leaf.kind === "party")
|
|
507
|
+
out.push(leaf.value);
|
|
508
|
+
return; // scalar leaf — nothing to recurse into
|
|
509
|
+
}
|
|
510
|
+
// Not a scalar leaf — it must be a single structural oneof member. Reject any
|
|
511
|
+
// ambiguous (duplicate/multi-member) Value before descending so a smuggled
|
|
512
|
+
// extra party cannot hide behind a last-wins parser disagreement.
|
|
513
|
+
assertSingleValueMember(value);
|
|
514
|
+
const v = decodeMessage(value);
|
|
515
|
+
// record → recurse each field value
|
|
516
|
+
const rec = lenFieldUnique(v, V_RECORD, "Value.record");
|
|
517
|
+
if (rec !== undefined) {
|
|
518
|
+
const recFields = decodeMessage(rec);
|
|
519
|
+
for (const rf of lenFields(recFields, RECORD_FIELDS)) {
|
|
520
|
+
const f = decodeMessage(rf);
|
|
521
|
+
const valBytes = lenFieldUnique(f, RF_VALUE, "RecordField.value");
|
|
522
|
+
if (valBytes !== undefined)
|
|
523
|
+
collectPartyLeaves(valBytes, out, depth + 1);
|
|
524
|
+
}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// list → recurse elements
|
|
528
|
+
const list = lenFieldUnique(v, V_LIST, "Value.list");
|
|
529
|
+
if (list !== undefined) {
|
|
530
|
+
for (const el of lenFields(decodeMessage(list), LIST_ELEMENTS)) {
|
|
531
|
+
collectPartyLeaves(el, out, depth + 1);
|
|
532
|
+
}
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// optional → recurse inner value
|
|
536
|
+
const opt = lenFieldUnique(v, V_OPTIONAL, "Value.optional");
|
|
537
|
+
if (opt !== undefined) {
|
|
538
|
+
const inner = lenFieldUnique(decodeMessage(opt), OPTIONAL_VALUE, "Optional.value");
|
|
539
|
+
if (inner !== undefined)
|
|
540
|
+
collectPartyLeaves(inner, out, depth + 1);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// variant → recurse inner value
|
|
544
|
+
const variant = lenFieldUnique(v, V_VARIANT, "Value.variant");
|
|
545
|
+
if (variant !== undefined) {
|
|
546
|
+
const inner = lenFieldUnique(decodeMessage(variant), VARIANT_VALUE, "Variant.value");
|
|
547
|
+
if (inner !== undefined)
|
|
548
|
+
collectPartyLeaves(inner, out, depth + 1);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// gen_map → recurse BOTH the key and the value of every entry. In a GenMap
|
|
552
|
+
// both Entry.key and Entry.value are full `Value`s, so a party can hide in
|
|
553
|
+
// either; descend both so a smuggled recipient cannot escape the backstop.
|
|
554
|
+
const genMap = lenFieldUnique(v, V_GEN_MAP, "Value.gen_map");
|
|
555
|
+
if (genMap !== undefined) {
|
|
556
|
+
for (const entry of lenFields(decodeMessage(genMap), MAP_ENTRIES)) {
|
|
557
|
+
const e = decodeMessage(entry);
|
|
558
|
+
const key = lenFieldUnique(e, ENTRY_KEY, "GenMap.Entry.key");
|
|
559
|
+
if (key !== undefined)
|
|
560
|
+
collectPartyLeaves(key, out, depth + 1);
|
|
561
|
+
const val = lenFieldUnique(e, ENTRY_VALUE, "GenMap.Entry.value");
|
|
562
|
+
if (val !== undefined)
|
|
563
|
+
collectPartyLeaves(val, out, depth + 1);
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// text_map → recurse entry VALUES only (TextMap keys are plain strings, never
|
|
568
|
+
// parties), so a party leaf can only appear in a value.
|
|
569
|
+
const textMap = lenFieldUnique(v, V_TEXT_MAP, "Value.text_map");
|
|
570
|
+
if (textMap !== undefined) {
|
|
571
|
+
for (const entry of lenFields(decodeMessage(textMap), MAP_ENTRIES)) {
|
|
572
|
+
const val = lenFieldUnique(decodeMessage(entry), ENTRY_VALUE, "TextMap.Entry.value");
|
|
573
|
+
if (val !== undefined)
|
|
574
|
+
collectPartyLeaves(val, out, depth + 1);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Collect every party-typed leaf in a `GlobalKeyWithMaintainers` (the contract-
|
|
580
|
+
* key metadata attached to Create.key (field 8) / Exercise.key (field 15) /
|
|
581
|
+
* Fetch.key (field 9) / QueryByKey.key (field 5)). A contract key carries
|
|
582
|
+
* parties in TWO positions, BOTH covered by the agent's signature once a V3
|
|
583
|
+
* hashing scheme is negotiated (the V3 NodeHashBuilder hashes keyOpt +
|
|
584
|
+
* maintainers): the `maintainers` (GKWM field 2, repeated string party) AND the
|
|
585
|
+
* inner contract-key `Value` (GKWM.key=1 → GlobalKey.key=3), which is a full
|
|
586
|
+
* Daml `Value` tree that can itself hold a party leaf. Both were previously
|
|
587
|
+
* invisible to the foreign-party backstop, so a relay could place ATTACKER ONLY
|
|
588
|
+
* in a key position and slip past `assertNoForeignParties` (the file's stated
|
|
589
|
+
* invariant is that a party introduced ANYWHERE the signature covers is visible
|
|
590
|
+
* to the backstop). We scan both and feed them into the backstop's `elsewhere`
|
|
591
|
+
* set — a key party is NEVER a legitimate recipient position, so it must equal
|
|
592
|
+
* an already-allowed party or be rejected. Fail-closed; honest CC
|
|
593
|
+
* transfers/commands carry no contract key, so this never affects the happy path.
|
|
594
|
+
*/
|
|
595
|
+
function collectGlobalKeyWithMaintainersParties(gkwm, out, depth) {
|
|
596
|
+
if (depth > MAX_DEPTH)
|
|
597
|
+
throw new PreparedDecodeError("contract key nesting too deep");
|
|
598
|
+
const f = decodeMessage(gkwm);
|
|
599
|
+
// maintainers (repeated string party) — authorization parties on the key.
|
|
600
|
+
out.push(...stringFields(f, GKWM_MAINTAINERS));
|
|
601
|
+
// the inner GlobalKey.key Value (the contract-key payload) — descend it as a
|
|
602
|
+
// full Daml Value so a party leaf hidden inside the key surfaces too.
|
|
603
|
+
const globalKey = lenFieldUnique(f, GKWM_KEY, "GlobalKeyWithMaintainers.key");
|
|
604
|
+
if (globalKey !== undefined) {
|
|
605
|
+
const keyValue = lenFieldUnique(decodeMessage(globalKey), GLOBALKEY_KEY, "GlobalKey.key");
|
|
606
|
+
if (keyValue !== undefined)
|
|
607
|
+
collectPartyLeaves(keyValue, out, depth + 1);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
611
|
+
* PreparedTransaction structural descent.
|
|
612
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
613
|
+
// PreparedTransaction / Metadata / DamlTransaction / Node field numbers.
|
|
614
|
+
// Schema-pinned against com.daml.ledger.api.v2.interactive (Canton 3.x):
|
|
615
|
+
// PreparedTransaction.transaction=1, .metadata=2
|
|
616
|
+
// Metadata.submitter_info=2 ; SubmitterInfo.act_as=1
|
|
617
|
+
// DamlTransaction.version=1, .roots=2, .nodes=3
|
|
618
|
+
// DamlTransaction.Node.node_id=1, oneof versioned_node { v1=1000 }
|
|
619
|
+
// transaction.v1.Node oneof node_type { create=1 fetch=2 exercise=3
|
|
620
|
+
// rollback=4 query_by_key=5 }
|
|
621
|
+
// Exercise.choice_id=9, .chosen_value=10, .children=12, .exercise_result=13
|
|
622
|
+
// Create.argument=5 ; Rollback.children=1
|
|
623
|
+
const PT_TRANSACTION = 1;
|
|
624
|
+
const PT_METADATA = 2;
|
|
625
|
+
const MD_SUBMITTER_INFO = 2;
|
|
626
|
+
const SI_ACT_AS = 1;
|
|
627
|
+
// Metadata "needs to be signed" block (interactive_submission_service.proto):
|
|
628
|
+
// submitter_info=2 synchronizer_id=3 mediator_group=4 transaction_uuid=5
|
|
629
|
+
// preparation_time=6 input_contracts=7 min_ledger_effective_time=9
|
|
630
|
+
// max_ledger_effective_time=10 max_record_time=11
|
|
631
|
+
// (field 8 = global_key_mapping is the ONLY field NOT signed.) Every signed
|
|
632
|
+
// field is covered by the agent's signature, so verify must constrain the ones
|
|
633
|
+
// that affect WHERE/WHEN the transfer lands or WHICH parties it touches.
|
|
634
|
+
const MD_SYNCHRONIZER_ID = 3;
|
|
635
|
+
const MD_PREPARATION_TIME = 6;
|
|
636
|
+
const MD_INPUT_CONTRACTS = 7;
|
|
637
|
+
const MD_MIN_LEDGER_EFFECTIVE_TIME = 9;
|
|
638
|
+
const MD_MAX_LEDGER_EFFECTIVE_TIME = 10;
|
|
639
|
+
const MD_MAX_RECORD_TIME = 11;
|
|
640
|
+
// Metadata.InputContract: oneof contract { v1 Create = 1 }, created_at=1000,
|
|
641
|
+
// event_blob=1002. We descend the Create v1 argument for the party backstop.
|
|
642
|
+
const IC_V1 = 1;
|
|
643
|
+
const DT_ROOTS = 2;
|
|
644
|
+
const DT_NODES = 3;
|
|
645
|
+
const NODE_ID = 1;
|
|
646
|
+
const NODE_V1 = 1000;
|
|
647
|
+
// transaction.v1.Node.node_type oneof members.
|
|
648
|
+
const V1NODE_CREATE = 1;
|
|
649
|
+
const V1NODE_FETCH = 2;
|
|
650
|
+
const V1NODE_EXERCISE = 3;
|
|
651
|
+
const V1NODE_ROLLBACK = 4;
|
|
652
|
+
const V1NODE_QUERY_BY_KEY = 5;
|
|
653
|
+
// interactive.transaction.v1.Exercise field numbers (interactive_submission_data.proto).
|
|
654
|
+
const EX_CONTRACT_ID = 2; // Exercise.contract_id — the exercised target contract.
|
|
655
|
+
const EX_TEMPLATE_ID = 4;
|
|
656
|
+
const EX_SIGNATORIES = 5;
|
|
657
|
+
const EX_STAKEHOLDERS = 6;
|
|
658
|
+
const EX_ACTING_PARTIES = 7;
|
|
659
|
+
const EX_CHOICE_ID = 9;
|
|
660
|
+
const EX_CHOSEN_VALUE = 10;
|
|
661
|
+
const EX_CHILDREN = 12;
|
|
662
|
+
const EX_RESULT = 13;
|
|
663
|
+
const EX_CHOICE_OBSERVERS = 14;
|
|
664
|
+
const EX_KEY = 15; // Exercise.key : optional GlobalKeyWithMaintainers
|
|
665
|
+
// interactive.transaction.v1.Create field numbers.
|
|
666
|
+
const CREATE_ARGUMENT = 5;
|
|
667
|
+
const CREATE_SIGNATORIES = 6;
|
|
668
|
+
const CREATE_STAKEHOLDERS = 7;
|
|
669
|
+
const CREATE_KEY = 8; // Create.key : optional GlobalKeyWithMaintainers
|
|
670
|
+
// interactive.transaction.v1.Fetch field numbers (party lists; no Daml Value arg).
|
|
671
|
+
const FETCH_SIGNATORIES = 5;
|
|
672
|
+
const FETCH_STAKEHOLDERS = 6;
|
|
673
|
+
const FETCH_ACTING_PARTIES = 7;
|
|
674
|
+
const FETCH_KEY = 9; // Fetch.key : optional GlobalKeyWithMaintainers
|
|
675
|
+
// interactive.transaction.v1.QueryByKey + GlobalKeyWithMaintainers + GlobalKey.
|
|
676
|
+
const QBK_KEY = 5; // QueryByKey.key : GlobalKeyWithMaintainers
|
|
677
|
+
const GKWM_KEY = 1; // GlobalKeyWithMaintainers.key : GlobalKey
|
|
678
|
+
const GKWM_MAINTAINERS = 2; // GlobalKeyWithMaintainers.maintainers (repeated string party)
|
|
679
|
+
const GLOBALKEY_KEY = 3; // GlobalKey.key : Value (the contract-key Value tree)
|
|
680
|
+
const ROLLBACK_CHILDREN = 1;
|
|
681
|
+
// Identifier (com.daml.ledger.api.v2.value.Identifier): module_name=2, entity_name=3.
|
|
682
|
+
// (package_id=1 is intentionally NOT pinned — it varies with package upgrades.)
|
|
683
|
+
const ID_PACKAGE_ID = 1;
|
|
684
|
+
const ID_MODULE_NAME = 2;
|
|
685
|
+
const ID_ENTITY_NAME = 3;
|
|
686
|
+
/** The transfer choice on a TransferFactory. */
|
|
687
|
+
const TRANSFER_CHOICE = "TransferFactory_Transfer";
|
|
688
|
+
/** All values of a repeated `string` field, decoded UTF-8 (in order). */
|
|
689
|
+
function stringFields(fields, field) {
|
|
690
|
+
return lenFields(fields, field).map(utf8);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Decode an `Identifier` (value.proto) to its `module:entity` qualified name.
|
|
694
|
+
* The package id (field 1) is deliberately DROPPED: it changes across package
|
|
695
|
+
* upgrades, so pinning it would break honest transfers; module + entity together
|
|
696
|
+
* unambiguously name the template. Returns undefined if the identifier is empty
|
|
697
|
+
* or does not carry both names.
|
|
698
|
+
*/
|
|
699
|
+
function identifierQualifiedName(idBytes) {
|
|
700
|
+
// An Identifier may be serialized either as the structured message
|
|
701
|
+
// {package_id=1, module_name=2, entity_name=3} OR (in some Canton encodings of
|
|
702
|
+
// the interactive node template_id) as a single flat string "pkg:module:entity"
|
|
703
|
+
// / "module:entity". Handle both: try the structured fields first; only if a
|
|
704
|
+
// structured decode succeeds AND carries a module/entity field do we use it. A
|
|
705
|
+
// flat string is NOT valid protobuf, so `decodeMessage` may throw — fall back
|
|
706
|
+
// to the flat-string interpretation in that case.
|
|
707
|
+
let moduleName;
|
|
708
|
+
let entityName;
|
|
709
|
+
let structuredHadOtherFields = false;
|
|
710
|
+
try {
|
|
711
|
+
const f = decodeMessage(idBytes);
|
|
712
|
+
moduleName = lenFieldUnique(f, ID_MODULE_NAME, "Identifier.module_name");
|
|
713
|
+
entityName = lenFieldUnique(f, ID_ENTITY_NAME, "Identifier.entity_name");
|
|
714
|
+
structuredHadOtherFields = f.some((x) => x.field === ID_PACKAGE_ID);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
/* not structured protobuf — treat as a flat string below */
|
|
718
|
+
}
|
|
719
|
+
if (moduleName !== undefined && entityName !== undefined) {
|
|
720
|
+
return `${utf8(moduleName)}:${utf8(entityName)}`;
|
|
721
|
+
}
|
|
722
|
+
// A structured Identifier with a package_id but no module/entity is malformed
|
|
723
|
+
// for our purpose; only fall through to flat-string when it did NOT look like a
|
|
724
|
+
// structured Identifier at all.
|
|
725
|
+
if (structuredHadOtherFields)
|
|
726
|
+
return undefined;
|
|
727
|
+
// Flat-string form. A template id string is "package:Module.Path:Entity" or
|
|
728
|
+
// "Module.Path:Entity"; normalize to the trailing two colon-separated segments
|
|
729
|
+
// (module:entity), dropping a leading package-id when present.
|
|
730
|
+
let flat;
|
|
731
|
+
try {
|
|
732
|
+
flat = utf8(idBytes);
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
if (flat.length === 0 || flat.includes(""))
|
|
738
|
+
return undefined;
|
|
739
|
+
const parts = flat.split(":");
|
|
740
|
+
if (parts.length >= 3)
|
|
741
|
+
return `${parts[parts.length - 2]}:${parts[parts.length - 1]}`;
|
|
742
|
+
if (parts.length === 2)
|
|
743
|
+
return flat;
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Decode the inner `transaction.v1.Node` (the node_type oneof). Returns the
|
|
748
|
+
* single set member and the per-kind payload we validate. A node that sets NO
|
|
749
|
+
* known member, or MORE THAN ONE, is reported as `kind: undefined` so the
|
|
750
|
+
* invariant layer rejects it (fail closed — an ambiguous/opaque node could be
|
|
751
|
+
* executed by the participant as something other than what we read).
|
|
752
|
+
*
|
|
753
|
+
* Beyond the Daml `Value` payloads we also collect every node-level
|
|
754
|
+
* `repeated string party` field (signatories/stakeholders/acting_parties/
|
|
755
|
+
* choice_observers and a QueryByKey's key maintainers) into `partyMeta`, so the
|
|
756
|
+
* foreign-party backstop sees a party placed in authorization/visibility
|
|
757
|
+
* metadata (a position outside the Value-leaf walk), and the exercised
|
|
758
|
+
* template's `module:entity` so the arm can pin WHICH template the choice runs
|
|
759
|
+
* against.
|
|
760
|
+
*/
|
|
761
|
+
function decodeV1Node(v1Body) {
|
|
762
|
+
const v1n = decodeMessage(v1Body);
|
|
763
|
+
// Detect which (if any) node_type oneof members are present. Each is
|
|
764
|
+
// non-repeated; a duplicate is ambiguous under last-wins so reject via the
|
|
765
|
+
// unique accessor. Count DISTINCT members set — more than one ⇒ reject.
|
|
766
|
+
const create = lenFieldUnique(v1n, V1NODE_CREATE, "v1.Node.create");
|
|
767
|
+
const fetch = lenFieldUnique(v1n, V1NODE_FETCH, "v1.Node.fetch");
|
|
768
|
+
const exercise = lenFieldUnique(v1n, V1NODE_EXERCISE, "v1.Node.exercise");
|
|
769
|
+
const rollback = lenFieldUnique(v1n, V1NODE_ROLLBACK, "v1.Node.rollback");
|
|
770
|
+
const queryByKey = lenFieldUnique(v1n, V1NODE_QUERY_BY_KEY, "v1.Node.query_by_key");
|
|
771
|
+
const present = [create, fetch, exercise, rollback, queryByKey].filter((m) => m !== undefined);
|
|
772
|
+
if (present.length === 0)
|
|
773
|
+
return { children: [], values: [], partyMeta: [] }; // kind undefined ⇒ reject
|
|
774
|
+
if (present.length > 1)
|
|
775
|
+
return { children: [], values: [], partyMeta: [] }; // ambiguous oneof ⇒ reject
|
|
776
|
+
if (exercise !== undefined) {
|
|
777
|
+
const exFields = decodeMessage(exercise);
|
|
778
|
+
const choiceIdBytes = lenFieldUnique(exFields, EX_CHOICE_ID, "Exercise.choice_id");
|
|
779
|
+
const chosen = lenFieldUnique(exFields, EX_CHOSEN_VALUE, "Exercise.chosen_value");
|
|
780
|
+
const result = lenFieldUnique(exFields, EX_RESULT, "Exercise.exercise_result");
|
|
781
|
+
const children = lenFields(exFields, EX_CHILDREN).map(utf8);
|
|
782
|
+
const tmpl = lenFieldUnique(exFields, EX_TEMPLATE_ID, "Exercise.template_id");
|
|
783
|
+
const cidBytes = lenFieldUnique(exFields, EX_CONTRACT_ID, "Exercise.contract_id");
|
|
784
|
+
const values = [];
|
|
785
|
+
if (chosen !== undefined)
|
|
786
|
+
values.push(chosen);
|
|
787
|
+
if (result !== undefined)
|
|
788
|
+
values.push(result);
|
|
789
|
+
const partyMeta = [
|
|
790
|
+
...stringFields(exFields, EX_SIGNATORIES),
|
|
791
|
+
...stringFields(exFields, EX_STAKEHOLDERS),
|
|
792
|
+
...stringFields(exFields, EX_ACTING_PARTIES),
|
|
793
|
+
...stringFields(exFields, EX_CHOICE_OBSERVERS),
|
|
794
|
+
];
|
|
795
|
+
// Exercise.key (field 15, optional GlobalKeyWithMaintainers): a contract-key
|
|
796
|
+
// party (maintainer or inner key Value leaf) is a position the agent's
|
|
797
|
+
// signature covers under V3 hashing — scan it so a relay cannot hide a
|
|
798
|
+
// foreign party there (closes the contract-key backstop blind spot).
|
|
799
|
+
const exKey = lenFieldUnique(exFields, EX_KEY, "Exercise.key");
|
|
800
|
+
if (exKey !== undefined)
|
|
801
|
+
collectGlobalKeyWithMaintainersParties(exKey, partyMeta, 0);
|
|
802
|
+
const dec = choiceIdBytes !== undefined && chosen !== undefined
|
|
803
|
+
? {
|
|
804
|
+
choiceId: utf8(choiceIdBytes),
|
|
805
|
+
chosenValue: chosen,
|
|
806
|
+
contractId: cidBytes !== undefined ? utf8(cidBytes) : undefined,
|
|
807
|
+
templateQualifiedName: tmpl !== undefined ? identifierQualifiedName(tmpl) : undefined,
|
|
808
|
+
}
|
|
809
|
+
: undefined;
|
|
810
|
+
return { kind: "exercise", exercise: dec, children, values, partyMeta };
|
|
811
|
+
}
|
|
812
|
+
if (create !== undefined) {
|
|
813
|
+
const cFields = decodeMessage(create);
|
|
814
|
+
const arg = lenFieldUnique(cFields, CREATE_ARGUMENT, "Create.argument");
|
|
815
|
+
const partyMeta = [
|
|
816
|
+
...stringFields(cFields, CREATE_SIGNATORIES),
|
|
817
|
+
...stringFields(cFields, CREATE_STAKEHOLDERS),
|
|
818
|
+
];
|
|
819
|
+
// Create.key (field 8, optional GlobalKeyWithMaintainers): scan the key
|
|
820
|
+
// maintainers + inner contract-key Value so a foreign party placed ONLY in a
|
|
821
|
+
// consequence Create's key (a position the V3 hash signs) surfaces.
|
|
822
|
+
const cKey = lenFieldUnique(cFields, CREATE_KEY, "Create.key");
|
|
823
|
+
if (cKey !== undefined)
|
|
824
|
+
collectGlobalKeyWithMaintainersParties(cKey, partyMeta, 0);
|
|
825
|
+
return { kind: "create", children: [], values: arg !== undefined ? [arg] : [], partyMeta };
|
|
826
|
+
}
|
|
827
|
+
if (rollback !== undefined) {
|
|
828
|
+
const rFields = decodeMessage(rollback);
|
|
829
|
+
const children = lenFields(rFields, ROLLBACK_CHILDREN).map(utf8);
|
|
830
|
+
return { kind: "rollback", children, values: [], partyMeta: [] };
|
|
831
|
+
}
|
|
832
|
+
if (fetch !== undefined) {
|
|
833
|
+
// Fetch carries no Daml `Value` argument, but DOES carry node-level party
|
|
834
|
+
// lists (signatories/stakeholders/acting_parties) — collect them so the
|
|
835
|
+
// backstop sees a foreign party smuggled onto a fetch consequence.
|
|
836
|
+
const fFields = decodeMessage(fetch);
|
|
837
|
+
const partyMeta = [
|
|
838
|
+
...stringFields(fFields, FETCH_SIGNATORIES),
|
|
839
|
+
...stringFields(fFields, FETCH_STAKEHOLDERS),
|
|
840
|
+
...stringFields(fFields, FETCH_ACTING_PARTIES),
|
|
841
|
+
];
|
|
842
|
+
// Fetch.key (field 9, optional GlobalKeyWithMaintainers): scan its parties
|
|
843
|
+
// (maintainers + inner key Value) too.
|
|
844
|
+
const fKey = lenFieldUnique(fFields, FETCH_KEY, "Fetch.key");
|
|
845
|
+
if (fKey !== undefined)
|
|
846
|
+
collectGlobalKeyWithMaintainersParties(fKey, partyMeta, 0);
|
|
847
|
+
return { kind: "fetch", children: [], values: [], partyMeta };
|
|
848
|
+
}
|
|
849
|
+
// query_by_key — carries no Value argument, but its GlobalKeyWithMaintainers
|
|
850
|
+
// carries key maintainer parties AND an inner contract-key Value that can hold
|
|
851
|
+
// a party leaf; collect BOTH for the backstop (previously only maintainers
|
|
852
|
+
// were scanned, so a party hidden in the inner key Value escaped).
|
|
853
|
+
const qFields = decodeMessage(queryByKey);
|
|
854
|
+
const partyMeta = [];
|
|
855
|
+
const key = lenFieldUnique(qFields, QBK_KEY, "QueryByKey.key");
|
|
856
|
+
if (key !== undefined)
|
|
857
|
+
collectGlobalKeyWithMaintainersParties(key, partyMeta, 0);
|
|
858
|
+
return { kind: "query_by_key", children: [], values: [], partyMeta };
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Decode a base64 `PreparedTransaction` into the bits we validate.
|
|
862
|
+
*
|
|
863
|
+
* Enumerates EVERY node in DamlTransaction.nodes — every node-version member and
|
|
864
|
+
* every node-type oneof member — recording unrecognized ones as
|
|
865
|
+
* present-but-unvalidated (never silently skipping them). The single allowed
|
|
866
|
+
* root exercise, no-orphan reachability, and the all-nodes value backstop are
|
|
867
|
+
* enforced by the per-arm invariant (`assertSingleAllowedRootExercise`).
|
|
868
|
+
*/
|
|
869
|
+
export function decodePrepared(preparedTransactionB64) {
|
|
870
|
+
let bytes;
|
|
871
|
+
try {
|
|
872
|
+
bytes = Buffer.from(preparedTransactionB64, "base64");
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
throw new PreparedDecodeError("preparedTransaction is not valid base64");
|
|
876
|
+
}
|
|
877
|
+
if (bytes.length === 0)
|
|
878
|
+
throw new PreparedDecodeError("preparedTransaction decoded to empty bytes");
|
|
879
|
+
const top = decodeMessage(bytes);
|
|
880
|
+
// metadata.submitter_info.act_as. `transaction` and `metadata` are
|
|
881
|
+
// non-repeated: a duplicate would let a last-wins participant read a different
|
|
882
|
+
// one than we validate, so reject duplicates (act_as / nodes stay repeated).
|
|
883
|
+
const actAs = [];
|
|
884
|
+
let synchronizerId;
|
|
885
|
+
let preparationTime;
|
|
886
|
+
let minLedgerEffectiveTime;
|
|
887
|
+
let maxLedgerEffectiveTime;
|
|
888
|
+
let maxRecordTime;
|
|
889
|
+
const inputContractValues = [];
|
|
890
|
+
const inputContractPartyMeta = [];
|
|
891
|
+
const metadata = lenFieldUnique(top, PT_METADATA, "PreparedTransaction.metadata");
|
|
892
|
+
if (metadata !== undefined) {
|
|
893
|
+
const md = decodeMessage(metadata);
|
|
894
|
+
const si = lenFieldUnique(md, MD_SUBMITTER_INFO, "Metadata.submitter_info");
|
|
895
|
+
if (si !== undefined) {
|
|
896
|
+
for (const a of lenFields(decodeMessage(si), SI_ACT_AS))
|
|
897
|
+
actAs.push(utf8(a));
|
|
898
|
+
}
|
|
899
|
+
// synchronizer_id (field 3) — SIGNED; pinned to caller intent by the arms.
|
|
900
|
+
const sync = lenFieldUnique(md, MD_SYNCHRONIZER_ID, "Metadata.synchronizer_id");
|
|
901
|
+
if (sync !== undefined)
|
|
902
|
+
synchronizerId = utf8(sync);
|
|
903
|
+
// SIGNED timing fields (uint64 µs). Read BigInt-precise; the arms sanity-bound.
|
|
904
|
+
preparationTime = varintFieldUnique(md, MD_PREPARATION_TIME, "Metadata.preparation_time");
|
|
905
|
+
minLedgerEffectiveTime = varintFieldUnique(md, MD_MIN_LEDGER_EFFECTIVE_TIME, "Metadata.min_ledger_effective_time");
|
|
906
|
+
maxLedgerEffectiveTime = varintFieldUnique(md, MD_MAX_LEDGER_EFFECTIVE_TIME, "Metadata.max_ledger_effective_time");
|
|
907
|
+
maxRecordTime = varintFieldUnique(md, MD_MAX_RECORD_TIME, "Metadata.max_record_time");
|
|
908
|
+
// input_contracts (field 7, repeated, SIGNED): descend each InputContract's
|
|
909
|
+
// Create v1 argument so the foreign-party backstop covers parties that appear
|
|
910
|
+
// ONLY inside the authenticated input-contract set (never in a tx node). We
|
|
911
|
+
// ALSO read the Create's signatories/stakeholders/key — the V2 metadata hasher
|
|
912
|
+
// binds disclosed/input contracts via hashNode(toCreateNode) → addCreateNode,
|
|
913
|
+
// which hashes argument + signatories + stakeholders, so those party fields are
|
|
914
|
+
// covered by the agent's signature and a foreign party placed ONLY there must
|
|
915
|
+
// surface to the backstop (closes the input-contract party-coverage asymmetry).
|
|
916
|
+
for (const ic of lenFields(md, MD_INPUT_CONTRACTS)) {
|
|
917
|
+
const icFields = decodeMessage(ic);
|
|
918
|
+
const v1create = lenFieldUnique(icFields, IC_V1, "InputContract.v1");
|
|
919
|
+
if (v1create === undefined) {
|
|
920
|
+
// The InputContract sets no known `contract` oneof member (v1 = field 1).
|
|
921
|
+
// It is carried under an unknown/future encoding whose Create argument we
|
|
922
|
+
// cannot scan — fail closed (mirror the recognized=false treatment of
|
|
923
|
+
// transaction nodes). A foreign party hidden in such an input contract
|
|
924
|
+
// would otherwise escape the all-nodes backstop. (created_at/event_blob
|
|
925
|
+
// are high-numbered non-oneof fields, so an honest InputContract always
|
|
926
|
+
// carries the v1 member here.)
|
|
927
|
+
throw new PreparedDecodeError("Metadata.input_contracts entry sets no known `contract` member (expected v1) — " +
|
|
928
|
+
"refusing to sign (uninspectable input contract that could carry a foreign party)");
|
|
929
|
+
}
|
|
930
|
+
const createFields = decodeMessage(v1create);
|
|
931
|
+
const arg = lenFieldUnique(createFields, CREATE_ARGUMENT, "InputContract.v1.argument");
|
|
932
|
+
if (arg !== undefined)
|
|
933
|
+
inputContractValues.push(arg);
|
|
934
|
+
inputContractPartyMeta.push(...stringFields(createFields, CREATE_SIGNATORIES));
|
|
935
|
+
inputContractPartyMeta.push(...stringFields(createFields, CREATE_STAKEHOLDERS));
|
|
936
|
+
const icKey = lenFieldUnique(createFields, CREATE_KEY, "InputContract.v1.key");
|
|
937
|
+
if (icKey !== undefined) {
|
|
938
|
+
collectGlobalKeyWithMaintainersParties(icKey, inputContractPartyMeta, 0);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const transaction = lenFieldUnique(top, PT_TRANSACTION, "PreparedTransaction.transaction");
|
|
943
|
+
if (transaction === undefined) {
|
|
944
|
+
throw new PreparedDecodeError("PreparedTransaction has no transaction");
|
|
945
|
+
}
|
|
946
|
+
const dt = decodeMessage(transaction);
|
|
947
|
+
const roots = lenFields(dt, DT_ROOTS).map(utf8);
|
|
948
|
+
const nodes = [];
|
|
949
|
+
const exercises = [];
|
|
950
|
+
for (const node of lenFields(dt, DT_NODES)) {
|
|
951
|
+
const n = decodeMessage(node);
|
|
952
|
+
// node_id is non-repeated; reject a duplicate (a last-wins parser could
|
|
953
|
+
// disagree about which id this node carries, breaking roots/children
|
|
954
|
+
// reachability matching).
|
|
955
|
+
const nodeIdBytes = lenFieldUnique(n, NODE_ID, "Node.node_id");
|
|
956
|
+
const nodeId = nodeIdBytes !== undefined ? utf8(nodeIdBytes) : "";
|
|
957
|
+
// Versioned-node oneof: the ONLY known member is v1 (1000). If v1 is absent
|
|
958
|
+
// OR any OTHER length-delimited member (beyond node_id) is present, the node
|
|
959
|
+
// is carried under a version we cannot read → record it unrecognized so the
|
|
960
|
+
// invariant rejects it (closes the "hidden under node-version 1001" bypass).
|
|
961
|
+
const v1 = lenFieldUnique(n, NODE_V1, "Node.v1");
|
|
962
|
+
const extraVersioned = n.some((f) => f.wire === WIRE_LEN && f.field !== NODE_ID && f.field !== NODE_V1);
|
|
963
|
+
if (v1 === undefined || extraVersioned) {
|
|
964
|
+
nodes.push({ nodeId, recognizedVersion: false, children: [], values: [], partyMeta: [] });
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const dv = decodeV1Node(v1);
|
|
968
|
+
nodes.push({
|
|
969
|
+
nodeId,
|
|
970
|
+
recognizedVersion: true,
|
|
971
|
+
kind: dv.kind,
|
|
972
|
+
exercise: dv.exercise,
|
|
973
|
+
children: dv.children,
|
|
974
|
+
values: dv.values,
|
|
975
|
+
partyMeta: dv.partyMeta,
|
|
976
|
+
});
|
|
977
|
+
if (dv.kind === "exercise" && dv.exercise !== undefined)
|
|
978
|
+
exercises.push(dv.exercise);
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
actAs,
|
|
982
|
+
roots,
|
|
983
|
+
nodes,
|
|
984
|
+
exercises,
|
|
985
|
+
synchronizerId,
|
|
986
|
+
preparationTime,
|
|
987
|
+
minLedgerEffectiveTime,
|
|
988
|
+
maxLedgerEffectiveTime,
|
|
989
|
+
maxRecordTime,
|
|
990
|
+
inputContractValues,
|
|
991
|
+
inputContractPartyMeta,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
995
|
+
* Shared node-traversal INVARIANT (fix for the fund-redirection bypass).
|
|
996
|
+
*
|
|
997
|
+
* The strong typed-decode core proves the matched exercise's args equal intent.
|
|
998
|
+
* But a signature authorizes the WHOLE DamlTransaction, so verify must also
|
|
999
|
+
* prove there is NOTHING ELSE in the transaction. This invariant, enforced by
|
|
1000
|
+
* BOTH the cip56 and v1 arms, requires fail-closed (in this order — the first
|
|
1001
|
+
* three preserve the long-standing per-arm rejection messages, the rest close
|
|
1002
|
+
* the newly-found node-traversal bypasses):
|
|
1003
|
+
*
|
|
1004
|
+
* 1. EXACTLY ONE exercise of the allowed choice, and NO other exercise of any
|
|
1005
|
+
* choice (extra-leg / wrong-choice / no-exercise contract — unchanged).
|
|
1006
|
+
* 2. EVERY node is recognized — known node version (v1) AND a single known
|
|
1007
|
+
* node type. Any unrecognized version or unknown/ambiguous node type ⇒
|
|
1008
|
+
* reject (closes the non-1000 node-version and unknown-node-type bypasses).
|
|
1009
|
+
* 3. EXACTLY ONE root, and that root node is THE single allowed exercise with
|
|
1010
|
+
* the expected choice id. >1 root, or a root that is not that exercise ⇒
|
|
1011
|
+
* reject (closes the sibling-Create / second-root extra-leg bypass).
|
|
1012
|
+
* 4. NO ORPHANS — every other node is reachable from the root via the
|
|
1013
|
+
* exercise/rollback children chain. An unreferenced sibling node (even if
|
|
1014
|
+
* `roots` lists only the honest id) ⇒ reject.
|
|
1015
|
+
*
|
|
1016
|
+
* The authoritative-submitter (act_as) check and the position-aware all-message
|
|
1017
|
+
* value backstop are applied by the arm AFTER its money-critical field comparison
|
|
1018
|
+
* (so a consistent-attacker tx still surfaces as a field mismatch first), via
|
|
1019
|
+
* `assertActAsIsSender` / `collectSplitPartyLeaves` + `assertNoForeignParties`.
|
|
1020
|
+
*
|
|
1021
|
+
* Returns the single validated root exercise (and its node id, for the
|
|
1022
|
+
* position-aware backstop) so the arm can extract+compare its money-critical
|
|
1023
|
+
* fields. `allowedChoiceId` is the only choice the root may be.
|
|
1024
|
+
*/
|
|
1025
|
+
function assertSingleAllowedRootExercise(decoded, allowedChoiceId,
|
|
1026
|
+
/** Human-friendly plural ("transfer" / the choice id) used ONLY in the
|
|
1027
|
+
* exercise-count messages so each arm keeps its historical wording. */
|
|
1028
|
+
countNoun = allowedChoiceId) {
|
|
1029
|
+
// (1) Exercise-count contract (unchanged messages): exactly one exercise of
|
|
1030
|
+
// the allowed choice, and no other exercise of any choice. This runs first so
|
|
1031
|
+
// an extra/duplicate/wrong-choice exercise surfaces with the historical
|
|
1032
|
+
// message before the structural (root/orphan) checks below.
|
|
1033
|
+
const allowedExercises = decoded.exercises.filter((e) => e.choiceId === allowedChoiceId);
|
|
1034
|
+
if (allowedExercises.length === 0) {
|
|
1035
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction contains no ${allowedChoiceId} exercise — refusing to sign ` +
|
|
1036
|
+
`(possible tampered/compromised relay)`);
|
|
1037
|
+
}
|
|
1038
|
+
if (allowedExercises.length > 1) {
|
|
1039
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${allowedExercises.length} ${countNoun} ` +
|
|
1040
|
+
`exercises — refusing to sign (possible tampered/compromised relay adding a second leg)`);
|
|
1041
|
+
}
|
|
1042
|
+
const otherExercises = decoded.exercises.filter((e) => e.choiceId !== allowedChoiceId);
|
|
1043
|
+
if (otherExercises.length > 0) {
|
|
1044
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction contains unexpected exercise(s) ` +
|
|
1045
|
+
`${otherExercises.map((e) => JSON.stringify(e.choiceId)).join(", ")} — refusing to sign`);
|
|
1046
|
+
}
|
|
1047
|
+
// (2) Every node must be fully recognized. An unrecognized node version or an
|
|
1048
|
+
// unknown/ambiguous node type is opaque — a participant could execute it under
|
|
1049
|
+
// our signature — so fail closed.
|
|
1050
|
+
for (const node of decoded.nodes) {
|
|
1051
|
+
if (!node.recognizedVersion) {
|
|
1052
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) carried ` +
|
|
1053
|
+
`under an unrecognized node version — refusing to sign (cannot prove it does not move ` +
|
|
1054
|
+
`value under the agent's authority)`);
|
|
1055
|
+
}
|
|
1056
|
+
if (node.kind === undefined) {
|
|
1057
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) with an ` +
|
|
1058
|
+
`unknown or ambiguous node type — refusing to sign`);
|
|
1059
|
+
}
|
|
1060
|
+
// A node typed as an exercise whose choice_id/chosen_value we could NOT fully
|
|
1061
|
+
// decode (DecodedExercise undefined) is an OPAQUE exercise: it is invisible to
|
|
1062
|
+
// the exercise-count checks above (which key off decoded.exercises, populated
|
|
1063
|
+
// only for fully-decoded exercises) yet a participant could run it under the
|
|
1064
|
+
// agent's single signature. Fail closed — we cannot prove it is the single
|
|
1065
|
+
// allowed transfer. (Closes the exercise-count blind spot where a sibling
|
|
1066
|
+
// exercise sets the exercise oneof + choice_id but OMITS chosen_value.)
|
|
1067
|
+
if (node.kind === "exercise" && node.exercise === undefined) {
|
|
1068
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction contains an exercise node (id ${JSON.stringify(node.nodeId)}) ` +
|
|
1069
|
+
`whose choice argument could not be decoded (missing choice_id/chosen_value) — refusing ` +
|
|
1070
|
+
`to sign (cannot prove it is the intended ${JSON.stringify(allowedChoiceId)} exercise)`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// (3) Exactly one root, and it must resolve to the single allowed exercise.
|
|
1074
|
+
if (decoded.roots.length !== 1) {
|
|
1075
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction has ${decoded.roots.length} root nodes — expected exactly one ` +
|
|
1076
|
+
`(${JSON.stringify(allowedChoiceId)}) — refusing to sign (possible tampered/compromised ` +
|
|
1077
|
+
`relay adding a second root leg)`);
|
|
1078
|
+
}
|
|
1079
|
+
const byId = new Map();
|
|
1080
|
+
for (const node of decoded.nodes) {
|
|
1081
|
+
if (byId.has(node.nodeId)) {
|
|
1082
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction has duplicate node id ${JSON.stringify(node.nodeId)} — ` +
|
|
1083
|
+
`refusing to sign (ambiguous node graph)`);
|
|
1084
|
+
}
|
|
1085
|
+
byId.set(node.nodeId, node);
|
|
1086
|
+
}
|
|
1087
|
+
const rootId = decoded.roots[0];
|
|
1088
|
+
const rootNode = byId.get(rootId);
|
|
1089
|
+
if (rootNode === undefined) {
|
|
1090
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction root id ${JSON.stringify(rootId)} does not match any node — ` +
|
|
1091
|
+
`refusing to sign`);
|
|
1092
|
+
}
|
|
1093
|
+
if (rootNode.kind !== "exercise" || rootNode.exercise === undefined) {
|
|
1094
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction root node is not a ${JSON.stringify(allowedChoiceId)} exercise ` +
|
|
1095
|
+
`(got node type ${JSON.stringify(rootNode.kind ?? "unknown")}) — refusing to sign`);
|
|
1096
|
+
}
|
|
1097
|
+
if (rootNode.exercise.choiceId !== allowedChoiceId) {
|
|
1098
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction root exercise is ${JSON.stringify(rootNode.exercise.choiceId)} — ` +
|
|
1099
|
+
`expected ${JSON.stringify(allowedChoiceId)} — refusing to sign`);
|
|
1100
|
+
}
|
|
1101
|
+
// (4) No orphans: every node must be reachable from the single root via the
|
|
1102
|
+
// children chain. An unreferenced sibling (the extra-leg vector) ⇒ reject.
|
|
1103
|
+
const reachable = new Set();
|
|
1104
|
+
const stack = [rootId];
|
|
1105
|
+
while (stack.length > 0) {
|
|
1106
|
+
const id = stack.pop();
|
|
1107
|
+
if (reachable.has(id))
|
|
1108
|
+
continue;
|
|
1109
|
+
reachable.add(id);
|
|
1110
|
+
const node = byId.get(id);
|
|
1111
|
+
if (node === undefined) {
|
|
1112
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction references child node id ${JSON.stringify(id)} that does not ` +
|
|
1113
|
+
`exist — refusing to sign`);
|
|
1114
|
+
}
|
|
1115
|
+
for (const child of node.children)
|
|
1116
|
+
stack.push(child);
|
|
1117
|
+
}
|
|
1118
|
+
const orphans = decoded.nodes.filter((nd) => !reachable.has(nd.nodeId));
|
|
1119
|
+
if (orphans.length > 0) {
|
|
1120
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${orphans.length} node(s) not reachable from the root ` +
|
|
1121
|
+
`(ids ${orphans.map((o) => JSON.stringify(o.nodeId)).join(", ")}) — refusing to sign ` +
|
|
1122
|
+
`(possible tampered/compromised relay hiding an extra leg)`);
|
|
1123
|
+
}
|
|
1124
|
+
return { exercise: rootNode.exercise, rootNodeId: rootId };
|
|
1125
|
+
}
|
|
1126
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
1127
|
+
* Shared SIGNED-Metadata checks (synchronizer + timing). Every field below is
|
|
1128
|
+
* in the proto's "Metadata information that needs to be signed" block, so the
|
|
1129
|
+
* agent's single signature authorizes them; verify must constrain the ones that
|
|
1130
|
+
* decide WHERE (which synchronizer) and WHEN (validity window) the transfer
|
|
1131
|
+
* lands, or a malicious relay can land the agent's signature on a wrong domain
|
|
1132
|
+
* or with an already-lapsed / implausibly-skewed validity window.
|
|
1133
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
1134
|
+
/** Pin Metadata.synchronizer_id to caller intent when the caller supplies one.
|
|
1135
|
+
* Fail-closed: if pinned but absent/different, refuse. (No pin ⇒ unchanged.) */
|
|
1136
|
+
function assertSynchronizerMatches(synchronizerId, expected) {
|
|
1137
|
+
if (expected === undefined)
|
|
1138
|
+
return; // caller did not pin — unchanged behaviour
|
|
1139
|
+
if (synchronizerId === undefined) {
|
|
1140
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction has no synchronizer_id but caller intent pins ` +
|
|
1141
|
+
`${JSON.stringify(expected)} — refusing to sign`);
|
|
1142
|
+
}
|
|
1143
|
+
if (synchronizerId !== expected) {
|
|
1144
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction synchronizer_id is ${JSON.stringify(synchronizerId)} — expected ` +
|
|
1145
|
+
`${JSON.stringify(expected)} — refusing to sign (relay-chosen synchronizer/domain)`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/** A generous skew (24h) for sanity-bounding the SIGNED Metadata timing fields.
|
|
1149
|
+
* We are not pinning these to a tight window (the participant enforces the real
|
|
1150
|
+
* ledger-time window); we only reject values that are clearly wrong — already
|
|
1151
|
+
* lapsed, or implausibly far from now — so the agent never blind-signs a command
|
|
1152
|
+
* that can never settle or carries a wildly-skewed timestamp. */
|
|
1153
|
+
const TIMING_SKEW_MS = 24 * 60 * 60 * 1000;
|
|
1154
|
+
/** Microseconds-since-epoch BigInt → milliseconds Number (NaN if not finite). */
|
|
1155
|
+
function microsToMs(micros) {
|
|
1156
|
+
if (micros === undefined)
|
|
1157
|
+
return NaN;
|
|
1158
|
+
try {
|
|
1159
|
+
return Number(micros / 1000n);
|
|
1160
|
+
}
|
|
1161
|
+
catch {
|
|
1162
|
+
return NaN;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Sanity-bound the SIGNED Metadata timing fields against `nowMs`:
|
|
1167
|
+
* - preparation_time must be within ±TIMING_SKEW of now (not wildly skewed);
|
|
1168
|
+
* - max_record_time / max_ledger_effective_time must not already be in the past
|
|
1169
|
+
* (an expired command can never settle — at best a DoS, at worst replays a
|
|
1170
|
+
* stale intent). These are best-effort: absent fields are skipped.
|
|
1171
|
+
*/
|
|
1172
|
+
function assertTimingPlausible(decoded, nowMs) {
|
|
1173
|
+
const prep = microsToMs(decoded.preparationTime);
|
|
1174
|
+
if (Number.isFinite(prep)) {
|
|
1175
|
+
if (prep > nowMs + TIMING_SKEW_MS || prep < nowMs - TIMING_SKEW_MS) {
|
|
1176
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction preparation_time (${new Date(prep).toISOString()}) is ` +
|
|
1177
|
+
`implausibly far from now — refusing to sign`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const maxRecord = microsToMs(decoded.maxRecordTime);
|
|
1181
|
+
if (Number.isFinite(maxRecord) && maxRecord <= nowMs) {
|
|
1182
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction max_record_time (${new Date(maxRecord).toISOString()}) is ` +
|
|
1183
|
+
`already in the past — refusing to sign (command could never be recorded)`);
|
|
1184
|
+
}
|
|
1185
|
+
const maxLet = microsToMs(decoded.maxLedgerEffectiveTime);
|
|
1186
|
+
if (Number.isFinite(maxLet) && maxLet <= nowMs) {
|
|
1187
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction max_ledger_effective_time (${new Date(maxLet).toISOString()}) ` +
|
|
1188
|
+
`is already in the past — refusing to sign`);
|
|
1189
|
+
}
|
|
1190
|
+
// min_ledger_effective_time is SIGNED and was DECODED but previously not bounded
|
|
1191
|
+
// (an asymmetry vs the fields above). A relay-chosen value implausibly far in
|
|
1192
|
+
// the future would make the agent sign a command pinned to an unreachable
|
|
1193
|
+
// validity window (it can never become ledger-valid before it expires). Reject
|
|
1194
|
+
// it like the others; near-now / absent values pass.
|
|
1195
|
+
const minLet = microsToMs(decoded.minLedgerEffectiveTime);
|
|
1196
|
+
if (Number.isFinite(minLet) && minLet > nowMs + TIMING_SKEW_MS) {
|
|
1197
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction min_ledger_effective_time (${new Date(minLet).toISOString()}) ` +
|
|
1198
|
+
`is implausibly far in the future — refusing to sign (command pinned to an unreachable ` +
|
|
1199
|
+
`validity window)`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Pin WHICH template the validated root exercise runs against. The choice NAME
|
|
1204
|
+
* and ARGUMENT are pinned elsewhere; this closes the template/contract-confusion
|
|
1205
|
+
* surface by additionally requiring the exercised template's `module:entity`
|
|
1206
|
+
* qualified name to equal caller intent (the package id is intentionally not
|
|
1207
|
+
* pinned — it changes across upgrades). No pin ⇒ unchanged behaviour.
|
|
1208
|
+
*/
|
|
1209
|
+
function assertTemplateMatches(ex, expected) {
|
|
1210
|
+
if (expected === undefined)
|
|
1211
|
+
return;
|
|
1212
|
+
if (ex.templateQualifiedName === undefined) {
|
|
1213
|
+
throw new PreparedTransferMismatchError(`relay-prepared exercise carries no decodable template_id but caller intent pins ` +
|
|
1214
|
+
`${JSON.stringify(expected)} — refusing to sign`);
|
|
1215
|
+
}
|
|
1216
|
+
if (ex.templateQualifiedName !== expected) {
|
|
1217
|
+
throw new PreparedTransferMismatchError(`relay-prepared exercise runs against template ${JSON.stringify(ex.templateQualifiedName)} — ` +
|
|
1218
|
+
`expected ${JSON.stringify(expected)} — refusing to sign (template/contract confusion)`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Pin WHICH contract the validated root exercise is exercised on
|
|
1223
|
+
* (Exercise.contract_id). Defense-in-depth, OPT-IN: when the caller supplies the
|
|
1224
|
+
* exact target cid it built the command against (the factory / EPAR contract id
|
|
1225
|
+
* the relay resolved to it), the verifier requires the prepared exercise to
|
|
1226
|
+
* target the SAME contract and fails closed otherwise — closing the
|
|
1227
|
+
* resolve→prepare TOCTOU where a relay resolves one contract to the agent but
|
|
1228
|
+
* prepares the exercise against another. No pin ⇒ unchanged behaviour (the
|
|
1229
|
+
* all-nodes party backstop still contains any fund redirect a substituted target
|
|
1230
|
+
* could attempt, since the redirect needs a foreign-party consequence).
|
|
1231
|
+
*/
|
|
1232
|
+
function assertContractIdMatches(ex, expected) {
|
|
1233
|
+
if (expected === undefined)
|
|
1234
|
+
return;
|
|
1235
|
+
if (ex.contractId === undefined) {
|
|
1236
|
+
throw new PreparedTransferMismatchError(`relay-prepared exercise carries no decodable contract_id but caller intent pins ` +
|
|
1237
|
+
`${JSON.stringify(expected)} — refusing to sign`);
|
|
1238
|
+
}
|
|
1239
|
+
if (ex.contractId !== expected) {
|
|
1240
|
+
throw new PreparedTransferMismatchError(`relay-prepared exercise targets contract ${JSON.stringify(ex.contractId)} — ` +
|
|
1241
|
+
`expected ${JSON.stringify(expected)} — refusing to sign ` +
|
|
1242
|
+
`(relay points the choice at a contract other than the one it resolved)`);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Collect party leaves across the WHOLE signed message, split by position
|
|
1247
|
+
* relative to the validated root exercise (`rootNodeId`). Reuses the strong,
|
|
1248
|
+
* fail-closed `collectPartyLeaves` primitive on each Value payload, and adds the
|
|
1249
|
+
* node-level party-metadata strings + Metadata.input_contracts Create arguments
|
|
1250
|
+
* so a party introduced anywhere the agent's signature covers is visible.
|
|
1251
|
+
*/
|
|
1252
|
+
function collectSplitPartyLeaves(decoded, rootNodeId) {
|
|
1253
|
+
const rootArg = [];
|
|
1254
|
+
const elsewhere = [];
|
|
1255
|
+
for (const node of decoded.nodes) {
|
|
1256
|
+
const isRoot = node.nodeId === rootNodeId;
|
|
1257
|
+
if (isRoot && node.exercise !== undefined) {
|
|
1258
|
+
// The root exercise's chosen_value is the only place the admin/dso may sit
|
|
1259
|
+
// at its pinned position; its exercise_result (also in node.values) is a
|
|
1260
|
+
// consequence, so treat it as "elsewhere".
|
|
1261
|
+
collectPartyLeaves(node.exercise.chosenValue, rootArg, 0);
|
|
1262
|
+
for (const value of node.values) {
|
|
1263
|
+
if (value !== node.exercise.chosenValue)
|
|
1264
|
+
collectPartyLeaves(value, elsewhere, 0);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
for (const value of node.values)
|
|
1269
|
+
collectPartyLeaves(value, elsewhere, 0);
|
|
1270
|
+
}
|
|
1271
|
+
// Node-level party metadata (authorization/visibility parties) — for ALL
|
|
1272
|
+
// nodes including the root — are never a place the relay may introduce an
|
|
1273
|
+
// unexpected party, so they go in `elsewhere`.
|
|
1274
|
+
for (const p of node.partyMeta)
|
|
1275
|
+
elsewhere.push(p);
|
|
1276
|
+
}
|
|
1277
|
+
// Metadata.input_contracts Create arguments (SIGNED): any party here is an
|
|
1278
|
+
// input-contract payload party — not a place a new recipient legitimately
|
|
1279
|
+
// appears, so scan it under `elsewhere` too.
|
|
1280
|
+
for (const value of decoded.inputContractValues)
|
|
1281
|
+
collectPartyLeaves(value, elsewhere, 0);
|
|
1282
|
+
// Metadata.input_contracts Create signatories/stakeholders/key (also SIGNED via
|
|
1283
|
+
// the V2 metadata hasher's disclosed-contract hashNode): same treatment.
|
|
1284
|
+
for (const p of decoded.inputContractPartyMeta)
|
|
1285
|
+
elsewhere.push(p);
|
|
1286
|
+
return { rootArg, elsewhere };
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* The foreign-party backstop, position-aware so a relay-supplied admin/dso can
|
|
1290
|
+
* never widen the recipient set across the whole transaction.
|
|
1291
|
+
*
|
|
1292
|
+
* @param allowed {sender, receiver, (delegate)} — recipients pinned to intent.
|
|
1293
|
+
* @param adminDso the admin/dso value read at its OWN root position (or undefined).
|
|
1294
|
+
* @param adminDsoRootMax how many times `adminDso` may legitimately appear in the
|
|
1295
|
+
* root chosen_value (cip56: 2 = expectedAdmin + instrumentId.admin;
|
|
1296
|
+
* v1: 1 = expectedDso).
|
|
1297
|
+
* @param adminDsoTrusted true iff `adminDso` was pinned to an independently-trusted
|
|
1298
|
+
* caller value — only then is it safe to value-exclude it
|
|
1299
|
+
* OUTSIDE its root position (consequence/meta/input nodes).
|
|
1300
|
+
*/
|
|
1301
|
+
function assertNoForeignParties(leaves, allowed, adminDso, adminDsoRootMax, adminDsoTrusted) {
|
|
1302
|
+
const foreign = [];
|
|
1303
|
+
// Root chosen_value: allow recipients freely; allow adminDso up to its known
|
|
1304
|
+
// count at its pinned position(s); a SECOND/extra occurrence (e.g. a smuggled
|
|
1305
|
+
// extra recipient leaf that happens to equal the admin/dso value) is foreign.
|
|
1306
|
+
let adminDsoBudget = adminDso !== undefined ? adminDsoRootMax : 0;
|
|
1307
|
+
for (const p of leaves.rootArg) {
|
|
1308
|
+
if (allowed.has(p))
|
|
1309
|
+
continue;
|
|
1310
|
+
if (adminDso !== undefined && p === adminDso && adminDsoBudget > 0) {
|
|
1311
|
+
adminDsoBudget--;
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
foreign.push(p);
|
|
1315
|
+
}
|
|
1316
|
+
// Everywhere else (consequence/sibling node Value payloads, ANY node's party
|
|
1317
|
+
// metadata, Metadata.input_contracts arguments): allow recipients freely; allow
|
|
1318
|
+
// adminDso ONLY when it is independently TRUSTED (caller-pinned to an out-of-band
|
|
1319
|
+
// value). A relay-chosen, UNPINNED admin/dso that reappears outside its root
|
|
1320
|
+
// position is treated as FOREIGN — this closes the "alias the unpinned
|
|
1321
|
+
// admin/dso to an attacker and inject that same value as a consequence /
|
|
1322
|
+
// node-metadata / input-contract party" neutralization (the relay controls the
|
|
1323
|
+
// unpinned value, so value-excluding it anywhere would whitelist the attacker
|
|
1324
|
+
// globally). The honest consequence legitimately carries the real DSO as a
|
|
1325
|
+
// payload party + signatory, so a value-moving caller MUST supply a trusted
|
|
1326
|
+
// expectedDso/instrumentAdmin (out-of-band; tx.ts plumbs it from
|
|
1327
|
+
// CANTON_AGENT_DSO_PARTY) — without it we fail closed here rather than trust a
|
|
1328
|
+
// relay-supplied value. The previous no-trusted-pin value-global fallback is
|
|
1329
|
+
// REMOVED: it was exactly the residual neutralization an adversary exploits.
|
|
1330
|
+
for (const p of leaves.elsewhere) {
|
|
1331
|
+
if (allowed.has(p))
|
|
1332
|
+
continue;
|
|
1333
|
+
if (adminDso !== undefined && adminDsoTrusted && p === adminDso)
|
|
1334
|
+
continue;
|
|
1335
|
+
foreign.push(p);
|
|
1336
|
+
}
|
|
1337
|
+
if (foreign.length > 0) {
|
|
1338
|
+
const uniq = [...new Set(foreign)];
|
|
1339
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction references unexpected part${uniq.length === 1 ? "y" : "ies"} ` +
|
|
1340
|
+
`${uniq.map((f) => JSON.stringify(f)).join(", ")} — refusing to sign ` +
|
|
1341
|
+
`(possible tampered/compromised relay redirecting funds)`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Cross-check the authoritative submitter (Metadata.submitter_info.act_as):
|
|
1346
|
+
* REQUIRE a non-empty act_as whose only party is the sender. The non-empty
|
|
1347
|
+
* requirement closes GAP A3 (an empty act_as previously skipped this check
|
|
1348
|
+
* silently). Called by both arms AFTER the money-critical field comparison so a
|
|
1349
|
+
* consistent-attacker transaction surfaces as a field mismatch first.
|
|
1350
|
+
*/
|
|
1351
|
+
function assertActAsIsSender(actAs, sender) {
|
|
1352
|
+
if (actAs.length === 0) {
|
|
1353
|
+
throw new PreparedTransferMismatchError("relay-prepared transaction has an empty act_as (authoritative submitter) — " +
|
|
1354
|
+
"refusing to sign (the submitter must be positively proven to be the agent)");
|
|
1355
|
+
}
|
|
1356
|
+
const unexpected = actAs.filter((p) => p !== sender);
|
|
1357
|
+
if (unexpected.length > 0 || !actAs.includes(sender)) {
|
|
1358
|
+
throw new PreparedTransferMismatchError(`relay-prepared transaction acts as ${JSON.stringify(actAs)} — expected only ` +
|
|
1359
|
+
`${JSON.stringify(sender)} — refusing to sign`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Read a money-critical record field BY ITS DAML DECLARATION-ORDER POSITION,
|
|
1364
|
+
* cross-checking labels — the fix for the label-vs-positional binding divergence
|
|
1365
|
+
* (amount inflation / receiver swap via relabeled/reordered fields).
|
|
1366
|
+
*
|
|
1367
|
+
* WHY POSITIONAL: a Daml-LF record is POSITIONAL. The participant binds the
|
|
1368
|
+
* choice argument by the choice type's field ORDER; the wire `RecordField.label`
|
|
1369
|
+
* is advisory. A reader that prefers LABELS can be made to diverge from the
|
|
1370
|
+
* engine: a malicious relay places the honest value at a field LABELED "amount"
|
|
1371
|
+
* but at a wire position the engine does NOT read as amount, and an INFLATED
|
|
1372
|
+
* Numeric at the wire position the engine binds as amount (under a junk label) —
|
|
1373
|
+
* a by-label read sees the honest decoy and passes while the engine moves the
|
|
1374
|
+
* inflated amount. We therefore read the field at its declaration-order
|
|
1375
|
+
* `position` (what the engine reads) and additionally FAIL CLOSED on any
|
|
1376
|
+
* label/position inconsistency:
|
|
1377
|
+
* (a) the entry AT `position`, if labelled, must carry exactly `label`; and
|
|
1378
|
+
* (b) NO OTHER entry may carry `label` (a decoy field re-using a money-critical
|
|
1379
|
+
* label at the wrong position).
|
|
1380
|
+
* Honest encodings — fully labelled in declaration order, OR label-free
|
|
1381
|
+
* (normalized) — both pass; only a label/position-divergent record is rejected.
|
|
1382
|
+
* `expectedAt` lets the caller reject a field that the engine would bind at a
|
|
1383
|
+
* position the record does not even have (arity check for that field).
|
|
1384
|
+
*/
|
|
1385
|
+
function entryByDeclOrder(entries, position, label) {
|
|
1386
|
+
// (b) a money-critical label may appear at most once, and only at its own
|
|
1387
|
+
// declaration-order position. A decoy field re-using the label elsewhere is a
|
|
1388
|
+
// divergence attempt — fail closed.
|
|
1389
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1390
|
+
if (i !== position && entries[i]?.label === label) {
|
|
1391
|
+
throw new PreparedDecodeError(`record field labelled ${JSON.stringify(label)} appears at position ${i} but the Daml ` +
|
|
1392
|
+
`declaration order binds it at position ${position} — refusing to sign ` +
|
|
1393
|
+
`(label/position divergence, possible amount/receiver tamper)`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
const e = entries[position];
|
|
1397
|
+
if (e === undefined)
|
|
1398
|
+
return undefined;
|
|
1399
|
+
// (a) the entry the engine binds at `position`, if labelled, must be THIS field.
|
|
1400
|
+
if (e.label !== "" && e.label !== label) {
|
|
1401
|
+
throw new PreparedDecodeError(`record field at Daml declaration position ${position} is labelled ${JSON.stringify(e.label)} ` +
|
|
1402
|
+
`but ${JSON.stringify(label)} is expected there — refusing to sign ` +
|
|
1403
|
+
`(label/position divergence, possible amount/receiver tamper)`);
|
|
1404
|
+
}
|
|
1405
|
+
return e;
|
|
1406
|
+
}
|
|
1407
|
+
/** Read instrumentId {admin, id} from a record entry that is itself a record.
|
|
1408
|
+
* Declaration order: [0] admin:Party [1] id:Text. Read positionally with the
|
|
1409
|
+
* same label/position-divergence guard as the transfer fields. */
|
|
1410
|
+
function readInstrument(value) {
|
|
1411
|
+
const entries = recordEntries(value);
|
|
1412
|
+
const adminE = entryByDeclOrder(entries, 0, "admin");
|
|
1413
|
+
const idE = entryByDeclOrder(entries, 1, "id");
|
|
1414
|
+
const adminLeaf = adminE ? leafOf(adminE.value) : undefined;
|
|
1415
|
+
const idLeaf = idE ? leafOf(idE.value) : undefined;
|
|
1416
|
+
if (!adminLeaf || adminLeaf.kind !== "party") {
|
|
1417
|
+
throw new PreparedDecodeError("instrumentId.admin is not a party");
|
|
1418
|
+
}
|
|
1419
|
+
if (!idLeaf || idLeaf.kind !== "text") {
|
|
1420
|
+
throw new PreparedDecodeError("instrumentId.id is not text");
|
|
1421
|
+
}
|
|
1422
|
+
return { admin: adminLeaf.value, id: idLeaf.value };
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Extract the transfer body (sender/receiver/amount/instrument) from a
|
|
1426
|
+
* TransferFactory_Transfer choice argument, BY TYPE at its structural position.
|
|
1427
|
+
*/
|
|
1428
|
+
export function extractTransfer(chosenValue) {
|
|
1429
|
+
const top = recordEntries(chosenValue);
|
|
1430
|
+
// Locate the `transfer` sub-record STRICTLY at its Daml declaration position.
|
|
1431
|
+
// The outer choiceArgument is
|
|
1432
|
+
// [0]expectedAdmin:Party [1]transfer:Record [2]extraArgs:Record
|
|
1433
|
+
// so the participant binds `transfer` at declaration position 1, REGARDLESS of
|
|
1434
|
+
// labels (a Daml-LF record is positional; the wire `RecordField.label` is
|
|
1435
|
+
// advisory). We therefore read position 1 and ONLY position 1, with the same
|
|
1436
|
+
// label/position-divergence guard the money fields use.
|
|
1437
|
+
//
|
|
1438
|
+
// We deliberately do NOT fall back to a shape-based search ("find the first
|
|
1439
|
+
// record that looks like a transfer"): that override was a NON-positional read
|
|
1440
|
+
// that a malicious relay could weaponize. By type-diverging ONE field of the
|
|
1441
|
+
// engine's real position-1 transfer record (e.g. sender as Optional(Party) or
|
|
1442
|
+
// receiver as List(Party)) the relay made it fail the shape heuristic, so the
|
|
1443
|
+
// search returned a relay-planted, well-typed DECOY transfer record at a
|
|
1444
|
+
// DIFFERENT outer position carrying the honest amount — while the engine bound
|
|
1445
|
+
// the inflated amount from position 1. The verifier compared the decoy and
|
|
1446
|
+
// passed. Reading position 1 directly closes that divergence: the entry the
|
|
1447
|
+
// engine binds as `transfer` is the entry we validate, and a type-malformed
|
|
1448
|
+
// transfer record is itself a tamper signal — it fails the per-field type
|
|
1449
|
+
// checks below and we refuse to sign rather than search elsewhere.
|
|
1450
|
+
const transferVal = entryByDeclOrder(top, 1, "transfer")?.value;
|
|
1451
|
+
if (!transferVal)
|
|
1452
|
+
throw new PreparedDecodeError("could not locate transfer record in choice argument");
|
|
1453
|
+
const t = recordEntries(transferVal);
|
|
1454
|
+
// Read each money-critical field at its DAML DECLARATION-ORDER POSITION (what
|
|
1455
|
+
// the participant binds), NOT by label — and fail closed on any label/position
|
|
1456
|
+
// divergence (the amount-inflation / receiver-swap vector). Declaration order:
|
|
1457
|
+
// [0] sender:Party [1] receiver:Party [2] amount:Numeric [3] instrumentId:Record
|
|
1458
|
+
// (trailing requestedAt/executeBefore/inputHoldingCids/meta are not money-
|
|
1459
|
+
// critical here; party leaves in them are still covered by the backstop).
|
|
1460
|
+
const senderE = entryByDeclOrder(t, 0, "sender");
|
|
1461
|
+
const receiverE = entryByDeclOrder(t, 1, "receiver");
|
|
1462
|
+
const amountE = entryByDeclOrder(t, 2, "amount");
|
|
1463
|
+
const instrE = entryByDeclOrder(t, 3, "instrumentId");
|
|
1464
|
+
const sender = senderE ? leafOf(senderE.value) : undefined;
|
|
1465
|
+
const receiver = receiverE ? leafOf(receiverE.value) : undefined;
|
|
1466
|
+
const amount = amountE ? leafOf(amountE.value) : undefined;
|
|
1467
|
+
const instrumentVal = instrE?.value;
|
|
1468
|
+
if (!sender || sender.kind !== "party")
|
|
1469
|
+
throw new PreparedDecodeError("transfer.sender is not a party");
|
|
1470
|
+
if (!receiver || receiver.kind !== "party")
|
|
1471
|
+
throw new PreparedDecodeError("transfer.receiver is not a party");
|
|
1472
|
+
if (!amount || amount.kind !== "numeric")
|
|
1473
|
+
throw new PreparedDecodeError("transfer.amount is not numeric");
|
|
1474
|
+
if (!instrumentVal)
|
|
1475
|
+
throw new PreparedDecodeError("transfer.instrumentId missing");
|
|
1476
|
+
const instrument = readInstrument(instrumentVal);
|
|
1477
|
+
return {
|
|
1478
|
+
sender: sender.value,
|
|
1479
|
+
receiver: receiver.value,
|
|
1480
|
+
amount: amount.value,
|
|
1481
|
+
instrumentAdmin: instrument.admin,
|
|
1482
|
+
instrumentId: instrument.id,
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
export class PreparedDecodeError extends Error {
|
|
1486
|
+
constructor(message) {
|
|
1487
|
+
super(`preparedTransaction: ${message}`);
|
|
1488
|
+
this.name = "PreparedDecodeError";
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
export class PreparedTransferMismatchError extends Error {
|
|
1492
|
+
constructor(message) {
|
|
1493
|
+
super(message);
|
|
1494
|
+
this.name = "PreparedTransferMismatchError";
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Assert the relay-returned `preparedTransaction` encodes EXACTLY the transfer
|
|
1499
|
+
* the agent intended. Throws `PreparedTransferMismatchError` on any mismatch
|
|
1500
|
+
* and `PreparedDecodeError` if the bytes are not a decodable PreparedTransaction
|
|
1501
|
+
* carrying a single transfer exercise. Call this BEFORE signing `hash`.
|
|
1502
|
+
*
|
|
1503
|
+
* Fail-closed: anything we cannot positively prove matches the intent throws.
|
|
1504
|
+
*/
|
|
1505
|
+
export function assertPreparedTransferMatches(preparedTransactionB64, expect) {
|
|
1506
|
+
const decoded = decodePrepared(preparedTransactionB64);
|
|
1507
|
+
// Node-traversal invariant (shared with the v1 arm): exactly one allowed
|
|
1508
|
+
// exercise & no other; EVERY node recognized (no node hidden under an unknown
|
|
1509
|
+
// version/type); EXACTLY ONE root that IS the single allowed
|
|
1510
|
+
// TransferFactory_Transfer exercise; no orphan/extra-leg node. Returns the
|
|
1511
|
+
// validated root exercise + its node id (for the position-aware backstop).
|
|
1512
|
+
const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, TRANSFER_CHOICE, "transfer");
|
|
1513
|
+
const t = extractTransfer(ex.chosenValue);
|
|
1514
|
+
const mismatches = [];
|
|
1515
|
+
if (t.sender !== expect.sender) {
|
|
1516
|
+
mismatches.push(`sender (got ${JSON.stringify(t.sender)}, intended ${JSON.stringify(expect.sender)})`);
|
|
1517
|
+
}
|
|
1518
|
+
if (t.receiver !== expect.receiver) {
|
|
1519
|
+
mismatches.push(`receiver (got ${JSON.stringify(t.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
|
|
1520
|
+
}
|
|
1521
|
+
if (t.amount !== expect.amount) {
|
|
1522
|
+
mismatches.push(`amount (got ${JSON.stringify(t.amount)}, intended ${JSON.stringify(expect.amount)})`);
|
|
1523
|
+
}
|
|
1524
|
+
if (t.instrumentId !== expect.instrumentId) {
|
|
1525
|
+
mismatches.push(`instrumentId.id (got ${JSON.stringify(t.instrumentId)}, intended ${JSON.stringify(expect.instrumentId)})`);
|
|
1526
|
+
}
|
|
1527
|
+
// Only pin the admin if the caller supplied an independently-trusted value.
|
|
1528
|
+
if (expect.instrumentAdmin !== undefined && t.instrumentAdmin !== expect.instrumentAdmin) {
|
|
1529
|
+
mismatches.push(`instrumentId.admin (got ${JSON.stringify(t.instrumentAdmin)}, intended ${JSON.stringify(expect.instrumentAdmin)})`);
|
|
1530
|
+
}
|
|
1531
|
+
if (mismatches.length > 0) {
|
|
1532
|
+
throw new PreparedTransferMismatchError(`relay-prepared transfer does not match intent: ${mismatches.join("; ")} — ` +
|
|
1533
|
+
`refusing to sign (possible tampered/compromised relay redirecting funds)`);
|
|
1534
|
+
}
|
|
1535
|
+
// The admin/dso must never be aliased to a money role — otherwise a relay could
|
|
1536
|
+
// set the (unpinned) admin to the receiver/sender and have the backstop exempt
|
|
1537
|
+
// a money party. Reject up front.
|
|
1538
|
+
if (t.instrumentAdmin === expect.sender ||
|
|
1539
|
+
t.instrumentAdmin === expect.receiver) {
|
|
1540
|
+
throw new PreparedTransferMismatchError(`relay-prepared transfer instrumentId.admin ${JSON.stringify(t.instrumentAdmin)} equals a ` +
|
|
1541
|
+
`transfer party (sender/receiver) — refusing to sign`);
|
|
1542
|
+
}
|
|
1543
|
+
// Pin WHICH template + WHICH contract the choice runs against
|
|
1544
|
+
// (template/contract-confusion + resolve→prepare TOCTOU) and the SIGNED
|
|
1545
|
+
// synchronizer + timing metadata (relay-chosen domain / validity window). All
|
|
1546
|
+
// no-ops unless the caller supplies the corresponding intent.
|
|
1547
|
+
assertTemplateMatches(ex, expect.templateQualifiedName);
|
|
1548
|
+
assertContractIdMatches(ex, expect.expectedContractId);
|
|
1549
|
+
assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
|
|
1550
|
+
assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
|
|
1551
|
+
// Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
|
|
1552
|
+
// only party is the sender (closes GAP A3 — an empty act_as no longer skips
|
|
1553
|
+
// this). Placed after the field comparison so a consistent-attacker tx
|
|
1554
|
+
// surfaces as a field mismatch first.
|
|
1555
|
+
assertActAsIsSender(decoded.actAs, expect.sender);
|
|
1556
|
+
// Position-aware foreign-party backstop over the WHOLE signed message (all node
|
|
1557
|
+
// Value payloads + node-level party metadata + Metadata.input_contracts): no
|
|
1558
|
+
// party other than {sender, receiver} may appear, EXCEPT the instrument admin
|
|
1559
|
+
// (DSO) at its known root positions (expectedAdmin + instrumentId.admin = 2).
|
|
1560
|
+
// The admin is value-excluded OUTSIDE its root position ONLY when pinned to an
|
|
1561
|
+
// independently-trusted value (expect.instrumentAdmin) — so a relay-chosen,
|
|
1562
|
+
// unpinned admin can no longer be aliased to the attacker and smuggled in as a
|
|
1563
|
+
// consequence/metadata recipient.
|
|
1564
|
+
const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
|
|
1565
|
+
assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver]), t.instrumentAdmin, 2 /* expectedAdmin + instrumentId.admin */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */);
|
|
1566
|
+
}
|
|
1567
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
1568
|
+
* v1 (external-party-amulet-rules) — verify-before-sign for the
|
|
1569
|
+
* `ExternalPartyAmuletRules_CreateTransferCommand` exercise.
|
|
1570
|
+
*
|
|
1571
|
+
* THREAT MODEL — identical to the cip56 arm above, for the v1 path. The agent
|
|
1572
|
+
* asks the relay to PREPARE an `ExternalPartyAmuletRules_CreateTransferCommand`
|
|
1573
|
+
* (it creates a `TransferCommand` the facilitator later settles). A compromised
|
|
1574
|
+
* relay could prepare a DIFFERENT command — swap the receiver, inflate the
|
|
1575
|
+
* amount, redirect the delegate to an attacker who can then steer settlement,
|
|
1576
|
+
* or change the sender — and hand back its hash. We decode the prepared bytes
|
|
1577
|
+
* with the SAME structural rigor (schema-pinned field numbers, fail-closed on
|
|
1578
|
+
* duplicate/multi-member oneofs, parties matched BY TYPE not text) and assert
|
|
1579
|
+
* each money-critical field equals CALLER INTENT at its structural position.
|
|
1580
|
+
*
|
|
1581
|
+
* choiceArgument :: Record {
|
|
1582
|
+
* sender : Party, -- MUST == the agent's own party
|
|
1583
|
+
* receiver : Party, -- MUST == merchant payTo from the 402
|
|
1584
|
+
* delegate : Party, -- MUST == facilitatorParty from the 402 extra
|
|
1585
|
+
* amount : Numeric, -- MUST == the required amount
|
|
1586
|
+
* expiresAt : Time, -- sanity-checked (present, plausible)
|
|
1587
|
+
* nonce : Int, -- sanity-checked (>= 0, matches expected)
|
|
1588
|
+
* description : Optional Text, -- not money-critical (facilitator re-validates)
|
|
1589
|
+
* expectedDso : Party -- relay-supplied; NOT trusted as a recipient
|
|
1590
|
+
* }
|
|
1591
|
+
*
|
|
1592
|
+
* We extract sender/receiver/delegate by label, else by Daml declaration order
|
|
1593
|
+
* (parties[0]/[1]/[2]); amount = the single Numeric leaf; nonce = the single
|
|
1594
|
+
* Int64 leaf. Every field read uses the unique accessors so a duplicate/decoy
|
|
1595
|
+
* occurrence fails closed rather than diverging from the participant's
|
|
1596
|
+
* last-wins parse. The foreign-party backstop allows ONLY {sender, receiver,
|
|
1597
|
+
* delegate} plus the expectedDso AT its own position — any other party (an extra
|
|
1598
|
+
* leg, a smuggled GenMap recipient) is rejected. expectedDso is relay-supplied,
|
|
1599
|
+
* so it is allowed positionally but NEVER used to widen the recipient allowlist.
|
|
1600
|
+
* ════════════════════════════════════════════════════════════════════════ */
|
|
1601
|
+
/** The create choice on ExternalPartyAmuletRules (v1 / facilitator-pays-gas). */
|
|
1602
|
+
const CREATE_TRANSFER_COMMAND_CHOICE = "ExternalPartyAmuletRules_CreateTransferCommand";
|
|
1603
|
+
/**
|
|
1604
|
+
* Extract the v1 create-command fields from its (flat) choice-argument record,
|
|
1605
|
+
* BY TYPE at its DAML DECLARATION-ORDER POSITION (what the participant binds),
|
|
1606
|
+
* NOT by label — and fail closed on any label/position divergence (the
|
|
1607
|
+
* amount-inflation / receiver-swap vector). Declaration order:
|
|
1608
|
+
* [0] sender:Party [1] receiver:Party [2] delegate:Party [3] amount:Numeric
|
|
1609
|
+
* [4] expiresAt:Time [5] nonce:Int [6] description:Optional Text [7] expectedDso:Party
|
|
1610
|
+
* Honest encodings — fully labelled in order OR label-free (normalized) — both
|
|
1611
|
+
* pass; a record whose labels disagree with declaration order (a decoy field
|
|
1612
|
+
* re-using a money-critical label, or a mislabeled value at a money position) is
|
|
1613
|
+
* rejected. Fails closed if a money-critical field is the wrong type / missing.
|
|
1614
|
+
*/
|
|
1615
|
+
export function extractCreateTransferCommand(chosenValue) {
|
|
1616
|
+
const entries = recordEntries(chosenValue);
|
|
1617
|
+
// ── positional reads at Daml declaration order, with label/position guard ──
|
|
1618
|
+
const senderE = entryByDeclOrder(entries, 0, "sender");
|
|
1619
|
+
const receiverE = entryByDeclOrder(entries, 1, "receiver");
|
|
1620
|
+
const delegateE = entryByDeclOrder(entries, 2, "delegate");
|
|
1621
|
+
const amountE = entryByDeclOrder(entries, 3, "amount");
|
|
1622
|
+
const expiresAtE = entryByDeclOrder(entries, 4, "expiresAt");
|
|
1623
|
+
const nonceE = entryByDeclOrder(entries, 5, "nonce");
|
|
1624
|
+
// position 6 = description:Optional Text — not money-critical; skip.
|
|
1625
|
+
const expectedDsoE = entryByDeclOrder(entries, 7, "expectedDso");
|
|
1626
|
+
const sender = senderE ? leafOf(senderE.value) : undefined;
|
|
1627
|
+
const receiver = receiverE ? leafOf(receiverE.value) : undefined;
|
|
1628
|
+
const delegate = delegateE ? leafOf(delegateE.value) : undefined;
|
|
1629
|
+
const amount = amountE ? leafOf(amountE.value) : undefined;
|
|
1630
|
+
const nonce = nonceE ? leafOf(nonceE.value) : undefined;
|
|
1631
|
+
const expiresAt = expiresAtE ? leafOf(expiresAtE.value) : undefined;
|
|
1632
|
+
// expectedDso is relay-supplied; read at its own position for the backstop
|
|
1633
|
+
// exclusion only, NEVER as a recipient allowlist entry.
|
|
1634
|
+
const expectedDso = expectedDsoE ? leafOf(expectedDsoE.value) : undefined;
|
|
1635
|
+
if (!sender || sender.kind !== "party") {
|
|
1636
|
+
throw new PreparedDecodeError("CreateTransferCommand.sender is not a party");
|
|
1637
|
+
}
|
|
1638
|
+
if (!receiver || receiver.kind !== "party") {
|
|
1639
|
+
throw new PreparedDecodeError("CreateTransferCommand.receiver is not a party");
|
|
1640
|
+
}
|
|
1641
|
+
if (!delegate || delegate.kind !== "party") {
|
|
1642
|
+
throw new PreparedDecodeError("CreateTransferCommand.delegate is not a party");
|
|
1643
|
+
}
|
|
1644
|
+
if (!amount || amount.kind !== "numeric") {
|
|
1645
|
+
throw new PreparedDecodeError("CreateTransferCommand.amount is not numeric");
|
|
1646
|
+
}
|
|
1647
|
+
if (!nonce || nonce.kind !== "int64") {
|
|
1648
|
+
throw new PreparedDecodeError("CreateTransferCommand.nonce is not an int");
|
|
1649
|
+
}
|
|
1650
|
+
return {
|
|
1651
|
+
sender: sender.value,
|
|
1652
|
+
receiver: receiver.value,
|
|
1653
|
+
delegate: delegate.value,
|
|
1654
|
+
amount: amount.value,
|
|
1655
|
+
nonce: nonce.value,
|
|
1656
|
+
...(expiresAt && expiresAt.kind === "timestamp"
|
|
1657
|
+
? { expiresAt: expiresAt.value }
|
|
1658
|
+
: {}),
|
|
1659
|
+
...(expectedDso && expectedDso.kind === "party"
|
|
1660
|
+
? { expectedDso: expectedDso.value }
|
|
1661
|
+
: {}),
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Assert the relay-returned `preparedTransaction` encodes EXACTLY the v1
|
|
1666
|
+
* `ExternalPartyAmuletRules_CreateTransferCommand` the agent intended. Throws
|
|
1667
|
+
* `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
|
|
1668
|
+
* the bytes are not a decodable PreparedTransaction carrying a single
|
|
1669
|
+
* create-command exercise. Call BEFORE signing the hash. Fail-closed: anything
|
|
1670
|
+
* not positively proven to match the intent throws.
|
|
1671
|
+
*/
|
|
1672
|
+
export function assertPreparedCreateTransferCommandMatches(preparedTransactionB64, expect) {
|
|
1673
|
+
const decoded = decodePrepared(preparedTransactionB64);
|
|
1674
|
+
// Node-traversal invariant (shared with the cip56 arm): exactly one allowed
|
|
1675
|
+
// exercise & no other; EVERY node recognized (no node hidden under an unknown
|
|
1676
|
+
// node version/type — closes the "sibling Create" and "node-version 1001"
|
|
1677
|
+
// bypasses); EXACTLY ONE root that IS the single allowed CreateTransferCommand
|
|
1678
|
+
// exercise; no orphan/extra-leg node. Returns the validated root exercise.
|
|
1679
|
+
const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, CREATE_TRANSFER_COMMAND_CHOICE);
|
|
1680
|
+
const c = extractCreateTransferCommand(ex.chosenValue);
|
|
1681
|
+
const mismatches = [];
|
|
1682
|
+
if (c.sender !== expect.sender) {
|
|
1683
|
+
mismatches.push(`sender (got ${JSON.stringify(c.sender)}, intended ${JSON.stringify(expect.sender)})`);
|
|
1684
|
+
}
|
|
1685
|
+
if (c.receiver !== expect.receiver) {
|
|
1686
|
+
mismatches.push(`receiver (got ${JSON.stringify(c.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
|
|
1687
|
+
}
|
|
1688
|
+
if (c.delegate !== expect.delegate) {
|
|
1689
|
+
mismatches.push(`delegate (got ${JSON.stringify(c.delegate)}, intended ${JSON.stringify(expect.delegate)})`);
|
|
1690
|
+
}
|
|
1691
|
+
if (c.amount !== expect.amount) {
|
|
1692
|
+
mismatches.push(`amount (got ${JSON.stringify(c.amount)}, intended ${JSON.stringify(expect.amount)})`);
|
|
1693
|
+
}
|
|
1694
|
+
if (expect.nonce !== undefined && c.nonce !== expect.nonce) {
|
|
1695
|
+
mismatches.push(`nonce (got ${JSON.stringify(c.nonce)}, intended ${JSON.stringify(expect.nonce)})`);
|
|
1696
|
+
}
|
|
1697
|
+
// Pin the DSO only if the caller supplied an independently-trusted value.
|
|
1698
|
+
if (expect.expectedDso !== undefined && c.expectedDso !== expect.expectedDso) {
|
|
1699
|
+
mismatches.push(`expectedDso (got ${JSON.stringify(c.expectedDso)}, intended ${JSON.stringify(expect.expectedDso)})`);
|
|
1700
|
+
}
|
|
1701
|
+
if (mismatches.length > 0) {
|
|
1702
|
+
throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand does not match intent: ` +
|
|
1703
|
+
`${mismatches.join("; ")} — refusing to sign ` +
|
|
1704
|
+
`(possible tampered/compromised relay redirecting funds)`);
|
|
1705
|
+
}
|
|
1706
|
+
// The DSO must never be aliased to a money role — otherwise a relay could set
|
|
1707
|
+
// the (unpinned) expectedDso to the receiver/sender/delegate and have the
|
|
1708
|
+
// backstop exempt a money party. Reject up front.
|
|
1709
|
+
if (c.expectedDso !== undefined &&
|
|
1710
|
+
(c.expectedDso === expect.sender ||
|
|
1711
|
+
c.expectedDso === expect.receiver ||
|
|
1712
|
+
c.expectedDso === expect.delegate)) {
|
|
1713
|
+
throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand expectedDso ${JSON.stringify(c.expectedDso)} equals a ` +
|
|
1714
|
+
`command party (sender/receiver/delegate) — refusing to sign`);
|
|
1715
|
+
}
|
|
1716
|
+
// Pin WHICH template + WHICH contract the choice runs against
|
|
1717
|
+
// (template/contract-confusion + resolve→prepare TOCTOU) and the SIGNED
|
|
1718
|
+
// synchronizer + timing metadata (relay-chosen domain / validity window).
|
|
1719
|
+
// No-ops unless the caller supplies the corresponding intent.
|
|
1720
|
+
assertTemplateMatches(ex, expect.templateQualifiedName);
|
|
1721
|
+
assertContractIdMatches(ex, expect.expectedContractId);
|
|
1722
|
+
assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
|
|
1723
|
+
assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
|
|
1724
|
+
// nonce sanity: a negative nonce is never legitimate (Daml Int can be
|
|
1725
|
+
// negative on the wire, but a TransferCommand nonce is a monotonic counter).
|
|
1726
|
+
let nonceBig;
|
|
1727
|
+
try {
|
|
1728
|
+
nonceBig = BigInt(c.nonce);
|
|
1729
|
+
}
|
|
1730
|
+
catch {
|
|
1731
|
+
throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand has a non-integer nonce ` +
|
|
1732
|
+
`${JSON.stringify(c.nonce)} — refusing to sign`);
|
|
1733
|
+
}
|
|
1734
|
+
if (nonceBig < 0n) {
|
|
1735
|
+
throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand has a negative nonce ` +
|
|
1736
|
+
`${JSON.stringify(c.nonce)} — refusing to sign`);
|
|
1737
|
+
}
|
|
1738
|
+
// expiresAt sanity (best-effort): when present as a timestamp leaf, reject a
|
|
1739
|
+
// value already in the past — a relay-set expiry that has lapsed would create
|
|
1740
|
+
// a command the facilitator can never settle (and at worst replays a stale
|
|
1741
|
+
// intent). Value.timestamp is microseconds since the Unix epoch.
|
|
1742
|
+
if (c.expiresAt !== undefined) {
|
|
1743
|
+
const nowMs = expect.nowMs ?? Date.now();
|
|
1744
|
+
let expiresMs;
|
|
1745
|
+
try {
|
|
1746
|
+
expiresMs = Number(BigInt(c.expiresAt) / 1000n);
|
|
1747
|
+
}
|
|
1748
|
+
catch {
|
|
1749
|
+
expiresMs = NaN;
|
|
1750
|
+
}
|
|
1751
|
+
if (Number.isFinite(expiresMs) && expiresMs <= nowMs) {
|
|
1752
|
+
throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand expiresAt is in the past ` +
|
|
1753
|
+
`(${new Date(expiresMs).toISOString()}) — refusing to sign`);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
// Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
|
|
1757
|
+
// only party is the sender (closes GAP A3 — an empty act_as no longer skips
|
|
1758
|
+
// this). Placed after the field comparison so a consistent-attacker tx
|
|
1759
|
+
// surfaces as a field mismatch first.
|
|
1760
|
+
assertActAsIsSender(decoded.actAs, expect.sender);
|
|
1761
|
+
// Position-aware foreign-party backstop over the WHOLE signed message (all node
|
|
1762
|
+
// Value payloads + node-level party metadata + Metadata.input_contracts): no
|
|
1763
|
+
// party other than {sender, receiver, delegate} may appear, EXCEPT the
|
|
1764
|
+
// expectedDso (DSO) at its known root position (1). The DSO is value-excluded
|
|
1765
|
+
// OUTSIDE its root position ONLY when pinned to an independently-trusted value
|
|
1766
|
+
// (expect.expectedDso) — so a relay-chosen, unpinned DSO can no longer be
|
|
1767
|
+
// aliased to the attacker and smuggled in as a consequence/metadata recipient.
|
|
1768
|
+
const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
|
|
1769
|
+
assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver, expect.delegate]), c.expectedDso, 1 /* expectedDso */, expect.expectedDso !== undefined /* trusted iff caller pinned it */);
|
|
1770
|
+
}
|
|
1771
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
1772
|
+
* claim path — verify-before-sign for `TransferInstruction_Accept`.
|
|
1773
|
+
*
|
|
1774
|
+
* THREAT MODEL: the relay is MALICIOUS. `claimAll` accepts INCOMING transfers
|
|
1775
|
+
* (funds the agent is RECEIVING). Previously it passed NO verify and NO hash
|
|
1776
|
+
* binding, so the agent blind-signed whatever the relay returned — a malicious
|
|
1777
|
+
* relay could return an OUTBOUND drain (a CreateTransferCommand / TransferFactory
|
|
1778
|
+
* _Transfer sending the agent's balance to an attacker) with the matching honest
|
|
1779
|
+
* hash, and the agent would sign it. A single Ed25519 signature authorizes the
|
|
1780
|
+
* WHOLE transaction, so the claim path must be gated like every value-moving
|
|
1781
|
+
* path.
|
|
1782
|
+
*
|
|
1783
|
+
* The decisive structural property of a legitimate claim: its SINGLE ROOT
|
|
1784
|
+
* exercise is `TransferInstruction_Accept`, and there is NO OTHER exercise of any
|
|
1785
|
+
* choice — in particular no outbound transfer/create-transfer-command leg. We
|
|
1786
|
+
* reuse the shared invariant (`assertSingleAllowedRootExercise` with
|
|
1787
|
+
* allowedChoiceId = TransferInstruction_Accept), which fails closed on a drain
|
|
1788
|
+
* (its root is an outbound choice ≠ the accept) and on any extra leg / hidden
|
|
1789
|
+
* node, and we require the authoritative submitter (act_as) to be exactly the
|
|
1790
|
+
* agent. We deliberately do NOT pin a foreign-party allowlist here: an accept of
|
|
1791
|
+
* an incoming transfer legitimately references its counterparties (the sender
|
|
1792
|
+
* paying the agent, the DSO, etc.) — the root-choice + act_as + hash-binding gate
|
|
1793
|
+
* is what makes a drain unsignable, not a recipient allowlist.
|
|
1794
|
+
* ════════════════════════════════════════════════════════════════════════ */
|
|
1795
|
+
/** The accept choice on a Splice TransferInstruction (funds-in / claim path). */
|
|
1796
|
+
const ACCEPT_CHOICE = "TransferInstruction_Accept";
|
|
1797
|
+
/**
|
|
1798
|
+
* Assert the relay-returned `preparedTransaction` for the claim path encodes
|
|
1799
|
+
* EXACTLY a single `TransferInstruction_Accept` exercise submitted by the agent —
|
|
1800
|
+
* and is NOT an outbound transfer/create-transfer-command drain. Throws
|
|
1801
|
+
* `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
|
|
1802
|
+
* the bytes are not a decodable PreparedTransaction. Call BEFORE signing the
|
|
1803
|
+
* hash. Fail-closed: anything not positively proven to be an inbound accept by
|
|
1804
|
+
* the agent throws.
|
|
1805
|
+
*/
|
|
1806
|
+
export function assertPreparedAcceptMatches(preparedTransactionB64, expect) {
|
|
1807
|
+
const decoded = decodePrepared(preparedTransactionB64);
|
|
1808
|
+
// Shared node-traversal invariant: exactly one allowed exercise & NO other
|
|
1809
|
+
// exercise of any choice (so a relay-injected outbound CreateTransferCommand /
|
|
1810
|
+
// TransferFactory_Transfer drain is rejected — its root is not the accept);
|
|
1811
|
+
// EVERY node recognized; EXACTLY ONE root that IS the TransferInstruction_Accept
|
|
1812
|
+
// exercise; no orphan/extra-leg node.
|
|
1813
|
+
assertSingleAllowedRootExercise(decoded, ACCEPT_CHOICE);
|
|
1814
|
+
// SIGNED timing metadata sanity (relay-chosen validity window).
|
|
1815
|
+
assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
|
|
1816
|
+
// Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
|
|
1817
|
+
// only party is the agent — the accept must be submitted under the agent's own
|
|
1818
|
+
// authority, never a third party's.
|
|
1819
|
+
assertActAsIsSender(decoded.actAs, expect.selfParty);
|
|
1820
|
+
}
|
|
1821
|
+
/** Thrown when a supplied `recomputeHash` cannot faithfully encode the bytes. */
|
|
1822
|
+
export class PreparedHashUnavailableError extends Error {
|
|
1823
|
+
constructor(message) {
|
|
1824
|
+
super(message);
|
|
1825
|
+
this.name = "PreparedHashUnavailableError";
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
/** Constant-time base64 comparison (length-independent leak is acceptable). */
|
|
1829
|
+
function constantTimeEqualB64(a, b) {
|
|
1830
|
+
let ba;
|
|
1831
|
+
let bb;
|
|
1832
|
+
try {
|
|
1833
|
+
ba = Buffer.from(a, "base64");
|
|
1834
|
+
bb = Buffer.from(b, "base64");
|
|
1835
|
+
}
|
|
1836
|
+
catch {
|
|
1837
|
+
return false;
|
|
1838
|
+
}
|
|
1839
|
+
if (ba.length !== bb.length || ba.length === 0)
|
|
1840
|
+
return false;
|
|
1841
|
+
return timingSafeEqual(ba, bb);
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Bind the `hash` the relay told us to sign to the `preparedTransaction` bytes
|
|
1845
|
+
* we just structurally validated. Call this BEFORE signing `hash`.
|
|
1846
|
+
*
|
|
1847
|
+
* THE BINDING (why blind-signing breaks self-custody)
|
|
1848
|
+
* ---------------------------------------------------
|
|
1849
|
+
* The signed artifact is `hash`. The participant, on `execute`, recomputes the
|
|
1850
|
+
* V2 hash from whatever bytes reach it and accepts the signature only if it
|
|
1851
|
+
* matches. A compromised relay (the participant proxy) can therefore return
|
|
1852
|
+
* structurally-HONEST `preparedTransaction` bytes (which sail through
|
|
1853
|
+
* `assertPreparedTransferMatches`) paired with `hash = V2(PT_evil)` for a
|
|
1854
|
+
* DIFFERENT transfer, then forward `PT_evil` instead of the honest bytes to the
|
|
1855
|
+
* participant: V2(PT_evil) == hash, signature valid, attacker paid. Validating
|
|
1856
|
+
* the bytes is therefore necessary but NOT sufficient — the agent must also
|
|
1857
|
+
* prove the hash it signs is the hash OF THOSE validated bytes. That requires
|
|
1858
|
+
* recomputing the hash locally; the relay-supplied hash is untrusted input.
|
|
1859
|
+
*
|
|
1860
|
+
* This function enforces, fail-closed:
|
|
1861
|
+
* 1. shape: `hash` is a present, well-formed, non-empty base64 digest, and the
|
|
1862
|
+
* `preparedTransaction` is a decodable PreparedTransaction with exactly the
|
|
1863
|
+
* one transfer exercise (so we are signing a transfer we understood);
|
|
1864
|
+
* 2. binding: EITHER `opts.recomputeHash` is supplied and
|
|
1865
|
+
* `recomputeHash(prepared) === hash` (constant-time) — the real binding —
|
|
1866
|
+
* OR `opts.trustRelayHash === true` is explicitly set (the documented,
|
|
1867
|
+
* off-by-default escape hatch for human-in-the-loop / trusted-relay use).
|
|
1868
|
+
* If neither holds, we REFUSE to sign rather than blind-sign an unbound,
|
|
1869
|
+
* relay-chosen hash.
|
|
1870
|
+
*/
|
|
1871
|
+
export async function assertHashBinding(preparedTransactionB64, hashB64, opts = {}) {
|
|
1872
|
+
if (typeof hashB64 !== "string" || hashB64.length === 0) {
|
|
1873
|
+
throw new PreparedTransferMismatchError("relay returned an empty hash — refusing to sign (possible tampered/compromised relay)");
|
|
1874
|
+
}
|
|
1875
|
+
let hashBytes;
|
|
1876
|
+
try {
|
|
1877
|
+
hashBytes = Buffer.from(hashB64, "base64");
|
|
1878
|
+
}
|
|
1879
|
+
catch {
|
|
1880
|
+
throw new PreparedTransferMismatchError("relay hash is not valid base64 — refusing to sign");
|
|
1881
|
+
}
|
|
1882
|
+
if (hashBytes.length === 0) {
|
|
1883
|
+
throw new PreparedTransferMismatchError("relay hash decoded to empty bytes — refusing to sign");
|
|
1884
|
+
}
|
|
1885
|
+
// Decoding here ensures the bytes we are about to sign-and-submit are a real,
|
|
1886
|
+
// structurally-valid PreparedTransaction (throws otherwise).
|
|
1887
|
+
decodePrepared(preparedTransactionB64);
|
|
1888
|
+
// The actual hash<->bytes binding.
|
|
1889
|
+
if (opts.recomputeHash) {
|
|
1890
|
+
let local;
|
|
1891
|
+
try {
|
|
1892
|
+
// Await covers both a sync `=> string` and the async WebCrypto-backed
|
|
1893
|
+
// conformant recompute (`=> Promise<string>`); a rejected promise lands
|
|
1894
|
+
// in catch and fails CLOSED, never falling back to the relay hash.
|
|
1895
|
+
local = await opts.recomputeHash(preparedTransactionB64);
|
|
1896
|
+
}
|
|
1897
|
+
catch (e) {
|
|
1898
|
+
// The recompute could not faithfully encode the bytes — fail CLOSED. We
|
|
1899
|
+
// never fall back to trusting the relay hash on a recompute failure.
|
|
1900
|
+
throw new PreparedTransferMismatchError(`could not recompute the prepared-transaction hash to bind it to the ` +
|
|
1901
|
+
`validated bytes (${e.message}) — refusing to sign`);
|
|
1902
|
+
}
|
|
1903
|
+
if (!constantTimeEqualB64(local, hashB64)) {
|
|
1904
|
+
throw new PreparedTransferMismatchError("relay-returned hash does NOT match the hash of the prepared-transaction " +
|
|
1905
|
+
"bytes we validated — refusing to sign (possible tampered/compromised " +
|
|
1906
|
+
"relay supplying the hash of a different transaction)");
|
|
1907
|
+
}
|
|
1908
|
+
return; // bound: the hash we sign is the hash of the bytes we validated
|
|
1909
|
+
}
|
|
1910
|
+
if (opts.trustRelayHash === true) {
|
|
1911
|
+
return; // explicit, documented, off-by-default escape hatch
|
|
1912
|
+
}
|
|
1913
|
+
// No way to bind the hash to the validated bytes and no explicit opt-in to
|
|
1914
|
+
// trust the relay → refuse. Blind-signing a relay-chosen hash is exactly the
|
|
1915
|
+
// self-custody break this module exists to prevent.
|
|
1916
|
+
throw new PreparedTransferMismatchError("cannot bind the relay-returned hash to the validated prepared-transaction " +
|
|
1917
|
+
"bytes: no hash recomputation is available and trustRelayHash is not set. " +
|
|
1918
|
+
"Refusing to blind-sign a relay-supplied hash (Canton requires recomputing " +
|
|
1919
|
+
"the hash when the preparing participant is not trusted). Supply " +
|
|
1920
|
+
"HashBindingOptions.recomputeHash with a participant-conformant V2 hash, or " +
|
|
1921
|
+
"explicitly set trustRelayHash:true only with human-in-the-loop approval.");
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Returns true iff `hash` is the plain SHA-256 of the `preparedTransaction`
|
|
1925
|
+
* bytes. NOTE: Canton V2 does NOT hash this way (see `assertHashBinding`), so
|
|
1926
|
+
* this is advisory only and is NEVER used to accept or reject a submission.
|
|
1927
|
+
* Retained for diagnostics / future schemes.
|
|
1928
|
+
*/
|
|
1929
|
+
export function hashMatchesPreparedPlain(preparedTransactionB64, hashB64) {
|
|
1930
|
+
const bytes = Buffer.from(preparedTransactionB64, "base64");
|
|
1931
|
+
const digest = createHash("sha256").update(bytes).digest("base64");
|
|
1932
|
+
return digest === hashB64;
|
|
1933
|
+
}
|
|
1934
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
1935
|
+
* ONBOARDING — verify-before-sign for the external-party topology multiHash.
|
|
1936
|
+
*
|
|
1937
|
+
* THREAT MODEL (identical to the transfer paths). During onboarding the agent
|
|
1938
|
+
* generates its OWN Ed25519 key, asks the relay to `generateExternalParty-
|
|
1939
|
+
* Topology`, and signs the relay-returned `multiHash` (a combined hash over the
|
|
1940
|
+
* onboarding/topology transactions) with that key, then submits it to
|
|
1941
|
+
* `allocateExternalParty`. If it signs the multiHash BLINDLY, a malicious relay
|
|
1942
|
+
* can return topology transactions that onboard a DIFFERENT key/party: the agent
|
|
1943
|
+
* would sign and submit a topology it never authored, and persist a party it does
|
|
1944
|
+
* not control (key-custody compromise).
|
|
1945
|
+
*
|
|
1946
|
+
* `assertOnboardingTopologyBindsKey` proves, fail-closed, that the topology
|
|
1947
|
+
* TRANSACTION BYTES the agent is about to submit (the same bytes the participant
|
|
1948
|
+
* re-derives the multiHash from on `allocate`) bind ONLY the agent's own key:
|
|
1949
|
+
*
|
|
1950
|
+
* (A) the agent's OWN raw Ed25519 public key appears as a byte-substring in the
|
|
1951
|
+
* topology transactions (belt-and-suspenders; (D) is authoritative). The
|
|
1952
|
+
* raw 32 bytes are also a contiguous suffix of the SPKI/DER encoding, so the
|
|
1953
|
+
* check is robust to RAW or DER framing.
|
|
1954
|
+
*
|
|
1955
|
+
* (B) the returned `party` is `name::fingerprint` whose namespace fingerprint
|
|
1956
|
+
* equals the returned `publicKeyFingerprint`. An external party lives in the
|
|
1957
|
+
* namespace of its OWN signing key (the namespace IS the key fingerprint),
|
|
1958
|
+
* so the party the agent persists must be in the onboarded key's namespace.
|
|
1959
|
+
*
|
|
1960
|
+
* (C) `publicKeyFingerprint` is present and non-empty (the namespace anchor).
|
|
1961
|
+
*
|
|
1962
|
+
* (D) AUTHORITATIVE structural decode: each onboarding transaction is decoded
|
|
1963
|
+
* as a real Canton `TopologyTransaction` (official
|
|
1964
|
+
* `decodeTopologyTransaction`) and the custody-granting mappings are
|
|
1965
|
+
* asserted to bind EXACTLY the agent's key — PartyToKeyMapping threshold 1
|
|
1966
|
+
* with the agent's key as the SOLE signer (no co-holder), any
|
|
1967
|
+
* NamespaceDelegation targets only the agent's key over its own namespace,
|
|
1968
|
+
* any PartyToParticipant is single-custody. This replaces the old
|
|
1969
|
+
* substring-only check (which a relay could satisfy while ALSO binding an
|
|
1970
|
+
* extra foreign co-signer) and closes the round-5 / convergence custody-
|
|
1971
|
+
* hijack vectors. See `assertOnboardingTopologyDecodeBindsKey`.
|
|
1972
|
+
*
|
|
1973
|
+
* COMPLEMENTARY hash binding (in onboard.ts, NOT here): the multiHash IS now
|
|
1974
|
+
* recomputed locally (`recomputeTopologyMultiHash`, official
|
|
1975
|
+
* @canton-network/core-tx-visualizer) and compared to the relay's `hashToSign`,
|
|
1976
|
+
* and the fingerprint is derived locally and compared to `publicKeyFingerprint`,
|
|
1977
|
+
* before signing the RECOMPUTED multiHash. Structural decode proves "the bytes
|
|
1978
|
+
* bind only my key"; the recompute proves "I signed the hash OF those bytes" —
|
|
1979
|
+
* both required, exactly as on the transfer path. The recompute is conformance-
|
|
1980
|
+
* tested (`canton-hash.conformance.test.ts`); a wrong recompute fails honest
|
|
1981
|
+
* onboarding CLOSED rather than mis-binding.
|
|
1982
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
1983
|
+
/** Thrown when relay-returned onboarding topology cannot be proven to bind the
|
|
1984
|
+
* agent's own key + namespace. Onboarding refuses to sign the multiHash. */
|
|
1985
|
+
export class OnboardingTopologyMismatchError extends Error {
|
|
1986
|
+
constructor(message) {
|
|
1987
|
+
super(message);
|
|
1988
|
+
this.name = "OnboardingTopologyMismatchError";
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
/** The raw 32-byte Ed25519 public key from an SPKI/DER base64, via JWK `x`
|
|
1992
|
+
* (robust to DER prefix length); falls back to the trailing-32-bytes slice if
|
|
1993
|
+
* the key cannot be parsed as Ed25519. */
|
|
1994
|
+
function rawEd25519PublicKey(spkiB64) {
|
|
1995
|
+
const der = Buffer.from(spkiB64, "base64");
|
|
1996
|
+
try {
|
|
1997
|
+
const jwk = createPublicKey({ key: der, format: "der", type: "spki" }).export({
|
|
1998
|
+
format: "jwk",
|
|
1999
|
+
});
|
|
2000
|
+
if (jwk.x) {
|
|
2001
|
+
const raw = Buffer.from(jwk.x, "base64url");
|
|
2002
|
+
if (raw.length === 32)
|
|
2003
|
+
return raw;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
catch {
|
|
2007
|
+
/* fall through to the slice form */
|
|
2008
|
+
}
|
|
2009
|
+
return der.subarray(Math.max(0, der.length - 32));
|
|
2010
|
+
}
|
|
2011
|
+
/** The namespace (fingerprint) part of a `name::fingerprint` party id, or
|
|
2012
|
+
* undefined if the id is not in that form. Canton forbids consecutive colons
|
|
2013
|
+
* inside the name, so the LAST `::` separates name from namespace. */
|
|
2014
|
+
function partyNamespace(party) {
|
|
2015
|
+
const idx = party.lastIndexOf("::");
|
|
2016
|
+
if (idx <= 0)
|
|
2017
|
+
return undefined; // no separator, or empty name
|
|
2018
|
+
const ns = party.slice(idx + 2);
|
|
2019
|
+
return ns.length > 0 ? ns : undefined;
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Assert the relay-returned onboarding topology binds the agent's OWN key and
|
|
2023
|
+
* namespace. Call this BEFORE signing the onboarding `multiHash`. Fail-closed:
|
|
2024
|
+
* anything not positively proven throws `OnboardingTopologyMismatchError`.
|
|
2025
|
+
*/
|
|
2026
|
+
export function assertOnboardingTopologyBindsKey(onboardingTransactionsB64, expect) {
|
|
2027
|
+
// (C) namespace anchor must be present.
|
|
2028
|
+
if (typeof expect.publicKeyFingerprint !== "string" ||
|
|
2029
|
+
expect.publicKeyFingerprint.length === 0) {
|
|
2030
|
+
throw new OnboardingTopologyMismatchError("relay returned an empty publicKeyFingerprint — refusing to sign the onboarding " +
|
|
2031
|
+
"topology (cannot anchor the party namespace to the agent's key)");
|
|
2032
|
+
}
|
|
2033
|
+
// (B) the party the agent will persist must live in the namespace of the key
|
|
2034
|
+
// being onboarded: party = name::fingerprint, namespace == publicKeyFingerprint.
|
|
2035
|
+
const ns = partyNamespace(expect.party);
|
|
2036
|
+
if (ns === undefined) {
|
|
2037
|
+
throw new OnboardingTopologyMismatchError(`relay returned a malformed party id ${JSON.stringify(expect.party)} (expected ` +
|
|
2038
|
+
`"name::fingerprint") — refusing to sign the onboarding topology`);
|
|
2039
|
+
}
|
|
2040
|
+
if (ns !== expect.publicKeyFingerprint) {
|
|
2041
|
+
throw new OnboardingTopologyMismatchError(`relay-returned party namespace ${JSON.stringify(ns)} does not equal the key fingerprint ` +
|
|
2042
|
+
`${JSON.stringify(expect.publicKeyFingerprint)} — refusing to sign the onboarding topology ` +
|
|
2043
|
+
`(party would not live in the agent key's own namespace — possible foreign-party custody hijack)`);
|
|
2044
|
+
}
|
|
2045
|
+
// (A) the agent's OWN raw public key must appear in the topology bytes
|
|
2046
|
+
// (belt-and-suspenders byte check; (D) below is the authoritative decode).
|
|
2047
|
+
if (!onboardingTransactionsB64 || onboardingTransactionsB64.length === 0) {
|
|
2048
|
+
throw new OnboardingTopologyMismatchError("relay returned no onboarding topology transactions — refusing to sign " +
|
|
2049
|
+
"(cannot prove the topology onboards the agent's own key)");
|
|
2050
|
+
}
|
|
2051
|
+
const rawKey = rawEd25519PublicKey(expect.publicKeySpkiB64);
|
|
2052
|
+
if (rawKey.length !== 32) {
|
|
2053
|
+
throw new OnboardingTopologyMismatchError("could not derive the agent's raw Ed25519 public key — refusing to sign the onboarding topology");
|
|
2054
|
+
}
|
|
2055
|
+
let keyFound = false;
|
|
2056
|
+
for (const txB64 of onboardingTransactionsB64) {
|
|
2057
|
+
let txBytes;
|
|
2058
|
+
try {
|
|
2059
|
+
txBytes = Buffer.from(txB64, "base64");
|
|
2060
|
+
}
|
|
2061
|
+
catch {
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
if (txBytes.length > 0 && txBytes.includes(rawKey)) {
|
|
2065
|
+
keyFound = true;
|
|
2066
|
+
break;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
if (!keyFound) {
|
|
2070
|
+
throw new OnboardingTopologyMismatchError("the agent's own public key does not appear in the relay-returned onboarding topology " +
|
|
2071
|
+
"transactions — refusing to sign (the topology would onboard a DIFFERENT key; possible " +
|
|
2072
|
+
"tampered/compromised relay performing a key-custody hijack)");
|
|
2073
|
+
}
|
|
2074
|
+
// (D) AUTHORITATIVE structural decode. The byte-substring check (A) proves the
|
|
2075
|
+
// key is PRESENT somewhere; it does NOT prove the AUTHORITATIVE mappings bind
|
|
2076
|
+
// EXACTLY the agent's own key with no foreign co-holders. Decode each topology
|
|
2077
|
+
// transaction as a real Canton `TopologyTransaction` proto and assert,
|
|
2078
|
+
// fail-closed, that the custody-granting mappings bind only the agent's key.
|
|
2079
|
+
// This closes the round-5 / convergence custody-hijack vectors that a substring
|
|
2080
|
+
// scan misses (an extra signing key in the PartyToKeyMapping, threshold>1, a
|
|
2081
|
+
// foreign NamespaceDelegation target, a co-hosting foreign participant set with
|
|
2082
|
+
// a consortium threshold, etc.).
|
|
2083
|
+
assertOnboardingTopologyDecodeBindsKey(onboardingTransactionsB64, expect, rawKey);
|
|
2084
|
+
}
|
|
2085
|
+
/* ──────────────────────────────────────────────────────────────────────────
|
|
2086
|
+
* (D) Structural topology decode — the authoritative custody binding.
|
|
2087
|
+
*
|
|
2088
|
+
* We decode each relay-returned onboarding transaction with the OFFICIAL
|
|
2089
|
+
* `decodeTopologyTransaction` (@canton-network/core-tx-visualizer) and inspect
|
|
2090
|
+
* the typed `TopologyMapping` oneof. The onboarding bundle for an external party
|
|
2091
|
+
* carries (at least) a PartyToKeyMapping (the party's signing key(s)); it may
|
|
2092
|
+
* also carry a NamespaceDelegation (authorizing a key over the party's
|
|
2093
|
+
* namespace) and a PartyToParticipant (hosting). We require, fail-closed:
|
|
2094
|
+
*
|
|
2095
|
+
* PartyToKeyMapping (REQUIRED, ≥1 across the bundle, all for the agent's party):
|
|
2096
|
+
* - threshold === 1
|
|
2097
|
+
* - signingKeys is EXACTLY [the agent's own key] — one key, equal to the
|
|
2098
|
+
* agent's, and NO additional key-holders (an extra co-signer would let the
|
|
2099
|
+
* relay co-authorize spends).
|
|
2100
|
+
*
|
|
2101
|
+
* NamespaceDelegation (OPTIONAL; if present, every one must):
|
|
2102
|
+
* - namespace === the agent's key fingerprint (the party's own namespace)
|
|
2103
|
+
* - targetKey === the agent's own key (no foreign delegate gets namespace
|
|
2104
|
+
* authority).
|
|
2105
|
+
*
|
|
2106
|
+
* PartyToParticipant (OPTIONAL; if present, every one must):
|
|
2107
|
+
* - party === the agent's party
|
|
2108
|
+
* - threshold === 1 (NOT a consortium party — threshold>1 means multiple
|
|
2109
|
+
* participants must co-act, a custody-sharing definition)
|
|
2110
|
+
* - participants is non-empty (hosting is sane).
|
|
2111
|
+
*
|
|
2112
|
+
* Any mapping that references a key/namespace/party that is NOT the agent's, or
|
|
2113
|
+
* any threshold>1, or any extra key-holder, is rejected. Mapping types we do not
|
|
2114
|
+
* expect in an external-party onboarding bundle (owner-to-key, decentralized
|
|
2115
|
+
* namespace, synchronizer state, …) are also rejected fail-closed — an honest
|
|
2116
|
+
* relay does not include them, and we refuse to sign a bundle we cannot fully
|
|
2117
|
+
* account for.
|
|
2118
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
2119
|
+
/** Extract the raw key bytes carried by a decoded `SigningPublicKey`, normalized
|
|
2120
|
+
* to the bare 32-byte Ed25519 point. Canton may carry the key RAW (32 bytes) or
|
|
2121
|
+
* DER-wrapped (SPKI); the raw 32-byte point is a contiguous suffix of the SPKI
|
|
2122
|
+
* encoding, so taking the trailing 32 bytes normalizes both. Returns undefined
|
|
2123
|
+
* if there are fewer than 32 bytes (malformed). */
|
|
2124
|
+
function rawPointFromSigningKey(pk) {
|
|
2125
|
+
if (!pk || !pk.publicKey || pk.publicKey.length < 32)
|
|
2126
|
+
return undefined;
|
|
2127
|
+
const buf = Buffer.from(pk.publicKey);
|
|
2128
|
+
// If it already IS 32 bytes, this is the point; otherwise (DER) the point is
|
|
2129
|
+
// the trailing 32 bytes (the SPKI BIT STRING payload sits at the end).
|
|
2130
|
+
return buf.subarray(buf.length - 32);
|
|
2131
|
+
}
|
|
2132
|
+
function assertOnboardingTopologyDecodeBindsKey(onboardingTransactionsB64, expect, agentRawKey) {
|
|
2133
|
+
/** True iff a decoded signing key is EXACTLY the agent's own Ed25519 point. */
|
|
2134
|
+
const isAgentKey = (pk) => {
|
|
2135
|
+
const point = rawPointFromSigningKey(pk);
|
|
2136
|
+
return point !== undefined && point.length === 32 && timingSafeEqual(point, agentRawKey);
|
|
2137
|
+
};
|
|
2138
|
+
let sawPartyToKey = false;
|
|
2139
|
+
for (const txB64 of onboardingTransactionsB64) {
|
|
2140
|
+
let tx;
|
|
2141
|
+
try {
|
|
2142
|
+
tx = decodeTopologyTransaction(txB64);
|
|
2143
|
+
}
|
|
2144
|
+
catch (e) {
|
|
2145
|
+
throw new OnboardingTopologyMismatchError(`could not decode a relay-returned onboarding transaction as a Canton ` +
|
|
2146
|
+
`TopologyTransaction (${e.message}) — refusing to sign ` +
|
|
2147
|
+
`(cannot structurally verify the topology binds the agent's own key)`);
|
|
2148
|
+
}
|
|
2149
|
+
const mapping = tx.mapping?.mapping;
|
|
2150
|
+
if (!mapping || mapping.oneofKind === undefined) {
|
|
2151
|
+
throw new OnboardingTopologyMismatchError("a relay-returned onboarding transaction has no topology mapping — refusing " +
|
|
2152
|
+
"to sign (cannot account for an empty/unknown mapping in the bundle)");
|
|
2153
|
+
}
|
|
2154
|
+
switch (mapping.oneofKind) {
|
|
2155
|
+
case "partyToKeyMapping": {
|
|
2156
|
+
const m = mapping.partyToKeyMapping;
|
|
2157
|
+
sawPartyToKey = true;
|
|
2158
|
+
// The mapping must be for the agent's OWN party.
|
|
2159
|
+
if (m.party !== expect.party) {
|
|
2160
|
+
throw new OnboardingTopologyMismatchError(`onboarding PartyToKeyMapping binds party ${JSON.stringify(m.party)} but the ` +
|
|
2161
|
+
`agent's party is ${JSON.stringify(expect.party)} — refusing to sign ` +
|
|
2162
|
+
`(topology would key a DIFFERENT party — possible custody hijack)`);
|
|
2163
|
+
}
|
|
2164
|
+
// EXACTLY threshold 1.
|
|
2165
|
+
if (m.threshold !== 1) {
|
|
2166
|
+
throw new OnboardingTopologyMismatchError(`onboarding PartyToKeyMapping has threshold ${m.threshold} (expected 1) — ` +
|
|
2167
|
+
`refusing to sign (a threshold≠1 key mapping is a custody-sharing definition)`);
|
|
2168
|
+
}
|
|
2169
|
+
// EXACTLY one signing key, and it is the agent's own — NO co-holders.
|
|
2170
|
+
if (m.signingKeys.length !== 1) {
|
|
2171
|
+
throw new OnboardingTopologyMismatchError(`onboarding PartyToKeyMapping carries ${m.signingKeys.length} signing keys ` +
|
|
2172
|
+
`(expected exactly 1 — the agent's own) — refusing to sign (an extra ` +
|
|
2173
|
+
`key-holder would let the relay co-authorize the agent's spends)`);
|
|
2174
|
+
}
|
|
2175
|
+
if (!isAgentKey(m.signingKeys[0])) {
|
|
2176
|
+
throw new OnboardingTopologyMismatchError("onboarding PartyToKeyMapping's signing key is NOT the agent's own key — " +
|
|
2177
|
+
"refusing to sign (the topology would onboard a foreign key; possible " +
|
|
2178
|
+
"tampered/compromised relay performing a key-custody hijack)");
|
|
2179
|
+
}
|
|
2180
|
+
break;
|
|
2181
|
+
}
|
|
2182
|
+
case "namespaceDelegation": {
|
|
2183
|
+
const m = mapping.namespaceDelegation;
|
|
2184
|
+
// If present, it must authorize ONLY the agent's own key over the agent's
|
|
2185
|
+
// own namespace (the key fingerprint). A foreign target/namespace would
|
|
2186
|
+
// grant namespace authority outside the agent's control.
|
|
2187
|
+
if (m.namespace !== expect.publicKeyFingerprint) {
|
|
2188
|
+
throw new OnboardingTopologyMismatchError(`onboarding NamespaceDelegation is for namespace ${JSON.stringify(m.namespace)} ` +
|
|
2189
|
+
`but the agent's namespace is ${JSON.stringify(expect.publicKeyFingerprint)} — ` +
|
|
2190
|
+
`refusing to sign (foreign-namespace delegation — possible custody hijack)`);
|
|
2191
|
+
}
|
|
2192
|
+
if (!isAgentKey(m.targetKey)) {
|
|
2193
|
+
throw new OnboardingTopologyMismatchError("onboarding NamespaceDelegation's target key is NOT the agent's own key — " +
|
|
2194
|
+
"refusing to sign (a foreign key would gain authority over the agent's " +
|
|
2195
|
+
"namespace — possible custody hijack)");
|
|
2196
|
+
}
|
|
2197
|
+
break;
|
|
2198
|
+
}
|
|
2199
|
+
case "partyToParticipant": {
|
|
2200
|
+
const m = mapping.partyToParticipant;
|
|
2201
|
+
if (m.party !== expect.party) {
|
|
2202
|
+
throw new OnboardingTopologyMismatchError(`onboarding PartyToParticipant hosts party ${JSON.stringify(m.party)} but the ` +
|
|
2203
|
+
`agent's party is ${JSON.stringify(expect.party)} — refusing to sign`);
|
|
2204
|
+
}
|
|
2205
|
+
// threshold>1 ⇒ consortium party (multiple participants must co-act) — a
|
|
2206
|
+
// custody-sharing definition the agent must never sign for its own party.
|
|
2207
|
+
if (m.threshold > 1) {
|
|
2208
|
+
throw new OnboardingTopologyMismatchError(`onboarding PartyToParticipant has threshold ${m.threshold} (>1 = consortium ` +
|
|
2209
|
+
`party) — refusing to sign (the agent's party must be single-custody)`);
|
|
2210
|
+
}
|
|
2211
|
+
if (!m.participants || m.participants.length === 0) {
|
|
2212
|
+
throw new OnboardingTopologyMismatchError("onboarding PartyToParticipant lists no hosting participants — refusing to " +
|
|
2213
|
+
"sign (the party would be unhosted/sane-check failed)");
|
|
2214
|
+
}
|
|
2215
|
+
break;
|
|
2216
|
+
}
|
|
2217
|
+
default: {
|
|
2218
|
+
// Any other mapping type is unexpected in an external-party onboarding
|
|
2219
|
+
// bundle. Fail closed rather than sign a bundle we cannot account for.
|
|
2220
|
+
throw new OnboardingTopologyMismatchError(`relay-returned onboarding bundle contains an unexpected topology mapping ` +
|
|
2221
|
+
`(${mapping.oneofKind}) — refusing to sign (an honest external-party ` +
|
|
2222
|
+
`onboarding carries only PartyToKeyMapping / NamespaceDelegation / ` +
|
|
2223
|
+
`PartyToParticipant; an extra mapping could grant foreign authority)`);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
// The custody-granting mapping (PartyToKeyMapping) is REQUIRED: without it the
|
|
2228
|
+
// bundle does not actually bind the agent's key to its party.
|
|
2229
|
+
if (!sawPartyToKey) {
|
|
2230
|
+
throw new OnboardingTopologyMismatchError("relay-returned onboarding bundle has NO PartyToKeyMapping — refusing to sign " +
|
|
2231
|
+
"(the topology does not bind the agent's signing key to its party; cannot " +
|
|
2232
|
+
"establish self-custody)");
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
//# sourceMappingURL=verify-prepared.js.map
|