@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.
@@ -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 { GovernanceOp } from '../warrant/governance-op.js';
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
- deviceSecret: string;
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
- deviceSecret: this.config.deviceSecret,
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
- deviceSecret: this.config.deviceSecret,
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
- deviceSecret: this.config.deviceSecret,
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
- const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(input.groupId)}/governance-intents`, { warrant, authorProof: this.config.authorProof, op: hex(input.op.bytes) });
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,2 @@
1
+ export { signerFromSecret, signerFromCryptoKey } from './signer.js';
2
+ export type { Signer } from './signer.js';
@@ -0,0 +1,3 @@
1
+ // Who signs — a key's capability, separated from its material, so a
2
+ // non-extractable `CryptoKey` can be used wherever a hex secret could.
3
+ export { signerFromSecret, signerFromCryptoKey } from './signer.js';
@@ -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
- /** The author device's ed25519 signing secret, hex (32 bytes). Never sent. */
75
- deviceSecret: string;
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 `signCreationWarrant` derived from the secret. */
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, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
25
- import { citedHeads } from './warrant.js';
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 key = await importSigningKey(input.deviceSecret);
86
- const deviceKey = await derivePublicKey(input.deviceSecret);
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 = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, preimage));
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
  }