@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.
- package/dist/admin-api/admin-client.d.ts +23 -3
- package/dist/admin-api/admin-client.js +33 -4
- package/dist/admin-api/admin-types.d.ts +19 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +3 -3
- package/dist/index.cjs +66 -6
- package/dist/index.cjs.map +2 -2
- package/dist/index.mjs +66 -6
- package/dist/index.mjs.map +2 -2
- package/dist/warrant/warrant.d.ts +89 -10
- package/dist/warrant/warrant.js +109 -12
- package/package.json +1 -1
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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;
|
package/dist/warrant/warrant.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
|
|
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
|
}
|