@calimero-network/mero-js 22.6.0 → 22.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account/account.d.ts +99 -4
- package/dist/account/account.js +0 -0
- package/dist/account/index.d.ts +6 -2
- package/dist/account/index.js +3 -1
- package/dist/admin-api/admin-client.d.ts +138 -2
- package/dist/admin-api/admin-client.js +237 -1
- package/dist/admin-api/admin-types.d.ts +159 -5
- package/dist/capabilities.d.ts +42 -0
- package/dist/capabilities.js +64 -0
- package/dist/cloud/cloud-client.d.ts +38 -8
- package/dist/cloud/cloud-client.js +38 -21
- package/dist/cloud/connect.d.ts +101 -2
- package/dist/cloud/connect.js +84 -8
- package/dist/cloud/enrol-redirect.d.ts +195 -0
- package/dist/cloud/enrol-redirect.js +236 -0
- package/dist/cloud/index.d.ts +6 -2
- package/dist/cloud/index.js +7 -1
- package/dist/cloud/link-redirect.d.ts +85 -0
- package/dist/cloud/link-redirect.js +105 -0
- package/dist/crypto/internal.d.ts +15 -0
- package/dist/crypto/internal.js +18 -0
- package/dist/device-cert/device-cert.d.ts +85 -2
- package/dist/device-cert/device-cert.js +100 -9
- package/dist/device-cert/index.d.ts +2 -2
- package/dist/device-cert/index.js +1 -1
- package/dist/events/sse.d.ts +22 -2
- package/dist/events/sse.js +20 -11
- package/dist/http-client/http-types.d.ts +18 -0
- package/dist/http-client/web-client.js +21 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +1999 -536
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +11 -6
- package/dist/index.js +17 -3
- package/dist/index.mjs +1999 -536
- package/dist/index.mjs.map +4 -4
- package/dist/login/login.d.ts +11 -1
- package/dist/login/login.js +5 -4
- package/dist/login/session.d.ts +21 -5
- package/dist/login/session.js +7 -15
- package/dist/namespace-op/namespace-op.d.ts +14 -2
- package/dist/namespace-op/namespace-op.js +9 -5
- package/dist/relay/index.d.ts +3 -3
- package/dist/relay/index.js +1 -1
- package/dist/relay/nonce-source.d.ts +135 -0
- package/dist/relay/nonce-source.js +187 -19
- package/dist/relay/relay-client.d.ts +148 -5
- package/dist/relay/relay-client.js +187 -7
- package/dist/relay-attestation/attest-node-key.d.ts +66 -0
- package/dist/relay-attestation/attest-node-key.js +110 -0
- package/dist/relay-attestation/index.d.ts +1 -0
- package/dist/relay-attestation/index.js +1 -0
- package/dist/request-proof/authorizer.d.ts +48 -0
- package/dist/request-proof/authorizer.js +65 -0
- package/dist/request-proof/index.d.ts +4 -0
- package/dist/request-proof/index.js +2 -0
- package/dist/request-proof/request-proof.d.ts +56 -0
- package/dist/request-proof/request-proof.js +111 -0
- package/dist/rpc/index.d.ts +21 -0
- package/dist/rpc/index.js +20 -0
- package/dist/transport/index.d.ts +6 -0
- package/dist/transport/index.js +3 -0
- package/dist/transport/mero-client.d.ts +286 -0
- package/dist/transport/mero-client.js +379 -0
- package/dist/transport/relay-observer.d.ts +152 -0
- package/dist/transport/relay-observer.js +173 -0
- package/dist/transport/relay-transport.d.ts +73 -0
- package/dist/transport/relay-transport.js +94 -0
- package/dist/transport/types.d.ts +100 -0
- package/dist/transport/types.js +32 -0
- package/dist/warrant/governance-op.d.ts +47 -1
- package/dist/warrant/governance-op.js +62 -2
- package/dist/warrant/index.d.ts +2 -2
- package/dist/warrant/index.js +1 -1
- package/package.json +1 -1
package/dist/login/login.d.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* order is implicit in a `derive`, so nothing on that side forces anyone to
|
|
32
32
|
* notice this file depends on both. This module's test asserts the same vectors.
|
|
33
33
|
*/
|
|
34
|
+
import { type Signer } from '../signer/signer.js';
|
|
34
35
|
/**
|
|
35
36
|
* The client surface a session is bound to.
|
|
36
37
|
*
|
|
@@ -87,8 +88,17 @@ export interface LoginStatementInput {
|
|
|
87
88
|
*
|
|
88
89
|
* Never sent anywhere. It signs locally and only the signature travels, which
|
|
89
90
|
* is what lets a statement be minted by something holding no node.
|
|
91
|
+
*
|
|
92
|
+
* Mutually exclusive with {@link LoginStatementInput.signer}, and the weaker
|
|
93
|
+
* of the two in a browser: a secret that exists as a string can be read by
|
|
94
|
+
* anything running on the origin.
|
|
95
|
+
*/
|
|
96
|
+
deviceSecret?: string;
|
|
97
|
+
/**
|
|
98
|
+
* The device's signer — use instead of
|
|
99
|
+
* {@link LoginStatementInput.deviceSecret} when the key cannot be exported.
|
|
90
100
|
*/
|
|
91
|
-
|
|
101
|
+
signer?: Signer;
|
|
92
102
|
}
|
|
93
103
|
/**
|
|
94
104
|
* Sign a login statement and return it hex-encoded, ready to present with an
|
package/dist/login/login.js
CHANGED
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
* order is implicit in a `derive`, so nothing on that side forces anyone to
|
|
32
32
|
* notice this file depends on both. This module's test asserts the same vectors.
|
|
33
33
|
*/
|
|
34
|
-
import { concat,
|
|
34
|
+
import { concat, domainHash, fromHex, hex, u32le, u64le, } from '../crypto/internal.js';
|
|
35
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
35
36
|
const SIGN_DOMAIN = new TextEncoder().encode('calimero.auth.login.v1');
|
|
36
37
|
/** The borsh enum tag for each variant, in core's declaration order. */
|
|
37
38
|
const AUDIENCE_TAG = {
|
|
@@ -91,8 +92,8 @@ export async function signLoginStatement(input) {
|
|
|
91
92
|
const node = fromHex(input.node, 'node', 32);
|
|
92
93
|
const challenge = fromHex(input.challenge, 'challenge', 32);
|
|
93
94
|
const sessionKey = fromHex(input.sessionKey, 'sessionKey', 32);
|
|
94
|
-
const
|
|
95
|
-
const deviceKey =
|
|
95
|
+
const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
|
|
96
|
+
const deviceKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
96
97
|
const issuedAt = u64le(input.issuedAt);
|
|
97
98
|
const expiresAt = u64le(input.expiresAt);
|
|
98
99
|
const preimage = await domainHash(SIGN_DOMAIN, [
|
|
@@ -104,6 +105,6 @@ export async function signLoginStatement(input) {
|
|
|
104
105
|
issuedAt,
|
|
105
106
|
expiresAt,
|
|
106
107
|
]);
|
|
107
|
-
const signature =
|
|
108
|
+
const signature = await signer.sign(preimage);
|
|
108
109
|
return hex(concat(node, audienceWire(input.audience), challenge, sessionKey, deviceKey, issuedAt, expiresAt, signature));
|
|
109
110
|
}
|
package/dist/login/session.d.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* cannot be escalated into use of the key that authorised it.
|
|
32
32
|
*/
|
|
33
33
|
import { type Audience } from './login.js';
|
|
34
|
+
import { type Signer } from '../signer/signer.js';
|
|
34
35
|
/** A freshly minted ephemeral keypair, hex-encoded. */
|
|
35
36
|
export interface SessionKeyPair {
|
|
36
37
|
/** The public half — what the statement is signed over. */
|
|
@@ -59,8 +60,18 @@ export interface LoginConfig {
|
|
|
59
60
|
* module note.
|
|
60
61
|
*/
|
|
61
62
|
node: string;
|
|
62
|
-
/**
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The device's ed25519 signing secret, hex (32 bytes). Never sent.
|
|
65
|
+
*
|
|
66
|
+
* Mutually exclusive with {@link LoginConfig.signer}, and the weaker of the
|
|
67
|
+
* two in a browser — see {@link Signer}.
|
|
68
|
+
*/
|
|
69
|
+
deviceSecret?: string;
|
|
70
|
+
/**
|
|
71
|
+
* The device's signer — use instead of {@link LoginConfig.deviceSecret} when
|
|
72
|
+
* the key cannot be exported to hex.
|
|
73
|
+
*/
|
|
74
|
+
signer?: Signer;
|
|
64
75
|
/**
|
|
65
76
|
* The device's account proof, hex — the credential certifying that this
|
|
66
77
|
* device belongs to its account.
|
|
@@ -73,9 +84,14 @@ export interface LoginConfig {
|
|
|
73
84
|
/** The client surface this session is bound to. */
|
|
74
85
|
audience: Audience;
|
|
75
86
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
87
|
+
* The token request's `client_name`. Defaults to {@link LoginConfig.nodeUrl}.
|
|
88
|
+
*
|
|
89
|
+
* mero-auth records this value as the session's `node_url` and, on every
|
|
90
|
+
* later request, refuses one whose Host is not that URL's host ("Token is not
|
|
91
|
+
* valid for this host"). So it must name the node being logged in to — which
|
|
92
|
+
* is what core's own clients send. The audience is not a substitute: a browser
|
|
93
|
+
* page's origin is a different host from the relay it talks to, and a token
|
|
94
|
+
* bound to the page's host is refused on the first call.
|
|
79
95
|
*/
|
|
80
96
|
clientName?: string;
|
|
81
97
|
/** Seconds the statement stays valid. Defaults to {@link DEFAULT_TTL_SECONDS}. */
|
package/dist/login/session.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { HTTPError } from '../http-client/web-client.js';
|
|
34
34
|
import { derivePublicKey, hex } from '../crypto/internal.js';
|
|
35
35
|
import { signLoginStatement } from './login.js';
|
|
36
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
36
37
|
/** Seconds a statement stays valid unless told otherwise. */
|
|
37
38
|
const DEFAULT_TTL_SECONDS = 300;
|
|
38
39
|
/**
|
|
@@ -47,17 +48,6 @@ export async function generateSessionKey() {
|
|
|
47
48
|
const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
48
49
|
return { publicKey: hex(await derivePublicKey(secret)), secret };
|
|
49
50
|
}
|
|
50
|
-
/** The audience's own spelling, used as the default client name. */
|
|
51
|
-
function audienceLabel(audience) {
|
|
52
|
-
switch (audience.kind) {
|
|
53
|
-
case 'webOrigin':
|
|
54
|
-
return audience.origin;
|
|
55
|
-
case 'codeSigningId':
|
|
56
|
-
return audience.id;
|
|
57
|
-
case 'cli':
|
|
58
|
-
return 'cli';
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
51
|
/**
|
|
62
52
|
* Log in with a device key and return the session.
|
|
63
53
|
*
|
|
@@ -75,9 +65,9 @@ export async function login(config) {
|
|
|
75
65
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
76
66
|
let response;
|
|
77
67
|
try {
|
|
68
|
+
// Called as `globalThis.fetch(...)`, never through a detached reference:
|
|
69
|
+
// a browser's fetch throws "Illegal invocation" when its receiver is lost.
|
|
78
70
|
const requestInit = { ...init, signal: controller.signal };
|
|
79
|
-
// globalThis.fetch must be called as a method on globalThis, not through a
|
|
80
|
-
// variable, or browsers throw "Illegal invocation".
|
|
81
71
|
response = config.fetch
|
|
82
72
|
? await config.fetch(`${baseUrl}${path}`, requestInit)
|
|
83
73
|
: await globalThis.fetch(`${baseUrl}${path}`, requestInit);
|
|
@@ -113,7 +103,9 @@ export async function login(config) {
|
|
|
113
103
|
sessionKey: session.publicKey,
|
|
114
104
|
issuedAt,
|
|
115
105
|
expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS),
|
|
116
|
-
|
|
106
|
+
// Resolved once and handed on, so a config carrying a secret and a config
|
|
107
|
+
// carrying a `CryptoKey` take exactly the same path from here down.
|
|
108
|
+
signer: await resolveSigner(config.deviceSecret, config.signer, 'deviceSecret'),
|
|
117
109
|
});
|
|
118
110
|
// `timestamp` is required and the request refuses unknown fields, so a body
|
|
119
111
|
// missing it is rejected during deserialization — before the provider runs,
|
|
@@ -126,7 +118,7 @@ export async function login(config) {
|
|
|
126
118
|
body: JSON.stringify({
|
|
127
119
|
auth_method: 'account_proof',
|
|
128
120
|
public_key: session.publicKey,
|
|
129
|
-
client_name: config.clientName ??
|
|
121
|
+
client_name: config.clientName ?? baseUrl,
|
|
130
122
|
timestamp: issuedAt,
|
|
131
123
|
provider_data: {
|
|
132
124
|
challenge,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* op, which is what the e2e does.
|
|
15
15
|
*/
|
|
16
16
|
import type { SignedGroupOpenInvitation } from '../admin-api/admin-types.js';
|
|
17
|
+
import { type Signer } from '../signer/signer.js';
|
|
17
18
|
/**
|
|
18
19
|
* Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value.
|
|
19
20
|
*
|
|
@@ -55,8 +56,19 @@ export interface SignMemberJoinInput {
|
|
|
55
56
|
* when applying the join and refuses otherwise.
|
|
56
57
|
*/
|
|
57
58
|
readonly credential: string;
|
|
58
|
-
/**
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The device signing secret, 64 hex. Must match the credential's `sign_pk`.
|
|
61
|
+
*
|
|
62
|
+
* Mutually exclusive with {@link SignMemberJoinInput.signer}. A browser should
|
|
63
|
+
* prefer the signer: joining is the one governance op a keyholder signs for
|
|
64
|
+
* itself, and it should not need the key in a form script can read.
|
|
65
|
+
*/
|
|
66
|
+
readonly deviceSecret?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The joiner's signer — use instead of
|
|
69
|
+
* {@link SignMemberJoinInput.deviceSecret} when the key cannot be exported.
|
|
70
|
+
*/
|
|
71
|
+
readonly signer?: Signer;
|
|
60
72
|
/**
|
|
61
73
|
* Heads this op is written against. Empty means "signed against an empty head"
|
|
62
74
|
* — which is NOT the same as genesis, and a node with a non-empty head will
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { concat,
|
|
1
|
+
import { concat, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
|
|
2
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
2
3
|
/**
|
|
3
4
|
* Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value.
|
|
4
5
|
*
|
|
@@ -122,10 +123,13 @@ export async function signMemberJoinOp(input) {
|
|
|
122
123
|
? concat(new Uint8Array([ROOT_OP.MemberJoinedAt]), fromHex(input.member, 'member', 32), encodeSignedInvitation(input.invitation), u64le(joinedAt), credential)
|
|
123
124
|
: concat(new Uint8Array([ROOT_OP.MemberJoined]), fromHex(input.member, 'member', 32), encodeSignedInvitation(input.invitation), credential);
|
|
124
125
|
const op = concat(new Uint8Array([NAMESPACE_OP_ROOT]), rootOp);
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
const signingKey = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
|
|
127
|
+
// Named apart from the signer itself: this is the public half the op carries
|
|
128
|
+
// as its `signer` field, which every peer compares with the credential's
|
|
129
|
+
// `sign_pk` before applying the join.
|
|
130
|
+
const signerPublicKey = fromHex(signingKey.publicKey, 'signer.publicKey', 32);
|
|
131
|
+
const signable = concat(new Uint8Array([SIGNED_NAMESPACE_OP_SCHEMA_VERSION]), fromHex(input.namespaceId, 'namespaceId', 32), u32le(parents.length), ...parents.map((p, i) => fromHex(p, `parentOpHashes[${i}]`, 32)), signerPublicKey, u64le(input.nonce), op);
|
|
132
|
+
const signature = await signingKey.sign(concat(NAMESPACE_SIGN_DOMAIN, signable));
|
|
129
133
|
// The signed struct repeats the signable fields, then the signature, then the
|
|
130
134
|
// admitter endorsement. The domain is a signing prefix only and is never part
|
|
131
135
|
// of the encoded op.
|
package/dist/relay/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { RelayClient, IntentRefusedError } from './relay-client.js';
|
|
2
|
-
export type { RelayClientConfig, RelayDescription, IntentResult, CreationDescription, CreateContextInput, CreatedContext, GovernanceDescription, GovernInput, GovernResult, } from './relay-client.js';
|
|
3
|
-
export { createMemoryNonceSource, createLocalStorageNonceSource } from './nonce-source.js';
|
|
4
|
-
export type { NonceSource } from './nonce-source.js';
|
|
2
|
+
export type { RelayClientConfig, RelayDescription, IntentResult, CreationDescription, CreateContextInput, CreatedContext, GovernanceDescription, GovernInput, GovernResult, FoundNamespaceInput, FoundedNamespace, } from './relay-client.js';
|
|
3
|
+
export { createMemoryNonceSource, createLocalStorageNonceSource, createRecoveringNonceSource, authorNonceLookup, WarrantNonceExhaustedError, } from './nonce-source.js';
|
|
4
|
+
export type { NonceSource, WarrantNonceLookup, RecoveringNonceSourceOptions, } from './nonce-source.js';
|
package/dist/relay/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { RelayClient, IntentRefusedError } from './relay-client.js';
|
|
2
|
-
export { createMemoryNonceSource, createLocalStorageNonceSource } from './nonce-source.js';
|
|
2
|
+
export { createMemoryNonceSource, createLocalStorageNonceSource, createRecoveringNonceSource, authorNonceLookup, WarrantNonceExhaustedError, } from './nonce-source.js';
|
|
@@ -22,9 +22,22 @@
|
|
|
22
22
|
* is the one you have to ask for. It is also why a source may jump: reserving a
|
|
23
23
|
* block and losing the tail is the correct trade.
|
|
24
24
|
*/
|
|
25
|
+
import type { WarrantNonceState } from '../admin-api/admin-types.js';
|
|
25
26
|
/** Produces the next nonce for one author device. Must never go backwards. */
|
|
26
27
|
export interface NonceSource {
|
|
27
28
|
next(): Promise<bigint>;
|
|
29
|
+
/**
|
|
30
|
+
* Raise the floor of the sequence to `floor`, if this source can record it.
|
|
31
|
+
*
|
|
32
|
+
* Optional, and additive: a source that does not implement it still works
|
|
33
|
+
* everywhere a `NonceSource` is taken, and {@link createRecoveringNonceSource}
|
|
34
|
+
* keeps the floor itself in that case. Implementing it is what lets a
|
|
35
|
+
* *persistent* source remember a jump, so a later cold start that cannot
|
|
36
|
+
* reach a node resumes above the mark rather than below it.
|
|
37
|
+
*
|
|
38
|
+
* Never lowers: a smaller `floor` than the source's own position is ignored.
|
|
39
|
+
*/
|
|
40
|
+
advanceTo?(floor: bigint): Promise<void>;
|
|
28
41
|
}
|
|
29
42
|
/**
|
|
30
43
|
* A counter in memory, starting at `start`.
|
|
@@ -58,3 +71,125 @@ export declare function createLocalStorageNonceSource(key: string, storage?: {
|
|
|
58
71
|
getItem(k: string): string | null;
|
|
59
72
|
setItem(k: string, v: string): void;
|
|
60
73
|
}): NonceSource;
|
|
74
|
+
/** A node (or anything else) that can answer where a device's sequence stands. */
|
|
75
|
+
export interface WarrantNonceLookup {
|
|
76
|
+
getWarrantNonce(contextId: string, authorDeviceKey: string): Promise<WarrantNonceState>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A lookup a delegated client can actually use.
|
|
80
|
+
*
|
|
81
|
+
* {@link WarrantNonceLookup} names the device key because the admin route takes
|
|
82
|
+
* it in the path. The delegated route does not: it reads the key from the
|
|
83
|
+
* certificate it verifies, so the only way to ask is to present that
|
|
84
|
+
* certificate. This adapter bridges the two — it satisfies the interface, and
|
|
85
|
+
* ignores the device key argument because the credential already determines it.
|
|
86
|
+
*
|
|
87
|
+
* That the argument is ignored is not an oversight to tidy away. The whole
|
|
88
|
+
* point of the delegated route is that the device key is not caller-supplied:
|
|
89
|
+
* a key passed alongside the proof would be a second spelling of a fact the
|
|
90
|
+
* signature already fixes, and two spellings can differ. The adapter therefore
|
|
91
|
+
* has nothing to check it against, and pretending otherwise would invent a
|
|
92
|
+
* comparison the protocol deliberately does not make.
|
|
93
|
+
*
|
|
94
|
+
* Use with {@link createRecoveringNonceSource} exactly as an `AdminApiClient`
|
|
95
|
+
* would be used — on a relay, this is the form that works.
|
|
96
|
+
*/
|
|
97
|
+
export declare function authorNonceLookup(options: {
|
|
98
|
+
/** Anything that can reach the node's admin path; an `AdminApiClient` does. */
|
|
99
|
+
client: {
|
|
100
|
+
getWarrantNonceAsAuthor(contextId: string, authorProof: string): Promise<WarrantNonceState>;
|
|
101
|
+
};
|
|
102
|
+
/** Hex borsh `AccountProof<DeviceCert>` — the same one the relay client sends. */
|
|
103
|
+
authorProof: string;
|
|
104
|
+
}): WarrantNonceLookup;
|
|
105
|
+
/** Thrown when every node asked says this device has spent `u64::MAX` here. */
|
|
106
|
+
export declare class WarrantNonceExhaustedError extends Error {
|
|
107
|
+
readonly contextId: string;
|
|
108
|
+
readonly authorDeviceKey: string;
|
|
109
|
+
name: string;
|
|
110
|
+
constructor(contextId: string, authorDeviceKey: string);
|
|
111
|
+
}
|
|
112
|
+
/** What {@link createRecoveringNonceSource} needs to know. */
|
|
113
|
+
export interface RecoveringNonceSourceOptions {
|
|
114
|
+
/**
|
|
115
|
+
* The node(s) to ask. Pass an {@link AdminApiClient} — it satisfies this
|
|
116
|
+
* structurally — or several, one per relay this author writes through.
|
|
117
|
+
*
|
|
118
|
+
* With more than one, the **highest** answer wins. Nonce state folds per peer,
|
|
119
|
+
* so a node can only be behind a client whose warrants it has not applied,
|
|
120
|
+
* never ahead of the truth; taking the highest is therefore the only reading
|
|
121
|
+
* that cannot hand out a nonce somebody has already spent.
|
|
122
|
+
*/
|
|
123
|
+
lookups: WarrantNonceLookup | readonly WarrantNonceLookup[];
|
|
124
|
+
contextId: string;
|
|
125
|
+
/** The author device's SIGNING key, base58 or hex. Not its `deviceId`. */
|
|
126
|
+
authorDeviceKey: string;
|
|
127
|
+
/**
|
|
128
|
+
* The client's own counter, folded in with `max`. Defaults to an in-memory
|
|
129
|
+
* one starting at 1.
|
|
130
|
+
*
|
|
131
|
+
* Pass {@link createLocalStorageNonceSource} to keep both halves: the local
|
|
132
|
+
* counter carries the sequence forward between calls, the node supplies the
|
|
133
|
+
* floor when that counter has been cleared.
|
|
134
|
+
*/
|
|
135
|
+
local?: NonceSource;
|
|
136
|
+
/**
|
|
137
|
+
* What to do when no node can be reached on the cold-start lookup.
|
|
138
|
+
*
|
|
139
|
+
* `'throw'` (the default) refuses to mint. That is the conservative reading:
|
|
140
|
+
* a source that quietly fell back to a cleared local counter would replay
|
|
141
|
+
* spent nonces, which is the exact failure this exists to prevent, and it
|
|
142
|
+
* would do it invisibly. `'local'` mints from the local counter anyway —
|
|
143
|
+
* correct only when that counter is known to be intact and the app would
|
|
144
|
+
* rather write offline than not at all.
|
|
145
|
+
*/
|
|
146
|
+
onLookupFailure?: 'throw' | 'local';
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A nonce source that asks the node where the sequence stands before its first
|
|
150
|
+
* mint, then carries on locally.
|
|
151
|
+
*
|
|
152
|
+
* # Why this is not the default
|
|
153
|
+
*
|
|
154
|
+
* It costs a round trip and an admin credential, and an author that never lost
|
|
155
|
+
* its counter gains nothing from it. It earns its place in exactly one
|
|
156
|
+
* situation, which is common rather than exotic: a client whose local state is
|
|
157
|
+
* gone. A browser keyholder lives in storage that is partitioned per top-level
|
|
158
|
+
* site and cleared on a schedule it does not control, so for it, losing the
|
|
159
|
+
* counter is the normal lifecycle. Without this, such a client either restarts
|
|
160
|
+
* at 1 — replaying every nonce it already spent, each write refused, which
|
|
161
|
+
* reads to the user as the app silently not saving — or guesses upward, one
|
|
162
|
+
* burnt round trip per wrong guess.
|
|
163
|
+
*
|
|
164
|
+
* # What it does
|
|
165
|
+
*
|
|
166
|
+
* The lookup happens once, lazily, on the first {@link NonceSource.next}: a
|
|
167
|
+
* source that is constructed and never used costs nothing, and an app that
|
|
168
|
+
* builds its relay client at startup does not pay a network call for it. The
|
|
169
|
+
* answer becomes a **floor**, and every nonce handed out afterwards is
|
|
170
|
+
* `max(localCounter, floor)`. It never goes backwards from either input, which
|
|
171
|
+
* is the one property a nonce source owes its caller.
|
|
172
|
+
*
|
|
173
|
+
* If the local counter is the one that is ahead, it wins — the node may simply
|
|
174
|
+
* not have applied this client's recent warrants yet.
|
|
175
|
+
*
|
|
176
|
+
* The floor is also pushed into the local source through
|
|
177
|
+
* {@link NonceSource.advanceTo} when it has one, so a persistent counter
|
|
178
|
+
* remembers the jump.
|
|
179
|
+
*
|
|
180
|
+
* # Exhaustion
|
|
181
|
+
*
|
|
182
|
+
* A node reporting no `nextNonce` means `u64::MAX` is spent and this device can
|
|
183
|
+
* never write here again. `next()` rejects with
|
|
184
|
+
* {@link WarrantNonceExhaustedError} rather than guessing, because every guess
|
|
185
|
+
* from there is refused forever.
|
|
186
|
+
*
|
|
187
|
+
* # On an older node
|
|
188
|
+
*
|
|
189
|
+
* The route is new and absent from core `master` builds, which answer 404 —
|
|
190
|
+
* {@link AdminApiClient.getWarrantNonce} surfaces that as
|
|
191
|
+
* `WarrantNonceRouteUnavailableError`. It reaches the caller as a lookup
|
|
192
|
+
* failure, so `onLookupFailure` decides: refuse to mint, or fall back to the
|
|
193
|
+
* local counter.
|
|
194
|
+
*/
|
|
195
|
+
export declare function createRecoveringNonceSource(options: RecoveringNonceSourceOptions): NonceSource;
|
|
@@ -41,6 +41,11 @@ export function createMemoryNonceSource(start = 1) {
|
|
|
41
41
|
next += 1n;
|
|
42
42
|
return Promise.resolve(value);
|
|
43
43
|
},
|
|
44
|
+
advanceTo(floor) {
|
|
45
|
+
if (floor > next)
|
|
46
|
+
next = floor;
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
},
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
/**
|
|
@@ -67,29 +72,192 @@ export function createLocalStorageNonceSource(key, storage) {
|
|
|
67
72
|
if (!store) {
|
|
68
73
|
throw new Error('no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence');
|
|
69
74
|
}
|
|
75
|
+
// A missing, empty, or corrupted value means "no sequence recorded", and the
|
|
76
|
+
// safe reading of that is not 1 — a cleared storage on a device that has
|
|
77
|
+
// already written would replay. But the client cannot know, and 1 is the only
|
|
78
|
+
// defensible start, so it is what a fresh key gets. This is why the doc above
|
|
79
|
+
// tells apps to key per device: a device whose storage was cleared should be
|
|
80
|
+
// re-paired rather than resumed — or handed a recovering source, which asks
|
|
81
|
+
// the node what it already spent instead of guessing.
|
|
82
|
+
const lastHandedOut = () => {
|
|
83
|
+
const raw = store.getItem(key);
|
|
84
|
+
if (raw === null || raw === '')
|
|
85
|
+
return 0n;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = BigInt(raw);
|
|
88
|
+
return parsed > 0n ? parsed : 0n;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return 0n;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
70
94
|
return {
|
|
71
95
|
next() {
|
|
72
|
-
const
|
|
73
|
-
// A missing, empty, or corrupted value means "no sequence recorded", and
|
|
74
|
-
// the safe reading of that is not 1 — a cleared storage on a device that
|
|
75
|
-
// has already written would replay. But the client cannot know, and 1 is
|
|
76
|
-
// the only defensible start, so it is what a fresh key gets. This is why
|
|
77
|
-
// the doc above tells apps to key per device: a device whose storage was
|
|
78
|
-
// cleared should be re-paired rather than resumed.
|
|
79
|
-
let current = 0n;
|
|
80
|
-
if (raw !== null && raw !== '') {
|
|
81
|
-
try {
|
|
82
|
-
const parsed = BigInt(raw);
|
|
83
|
-
if (parsed > 0n)
|
|
84
|
-
current = parsed;
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
current = 0n;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const value = current + 1n;
|
|
96
|
+
const value = lastHandedOut() + 1n;
|
|
91
97
|
store.setItem(key, value.toString());
|
|
92
98
|
return Promise.resolve(value);
|
|
93
99
|
},
|
|
100
|
+
advanceTo(floor) {
|
|
101
|
+
// Stored is the last number handed out, so the floor is recorded as the
|
|
102
|
+
// one below it — persisted rather than held in memory, which is the whole
|
|
103
|
+
// point: the next cold start resumes above the jump even if the node
|
|
104
|
+
// cannot be reached then.
|
|
105
|
+
const wanted = floor > 0n ? floor - 1n : 0n;
|
|
106
|
+
if (wanted > lastHandedOut())
|
|
107
|
+
store.setItem(key, wanted.toString());
|
|
108
|
+
return Promise.resolve();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A lookup a delegated client can actually use.
|
|
114
|
+
*
|
|
115
|
+
* {@link WarrantNonceLookup} names the device key because the admin route takes
|
|
116
|
+
* it in the path. The delegated route does not: it reads the key from the
|
|
117
|
+
* certificate it verifies, so the only way to ask is to present that
|
|
118
|
+
* certificate. This adapter bridges the two — it satisfies the interface, and
|
|
119
|
+
* ignores the device key argument because the credential already determines it.
|
|
120
|
+
*
|
|
121
|
+
* That the argument is ignored is not an oversight to tidy away. The whole
|
|
122
|
+
* point of the delegated route is that the device key is not caller-supplied:
|
|
123
|
+
* a key passed alongside the proof would be a second spelling of a fact the
|
|
124
|
+
* signature already fixes, and two spellings can differ. The adapter therefore
|
|
125
|
+
* has nothing to check it against, and pretending otherwise would invent a
|
|
126
|
+
* comparison the protocol deliberately does not make.
|
|
127
|
+
*
|
|
128
|
+
* Use with {@link createRecoveringNonceSource} exactly as an `AdminApiClient`
|
|
129
|
+
* would be used — on a relay, this is the form that works.
|
|
130
|
+
*/
|
|
131
|
+
export function authorNonceLookup(options) {
|
|
132
|
+
return {
|
|
133
|
+
getWarrantNonce: (contextId, _authorDeviceKey) => options.client.getWarrantNonceAsAuthor(contextId, options.authorProof),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Thrown when every node asked says this device has spent `u64::MAX` here. */
|
|
137
|
+
export class WarrantNonceExhaustedError extends Error {
|
|
138
|
+
constructor(contextId, authorDeviceKey) {
|
|
139
|
+
super(`author device ${authorDeviceKey} has spent every nonce in context ${contextId}; it must re-key to write again`);
|
|
140
|
+
this.contextId = contextId;
|
|
141
|
+
this.authorDeviceKey = authorDeviceKey;
|
|
142
|
+
this.name = 'WarrantNonceExhaustedError';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A nonce source that asks the node where the sequence stands before its first
|
|
147
|
+
* mint, then carries on locally.
|
|
148
|
+
*
|
|
149
|
+
* # Why this is not the default
|
|
150
|
+
*
|
|
151
|
+
* It costs a round trip and an admin credential, and an author that never lost
|
|
152
|
+
* its counter gains nothing from it. It earns its place in exactly one
|
|
153
|
+
* situation, which is common rather than exotic: a client whose local state is
|
|
154
|
+
* gone. A browser keyholder lives in storage that is partitioned per top-level
|
|
155
|
+
* site and cleared on a schedule it does not control, so for it, losing the
|
|
156
|
+
* counter is the normal lifecycle. Without this, such a client either restarts
|
|
157
|
+
* at 1 — replaying every nonce it already spent, each write refused, which
|
|
158
|
+
* reads to the user as the app silently not saving — or guesses upward, one
|
|
159
|
+
* burnt round trip per wrong guess.
|
|
160
|
+
*
|
|
161
|
+
* # What it does
|
|
162
|
+
*
|
|
163
|
+
* The lookup happens once, lazily, on the first {@link NonceSource.next}: a
|
|
164
|
+
* source that is constructed and never used costs nothing, and an app that
|
|
165
|
+
* builds its relay client at startup does not pay a network call for it. The
|
|
166
|
+
* answer becomes a **floor**, and every nonce handed out afterwards is
|
|
167
|
+
* `max(localCounter, floor)`. It never goes backwards from either input, which
|
|
168
|
+
* is the one property a nonce source owes its caller.
|
|
169
|
+
*
|
|
170
|
+
* If the local counter is the one that is ahead, it wins — the node may simply
|
|
171
|
+
* not have applied this client's recent warrants yet.
|
|
172
|
+
*
|
|
173
|
+
* The floor is also pushed into the local source through
|
|
174
|
+
* {@link NonceSource.advanceTo} when it has one, so a persistent counter
|
|
175
|
+
* remembers the jump.
|
|
176
|
+
*
|
|
177
|
+
* # Exhaustion
|
|
178
|
+
*
|
|
179
|
+
* A node reporting no `nextNonce` means `u64::MAX` is spent and this device can
|
|
180
|
+
* never write here again. `next()` rejects with
|
|
181
|
+
* {@link WarrantNonceExhaustedError} rather than guessing, because every guess
|
|
182
|
+
* from there is refused forever.
|
|
183
|
+
*
|
|
184
|
+
* # On an older node
|
|
185
|
+
*
|
|
186
|
+
* The route is new and absent from core `master` builds, which answer 404 —
|
|
187
|
+
* {@link AdminApiClient.getWarrantNonce} surfaces that as
|
|
188
|
+
* `WarrantNonceRouteUnavailableError`. It reaches the caller as a lookup
|
|
189
|
+
* failure, so `onLookupFailure` decides: refuse to mint, or fall back to the
|
|
190
|
+
* local counter.
|
|
191
|
+
*/
|
|
192
|
+
export function createRecoveringNonceSource(options) {
|
|
193
|
+
const lookups = Array.isArray(options.lookups)
|
|
194
|
+
? options.lookups
|
|
195
|
+
: [options.lookups];
|
|
196
|
+
if (lookups.length === 0) {
|
|
197
|
+
throw new Error('a recovering nonce source needs at least one node to ask');
|
|
198
|
+
}
|
|
199
|
+
const local = options.local ?? createMemoryNonceSource(1);
|
|
200
|
+
const onLookupFailure = options.onLookupFailure ?? 'throw';
|
|
201
|
+
let floor = null;
|
|
202
|
+
// Memoised so concurrent first calls ask once. Cleared on failure, since a
|
|
203
|
+
// node that was unreachable a moment ago may answer the next call — caching
|
|
204
|
+
// the failure would make one flaky request poison the whole session.
|
|
205
|
+
let pending = null;
|
|
206
|
+
const resolveFloor = async () => {
|
|
207
|
+
const answers = await Promise.allSettled(lookups.map((lookup) => lookup.getWarrantNonce(options.contextId, options.authorDeviceKey)));
|
|
208
|
+
let highest = null;
|
|
209
|
+
let exhausted = false;
|
|
210
|
+
for (const answer of answers) {
|
|
211
|
+
if (answer.status !== 'fulfilled')
|
|
212
|
+
continue;
|
|
213
|
+
const state = answer.value;
|
|
214
|
+
if (state.kind === 'exhausted') {
|
|
215
|
+
exhausted = true;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (highest === null || state.nextNonce > highest)
|
|
219
|
+
highest = state.nextNonce;
|
|
220
|
+
}
|
|
221
|
+
// Exhaustion is only decisive when nothing usable came back. One node
|
|
222
|
+
// saturated while another still has room is not a thing the ledger can
|
|
223
|
+
// produce — the sequence is the device's, not the node's — but if it ever
|
|
224
|
+
// is, the usable answer is the actionable one.
|
|
225
|
+
if (highest === null && exhausted) {
|
|
226
|
+
throw new WarrantNonceExhaustedError(options.contextId, options.authorDeviceKey);
|
|
227
|
+
}
|
|
228
|
+
if (highest === null) {
|
|
229
|
+
const first = answers.find((a) => a.status === 'rejected');
|
|
230
|
+
const reason = first && first.status === 'rejected' ? first.reason : undefined;
|
|
231
|
+
if (onLookupFailure === 'local') {
|
|
232
|
+
floor = 0n;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
throw reason instanceof Error
|
|
236
|
+
? reason
|
|
237
|
+
: new Error(`no node could answer where the warrant-nonce sequence stands: ${String(reason)}`);
|
|
238
|
+
}
|
|
239
|
+
floor = highest;
|
|
240
|
+
await local.advanceTo?.(highest);
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
async next() {
|
|
244
|
+
if (floor === null) {
|
|
245
|
+
pending ?? (pending = resolveFloor().finally(() => {
|
|
246
|
+
pending = null;
|
|
247
|
+
}));
|
|
248
|
+
await pending;
|
|
249
|
+
}
|
|
250
|
+
const mine = await local.next();
|
|
251
|
+
const value = floor !== null && floor > mine ? floor : mine;
|
|
252
|
+
// Advance the floor past what was just handed out, so a local source that
|
|
253
|
+
// is behind cannot re-offer this number on the next call.
|
|
254
|
+
floor = value + 1n;
|
|
255
|
+
return value;
|
|
256
|
+
},
|
|
257
|
+
async advanceTo(wanted) {
|
|
258
|
+
if (floor === null || wanted > floor)
|
|
259
|
+
floor = wanted;
|
|
260
|
+
await local.advanceTo?.(wanted);
|
|
261
|
+
},
|
|
94
262
|
};
|
|
95
263
|
}
|