@calimero-network/mero-js 22.2.0 → 22.4.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/README.md +5 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +295 -21
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +295 -21
- package/dist/index.mjs.map +4 -4
- package/dist/relay/index.d.ts +1 -1
- package/dist/relay/relay-client.d.ts +76 -0
- package/dist/relay/relay-client.js +77 -0
- package/dist/sealed/release.d.ts +16 -4
- package/dist/sealed/release.js +35 -5
- package/dist/warrant/creation-warrant.d.ts +145 -0
- package/dist/warrant/creation-warrant.js +189 -0
- package/dist/warrant/index.d.ts +2 -0
- package/dist/warrant/index.js +1 -0
- package/dist/warrant/warrant.d.ts +5 -0
- package/dist/warrant/warrant.js +5 -2
- 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 } from './relay-client.js';
|
|
2
|
+
export type { RelayClientConfig, RelayDescription, IntentResult, CreationDescription, CreateContextInput, CreatedContext, } from './relay-client.js';
|
|
3
3
|
export { createMemoryNonceSource, createLocalStorageNonceSource } from './nonce-source.js';
|
|
4
4
|
export type { NonceSource } from './nonce-source.js';
|
|
@@ -84,6 +84,51 @@ export interface RelayDescription {
|
|
|
84
84
|
/** The group whose admin grants that capability, hex. */
|
|
85
85
|
groupId: string;
|
|
86
86
|
}
|
|
87
|
+
/** What a relay says about its ability to create contexts in one group. */
|
|
88
|
+
export interface CreationDescription {
|
|
89
|
+
/** The account a creation warrant must name as `executor`, hex. */
|
|
90
|
+
executorAccount: string;
|
|
91
|
+
/** The group, hex. */
|
|
92
|
+
groupId: string;
|
|
93
|
+
/**
|
|
94
|
+
* Whether the relay has standing to act for members of this group (a
|
|
95
|
+
* `RelayTee` role or `CAN_AUTHOR_ON_BEHALF`). It needs no create rights of
|
|
96
|
+
* its own — those are checked on the author.
|
|
97
|
+
*/
|
|
98
|
+
canCreateOnBehalf: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Whether the author holds `CAN_CREATE_CONTEXT` (or admin) here. Present only
|
|
101
|
+
* when `describeCreation` was given an `author`.
|
|
102
|
+
*/
|
|
103
|
+
authorMayCreate?: boolean;
|
|
104
|
+
}
|
|
105
|
+
/** What {@link RelayClient.createContext} creates. */
|
|
106
|
+
export interface CreateContextInput {
|
|
107
|
+
/** The group to create the context in, hex. */
|
|
108
|
+
groupId: string;
|
|
109
|
+
/** The application to create it with, hex (32 bytes). */
|
|
110
|
+
applicationId: string;
|
|
111
|
+
/** The JSON the app's `init()` receives — run as the author's account. */
|
|
112
|
+
initArgs?: unknown;
|
|
113
|
+
/** Which service of a multi-service bundle; absent for the default. */
|
|
114
|
+
serviceName?: string;
|
|
115
|
+
/** A display name for the context. */
|
|
116
|
+
name?: string;
|
|
117
|
+
/**
|
|
118
|
+
* The seed the context id is derived from, hex (32 bytes). Random when
|
|
119
|
+
* absent, which is what almost every caller wants.
|
|
120
|
+
*/
|
|
121
|
+
seed?: string;
|
|
122
|
+
}
|
|
123
|
+
/** Where a delegated creation landed. */
|
|
124
|
+
export interface CreatedContext {
|
|
125
|
+
/** The new context's id, hex. */
|
|
126
|
+
contextId: string;
|
|
127
|
+
/** The group it was created in, hex. */
|
|
128
|
+
groupId: string;
|
|
129
|
+
/** The relay's identity in the new context, hex. */
|
|
130
|
+
memberPublicKey: string;
|
|
131
|
+
}
|
|
87
132
|
/** Where an accepted intent landed. */
|
|
88
133
|
export interface IntentResult<T = unknown> {
|
|
89
134
|
/** The context's scope root after the run — did this change anything? */
|
|
@@ -140,5 +185,36 @@ export declare class RelayClient {
|
|
|
140
185
|
* apply. Nothing accumulates and nothing is reusable.
|
|
141
186
|
*/
|
|
142
187
|
execute<T = unknown>(contextId: string, method: string, argsJson?: unknown): Promise<IntentResult<T>>;
|
|
188
|
+
/**
|
|
189
|
+
* Ask the relay what it can do about creating contexts in `groupId`.
|
|
190
|
+
*
|
|
191
|
+
* Pass `author` (an account, hex) to also learn whether that account may
|
|
192
|
+
* create here — `authorMayCreate` is present only then.
|
|
193
|
+
*/
|
|
194
|
+
describeCreation(groupId: string, opts?: {
|
|
195
|
+
author?: string;
|
|
196
|
+
}): Promise<CreationDescription>;
|
|
197
|
+
/**
|
|
198
|
+
* Create a context in a group through the relay, as the author.
|
|
199
|
+
*
|
|
200
|
+
* Always asks {@link describeCreation} first — with the author, so both
|
|
201
|
+
* standing checks happen before anything is signed. A relay without standing
|
|
202
|
+
* in the group, or an author without `CAN_CREATE_CONTEXT`, is refused here as
|
|
203
|
+
* an {@link IntentRefusedError} *before* a nonce is taken: the nonce source
|
|
204
|
+
* is monotonic, and a warrant nobody will spend burns one for nothing.
|
|
205
|
+
*
|
|
206
|
+
* The creation warrant's nonce is spent in the new context's per-device
|
|
207
|
+
* ledger — the same one later `execute` calls in that context draw from — so
|
|
208
|
+
* it comes from the configured {@link NonceSource} like any other warrant.
|
|
209
|
+
*
|
|
210
|
+
* Sealing: nothing here is plaintext-specific. The request goes through the
|
|
211
|
+
* injected `fetch`, so a `createAttestedSealedFetch` seals it exactly as it
|
|
212
|
+
* seals `execute`. Whether the relay *accepts* it sealed is the node's call:
|
|
213
|
+
* a proxy-auth relay only admits sealed requests for routes `merod` serves
|
|
214
|
+
* without a credential, so `/admin-api/groups/:id/context-intents` must be on
|
|
215
|
+
* that list on the core side or the request comes back
|
|
216
|
+
* `403 sealed_route_unguarded`.
|
|
217
|
+
*/
|
|
218
|
+
createContext(input: CreateContextInput): Promise<CreatedContext>;
|
|
143
219
|
private json;
|
|
144
220
|
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* unspendable: the number is gone and the write never happened.
|
|
29
29
|
*/
|
|
30
30
|
import { signWarrant } from '../warrant/warrant.js';
|
|
31
|
+
import { signCreationWarrant } from '../warrant/creation-warrant.js';
|
|
31
32
|
import { HTTPError } from '../http-client/web-client.js';
|
|
32
33
|
/** How long a freshly minted warrant stays presentable, in seconds. */
|
|
33
34
|
const DEFAULT_TTL_SECONDS = 300;
|
|
@@ -106,6 +107,82 @@ export class RelayClient {
|
|
|
106
107
|
const body = await this.json('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof });
|
|
107
108
|
return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
|
|
108
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Ask the relay what it can do about creating contexts in `groupId`.
|
|
112
|
+
*
|
|
113
|
+
* Pass `author` (an account, hex) to also learn whether that account may
|
|
114
|
+
* create here — `authorMayCreate` is present only then.
|
|
115
|
+
*/
|
|
116
|
+
async describeCreation(groupId, opts = {}) {
|
|
117
|
+
const query = opts.author ? `?author=${encodeURIComponent(opts.author)}` : '';
|
|
118
|
+
const body = await this.json('GET', `/admin-api/groups/${encodeURIComponent(groupId)}/context-intents${query}`);
|
|
119
|
+
return body.data;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Create a context in a group through the relay, as the author.
|
|
123
|
+
*
|
|
124
|
+
* Always asks {@link describeCreation} first — with the author, so both
|
|
125
|
+
* standing checks happen before anything is signed. A relay without standing
|
|
126
|
+
* in the group, or an author without `CAN_CREATE_CONTEXT`, is refused here as
|
|
127
|
+
* an {@link IntentRefusedError} *before* a nonce is taken: the nonce source
|
|
128
|
+
* is monotonic, and a warrant nobody will spend burns one for nothing.
|
|
129
|
+
*
|
|
130
|
+
* The creation warrant's nonce is spent in the new context's per-device
|
|
131
|
+
* ledger — the same one later `execute` calls in that context draw from — so
|
|
132
|
+
* it comes from the configured {@link NonceSource} like any other warrant.
|
|
133
|
+
*
|
|
134
|
+
* Sealing: nothing here is plaintext-specific. The request goes through the
|
|
135
|
+
* injected `fetch`, so a `createAttestedSealedFetch` seals it exactly as it
|
|
136
|
+
* seals `execute`. Whether the relay *accepts* it sealed is the node's call:
|
|
137
|
+
* a proxy-auth relay only admits sealed requests for routes `merod` serves
|
|
138
|
+
* without a credential, so `/admin-api/groups/:id/context-intents` must be on
|
|
139
|
+
* that list on the core side or the request comes back
|
|
140
|
+
* `403 sealed_route_unguarded`.
|
|
141
|
+
*/
|
|
142
|
+
async createContext(input) {
|
|
143
|
+
const described = await this.describeCreation(input.groupId, {
|
|
144
|
+
author: this.config.authorAccount,
|
|
145
|
+
});
|
|
146
|
+
if (!described.canCreateOnBehalf) {
|
|
147
|
+
throw new IntentRefusedError(`the relay (${described.executorAccount}) has no standing to act for members of group ` +
|
|
148
|
+
`${described.groupId}; an admin must admit it as a relay or grant it CAN_AUTHOR_ON_BEHALF ` +
|
|
149
|
+
'(checked before signing — no nonce was spent)', false, 403);
|
|
150
|
+
}
|
|
151
|
+
if (described.authorMayCreate === false) {
|
|
152
|
+
throw new IntentRefusedError(`the author (${this.config.authorAccount}) may not create contexts in group ` +
|
|
153
|
+
`${described.groupId}; it needs CAN_CREATE_CONTEXT or admin ` +
|
|
154
|
+
'(checked before signing — no nonce was spent)', false, 403);
|
|
155
|
+
}
|
|
156
|
+
const configured = this.config.executorAccount;
|
|
157
|
+
if (configured && configured.toLowerCase() !== described.executorAccount.toLowerCase()) {
|
|
158
|
+
throw new Error(`configured executorAccount ${configured} is not the relay's account ` +
|
|
159
|
+
`${described.executorAccount}; a warrant naming it would be unspendable`);
|
|
160
|
+
}
|
|
161
|
+
const executor = described.executorAccount;
|
|
162
|
+
this.executorAccount = executor;
|
|
163
|
+
const initArgs = input.initArgs ?? {};
|
|
164
|
+
const nonce = await this.config.nonces.next();
|
|
165
|
+
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
166
|
+
const { warrant } = await signCreationWarrant({
|
|
167
|
+
group: input.groupId,
|
|
168
|
+
seed: input.seed,
|
|
169
|
+
authorAccount: this.config.authorAccount,
|
|
170
|
+
executor,
|
|
171
|
+
applicationId: input.applicationId,
|
|
172
|
+
serviceName: input.serviceName,
|
|
173
|
+
name: input.name,
|
|
174
|
+
initArgs,
|
|
175
|
+
nonce,
|
|
176
|
+
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
177
|
+
deviceSecret: this.config.deviceSecret,
|
|
178
|
+
});
|
|
179
|
+
const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(input.groupId)}/context-intents`, { warrant, authorProof: this.config.authorProof, initArgs });
|
|
180
|
+
return {
|
|
181
|
+
contextId: body.data.contextId,
|
|
182
|
+
groupId: body.data.groupId,
|
|
183
|
+
memberPublicKey: body.data.memberPublicKey,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
109
186
|
async json(method, path, body) {
|
|
110
187
|
const headers = { Accept: 'application/json' };
|
|
111
188
|
if (body !== undefined)
|
package/dist/sealed/release.d.ts
CHANGED
|
@@ -97,6 +97,10 @@ export declare function nodeReleaseVersionFromImage(osImage: string): string;
|
|
|
97
97
|
* `GET /admin-api/tee/info` reports. The node's own claim, and not trusted:
|
|
98
98
|
* it only picks which signed release to fetch, and the quote must then match
|
|
99
99
|
* that release's measurements.
|
|
100
|
+
*
|
|
101
|
+
* `/tee/info` is read in the clear, before any session exists. A node that
|
|
102
|
+
* requires sealing serves it unsealed from core#4196 on; an older one refuses
|
|
103
|
+
* it with `403 sealed_required`, and then this throws saying so.
|
|
100
104
|
*/
|
|
101
105
|
export declare function fetchNodeReleaseVersion(baseUrl: string, options?: {
|
|
102
106
|
fetch?: typeof fetch;
|
|
@@ -172,15 +176,22 @@ export interface SignedReleaseSealedFetchOptions extends Omit<SignedReleaseVerif
|
|
|
172
176
|
* URL of a mirror serving {@link cloudNodeReleaseUrl}'s path, defaulting to
|
|
173
177
|
* {@link DEFAULT_RELEASE_MIRROR}; a function gets the version and returns
|
|
174
178
|
* the release, e.g. from a file bundled with the app. Whichever it is, it is
|
|
175
|
-
* not trusted: the release is verified. `/tee/info` is read in the clear
|
|
176
|
-
*
|
|
177
|
-
* function that ignores `version` and returns the release you expect.
|
|
179
|
+
* not trusted: the release is verified. `/tee/info` is read in the clear;
|
|
180
|
+
* for a node that refuses that, see `releaseVersion`.
|
|
178
181
|
*/
|
|
179
182
|
releaseSource?: string | ((context: {
|
|
180
183
|
baseUrl: string;
|
|
181
184
|
version: string;
|
|
182
185
|
fetch: typeof fetch;
|
|
183
186
|
}) => Promise<SignedNodeRelease>);
|
|
187
|
+
/**
|
|
188
|
+
* The release to fetch when the node will not name one: a node that
|
|
189
|
+
* requires sealing (`[server.sealed] required`) and predates core#4196
|
|
190
|
+
* refuses `/tee/info` unsealed. Used only then; a node that answers is
|
|
191
|
+
* taken at its word. Either way untrusted: the quote must match the signed
|
|
192
|
+
* release, so a wrong version only fails the attestation.
|
|
193
|
+
*/
|
|
194
|
+
releaseVersion?: string;
|
|
184
195
|
/** Also require the node to run this application with this bytecode hash. */
|
|
185
196
|
applicationId?: string;
|
|
186
197
|
applicationHash?: string;
|
|
@@ -190,7 +201,8 @@ export interface SignedReleaseSealedFetchOptions extends Omit<SignedReleaseVerif
|
|
|
190
201
|
/**
|
|
191
202
|
* {@link createAttestedSealedFetch} for a TEE node known only by URL, trusting
|
|
192
203
|
* whatever signed release at or above `minReleaseVersion` it runs: it reads
|
|
193
|
-
* the release the node names from its `/tee/info
|
|
204
|
+
* the release the node names from its `/tee/info` (or takes `releaseVersion`
|
|
205
|
+
* when a node too old to serve that unsealed requires sealing), gets that release from
|
|
194
206
|
* `releaseSource` (the public mirror unless you say otherwise), verifies its
|
|
195
207
|
* signature, builds the quote verifier from its measurements, attests the node
|
|
196
208
|
* and seals everything to it.
|
package/dist/sealed/release.js
CHANGED
|
@@ -127,10 +127,35 @@ export function nodeReleaseVersionFromImage(osImage) {
|
|
|
127
127
|
* `GET /admin-api/tee/info` reports. The node's own claim, and not trusted:
|
|
128
128
|
* it only picks which signed release to fetch, and the quote must then match
|
|
129
129
|
* that release's measurements.
|
|
130
|
+
*
|
|
131
|
+
* `/tee/info` is read in the clear, before any session exists. A node that
|
|
132
|
+
* requires sealing serves it unsealed from core#4196 on; an older one refuses
|
|
133
|
+
* it with `403 sealed_required`, and then this throws saying so.
|
|
130
134
|
*/
|
|
131
135
|
export async function fetchNodeReleaseVersion(baseUrl, options = {}) {
|
|
132
|
-
const
|
|
133
|
-
|
|
136
|
+
const version = await readNodeReleaseVersion(baseUrl, options.fetch ?? fetch);
|
|
137
|
+
if (version === undefined) {
|
|
138
|
+
throw new Error(`${infoUrl(baseUrl)} was refused unsealed: the node requires sealing and is too old to name its ` +
|
|
139
|
+
"release before a session exists. Name the release it runs yourself (createSignedReleaseSealedFetch's " +
|
|
140
|
+
'`releaseVersion`), or upgrade the node');
|
|
141
|
+
}
|
|
142
|
+
return version;
|
|
143
|
+
}
|
|
144
|
+
function infoUrl(baseUrl) {
|
|
145
|
+
return `${baseUrl.replace(/\/+$/, '')}/admin-api/tee/info`;
|
|
146
|
+
}
|
|
147
|
+
/** As {@link fetchNodeReleaseVersion}, but `undefined` when the node refuses `/tee/info` as `sealed_required`. */
|
|
148
|
+
async function readNodeReleaseVersion(baseUrl, givenFetch) {
|
|
149
|
+
const url = infoUrl(baseUrl);
|
|
150
|
+
const response = await givenFetch(url, { headers: { accept: 'application/json' } });
|
|
151
|
+
if (response.status === 403) {
|
|
152
|
+
const code = await response
|
|
153
|
+
.json()
|
|
154
|
+
.then((body) => body?.error?.code)
|
|
155
|
+
.catch(() => undefined);
|
|
156
|
+
if (code === 'sealed_required')
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
134
159
|
if (!response.ok)
|
|
135
160
|
throw new Error(`Fetching ${url} failed: HTTP ${response.status}`);
|
|
136
161
|
let osImage;
|
|
@@ -211,7 +236,8 @@ export function createSignedReleaseVerifier(options) {
|
|
|
211
236
|
/**
|
|
212
237
|
* {@link createAttestedSealedFetch} for a TEE node known only by URL, trusting
|
|
213
238
|
* whatever signed release at or above `minReleaseVersion` it runs: it reads
|
|
214
|
-
* the release the node names from its `/tee/info
|
|
239
|
+
* the release the node names from its `/tee/info` (or takes `releaseVersion`
|
|
240
|
+
* when a node too old to serve that unsealed requires sealing), gets that release from
|
|
215
241
|
* `releaseSource` (the public mirror unless you say otherwise), verifies its
|
|
216
242
|
* signature, builds the quote verifier from its measurements, attests the node
|
|
217
243
|
* and seals everything to it.
|
|
@@ -232,10 +258,14 @@ export function createSignedReleaseVerifier(options) {
|
|
|
232
258
|
* ```
|
|
233
259
|
*/
|
|
234
260
|
export function createSignedReleaseSealedFetch(options) {
|
|
235
|
-
const { baseUrl, releaseSource, applicationId, applicationHash, fetch: givenFetch, ...verifierOptions } = options;
|
|
261
|
+
const { baseUrl, releaseSource, releaseVersion: fallbackVersion, applicationId, applicationHash, fetch: givenFetch, ...verifierOptions } = options;
|
|
262
|
+
if (fallbackVersion !== undefined)
|
|
263
|
+
releaseVersion(fallbackVersion, 'releaseVersion');
|
|
236
264
|
const baseFetch = givenFetch ?? ((input, init) => fetch(input, init));
|
|
237
265
|
const release = async () => {
|
|
238
|
-
const version =
|
|
266
|
+
const version = fallbackVersion === undefined
|
|
267
|
+
? await fetchNodeReleaseVersion(baseUrl, { fetch: baseFetch })
|
|
268
|
+
: ((await readNodeReleaseVersion(baseUrl, baseFetch)) ?? fallbackVersion);
|
|
239
269
|
return typeof releaseSource === 'function'
|
|
240
270
|
? releaseSource({ baseUrl, version, fetch: baseFetch })
|
|
241
271
|
: fetchNodeRelease(cloudNodeReleaseUrl(releaseSource ?? DEFAULT_RELEASE_MIRROR, version), { fetch: baseFetch });
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a creation warrant: the author's consent for one relay to create one
|
|
3
|
+
* context in one group, once.
|
|
4
|
+
*
|
|
5
|
+
* The intent warrant in `warrant.ts` lets an account with no node *write* to a
|
|
6
|
+
* context; this lets it *create* one. The relay executes the warrant and
|
|
7
|
+
* publishes `GroupOp::ContextRegisteredOnBehalf` carrying it, and every peer
|
|
8
|
+
* checks the **author's** `CAN_CREATE_CONTEXT` (or admin) — the relay needs no
|
|
9
|
+
* create rights of its own, only standing to act for members. The app's `init`
|
|
10
|
+
* runs as the author's account.
|
|
11
|
+
*
|
|
12
|
+
* Same primitives and the same two-widths trap as `warrant.ts`: counts **inside
|
|
13
|
+
* the encoding** are borsh `u32`, counts the **signing preimage** hashes are
|
|
14
|
+
* `u64`. The two `Option<String>` labels add a third shape: on the wire an
|
|
15
|
+
* option is a one-byte tag followed (when present) by a `u32`-prefixed string,
|
|
16
|
+
* while the preimage hashes the tag and the bytes as two separate parts, the
|
|
17
|
+
* bytes empty when absent. That is what keeps `None` and `Some("")` distinct in
|
|
18
|
+
* both.
|
|
19
|
+
*
|
|
20
|
+
* **The byte contract is pinned in core**, at
|
|
21
|
+
* `crates/account/src/tests/creation_wire_fixture.rs`, and this module's test
|
|
22
|
+
* asserts the same vectors. A drift would surface at a relay as a 403.
|
|
23
|
+
*/
|
|
24
|
+
/** What a creation warrant authorises. */
|
|
25
|
+
export interface CreationWarrantInput {
|
|
26
|
+
/** The group the context is created in, hex (32 bytes). */
|
|
27
|
+
group: string;
|
|
28
|
+
/**
|
|
29
|
+
* The seed the node derives the new context's id from, hex (32 bytes).
|
|
30
|
+
*
|
|
31
|
+
* Optional: when absent, 32 random bytes are drawn and returned alongside the
|
|
32
|
+
* warrant. Pass one only to make the id predictable — reusing a seed asks for
|
|
33
|
+
* a context that already exists.
|
|
34
|
+
*/
|
|
35
|
+
seed?: string;
|
|
36
|
+
/** The author's account, hex — whose consent this is, and who runs `init`. */
|
|
37
|
+
authorAccount: string;
|
|
38
|
+
/** The relay authorised to act, hex. An account, not a key. */
|
|
39
|
+
executor: string;
|
|
40
|
+
/** The application the context is created with, hex (32 bytes). */
|
|
41
|
+
applicationId: string;
|
|
42
|
+
/**
|
|
43
|
+
* Which service of a multi-service bundle, or absent for the default.
|
|
44
|
+
*
|
|
45
|
+
* Absent and `""` are different values and sign differently. At most 256
|
|
46
|
+
* UTF-8 bytes.
|
|
47
|
+
*/
|
|
48
|
+
serviceName?: string;
|
|
49
|
+
/** A display name for the context, or absent. At most 256 UTF-8 bytes. */
|
|
50
|
+
name?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The JSON the app's `init()` will receive.
|
|
53
|
+
*
|
|
54
|
+
* Committed to rather than carried: the relay is sent the same value
|
|
55
|
+
* separately and the node checks it hashes to what was signed, so pass the
|
|
56
|
+
* exact value you will send. See {@link creationInitHash}.
|
|
57
|
+
*/
|
|
58
|
+
initArgs: unknown;
|
|
59
|
+
/**
|
|
60
|
+
* Monotonic per author **device**. Spent in the *new* context's warrant
|
|
61
|
+
* ledger, so the same per-device nonce source `RelayClient` uses is correct.
|
|
62
|
+
*/
|
|
63
|
+
nonce: number | bigint;
|
|
64
|
+
/** Unix seconds after which the relay must refuse it. */
|
|
65
|
+
notAfter: number | bigint;
|
|
66
|
+
/** Account-log heads the author saw, each hex (32 bytes). At most 64. */
|
|
67
|
+
accountHeads?: string[];
|
|
68
|
+
/**
|
|
69
|
+
* Governance heads the author's view descended from, each hex (32 bytes). At
|
|
70
|
+
* most 64. The same caveat as the intent warrant's floor applies: one the
|
|
71
|
+
* relay supplied is one the relay chose.
|
|
72
|
+
*/
|
|
73
|
+
governanceFloor?: string[];
|
|
74
|
+
/** The author device's ed25519 signing secret, hex (32 bytes). Never sent. */
|
|
75
|
+
deviceSecret: string;
|
|
76
|
+
}
|
|
77
|
+
/** A signed creation warrant and the seed it commits to. */
|
|
78
|
+
export interface SignedCreationWarrant {
|
|
79
|
+
/** Hex borsh `ContextCreationWarrant`, ready for the relay. */
|
|
80
|
+
warrant: string;
|
|
81
|
+
/** The seed, hex — the one passed in, or the one drawn when none was. */
|
|
82
|
+
seed: string;
|
|
83
|
+
}
|
|
84
|
+
/** A creation warrant's fields, decoded from the wire encoding. */
|
|
85
|
+
export interface CreationWarrantFields {
|
|
86
|
+
group: string;
|
|
87
|
+
seed: string;
|
|
88
|
+
authorAccount: string;
|
|
89
|
+
/** The author device key `signCreationWarrant` derived from the secret. */
|
|
90
|
+
deviceKey: string;
|
|
91
|
+
executor: string;
|
|
92
|
+
applicationId: string;
|
|
93
|
+
/** `null` when absent — distinct from `""`. */
|
|
94
|
+
serviceName: string | null;
|
|
95
|
+
/** `null` when absent — distinct from `""`. */
|
|
96
|
+
name: string | null;
|
|
97
|
+
initHash: string;
|
|
98
|
+
accountHeads: string[];
|
|
99
|
+
governanceFloor: string[];
|
|
100
|
+
nonce: bigint;
|
|
101
|
+
notAfter: bigint;
|
|
102
|
+
signature: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The commitment a creation warrant carries in place of the init arguments.
|
|
106
|
+
*
|
|
107
|
+
* Hashes `JSON.stringify(initArgs)`, the same bytes `RelayClient.createContext`
|
|
108
|
+
* sends, under its own domain.
|
|
109
|
+
*/
|
|
110
|
+
export declare function creationInitHash(initArgs: unknown): Promise<Uint8Array>;
|
|
111
|
+
/** The already-decoded fields the signature covers. Internal. */
|
|
112
|
+
export interface CreationPreimageParts {
|
|
113
|
+
group: Uint8Array;
|
|
114
|
+
seed: Uint8Array;
|
|
115
|
+
authorAccount: Uint8Array;
|
|
116
|
+
deviceKey: Uint8Array;
|
|
117
|
+
executor: Uint8Array;
|
|
118
|
+
applicationId: Uint8Array;
|
|
119
|
+
serviceName: Uint8Array | null;
|
|
120
|
+
name: Uint8Array | null;
|
|
121
|
+
initHash: Uint8Array;
|
|
122
|
+
accountHeads: Uint8Array[];
|
|
123
|
+
governanceFloor: Uint8Array[];
|
|
124
|
+
nonce: number | bigint;
|
|
125
|
+
notAfter: number | bigint;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The bytes the device key signs. Exported for the conformance test only — not
|
|
129
|
+
* from the package root.
|
|
130
|
+
*/
|
|
131
|
+
export declare function creationWarrantPreimage(p: CreationPreimageParts): Promise<Uint8Array>;
|
|
132
|
+
/**
|
|
133
|
+
* Sign a creation warrant and return it hex-encoded, with its seed.
|
|
134
|
+
*
|
|
135
|
+
* Returns the encoding rather than an object for the reason `signWarrant`
|
|
136
|
+
* does: the signature covers exactly these bytes.
|
|
137
|
+
*/
|
|
138
|
+
export declare function signCreationWarrant(input: CreationWarrantInput): Promise<SignedCreationWarrant>;
|
|
139
|
+
/**
|
|
140
|
+
* Decode a hex creation warrant back into its fields.
|
|
141
|
+
*
|
|
142
|
+
* Refuses truncated or trailing bytes rather than reading a neighbouring field.
|
|
143
|
+
* Does not verify the signature.
|
|
144
|
+
*/
|
|
145
|
+
export declare function parseCreationWarrant(warrant: string): CreationWarrantFields;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a creation warrant: the author's consent for one relay to create one
|
|
3
|
+
* context in one group, once.
|
|
4
|
+
*
|
|
5
|
+
* The intent warrant in `warrant.ts` lets an account with no node *write* to a
|
|
6
|
+
* context; this lets it *create* one. The relay executes the warrant and
|
|
7
|
+
* publishes `GroupOp::ContextRegisteredOnBehalf` carrying it, and every peer
|
|
8
|
+
* checks the **author's** `CAN_CREATE_CONTEXT` (or admin) — the relay needs no
|
|
9
|
+
* create rights of its own, only standing to act for members. The app's `init`
|
|
10
|
+
* runs as the author's account.
|
|
11
|
+
*
|
|
12
|
+
* Same primitives and the same two-widths trap as `warrant.ts`: counts **inside
|
|
13
|
+
* the encoding** are borsh `u32`, counts the **signing preimage** hashes are
|
|
14
|
+
* `u64`. The two `Option<String>` labels add a third shape: on the wire an
|
|
15
|
+
* option is a one-byte tag followed (when present) by a `u32`-prefixed string,
|
|
16
|
+
* while the preimage hashes the tag and the bytes as two separate parts, the
|
|
17
|
+
* bytes empty when absent. That is what keeps `None` and `Some("")` distinct in
|
|
18
|
+
* both.
|
|
19
|
+
*
|
|
20
|
+
* **The byte contract is pinned in core**, at
|
|
21
|
+
* `crates/account/src/tests/creation_wire_fixture.rs`, and this module's test
|
|
22
|
+
* asserts the same vectors. A drift would surface at a relay as a 403.
|
|
23
|
+
*/
|
|
24
|
+
import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
|
|
25
|
+
import { citedHeads } from './warrant.js';
|
|
26
|
+
const SIGN_DOMAIN = new TextEncoder().encode('calimero.context-creation-warrant.v1');
|
|
27
|
+
const INIT_DOMAIN = new TextEncoder().encode('calimero.context-creation.init.v1');
|
|
28
|
+
/** core's cap on `service_name` and `name`, in UTF-8 bytes. */
|
|
29
|
+
const MAX_LABEL_BYTES = 256;
|
|
30
|
+
/**
|
|
31
|
+
* The commitment a creation warrant carries in place of the init arguments.
|
|
32
|
+
*
|
|
33
|
+
* Hashes `JSON.stringify(initArgs)`, the same bytes `RelayClient.createContext`
|
|
34
|
+
* sends, under its own domain.
|
|
35
|
+
*/
|
|
36
|
+
export async function creationInitHash(initArgs) {
|
|
37
|
+
const bytes = new TextEncoder().encode(JSON.stringify(initArgs));
|
|
38
|
+
return domainHash(INIT_DOMAIN, [bytes]);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The bytes the device key signs. Exported for the conformance test only — not
|
|
42
|
+
* from the package root.
|
|
43
|
+
*/
|
|
44
|
+
export async function creationWarrantPreimage(p) {
|
|
45
|
+
// An option is two parts here — its tag, then its bytes (empty when absent) —
|
|
46
|
+
// so `None` and `Some("")` hash differently. u64 counts HERE; u32 on the wire.
|
|
47
|
+
return domainHash(SIGN_DOMAIN, [
|
|
48
|
+
p.group,
|
|
49
|
+
p.seed,
|
|
50
|
+
p.authorAccount,
|
|
51
|
+
p.deviceKey,
|
|
52
|
+
p.executor,
|
|
53
|
+
p.applicationId,
|
|
54
|
+
new Uint8Array([p.serviceName === null ? 0 : 1]),
|
|
55
|
+
p.serviceName ?? new Uint8Array(0),
|
|
56
|
+
new Uint8Array([p.name === null ? 0 : 1]),
|
|
57
|
+
p.name ?? new Uint8Array(0),
|
|
58
|
+
p.initHash,
|
|
59
|
+
u64le(p.accountHeads.length),
|
|
60
|
+
...p.accountHeads,
|
|
61
|
+
u64le(p.governanceFloor.length),
|
|
62
|
+
...p.governanceFloor,
|
|
63
|
+
u64le(p.nonce),
|
|
64
|
+
u64le(p.notAfter),
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Sign a creation warrant and return it hex-encoded, with its seed.
|
|
69
|
+
*
|
|
70
|
+
* Returns the encoding rather than an object for the reason `signWarrant`
|
|
71
|
+
* does: the signature covers exactly these bytes.
|
|
72
|
+
*/
|
|
73
|
+
export async function signCreationWarrant(input) {
|
|
74
|
+
const group = fromHex(input.group, 'group', 32);
|
|
75
|
+
const seed = input.seed === undefined
|
|
76
|
+
? crypto.getRandomValues(new Uint8Array(32))
|
|
77
|
+
: fromHex(input.seed, 'seed', 32);
|
|
78
|
+
const authorAccount = fromHex(input.authorAccount, 'authorAccount', 32);
|
|
79
|
+
const executor = fromHex(input.executor, 'executor', 32);
|
|
80
|
+
const applicationId = fromHex(input.applicationId, 'applicationId', 32);
|
|
81
|
+
const serviceName = label(input.serviceName, 'serviceName');
|
|
82
|
+
const name = label(input.name, 'name');
|
|
83
|
+
const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
|
|
84
|
+
const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
|
|
85
|
+
const key = await importSigningKey(input.deviceSecret);
|
|
86
|
+
const deviceKey = await derivePublicKey(input.deviceSecret);
|
|
87
|
+
const initHash = await creationInitHash(input.initArgs);
|
|
88
|
+
const preimage = await creationWarrantPreimage({
|
|
89
|
+
group,
|
|
90
|
+
seed,
|
|
91
|
+
authorAccount,
|
|
92
|
+
deviceKey,
|
|
93
|
+
executor,
|
|
94
|
+
applicationId,
|
|
95
|
+
serviceName,
|
|
96
|
+
name,
|
|
97
|
+
initHash,
|
|
98
|
+
accountHeads,
|
|
99
|
+
governanceFloor,
|
|
100
|
+
nonce: input.nonce,
|
|
101
|
+
notAfter: input.notAfter,
|
|
102
|
+
});
|
|
103
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, preimage));
|
|
104
|
+
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
|
+
return { warrant, seed: hex(seed) };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Decode a hex creation warrant back into its fields.
|
|
109
|
+
*
|
|
110
|
+
* Refuses truncated or trailing bytes rather than reading a neighbouring field.
|
|
111
|
+
* Does not verify the signature.
|
|
112
|
+
*/
|
|
113
|
+
export function parseCreationWarrant(warrant) {
|
|
114
|
+
const clean = warrant.trim();
|
|
115
|
+
if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length % 2 !== 0) {
|
|
116
|
+
throw new Error('creation warrant must be an even number of hex characters');
|
|
117
|
+
}
|
|
118
|
+
const bytes = new Uint8Array(clean.length / 2);
|
|
119
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
120
|
+
bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
121
|
+
}
|
|
122
|
+
const view = new DataView(bytes.buffer);
|
|
123
|
+
let o = 0;
|
|
124
|
+
const take = (n, what) => {
|
|
125
|
+
if (o + n > bytes.length) {
|
|
126
|
+
throw new Error(`creation warrant ends inside ${what} (${bytes.length} bytes)`);
|
|
127
|
+
}
|
|
128
|
+
const out = bytes.subarray(o, o + n);
|
|
129
|
+
o += n;
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
const u32 = (what) => view.getUint32(take(4, what).byteOffset, true);
|
|
133
|
+
const u64 = (what) => view.getBigUint64(take(8, what).byteOffset, true);
|
|
134
|
+
const option = (what) => {
|
|
135
|
+
const tag = take(1, what)[0];
|
|
136
|
+
if (tag === 0)
|
|
137
|
+
return null;
|
|
138
|
+
if (tag !== 1)
|
|
139
|
+
throw new Error(`${what} has option tag ${tag}, expected 0 or 1`);
|
|
140
|
+
const len = u32(what);
|
|
141
|
+
if (len > MAX_LABEL_BYTES) {
|
|
142
|
+
throw new Error(`${what} is ${len} bytes, over the ${MAX_LABEL_BYTES} a node accepts`);
|
|
143
|
+
}
|
|
144
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(take(len, what));
|
|
145
|
+
};
|
|
146
|
+
const heads = (what) => {
|
|
147
|
+
const count = u32(what);
|
|
148
|
+
if (count > 64) {
|
|
149
|
+
throw new Error(`${what} cites ${count} heads, over the 64 a node accepts`);
|
|
150
|
+
}
|
|
151
|
+
return Array.from({ length: count }, () => hex(take(32, what)));
|
|
152
|
+
};
|
|
153
|
+
const fields = {
|
|
154
|
+
group: hex(take(32, 'group')),
|
|
155
|
+
seed: hex(take(32, 'seed')),
|
|
156
|
+
authorAccount: hex(take(32, 'authorAccount')),
|
|
157
|
+
deviceKey: hex(take(32, 'deviceKey')),
|
|
158
|
+
executor: hex(take(32, 'executor')),
|
|
159
|
+
applicationId: hex(take(32, 'applicationId')),
|
|
160
|
+
serviceName: option('serviceName'),
|
|
161
|
+
name: option('name'),
|
|
162
|
+
initHash: hex(take(32, 'initHash')),
|
|
163
|
+
accountHeads: heads('accountHeads'),
|
|
164
|
+
governanceFloor: heads('governanceFloor'),
|
|
165
|
+
nonce: u64('nonce'),
|
|
166
|
+
notAfter: u64('notAfter'),
|
|
167
|
+
signature: hex(take(64, 'signature')),
|
|
168
|
+
};
|
|
169
|
+
if (o !== bytes.length) {
|
|
170
|
+
throw new Error(`creation warrant is ${bytes.length} bytes but its fields account for ${o}`);
|
|
171
|
+
}
|
|
172
|
+
return fields;
|
|
173
|
+
}
|
|
174
|
+
/** UTF-8 encode an optional label, refusing one longer than a node accepts. */
|
|
175
|
+
function label(value, what) {
|
|
176
|
+
if (value === undefined)
|
|
177
|
+
return null;
|
|
178
|
+
const bytes = new TextEncoder().encode(value);
|
|
179
|
+
if (bytes.length > MAX_LABEL_BYTES) {
|
|
180
|
+
throw new Error(`${what} is ${bytes.length} UTF-8 bytes, over the ${MAX_LABEL_BYTES} a node accepts`);
|
|
181
|
+
}
|
|
182
|
+
return bytes;
|
|
183
|
+
}
|
|
184
|
+
/** Borsh `Option<String>`: `0x00`, or `0x01` then a u32 length and the bytes. */
|
|
185
|
+
function borshOption(bytes) {
|
|
186
|
+
if (bytes === null)
|
|
187
|
+
return new Uint8Array([0]);
|
|
188
|
+
return concat(new Uint8Array([1]), u32le(bytes.length), bytes);
|
|
189
|
+
}
|
package/dist/warrant/index.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { signWarrant, intentHash } from './warrant.js';
|
|
2
2
|
export type { WarrantInput } from './warrant.js';
|
|
3
|
+
export { signCreationWarrant, parseCreationWarrant, creationInitHash, } from './creation-warrant.js';
|
|
4
|
+
export type { CreationWarrantInput, CreationWarrantFields, SignedCreationWarrant, } from './creation-warrant.js';
|
package/dist/warrant/index.js
CHANGED
|
@@ -121,6 +121,11 @@ export declare function intentHash(method: string, argsJson: unknown): Promise<U
|
|
|
121
121
|
* second spelling able to disagree with what was signed.
|
|
122
122
|
*/
|
|
123
123
|
export declare function signWarrant(input: WarrantInput): Promise<string>;
|
|
124
|
+
/**
|
|
125
|
+
* Decode a cited-head list, refusing one longer than a node will accept.
|
|
126
|
+
* Shared with `creation-warrant.ts`; internal, not exported from the package root.
|
|
127
|
+
*/
|
|
128
|
+
export declare function citedHeads(heads: string[] | undefined, label: string): Uint8Array[];
|
|
124
129
|
/** A warrant's fields, as hex, decoded from the wire encoding. */
|
|
125
130
|
export interface WarrantFields {
|
|
126
131
|
context: string;
|