@calimero-network/mero-js 13.3.0 → 14.0.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/account/index.d.ts +15 -15
- package/dist/account/index.js +15 -15
- package/dist/admin-api/admin-client.d.ts +12 -6
- package/dist/admin-api/admin-client.js +12 -6
- package/dist/admin-api/admin-types.d.ts +17 -11
- package/dist/events/group.d.ts +1 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +151 -9
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/index.mjs +151 -9
- package/dist/index.mjs.map +4 -4
- package/dist/warrant/index.d.ts +2 -0
- package/dist/warrant/index.js +2 -0
- package/dist/warrant/warrant.d.ts +76 -0
- package/dist/warrant/warrant.js +143 -0
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a warrant: the author's consent for one relay to run one intent, once.
|
|
3
|
+
*
|
|
4
|
+
* This exists because the case delegated authorship is *for* is an account that
|
|
5
|
+
* holds no node — a browser tab, a Node service, a bot. Every one of those is a
|
|
6
|
+
* JS runtime, and until now the only thing that could mint a warrant was `merod`,
|
|
7
|
+
* so "a member with no node" in practice meant "a member with a node it is not
|
|
8
|
+
* allowed to use for anything else".
|
|
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.
|
|
15
|
+
*
|
|
16
|
+
* **The byte contract is pinned in core**, at
|
|
17
|
+
* `crates/account/src/tests/warrant_wire_fixture.rs`. The domain strings below
|
|
18
|
+
* are `pub(crate)` there, so nothing in that repository forces anyone to notice
|
|
19
|
+
* this file depends on them — which is exactly why the fixture lives on that side
|
|
20
|
+
* and this module's test asserts the same vectors. A drift would otherwise leave
|
|
21
|
+
* this producing well-formed warrants whose signatures verify nowhere, surfacing
|
|
22
|
+
* at a relay as a 403: an authorization refusal, nowhere near the cause.
|
|
23
|
+
*/
|
|
24
|
+
/** What a warrant authorises. */
|
|
25
|
+
export interface WarrantInput {
|
|
26
|
+
/**
|
|
27
|
+
* The context, hex — the same string every other method here takes.
|
|
28
|
+
*
|
|
29
|
+
* Hex like everything else on this interface. It was base58 while the node
|
|
30
|
+
* spelled `ContextId` and `PublicKey` that way and `AccountId` and `DeviceId`
|
|
31
|
+
* in hex; that split is gone, so this module no longer needs a rule per field
|
|
32
|
+
* and no longer needs a base58 decoder to enforce one.
|
|
33
|
+
*/
|
|
34
|
+
context: string;
|
|
35
|
+
/** The author's account, hex — whose consent this is. */
|
|
36
|
+
authorAccount: string;
|
|
37
|
+
/** The relay authorised to act, hex. An account, not a key. */
|
|
38
|
+
executor: string;
|
|
39
|
+
/** The method the relay may run. */
|
|
40
|
+
method: string;
|
|
41
|
+
/** Its arguments, as the JSON the guest will receive. */
|
|
42
|
+
argsJson: unknown;
|
|
43
|
+
/**
|
|
44
|
+
* Monotonic per author **device**.
|
|
45
|
+
*
|
|
46
|
+
* Per device rather than per account because two devices of one account are
|
|
47
|
+
* independent replicas: they cannot coordinate on a counter, so an
|
|
48
|
+
* account-scoped sequence would have them refusing each other's warrants.
|
|
49
|
+
*/
|
|
50
|
+
nonce: number | bigint;
|
|
51
|
+
/** Unix seconds after which the relay must refuse it. */
|
|
52
|
+
notAfter: number | bigint;
|
|
53
|
+
/**
|
|
54
|
+
* The author device's ed25519 signing secret, hex (32 bytes).
|
|
55
|
+
*
|
|
56
|
+
* Never sent anywhere. It signs locally and only the signature travels, which
|
|
57
|
+
* is the whole reason a warrant can be minted by something holding no node.
|
|
58
|
+
*/
|
|
59
|
+
deviceSecret: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The commitment a warrant carries in place of the intent itself.
|
|
63
|
+
*
|
|
64
|
+
* Its own domain, distinct from the signing one, so a value computed for one
|
|
65
|
+
* purpose cannot be presented for the other.
|
|
66
|
+
*/
|
|
67
|
+
export declare function intentHash(method: string, argsJson: unknown): Promise<Uint8Array>;
|
|
68
|
+
/**
|
|
69
|
+
* Sign a warrant and return it hex-encoded, ready for `performIntent`.
|
|
70
|
+
*
|
|
71
|
+
* The 240 bytes are the canonical form: the signature covers exactly them, which
|
|
72
|
+
* is why this returns the encoding rather than an object. A caller that rebuilt
|
|
73
|
+
* the fields from JSON would have a second spelling able to disagree with what
|
|
74
|
+
* was signed.
|
|
75
|
+
*/
|
|
76
|
+
export declare function signWarrant(input: WarrantInput): Promise<string>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a warrant: the author's consent for one relay to run one intent, once.
|
|
3
|
+
*
|
|
4
|
+
* This exists because the case delegated authorship is *for* is an account that
|
|
5
|
+
* holds no node — a browser tab, a Node service, a bot. Every one of those is a
|
|
6
|
+
* JS runtime, and until now the only thing that could mint a warrant was `merod`,
|
|
7
|
+
* so "a member with no node" in practice meant "a member with a node it is not
|
|
8
|
+
* allowed to use for anything else".
|
|
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.
|
|
15
|
+
*
|
|
16
|
+
* **The byte contract is pinned in core**, at
|
|
17
|
+
* `crates/account/src/tests/warrant_wire_fixture.rs`. The domain strings below
|
|
18
|
+
* are `pub(crate)` there, so nothing in that repository forces anyone to notice
|
|
19
|
+
* this file depends on them — which is exactly why the fixture lives on that side
|
|
20
|
+
* and this module's test asserts the same vectors. A drift would otherwise leave
|
|
21
|
+
* this producing well-formed warrants whose signatures verify nowhere, surfacing
|
|
22
|
+
* at a relay as a 403: an authorization refusal, nowhere near the cause.
|
|
23
|
+
*/
|
|
24
|
+
const SIGN_DOMAIN = new TextEncoder().encode('calimero.warrant.v1');
|
|
25
|
+
const INTENT_DOMAIN = new TextEncoder().encode('calimero.warrant.intent.v1');
|
|
26
|
+
/** Ed25519 PKCS#8 prefix, so a raw 32-byte seed can be imported by WebCrypto. */
|
|
27
|
+
const PKCS8_ED25519_PREFIX = new Uint8Array([
|
|
28
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04,
|
|
29
|
+
0x22, 0x04, 0x20,
|
|
30
|
+
]);
|
|
31
|
+
function hex(bytes) {
|
|
32
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
33
|
+
}
|
|
34
|
+
function fromHex(value, label, expectedBytes) {
|
|
35
|
+
const clean = value.trim();
|
|
36
|
+
if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length !== expectedBytes * 2) {
|
|
37
|
+
throw new Error(`${label} must be ${expectedBytes * 2} hex characters, got ${clean.length}`);
|
|
38
|
+
}
|
|
39
|
+
// Indexed rather than `match(/../g)!`: the length is already validated above,
|
|
40
|
+
// so the assertion would only be telling the compiler what the guard proved,
|
|
41
|
+
// and this needs no assertion at all.
|
|
42
|
+
const bytes = new Uint8Array(expectedBytes);
|
|
43
|
+
for (let i = 0; i < expectedBytes; i += 1) {
|
|
44
|
+
bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
45
|
+
}
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
48
|
+
function u64le(value) {
|
|
49
|
+
const out = new Uint8Array(8);
|
|
50
|
+
new DataView(out.buffer).setBigUint64(0, BigInt(value), true);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function concat(...parts) {
|
|
54
|
+
const out = new Uint8Array(parts.reduce((total, p) => total + p.length, 0));
|
|
55
|
+
let offset = 0;
|
|
56
|
+
for (const part of parts) {
|
|
57
|
+
out.set(part, offset);
|
|
58
|
+
offset += part.length;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* core's `domain_hash`: SHA-256 over the length-prefixed domain, then each
|
|
64
|
+
* length-prefixed part. The lengths are what stop two different field splits from
|
|
65
|
+
* hashing alike.
|
|
66
|
+
*/
|
|
67
|
+
async function domainHash(domain, parts) {
|
|
68
|
+
const chunks = [u64le(domain.length), domain];
|
|
69
|
+
for (const part of parts) {
|
|
70
|
+
chunks.push(u64le(part.length), part);
|
|
71
|
+
}
|
|
72
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', concat(...chunks)));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The commitment a warrant carries in place of the intent itself.
|
|
76
|
+
*
|
|
77
|
+
* Its own domain, distinct from the signing one, so a value computed for one
|
|
78
|
+
* purpose cannot be presented for the other.
|
|
79
|
+
*/
|
|
80
|
+
export async function intentHash(method, argsJson) {
|
|
81
|
+
const args = new TextEncoder().encode(JSON.stringify(argsJson));
|
|
82
|
+
return domainHash(INTENT_DOMAIN, [new TextEncoder().encode(method), args]);
|
|
83
|
+
}
|
|
84
|
+
async function importSigningKey(secretHex) {
|
|
85
|
+
const seed = fromHex(secretHex, 'deviceSecret', 32);
|
|
86
|
+
const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
|
|
87
|
+
try {
|
|
88
|
+
return await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
// The original message is folded in rather than passed as `cause`: this
|
|
92
|
+
// package targets ES2020, where `Error` has no `cause` option.
|
|
93
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : '';
|
|
94
|
+
throw new Error('this runtime has no WebCrypto Ed25519, so a warrant cannot be signed ' +
|
|
95
|
+
'here. Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it; ' +
|
|
96
|
+
'otherwise mint the warrant with `merod account warrant`' +
|
|
97
|
+
detail);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Sign a warrant and return it hex-encoded, ready for `performIntent`.
|
|
102
|
+
*
|
|
103
|
+
* The 240 bytes are the canonical form: the signature covers exactly them, which
|
|
104
|
+
* is why this returns the encoding rather than an object. A caller that rebuilt
|
|
105
|
+
* the fields from JSON would have a second spelling able to disagree with what
|
|
106
|
+
* was signed.
|
|
107
|
+
*/
|
|
108
|
+
export async function signWarrant(input) {
|
|
109
|
+
const context = fromHex(input.context, 'context', 32);
|
|
110
|
+
const authorAccount = fromHex(input.authorAccount, 'authorAccount', 32);
|
|
111
|
+
const executor = fromHex(input.executor, 'executor', 32);
|
|
112
|
+
const key = await importSigningKey(input.deviceSecret);
|
|
113
|
+
const publicKey = await derivePublicKey(input.deviceSecret);
|
|
114
|
+
const commitment = await intentHash(input.method, input.argsJson);
|
|
115
|
+
const nonce = u64le(input.nonce);
|
|
116
|
+
const notAfter = u64le(input.notAfter);
|
|
117
|
+
const preimage = await domainHash(SIGN_DOMAIN, [
|
|
118
|
+
context,
|
|
119
|
+
authorAccount,
|
|
120
|
+
publicKey,
|
|
121
|
+
executor,
|
|
122
|
+
commitment,
|
|
123
|
+
nonce,
|
|
124
|
+
notAfter,
|
|
125
|
+
]);
|
|
126
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, preimage));
|
|
127
|
+
return hex(concat(context, authorAccount, publicKey, executor, commitment, nonce, notAfter, signature));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The public half of an ed25519 seed.
|
|
131
|
+
*
|
|
132
|
+
* Derived rather than taken as an argument, for the reason core does the same: a
|
|
133
|
+
* caller able to name a key it does not hold could produce a warrant it cannot
|
|
134
|
+
* sign, and the field would stop meaning "who authorised this".
|
|
135
|
+
*/
|
|
136
|
+
async function derivePublicKey(secretHex) {
|
|
137
|
+
const seed = fromHex(secretHex, 'deviceSecret', 32);
|
|
138
|
+
const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
|
|
139
|
+
const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
|
140
|
+
const jwk = await crypto.subtle.exportKey('jwk', key);
|
|
141
|
+
const raw = jwk.x;
|
|
142
|
+
return Uint8Array.from(atob(raw.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
|
143
|
+
}
|