@calimero-network/mero-js 22.5.1 → 22.7.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/capabilities.d.ts +42 -0
- package/dist/capabilities.js +64 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +403 -116
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -1
- package/dist/index.mjs +403 -116
- package/dist/index.mjs.map +4 -4
- package/dist/relay/index.d.ts +1 -1
- package/dist/relay/relay-client.d.ts +107 -2
- package/dist/relay/relay-client.js +121 -6
- package/dist/signer/index.d.ts +2 -0
- package/dist/signer/index.js +3 -0
- package/dist/signer/signer.d.ts +92 -0
- package/dist/signer/signer.js +147 -0
- package/dist/warrant/creation-warrant.d.ts +17 -3
- package/dist/warrant/creation-warrant.js +6 -5
- package/dist/warrant/governance-op.d.ts +47 -1
- package/dist/warrant/governance-op.js +62 -2
- package/dist/warrant/governance-warrant.d.ts +17 -3
- package/dist/warrant/governance-warrant.js +6 -5
- package/dist/warrant/index.d.ts +2 -2
- package/dist/warrant/index.js +1 -1
- package/dist/warrant/warrant.d.ts +20 -1
- package/dist/warrant/warrant.js +19 -4
- package/package.json +1 -1
package/dist/relay/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { RelayClient, IntentRefusedError } from './relay-client.js';
|
|
2
|
-
export type { RelayClientConfig, RelayDescription, IntentResult, CreationDescription, CreateContextInput, CreatedContext, GovernanceDescription, GovernInput, GovernResult, } from './relay-client.js';
|
|
2
|
+
export type { RelayClientConfig, RelayDescription, IntentResult, CreationDescription, CreateContextInput, CreatedContext, GovernanceDescription, GovernInput, GovernResult, FoundNamespaceInput, FoundedNamespace, } from './relay-client.js';
|
|
3
3
|
export { createMemoryNonceSource, createLocalStorageNonceSource } from './nonce-source.js';
|
|
4
4
|
export type { NonceSource } from './nonce-source.js';
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* monotonic per-device sequence, and one minted against the wrong executor is
|
|
28
28
|
* unspendable: the number is gone and the write never happened.
|
|
29
29
|
*/
|
|
30
|
-
import type
|
|
30
|
+
import { type GovernanceOp } from '../warrant/governance-op.js';
|
|
31
|
+
import { type Signer } from '../signer/signer.js';
|
|
31
32
|
import type { NonceSource } from './nonce-source.js';
|
|
32
33
|
/** Where and as whom to write, plus the key that consents. */
|
|
33
34
|
export interface RelayClientConfig {
|
|
@@ -60,8 +61,18 @@ export interface RelayClientConfig {
|
|
|
60
61
|
*
|
|
61
62
|
* Never transmitted. It signs in this process and only the signature leaves,
|
|
62
63
|
* which is the whole reason a keyholder can author without a node.
|
|
64
|
+
*
|
|
65
|
+
* Mutually exclusive with {@link RelayClientConfig.signer}: exactly one is
|
|
66
|
+
* required. A browser should prefer the signer: a secret held as a string is
|
|
67
|
+
* readable by anything on the origin, and a non-extractable key is not.
|
|
68
|
+
*/
|
|
69
|
+
deviceSecret?: string;
|
|
70
|
+
/**
|
|
71
|
+
* The author device's signer: use instead of
|
|
72
|
+
* {@link RelayClientConfig.deviceSecret} when the key cannot be exported
|
|
73
|
+
* (a non-extractable WebCrypto key, a passkey, a hardware or remote signer).
|
|
63
74
|
*/
|
|
64
|
-
|
|
75
|
+
signer?: Signer;
|
|
65
76
|
/** Where nonces come from. See {@link NonceSource} — a reset replays. */
|
|
66
77
|
nonces: NonceSource;
|
|
67
78
|
/**
|
|
@@ -158,6 +169,61 @@ export interface GovernResult {
|
|
|
158
169
|
/** The group the op acted on, hex: the new subgroup, for a creation. */
|
|
159
170
|
groupId: string;
|
|
160
171
|
}
|
|
172
|
+
/** What {@link RelayClient.foundNamespace} founds with. */
|
|
173
|
+
export interface FoundNamespaceInput {
|
|
174
|
+
/**
|
|
175
|
+
* The salt the namespace id is derived from, hex (32 bytes). Random when
|
|
176
|
+
* absent, which is what almost every caller wants: one account founds many
|
|
177
|
+
* namespaces by varying it.
|
|
178
|
+
*/
|
|
179
|
+
salt?: string;
|
|
180
|
+
/**
|
|
181
|
+
* The relay's account, hex: the warrant's `executor`. The namespace does not
|
|
182
|
+
* exist yet, so there is nothing to ask `describeGovernance` about. Falls back
|
|
183
|
+
* to the client's configured or already-learned `executorAccount`.
|
|
184
|
+
*/
|
|
185
|
+
executorAccount?: string;
|
|
186
|
+
/**
|
|
187
|
+
* The namespace root's default capability mask (`MemberCapabilities` bits),
|
|
188
|
+
* set right after founding. A namespace is founded with core's minimal
|
|
189
|
+
* default (`CAN_JOIN_OPEN_SUBGROUPS` only), so an app that wants members to
|
|
190
|
+
* create contexts or invite, for example, names its own mask here.
|
|
191
|
+
*
|
|
192
|
+
* Set through the relay with a delegated `DefaultCapabilitiesSet`, applied as
|
|
193
|
+
* the founder, who is the namespace's admin. It cannot include
|
|
194
|
+
* `CAN_AUTHOR_ON_BEHALF` (bit 9): which relays may write for members is never
|
|
195
|
+
* decided through a relay, and such a mask is refused before anything is
|
|
196
|
+
* signed.
|
|
197
|
+
*/
|
|
198
|
+
defaultCapabilities?: number;
|
|
199
|
+
}
|
|
200
|
+
/** A namespace founded through a relay. */
|
|
201
|
+
export interface FoundedNamespace {
|
|
202
|
+
/** The new namespace's id, hex: `foundedNamespaceId(author, salt)`. */
|
|
203
|
+
namespaceId: string;
|
|
204
|
+
/**
|
|
205
|
+
* The salt it was derived with, hex. Keep it if anyone will need to be shown
|
|
206
|
+
* who founded the namespace: `(founder, salt)` reproduces the id.
|
|
207
|
+
*/
|
|
208
|
+
salt: string;
|
|
209
|
+
/**
|
|
210
|
+
* Whether the relay admitted itself as the namespace's first TEE, which also
|
|
211
|
+
* sets its default relay-mode admission policy. `false` from a relay that is
|
|
212
|
+
* not a TEE, or whose attestation failed (see `teeError`). The namespace is
|
|
213
|
+
* founded either way.
|
|
214
|
+
*/
|
|
215
|
+
teeEnabled: boolean;
|
|
216
|
+
/** Why `teeEnabled` is `false`, when the relay tried and failed. */
|
|
217
|
+
teeError?: string;
|
|
218
|
+
/**
|
|
219
|
+
* Present only when `defaultCapabilities` was asked for: whether the mask was
|
|
220
|
+
* set. `false` leaves the namespace founded with core's default; retry with
|
|
221
|
+
* `govern({ groupId: namespaceId, op: defaultCapabilitiesSetOp(mask) })`.
|
|
222
|
+
*/
|
|
223
|
+
defaultCapabilitiesSet?: boolean;
|
|
224
|
+
/** Why `defaultCapabilitiesSet` is `false`. */
|
|
225
|
+
defaultCapabilitiesError?: string;
|
|
226
|
+
}
|
|
161
227
|
/** Where an accepted intent landed. */
|
|
162
228
|
export interface IntentResult<T = unknown> {
|
|
163
229
|
/** The context's scope root after the run — did this change anything? */
|
|
@@ -198,6 +264,12 @@ export declare class RelayClient {
|
|
|
198
264
|
private readonly fetchImpl;
|
|
199
265
|
private readonly timeoutMs;
|
|
200
266
|
constructor(config: RelayClientConfig);
|
|
267
|
+
/**
|
|
268
|
+
* The author device's signer, built from `deviceSecret` when that is what was
|
|
269
|
+
* configured. A {@link Signer} and never the secret, so no caller of a
|
|
270
|
+
* `RelayClient` gains the ability to read key material.
|
|
271
|
+
*/
|
|
272
|
+
authorSigner(): Promise<Signer>;
|
|
201
273
|
/**
|
|
202
274
|
* Ask the relay what it can do in `contextId`.
|
|
203
275
|
*
|
|
@@ -267,6 +339,39 @@ export declare class RelayClient {
|
|
|
267
339
|
* other warrant.
|
|
268
340
|
*/
|
|
269
341
|
govern(input: GovernInput): Promise<GovernResult>;
|
|
342
|
+
/**
|
|
343
|
+
* Found a namespace through the relay, with the author as its founder, owner
|
|
344
|
+
* and admin. For an account with no node, this is how it gets a namespace at
|
|
345
|
+
* all.
|
|
346
|
+
*
|
|
347
|
+
* The author signs the exact genesis (`NamespaceCreatedV2`, carrying the
|
|
348
|
+
* author's own credential and the salt) under a root-plane governance
|
|
349
|
+
* warrant scoped to the new namespace's id, which is derived from the
|
|
350
|
+
* author's account and the salt, so the relay can neither choose another id
|
|
351
|
+
* nor found it for anyone else. The relay takes an identity in the new
|
|
352
|
+
* namespace, mints its key, publishes the genesis, and is seated as the
|
|
353
|
+
* founding relay (a `Member` holding `CAN_AUTHOR_ON_BEHALF`), so later
|
|
354
|
+
* `govern`, `createContext` and `execute` calls in the namespace work
|
|
355
|
+
* through it straight away. A TEE relay then admits itself as the
|
|
356
|
+
* namespace's first TEE; `teeEnabled` reports whether it did.
|
|
357
|
+
*
|
|
358
|
+
* The executor cannot be learned from the relay first: the namespace does not
|
|
359
|
+
* exist, so `describeGovernance` has nothing to answer about. It comes from
|
|
360
|
+
* `input.executorAccount`, the configured one, or one an earlier call learned
|
|
361
|
+
* (`describe`, `createContext`, `govern`), and without any of them this
|
|
362
|
+
* throws before taking a nonce.
|
|
363
|
+
* The nonce is spent in the new namespace's ledger.
|
|
364
|
+
*
|
|
365
|
+
* With `defaultCapabilities`, the founder then sets the namespace's default
|
|
366
|
+
* capability mask through the same relay (a second warrant, a second nonce)
|
|
367
|
+
* and `defaultCapabilitiesSet` reports whether it landed. A failure there
|
|
368
|
+
* does not throw, since the namespace is already founded.
|
|
369
|
+
*
|
|
370
|
+
* Refusals (`400`/`403`) come back as {@link IntentRefusedError}; a salt
|
|
371
|
+
* that names an existing namespace is a `409`, an `HTTPError`.
|
|
372
|
+
*/
|
|
373
|
+
foundNamespace(input?: FoundNamespaceInput): Promise<FoundedNamespace>;
|
|
374
|
+
private postGovernance;
|
|
270
375
|
/**
|
|
271
376
|
* The executor a warrant must name: the one the relay answered with, which a
|
|
272
377
|
* configured account must agree with. Remembered for later calls.
|
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
import { signWarrant } from '../warrant/warrant.js';
|
|
31
31
|
import { signCreationWarrant } from '../warrant/creation-warrant.js';
|
|
32
32
|
import { signGovernanceWarrant } from '../warrant/governance-warrant.js';
|
|
33
|
+
import { defaultCapabilitiesSetOp, foundedNamespaceId, namespaceCreatedOp, } from '../warrant/governance-op.js';
|
|
33
34
|
import { HTTPError } from '../http-client/web-client.js';
|
|
34
|
-
import { hex } from '../crypto/internal.js';
|
|
35
|
+
import { fromHex, hex } from '../crypto/internal.js';
|
|
36
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
35
37
|
/** How long a freshly minted warrant stays presentable, in seconds. */
|
|
36
38
|
const DEFAULT_TTL_SECONDS = 300;
|
|
37
39
|
/**
|
|
@@ -71,6 +73,14 @@ export class RelayClient {
|
|
|
71
73
|
: (input, init) => globalThis.fetch(input, init);
|
|
72
74
|
this.timeoutMs = config.timeoutMs ?? 10000;
|
|
73
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The author device's signer, built from `deviceSecret` when that is what was
|
|
78
|
+
* configured. A {@link Signer} and never the secret, so no caller of a
|
|
79
|
+
* `RelayClient` gains the ability to read key material.
|
|
80
|
+
*/
|
|
81
|
+
async authorSigner() {
|
|
82
|
+
return resolveSigner(this.config.deviceSecret, this.config.signer, 'deviceSecret');
|
|
83
|
+
}
|
|
74
84
|
/**
|
|
75
85
|
* Ask the relay what it can do in `contextId`.
|
|
76
86
|
*
|
|
@@ -94,6 +104,8 @@ export class RelayClient {
|
|
|
94
104
|
*/
|
|
95
105
|
async execute(contextId, method, argsJson = {}) {
|
|
96
106
|
const executor = this.executorAccount ?? (await this.describe(contextId)).executorAccount;
|
|
107
|
+
// Resolved before the nonce, so a misconfigured key burns none.
|
|
108
|
+
const signer = await this.authorSigner();
|
|
97
109
|
const nonce = await this.config.nonces.next();
|
|
98
110
|
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
99
111
|
const warrant = await signWarrant({
|
|
@@ -104,7 +116,7 @@ export class RelayClient {
|
|
|
104
116
|
argsJson,
|
|
105
117
|
nonce,
|
|
106
118
|
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
107
|
-
|
|
119
|
+
signer,
|
|
108
120
|
});
|
|
109
121
|
const body = await this.json('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof });
|
|
110
122
|
return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
|
|
@@ -157,6 +169,8 @@ export class RelayClient {
|
|
|
157
169
|
}
|
|
158
170
|
const executor = this.checkedExecutor(described.executorAccount);
|
|
159
171
|
const initArgs = input.initArgs ?? {};
|
|
172
|
+
// Resolved before the nonce, so a misconfigured key burns none.
|
|
173
|
+
const signer = await this.authorSigner();
|
|
160
174
|
const nonce = await this.config.nonces.next();
|
|
161
175
|
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
162
176
|
const { warrant } = await signCreationWarrant({
|
|
@@ -170,7 +184,7 @@ export class RelayClient {
|
|
|
170
184
|
initArgs,
|
|
171
185
|
nonce,
|
|
172
186
|
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
173
|
-
|
|
187
|
+
signer,
|
|
174
188
|
});
|
|
175
189
|
const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(input.groupId)}/context-intents`, { warrant, authorProof: this.config.authorProof, initArgs });
|
|
176
190
|
return {
|
|
@@ -211,6 +225,8 @@ export class RelayClient {
|
|
|
211
225
|
'(checked before signing, no nonce was spent)', false, 403);
|
|
212
226
|
}
|
|
213
227
|
const executor = this.checkedExecutor(described.executorAccount);
|
|
228
|
+
// Resolved before the nonce, so a misconfigured key burns none.
|
|
229
|
+
const signer = await this.authorSigner();
|
|
214
230
|
const nonce = await this.config.nonces.next();
|
|
215
231
|
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
216
232
|
const warrant = await signGovernanceWarrant({
|
|
@@ -220,10 +236,109 @@ export class RelayClient {
|
|
|
220
236
|
executor,
|
|
221
237
|
nonce,
|
|
222
238
|
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
223
|
-
|
|
239
|
+
signer,
|
|
240
|
+
});
|
|
241
|
+
const data = await this.postGovernance(input.groupId, input.op, warrant);
|
|
242
|
+
return { groupId: data.groupId };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Found a namespace through the relay, with the author as its founder, owner
|
|
246
|
+
* and admin. For an account with no node, this is how it gets a namespace at
|
|
247
|
+
* all.
|
|
248
|
+
*
|
|
249
|
+
* The author signs the exact genesis (`NamespaceCreatedV2`, carrying the
|
|
250
|
+
* author's own credential and the salt) under a root-plane governance
|
|
251
|
+
* warrant scoped to the new namespace's id, which is derived from the
|
|
252
|
+
* author's account and the salt, so the relay can neither choose another id
|
|
253
|
+
* nor found it for anyone else. The relay takes an identity in the new
|
|
254
|
+
* namespace, mints its key, publishes the genesis, and is seated as the
|
|
255
|
+
* founding relay (a `Member` holding `CAN_AUTHOR_ON_BEHALF`), so later
|
|
256
|
+
* `govern`, `createContext` and `execute` calls in the namespace work
|
|
257
|
+
* through it straight away. A TEE relay then admits itself as the
|
|
258
|
+
* namespace's first TEE; `teeEnabled` reports whether it did.
|
|
259
|
+
*
|
|
260
|
+
* The executor cannot be learned from the relay first: the namespace does not
|
|
261
|
+
* exist, so `describeGovernance` has nothing to answer about. It comes from
|
|
262
|
+
* `input.executorAccount`, the configured one, or one an earlier call learned
|
|
263
|
+
* (`describe`, `createContext`, `govern`), and without any of them this
|
|
264
|
+
* throws before taking a nonce.
|
|
265
|
+
* The nonce is spent in the new namespace's ledger.
|
|
266
|
+
*
|
|
267
|
+
* With `defaultCapabilities`, the founder then sets the namespace's default
|
|
268
|
+
* capability mask through the same relay (a second warrant, a second nonce)
|
|
269
|
+
* and `defaultCapabilitiesSet` reports whether it landed. A failure there
|
|
270
|
+
* does not throw, since the namespace is already founded.
|
|
271
|
+
*
|
|
272
|
+
* Refusals (`400`/`403`) come back as {@link IntentRefusedError}; a salt
|
|
273
|
+
* that names an existing namespace is a `409`, an `HTTPError`.
|
|
274
|
+
*/
|
|
275
|
+
async foundNamespace(input = {}) {
|
|
276
|
+
const executor = input.executorAccount
|
|
277
|
+
? this.checkedExecutor(input.executorAccount)
|
|
278
|
+
: this.executorAccount;
|
|
279
|
+
if (!executor) {
|
|
280
|
+
throw new Error("foundNamespace needs the relay's account: pass executorAccount or configure it " +
|
|
281
|
+
'(checked before signing, no nonce was spent)');
|
|
282
|
+
}
|
|
283
|
+
const salt = input.salt
|
|
284
|
+
? hex(fromHex(input.salt, 'salt', 32))
|
|
285
|
+
: hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
286
|
+
const namespaceId = await foundedNamespaceId(this.config.authorAccount, salt);
|
|
287
|
+
const op = namespaceCreatedOp({
|
|
288
|
+
founder: this.config.authorAccount,
|
|
289
|
+
credential: this.config.authorProof,
|
|
290
|
+
salt,
|
|
291
|
+
});
|
|
292
|
+
// Encoded (and so validated) before anything is signed: a mask the relay
|
|
293
|
+
// would refuse must not cost the founding a nonce.
|
|
294
|
+
const capabilitiesOp = input.defaultCapabilities === undefined
|
|
295
|
+
? undefined
|
|
296
|
+
: defaultCapabilitiesSetOp(input.defaultCapabilities);
|
|
297
|
+
// Resolved before the nonce, so a misconfigured key burns none.
|
|
298
|
+
const signer = await this.authorSigner();
|
|
299
|
+
const nonce = await this.config.nonces.next();
|
|
300
|
+
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
301
|
+
const warrant = await signGovernanceWarrant({
|
|
302
|
+
scope: namespaceId,
|
|
303
|
+
op,
|
|
304
|
+
authorAccount: this.config.authorAccount,
|
|
305
|
+
executor,
|
|
306
|
+
nonce,
|
|
307
|
+
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
308
|
+
signer,
|
|
309
|
+
});
|
|
310
|
+
const data = await this.postGovernance(namespaceId, op, warrant);
|
|
311
|
+
const founded = {
|
|
312
|
+
namespaceId: data.groupId,
|
|
313
|
+
salt,
|
|
314
|
+
teeEnabled: data.teeEnabled ?? false,
|
|
315
|
+
...(data.teeError ? { teeError: data.teeError } : {}),
|
|
316
|
+
};
|
|
317
|
+
if (!capabilitiesOp)
|
|
318
|
+
return founded;
|
|
319
|
+
// A second op, under its own warrant, on the namespace root: the founder is
|
|
320
|
+
// its admin and the relay was seated to act for members, so it goes through
|
|
321
|
+
// the ordinary `govern` path. The namespace exists whatever happens here, so
|
|
322
|
+
// a failure is reported rather than thrown.
|
|
323
|
+
try {
|
|
324
|
+
await this.govern({ groupId: founded.namespaceId, op: capabilitiesOp });
|
|
325
|
+
return { ...founded, defaultCapabilitiesSet: true };
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
return {
|
|
329
|
+
...founded,
|
|
330
|
+
defaultCapabilitiesSet: false,
|
|
331
|
+
defaultCapabilitiesError: err instanceof Error ? err.message : String(err),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async postGovernance(groupId, op, warrant) {
|
|
336
|
+
const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(groupId)}/governance-intents`, {
|
|
337
|
+
warrant,
|
|
338
|
+
authorProof: this.config.authorProof,
|
|
339
|
+
op: hex(op.bytes),
|
|
224
340
|
});
|
|
225
|
-
|
|
226
|
-
return { groupId: body.data.groupId };
|
|
341
|
+
return body.data;
|
|
227
342
|
}
|
|
228
343
|
/**
|
|
229
344
|
* The executor a warrant must name: the one the relay answered with, which a
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who signs, separated from what is signed.
|
|
3
|
+
*
|
|
4
|
+
* Every signing entry point in this package used to take 32 hex bytes. That is
|
|
5
|
+
* fine for a CLI or a server holding a key file, and it is the one thing a
|
|
6
|
+
* browser must not do: a secret that exists as a string can be read by anything
|
|
7
|
+
* running on the origin, and a stolen account key is the account — permanently,
|
|
8
|
+
* because unlike a device key there is nothing above it to revoke it with.
|
|
9
|
+
*
|
|
10
|
+
* WebCrypto can hold an Ed25519 key that **signs but cannot be exported**
|
|
11
|
+
* (`generateKey(..., extractable: false, ...)`). Such a key has no hex
|
|
12
|
+
* representation to pass, so it could not be used with this package at all —
|
|
13
|
+
* which pushed consumers into reimplementing core's wire formats around their
|
|
14
|
+
* own key. That is the failure `crates/account/src/tests/sdk_credential_fixture.rs`
|
|
15
|
+
* exists to document: a second implementation of a byte contract agreed with
|
|
16
|
+
* itself for a full release while core rejected every credential it produced.
|
|
17
|
+
*
|
|
18
|
+
* So the interface is the key's *capability*, not its material:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const signer = await signerFromSecret(deviceSecret); // as before
|
|
22
|
+
* const signer = await signerFromCryptoKey(privateKey, pubKey); // unexportable
|
|
23
|
+
* await signWarrant({ ...terms, signer });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* `deviceSecret` and `rootSecret` keep working everywhere they did. They are
|
|
27
|
+
* now one way of producing a `Signer` rather than the only way, and callers
|
|
28
|
+
* that pass a secret get a signer built for them.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Something that can produce Ed25519 signatures and name the key they verify
|
|
32
|
+
* against.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately the smallest surface that works: a signature and the public key
|
|
35
|
+
* it belongs to. Anything larger would start describing *how* the key is held,
|
|
36
|
+
* which is the detail this exists to hide — a hex seed, a non-extractable
|
|
37
|
+
* `CryptoKey`, a hardware token behind an async prompt, and a remote signing
|
|
38
|
+
* service all satisfy it.
|
|
39
|
+
*/
|
|
40
|
+
export interface Signer {
|
|
41
|
+
/**
|
|
42
|
+
* The public half, 64 hex characters.
|
|
43
|
+
*
|
|
44
|
+
* Carried rather than derived on demand because a non-extractable private key
|
|
45
|
+
* cannot produce it: the public half has to be captured when the key is
|
|
46
|
+
* generated. A signer that could not name its key would force every caller to
|
|
47
|
+
* pass the public key alongside it, and the two could then disagree.
|
|
48
|
+
*/
|
|
49
|
+
readonly publicKey: string;
|
|
50
|
+
/**
|
|
51
|
+
* Sign `payload` — the exact bytes, with no hashing or framing added.
|
|
52
|
+
*
|
|
53
|
+
* Every caller in this package passes a 32-byte domain-separated digest it
|
|
54
|
+
* has already computed, and an implementation that hashed again would produce
|
|
55
|
+
* signatures that verify nowhere.
|
|
56
|
+
*/
|
|
57
|
+
sign(payload: Uint8Array): Promise<Uint8Array>;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A signer over a raw 32-byte Ed25519 seed, hex.
|
|
61
|
+
*
|
|
62
|
+
* The public half is derived from the seed rather than accepted alongside it,
|
|
63
|
+
* for the reason core does the same: a caller able to name a key it does not
|
|
64
|
+
* hold could produce a statement it cannot sign, and the field would stop
|
|
65
|
+
* meaning "who authorised this".
|
|
66
|
+
*
|
|
67
|
+
* @param secret the seed, 64 hex characters
|
|
68
|
+
* @param label what to call it in an error — `deviceSecret`, `rootSecret`
|
|
69
|
+
*/
|
|
70
|
+
export declare function signerFromSecret(secret: string, label?: string): Promise<Signer>;
|
|
71
|
+
/**
|
|
72
|
+
* A signer over a `CryptoKey` this process may use and cannot read.
|
|
73
|
+
*
|
|
74
|
+
* The point of the whole interface. Generate the key with
|
|
75
|
+
* `generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])`, keep the pair's
|
|
76
|
+
* private half (structured-clone it into IndexedDB if it must survive a reload),
|
|
77
|
+
* and pass it here with its public half.
|
|
78
|
+
*
|
|
79
|
+
* @param privateKey the signing key — extractable or not, it is never exported
|
|
80
|
+
* @param publicKey its public half: 64 hex, or the pair's `publicKey` (which
|
|
81
|
+
* WebCrypto leaves exportable even when the private half is not)
|
|
82
|
+
*/
|
|
83
|
+
export declare function signerFromCryptoKey(privateKey: CryptoKey, publicKey: string | CryptoKey): Promise<Signer>;
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the signer an input describes, accepting either form.
|
|
86
|
+
*
|
|
87
|
+
* Internal. Every signing entry point takes an optional secret *and* an
|
|
88
|
+
* optional signer, and exactly one must be present — passing both is refused
|
|
89
|
+
* rather than silently preferring one, because the two could name different
|
|
90
|
+
* keys and the caller would have no way to tell which signed.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveSigner(secret: string | undefined, signer: Signer | undefined, label: string): Promise<Signer>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who signs, separated from what is signed.
|
|
3
|
+
*
|
|
4
|
+
* Every signing entry point in this package used to take 32 hex bytes. That is
|
|
5
|
+
* fine for a CLI or a server holding a key file, and it is the one thing a
|
|
6
|
+
* browser must not do: a secret that exists as a string can be read by anything
|
|
7
|
+
* running on the origin, and a stolen account key is the account — permanently,
|
|
8
|
+
* because unlike a device key there is nothing above it to revoke it with.
|
|
9
|
+
*
|
|
10
|
+
* WebCrypto can hold an Ed25519 key that **signs but cannot be exported**
|
|
11
|
+
* (`generateKey(..., extractable: false, ...)`). Such a key has no hex
|
|
12
|
+
* representation to pass, so it could not be used with this package at all —
|
|
13
|
+
* which pushed consumers into reimplementing core's wire formats around their
|
|
14
|
+
* own key. That is the failure `crates/account/src/tests/sdk_credential_fixture.rs`
|
|
15
|
+
* exists to document: a second implementation of a byte contract agreed with
|
|
16
|
+
* itself for a full release while core rejected every credential it produced.
|
|
17
|
+
*
|
|
18
|
+
* So the interface is the key's *capability*, not its material:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const signer = await signerFromSecret(deviceSecret); // as before
|
|
22
|
+
* const signer = await signerFromCryptoKey(privateKey, pubKey); // unexportable
|
|
23
|
+
* await signWarrant({ ...terms, signer });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* `deviceSecret` and `rootSecret` keep working everywhere they did. They are
|
|
27
|
+
* now one way of producing a `Signer` rather than the only way, and callers
|
|
28
|
+
* that pass a secret get a signer built for them.
|
|
29
|
+
*/
|
|
30
|
+
import { concat, fromHex, hex } from '../crypto/internal.js';
|
|
31
|
+
/** Ed25519 PKCS#8 prefix, so a raw 32-byte seed can be imported by WebCrypto. */
|
|
32
|
+
const PKCS8_ED25519_PREFIX = new Uint8Array([
|
|
33
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04,
|
|
34
|
+
0x22, 0x04, 0x20,
|
|
35
|
+
]);
|
|
36
|
+
/** Turn WebCrypto's bare `NotSupportedError` into something actionable. */
|
|
37
|
+
function unsupported(cause) {
|
|
38
|
+
// The original is folded into the message rather than passed as `cause`:
|
|
39
|
+
// this package targets ES2020, where `Error` has no `cause` option.
|
|
40
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : '';
|
|
41
|
+
return new Error('this runtime has no WebCrypto Ed25519, so nothing can be signed here. ' +
|
|
42
|
+
'Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it' +
|
|
43
|
+
detail);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A signer over a raw 32-byte Ed25519 seed, hex.
|
|
47
|
+
*
|
|
48
|
+
* The public half is derived from the seed rather than accepted alongside it,
|
|
49
|
+
* for the reason core does the same: a caller able to name a key it does not
|
|
50
|
+
* hold could produce a statement it cannot sign, and the field would stop
|
|
51
|
+
* meaning "who authorised this".
|
|
52
|
+
*
|
|
53
|
+
* @param secret the seed, 64 hex characters
|
|
54
|
+
* @param label what to call it in an error — `deviceSecret`, `rootSecret`
|
|
55
|
+
*/
|
|
56
|
+
export async function signerFromSecret(secret, label = 'secret') {
|
|
57
|
+
const seed = fromHex(secret, label, 32);
|
|
58
|
+
const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
|
|
59
|
+
let privateKey;
|
|
60
|
+
let publicKey;
|
|
61
|
+
try {
|
|
62
|
+
// Imported twice on purpose: `extractable: true` is needed to read the
|
|
63
|
+
// public half out of a JWK, and the signing key is then re-imported
|
|
64
|
+
// unexportable so a `Signer` built from a secret is no more powerful than
|
|
65
|
+
// one built from a `CryptoKey`.
|
|
66
|
+
const exportable = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
|
67
|
+
const jwk = (await crypto.subtle.exportKey('jwk', exportable));
|
|
68
|
+
publicKey = hex(Uint8Array.from(atob(jwk.x.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)));
|
|
69
|
+
privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
|
|
70
|
+
}
|
|
71
|
+
catch (cause) {
|
|
72
|
+
throw unsupported(cause);
|
|
73
|
+
}
|
|
74
|
+
return fromKeyAndPublic(privateKey, publicKey);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A signer over a `CryptoKey` this process may use and cannot read.
|
|
78
|
+
*
|
|
79
|
+
* The point of the whole interface. Generate the key with
|
|
80
|
+
* `generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])`, keep the pair's
|
|
81
|
+
* private half (structured-clone it into IndexedDB if it must survive a reload),
|
|
82
|
+
* and pass it here with its public half.
|
|
83
|
+
*
|
|
84
|
+
* @param privateKey the signing key — extractable or not, it is never exported
|
|
85
|
+
* @param publicKey its public half: 64 hex, or the pair's `publicKey` (which
|
|
86
|
+
* WebCrypto leaves exportable even when the private half is not)
|
|
87
|
+
*/
|
|
88
|
+
export async function signerFromCryptoKey(privateKey, publicKey) {
|
|
89
|
+
if (privateKey.type !== 'private') {
|
|
90
|
+
throw new Error(`signerFromCryptoKey needs the private half of the pair, got a ${privateKey.type} key`);
|
|
91
|
+
}
|
|
92
|
+
if (!privateKey.usages.includes('sign')) {
|
|
93
|
+
throw new Error("this CryptoKey cannot sign: generate it with usages ['sign', 'verify']");
|
|
94
|
+
}
|
|
95
|
+
let publicHex;
|
|
96
|
+
if (typeof publicKey === 'string') {
|
|
97
|
+
// Validated rather than trusted: a wrong public key produces signatures
|
|
98
|
+
// that verify nowhere, and the refusal arrives at a relay as a 403 —
|
|
99
|
+
// nowhere near this call.
|
|
100
|
+
fromHex(publicKey, 'publicKey', 32);
|
|
101
|
+
publicHex = publicKey.trim().toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
try {
|
|
105
|
+
publicHex = hex(new Uint8Array(await crypto.subtle.exportKey('raw', publicKey)));
|
|
106
|
+
}
|
|
107
|
+
catch (cause) {
|
|
108
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : '';
|
|
109
|
+
throw new Error('could not read the public half of this key pair; pass the public key ' +
|
|
110
|
+
'as 64 hex instead' +
|
|
111
|
+
detail);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return fromKeyAndPublic(privateKey, publicHex);
|
|
115
|
+
}
|
|
116
|
+
/** The shared tail of both constructors. */
|
|
117
|
+
function fromKeyAndPublic(privateKey, publicKey) {
|
|
118
|
+
return {
|
|
119
|
+
publicKey,
|
|
120
|
+
async sign(payload) {
|
|
121
|
+
try {
|
|
122
|
+
return new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, payload));
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
throw unsupported(cause);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the signer an input describes, accepting either form.
|
|
132
|
+
*
|
|
133
|
+
* Internal. Every signing entry point takes an optional secret *and* an
|
|
134
|
+
* optional signer, and exactly one must be present — passing both is refused
|
|
135
|
+
* rather than silently preferring one, because the two could name different
|
|
136
|
+
* keys and the caller would have no way to tell which signed.
|
|
137
|
+
*/
|
|
138
|
+
export async function resolveSigner(secret, signer, label) {
|
|
139
|
+
if (signer && secret !== undefined) {
|
|
140
|
+
throw new Error(`pass either ${label} or signer, not both — they may name different keys`);
|
|
141
|
+
}
|
|
142
|
+
if (signer)
|
|
143
|
+
return signer;
|
|
144
|
+
if (secret !== undefined)
|
|
145
|
+
return signerFromSecret(secret, label);
|
|
146
|
+
throw new Error(`${label} or signer is required`);
|
|
147
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* `crates/account/src/tests/creation_wire_fixture.rs`, and this module's test
|
|
22
22
|
* asserts the same vectors. A drift would surface at a relay as a 403.
|
|
23
23
|
*/
|
|
24
|
+
import { type Signer } from '../signer/signer.js';
|
|
24
25
|
/** What a creation warrant authorises. */
|
|
25
26
|
export interface CreationWarrantInput {
|
|
26
27
|
/** The group the context is created in, hex (32 bytes). */
|
|
@@ -71,8 +72,21 @@ export interface CreationWarrantInput {
|
|
|
71
72
|
* relay supplied is one the relay chose.
|
|
72
73
|
*/
|
|
73
74
|
governanceFloor?: string[];
|
|
74
|
-
/**
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The author device's ed25519 signing secret, hex (32 bytes). Never sent.
|
|
77
|
+
*
|
|
78
|
+
* Mutually exclusive with {@link CreationWarrantInput.signer}: exactly one is required.
|
|
79
|
+
* Prefer `signer` in a browser, since a secret held as a string is readable
|
|
80
|
+
* by anything on the origin.
|
|
81
|
+
*/
|
|
82
|
+
deviceSecret?: string;
|
|
83
|
+
/**
|
|
84
|
+
* The author device's signer, in place of {@link CreationWarrantInput.deviceSecret}: for a
|
|
85
|
+
* key that cannot be exported to hex (a non-extractable WebCrypto key, a
|
|
86
|
+
* passkey, a hardware or remote signer). Its `publicKey` becomes the
|
|
87
|
+
* warrant's `author_device_key`, and it signs the 32-byte preimage as is.
|
|
88
|
+
*/
|
|
89
|
+
signer?: Signer;
|
|
76
90
|
}
|
|
77
91
|
/** A signed creation warrant and the seed it commits to. */
|
|
78
92
|
export interface SignedCreationWarrant {
|
|
@@ -86,7 +100,7 @@ export interface CreationWarrantFields {
|
|
|
86
100
|
group: string;
|
|
87
101
|
seed: string;
|
|
88
102
|
authorAccount: string;
|
|
89
|
-
/** The author device key
|
|
103
|
+
/** The author device key: the signer's public key. */
|
|
90
104
|
deviceKey: string;
|
|
91
105
|
executor: string;
|
|
92
106
|
applicationId: string;
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* `crates/account/src/tests/creation_wire_fixture.rs`, and this module's test
|
|
22
22
|
* asserts the same vectors. A drift would surface at a relay as a 403.
|
|
23
23
|
*/
|
|
24
|
-
import { concat,
|
|
25
|
-
import {
|
|
24
|
+
import { concat, domainHash, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
|
|
25
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
26
|
+
import { checkedSignature, citedHeads } from './warrant.js';
|
|
26
27
|
const SIGN_DOMAIN = new TextEncoder().encode('calimero.context-creation-warrant.v1');
|
|
27
28
|
const INIT_DOMAIN = new TextEncoder().encode('calimero.context-creation.init.v1');
|
|
28
29
|
/** core's cap on `service_name` and `name`, in UTF-8 bytes. */
|
|
@@ -82,8 +83,8 @@ export async function signCreationWarrant(input) {
|
|
|
82
83
|
const name = label(input.name, 'name');
|
|
83
84
|
const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
|
|
84
85
|
const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
|
|
85
|
-
const
|
|
86
|
-
const deviceKey =
|
|
86
|
+
const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
|
|
87
|
+
const deviceKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
87
88
|
const initHash = await creationInitHash(input.initArgs);
|
|
88
89
|
const preimage = await creationWarrantPreimage({
|
|
89
90
|
group,
|
|
@@ -100,7 +101,7 @@ export async function signCreationWarrant(input) {
|
|
|
100
101
|
nonce: input.nonce,
|
|
101
102
|
notAfter: input.notAfter,
|
|
102
103
|
});
|
|
103
|
-
const signature =
|
|
104
|
+
const signature = checkedSignature(await signer.sign(preimage));
|
|
104
105
|
const warrant = hex(concat(group, seed, authorAccount, deviceKey, executor, applicationId, borshOption(serviceName), borshOption(name), initHash, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, u64le(input.nonce), u64le(input.notAfter), signature));
|
|
105
106
|
return { warrant, seed: hex(seed) };
|
|
106
107
|
}
|