@openlfcp/client 0.2.2 → 0.3.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/dist/data-unit.d.ts +11 -0
- package/dist/data-unit.js +9 -0
- package/dist/identity/accept.d.ts +115 -0
- package/dist/identity/accept.js +335 -0
- package/dist/identity/context.d.ts +214 -0
- package/dist/identity/context.js +495 -0
- package/dist/identity/devices.d.ts +127 -0
- package/dist/identity/devices.js +221 -0
- package/dist/identity/document.d.ts +112 -0
- package/dist/identity/document.js +54 -0
- package/dist/identity/index.d.ts +17 -0
- package/dist/identity/index.js +17 -0
- package/dist/identity/journal.d.ts +139 -0
- package/dist/identity/journal.js +202 -0
- package/dist/identity/network.d.ts +88 -0
- package/dist/identity/network.js +243 -0
- package/dist/identity/pairing-request.d.ts +147 -0
- package/dist/identity/pairing-request.js +422 -0
- package/dist/identity/pairing.d.ts +225 -0
- package/dist/identity/pairing.js +624 -0
- package/dist/identity/phases.d.ts +75 -0
- package/dist/identity/phases.js +104 -0
- package/dist/identity/proof.d.ts +45 -0
- package/dist/identity/proof.js +71 -0
- package/dist/identity/reconcile.d.ts +73 -0
- package/dist/identity/reconcile.js +273 -0
- package/dist/identity/resource.d.ts +74 -0
- package/dist/identity/resource.js +201 -0
- package/dist/identity/revocation.d.ts +106 -0
- package/dist/identity/revocation.js +403 -0
- package/dist/identity/roster.d.ts +89 -0
- package/dist/identity/roster.js +130 -0
- package/dist/identity/rotation.d.ts +131 -0
- package/dist/identity/rotation.js +363 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/revoke.d.ts +8 -0
- package/dist/revoke.js +12 -8
- package/dist/storage.d.ts +18 -0
- package/dist/storage.js +26 -0
- package/dist/sync-client.d.ts +2 -0
- package/dist/sync-client.js +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { fromHex, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { exportSecretKeyBytes, importResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import { secretRef } from "@openlfcp/storage";
|
|
4
|
+
const OP_PREFIX = "identity-op:";
|
|
5
|
+
const LEDGER_PREFIX = "identity-ledger:";
|
|
6
|
+
const SECRETS_IN_JOURNAL = /secret|seed|private|dek/i;
|
|
7
|
+
function checkNoSecrets(value, path = "intent") {
|
|
8
|
+
if (value === null || typeof value !== "object")
|
|
9
|
+
return;
|
|
10
|
+
for (const [k, v] of Object.entries(value)) {
|
|
11
|
+
// §9.8: the journal never names a secret field; secrets go to the SecretStore.
|
|
12
|
+
if (SECRETS_IN_JOURNAL.test(k) && k !== "dekRef")
|
|
13
|
+
throw new Error(`a journal row must not hold secrets (${path}.${k}, §9.8)`);
|
|
14
|
+
checkNoSecrets(v, `${path}.${k}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The durable operation journal (§9.1). */
|
|
18
|
+
export class OperationJournal {
|
|
19
|
+
#storage;
|
|
20
|
+
constructor(storage) {
|
|
21
|
+
this.#storage = storage;
|
|
22
|
+
}
|
|
23
|
+
async #put(op) {
|
|
24
|
+
checkNoSecrets(op.intent);
|
|
25
|
+
const result = await this.#storage.commit([
|
|
26
|
+
{ op: "put-local-mark", key: `${OP_PREFIX}${op.id}`, value: JSON.stringify(op) },
|
|
27
|
+
]);
|
|
28
|
+
if (!result.ok)
|
|
29
|
+
throw new Error(`the operation journal was not stored: ${result.reason}`);
|
|
30
|
+
}
|
|
31
|
+
/** Persists a new operation before any of its records is sent. */
|
|
32
|
+
async begin(op) {
|
|
33
|
+
const row = Object.freeze({
|
|
34
|
+
v: 1,
|
|
35
|
+
status: "open",
|
|
36
|
+
phases: [],
|
|
37
|
+
...op,
|
|
38
|
+
});
|
|
39
|
+
if ((await this.get(op.id)) !== undefined)
|
|
40
|
+
throw new Error(`operation ${op.id} is already journaled`);
|
|
41
|
+
await this.#put(row);
|
|
42
|
+
return row;
|
|
43
|
+
}
|
|
44
|
+
async get(id) {
|
|
45
|
+
const value = await this.#storage.localMarks.get(`${OP_PREFIX}${id}`);
|
|
46
|
+
return value === undefined ? undefined : JSON.parse(value);
|
|
47
|
+
}
|
|
48
|
+
/** Every journaled operation, optionally of one kind and status, oldest first. */
|
|
49
|
+
async list(filter = {}) {
|
|
50
|
+
return (await this.#storage.localMarks.list(OP_PREFIX))
|
|
51
|
+
.map((m) => JSON.parse(m.value))
|
|
52
|
+
.filter((o) => (filter.kind === undefined || o.kind === filter.kind) &&
|
|
53
|
+
(filter.status === undefined || o.status === filter.status))
|
|
54
|
+
.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1));
|
|
55
|
+
}
|
|
56
|
+
/** Writes the phase (replacing the one with its ID), durably. */
|
|
57
|
+
async putPhase(id, phase) {
|
|
58
|
+
const op = await this.load(id);
|
|
59
|
+
const phases = op.phases.some((p) => p.id === phase.id)
|
|
60
|
+
? op.phases.map((p) => (p.id === phase.id ? phase : p))
|
|
61
|
+
: [...op.phases, phase];
|
|
62
|
+
const next = Object.freeze({ ...op, phases: Object.freeze(phases) });
|
|
63
|
+
await this.#put(next);
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
/** Updates the intent with more public facts (e.g. the session's record IDs). */
|
|
67
|
+
async updateIntent(id, patch) {
|
|
68
|
+
const op = await this.load(id);
|
|
69
|
+
const next = Object.freeze({ ...op, intent: Object.freeze({ ...op.intent, ...patch }) });
|
|
70
|
+
await this.#put(next);
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
async setStatus(id, status) {
|
|
74
|
+
const op = await this.load(id);
|
|
75
|
+
const next = Object.freeze({ ...op, status });
|
|
76
|
+
await this.#put(next);
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
/** Removes a finished operation's row. */
|
|
80
|
+
async remove(id) {
|
|
81
|
+
await this.#storage.commit([{ op: "put-local-mark", key: `${OP_PREFIX}${id}`, value: null }]);
|
|
82
|
+
}
|
|
83
|
+
async load(id) {
|
|
84
|
+
const op = await this.get(id);
|
|
85
|
+
if (op === undefined)
|
|
86
|
+
throw new Error(`operation ${id} is not journaled`);
|
|
87
|
+
return op;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** The phase `id` of `op`, if journaled. */
|
|
91
|
+
export const phaseOf = (op, id) => op.phases.find((p) => p.id === id);
|
|
92
|
+
const pendingKey = (resource, epoch, recipient) => `identity-package-pending:${toHex(resource)}:${epoch}:${toHex(recipient)}`;
|
|
93
|
+
const ledgerKey = (resource, epoch, recipient) => `${LEDGER_PREFIX}${toHex(resource)}:${epoch}:${toHex(recipient)}`;
|
|
94
|
+
/**
|
|
95
|
+
* The package ledger (§9.5): every Key Package this installation sent, as
|
|
96
|
+
* `(resource, epoch, recipient, package ID)`. It drives reconciliation's
|
|
97
|
+
* key step (§7.1 step 3); delivery is never inferred from the absence of a
|
|
98
|
+
* complaint, and a cleared entry causes a re-send.
|
|
99
|
+
*/
|
|
100
|
+
export class PackageLedger {
|
|
101
|
+
#storage;
|
|
102
|
+
constructor(storage) {
|
|
103
|
+
this.#storage = storage;
|
|
104
|
+
}
|
|
105
|
+
/** Records an acknowledged package and drops its pending bytes, in one commit. */
|
|
106
|
+
async record(entry) {
|
|
107
|
+
const result = await this.#storage.commit([
|
|
108
|
+
{
|
|
109
|
+
op: "put-local-mark",
|
|
110
|
+
key: ledgerKey(entry.resource, entry.epoch, entry.recipient),
|
|
111
|
+
value: toHex(entry.packageId),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
op: "put-local-mark",
|
|
115
|
+
key: pendingKey(entry.resource, entry.epoch, entry.recipient),
|
|
116
|
+
value: null,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
if (!result.ok)
|
|
120
|
+
throw new Error(`the package ledger was not stored: ${result.reason}`);
|
|
121
|
+
}
|
|
122
|
+
/** The exact bytes of a package sent but not yet acknowledged (§9.1). */
|
|
123
|
+
async pending(resource, epoch, recipient) {
|
|
124
|
+
const v = await this.#storage.localMarks.get(pendingKey(resource, epoch, recipient));
|
|
125
|
+
return v === undefined ? undefined : fromHex(v);
|
|
126
|
+
}
|
|
127
|
+
/** Persists a package's exact bytes before it is sent. */
|
|
128
|
+
async putPending(resource, epoch, recipient, bytes) {
|
|
129
|
+
const result = await this.#storage.commit([
|
|
130
|
+
{ op: "put-local-mark", key: pendingKey(resource, epoch, recipient), value: toHex(bytes) },
|
|
131
|
+
]);
|
|
132
|
+
if (!result.ok)
|
|
133
|
+
throw new Error(`the pending package was not stored: ${result.reason}`);
|
|
134
|
+
}
|
|
135
|
+
async has(resource, epoch, recipient) {
|
|
136
|
+
return ((await this.#storage.localMarks.get(ledgerKey(resource, epoch, recipient))) !== undefined);
|
|
137
|
+
}
|
|
138
|
+
/** Every entry for `resource`. */
|
|
139
|
+
async list(resource) {
|
|
140
|
+
return (await this.#storage.localMarks.list(`${LEDGER_PREFIX}${toHex(resource)}:`)).map((m) => {
|
|
141
|
+
const [, r, e, p] = m.key.split(":");
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
resource: fromHex(r),
|
|
144
|
+
epoch: BigInt(e),
|
|
145
|
+
recipient: fromHex(p),
|
|
146
|
+
packageId: fromHex(m.value),
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* "Re-send keys" (§7.1 step 3): forgets every package sent to
|
|
152
|
+
* `recipient`, in `resource` or everywhere, so the next reconciliation
|
|
153
|
+
* re-seals them.
|
|
154
|
+
*/
|
|
155
|
+
async clear(recipient, resource) {
|
|
156
|
+
const prefix = resource === undefined ? LEDGER_PREFIX : `${LEDGER_PREFIX}${toHex(resource)}:`;
|
|
157
|
+
const suffix = `:${toHex(recipient)}`;
|
|
158
|
+
const keys = (await this.#storage.localMarks.list(prefix))
|
|
159
|
+
.map((m) => m.key)
|
|
160
|
+
.filter((k) => k.endsWith(suffix));
|
|
161
|
+
if (keys.length === 0)
|
|
162
|
+
return 0;
|
|
163
|
+
const result = await this.#storage.commit(keys.map((key) => ({ op: "put-local-mark", key, value: null })));
|
|
164
|
+
if (!result.ok)
|
|
165
|
+
throw new Error(`the package ledger was not updated: ${result.reason}`);
|
|
166
|
+
return keys.length;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The SecretStore slot of a DEK generated for a KEY_EPOCH that is not
|
|
171
|
+
* committed yet (§9.1: persisted before the record is sent). It is not the
|
|
172
|
+
* epoch's DEK slot: until the record is accepted, another rotator may
|
|
173
|
+
* commit that epoch number with another DEK.
|
|
174
|
+
*/
|
|
175
|
+
export const pendingDekRef = (resource, operation) => secretRef("resource-dek", `${toHex(resource)}.pending.${operation}`);
|
|
176
|
+
/** The SecretStore slot of a pairing device's invite-secret (§5.2, §9.8). */
|
|
177
|
+
export const pairingSecretRef = (pairingDevice) => secretRef("invitation-secret", `pairing.${toHex(pairingDevice)}`);
|
|
178
|
+
export async function putPendingDek(secrets, resource, operation, dek) {
|
|
179
|
+
const bytes = exportSecretKeyBytes(dek);
|
|
180
|
+
try {
|
|
181
|
+
await secrets.put(pendingDekRef(resource, operation), bytes);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
bytes.fill(0);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export async function getPendingDek(secrets, resource, operation) {
|
|
188
|
+
const bytes = await secrets.get(pendingDekRef(resource, operation));
|
|
189
|
+
if (bytes === undefined)
|
|
190
|
+
return undefined;
|
|
191
|
+
try {
|
|
192
|
+
return importResourceDEK(bytes);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
bytes.fill(0);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** A fresh, unique operation ID. */
|
|
199
|
+
export function newOperationId(kind, random) {
|
|
200
|
+
return `${kind}.${toHex(random)}`;
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=journal.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type ControlRecordId, type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type ControlHeadRef, type DataRange, type LiveActorHave, type Signer } from "@openlfcp/wire";
|
|
3
|
+
import { type WebSocketFactory } from "../connection.js";
|
|
4
|
+
/**
|
|
5
|
+
* The network port of the identity flows (IDENTITY-PROFILE-01 §§5–9).
|
|
6
|
+
*
|
|
7
|
+
* Every flow talks to coordinators through an IdentityChannel: one
|
|
8
|
+
* authenticated session as one Principal (the installation's own, a
|
|
9
|
+
* pairing device's, an Invitation Principal's) to one coordinator. The
|
|
10
|
+
* port keeps the flows independent of the transport: connectionNetwork()
|
|
11
|
+
* runs it over LfcpConnection, tests run it against an in-memory
|
|
12
|
+
* coordinator. A transport failure throws IdentityUnavailable and leaves
|
|
13
|
+
* every journal as it is, so the flow resumes later (§9); a NACK is an
|
|
14
|
+
* answer, returned to the flow.
|
|
15
|
+
*/
|
|
16
|
+
/** The coordinator's answer to an upload (CONTROL_PUT, KEY_PACKAGE_PUT, DATA_PUT, RESOURCE_HOST). */
|
|
17
|
+
export type IdentityAnswer = {
|
|
18
|
+
readonly kind: "ack";
|
|
19
|
+
} | {
|
|
20
|
+
readonly kind: "nack";
|
|
21
|
+
readonly code: string;
|
|
22
|
+
/** For CONTROL_HEAD_MISMATCH: the coordinator's head, when it named one (§47). */
|
|
23
|
+
readonly head?: ControlRecordId;
|
|
24
|
+
};
|
|
25
|
+
export type OpenAnswer = {
|
|
26
|
+
readonly kind: "opened";
|
|
27
|
+
readonly heads: readonly ControlHeadRef[];
|
|
28
|
+
readonly haves: readonly LiveActorHave[];
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: "nack";
|
|
31
|
+
readonly code: string;
|
|
32
|
+
};
|
|
33
|
+
export interface IdentityChannel {
|
|
34
|
+
/** READY's extensions (§37); `identity-01` when the server supports device records. */
|
|
35
|
+
readonly extensions: readonly string[];
|
|
36
|
+
/** RESOURCE_HOST with the exact Genesis bytes (§39). */
|
|
37
|
+
host(genesis: Uint8Array): Promise<IdentityAnswer>;
|
|
38
|
+
/** RESOURCE_OPEN without subscriptions (§41). */
|
|
39
|
+
open(resource: ResourceId): Promise<OpenAnswer>;
|
|
40
|
+
/** CONTROL_GET from `start` to the end (§45); throws IdentityRefused on a NACK. */
|
|
41
|
+
control(resource: ResourceId, start: bigint): Promise<readonly Uint8Array[]>;
|
|
42
|
+
/** CONTROL_PUT with compare-and-swap (§47). */
|
|
43
|
+
putControl(resource: ResourceId, expectedHead: ControlRecordId, record: Uint8Array): Promise<IdentityAnswer>;
|
|
44
|
+
/** KEY_PACKAGE_GET (§52: at most 256 epochs); throws IdentityRefused on a NACK. */
|
|
45
|
+
keyPackages(resource: ResourceId, recipient: PrincipalId, epochs: readonly DataEpoch[]): Promise<readonly Uint8Array[]>;
|
|
46
|
+
/** KEY_PACKAGE_PUT (§53). */
|
|
47
|
+
putKeyPackage(resource: ResourceId, bytes: Uint8Array): Promise<IdentityAnswer>;
|
|
48
|
+
/** DATA_GET of the ranges (§49); throws IdentityRefused on a NACK. */
|
|
49
|
+
dataUnits(resource: ResourceId, ranges: readonly DataRange[]): Promise<readonly Uint8Array[]>;
|
|
50
|
+
/** DATA_PUT of one unit (§50). */
|
|
51
|
+
putDataUnit(resource: ResourceId, bytes: Uint8Array): Promise<IdentityAnswer>;
|
|
52
|
+
close(): void;
|
|
53
|
+
}
|
|
54
|
+
export interface IdentityNetwork {
|
|
55
|
+
/** An authenticated session as `signer` to the coordinator at `url`; throws IdentityUnavailable. */
|
|
56
|
+
connect(signer: Signer, url: string): Promise<IdentityChannel>;
|
|
57
|
+
}
|
|
58
|
+
/** The coordinator could not be reached or the session ended: a timeout is never a decision (§6). */
|
|
59
|
+
export declare class IdentityUnavailable extends Error {
|
|
60
|
+
constructor(message: string);
|
|
61
|
+
}
|
|
62
|
+
/** A read the coordinator refused (NACK), e.g. AUTHORIZATION_FAILED for a revoked device (§8.6). */
|
|
63
|
+
export declare class IdentityRefused extends Error {
|
|
64
|
+
readonly code: string;
|
|
65
|
+
constructor(code: string, message: string);
|
|
66
|
+
}
|
|
67
|
+
/** The READY extension of servers that support device records (W §37). */
|
|
68
|
+
export declare const IDENTITY_EXTENSION = "identity-01";
|
|
69
|
+
/** Connections reused within one flow run, by (Principal, URL); closed together. */
|
|
70
|
+
export declare class ChannelPool {
|
|
71
|
+
#private;
|
|
72
|
+
constructor(network: IdentityNetwork);
|
|
73
|
+
get(signer: Signer, url: string): Promise<IdentityChannel>;
|
|
74
|
+
closeAll(): void;
|
|
75
|
+
}
|
|
76
|
+
export interface ConnectionNetworkOptions {
|
|
77
|
+
readonly now: () => number;
|
|
78
|
+
readonly webSocket?: WebSocketFactory;
|
|
79
|
+
/**
|
|
80
|
+
* A fresh timer per awaited reply (e.g. `() => sleep(30_000)` from the
|
|
81
|
+
* host): when it resolves first the request is "unavailable", never a
|
|
82
|
+
* decision (§6). Without it a request waits until the connection closes.
|
|
83
|
+
*/
|
|
84
|
+
readonly timeout?: () => Promise<unknown>;
|
|
85
|
+
}
|
|
86
|
+
/** The identity network over LfcpConnection: one session per (Principal, coordinator). */
|
|
87
|
+
export declare function connectionNetwork(options: ConnectionNetworkOptions): IdentityNetwork;
|
|
88
|
+
//# sourceMappingURL=network.d.ts.map
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { controlRecordId, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { createMessage, ERROR_CODE, } from "@openlfcp/wire";
|
|
3
|
+
import { LfcpConnection } from "../connection.js";
|
|
4
|
+
/** The coordinator could not be reached or the session ended: a timeout is never a decision (§6). */
|
|
5
|
+
export class IdentityUnavailable extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "IdentityUnavailable";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** A read the coordinator refused (NACK), e.g. AUTHORIZATION_FAILED for a revoked device (§8.6). */
|
|
12
|
+
export class IdentityRefused extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "IdentityRefused";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The READY extension of servers that support device records (W §37). */
|
|
21
|
+
export const IDENTITY_EXTENSION = "identity-01";
|
|
22
|
+
/** Connections reused within one flow run, by (Principal, URL); closed together. */
|
|
23
|
+
export class ChannelPool {
|
|
24
|
+
#network;
|
|
25
|
+
#open = new Map();
|
|
26
|
+
constructor(network) {
|
|
27
|
+
this.#network = network;
|
|
28
|
+
}
|
|
29
|
+
get(signer, url) {
|
|
30
|
+
const key = `${toHex(signer.descriptor.principalId)} ${url}`;
|
|
31
|
+
let c = this.#open.get(key);
|
|
32
|
+
if (c === undefined) {
|
|
33
|
+
c = this.#network.connect(signer, url);
|
|
34
|
+
// A failed connection is not reused.
|
|
35
|
+
c.catch(() => this.#open.delete(key));
|
|
36
|
+
this.#open.set(key, c);
|
|
37
|
+
}
|
|
38
|
+
return c;
|
|
39
|
+
}
|
|
40
|
+
closeAll() {
|
|
41
|
+
for (const c of this.#open.values())
|
|
42
|
+
c.then((ch) => ch.close()).catch(() => undefined);
|
|
43
|
+
this.#open.clear();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const CODE_NAME = new Map(Object.entries(ERROR_CODE).map(([k, v]) => [v, k]));
|
|
47
|
+
const codeName = (code) => CODE_NAME.get(code) ?? `ERROR_${code}`;
|
|
48
|
+
const MAX_EPOCHS_PER_GET = 256;
|
|
49
|
+
/** Request/response over one LfcpConnection. */
|
|
50
|
+
class RequestSession {
|
|
51
|
+
#connection;
|
|
52
|
+
#inbox = [];
|
|
53
|
+
#waiters = [];
|
|
54
|
+
#closed = null;
|
|
55
|
+
#extensions = null;
|
|
56
|
+
#timeout;
|
|
57
|
+
constructor(options) {
|
|
58
|
+
this.#timeout = options.timeout;
|
|
59
|
+
this.#connection = new LfcpConnection({
|
|
60
|
+
url: options.url,
|
|
61
|
+
signer: options.signer,
|
|
62
|
+
now: options.now,
|
|
63
|
+
...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }),
|
|
64
|
+
}, {
|
|
65
|
+
state: () => undefined,
|
|
66
|
+
ready: (r) => {
|
|
67
|
+
this.#extensions = Object.freeze([...r.extensions]);
|
|
68
|
+
this.#wake();
|
|
69
|
+
},
|
|
70
|
+
message: (m) => {
|
|
71
|
+
this.#inbox.push(m);
|
|
72
|
+
this.#wake();
|
|
73
|
+
},
|
|
74
|
+
closed: (reason) => {
|
|
75
|
+
this.#closed = reason;
|
|
76
|
+
this.#wake();
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
get extensions() {
|
|
81
|
+
return this.#extensions ?? [];
|
|
82
|
+
}
|
|
83
|
+
#wake() {
|
|
84
|
+
const waiting = this.#waiters;
|
|
85
|
+
this.#waiters = [];
|
|
86
|
+
for (const w of waiting)
|
|
87
|
+
w();
|
|
88
|
+
}
|
|
89
|
+
async #until(take, what) {
|
|
90
|
+
let timedOut = false;
|
|
91
|
+
this.#timeout?.().then(() => {
|
|
92
|
+
timedOut = true;
|
|
93
|
+
this.#wake();
|
|
94
|
+
}, () => undefined);
|
|
95
|
+
for (;;) {
|
|
96
|
+
const v = take();
|
|
97
|
+
if (v !== undefined)
|
|
98
|
+
return v;
|
|
99
|
+
if (this.#closed !== null)
|
|
100
|
+
throw new IdentityUnavailable(`the connection closed while waiting for ${what}: ${this.#closed}`);
|
|
101
|
+
if (timedOut)
|
|
102
|
+
throw new IdentityUnavailable(`timed out waiting for ${what}`);
|
|
103
|
+
await new Promise((resolve) => this.#waiters.push(resolve));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async connect() {
|
|
107
|
+
this.#connection.connect();
|
|
108
|
+
await this.#until(() => (this.#extensions !== null ? true : undefined), "READY");
|
|
109
|
+
}
|
|
110
|
+
/** Sends a request and returns its correlated replies, until `done` says the last one arrived. */
|
|
111
|
+
async #request(m, what, done = () => true) {
|
|
112
|
+
this.#connection.send(m);
|
|
113
|
+
const id = toHex(m.messageId);
|
|
114
|
+
const replies = [];
|
|
115
|
+
for (;;) {
|
|
116
|
+
const reply = await this.#until(() => {
|
|
117
|
+
const i = this.#inbox.findIndex((r) => r.correlationId !== undefined && toHex(r.correlationId) === id);
|
|
118
|
+
return i === -1 ? undefined : this.#inbox.splice(i, 1)[0];
|
|
119
|
+
}, what);
|
|
120
|
+
replies.push(reply);
|
|
121
|
+
if (reply.type === "NACK" || done(reply))
|
|
122
|
+
return replies;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
#answer(reply) {
|
|
126
|
+
if (reply?.type === "ACK" || reply?.type === "RESOURCE_HOSTED")
|
|
127
|
+
return { kind: "ack" };
|
|
128
|
+
if (reply?.type === "NACK") {
|
|
129
|
+
const head = reply.body.details;
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
kind: "nack",
|
|
132
|
+
code: codeName(reply.body.code),
|
|
133
|
+
...(head instanceof Uint8Array && head.length === 32
|
|
134
|
+
? { head: controlRecordId(head) }
|
|
135
|
+
: {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
throw new IdentityUnavailable(`unexpected answer ${reply?.type ?? "none"}`);
|
|
139
|
+
}
|
|
140
|
+
#refuse(reply, what) {
|
|
141
|
+
if (reply.type === "NACK")
|
|
142
|
+
throw new IdentityRefused(codeName(reply.body.code), `${what} was refused`);
|
|
143
|
+
throw new IdentityUnavailable(`${what} was answered with ${reply.type}`);
|
|
144
|
+
}
|
|
145
|
+
async host(genesis) {
|
|
146
|
+
const [reply] = await this.#request(createMessage("RESOURCE_HOST", { genesis }), "RESOURCE_HOSTED");
|
|
147
|
+
return this.#answer(reply);
|
|
148
|
+
}
|
|
149
|
+
async open(resource) {
|
|
150
|
+
const [reply] = await this.#request(createMessage("RESOURCE_OPEN", { resourceId: resource, heads: [], haves: [] }), "RESOURCE_OPENED");
|
|
151
|
+
if (reply?.type === "RESOURCE_OPENED")
|
|
152
|
+
return Object.freeze({ kind: "opened", heads: reply.body.heads, haves: reply.body.haves });
|
|
153
|
+
if (reply?.type === "NACK")
|
|
154
|
+
return { kind: "nack", code: codeName(reply.body.code) };
|
|
155
|
+
throw new IdentityUnavailable(`RESOURCE_OPEN was answered with ${reply?.type}`);
|
|
156
|
+
}
|
|
157
|
+
async control(resource, start) {
|
|
158
|
+
const opened = await this.open(resource);
|
|
159
|
+
if (opened.kind === "nack")
|
|
160
|
+
throw new IdentityRefused(opened.code, "RESOURCE_OPEN was refused");
|
|
161
|
+
const end = opened.heads.reduce((m, h) => (h.seq > m ? h.seq : m), -1n);
|
|
162
|
+
if (end < start)
|
|
163
|
+
return [];
|
|
164
|
+
const out = [];
|
|
165
|
+
const replies = await this.#request(createMessage("CONTROL_GET", { resourceId: resource, start, end }), "CONTROL_BATCH", (r) => {
|
|
166
|
+
if (r.type === "CONTROL_BATCH")
|
|
167
|
+
out.push(...r.body.objects);
|
|
168
|
+
return out.length >= Number(end - start + 1n);
|
|
169
|
+
});
|
|
170
|
+
const last = replies.at(-1);
|
|
171
|
+
if (last.type === "NACK")
|
|
172
|
+
this.#refuse(last, "CONTROL_GET");
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
async putControl(resource, expectedHead, record) {
|
|
176
|
+
const [reply] = await this.#request(createMessage("CONTROL_PUT", { resourceId: resource, expectedHead, record }), "the CONTROL_PUT answer");
|
|
177
|
+
return this.#answer(reply);
|
|
178
|
+
}
|
|
179
|
+
async keyPackages(resource, recipient, epochs) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (let i = 0; i < epochs.length; i += MAX_EPOCHS_PER_GET) {
|
|
182
|
+
const [reply] = await this.#request(createMessage("KEY_PACKAGE_GET", {
|
|
183
|
+
resourceId: resource,
|
|
184
|
+
recipient,
|
|
185
|
+
epochs: epochs.slice(i, i + MAX_EPOCHS_PER_GET),
|
|
186
|
+
}), "KEY_PACKAGE_BATCH");
|
|
187
|
+
if (reply?.type !== "KEY_PACKAGE_BATCH")
|
|
188
|
+
this.#refuse(reply, "KEY_PACKAGE_GET");
|
|
189
|
+
out.push(...reply.body.objects);
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
async putKeyPackage(resource, bytes) {
|
|
194
|
+
const [reply] = await this.#request(createMessage("KEY_PACKAGE_PUT", { resourceId: resource, objects: [bytes] }), "the KEY_PACKAGE_PUT answer");
|
|
195
|
+
return this.#answer(reply);
|
|
196
|
+
}
|
|
197
|
+
async dataUnits(resource, ranges) {
|
|
198
|
+
if (ranges.length === 0)
|
|
199
|
+
return [];
|
|
200
|
+
const want = ranges.reduce((n, r) => n + Number(r.end - r.start + 1n), 0);
|
|
201
|
+
const out = [];
|
|
202
|
+
const replies = await this.#request(createMessage("DATA_GET", { resourceId: resource, ranges }), "DATA_BATCH", (r) => {
|
|
203
|
+
if (r.type === "DATA_BATCH")
|
|
204
|
+
out.push(...r.body.objects);
|
|
205
|
+
// A server may hold fewer units than asked; an empty batch ends the answer.
|
|
206
|
+
return out.length >= want || (r.type === "DATA_BATCH" && r.body.objects.length === 0);
|
|
207
|
+
});
|
|
208
|
+
const last = replies.at(-1);
|
|
209
|
+
if (last.type === "NACK")
|
|
210
|
+
this.#refuse(last, "DATA_GET");
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
async putDataUnit(resource, bytes) {
|
|
214
|
+
const [reply] = await this.#request(createMessage("DATA_PUT", { resourceId: resource, objects: [bytes] }), "the DATA_PUT answer");
|
|
215
|
+
return this.#answer(reply);
|
|
216
|
+
}
|
|
217
|
+
close() {
|
|
218
|
+
this.#connection.close("the identity flow finished");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** The identity network over LfcpConnection: one session per (Principal, coordinator). */
|
|
222
|
+
export function connectionNetwork(options) {
|
|
223
|
+
return {
|
|
224
|
+
async connect(signer, url) {
|
|
225
|
+
const s = new RequestSession({
|
|
226
|
+
url,
|
|
227
|
+
signer,
|
|
228
|
+
now: options.now,
|
|
229
|
+
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
|
|
230
|
+
...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }),
|
|
231
|
+
});
|
|
232
|
+
try {
|
|
233
|
+
await s.connect();
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
s.close();
|
|
237
|
+
throw e;
|
|
238
|
+
}
|
|
239
|
+
return s;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=network.js.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type CertificateChain, type DeviceCertificatePayload } from "@openlfcp/wire";
|
|
3
|
+
import type { FlowOptions } from "./devices.js";
|
|
4
|
+
import type { PairingLinkText } from "./pairing.js";
|
|
5
|
+
import type { LinearChain } from "./phases.js";
|
|
6
|
+
import { type IdentityDocumentFactory } from "./resource.js";
|
|
7
|
+
/**
|
|
8
|
+
* Pairing a new installation, the new side `B` (IDENTITY-PROFILE-01 §5.3
|
|
9
|
+
* steps 2, 3, 8 and 11; §6). `B` opens the Identity Resource as the
|
|
10
|
+
* pairing device `J` of the link, verifies it, persists its candidates and
|
|
11
|
+
* writes its request as `J`; later it learns its result from the chain
|
|
12
|
+
* alone: while `J` is active the request waits; once `J` is revoked the
|
|
13
|
+
* revoked-reader prefix (W §41) says whether the session was consumed
|
|
14
|
+
* (reason 3, with the approval digest `d`) or cancelled. The first
|
|
15
|
+
* verified certificate naming `D_B` whose payload digest is `d` decides;
|
|
16
|
+
* a timeout never does.
|
|
17
|
+
*/
|
|
18
|
+
export interface RequestPairingOptions extends FlowOptions {
|
|
19
|
+
readonly link: string | PairingLinkText;
|
|
20
|
+
readonly label: string;
|
|
21
|
+
/** 0 personal, 1 service. */
|
|
22
|
+
readonly kind: 0 | 1;
|
|
23
|
+
/** Service: the Resources its human chose from the roster. */
|
|
24
|
+
readonly scope?: readonly ResourceId[];
|
|
25
|
+
/**
|
|
26
|
+
* Service, headless (§5.3 "Headless installation"): the Resources its
|
|
27
|
+
* human chose, by roster name, resolved on the roster read as `J` once
|
|
28
|
+
* the link is verified, and added to `scope`. A name that matches no
|
|
29
|
+
* roster entry, or several, writes no request (`unknown-sections`).
|
|
30
|
+
*/
|
|
31
|
+
readonly sections?: readonly string[];
|
|
32
|
+
readonly platform?: string;
|
|
33
|
+
/** RFC 3339 UTC for `requested_at`. */
|
|
34
|
+
readonly requestedAt?: string;
|
|
35
|
+
readonly document: IdentityDocumentFactory;
|
|
36
|
+
}
|
|
37
|
+
export type RequestPairingResult = {
|
|
38
|
+
readonly kind: "requested";
|
|
39
|
+
readonly operation: string;
|
|
40
|
+
readonly identity: ResourceId;
|
|
41
|
+
/** §5.2: show it; the human compares it with A's screen. */
|
|
42
|
+
readonly fingerprint: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The link is rejected (§5.2): it does not parse, or its secret is not its
|
|
46
|
+
* device's. Like `not-a-session`, a client-local outcome with no wire code
|
|
47
|
+
* (W §62; the corpora name it PAIRING_LINK_REJECTED): nothing more is sent
|
|
48
|
+
* as `J` and no request is written.
|
|
49
|
+
*/
|
|
50
|
+
| {
|
|
51
|
+
readonly kind: "invalid-link";
|
|
52
|
+
readonly reason: string;
|
|
53
|
+
}
|
|
54
|
+
/** The link is rejected (§5.2): `I` is not an Identity Resource, or `J` is not an active pairing device of its owner. */
|
|
55
|
+
| {
|
|
56
|
+
readonly kind: "not-a-session";
|
|
57
|
+
readonly reason: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A requested section name matches no roster entry, or several (§5.3
|
|
61
|
+
* headless): nothing is persisted or written. `available` lists the
|
|
62
|
+
* names of the roster Resources the device may request.
|
|
63
|
+
*/
|
|
64
|
+
| {
|
|
65
|
+
readonly kind: "unknown-sections";
|
|
66
|
+
readonly unknown: readonly string[];
|
|
67
|
+
readonly available: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
/** No DEK of `I` or no root unit 1: the document cannot be verified (§5.3 step 2). */
|
|
70
|
+
| {
|
|
71
|
+
readonly kind: "missing-material";
|
|
72
|
+
readonly missing: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
/** The coordinator refused the request unit (e.g. ACTOR_EQUIVOCATION: another installation used the link at once). */
|
|
75
|
+
| {
|
|
76
|
+
readonly kind: "refused";
|
|
77
|
+
readonly operation: string;
|
|
78
|
+
readonly code: string;
|
|
79
|
+
} | {
|
|
80
|
+
readonly kind: "unavailable";
|
|
81
|
+
readonly operation?: string;
|
|
82
|
+
readonly reason: string;
|
|
83
|
+
};
|
|
84
|
+
/** §5.3 steps 2–3. */
|
|
85
|
+
export declare function requestPairing(o: RequestPairingOptions): Promise<RequestPairingResult>;
|
|
86
|
+
/** §6: the status of one Resource for the new device, from its own observations only. */
|
|
87
|
+
export type CandidateStatus = "ready" | "waiting-for-keys" | "pending-registration" | "not-granted" | "unproven" | "unreachable" | "unsupported";
|
|
88
|
+
export interface CandidateResult {
|
|
89
|
+
readonly resource: ResourceId;
|
|
90
|
+
readonly status: CandidateStatus;
|
|
91
|
+
/** The roster name persisted with the candidate (§5.3 step 3), for display; absent for I. */
|
|
92
|
+
readonly name?: string;
|
|
93
|
+
}
|
|
94
|
+
export type PairingOutcome =
|
|
95
|
+
/** `J` is still active: the request waits for the managing device (§5.3 step 3). */
|
|
96
|
+
{
|
|
97
|
+
readonly kind: "waiting";
|
|
98
|
+
}
|
|
99
|
+
/** The session was cancelled (reason 0): final (§5.3 step 8). */
|
|
100
|
+
| {
|
|
101
|
+
readonly kind: "rejected";
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The session was consumed. Without a certificate yet: "approved,
|
|
105
|
+
* waiting for registration"; with it, each candidate's status (§6).
|
|
106
|
+
*/
|
|
107
|
+
| {
|
|
108
|
+
readonly kind: "approved";
|
|
109
|
+
readonly certificate: CertificateChain | null;
|
|
110
|
+
readonly candidates: readonly CandidateResult[];
|
|
111
|
+
/** Certificates naming D_B with another digest: reported, never used (§5.3 step 8). */
|
|
112
|
+
readonly foreignCertificates: number;
|
|
113
|
+
}
|
|
114
|
+
/** `J` is no longer readable and no revoked-reader proof is available yet. */
|
|
115
|
+
| {
|
|
116
|
+
readonly kind: "unknown";
|
|
117
|
+
readonly reason: string;
|
|
118
|
+
} | {
|
|
119
|
+
readonly kind: "unavailable";
|
|
120
|
+
readonly reason: string;
|
|
121
|
+
};
|
|
122
|
+
/** What a candidate's own chain proves about the device's approved certificate (§5.3 step 8). */
|
|
123
|
+
export interface CandidateEvidence {
|
|
124
|
+
/** The certificate IDs of every accepted leaf naming the device, oldest first. */
|
|
125
|
+
readonly leaves: readonly Hash32[];
|
|
126
|
+
/** A leaf whose payload digest is the approval was accepted here at some point. */
|
|
127
|
+
readonly proven: boolean;
|
|
128
|
+
/** Registered and active here, or still pending registration. */
|
|
129
|
+
readonly registration: "active" | "pending";
|
|
130
|
+
/**
|
|
131
|
+
* The device may use this Resource: proven, active, and its current leaf
|
|
132
|
+
* has the result's kind, mask and scope (N8). A Resource where the device
|
|
133
|
+
* is registered without proof is "approval not provable here".
|
|
134
|
+
*/
|
|
135
|
+
readonly usable: boolean;
|
|
136
|
+
}
|
|
137
|
+
export declare function candidateEvidence(chain: LinearChain, device: PrincipalId, digest: Hash32, result: DeviceCertificatePayload): CandidateEvidence;
|
|
138
|
+
/**
|
|
139
|
+
* §5.3 steps 8 and 11: learns the session's result and the status of
|
|
140
|
+
* every candidate. Resumable at any point from the persisted candidates
|
|
141
|
+
* (and while `J` is active, from the link's secret in the SecretStore).
|
|
142
|
+
*/
|
|
143
|
+
export declare function completePairing(o: FlowOptions & {
|
|
144
|
+
readonly operation: string;
|
|
145
|
+
readonly document: IdentityDocumentFactory;
|
|
146
|
+
}): Promise<PairingOutcome>;
|
|
147
|
+
//# sourceMappingURL=pairing-request.d.ts.map
|