@calimero-network/mero-js 18.3.0 → 19.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.
@@ -7,11 +7,17 @@
7
7
  * so "a member with no node" in practice meant "a member with a node it is not
8
8
  * allowed to use for anything else".
9
9
  *
10
- * **No dependencies, and that is not incidental.** The three primitives are all
11
- * built in: `crypto.subtle.digest('SHA-256')` for the hash, `crypto.subtle`
12
- * Ed25519 for the signature, and `DataView.setBigUint64` for the two u64s. The
13
- * warrant's every field is fixed-width, so its encoding is concatenation — there
14
- * is no borsh here because none is needed.
10
+ * **No dependencies, and that is not incidental.** The primitives are all built
11
+ * in: `crypto.subtle.digest('SHA-256')` for the hash, `crypto.subtle` Ed25519
12
+ * for the signature, and `DataView` for the integers.
13
+ *
14
+ * Warrant v2 (core#3933) gave up the fixed-width encoding that used to make this
15
+ * pure concatenation: `method` travels in the clear as a string and each cited
16
+ * head list is a vector, so three borsh `u32` counts now sit inside the bytes.
17
+ * Two widths to keep straight, and they are not the same: the counts **inside
18
+ * the encoding** are `u32`, while the counts the **signing preimage** hashes are
19
+ * `u64`. Same numbers, different serialization — reusing one for the other
20
+ * produces a warrant that is well-formed and verifies nowhere.
15
21
  *
16
22
  * **The byte contract is pinned in core**, at
17
23
  * `crates/account/src/tests/warrant_wire_fixture.rs`. The domain strings below
@@ -37,7 +43,29 @@ export interface WarrantInput {
37
43
  authorAccount: string;
38
44
  /** The relay authorised to act, hex. An account, not a key. */
39
45
  executor: string;
40
- /** The method the relay may run. */
46
+ /**
47
+ * The application build this warrant is signed against, hex (32 bytes).
48
+ *
49
+ * Pins the code rather than a version string, so a relay cannot wait for an
50
+ * upgrade that widens what `method` does and then spend a warrant signed
51
+ * against the narrower one. Read it from the context
52
+ * (`GET /admin-api/contexts/{id}` → `applicationId`); a value guessed here
53
+ * pins the wrong build, which is worse than not pinning.
54
+ *
55
+ * Defaults to 32 zero bytes, matching `merod account sign-warrant`'s
56
+ * `--app-version` default. Nothing verifies the field yet — core#3933 landed
57
+ * the field set ahead of its enforcement so a signer is written once against
58
+ * the final bytes — but a warrant minted with the default will be refused once
59
+ * pinning lands, so pass the real value for anything meant to outlive it.
60
+ */
61
+ appVersion?: string;
62
+ /**
63
+ * The method the relay may run.
64
+ *
65
+ * Carried in the clear as of v2, so a peer can select a per-method write-set
66
+ * without reversing a hash against the app's ABI. The *arguments* are still
67
+ * committed to rather than carried — see {@link intentHash}.
68
+ */
41
69
  method: string;
42
70
  /** Its arguments, as the JSON the guest will receive. */
43
71
  argsJson: unknown;
@@ -49,6 +77,25 @@ export interface WarrantInput {
49
77
  * account-scoped sequence would have them refusing each other's warrants.
50
78
  */
51
79
  nonce: number | bigint;
80
+ /**
81
+ * Account-log heads this author saw when signing, each hex (32 bytes).
82
+ *
83
+ * Empty by default, and empty is the honest answer for a client that tracks
84
+ * no log — a fabricated view is worse than none. At most
85
+ * {@link MAX_CITED_HEADS}.
86
+ */
87
+ accountHeads?: string[];
88
+ /**
89
+ * Governance heads this author's view descended from, each hex (32 bytes).
90
+ *
91
+ * **Where these come from decides whether they mean anything.** A floor the
92
+ * relay supplied is a floor the relay chose: a relay that withholds a
93
+ * governance op can hand you a stale view and collect a warrant citing it.
94
+ * Take the value from the account's own devices or a co-signing peer outside
95
+ * the relay's operator. As it stands this detects an honest relay's staleness
96
+ * and does not constrain a dishonest one.
97
+ */
98
+ governanceFloor?: string[];
52
99
  /** Unix seconds after which the relay must refuse it. */
53
100
  notAfter: number | bigint;
54
101
  /**
@@ -69,9 +116,41 @@ export declare function intentHash(method: string, argsJson: unknown): Promise<U
69
116
  /**
70
117
  * Sign a warrant and return it hex-encoded, ready for `performIntent`.
71
118
  *
72
- * The 240 bytes are the canonical form: the signature covers exactly them, which
73
- * is why this returns the encoding rather than an object. A caller that rebuilt
74
- * the fields from JSON would have a second spelling able to disagree with what
75
- * was signed.
119
+ * Returns the encoding rather than an object because the signature covers
120
+ * exactly these bytes: a caller that rebuilt the fields from JSON would have a
121
+ * second spelling able to disagree with what was signed.
76
122
  */
77
123
  export declare function signWarrant(input: WarrantInput): Promise<string>;
124
+ /** A warrant's fields, as hex, decoded from the wire encoding. */
125
+ export interface WarrantFields {
126
+ context: string;
127
+ authorAccount: string;
128
+ /** The author device key `signWarrant` derived from the secret. */
129
+ deviceKey: string;
130
+ executor: string;
131
+ appVersion: string;
132
+ method: string;
133
+ intentHash: string;
134
+ accountHeads: string[];
135
+ governanceFloor: string[];
136
+ /** u64 little-endian, as it sits on the wire. */
137
+ nonce: string;
138
+ /** u64 little-endian, as it sits on the wire. */
139
+ notAfter: string;
140
+ signature: string;
141
+ }
142
+ /**
143
+ * Decode a hex warrant back into its fields.
144
+ *
145
+ * Exists because v2's offsets are no longer constants: `method` is a string and
146
+ * the head lists are vectors, so everything after `appVersion` moves with the
147
+ * method's length. Anything reading a warrant by fixed offset — a test, a client
148
+ * inspecting one it was handed — silently reads the wrong field instead of
149
+ * failing, which is how the three call sites that did so passed while comparing
150
+ * the method's length prefix to a nonce.
151
+ *
152
+ * One copy of the layout, beside the encoder that produces it, so the two cannot
153
+ * drift. Internal: not exported from the package root, because a client that
154
+ * needs a warrant's fields has the input it signed.
155
+ */
156
+ export declare function parseWarrant(warrant: string): WarrantFields;
@@ -7,11 +7,17 @@
7
7
  * so "a member with no node" in practice meant "a member with a node it is not
8
8
  * allowed to use for anything else".
9
9
  *
10
- * **No dependencies, and that is not incidental.** The three primitives are all
11
- * built in: `crypto.subtle.digest('SHA-256')` for the hash, `crypto.subtle`
12
- * Ed25519 for the signature, and `DataView.setBigUint64` for the two u64s. The
13
- * warrant's every field is fixed-width, so its encoding is concatenation — there
14
- * is no borsh here because none is needed.
10
+ * **No dependencies, and that is not incidental.** The primitives are all built
11
+ * in: `crypto.subtle.digest('SHA-256')` for the hash, `crypto.subtle` Ed25519
12
+ * for the signature, and `DataView` for the integers.
13
+ *
14
+ * Warrant v2 (core#3933) gave up the fixed-width encoding that used to make this
15
+ * pure concatenation: `method` travels in the clear as a string and each cited
16
+ * head list is a vector, so three borsh `u32` counts now sit inside the bytes.
17
+ * Two widths to keep straight, and they are not the same: the counts **inside
18
+ * the encoding** are `u32`, while the counts the **signing preimage** hashes are
19
+ * `u64`. Same numbers, different serialization — reusing one for the other
20
+ * produces a warrant that is well-formed and verifies nowhere.
15
21
  *
16
22
  * **The byte contract is pinned in core**, at
17
23
  * `crates/account/src/tests/warrant_wire_fixture.rs`. The domain strings below
@@ -21,9 +27,16 @@
21
27
  * this producing well-formed warrants whose signatures verify nowhere, surfacing
22
28
  * at a relay as a 403: an authorization refusal, nowhere near the cause.
23
29
  */
24
- import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u64le, } from '../crypto/internal.js';
25
- const SIGN_DOMAIN = new TextEncoder().encode('calimero.warrant.v1');
30
+ import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
31
+ const SIGN_DOMAIN = new TextEncoder().encode('calimero.warrant.v2');
26
32
  const INTENT_DOMAIN = new TextEncoder().encode('calimero.warrant.intent.v1');
33
+ /**
34
+ * core's `MAX_WARRANT_CITED_HEADS`. A node refuses more than this before doing
35
+ * any Ed25519 work, so minting past it produces a warrant nothing will spend.
36
+ */
37
+ const MAX_CITED_HEADS = 64;
38
+ /** 32 zero bytes — the `appVersion` default. See {@link WarrantInput.appVersion}. */
39
+ const UNPINNED_APP = '00'.repeat(32);
27
40
  /**
28
41
  * The commitment a warrant carries in place of the intent itself.
29
42
  *
@@ -37,29 +50,113 @@ export async function intentHash(method, argsJson) {
37
50
  /**
38
51
  * Sign a warrant and return it hex-encoded, ready for `performIntent`.
39
52
  *
40
- * The 240 bytes are the canonical form: the signature covers exactly them, which
41
- * is why this returns the encoding rather than an object. A caller that rebuilt
42
- * the fields from JSON would have a second spelling able to disagree with what
43
- * was signed.
53
+ * Returns the encoding rather than an object because the signature covers
54
+ * exactly these bytes: a caller that rebuilt the fields from JSON would have a
55
+ * second spelling able to disagree with what was signed.
44
56
  */
45
57
  export async function signWarrant(input) {
46
58
  const context = fromHex(input.context, 'context', 32);
47
59
  const authorAccount = fromHex(input.authorAccount, 'authorAccount', 32);
48
60
  const executor = fromHex(input.executor, 'executor', 32);
61
+ const appVersion = fromHex(input.appVersion ?? UNPINNED_APP, 'appVersion', 32);
62
+ const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
63
+ const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
49
64
  const key = await importSigningKey(input.deviceSecret);
50
65
  const publicKey = await derivePublicKey(input.deviceSecret);
66
+ const method = new TextEncoder().encode(input.method);
51
67
  const commitment = await intentHash(input.method, input.argsJson);
52
68
  const nonce = u64le(input.nonce);
53
69
  const notAfter = u64le(input.notAfter);
70
+ // Each head list is preceded by its own count. `domainHash` length-prefixes
71
+ // every part it is given, so the heads are individually unambiguous — but the
72
+ // two lists are adjacent, so without the counts `accountHeads = [a, b],
73
+ // governanceFloor = []` hashes identically to `[a], [b]`, and a relay could
74
+ // relabel which plane a head was cited from. u64 HERE; u32 in the encoding.
54
75
  const preimage = await domainHash(SIGN_DOMAIN, [
55
76
  context,
56
77
  authorAccount,
57
78
  publicKey,
58
79
  executor,
80
+ appVersion,
81
+ method,
59
82
  commitment,
83
+ u64le(accountHeads.length),
84
+ ...accountHeads,
85
+ u64le(governanceFloor.length),
86
+ ...governanceFloor,
60
87
  nonce,
61
88
  notAfter,
62
89
  ]);
63
90
  const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, preimage));
64
- return hex(concat(context, authorAccount, publicKey, executor, commitment, nonce, notAfter, signature));
91
+ // Borsh: a `String` is a u32 length then its UTF-8 bytes; a `Vec<[u8; 32]>` is
92
+ // a u32 count then the elements. u32 HERE; u64 in the preimage above.
93
+ return hex(concat(context, authorAccount, publicKey, executor, appVersion, u32le(method.length), method, commitment, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, nonce, notAfter, signature));
94
+ }
95
+ /** Decode a cited-head list, refusing one longer than a node will accept. */
96
+ function citedHeads(heads, label) {
97
+ const list = heads ?? [];
98
+ if (list.length > MAX_CITED_HEADS) {
99
+ throw new Error(`${label} cites ${list.length} heads, over the ${MAX_CITED_HEADS} a node accepts`);
100
+ }
101
+ return list.map((head, i) => fromHex(head, `${label}[${i}]`, 32));
102
+ }
103
+ /**
104
+ * Decode a hex warrant back into its fields.
105
+ *
106
+ * Exists because v2's offsets are no longer constants: `method` is a string and
107
+ * the head lists are vectors, so everything after `appVersion` moves with the
108
+ * method's length. Anything reading a warrant by fixed offset — a test, a client
109
+ * inspecting one it was handed — silently reads the wrong field instead of
110
+ * failing, which is how the three call sites that did so passed while comparing
111
+ * the method's length prefix to a nonce.
112
+ *
113
+ * One copy of the layout, beside the encoder that produces it, so the two cannot
114
+ * drift. Internal: not exported from the package root, because a client that
115
+ * needs a warrant's fields has the input it signed.
116
+ */
117
+ export function parseWarrant(warrant) {
118
+ const at = (start, bytes) => warrant.slice(start * 2, (start + bytes) * 2);
119
+ // A borsh u32 is little-endian, so the hex pairs read back to front.
120
+ const u32At = (start) => Number.parseInt((at(start, 4).match(/../g) ?? []).reverse().join(''), 16);
121
+ let o = 0;
122
+ const take = (bytes) => {
123
+ const field = at(o, bytes);
124
+ o += bytes;
125
+ return field;
126
+ };
127
+ const context = take(32);
128
+ const authorAccount = take(32);
129
+ const deviceKey = take(32);
130
+ const executor = take(32);
131
+ const appVersion = take(32);
132
+ const methodLen = u32At(o);
133
+ o += 4;
134
+ const method = take(methodLen);
135
+ const intentHashField = take(32);
136
+ const accountCount = u32At(o);
137
+ o += 4;
138
+ const accountHeads = Array.from({ length: accountCount }, () => take(32));
139
+ const governanceCount = u32At(o);
140
+ o += 4;
141
+ const governanceFloor = Array.from({ length: governanceCount }, () => take(32));
142
+ const nonce = take(8);
143
+ const notAfter = take(8);
144
+ const signature = take(64);
145
+ if (o * 2 !== warrant.length) {
146
+ throw new Error(`warrant is ${warrant.length / 2} bytes but its fields account for ${o}`);
147
+ }
148
+ return {
149
+ context,
150
+ authorAccount,
151
+ deviceKey,
152
+ executor,
153
+ appVersion,
154
+ method,
155
+ intentHash: intentHashField,
156
+ accountHeads,
157
+ governanceFloor,
158
+ nonce,
159
+ notAfter,
160
+ signature,
161
+ };
65
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calimero-network/mero-js",
3
- "version": "18.3.0",
3
+ "version": "19.1.0",
4
4
  "description": "Pure JavaScript SDK for Calimero",
5
5
  "type": "module",
6
6
  "exports": {