@calimero-network/mero-js 22.5.0 → 22.6.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.
@@ -24,8 +24,12 @@ import type { SignedGroupOpenInvitation } from '../admin-api/admin-types.js';
24
24
  * governance through a relay. None of these changes a layout this module
25
25
  * signs; the version moves with core's so nodes on different versions never
26
26
  * share a namespace.
27
+ *
28
+ * 16: a namespace can be founded through a relay (a delegated
29
+ * `NamespaceCreatedV2`), and `GroupOp::FoundingRelayAttested` lets that relay
30
+ * admit itself as the namespace's first TEE. Again no layout this module signs.
27
31
  */
28
- export declare const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 15;
32
+ export declare const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 16;
29
33
  /**
30
34
  * Borsh-encode a `SignedGroupOpenInvitation`.
31
35
  *
@@ -9,8 +9,12 @@ import { concat, derivePublicKey, fromHex, hex, importSigningKey, u32le, u64le,
9
9
  * governance through a relay. None of these changes a layout this module
10
10
  * signs; the version moves with core's so nodes on different versions never
11
11
  * share a namespace.
12
+ *
13
+ * 16: a namespace can be founded through a relay (a delegated
14
+ * `NamespaceCreatedV2`), and `GroupOp::FoundingRelayAttested` lets that relay
15
+ * admit itself as the namespace's first TEE. Again no layout this module signs.
12
16
  */
13
- export const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 15;
17
+ export const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 16;
14
18
  /** Domain prefixed to the signable bytes; core's `NAMESPACE_GOVERNANCE_SIGN_DOMAIN`. */
15
19
  const NAMESPACE_SIGN_DOMAIN = new TextEncoder().encode('calimero.namespace.v1');
16
20
  /** `NamespaceOp::Root` is the first variant. */
@@ -28,6 +28,7 @@
28
28
  * unspendable: the number is gone and the write never happened.
29
29
  */
30
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
  /**
@@ -198,6 +209,12 @@ export declare class RelayClient {
198
209
  private readonly fetchImpl;
199
210
  private readonly timeoutMs;
200
211
  constructor(config: RelayClientConfig);
212
+ /**
213
+ * The author device's signer, built from `deviceSecret` when that is what was
214
+ * configured. A {@link Signer} and never the secret, so no caller of a
215
+ * `RelayClient` gains the ability to read key material.
216
+ */
217
+ authorSigner(): Promise<Signer>;
201
218
  /**
202
219
  * Ask the relay what it can do in `contextId`.
203
220
  *
@@ -32,6 +32,7 @@ import { signCreationWarrant } from '../warrant/creation-warrant.js';
32
32
  import { signGovernanceWarrant } from '../warrant/governance-warrant.js';
33
33
  import { HTTPError } from '../http-client/web-client.js';
34
34
  import { hex } from '../crypto/internal.js';
35
+ import { resolveSigner } from '../signer/signer.js';
35
36
  /** How long a freshly minted warrant stays presentable, in seconds. */
36
37
  const DEFAULT_TTL_SECONDS = 300;
37
38
  /**
@@ -71,6 +72,14 @@ export class RelayClient {
71
72
  : (input, init) => globalThis.fetch(input, init);
72
73
  this.timeoutMs = config.timeoutMs ?? 10000;
73
74
  }
75
+ /**
76
+ * The author device's signer, built from `deviceSecret` when that is what was
77
+ * configured. A {@link Signer} and never the secret, so no caller of a
78
+ * `RelayClient` gains the ability to read key material.
79
+ */
80
+ async authorSigner() {
81
+ return resolveSigner(this.config.deviceSecret, this.config.signer, 'deviceSecret');
82
+ }
74
83
  /**
75
84
  * Ask the relay what it can do in `contextId`.
76
85
  *
@@ -94,6 +103,8 @@ export class RelayClient {
94
103
  */
95
104
  async execute(contextId, method, argsJson = {}) {
96
105
  const executor = this.executorAccount ?? (await this.describe(contextId)).executorAccount;
106
+ // Resolved before the nonce, so a misconfigured key burns none.
107
+ const signer = await this.authorSigner();
97
108
  const nonce = await this.config.nonces.next();
98
109
  const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
99
110
  const warrant = await signWarrant({
@@ -104,7 +115,7 @@ export class RelayClient {
104
115
  argsJson,
105
116
  nonce,
106
117
  notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
107
- deviceSecret: this.config.deviceSecret,
118
+ signer,
108
119
  });
109
120
  const body = await this.json('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof });
110
121
  return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
@@ -157,6 +168,8 @@ export class RelayClient {
157
168
  }
158
169
  const executor = this.checkedExecutor(described.executorAccount);
159
170
  const initArgs = input.initArgs ?? {};
171
+ // Resolved before the nonce, so a misconfigured key burns none.
172
+ const signer = await this.authorSigner();
160
173
  const nonce = await this.config.nonces.next();
161
174
  const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
162
175
  const { warrant } = await signCreationWarrant({
@@ -170,7 +183,7 @@ export class RelayClient {
170
183
  initArgs,
171
184
  nonce,
172
185
  notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
173
- deviceSecret: this.config.deviceSecret,
186
+ signer,
174
187
  });
175
188
  const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(input.groupId)}/context-intents`, { warrant, authorProof: this.config.authorProof, initArgs });
176
189
  return {
@@ -211,6 +224,8 @@ export class RelayClient {
211
224
  '(checked before signing, no nonce was spent)', false, 403);
212
225
  }
213
226
  const executor = this.checkedExecutor(described.executorAccount);
227
+ // Resolved before the nonce, so a misconfigured key burns none.
228
+ const signer = await this.authorSigner();
214
229
  const nonce = await this.config.nonces.next();
215
230
  const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
216
231
  const warrant = await signGovernanceWarrant({
@@ -220,7 +235,7 @@ export class RelayClient {
220
235
  executor,
221
236
  nonce,
222
237
  notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
223
- deviceSecret: this.config.deviceSecret,
238
+ signer,
224
239
  });
225
240
  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
241
  return { groupId: body.data.groupId };
@@ -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
  }
@@ -26,6 +26,7 @@
26
26
  * `crates/account/src/tests/governance_wire_fixture.rs`, and this module's test
27
27
  * asserts the same vectors. A drift would surface at a relay as a 400.
28
28
  */
29
+ import { type Signer } from '../signer/signer.js';
29
30
  import type { GovernanceOp, GovernanceOpKind } from './governance-op.js';
30
31
  /** What a governance warrant authorises. */
31
32
  export interface GovernanceWarrantInput {
@@ -51,15 +52,28 @@ export interface GovernanceWarrantInput {
51
52
  accountHeads?: string[];
52
53
  /** Governance heads the author's view descended from, each hex (32 bytes). At most 64. */
53
54
  governanceFloor?: string[];
54
- /** The author device's ed25519 signing secret, hex (32 bytes). Never sent. */
55
- deviceSecret: string;
55
+ /**
56
+ * The author device's ed25519 signing secret, hex (32 bytes). Never sent.
57
+ *
58
+ * Mutually exclusive with {@link GovernanceWarrantInput.signer}: exactly one is required.
59
+ * Prefer `signer` in a browser, since a secret held as a string is readable
60
+ * by anything on the origin.
61
+ */
62
+ deviceSecret?: string;
63
+ /**
64
+ * The author device's signer, in place of {@link GovernanceWarrantInput.deviceSecret}: for a
65
+ * key that cannot be exported to hex (a non-extractable WebCrypto key, a
66
+ * passkey, a hardware or remote signer). Its `publicKey` becomes the
67
+ * warrant's `author_device_key`, and it signs the 32-byte preimage as is.
68
+ */
69
+ signer?: Signer;
56
70
  }
57
71
  /** A governance warrant's fields, decoded from the wire encoding. */
58
72
  export interface GovernanceWarrantFields {
59
73
  scope: string;
60
74
  kind: GovernanceOpKind;
61
75
  authorAccount: string;
62
- /** The author device key `signGovernanceWarrant` derived from the secret. */
76
+ /** The author device key: the signer's public key. */
63
77
  deviceKey: string;
64
78
  executor: string;
65
79
  opHash: string;
@@ -26,8 +26,9 @@
26
26
  * `crates/account/src/tests/governance_wire_fixture.rs`, and this module's test
27
27
  * asserts the same vectors. A drift would surface at a relay as a 400.
28
28
  */
29
- import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
30
- import { citedHeads } from './warrant.js';
29
+ import { concat, domainHash, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
30
+ import { resolveSigner } from '../signer/signer.js';
31
+ import { checkedSignature, citedHeads } from './warrant.js';
31
32
  const SIGN_DOMAIN = new TextEncoder().encode('calimero.governance-warrant.v1');
32
33
  const OP_DOMAIN = new TextEncoder().encode('calimero.governance-warrant.op.v1');
33
34
  /** core's `GovernanceOpKind` discriminants. */
@@ -72,8 +73,8 @@ export async function signGovernanceWarrant(input) {
72
73
  const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
73
74
  const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
74
75
  const kind = kindByte(input.op.kind);
75
- const key = await importSigningKey(input.deviceSecret);
76
- const deviceKey = await derivePublicKey(input.deviceSecret);
76
+ const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
77
+ const deviceKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
77
78
  const opHash = await governanceOpHash(input.op);
78
79
  const preimage = await governanceWarrantPreimage({
79
80
  scope,
@@ -87,7 +88,7 @@ export async function signGovernanceWarrant(input) {
87
88
  nonce: input.nonce,
88
89
  notAfter: input.notAfter,
89
90
  });
90
- const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, preimage));
91
+ const signature = checkedSignature(await signer.sign(preimage));
91
92
  return hex(concat(scope, new Uint8Array([kind]), authorAccount, deviceKey, executor, opHash, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, u64le(input.nonce), u64le(input.notAfter), signature));
92
93
  }
93
94
  /**
@@ -27,6 +27,7 @@
27
27
  * this producing well-formed warrants whose signatures verify nowhere, surfacing
28
28
  * at a relay as a 403: an authorization refusal, nowhere near the cause.
29
29
  */
30
+ import { type Signer } from '../signer/signer.js';
30
31
  /** Ed25519 PKCS#8 prefix, so a raw 32-byte seed can be imported by WebCrypto. */
31
32
  /** What a warrant authorises. */
32
33
  export interface WarrantInput {
@@ -103,8 +104,18 @@ export interface WarrantInput {
103
104
  *
104
105
  * Never sent anywhere. It signs locally and only the signature travels, which
105
106
  * is the whole reason a warrant can be minted by something holding no node.
107
+ *
108
+ * Mutually exclusive with {@link WarrantInput.signer}: exactly one is
109
+ * required. Prefer `signer` in a browser, since a secret held as a string is
110
+ * readable by anything on the origin.
111
+ */
112
+ deviceSecret?: string;
113
+ /**
114
+ * The author device's signer, in place of {@link WarrantInput.deviceSecret}:
115
+ * for a key that cannot be exported to hex (a non-extractable WebCrypto key,
116
+ * a passkey, a hardware or remote signer).
106
117
  */
107
- deviceSecret: string;
118
+ signer?: Signer;
108
119
  }
109
120
  /**
110
121
  * The commitment a warrant carries in place of the intent itself.
@@ -125,6 +136,14 @@ export declare function signWarrant(input: WarrantInput): Promise<string>;
125
136
  * Decode a cited-head list, refusing one longer than a node will accept.
126
137
  * Shared with `creation-warrant.ts`; internal, not exported from the package root.
127
138
  */
139
+ /**
140
+ * A signer's output, refused unless it is a 64-byte Ed25519 signature.
141
+ *
142
+ * The signature is spliced into the encoding at a fixed width, so a signer that
143
+ * returned anything else would yield a warrant that parses as garbage at a
144
+ * relay, far from the signer at fault. Internal.
145
+ */
146
+ export declare function checkedSignature(signature: Uint8Array): Uint8Array;
128
147
  export declare function citedHeads(heads: string[] | undefined, label: string): Uint8Array[];
129
148
  /** A warrant's fields, as hex, decoded from the wire encoding. */
130
149
  export interface WarrantFields {
@@ -27,7 +27,8 @@
27
27
  * this producing well-formed warrants whose signatures verify nowhere, surfacing
28
28
  * at a relay as a 403: an authorization refusal, nowhere near the cause.
29
29
  */
30
- import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
30
+ import { concat, domainHash, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
31
+ import { resolveSigner } from '../signer/signer.js';
31
32
  const SIGN_DOMAIN = new TextEncoder().encode('calimero.warrant.v2');
32
33
  const INTENT_DOMAIN = new TextEncoder().encode('calimero.warrant.intent.v1');
33
34
  /**
@@ -61,8 +62,8 @@ export async function signWarrant(input) {
61
62
  const appVersion = fromHex(input.appVersion ?? UNPINNED_APP, 'appVersion', 32);
62
63
  const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
63
64
  const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
64
- const key = await importSigningKey(input.deviceSecret);
65
- const publicKey = await derivePublicKey(input.deviceSecret);
65
+ const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
66
+ const publicKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
66
67
  const method = new TextEncoder().encode(input.method);
67
68
  const commitment = await intentHash(input.method, input.argsJson);
68
69
  const nonce = u64le(input.nonce);
@@ -87,7 +88,7 @@ export async function signWarrant(input) {
87
88
  nonce,
88
89
  notAfter,
89
90
  ]);
90
- const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, preimage));
91
+ const signature = checkedSignature(await signer.sign(preimage));
91
92
  // Borsh: a `String` is a u32 length then its UTF-8 bytes; a `Vec<[u8; 32]>` is
92
93
  // a u32 count then the elements. u32 HERE; u64 in the preimage above.
93
94
  return hex(concat(context, authorAccount, publicKey, executor, appVersion, u32le(method.length), method, commitment, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, nonce, notAfter, signature));
@@ -96,6 +97,20 @@ export async function signWarrant(input) {
96
97
  * Decode a cited-head list, refusing one longer than a node will accept.
97
98
  * Shared with `creation-warrant.ts`; internal, not exported from the package root.
98
99
  */
100
+ /**
101
+ * A signer's output, refused unless it is a 64-byte Ed25519 signature.
102
+ *
103
+ * The signature is spliced into the encoding at a fixed width, so a signer that
104
+ * returned anything else would yield a warrant that parses as garbage at a
105
+ * relay, far from the signer at fault. Internal.
106
+ */
107
+ export function checkedSignature(signature) {
108
+ if (!(signature instanceof Uint8Array) || signature.length !== 64) {
109
+ const got = signature instanceof Uint8Array ? `${signature.length} bytes` : typeof signature;
110
+ throw new Error(`signer returned ${got}, expected a 64-byte Ed25519 signature`);
111
+ }
112
+ return signature;
113
+ }
99
114
  export function citedHeads(heads, label) {
100
115
  const list = heads ?? [];
101
116
  if (list.length > MAX_CITED_HEADS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calimero-network/mero-js",
3
- "version": "22.5.0",
3
+ "version": "22.6.0",
4
4
  "description": "Pure JavaScript SDK for Calimero",
5
5
  "type": "module",
6
6
  "exports": {