@openlfcp/client 0.1.0-rc.1
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/LICENSE +201 -0
- package/README.md +27 -0
- package/dist/apply.d.ts +299 -0
- package/dist/apply.js +741 -0
- package/dist/checkpoint.d.ts +31 -0
- package/dist/checkpoint.js +50 -0
- package/dist/connection.d.ts +87 -0
- package/dist/connection.js +210 -0
- package/dist/data-unit.d.ts +50 -0
- package/dist/data-unit.js +39 -0
- package/dist/engine-guard.d.ts +58 -0
- package/dist/engine-guard.js +148 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/invite.d.ts +154 -0
- package/dist/invite.js +391 -0
- package/dist/outbound.d.ts +227 -0
- package/dist/outbound.js +508 -0
- package/dist/queue.d.ts +38 -0
- package/dist/queue.js +116 -0
- package/dist/resource-state.d.ts +34 -0
- package/dist/resource-state.js +24 -0
- package/dist/snapshot.d.ts +42 -0
- package/dist/snapshot.js +40 -0
- package/dist/storage.d.ts +96 -0
- package/dist/storage.js +289 -0
- package/dist/sync-client.d.ts +203 -0
- package/dist/sync-client.js +1032 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Name of this package. */
|
|
2
|
+
export declare const PACKAGE = "@openlfcp/client";
|
|
3
|
+
export { type ApplyOutcome, type DataProfileHandler, DataUnitApplier, type DataUnitApplierOptions, type EpochReconciliation, type EquivocationOutcome, type ExcludedUnit, type ProfileApplyResult, type ProfileDiagnostic, type ProfileExcludeResult, type ProfileUnit, } from "./apply.js";
|
|
4
|
+
export { type CheckpointSource, ProfileCheckpointer } from "./checkpoint.js";
|
|
5
|
+
export { type ConnectionEvents, type ConnectionOptions, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, type WebSocketFactory, type WebSocketLike, } from "./connection.js";
|
|
6
|
+
export { type CreateDataUnitOptions, type CreatedDataUnit, createDataUnit } from "./data-unit.js";
|
|
7
|
+
export { EngineGuard, type EngineItem, isEngineTrap, type Suspicion, snapshotItem, unitItem, } from "./engine-guard.js";
|
|
8
|
+
export { type AcceptedInvitation, type AcceptInvitationOptions, type AcceptInvitationProgress, type AcceptInvitationStage, acceptInvitation, type CreatedInvitation, type CreateInvitationOptions, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, } from "./invite.js";
|
|
9
|
+
export { type AckOutcome, type BlockedItem, exponentialBackoff, type NackOutcome, type OutboundMessage, OutboundQueue, type OutboundQueueOptions, type ResourceSyncState, type RetryPolicy, type RetryReason, resourceSyncState, type StaleOutboundUnit, snapshotFrontier, } from "./outbound.js";
|
|
10
|
+
export { createQueuedSnapshot, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
|
|
11
|
+
export { type ResourcePhase, type ResourcePhaseEvent, resourcePhaseTransition, } from "./resource-state.js";
|
|
12
|
+
export { type CreatedSnapshot, type CreateSnapshotOptions, createSnapshot } from "./snapshot.js";
|
|
13
|
+
export { adoptStoredDeks, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
14
|
+
export { defaultReconnect, type ReconnectPolicy, type ResourceBinding, type SnapshotBinding, SyncClient, type SyncClientOptions, type SyncEvent, startSyncDriver, } from "./sync-client.js";
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Name of this package. */
|
|
2
|
+
export const PACKAGE = "@openlfcp/client";
|
|
3
|
+
export { DataUnitApplier, } from "./apply.js";
|
|
4
|
+
export { ProfileCheckpointer } from "./checkpoint.js";
|
|
5
|
+
export { LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, } from "./connection.js";
|
|
6
|
+
export { createDataUnit } from "./data-unit.js";
|
|
7
|
+
export { EngineGuard, isEngineTrap, snapshotItem, unitItem, } from "./engine-guard.js";
|
|
8
|
+
export { acceptInvitation, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, } from "./invite.js";
|
|
9
|
+
export { exponentialBackoff, OutboundQueue, resourceSyncState, snapshotFrontier, } from "./outbound.js";
|
|
10
|
+
export { createQueuedSnapshot, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
|
|
11
|
+
export { resourcePhaseTransition, } from "./resource-state.js";
|
|
12
|
+
export { createSnapshot } from "./snapshot.js";
|
|
13
|
+
export { adoptStoredDeks, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
14
|
+
export { defaultReconnect, SyncClient, startSyncDriver, } from "./sync-client.js";
|
|
15
|
+
//# sourceMappingURL=index.js.map
|
package/dist/invite.d.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { type ControlRecordId, type DataEpoch, type Hash32, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type AgreementKeyPair, InvitationSecret, type ResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import { type LfcpStorage, type SecretStore } from "@openlfcp/storage";
|
|
4
|
+
import { type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
|
|
5
|
+
import { type WebSocketFactory } from "./connection.js";
|
|
6
|
+
/**
|
|
7
|
+
* Link invitations and the one-time capability claim (LFCP-WIRE-01 §18,
|
|
8
|
+
* §18.1, §18.2, §25.2, §73; LFCP-053), built on the invitation codec of
|
|
9
|
+
* @openlfcp/wire (LFCP-039b) and this package's queue and storage.
|
|
10
|
+
*
|
|
11
|
+
* - createInvitation (the inviter): a fresh Invitation Principal, a
|
|
12
|
+
* CAPABILITY_GRANT to it with an explicit claim_limit (a grant without
|
|
13
|
+
* one is not claimable, §18), and a Key Package of the current epoch's
|
|
14
|
+
* DEK sealed to it at the grant's head, both checked locally and queued
|
|
15
|
+
* for the coordinator; and the bearer lfcp://join link.
|
|
16
|
+
* - acceptInvitation (the joiner), in the order of §73: authenticate and
|
|
17
|
+
* open the Resource as the Invitation Principal, fetch the Control
|
|
18
|
+
* Chain, verify that the secret's Principal is the grant's subject
|
|
19
|
+
* (§18.2) before the secret is used for anything else, open the
|
|
20
|
+
* invitation Key Package for the DEK (§25.2: the invitation exception),
|
|
21
|
+
* and submit a CAPABILITY_CLAIM signed by the Invitation Principal with
|
|
22
|
+
* CONTROL_PUT at the current head (§47). The coordinator serializes
|
|
23
|
+
* claims (§18.1 rule 6, §18.3): a claim that loses gets
|
|
24
|
+
* CONTROL_HEAD_MISMATCH, is rebuilt once on the refreshed chain, and the
|
|
25
|
+
* coordinator's answer to that is final (AUTHORIZATION_FAILED once the
|
|
26
|
+
* invitation is used up). The client does not pre-judge claimability:
|
|
27
|
+
* the coordinator is the serialization point.
|
|
28
|
+
*
|
|
29
|
+
* After a successful claim the claimant's storage holds the validated
|
|
30
|
+
* chain with its new grant and the secrets hold the epoch's DEK, so a
|
|
31
|
+
* SyncClient for the claimant opens and synchronizes the Resource as the
|
|
32
|
+
* claimant. §73 delivers the DEK through the Invitation Principal's
|
|
33
|
+
* package and draws no package to the claimant, so none is needed to
|
|
34
|
+
* join; later epochs reach the claimant like any holder of data/read.
|
|
35
|
+
*
|
|
36
|
+
* Secrets: a bearer link is a key. InvitationLink and InvitationSecret
|
|
37
|
+
* print "[redacted]"; nothing here logs; no error or result carries a
|
|
38
|
+
* URI, a secret or a DEK.
|
|
39
|
+
*/
|
|
40
|
+
/** A bearer invitation URI, redacted when printed or serialized; reveal() is the only way to read it. */
|
|
41
|
+
export declare class InvitationLink {
|
|
42
|
+
#private;
|
|
43
|
+
constructor(uri: string);
|
|
44
|
+
/** The lfcp://join URI with its #secret= fragment. Hand it to the invitee only; never log it. */
|
|
45
|
+
reveal(): string;
|
|
46
|
+
toJSON(): string;
|
|
47
|
+
toString(): string;
|
|
48
|
+
}
|
|
49
|
+
/** The usual invitation grant (§18): data/read, data/write, invite/claim. */
|
|
50
|
+
export declare const DEFAULT_INVITATION_ABILITIES: readonly bigint[];
|
|
51
|
+
export interface CreateInvitationOptions {
|
|
52
|
+
readonly storage: Pick<LfcpStorage, "control" | "commit">;
|
|
53
|
+
readonly resourceId: ResourceId;
|
|
54
|
+
/** The issuer: needs the authority to grant the abilities (§17.2) and key/distribute (§25.2). */
|
|
55
|
+
readonly inviter: Signer;
|
|
56
|
+
/** The DEK of the current Data Epoch; checked against the epoch's commitment. */
|
|
57
|
+
readonly dek: ResourceDEK;
|
|
58
|
+
/** The endpoint URLs the link names (§18.2), in order; at least one. */
|
|
59
|
+
readonly endpoints: readonly string[];
|
|
60
|
+
/** Must include invite/claim; default data/read, data/write, invite/claim. */
|
|
61
|
+
readonly abilities?: readonly bigint[];
|
|
62
|
+
/** How many claims the grant allows; default 1, at least 1. */
|
|
63
|
+
readonly claimLimit?: bigint;
|
|
64
|
+
readonly delegable?: readonly bigint[];
|
|
65
|
+
/** The Invitation Principal; default a fresh one. */
|
|
66
|
+
readonly secret?: InvitationSecret;
|
|
67
|
+
}
|
|
68
|
+
export interface CreatedInvitation {
|
|
69
|
+
readonly link: InvitationLink;
|
|
70
|
+
/** The invitation grant's Control Record ID, queued for CONTROL_PUT. */
|
|
71
|
+
readonly grantId: ControlRecordId;
|
|
72
|
+
readonly invitationPrincipal: PrincipalDescriptor;
|
|
73
|
+
/** The queued Key Package of the current epoch, sealed to the Invitation Principal. */
|
|
74
|
+
readonly keyPackageId: Hash32;
|
|
75
|
+
readonly epoch: DataEpoch;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The inviter's side (§18, §73). The grant must validate on the stored
|
|
79
|
+
* chain and the package must pass §25.2 at the grant's head, both checked
|
|
80
|
+
* before anything is queued; the grant is queued before the package.
|
|
81
|
+
* Share the link once both are ACKed: until then the grant is not on the
|
|
82
|
+
* coordinator's chain.
|
|
83
|
+
*/
|
|
84
|
+
export declare function createInvitation(options: CreateInvitationOptions): Promise<CreatedInvitation>;
|
|
85
|
+
export interface AcceptInvitationOptions {
|
|
86
|
+
/** The bearer link: an InvitationLink, or its revealed URI. */
|
|
87
|
+
readonly link: InvitationLink | string;
|
|
88
|
+
/** The claimant: receives the grant and later synchronizes as itself. */
|
|
89
|
+
readonly claimant: {
|
|
90
|
+
readonly signer: Signer;
|
|
91
|
+
readonly agreement: AgreementKeyPair;
|
|
92
|
+
};
|
|
93
|
+
/** The claimant's storage and secrets: on "claimed" they hold the chain and the DEK. */
|
|
94
|
+
readonly storage: Pick<LfcpStorage, "control" | "commit">;
|
|
95
|
+
readonly secrets: SecretStore;
|
|
96
|
+
/** Abilities to claim; default the grant's, without invite/claim unless it is delegable (§18.1 rule 4). */
|
|
97
|
+
readonly abilities?: readonly bigint[];
|
|
98
|
+
/** The endpoint to use; default the link's first. */
|
|
99
|
+
readonly url?: string;
|
|
100
|
+
readonly now: () => number;
|
|
101
|
+
readonly webSocket?: WebSocketFactory;
|
|
102
|
+
/** When it settles, the attempt gives up: the caller's timer (e.g. a 30 s sleep). */
|
|
103
|
+
readonly timeout?: Promise<unknown>;
|
|
104
|
+
/**
|
|
105
|
+
* Called as each §73 step starts, for a join dialog. The payload names
|
|
106
|
+
* the step only: never a link, a secret, a key or a DEK. Synchronizing
|
|
107
|
+
* comes after: a SyncClient for the claimant does it.
|
|
108
|
+
*/
|
|
109
|
+
readonly onProgress?: (progress: AcceptInvitationProgress) => void;
|
|
110
|
+
}
|
|
111
|
+
/** The §73 steps of acceptInvitation, in order. */
|
|
112
|
+
export type AcceptInvitationStage = "connecting" | "validating-invitation" | "retrieving-key" | "claiming-capability";
|
|
113
|
+
export interface AcceptInvitationProgress {
|
|
114
|
+
readonly stage: AcceptInvitationStage;
|
|
115
|
+
/** "claiming-capability" only: 1, or 2 when the claim is rebuilt after CONTROL_HEAD_MISMATCH. */
|
|
116
|
+
readonly attempt?: number;
|
|
117
|
+
}
|
|
118
|
+
export type AcceptedInvitation = {
|
|
119
|
+
readonly kind: "claimed";
|
|
120
|
+
readonly resourceId: ResourceId;
|
|
121
|
+
/** The claimant's grant: the claim record's ID (§17.2, §18.1). */
|
|
122
|
+
readonly grantId: ControlRecordId;
|
|
123
|
+
readonly abilities: readonly bigint[];
|
|
124
|
+
/** The epochs whose DEK the claimant now holds. */
|
|
125
|
+
readonly epochs: readonly DataEpoch[];
|
|
126
|
+
/** The CONTROL_PUT answers in order, e.g. ["ACK"] or ["CONTROL_HEAD_MISMATCH", "ACK"]. */
|
|
127
|
+
readonly attempts: readonly string[];
|
|
128
|
+
} | {
|
|
129
|
+
/** The coordinator refused the claim, e.g. AUTHORIZATION_FAILED: the invitation is used up. */
|
|
130
|
+
readonly kind: "refused";
|
|
131
|
+
readonly resourceId: ResourceId;
|
|
132
|
+
readonly code: string;
|
|
133
|
+
readonly attempts: readonly string[];
|
|
134
|
+
} | {
|
|
135
|
+
/**
|
|
136
|
+
* The claim could not be attempted or answered: the connection failed
|
|
137
|
+
* or timed out, the server refused the session or a request, or the
|
|
138
|
+
* chain is forked (§42: no security-sensitive mutation then).
|
|
139
|
+
*/
|
|
140
|
+
readonly kind: "unavailable";
|
|
141
|
+
readonly resourceId: ResourceId;
|
|
142
|
+
readonly reason: string;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* The joiner's side (§18.1, §18.2, §73). On "claimed" the claimant's
|
|
146
|
+
* storage holds the chain with the claimant's grant and its secrets the
|
|
147
|
+
* DEK: open the Resource with a SyncClient for the claimant. Throws
|
|
148
|
+
* INVALID_INVITATION for a link without a secret or a secret that is not
|
|
149
|
+
* the grant's subject, MISSING_DEPENDENCY when the grant is not on the
|
|
150
|
+
* chain, and KEY_PACKAGE_OPEN_FAILED when no package delivers the current
|
|
151
|
+
* epoch's DEK; transport and server stops are "unavailable".
|
|
152
|
+
*/
|
|
153
|
+
export declare function acceptInvitation(options: AcceptInvitationOptions): Promise<AcceptedInvitation>;
|
|
154
|
+
//# sourceMappingURL=invite.d.ts.map
|
package/dist/invite.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { bytesEqual, hash32, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { dekCommitment, exportSecretKeyBytes, InvitationSecret, } from "@openlfcp/crypto";
|
|
3
|
+
import { dekSecretRef } from "@openlfcp/storage";
|
|
4
|
+
import { ABILITY, assembleInviteUri, createMessage, ERROR_CODE, invitationPrincipal, parseControlRecord, parseInviteUri, parseKeyPackage, receiveKeyPackage, sealKeyPackage, signControlRecord, validateControlChain, verifyInvitationSecret, verifyKeyPackage, } from "@openlfcp/wire";
|
|
5
|
+
import { LfcpConnection } from "./connection.js";
|
|
6
|
+
import { queueControlRecord, queueKeyPackage } from "./queue.js";
|
|
7
|
+
import { loadControlChain, saveControlChain } from "./storage.js";
|
|
8
|
+
/**
|
|
9
|
+
* Link invitations and the one-time capability claim (LFCP-WIRE-01 §18,
|
|
10
|
+
* §18.1, §18.2, §25.2, §73; LFCP-053), built on the invitation codec of
|
|
11
|
+
* @openlfcp/wire (LFCP-039b) and this package's queue and storage.
|
|
12
|
+
*
|
|
13
|
+
* - createInvitation (the inviter): a fresh Invitation Principal, a
|
|
14
|
+
* CAPABILITY_GRANT to it with an explicit claim_limit (a grant without
|
|
15
|
+
* one is not claimable, §18), and a Key Package of the current epoch's
|
|
16
|
+
* DEK sealed to it at the grant's head, both checked locally and queued
|
|
17
|
+
* for the coordinator; and the bearer lfcp://join link.
|
|
18
|
+
* - acceptInvitation (the joiner), in the order of §73: authenticate and
|
|
19
|
+
* open the Resource as the Invitation Principal, fetch the Control
|
|
20
|
+
* Chain, verify that the secret's Principal is the grant's subject
|
|
21
|
+
* (§18.2) before the secret is used for anything else, open the
|
|
22
|
+
* invitation Key Package for the DEK (§25.2: the invitation exception),
|
|
23
|
+
* and submit a CAPABILITY_CLAIM signed by the Invitation Principal with
|
|
24
|
+
* CONTROL_PUT at the current head (§47). The coordinator serializes
|
|
25
|
+
* claims (§18.1 rule 6, §18.3): a claim that loses gets
|
|
26
|
+
* CONTROL_HEAD_MISMATCH, is rebuilt once on the refreshed chain, and the
|
|
27
|
+
* coordinator's answer to that is final (AUTHORIZATION_FAILED once the
|
|
28
|
+
* invitation is used up). The client does not pre-judge claimability:
|
|
29
|
+
* the coordinator is the serialization point.
|
|
30
|
+
*
|
|
31
|
+
* After a successful claim the claimant's storage holds the validated
|
|
32
|
+
* chain with its new grant and the secrets hold the epoch's DEK, so a
|
|
33
|
+
* SyncClient for the claimant opens and synchronizes the Resource as the
|
|
34
|
+
* claimant. §73 delivers the DEK through the Invitation Principal's
|
|
35
|
+
* package and draws no package to the claimant, so none is needed to
|
|
36
|
+
* join; later epochs reach the claimant like any holder of data/read.
|
|
37
|
+
*
|
|
38
|
+
* Secrets: a bearer link is a key. InvitationLink and InvitationSecret
|
|
39
|
+
* print "[redacted]"; nothing here logs; no error or result carries a
|
|
40
|
+
* URI, a secret or a DEK.
|
|
41
|
+
*/
|
|
42
|
+
/** A bearer invitation URI, redacted when printed or serialized; reveal() is the only way to read it. */
|
|
43
|
+
export class InvitationLink {
|
|
44
|
+
#uri;
|
|
45
|
+
constructor(uri) {
|
|
46
|
+
this.#uri = uri;
|
|
47
|
+
}
|
|
48
|
+
/** The lfcp://join URI with its #secret= fragment. Hand it to the invitee only; never log it. */
|
|
49
|
+
reveal() {
|
|
50
|
+
return this.#uri;
|
|
51
|
+
}
|
|
52
|
+
toJSON() {
|
|
53
|
+
return "[redacted]";
|
|
54
|
+
}
|
|
55
|
+
toString() {
|
|
56
|
+
return "[redacted]";
|
|
57
|
+
}
|
|
58
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
59
|
+
return "[redacted]";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The usual invitation grant (§18): data/read, data/write, invite/claim. */
|
|
63
|
+
export const DEFAULT_INVITATION_ABILITIES = Object.freeze([
|
|
64
|
+
ABILITY.DATA_READ,
|
|
65
|
+
ABILITY.DATA_WRITE,
|
|
66
|
+
ABILITY.INVITE_CLAIM,
|
|
67
|
+
]);
|
|
68
|
+
const fail = (code, why) => {
|
|
69
|
+
throw new LfcpError(code, why);
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The inviter's side (§18, §73). The grant must validate on the stored
|
|
73
|
+
* chain and the package must pass §25.2 at the grant's head, both checked
|
|
74
|
+
* before anything is queued; the grant is queued before the package.
|
|
75
|
+
* Share the link once both are ACKed: until then the grant is not on the
|
|
76
|
+
* coordinator's chain.
|
|
77
|
+
*/
|
|
78
|
+
export async function createInvitation(options) {
|
|
79
|
+
const R = options.resourceId;
|
|
80
|
+
const stored = await loadControlChain(options.storage, R);
|
|
81
|
+
const view = stored?.kind === "linear"
|
|
82
|
+
? stored
|
|
83
|
+
: fail("MISSING_DEPENDENCY", "no valid local Control Chain for the Resource");
|
|
84
|
+
const abilities = options.abilities ?? DEFAULT_INVITATION_ABILITIES;
|
|
85
|
+
const claimLimit = options.claimLimit ?? 1n;
|
|
86
|
+
if (!abilities.includes(ABILITY.INVITE_CLAIM))
|
|
87
|
+
fail("UNSUPPORTED_VALUE", "an invitation grant must include invite/claim (§18)");
|
|
88
|
+
if (claimLimit < 1n)
|
|
89
|
+
fail("UNSUPPORTED_VALUE", "an invitation grant needs a claim_limit of at least 1 (§18)");
|
|
90
|
+
const epoch = view.state.epoch.epoch;
|
|
91
|
+
if (!bytesEqual(dekCommitment(R, epoch, options.dek), view.state.epoch.dekCommitment))
|
|
92
|
+
fail("DEK_COMMITMENT_MISMATCH", `the DEK is not the one committed for epoch ${epoch}`);
|
|
93
|
+
const secret = options.secret ?? InvitationSecret.generate();
|
|
94
|
+
const invitee = invitationPrincipal(secret);
|
|
95
|
+
const grant = signControlRecord({ resourceId: R, controlSeq: view.state.seq + 1n, prevControlId: view.state.head }, {
|
|
96
|
+
type: "CAPABILITY_GRANT",
|
|
97
|
+
subject: invitee,
|
|
98
|
+
abilities,
|
|
99
|
+
delegable: options.delegable ?? [],
|
|
100
|
+
claimLimit,
|
|
101
|
+
}, options.inviter);
|
|
102
|
+
const extended = validateControlChain([...view.records.map((r) => r.signed.bytes), grant.bytes]);
|
|
103
|
+
const next = extended.kind === "linear"
|
|
104
|
+
? extended
|
|
105
|
+
: fail("AUTHORIZATION_FAILED", "the invitation grant does not validate on the local chain");
|
|
106
|
+
const sealed = await sealKeyPackage({
|
|
107
|
+
resourceId: R,
|
|
108
|
+
epoch,
|
|
109
|
+
controlHead: grant.recordId,
|
|
110
|
+
recipient: invitee,
|
|
111
|
+
dek: options.dek,
|
|
112
|
+
signer: options.inviter,
|
|
113
|
+
});
|
|
114
|
+
const check = verifyKeyPackage(next, parseKeyPackage(sealed.bytes));
|
|
115
|
+
if (check.kind !== "authorized")
|
|
116
|
+
fail("AUTHORIZATION_FAILED", `the invitation Key Package is refused: ${check.message}`);
|
|
117
|
+
await queueControlRecord(options.storage, grant.bytes);
|
|
118
|
+
const keyPackageId = await queueKeyPackage(options.storage, sealed.bytes);
|
|
119
|
+
const uri = assembleInviteUri({
|
|
120
|
+
resourceId: R,
|
|
121
|
+
endpoints: options.endpoints,
|
|
122
|
+
grantId: grant.recordId,
|
|
123
|
+
secret,
|
|
124
|
+
});
|
|
125
|
+
return Object.freeze({
|
|
126
|
+
link: new InvitationLink(uri),
|
|
127
|
+
grantId: grant.recordId,
|
|
128
|
+
invitationPrincipal: invitee,
|
|
129
|
+
keyPackageId,
|
|
130
|
+
epoch,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const CODE_NAME = new Map(Object.entries(ERROR_CODE).map(([k, v]) => [v, k]));
|
|
134
|
+
const codeName = (code) => CODE_NAME.get(code) ?? `ERROR_${code}`;
|
|
135
|
+
const UINT64_MAX = 2n ** 64n - 1n;
|
|
136
|
+
/** Thrown inside the claim session for a transport or server-side stop; becomes "unavailable". */
|
|
137
|
+
class Unavailable extends Error {
|
|
138
|
+
}
|
|
139
|
+
/** Request/response over one LfcpConnection, for the short session as the Invitation Principal. */
|
|
140
|
+
class ClaimSession {
|
|
141
|
+
#connection;
|
|
142
|
+
#inbox = [];
|
|
143
|
+
#waiters = [];
|
|
144
|
+
#closed = null;
|
|
145
|
+
#ready = false;
|
|
146
|
+
#timedOut = false;
|
|
147
|
+
constructor(options, timeout) {
|
|
148
|
+
this.#connection = new LfcpConnection(options, {
|
|
149
|
+
state: () => undefined,
|
|
150
|
+
ready: () => {
|
|
151
|
+
this.#ready = true;
|
|
152
|
+
this.#wake();
|
|
153
|
+
},
|
|
154
|
+
message: (m) => {
|
|
155
|
+
this.#inbox.push(m);
|
|
156
|
+
this.#wake();
|
|
157
|
+
},
|
|
158
|
+
closed: (reason) => {
|
|
159
|
+
this.#closed = reason;
|
|
160
|
+
this.#wake();
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
timeout.then(() => {
|
|
164
|
+
this.#timedOut = true;
|
|
165
|
+
this.#wake();
|
|
166
|
+
}, () => undefined);
|
|
167
|
+
}
|
|
168
|
+
#wake() {
|
|
169
|
+
const waiting = this.#waiters;
|
|
170
|
+
this.#waiters = [];
|
|
171
|
+
for (const w of waiting)
|
|
172
|
+
w();
|
|
173
|
+
}
|
|
174
|
+
async #until(take, what) {
|
|
175
|
+
for (;;) {
|
|
176
|
+
const v = take();
|
|
177
|
+
if (v !== undefined)
|
|
178
|
+
return v;
|
|
179
|
+
if (this.#timedOut)
|
|
180
|
+
throw new Unavailable(`timed out waiting for ${what}`);
|
|
181
|
+
if (this.#closed !== null)
|
|
182
|
+
throw new Unavailable(`the connection closed while waiting for ${what}: ${this.#closed}`);
|
|
183
|
+
await new Promise((r) => this.#waiters.push(r));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async connect() {
|
|
187
|
+
this.#connection.connect();
|
|
188
|
+
await this.#until(() => (this.#ready ? true : undefined), "READY");
|
|
189
|
+
}
|
|
190
|
+
/** Sends a request and returns a function awaiting its next correlated reply. */
|
|
191
|
+
request(m, what) {
|
|
192
|
+
this.#connection.send(m);
|
|
193
|
+
const id = toHex(m.messageId);
|
|
194
|
+
return () => this.#until(() => {
|
|
195
|
+
const i = this.#inbox.findIndex((r) => r.correlationId !== undefined && toHex(r.correlationId) === id);
|
|
196
|
+
return i === -1 ? undefined : this.#inbox.splice(i, 1)[0];
|
|
197
|
+
}, what);
|
|
198
|
+
}
|
|
199
|
+
close() {
|
|
200
|
+
this.#connection.close("the invitation claim finished");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const NEVER = new Promise(() => undefined);
|
|
204
|
+
async function openAsInvitee(s, R, grantId) {
|
|
205
|
+
const reply = await s.request(createMessage("RESOURCE_OPEN", {
|
|
206
|
+
resourceId: R,
|
|
207
|
+
heads: [],
|
|
208
|
+
haves: [],
|
|
209
|
+
grantIds: [hash32(grantId)],
|
|
210
|
+
}), "RESOURCE_OPENED")();
|
|
211
|
+
if (reply.type === "NACK")
|
|
212
|
+
throw new Unavailable(`RESOURCE_OPEN was refused: ${codeName(reply.body.code)}`);
|
|
213
|
+
if (reply.type !== "RESOURCE_OPENED")
|
|
214
|
+
throw new Unavailable(`RESOURCE_OPEN was answered with ${reply.type}`);
|
|
215
|
+
return reply.body.heads;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Control Records from `have.length` on, until the chain validates and
|
|
219
|
+
* reaches `until`: a sequence, or the head a CONTROL_HEAD_MISMATCH named.
|
|
220
|
+
*/
|
|
221
|
+
async function fetchChain(s, R, have, until) {
|
|
222
|
+
const next = s.request(createMessage("CONTROL_GET", {
|
|
223
|
+
resourceId: R,
|
|
224
|
+
start: BigInt(have.length),
|
|
225
|
+
end: "seq" in until ? until.seq : UINT64_MAX,
|
|
226
|
+
}), "CONTROL_BATCH");
|
|
227
|
+
const records = [...have];
|
|
228
|
+
for (;;) {
|
|
229
|
+
const reply = await next();
|
|
230
|
+
if (reply.type === "NACK")
|
|
231
|
+
throw new Unavailable(`CONTROL_GET was refused: ${codeName(reply.body.code)}`);
|
|
232
|
+
if (reply.type !== "CONTROL_BATCH")
|
|
233
|
+
continue;
|
|
234
|
+
for (const bytes of reply.body.objects)
|
|
235
|
+
if (parseControlRecord(bytes).payload.controlSeq === BigInt(records.length))
|
|
236
|
+
records.push(bytes);
|
|
237
|
+
const chain = validateControlChain(records);
|
|
238
|
+
if (chain.kind === "conflict")
|
|
239
|
+
throw new Unavailable("the Control Chain is forked: no claim is made (§42)");
|
|
240
|
+
if (chain.kind !== "linear")
|
|
241
|
+
continue;
|
|
242
|
+
if ("seq" in until ? chain.state.seq >= until.seq : bytesEqual(chain.state.head, until.head))
|
|
243
|
+
return chain;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The joiner's side (§18.1, §18.2, §73). On "claimed" the claimant's
|
|
248
|
+
* storage holds the chain with the claimant's grant and its secrets the
|
|
249
|
+
* DEK: open the Resource with a SyncClient for the claimant. Throws
|
|
250
|
+
* INVALID_INVITATION for a link without a secret or a secret that is not
|
|
251
|
+
* the grant's subject, MISSING_DEPENDENCY when the grant is not on the
|
|
252
|
+
* chain, and KEY_PACKAGE_OPEN_FAILED when no package delivers the current
|
|
253
|
+
* epoch's DEK; transport and server stops are "unavailable".
|
|
254
|
+
*/
|
|
255
|
+
export async function acceptInvitation(options) {
|
|
256
|
+
const invitation = parseInviteUri(typeof options.link === "string" ? options.link : options.link.reveal());
|
|
257
|
+
const secret = invitation.secret ??
|
|
258
|
+
fail("INVALID_INVITATION", "the link has no #secret= fragment: it is a targeted invitation");
|
|
259
|
+
const R = invitation.resourceId;
|
|
260
|
+
const invitee = Object.freeze({
|
|
261
|
+
key: secret.signingKey,
|
|
262
|
+
descriptor: invitationPrincipal(secret),
|
|
263
|
+
});
|
|
264
|
+
const s = new ClaimSession({
|
|
265
|
+
url: options.url ?? invitation.endpoints[0],
|
|
266
|
+
signer: invitee,
|
|
267
|
+
now: options.now,
|
|
268
|
+
...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }),
|
|
269
|
+
}, options.timeout ?? NEVER);
|
|
270
|
+
const progress = (stage, attempt) => options.onProgress?.(Object.freeze(attempt === undefined ? { stage } : { stage, attempt }));
|
|
271
|
+
try {
|
|
272
|
+
// §73: the Invitation Principal authenticates and opens the Resource.
|
|
273
|
+
progress("connecting");
|
|
274
|
+
await s.connect();
|
|
275
|
+
progress("validating-invitation");
|
|
276
|
+
const heads = await openAsInvitee(s, R, invitation.grantId);
|
|
277
|
+
if (heads.length !== 1)
|
|
278
|
+
throw new Unavailable("the Resource has no single Control Head: no claim is made (§42)");
|
|
279
|
+
let chain = await fetchChain(s, R, [], { seq: heads[0].seq });
|
|
280
|
+
// §18.2: the secret's Principal must be the grant's subject before the secret is used further.
|
|
281
|
+
const claimSigner = verifyInvitationSecret(chain.state, invitation.grantId, secret);
|
|
282
|
+
const grant = chain.state.grants.get(toHex(invitation.grantId)) ??
|
|
283
|
+
fail("MISSING_DEPENDENCY", "the invitation grant is not on the chain");
|
|
284
|
+
// §73, §25.2: the invitation's Key Package delivers the DEK.
|
|
285
|
+
progress("retrieving-key");
|
|
286
|
+
const batch = await s.request(createMessage("KEY_PACKAGE_GET", {
|
|
287
|
+
resourceId: R,
|
|
288
|
+
recipient: invitee.descriptor.principalId,
|
|
289
|
+
// §52: at most 256 epochs. The claim needs the current epoch's DEK,
|
|
290
|
+
// and the invitation's package is of a recent epoch: the newest 256.
|
|
291
|
+
epochs: [...chain.state.epochs.values()].map((e) => e.epoch).slice(-256),
|
|
292
|
+
}), "KEY_PACKAGE_BATCH")();
|
|
293
|
+
if (batch.type !== "KEY_PACKAGE_BATCH")
|
|
294
|
+
throw new Unavailable(batch.type === "NACK"
|
|
295
|
+
? `KEY_PACKAGE_GET was refused: ${codeName(batch.body.code)}`
|
|
296
|
+
: `KEY_PACKAGE_GET was answered with ${batch.type}`);
|
|
297
|
+
const deks = new Map();
|
|
298
|
+
let problem = "no Key Package for the Invitation Principal";
|
|
299
|
+
for (const bytes of batch.body.objects) {
|
|
300
|
+
const r = await receiveKeyPackage(chain, bytes, {
|
|
301
|
+
descriptor: invitee.descriptor,
|
|
302
|
+
agreement: secret.agreementKey,
|
|
303
|
+
});
|
|
304
|
+
if (r.kind === "opened")
|
|
305
|
+
deks.set(String(r.epoch), r.dek);
|
|
306
|
+
else
|
|
307
|
+
problem = r.kind === "rejected" ? r.wireCode : r.code;
|
|
308
|
+
}
|
|
309
|
+
if (!deks.has(String(chain.state.epoch.epoch)))
|
|
310
|
+
fail("KEY_PACKAGE_OPEN_FAILED", `the current epoch's DEK was not delivered (${problem})`);
|
|
311
|
+
// §18.1: the claim, signed by the Invitation Principal, at the current head (§47).
|
|
312
|
+
const delegable = grant.delegable.includes(ABILITY.INVITE_CLAIM);
|
|
313
|
+
const abilities = options.abilities ?? grant.abilities.filter((a) => a !== ABILITY.INVITE_CLAIM || delegable);
|
|
314
|
+
const attempts = [];
|
|
315
|
+
for (;;) {
|
|
316
|
+
progress("claiming-capability", attempts.length + 1);
|
|
317
|
+
const claim = signControlRecord({ resourceId: R, controlSeq: chain.state.seq + 1n, prevControlId: chain.state.head }, {
|
|
318
|
+
type: "CAPABILITY_CLAIM",
|
|
319
|
+
invitationGrantId: invitation.grantId,
|
|
320
|
+
claimant: options.claimant.signer.descriptor,
|
|
321
|
+
abilities,
|
|
322
|
+
}, claimSigner);
|
|
323
|
+
const answer = await s.request(createMessage("CONTROL_PUT", {
|
|
324
|
+
resourceId: R,
|
|
325
|
+
expectedHead: chain.state.head,
|
|
326
|
+
record: claim.bytes,
|
|
327
|
+
}), "the CONTROL_PUT answer")();
|
|
328
|
+
if (answer.type === "ACK") {
|
|
329
|
+
attempts.push("ACK");
|
|
330
|
+
const claimed = validateControlChain([
|
|
331
|
+
...chain.records.map((r) => r.signed.bytes),
|
|
332
|
+
claim.bytes,
|
|
333
|
+
]);
|
|
334
|
+
if (claimed.kind !== "linear")
|
|
335
|
+
fail("INVALID_CONTROL_CHAIN", "the acknowledged claim does not validate locally");
|
|
336
|
+
await persist(options, claimed, deks);
|
|
337
|
+
return Object.freeze({
|
|
338
|
+
kind: "claimed",
|
|
339
|
+
resourceId: R,
|
|
340
|
+
grantId: claim.recordId,
|
|
341
|
+
abilities: Object.freeze([...abilities]),
|
|
342
|
+
epochs: Object.freeze([...deks.keys()].map((e) => BigInt(e))),
|
|
343
|
+
attempts: Object.freeze(attempts),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (answer.type !== "NACK")
|
|
347
|
+
throw new Unavailable(`CONTROL_PUT was answered with ${answer.type}`);
|
|
348
|
+
const code = codeName(answer.body.code);
|
|
349
|
+
attempts.push(code);
|
|
350
|
+
const head = answer.body.details;
|
|
351
|
+
// One refresh after a lost race (§73); the coordinator's next answer is final.
|
|
352
|
+
if (code !== "CONTROL_HEAD_MISMATCH" || attempts.length > 1 || !(head instanceof Uint8Array))
|
|
353
|
+
return Object.freeze({
|
|
354
|
+
kind: "refused",
|
|
355
|
+
resourceId: R,
|
|
356
|
+
code,
|
|
357
|
+
attempts: Object.freeze(attempts),
|
|
358
|
+
});
|
|
359
|
+
chain = await fetchChain(s, R, chain.records.map((r) => r.signed.bytes), { head });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
if (e instanceof Unavailable)
|
|
364
|
+
return Object.freeze({ kind: "unavailable", resourceId: R, reason: e.message });
|
|
365
|
+
throw e;
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
s.close();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** The claimant's storage: the chain with its grant, then each DEK before the row that names it (LFCP-034). */
|
|
372
|
+
async function persist(options, chain, deks) {
|
|
373
|
+
const R = chain.state.resourceId;
|
|
374
|
+
const before = await loadControlChain(options.storage, R);
|
|
375
|
+
const saved = await saveControlChain(options.storage, chain, before?.kind === "linear" ? before.state.head : null);
|
|
376
|
+
if (!saved.ok)
|
|
377
|
+
fail("UNSUPPORTED_VALUE", `the claimed chain was not stored: ${saved.reason}`);
|
|
378
|
+
for (const row of await options.storage.control.epochs(R)) {
|
|
379
|
+
const dek = deks.get(String(row.epoch));
|
|
380
|
+
if (dek === undefined)
|
|
381
|
+
continue;
|
|
382
|
+
const ref = dekSecretRef(R, row.epoch);
|
|
383
|
+
await options.secrets.put(ref, exportSecretKeyBytes(dek));
|
|
384
|
+
const result = await options.storage.commit([
|
|
385
|
+
{ op: "put-epoch", resourceId: R, epoch: { ...row, dekRef: ref } },
|
|
386
|
+
]);
|
|
387
|
+
if (!result.ok)
|
|
388
|
+
fail("UNSUPPORTED_VALUE", `the DEK reference was not stored: ${result.reason}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
//# sourceMappingURL=invite.js.map
|