@openlfcp/client 0.4.0 → 0.5.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/identity/context.d.ts +33 -1
- package/dist/identity/context.js +98 -11
- package/dist/identity/document.d.ts +23 -0
- package/dist/identity/index.d.ts +3 -3
- package/dist/identity/index.js +2 -2
- package/dist/identity/pairing-request.d.ts +12 -0
- package/dist/identity/pairing-request.js +40 -9
- package/dist/identity/pairing.d.ts +11 -0
- package/dist/identity/pairing.js +16 -2
- package/dist/identity/reconcile.d.ts +73 -3
- package/dist/identity/reconcile.js +163 -35
- package/dist/identity/revocation.d.ts +11 -1
- package/dist/identity/revocation.js +66 -12
- package/dist/identity/rotation.d.ts +9 -1
- package/dist/identity/rotation.js +8 -3
- package/package.json +5 -5
|
@@ -4,7 +4,7 @@ import { type LfcpStorage, type SecretStore } from "@openlfcp/storage";
|
|
|
4
4
|
import { type DataUnitPayload, type Parsed, type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
|
|
5
5
|
import { type IdentityDocumentPort, type IdentityIntent } from "./document.js";
|
|
6
6
|
import { type JournalPhase, type OperationJournal, type PackageLedger } from "./journal.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type IdentityChannel } from "./network.js";
|
|
8
8
|
import type { LinearChain, PhaseState } from "./phases.js";
|
|
9
9
|
/** The storage the identity flows use. */
|
|
10
10
|
export type IdentityStorage = Pick<LfcpStorage, "control" | "commit" | "localMarks" | "actorSequences">;
|
|
@@ -61,6 +61,13 @@ export interface PhaseOutcome {
|
|
|
61
61
|
* with the same ACK, W §47), a record rebuilt on a new head otherwise.
|
|
62
62
|
*/
|
|
63
63
|
export declare function runControlPhase(storage: IdentityStorage, channel: IdentityChannel, journal: OperationJournal, operation: string, spec: ControlPhaseSpec): Promise<PhaseOutcome>;
|
|
64
|
+
/**
|
|
65
|
+
* The client-local refusal code of a device record or same-root Key Package
|
|
66
|
+
* this client does not send to a server whose READY lacks `identity-01`
|
|
67
|
+
* (LFCP-WIRE-01 §37): the Resource is hosted on a server without identity
|
|
68
|
+
* support (IDENTITY-PROFILE-01 §6). No wire code.
|
|
69
|
+
*/
|
|
70
|
+
export declare const IDENTITY_UNSUPPORTED = "IDENTITY_UNSUPPORTED";
|
|
64
71
|
/** The DEKs this client holds for `chain`'s epochs, each checked against its commitment. */
|
|
65
72
|
export declare function heldDeks(secrets: SecretStore, chain: LinearChain): Promise<Map<string, ResourceDEK>>;
|
|
66
73
|
/** Stores the DEK of `epoch` (secret first) and links the epoch rows to it (LFCP-034). */
|
|
@@ -72,6 +79,16 @@ export declare function storeDek(storage: IdentityStorage, secrets: SecretStore,
|
|
|
72
79
|
export declare function fetchDeks(storage: IdentityStorage, secrets: SecretStore, channel: IdentityChannel, chain: LinearChain, recipient: Installation): Promise<Map<string, ResourceDEK>>;
|
|
73
80
|
/** Every Principal with `holds(P, 1)` at the head (W §19, §25.2): the reader set, by ID. */
|
|
74
81
|
export declare function readersAt(state: LinearChain["state"]): readonly PrincipalDescriptor[];
|
|
82
|
+
/**
|
|
83
|
+
* IDENTITY-PROFILE-01 §7.1 step 1: whether `principal`, a reader of the
|
|
84
|
+
* target state, is a device that the Identity Resource `identity` has
|
|
85
|
+
* revoked, or whose current chain in the target passes through such a
|
|
86
|
+
* device. Until the pair is propagated the target still counts the device
|
|
87
|
+
* as active (an unpropagated pair), but the installations of `identity`'s
|
|
88
|
+
* root treat it as revoked there and seal nothing to it, in a rotation as
|
|
89
|
+
* in step 3.
|
|
90
|
+
*/
|
|
91
|
+
export declare function revokedByIdentity(identity: LinearChain, target: LinearChain["state"], principal: PrincipalId): boolean;
|
|
75
92
|
export interface KeyDelivery {
|
|
76
93
|
/** Packages sealed and acknowledged now, as (epoch, recipient hex). */
|
|
77
94
|
readonly sent: readonly {
|
|
@@ -136,6 +153,14 @@ export interface IdentityDocumentLoad {
|
|
|
136
153
|
}
|
|
137
154
|
/** The Genesis owner of a chain: the root of an Identity Resource (§2.3). */
|
|
138
155
|
export declare function genesisOwner(chain: LinearChain): PrincipalId;
|
|
156
|
+
/**
|
|
157
|
+
* Whether the retired entry `devices[key]` needs its flag (§7.1 step 4):
|
|
158
|
+
* some value of its `revoked` is not `true`, or the field is absent. A port
|
|
159
|
+
* without `retirement` shows one value. False when there is no valid entry.
|
|
160
|
+
*/
|
|
161
|
+
export declare function retiredFlagMissing(entries: ReadonlyMap<string, {
|
|
162
|
+
readonly revoked?: boolean;
|
|
163
|
+
}>, key: string, revokedValues?: (key: string) => readonly unknown[]): boolean;
|
|
139
164
|
/**
|
|
140
165
|
* A new unit of `signer` in the Identity Resource carrying `intents` as
|
|
141
166
|
* one change: the next actor sequence from the storage's reservation
|
|
@@ -150,6 +175,13 @@ export declare function sealIdentityUnit(options: {
|
|
|
150
175
|
readonly doc: IdentityDocumentPort;
|
|
151
176
|
readonly latest: IdentityDocumentLoad["latest"];
|
|
152
177
|
readonly intents: readonly IdentityIntent[];
|
|
178
|
+
/**
|
|
179
|
+
* Retired entries whose `revoked` flag the change sets (§7.1 step 4,
|
|
180
|
+
* §8.2): field by field when the port has `retirement`, else with
|
|
181
|
+
* `device.put` of the entry's display fields (its `missing_keys` hint is
|
|
182
|
+
* the device's own, §3 AI5).
|
|
183
|
+
*/
|
|
184
|
+
readonly retire?: readonly string[];
|
|
153
185
|
/** The sequence to use instead of a reservation (a pairing device shared by installations, §5.3). */
|
|
154
186
|
readonly seq?: bigint;
|
|
155
187
|
}): Promise<Uint8Array>;
|
package/dist/identity/context.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { actorSequence, bytesEqual, controlRecordId, dataEpoch, fromHex, hash32, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
2
|
import { dekCommitment, exportSecretKeyBytes, importResourceDEK, sha256, } from "@openlfcp/crypto";
|
|
3
3
|
import { dekSecretRef } from "@openlfcp/storage";
|
|
4
|
-
import { ABILITY, hasAbility, InMemorySeenUnits, parseDataUnit, parseKeyPackage, receiveDataUnit, receiveKeyPackage, sealDataUnit, sealKeyPackage, validateControlChain, verifyKeyPackage, verifySignedObject, } from "@openlfcp/wire";
|
|
4
|
+
import { ABILITY, decodeControlRecord, hasAbility, InMemorySeenUnits, parseDataUnit, parseKeyPackage, receiveDataUnit, receiveKeyPackage, revocationOf, sealDataUnit, sealKeyPackage, validateControlChain, verifyKeyPackage, verifySignedObject, } from "@openlfcp/wire";
|
|
5
5
|
import { adoptStoredDeks, checkWriteBarrier, loadControlChain, saveControlChain, saveControlConflict, } from "../storage.js";
|
|
6
6
|
import { identityPlaintextCodec, identityWriterClass, } from "./document.js";
|
|
7
7
|
import { phaseOf, } from "./journal.js";
|
|
8
|
-
import { IdentityUnavailable } from "./network.js";
|
|
8
|
+
import { IDENTITY_EXTENSION, IdentityUnavailable } from "./network.js";
|
|
9
9
|
/** The Resource or the Identity Resource is forked (§9.7): approval, reconciliation and rotation are suspended. */
|
|
10
10
|
export class IdentityConflict extends Error {
|
|
11
11
|
resource;
|
|
@@ -104,11 +104,22 @@ export async function runControlPhase(storage, channel, journal, operation, spec
|
|
|
104
104
|
if (attempt >= MAX_HEAD_RETRIES)
|
|
105
105
|
throw new IdentityUnavailable(`the head kept moving while proposing ${spec.id}`);
|
|
106
106
|
const head = toHex(chain.state.head);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
bytes
|
|
107
|
+
const journaled = spec.resend !== false && before?.bytes !== undefined && before.expectedHead === head;
|
|
108
|
+
const bytes = journaled ? fromHex(before.bytes) : await spec.build(chain);
|
|
109
|
+
if (!channel.extensions.includes(IDENTITY_EXTENSION) && isDeviceRecord(bytes)) {
|
|
110
|
+
// W §37: no record of type 9 or 10 to a server whose READY lacks identity-01,
|
|
111
|
+
// journaled bytes included (a resumed operation checks the current READY).
|
|
112
|
+
await journal.putPhase(operation, {
|
|
113
|
+
...(before ?? {}),
|
|
114
|
+
id: spec.id,
|
|
115
|
+
kind: spec.kind,
|
|
116
|
+
resource: toHex(R),
|
|
117
|
+
state: "refused",
|
|
118
|
+
code: IDENTITY_UNSUPPORTED,
|
|
119
|
+
});
|
|
120
|
+
return Object.freeze({ state: "refused", chain, code: IDENTITY_UNSUPPORTED });
|
|
121
|
+
}
|
|
122
|
+
if (!journaled) {
|
|
112
123
|
const id = toHex(validateRecordId(bytes));
|
|
113
124
|
await journal.putPhase(operation, {
|
|
114
125
|
id: spec.id,
|
|
@@ -136,6 +147,18 @@ export async function runControlPhase(storage, channel, journal, operation, spec
|
|
|
136
147
|
return Object.freeze({ state: "refused", chain, code: answer.code });
|
|
137
148
|
}
|
|
138
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* The client-local refusal code of a device record or same-root Key Package
|
|
152
|
+
* this client does not send to a server whose READY lacks `identity-01`
|
|
153
|
+
* (LFCP-WIRE-01 §37): the Resource is hosted on a server without identity
|
|
154
|
+
* support (IDENTITY-PROFILE-01 §6). No wire code.
|
|
155
|
+
*/
|
|
156
|
+
export const IDENTITY_UNSUPPORTED = "IDENTITY_UNSUPPORTED";
|
|
157
|
+
/** A DEVICE_GRANT (type 9) or DEVICE_REVOKE (type 10). */
|
|
158
|
+
function isDeviceRecord(bytes) {
|
|
159
|
+
const type = decodeControlRecord(bytes).body.type;
|
|
160
|
+
return type === "DEVICE_GRANT" || type === "DEVICE_REVOKE";
|
|
161
|
+
}
|
|
139
162
|
/** A Control Record's ID: SHA-256 of its exact bytes (W §13). */
|
|
140
163
|
const validateRecordId = (bytes) => controlRecordId(sha256(bytes));
|
|
141
164
|
function ownRecordOn(chain, sent) {
|
|
@@ -219,6 +242,23 @@ export function readersAt(state) {
|
|
|
219
242
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
220
243
|
.map(([, d]) => d));
|
|
221
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* IDENTITY-PROFILE-01 §7.1 step 1: whether `principal`, a reader of the
|
|
247
|
+
* target state, is a device that the Identity Resource `identity` has
|
|
248
|
+
* revoked, or whose current chain in the target passes through such a
|
|
249
|
+
* device. Until the pair is propagated the target still counts the device
|
|
250
|
+
* as active (an unpropagated pair), but the installations of `identity`'s
|
|
251
|
+
* root treat it as revoked there and seal nothing to it, in a rotation as
|
|
252
|
+
* in step 3.
|
|
253
|
+
*/
|
|
254
|
+
export function revokedByIdentity(identity, target, principal) {
|
|
255
|
+
const R = genesisOwner(identity);
|
|
256
|
+
const revoked = (d) => revocationOf(identity.state, R, d) !== undefined;
|
|
257
|
+
if (revoked(principal))
|
|
258
|
+
return true;
|
|
259
|
+
const binding = target.devices.get(toHex(principal));
|
|
260
|
+
return binding !== undefined && bytesEqual(binding.root, R) && binding.issuers.some(revoked);
|
|
261
|
+
}
|
|
222
262
|
/**
|
|
223
263
|
* Seals every DEK of `deks` to each recipient at the chain's head and
|
|
224
264
|
* records each acknowledged package (§9.5). A package the ledger records is
|
|
@@ -233,6 +273,21 @@ export async function deliverKeys(options) {
|
|
|
233
273
|
const skipped = [];
|
|
234
274
|
let complete = 0;
|
|
235
275
|
const epochs = [...options.deks.keys()].sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1));
|
|
276
|
+
// W §37: without identity-01 the server applies no same-root rule (W §25.2); a sender
|
|
277
|
+
// without key/distribute is authorized only by it, and sends nothing there.
|
|
278
|
+
const identity = options.channel.extensions.includes(IDENTITY_EXTENSION);
|
|
279
|
+
const sameRootOnly = !identity && !hasAbility(chain.state, sender.descriptor.principalId, ABILITY.KEY_DISTRIBUTE);
|
|
280
|
+
// The same for a pending package, at its own referenced head: a later key/distribute does
|
|
281
|
+
// not authorize it there (W §25.2), so it is re-sealed at the current head instead.
|
|
282
|
+
const resendable = (bytes) => {
|
|
283
|
+
const parsed = parseKeyPackage(bytes);
|
|
284
|
+
if (verifyKeyPackage(chain, parsed).kind !== "authorized")
|
|
285
|
+
return false;
|
|
286
|
+
if (identity)
|
|
287
|
+
return true;
|
|
288
|
+
const atHead = chain.stateAt(parsed.payload.controlHead);
|
|
289
|
+
return (atHead !== undefined && hasAbility(atHead, parsed.payload.sender, ABILITY.KEY_DISTRIBUTE));
|
|
290
|
+
};
|
|
236
291
|
for (const recipient of options.recipients) {
|
|
237
292
|
const rid = recipient.principalId;
|
|
238
293
|
const hex = toHex(rid);
|
|
@@ -242,6 +297,10 @@ export async function deliverKeys(options) {
|
|
|
242
297
|
skipped.push({ recipient: hex, reason: "not a reader at the head" });
|
|
243
298
|
continue;
|
|
244
299
|
}
|
|
300
|
+
if (sameRootOnly) {
|
|
301
|
+
skipped.push({ recipient: hex, reason: IDENTITY_UNSUPPORTED });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
245
304
|
for (const e of epochs) {
|
|
246
305
|
const epoch = dataEpoch(BigInt(e));
|
|
247
306
|
if ((await ledger.has(R, epoch, rid)) && !(options.resend?.get(hex)?.has(e) ?? false)) {
|
|
@@ -250,8 +309,7 @@ export async function deliverKeys(options) {
|
|
|
250
309
|
}
|
|
251
310
|
// §9.1: a package sent before is sent again as the same bytes, while it is still authorized.
|
|
252
311
|
let bytes = await ledger.pending(R, epoch, rid);
|
|
253
|
-
if (bytes !== undefined &&
|
|
254
|
-
verifyKeyPackage(chain, parseKeyPackage(bytes)).kind !== "authorized")
|
|
312
|
+
if (bytes !== undefined && !resendable(bytes))
|
|
255
313
|
bytes = undefined;
|
|
256
314
|
if (bytes === undefined) {
|
|
257
315
|
const sealed = await sealKeyPackage({
|
|
@@ -368,6 +426,28 @@ export function genesisOwner(chain) {
|
|
|
368
426
|
const g = chain.records[0];
|
|
369
427
|
return g?.body.type === "GENESIS" ? g.body.owner.principalId : chain.state.owner.principalId;
|
|
370
428
|
}
|
|
429
|
+
/** 0.4's flag write on a port without `retirement`: the entry's display fields and the flag. */
|
|
430
|
+
function retiredPut(doc, key) {
|
|
431
|
+
const entry = doc.devices().get(key);
|
|
432
|
+
if (entry === undefined)
|
|
433
|
+
return [];
|
|
434
|
+
const { missing_keys: _hint, ...display } = entry;
|
|
435
|
+
return [{ intent: "device.put", key, entry: { ...display, revoked: true } }];
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Whether the retired entry `devices[key]` needs its flag (§7.1 step 4):
|
|
439
|
+
* some value of its `revoked` is not `true`, or the field is absent. A port
|
|
440
|
+
* without `retirement` shows one value. False when there is no valid entry.
|
|
441
|
+
*/
|
|
442
|
+
export function retiredFlagMissing(entries, key, revokedValues) {
|
|
443
|
+
const entry = entries.get(key);
|
|
444
|
+
if (entry === undefined)
|
|
445
|
+
return false;
|
|
446
|
+
if (revokedValues === undefined)
|
|
447
|
+
return entry.revoked !== true;
|
|
448
|
+
const values = revokedValues(key);
|
|
449
|
+
return values.length === 0 || values.some((v) => v !== true);
|
|
450
|
+
}
|
|
371
451
|
/**
|
|
372
452
|
* A new unit of `signer` in the Identity Resource carrying `intents` as
|
|
373
453
|
* one change: the next actor sequence from the storage's reservation
|
|
@@ -390,11 +470,18 @@ export async function sealIdentityUnit(options) {
|
|
|
390
470
|
while (previous !== undefined && seq <= previous.seq)
|
|
391
471
|
seq = await options.storage.actorSequences.reserveNext(R, me);
|
|
392
472
|
}
|
|
393
|
-
const
|
|
473
|
+
const author = {
|
|
394
474
|
signer: me,
|
|
395
475
|
actorSeq: seq,
|
|
396
476
|
writerClass: identityWriterClass(chain.state, genesisOwner(chain), me),
|
|
397
|
-
}
|
|
477
|
+
};
|
|
478
|
+
const retire = options.retire ?? [];
|
|
479
|
+
const { doc } = options;
|
|
480
|
+
const plaintext = retire.length === 0
|
|
481
|
+
? doc.write(author, options.intents)
|
|
482
|
+
: doc.retirement !== undefined
|
|
483
|
+
? doc.retirement.write(author, options.intents, retire)
|
|
484
|
+
: doc.write(author, [...options.intents, ...retire.flatMap((key) => retiredPut(doc, key))]);
|
|
398
485
|
return sealDataUnit({
|
|
399
486
|
resourceId: R,
|
|
400
487
|
dataEpoch: epoch,
|
|
@@ -95,6 +95,29 @@ export interface IdentityDocumentPort {
|
|
|
95
95
|
readonly hash: string;
|
|
96
96
|
readonly signer: PrincipalId;
|
|
97
97
|
}[];
|
|
98
|
+
/**
|
|
99
|
+
* Optional (0.5.0): a device entry's `revoked` flag field by field, for
|
|
100
|
+
* a retired entry (§7.1 step 4, §8.2). With it, reconciliation sees every
|
|
101
|
+
* concurrent value of the flag and sets it without writing the entry's
|
|
102
|
+
* other fields, so it never resolves a conflict on them (a concurrent
|
|
103
|
+
* rename). A port without it shows one value per field (`devices()`), and
|
|
104
|
+
* the flag is written with `device.put` of the entry's display fields, as
|
|
105
|
+
* in 0.4. IdentityReplica provides both (`fieldValues`, `write`'s
|
|
106
|
+
* `retire` option).
|
|
107
|
+
*/
|
|
108
|
+
readonly retirement?: IdentityRetirementPort;
|
|
109
|
+
}
|
|
110
|
+
/** IdentityDocumentPort.retirement. */
|
|
111
|
+
export interface IdentityRetirementPort {
|
|
112
|
+
/** Every value `devices[key].revoked` holds now: several under concurrent writes, none when absent. */
|
|
113
|
+
revokedValues(key: string): readonly unknown[];
|
|
114
|
+
/**
|
|
115
|
+
* Like IdentityDocumentPort.write, the plaintext of one change by
|
|
116
|
+
* `author`: `intents` in order (possibly none), then `revoked = true` in
|
|
117
|
+
* each existing entry `devices[key]` of `retire`, no other field of those
|
|
118
|
+
* entries written.
|
|
119
|
+
*/
|
|
120
|
+
write(author: IdentityAuthor, intents: readonly IdentityIntent[], retire: readonly string[]): Uint8Array;
|
|
98
121
|
}
|
|
99
122
|
/** A passthrough Data Unit codec: the port produces and consumes the plaintext. */
|
|
100
123
|
export declare const identityPlaintextCodec: DataProfileCodec<Uint8Array>;
|
package/dist/identity/index.d.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
export { acceptInvitationAsDevice, claimForRootRecord, createResourceAsDevice, type DeviceAcceptResult, type DeviceCreateResult, type DeviceResourceResult, type IncompleteDeviceResource, selfRegistrationRecord, } from "./accept.js";
|
|
3
3
|
export { type DocumentWrite, deliverKeys, extendChain, fetchDeks, genesisOwner, heldDeks, IdentityConflict, type IdentityDocumentLoad, type IdentityStorage, type Installation, type KeyDelivery, loadIdentityDocument, type PhaseOutcome, readersAt, runControlPhase, runUnitPhase, sealIdentityUnit, storeDek, syncChain, } from "./context.js";
|
|
4
4
|
export { checkIssuance, type Feasibility, type FlowOptions, issuerCertificates, leafPayload, MASK_PRESETS, type MaskPreset, mayManageDevices, type RegisterTarget, type RegistrationResult, type RegistrationStatus, registerDevice, signChain, type UnitProfile, } from "./devices.js";
|
|
5
|
-
export { type DeviceEntryValue, IDENTITY_PROFILE, type IdentityAuthor, type IdentityDocumentPort, type IdentityIntent, type IdentityWriterClass, identityPlaintextCodec, identityWriterClass, type PairingRequestValue, principalOfRef, principalRefOf, type RosterEntryValue, resourceRef, } from "./document.js";
|
|
5
|
+
export { type DeviceEntryValue, IDENTITY_PROFILE, type IdentityAuthor, type IdentityDocumentPort, type IdentityIntent, type IdentityRetirementPort, type IdentityWriterClass, identityPlaintextCodec, identityWriterClass, type PairingRequestValue, principalOfRef, principalRefOf, type RosterEntryValue, resourceRef, } from "./document.js";
|
|
6
6
|
export { type JournalOperation, type JournalPhase, type LedgerEntry, OperationJournal, type OperationKind, type OperationStatus, PackageLedger, pairingSecretRef, pendingDekRef, } from "./journal.js";
|
|
7
7
|
export { ChannelPool, type ConnectionNetworkOptions, connectionNetwork, IDENTITY_EXTENSION, type IdentityAnswer, type IdentityChannel, type IdentityNetwork, IdentityRefused, IdentityUnavailable, type OpenAnswer, } from "./network.js";
|
|
8
8
|
export { type ApprovalResult, approvedGrantRecord, approvePairing, type CancelResult, cancelPairing, cancelRecord, consumeRecord, createPairingSession, openPairingSessions, PairingLinkText, type PairingReview, type PairingSessionResult, pairingRequests, resumeApproval, resumePairingSession, } from "./pairing.js";
|
|
9
|
-
export { type CandidateEvidence, type CandidateResult, type CandidateStatus, candidateEvidence, completePairing, type PairingOutcome, type RequestPairingOptions, type RequestPairingResult, requestPairing, } from "./pairing-request.js";
|
|
9
|
+
export { type CandidateEvidence, type CandidateResult, type CandidateStatus, candidateEvidence, completePairing, type PairingOutcome, pairingLinkRejection, type RequestPairingOptions, type RequestPairingResult, requestPairing, } from "./pairing-request.js";
|
|
10
10
|
export { certificateInScope, classifyCancel, classifyConsume, classifyPackage, classifyRegister, classifyRevoke, classifyRotate, keyEpochAfter, type LinearChain, type PhaseState, type RegisterPhase, recordSeq, } from "./phases.js";
|
|
11
11
|
export { type RevocationStatus, revocationStatus } from "./proof.js";
|
|
12
|
-
export { planReconciliation, type ReconcileStep, reconcileDevices, reconcileRecord, type TargetReconciliation, } from "./reconcile.js";
|
|
12
|
+
export { type DisplayPlan, keyRecipients, planDisplayData, planReconciliation, planRevocations, type ReconcileStep, reconcileDevices, reconcileRecord, revocationRelevant, type TargetReconciliation, } from "./reconcile.js";
|
|
13
13
|
export { type CreatedIdentityResource, type CreateIdentityResourceOptions, createIdentityResource, type IdentityDocumentFactory, type IdentityFlowOptions, identityTarget, localIdentityResource, resumeIdentityCreation, setLocalIdentityResource, } from "./resource.js";
|
|
14
14
|
export { previewRevocation, type ResourcePreview, type RevocationPreview, type RevocationResult, type RevokeDeviceResult, revokeDevice, rotateIfExposed, } from "./revocation.js";
|
|
15
15
|
export { type AddToRosterOptions, type AddToRosterResult, addToRoster, type RosterWrite, writeRosterEntry, } from "./roster.js";
|
package/dist/identity/index.js
CHANGED
|
@@ -6,10 +6,10 @@ export { IDENTITY_PROFILE, identityPlaintextCodec, identityWriterClass, principa
|
|
|
6
6
|
export { OperationJournal, PackageLedger, pairingSecretRef, pendingDekRef, } from "./journal.js";
|
|
7
7
|
export { ChannelPool, connectionNetwork, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
8
8
|
export { approvedGrantRecord, approvePairing, cancelPairing, cancelRecord, consumeRecord, createPairingSession, openPairingSessions, PairingLinkText, pairingRequests, resumeApproval, resumePairingSession, } from "./pairing.js";
|
|
9
|
-
export { candidateEvidence, completePairing, requestPairing, } from "./pairing-request.js";
|
|
9
|
+
export { candidateEvidence, completePairing, pairingLinkRejection, requestPairing, } from "./pairing-request.js";
|
|
10
10
|
export { certificateInScope, classifyCancel, classifyConsume, classifyPackage, classifyRegister, classifyRevoke, classifyRotate, keyEpochAfter, recordSeq, } from "./phases.js";
|
|
11
11
|
export { revocationStatus } from "./proof.js";
|
|
12
|
-
export { planReconciliation, reconcileDevices, reconcileRecord, } from "./reconcile.js";
|
|
12
|
+
export { keyRecipients, planDisplayData, planReconciliation, planRevocations, reconcileDevices, reconcileRecord, revocationRelevant, } from "./reconcile.js";
|
|
13
13
|
export { createIdentityResource, identityTarget, localIdentityResource, resumeIdentityCreation, setLocalIdentityResource, } from "./resource.js";
|
|
14
14
|
export { previewRevocation, revokeDevice, rotateIfExposed, } from "./revocation.js";
|
|
15
15
|
export { addToRoster, writeRosterEntry, } from "./roster.js";
|
|
@@ -81,6 +81,18 @@ export type RequestPairingResult = {
|
|
|
81
81
|
readonly operation?: string;
|
|
82
82
|
readonly reason: string;
|
|
83
83
|
};
|
|
84
|
+
/**
|
|
85
|
+
* §5.2, the checks after the link parses and its secret is its device's
|
|
86
|
+
* (`parsePairingUri`, `pairingDeviceSigner`), on what opening `I` as `J`
|
|
87
|
+
* gave: the session as `J` is refused (W §41), `I`'s Genesis profile is not
|
|
88
|
+
* `org.openlfcp.identity.v1`, or `J` is not a registered, active kind-2
|
|
89
|
+
* device of `I`'s owner in `I`'s chain. Returns why the link is rejected
|
|
90
|
+
* (a client-local outcome, the corpora's PAIRING_LINK_REJECTED), or
|
|
91
|
+
* undefined when it passes; a rejected link is not used any further.
|
|
92
|
+
*/
|
|
93
|
+
export declare function pairingLinkRejection(J: PrincipalId, opened: LinearChain | {
|
|
94
|
+
readonly refused: string;
|
|
95
|
+
}): string | undefined;
|
|
84
96
|
/** §5.3 steps 2–3. */
|
|
85
97
|
export declare function requestPairing(o: RequestPairingOptions): Promise<RequestPairingResult>;
|
|
86
98
|
/** §6: the status of one Resource for the new device, from its own observations only. */
|
|
@@ -5,6 +5,32 @@ import { IDENTITY_PROFILE, principalRefOf, resourceRef, } from "./document.js";
|
|
|
5
5
|
import { newOperationId, OperationJournal, pairingSecretRef, phaseOf } from "./journal.js";
|
|
6
6
|
import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
7
7
|
import { setLocalIdentityResource } from "./resource.js";
|
|
8
|
+
/**
|
|
9
|
+
* §5.2, the checks after the link parses and its secret is its device's
|
|
10
|
+
* (`parsePairingUri`, `pairingDeviceSigner`), on what opening `I` as `J`
|
|
11
|
+
* gave: the session as `J` is refused (W §41), `I`'s Genesis profile is not
|
|
12
|
+
* `org.openlfcp.identity.v1`, or `J` is not a registered, active kind-2
|
|
13
|
+
* device of `I`'s owner in `I`'s chain. Returns why the link is rejected
|
|
14
|
+
* (a client-local outcome, the corpora's PAIRING_LINK_REJECTED), or
|
|
15
|
+
* undefined when it passes; a rejected link is not used any further.
|
|
16
|
+
*/
|
|
17
|
+
export function pairingLinkRejection(J, opened) {
|
|
18
|
+
if ("refused" in opened)
|
|
19
|
+
return `the session as the link's device is refused (${opened.refused})`;
|
|
20
|
+
const state = opened.state;
|
|
21
|
+
if (state.dataProfile !== IDENTITY_PROFILE)
|
|
22
|
+
return `the Resource is not an Identity Resource (its Genesis profile is ${state.dataProfile})`;
|
|
23
|
+
const R = genesisOwner(opened);
|
|
24
|
+
const binding = state.devices.get(toHex(J));
|
|
25
|
+
const j = activeDevice(state, J);
|
|
26
|
+
if (binding === undefined ||
|
|
27
|
+
!binding.registered ||
|
|
28
|
+
j === undefined ||
|
|
29
|
+
j.kind !== DEVICE_KIND.PAIRING ||
|
|
30
|
+
!bytesEqual(j.root, R))
|
|
31
|
+
return "the link's device is not a registered, active pairing device of the owner";
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
8
34
|
/** §5.3 steps 2–3. */
|
|
9
35
|
export async function requestPairing(o) {
|
|
10
36
|
let link;
|
|
@@ -39,16 +65,21 @@ export async function requestPairing(o) {
|
|
|
39
65
|
};
|
|
40
66
|
}
|
|
41
67
|
const channel = await pool.get(J, url);
|
|
42
|
-
|
|
68
|
+
let chain;
|
|
69
|
+
try {
|
|
70
|
+
chain = await syncChain(o.storage, channel, I);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
// §5.2 check 2: the coordinator refuses the session as J (W §41).
|
|
74
|
+
if (!(e instanceof IdentityRefused))
|
|
75
|
+
throw e;
|
|
76
|
+
const reason = pairingLinkRejection(J.descriptor.principalId, { refused: e.code });
|
|
77
|
+
return { kind: "not-a-session", reason: reason ?? e.message };
|
|
78
|
+
}
|
|
43
79
|
const R = genesisOwner(chain);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (j === undefined || j.kind !== DEVICE_KIND.PAIRING || !bytesEqual(j.root, R))
|
|
48
|
-
return {
|
|
49
|
-
kind: "not-a-session",
|
|
50
|
-
reason: "the link's device is not an active pairing device of the owner",
|
|
51
|
-
};
|
|
80
|
+
const rejected = pairingLinkRejection(J.descriptor.principalId, chain);
|
|
81
|
+
if (rejected !== undefined)
|
|
82
|
+
return { kind: "not-a-session", reason: rejected };
|
|
52
83
|
const pairing = { signer: J, agreement: link.secret.agreementKey };
|
|
53
84
|
const deks = await fetchDeks(o.storage, o.secrets, channel, chain, pairing);
|
|
54
85
|
const doc = o.document(I, R);
|
|
@@ -43,6 +43,17 @@ export type PairingSessionResult = {
|
|
|
43
43
|
| {
|
|
44
44
|
readonly kind: "not-authorized";
|
|
45
45
|
readonly reason: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `I`'s coordinator does not list `identity-01` in READY: it is hosted on
|
|
49
|
+
* a server without identity support (IDENTITY-PROFILE-01 §6). Nothing is
|
|
50
|
+
* proposed or uploaded there: no DEVICE_GRANT, no same-root Key Package
|
|
51
|
+
* (LFCP-WIRE-01 §37).
|
|
52
|
+
*/
|
|
53
|
+
| {
|
|
54
|
+
readonly kind: "unsupported";
|
|
55
|
+
readonly session?: string;
|
|
56
|
+
readonly reason: string;
|
|
46
57
|
} | {
|
|
47
58
|
readonly kind: "refused";
|
|
48
59
|
readonly code: string;
|
package/dist/identity/pairing.js
CHANGED
|
@@ -5,7 +5,7 @@ import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, loadIdentityDoc
|
|
|
5
5
|
import { checkIssuance, leafPayload, MASK_PRESETS, mayManageDevices, registerDevice, signChain, } from "./devices.js";
|
|
6
6
|
import { identityPlaintextCodec, principalOfRef, principalRefOf, } from "./document.js";
|
|
7
7
|
import { newOperationId, OperationJournal, PackageLedger, pairingSecretRef, phaseOf, } from "./journal.js";
|
|
8
|
-
import { ChannelPool, IdentityUnavailable } from "./network.js";
|
|
8
|
+
import { ChannelPool, IDENTITY_EXTENSION, IdentityUnavailable, } from "./network.js";
|
|
9
9
|
import { classifyCancel, classifyConsume, classifyRegister } from "./phases.js";
|
|
10
10
|
import { identityTarget } from "./resource.js";
|
|
11
11
|
import { rotateIfExposed } from "./revocation.js";
|
|
@@ -40,6 +40,8 @@ export class PairingLinkText {
|
|
|
40
40
|
return "[redacted]";
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
/** W §37: the reason of an `unsupported` result. */
|
|
44
|
+
const NO_IDENTITY_SUPPORT = "the Identity Resource is hosted on a server without identity support (its READY does not list identity-01)";
|
|
43
45
|
const PAIRING_MASK = [1n, 2n];
|
|
44
46
|
/** Wraps a flow body: transport failures and forks become results, the journal stays. */
|
|
45
47
|
async function guarded(pool, body, unavailable, conflict) {
|
|
@@ -70,6 +72,8 @@ export async function createPairingSession(o) {
|
|
|
70
72
|
const me = o.installation.signer;
|
|
71
73
|
return guarded(pool, async () => {
|
|
72
74
|
const channel = await pool.get(me, target.url);
|
|
75
|
+
if (!channel.extensions.includes(IDENTITY_EXTENSION))
|
|
76
|
+
return { kind: "unsupported", reason: NO_IDENTITY_SUPPORT };
|
|
73
77
|
const chain = await syncChain(o.storage, channel, target.resource);
|
|
74
78
|
const R = genesisOwner(chain);
|
|
75
79
|
if (!mayManageDevices(chain.state, me.descriptor.principalId, R))
|
|
@@ -143,7 +147,14 @@ async function missingMaterial(o, channel, chain) {
|
|
|
143
147
|
dek: (epoch) => deks.get(String(epoch)),
|
|
144
148
|
profile: identityPlaintextCodec,
|
|
145
149
|
});
|
|
146
|
-
|
|
150
|
+
// A unit held whose only failure is the missing DEK of a real epoch of I (every other
|
|
151
|
+
// check passed, and that DEK is listed above) is not missing itself. A unit naming an
|
|
152
|
+
// epoch I does not have, or failing any other check, is missing (§5.3 step 1).
|
|
153
|
+
const onlyDekMissing = opened?.kind === "local-failure" &&
|
|
154
|
+
opened.reason === "NO_DEK" &&
|
|
155
|
+
unit !== undefined &&
|
|
156
|
+
chain.state.epochs.has(String(parseDataUnit(unit).payload.dataEpoch));
|
|
157
|
+
if (opened?.kind !== "accepted" && !onlyDekMissing)
|
|
147
158
|
missing.push("the root's Data Unit with actor sequence 1");
|
|
148
159
|
return missing;
|
|
149
160
|
}
|
|
@@ -156,6 +167,9 @@ async function finishSession(o, pool, session) {
|
|
|
156
167
|
const chainJ = certificateChain(op.intent.certificates.map((c) => parseDeviceCertificate(fromHex(c))), R);
|
|
157
168
|
const me = o.installation.signer;
|
|
158
169
|
const channel = await pool.get(me, op.intent.url);
|
|
170
|
+
// A resumed session may meet a server that no longer lists identity-01 (W §37).
|
|
171
|
+
if (!channel.extensions.includes(IDENTITY_EXTENSION))
|
|
172
|
+
return { kind: "unsupported", session, reason: NO_IDENTITY_SUPPORT };
|
|
159
173
|
const grant = await runControlPhase(o.storage, channel, journal, session, {
|
|
160
174
|
id: "grant",
|
|
161
175
|
kind: "register",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
-
import { type CertificateChain, type Signer } from "@openlfcp/wire";
|
|
2
|
+
import { type CertificateChain, type ControlState, type DeviceBinding, type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
|
|
3
3
|
import { type DocumentWrite, type KeyDelivery } from "./context.js";
|
|
4
|
+
import { type DeviceEntryValue, type IdentityIntent } from "./document.js";
|
|
4
5
|
import { type LinearChain, type PhaseState } from "./phases.js";
|
|
5
6
|
import { type IdentityFlowOptions } from "./resource.js";
|
|
6
7
|
import { type RotationOutcome } from "./rotation.js";
|
|
@@ -26,11 +27,40 @@ export type ReconcileStep = {
|
|
|
26
27
|
/** `I`'s chain for the device. */
|
|
27
28
|
readonly chain: CertificateChain;
|
|
28
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* §7.1 step 1: whether the revocation of `device` in `I` is relevant in the
|
|
32
|
+
* target `X`. It is when the device is bound in `X` (as a leaf or an
|
|
33
|
+
* intermediate, W §17.4 rule 9), or when it could become registered in `X`
|
|
34
|
+
* or issue a chain there: bound in `I` with kind 0, or with kind 1 and `X`
|
|
35
|
+
* in its scope, or not bound in `I` at all (its kind is unknown). A pairing
|
|
36
|
+
* device, or a service device whose scope does not list `X`, that is not
|
|
37
|
+
* bound in `X` can never act there (W §7.3 c3, c6; N8): its revocation
|
|
38
|
+
* would protect nothing and would take one of the 64 unbound pairs of the
|
|
39
|
+
* root that W §17.5 rule 5 allows in `X` and never frees.
|
|
40
|
+
*
|
|
41
|
+
* `binding` is the device's binding in `I` (undefined when unbound there).
|
|
42
|
+
*/
|
|
43
|
+
export declare function revocationRelevant(binding: Pick<DeviceBinding, "kind" | "scope"> | undefined, target: ControlState, device: PrincipalId): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* §7.1 step 1 for one target: the revocations of `I` to propose on `X`
|
|
46
|
+
* (the pairs of `I`'s `revoked` not in `X`'s that are relevant there, those
|
|
47
|
+
* whose device is bound in `X` first, each group in `I`'s chain order), and
|
|
48
|
+
* the devices whose revocation is not relevant in `X` and is not
|
|
49
|
+
* propagated. Empty for the target `I` itself.
|
|
50
|
+
*/
|
|
51
|
+
export declare function planRevocations(identity: LinearChain, target: LinearChain): {
|
|
52
|
+
readonly propose: readonly Extract<ReconcileStep, {
|
|
53
|
+
kind: "revoke";
|
|
54
|
+
}>[];
|
|
55
|
+
readonly notRelevant: readonly PrincipalId[];
|
|
56
|
+
};
|
|
29
57
|
/**
|
|
30
58
|
* §7.1 steps 1 and 2 for one target, in their normative order. For the
|
|
31
59
|
* target `I` itself both steps are vacuous. Registrations are planned on
|
|
32
|
-
* the target's state as the revocations leave it
|
|
33
|
-
* `
|
|
60
|
+
* the target's state as the revocations leave it, and every pair of `I`'s
|
|
61
|
+
* `revoked` counts as revoked in `X` whether its propagation succeeds or
|
|
62
|
+
* not (an unpropagated pair, refused by W §17.5 rule 5, or a pair that is
|
|
63
|
+
* not relevant): no device whose issuer `I` has revoked is ever registered.
|
|
34
64
|
*/
|
|
35
65
|
export declare function planReconciliation(identity: LinearChain, target: LinearChain): readonly ReconcileStep[];
|
|
36
66
|
/** The record of a step on the target's head, signed by the reconciler. */
|
|
@@ -43,6 +73,17 @@ export interface TargetReconciliation {
|
|
|
43
73
|
readonly step: ReconcileStep;
|
|
44
74
|
readonly state: PhaseState | "refused";
|
|
45
75
|
}[];
|
|
76
|
+
/**
|
|
77
|
+
* §7.1 step 1: devices whose revocation in `I` the target refused (the
|
|
78
|
+
* limit of W §17.5 rule 5, or the reconciler's authority there), to
|
|
79
|
+
* report to the human with the Resource. Each stays unpropagated: this
|
|
80
|
+
* run registered no device through it and sealed nothing to it. A later
|
|
81
|
+
* run proposes it again once the target's state changed since the
|
|
82
|
+
* refusal (its head moved, for one by the device's binding there); on the
|
|
83
|
+
* same state it reports the pair without sending anything. Empty when
|
|
84
|
+
* every relevant pair reached the target.
|
|
85
|
+
*/
|
|
86
|
+
readonly unpropagated?: readonly PrincipalId[];
|
|
46
87
|
readonly keys?: KeyDelivery;
|
|
47
88
|
readonly rotation?: RotationOutcome;
|
|
48
89
|
}
|
|
@@ -70,4 +111,33 @@ export declare function reconcileDevices(o: IdentityFlowOptions & {
|
|
|
70
111
|
*/
|
|
71
112
|
readonly document: DocumentWrite;
|
|
72
113
|
}>;
|
|
114
|
+
/** §7.1 step 4's writes in `I`'s document: new entries, and the retired entries to flag. */
|
|
115
|
+
export interface DisplayPlan {
|
|
116
|
+
readonly intents: readonly IdentityIntent[];
|
|
117
|
+
/** Retired entries (PrincipalRefs) whose `revoked` is set to `true`, and nothing else of them. */
|
|
118
|
+
readonly retire: readonly string[];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* §7.1 step 4: the display data reconciliation writes in `I`'s document,
|
|
122
|
+
* from `I`'s Control state and the valid device entries (§4). For every
|
|
123
|
+
* device of `R` of kind 0 or 1 bound in `I` as the leaf of an accepted
|
|
124
|
+
* chain (an intermediate-only binding issues and does not act in `I`):
|
|
125
|
+
* when it is not revoked in `I` and has no valid entry, an entry labelled
|
|
126
|
+
* with its fingerprint (§5.2); when it is revoked in `I` and some value of
|
|
127
|
+
* its entry's `revoked` (a retired entry) is not `true`, or the field is
|
|
128
|
+
* absent, that flag alone. `revokedValues` gives every concurrent value
|
|
129
|
+
* of the flag (IdentityDocumentPort.retirement); without it the entry's
|
|
130
|
+
* one shown value is read. No entry is created for a revoked device,
|
|
131
|
+
* nothing is written for a pairing device, nothing is relabelled or
|
|
132
|
+
* deleted, and a second run on the same states plans nothing.
|
|
133
|
+
*/
|
|
134
|
+
export declare function planDisplayData(identity: ControlState, entries: ReadonlyMap<string, DeviceEntryValue>, root: PrincipalId, revokedValues?: (key: string) => readonly unknown[]): DisplayPlan;
|
|
135
|
+
/**
|
|
136
|
+
* §7.1 step 3: the devices of `R` reconciliation seals the target's keys
|
|
137
|
+
* to: every reader of the target whose root is `R` (pairing devices of `I`
|
|
138
|
+
* included, in `I`), except a device revoked in `I` and a device whose
|
|
139
|
+
* current chain in the target passes through a device revoked in `I` (an
|
|
140
|
+
* unpropagated pair of step 1, which the target still counts as active).
|
|
141
|
+
*/
|
|
142
|
+
export declare function keyRecipients(identity: LinearChain, target: LinearChain): readonly PrincipalDescriptor[];
|
|
73
143
|
//# sourceMappingURL=reconcile.d.ts.map
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { bytesEqual, fromBase64url, fromHex, principalId, resourceId, secureRandom, toHex, } from "@openlfcp/core";
|
|
2
|
-
import { activeDevice, actsFor, DEVICE_KIND, deviceFingerprint, revocationOf, signControlRecord, } from "@openlfcp/wire";
|
|
3
|
-
import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, readersAt, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
2
|
+
import { activeDevice, actsFor, DEVICE_KIND, deviceFingerprint, isBound, revocationOf, signControlRecord, } from "@openlfcp/wire";
|
|
3
|
+
import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, readersAt, retiredFlagMissing, revokedByIdentity, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
4
4
|
import { mayManageDevices } from "./devices.js";
|
|
5
|
-
import { principalRefOf, resourceRef } from "./document.js";
|
|
5
|
+
import { principalRefOf, resourceRef, } from "./document.js";
|
|
6
6
|
import { newOperationId, OperationJournal, PackageLedger } from "./journal.js";
|
|
7
7
|
import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
8
8
|
import { classifyRegister, classifyRevoke } from "./phases.js";
|
|
@@ -10,36 +10,83 @@ import { identityTarget } from "./resource.js";
|
|
|
10
10
|
import { rotateIfExposed } from "./revocation.js";
|
|
11
11
|
import { checkFlowProfiles, resumeRotations } from "./rotation.js";
|
|
12
12
|
/**
|
|
13
|
-
* §7.1
|
|
14
|
-
* target `
|
|
15
|
-
*
|
|
16
|
-
* `I`
|
|
13
|
+
* §7.1 step 1: whether the revocation of `device` in `I` is relevant in the
|
|
14
|
+
* target `X`. It is when the device is bound in `X` (as a leaf or an
|
|
15
|
+
* intermediate, W §17.4 rule 9), or when it could become registered in `X`
|
|
16
|
+
* or issue a chain there: bound in `I` with kind 0, or with kind 1 and `X`
|
|
17
|
+
* in its scope, or not bound in `I` at all (its kind is unknown). A pairing
|
|
18
|
+
* device, or a service device whose scope does not list `X`, that is not
|
|
19
|
+
* bound in `X` can never act there (W §7.3 c3, c6; N8): its revocation
|
|
20
|
+
* would protect nothing and would take one of the 64 unbound pairs of the
|
|
21
|
+
* root that W §17.5 rule 5 allows in `X` and never frees.
|
|
22
|
+
*
|
|
23
|
+
* `binding` is the device's binding in `I` (undefined when unbound there).
|
|
17
24
|
*/
|
|
18
|
-
export function
|
|
25
|
+
export function revocationRelevant(binding, target, device) {
|
|
26
|
+
if (isBound(target, device))
|
|
27
|
+
return true;
|
|
28
|
+
if (binding === undefined || binding.kind === DEVICE_KIND.PERSONAL)
|
|
29
|
+
return true;
|
|
30
|
+
return (binding.kind === DEVICE_KIND.SERVICE &&
|
|
31
|
+
binding.scope.some((s) => bytesEqual(s, target.resourceId)));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* §7.1 step 1 for one target: the revocations of `I` to propose on `X`
|
|
35
|
+
* (the pairs of `I`'s `revoked` not in `X`'s that are relevant there, those
|
|
36
|
+
* whose device is bound in `X` first, each group in `I`'s chain order), and
|
|
37
|
+
* the devices whose revocation is not relevant in `X` and is not
|
|
38
|
+
* propagated. Empty for the target `I` itself.
|
|
39
|
+
*/
|
|
40
|
+
export function planRevocations(identity, target) {
|
|
19
41
|
if (bytesEqual(identity.state.resourceId, target.state.resourceId))
|
|
20
|
-
return [];
|
|
42
|
+
return { propose: [], notRelevant: [] };
|
|
21
43
|
const R = genesisOwner(identity);
|
|
22
44
|
const I = identity.state;
|
|
23
45
|
const X = target.state;
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
46
|
+
const bound = [];
|
|
47
|
+
const unbound = [];
|
|
48
|
+
const notRelevant = [];
|
|
49
|
+
const seen = new Set();
|
|
28
50
|
for (const r of identity.records) {
|
|
29
51
|
if (r.body.type !== "DEVICE_REVOKE" || !bytesEqual(r.body.root, R))
|
|
30
52
|
continue;
|
|
31
53
|
const d = r.body.device;
|
|
32
|
-
if (
|
|
54
|
+
if (revocationOf(X, R, d) !== undefined || seen.has(toHex(d)))
|
|
33
55
|
continue;
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
seen.add(toHex(d));
|
|
57
|
+
if (!revocationRelevant(I.devices.get(toHex(d)), X, d)) {
|
|
58
|
+
notRelevant.push(d);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
(isBound(X, d) ? bound : unbound).push({
|
|
36
62
|
kind: "revoke",
|
|
37
63
|
root: R,
|
|
38
64
|
device: d,
|
|
39
65
|
reason: r.body.reason === 3n ? 0n : r.body.reason,
|
|
40
66
|
});
|
|
41
67
|
}
|
|
42
|
-
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
propose: Object.freeze([...bound, ...unbound]),
|
|
70
|
+
notRelevant: Object.freeze(notRelevant),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* §7.1 steps 1 and 2 for one target, in their normative order. For the
|
|
75
|
+
* target `I` itself both steps are vacuous. Registrations are planned on
|
|
76
|
+
* the target's state as the revocations leave it, and every pair of `I`'s
|
|
77
|
+
* `revoked` counts as revoked in `X` whether its propagation succeeds or
|
|
78
|
+
* not (an unpropagated pair, refused by W §17.5 rule 5, or a pair that is
|
|
79
|
+
* not relevant): no device whose issuer `I` has revoked is ever registered.
|
|
80
|
+
*/
|
|
81
|
+
export function planReconciliation(identity, target) {
|
|
82
|
+
if (bytesEqual(identity.state.resourceId, target.state.resourceId))
|
|
83
|
+
return [];
|
|
84
|
+
const R = genesisOwner(identity);
|
|
85
|
+
const I = identity.state;
|
|
86
|
+
const X = target.state;
|
|
87
|
+
// Step 1: the relevant revocations of I, bound in X first.
|
|
88
|
+
const steps = [...planRevocations(identity, target).propose];
|
|
89
|
+
const revokedAfter = (d) => revocationOf(X, R, d) !== undefined || revocationOf(I, R, d) !== undefined;
|
|
43
90
|
// Step 2: registrations, in I's binding order.
|
|
44
91
|
for (const [hex, d] of I.devices) {
|
|
45
92
|
if (!bytesEqual(d.root, R) || d.chain === null || d.leafId === null)
|
|
@@ -115,10 +162,11 @@ export async function reconcileDevices(o) {
|
|
|
115
162
|
let document = { state: "none" };
|
|
116
163
|
let open = false;
|
|
117
164
|
try {
|
|
118
|
-
// A rotation an earlier run committed but did not adopt is finished first (§9.1).
|
|
119
|
-
await resumeRotations({ ...o, pool });
|
|
120
165
|
const channel = await pool.get(me, target.url);
|
|
121
166
|
const I = await syncChain(o.storage, channel, target.resource);
|
|
167
|
+
// A rotation an earlier run committed but did not adopt is finished first (§9.1). It
|
|
168
|
+
// seals nothing to a device I has revoked (§7.1 step 1).
|
|
169
|
+
await resumeRotations({ ...o, pool, identityChain: I });
|
|
122
170
|
const R = genesisOwner(I);
|
|
123
171
|
if (!mayManageDevices(I.state, me.descriptor.principalId, R))
|
|
124
172
|
return {
|
|
@@ -165,8 +213,9 @@ export async function reconcileDevices(o) {
|
|
|
165
213
|
throw e;
|
|
166
214
|
}
|
|
167
215
|
}
|
|
168
|
-
// Step 4: display data for devices
|
|
169
|
-
|
|
216
|
+
// Step 4: display data for the leaf-bound personal and service devices of I without a valid
|
|
217
|
+
// entry (labels default to the fingerprint), and the flag of a retired entry.
|
|
218
|
+
if (!nothingToWrite(displayPlan(I, doc, R)) && doc.initialized()) {
|
|
170
219
|
// Built on the current state of I: a rotation of I by this run, or by
|
|
171
220
|
// anyone, makes an earlier unit stale (W §19.1), and it is written again.
|
|
172
221
|
document = await runUnitPhase(channel, journal, operation, {
|
|
@@ -179,8 +228,8 @@ export async function reconcileDevices(o) {
|
|
|
179
228
|
const fresh = o.document(target.resource, R);
|
|
180
229
|
const loaded = await loadIdentityDocument({ channel, chain: now, deks, doc: fresh });
|
|
181
230
|
// Another manager may have written the entries since the first read (§7.1).
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
231
|
+
const plan = displayPlan(now, fresh, R);
|
|
232
|
+
if (nothingToWrite(plan))
|
|
184
233
|
return null;
|
|
185
234
|
return sealIdentityUnit({
|
|
186
235
|
storage: o.storage,
|
|
@@ -189,7 +238,8 @@ export async function reconcileDevices(o) {
|
|
|
189
238
|
signer: me,
|
|
190
239
|
doc: fresh,
|
|
191
240
|
latest: loaded.latest,
|
|
192
|
-
intents,
|
|
241
|
+
intents: plan.intents,
|
|
242
|
+
retire: plan.retire,
|
|
193
243
|
});
|
|
194
244
|
},
|
|
195
245
|
});
|
|
@@ -215,25 +265,76 @@ export async function reconcileDevices(o) {
|
|
|
215
265
|
}
|
|
216
266
|
return { operation, targets: Object.freeze(out), document };
|
|
217
267
|
}
|
|
218
|
-
/**
|
|
219
|
-
|
|
268
|
+
/**
|
|
269
|
+
* §7.1 step 4: the display data reconciliation writes in `I`'s document,
|
|
270
|
+
* from `I`'s Control state and the valid device entries (§4). For every
|
|
271
|
+
* device of `R` of kind 0 or 1 bound in `I` as the leaf of an accepted
|
|
272
|
+
* chain (an intermediate-only binding issues and does not act in `I`):
|
|
273
|
+
* when it is not revoked in `I` and has no valid entry, an entry labelled
|
|
274
|
+
* with its fingerprint (§5.2); when it is revoked in `I` and some value of
|
|
275
|
+
* its entry's `revoked` (a retired entry) is not `true`, or the field is
|
|
276
|
+
* absent, that flag alone. `revokedValues` gives every concurrent value
|
|
277
|
+
* of the flag (IdentityDocumentPort.retirement); without it the entry's
|
|
278
|
+
* one shown value is read. No entry is created for a revoked device,
|
|
279
|
+
* nothing is written for a pairing device, nothing is relabelled or
|
|
280
|
+
* deleted, and a second run on the same states plans nothing.
|
|
281
|
+
*/
|
|
282
|
+
export function planDisplayData(identity, entries, root, revokedValues) {
|
|
220
283
|
const intents = [];
|
|
221
|
-
|
|
222
|
-
|
|
284
|
+
const retire = [];
|
|
285
|
+
for (const [hex, d] of identity.devices) {
|
|
286
|
+
if (!bytesEqual(d.root, root) || d.chain === null)
|
|
223
287
|
continue;
|
|
224
|
-
|
|
225
|
-
|
|
288
|
+
if (d.kind !== DEVICE_KIND.PERSONAL && d.kind !== DEVICE_KIND.SERVICE)
|
|
289
|
+
continue;
|
|
290
|
+
const D = principalId(fromHex(hex));
|
|
291
|
+
const key = principalRefOf(D);
|
|
292
|
+
if (revocationOf(identity, root, D) !== undefined) {
|
|
293
|
+
if (retiredFlagMissing(entries, key, revokedValues))
|
|
294
|
+
retire.push(key);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (entries.has(key))
|
|
226
298
|
continue;
|
|
227
299
|
intents.push({
|
|
228
300
|
intent: "device.put",
|
|
229
301
|
key,
|
|
230
302
|
entry: {
|
|
231
|
-
label: deviceFingerprint(
|
|
303
|
+
label: deviceFingerprint(D),
|
|
232
304
|
kind: d.kind === DEVICE_KIND.SERVICE ? 1 : 0,
|
|
233
305
|
},
|
|
234
306
|
});
|
|
235
307
|
}
|
|
236
|
-
return intents;
|
|
308
|
+
return Object.freeze({ intents: Object.freeze(intents), retire: Object.freeze(retire) });
|
|
309
|
+
}
|
|
310
|
+
const displayPlan = (I, doc, R) => planDisplayData(I.state, doc.devices(), R, doc.retirement === undefined ? undefined : (key) => doc.retirement?.revokedValues(key) ?? []);
|
|
311
|
+
const nothingToWrite = (p) => p.intents.length === 0 && p.retire.length === 0;
|
|
312
|
+
/**
|
|
313
|
+
* §7.1 step 3: the devices of `R` reconciliation seals the target's keys
|
|
314
|
+
* to: every reader of the target whose root is `R` (pairing devices of `I`
|
|
315
|
+
* included, in `I`), except a device revoked in `I` and a device whose
|
|
316
|
+
* current chain in the target passes through a device revoked in `I` (an
|
|
317
|
+
* unpropagated pair of step 1, which the target still counts as active).
|
|
318
|
+
*/
|
|
319
|
+
export function keyRecipients(identity, target) {
|
|
320
|
+
const R = genesisOwner(identity);
|
|
321
|
+
return Object.freeze(readersAt(target.state).filter((r) => bytesEqual(actsFor(target.state, r.principalId), R) &&
|
|
322
|
+
!revokedByIdentity(identity, target.state, r.principalId)));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The revocations of `I` that `X` refused in an earlier reconciliation of
|
|
326
|
+
* this installation, as `<phase ID>@<head of X they were proposed on>`,
|
|
327
|
+
* with the refusal code (§7.1 step 1). A head names one state of `X`.
|
|
328
|
+
*/
|
|
329
|
+
async function refusedRevocations(journal, operation, X) {
|
|
330
|
+
const out = new Map();
|
|
331
|
+
const prefix = `revoke:${toHex(X)}:`;
|
|
332
|
+
for (const op of await journal.list({ kind: "reconcile" }))
|
|
333
|
+
if (op.id !== operation)
|
|
334
|
+
for (const p of op.phases)
|
|
335
|
+
if (p.id.startsWith(prefix) && p.state === "refused" && p.expectedHead !== undefined)
|
|
336
|
+
out.set(`${p.id}@${p.expectedHead}`, p.code ?? "refused");
|
|
337
|
+
return out;
|
|
237
338
|
}
|
|
238
339
|
async function reconcileTarget(o, channel, journal, ledger, operation, I, X, resend) {
|
|
239
340
|
const me = o.installation.signer;
|
|
@@ -241,9 +342,29 @@ async function reconcileTarget(o, channel, journal, ledger, operation, I, X, res
|
|
|
241
342
|
let chain = await syncChain(o.storage, channel, X);
|
|
242
343
|
const steps = [];
|
|
243
344
|
const manages = mayManageDevices(chain.state, me.descriptor.principalId, R);
|
|
345
|
+
const refused = manages
|
|
346
|
+
? await refusedRevocations(journal, operation, X)
|
|
347
|
+
: new Map();
|
|
244
348
|
if (manages)
|
|
245
349
|
for (const step of planReconciliation(I, chain)) {
|
|
246
350
|
const id = `${step.kind}:${toHex(X)}:${toHex(step.device)}`;
|
|
351
|
+
// §7.1 step 1: a refused propagation is not repeated as such. It is proposed again once
|
|
352
|
+
// X's state changed since the refusal (its head moved; binding the device moves it too,
|
|
353
|
+
// and a bound device's revocation is never limited), not on every run.
|
|
354
|
+
const head = toHex(chain.state.head);
|
|
355
|
+
const code = step.kind === "revoke" ? refused.get(`${id}@${head}`) : undefined;
|
|
356
|
+
if (code !== undefined && !isBound(chain.state, step.device)) {
|
|
357
|
+
await journal.putPhase(operation, {
|
|
358
|
+
id,
|
|
359
|
+
kind: "revoke",
|
|
360
|
+
resource: toHex(X),
|
|
361
|
+
state: "refused",
|
|
362
|
+
expectedHead: head,
|
|
363
|
+
code,
|
|
364
|
+
});
|
|
365
|
+
steps.push({ step, state: "refused" });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
247
368
|
const outcome = await runControlPhase(o.storage, channel, journal, operation, {
|
|
248
369
|
id,
|
|
249
370
|
kind: step.kind,
|
|
@@ -256,17 +377,24 @@ async function reconcileTarget(o, channel, journal, ledger, operation, I, X, res
|
|
|
256
377
|
steps.push({ step, state: outcome.state });
|
|
257
378
|
chain = outcome.chain;
|
|
258
379
|
}
|
|
259
|
-
//
|
|
380
|
+
// §7.1 step 1: a refused revocation is reported and stays unpropagated.
|
|
381
|
+
const unpropagated = steps
|
|
382
|
+
.filter((s) => s.step.kind === "revoke" && s.state === "refused")
|
|
383
|
+
.map((s) => s.step.device);
|
|
384
|
+
// Step 3: every reader of the target whose root is R (pairing devices of I included), by the
|
|
385
|
+
// ledger, except the devices I has revoked and those whose chain passes through one.
|
|
260
386
|
const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
|
|
261
|
-
const recipients =
|
|
387
|
+
const recipients = keyRecipients(I, chain);
|
|
262
388
|
const keys = await deliverKeys({ channel, ledger, chain, sender: me, recipients, deks, resend });
|
|
263
389
|
const rotation = o.rotate === false
|
|
264
390
|
? undefined
|
|
265
|
-
:
|
|
391
|
+
: // §7.1 step 1: the new epoch is sealed to no device I has revoked, nor through one.
|
|
392
|
+
await rotateIfExposed({ ...o, channel, operation, resource: X, identityChain: I });
|
|
266
393
|
return {
|
|
267
394
|
resource: X,
|
|
268
395
|
status: manages ? "done" : "not-authorized",
|
|
269
396
|
steps: Object.freeze(steps),
|
|
397
|
+
...(unpropagated.length === 0 ? {} : { unpropagated: Object.freeze(unpropagated) }),
|
|
270
398
|
keys,
|
|
271
399
|
...(rotation === undefined ? {} : { rotation }),
|
|
272
400
|
};
|
|
@@ -2,6 +2,7 @@ import { type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
|
2
2
|
import { type DocumentWrite } from "./context.js";
|
|
3
3
|
import { type FlowOptions } from "./devices.js";
|
|
4
4
|
import { type IdentityChannel } from "./network.js";
|
|
5
|
+
import { type LinearChain } from "./phases.js";
|
|
5
6
|
import { type IdentityFlowOptions } from "./resource.js";
|
|
6
7
|
import { type RotationOutcome } from "./rotation.js";
|
|
7
8
|
/**
|
|
@@ -39,7 +40,14 @@ export interface RevocationResult {
|
|
|
39
40
|
readonly resource: ResourceId;
|
|
40
41
|
readonly revoke: "revoked" | "complete" | "superseded" | "not-authorized"
|
|
41
42
|
/** The revoker's own chain passes through the device here: re-certify it here first (§8.1). */
|
|
42
|
-
| "self-dependent" | "refused"
|
|
43
|
+
| "self-dependent" | "refused"
|
|
44
|
+
/**
|
|
45
|
+
* §8.4, §7.1 step 1: a roster Resource where the device is not bound and
|
|
46
|
+
* can never act (a pairing device, or a service device whose scope does
|
|
47
|
+
* not list it): no revocation is proposed there, so that it does not take
|
|
48
|
+
* one of the root's 64 unbound pairs (W §17.5 rule 5).
|
|
49
|
+
*/
|
|
50
|
+
| "not-relevant" | "unreachable" | "unsupported" | "conflict";
|
|
43
51
|
readonly rotation?: RotationOutcome;
|
|
44
52
|
}
|
|
45
53
|
export type RevokeDeviceResult = {
|
|
@@ -102,5 +110,7 @@ export declare function rotateIfExposed(o: FlowOptions & {
|
|
|
102
110
|
readonly channel: IdentityChannel;
|
|
103
111
|
readonly operation: string;
|
|
104
112
|
readonly resource: ResourceId;
|
|
113
|
+
/** The Identity Resource of this installation's root: see rotateAfter. */
|
|
114
|
+
readonly identityChain?: LinearChain;
|
|
105
115
|
}): Promise<RotationOutcome>;
|
|
106
116
|
//# sourceMappingURL=revocation.d.ts.map
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { bytesEqual, fromBase64url, fromHex, principalId, resourceId, secureRandom, toHex, } from "@openlfcp/core";
|
|
2
|
-
import { activeDevice, signControlRecord, validateControlChain, } from "@openlfcp/wire";
|
|
3
|
-
import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
2
|
+
import { activeDevice, revocationOf, signControlRecord, validateControlChain, } from "@openlfcp/wire";
|
|
3
|
+
import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, retiredFlagMissing, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
4
4
|
import { mayManageDevices } from "./devices.js";
|
|
5
5
|
import { principalRefOf } from "./document.js";
|
|
6
6
|
import { newOperationId, OperationJournal, phaseOf } from "./journal.js";
|
|
7
7
|
import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
8
8
|
import { classifyRevoke } from "./phases.js";
|
|
9
|
+
import { revocationRelevant } from "./reconcile.js";
|
|
9
10
|
import { identityTarget } from "./resource.js";
|
|
10
11
|
import { checkFlowProfiles, exposingRevocationsSince, resumeRotations, rotateAfter, rotatorTest, } from "./rotation.js";
|
|
11
12
|
const toKeep = (state, root) => [...state.devices.entries()]
|
|
@@ -139,9 +140,6 @@ export async function revokeDevice(o) {
|
|
|
139
140
|
const pool = new ChannelPool(o.network);
|
|
140
141
|
const operation = o.operation ?? newOperationId("revoke-device", secureRandom(8));
|
|
141
142
|
try {
|
|
142
|
-
// Rotations an earlier operation committed but did not adopt are finished first (§9.1).
|
|
143
|
-
if (!selfRetire)
|
|
144
|
-
await resumeRotations({ ...o, pool });
|
|
145
143
|
const channel = await pool.get(o.installation.signer, target.url);
|
|
146
144
|
const known = await journal.get(operation);
|
|
147
145
|
let I;
|
|
@@ -154,6 +152,10 @@ export async function revokeDevice(o) {
|
|
|
154
152
|
throw e;
|
|
155
153
|
return retireElsewhere(o, pool, journal, known);
|
|
156
154
|
}
|
|
155
|
+
// Rotations an earlier operation committed but did not adopt are finished first (§9.1),
|
|
156
|
+
// sealing nothing to a device I has revoked (§7.1 step 1).
|
|
157
|
+
if (!selfRetire)
|
|
158
|
+
await resumeRotations({ ...o, pool, identityChain: I });
|
|
157
159
|
const R = genesisOwner(I);
|
|
158
160
|
if (!selfRetire && !mayManageDevices(I.state, me, R))
|
|
159
161
|
return { kind: "not-authorized" };
|
|
@@ -172,6 +174,9 @@ export async function revokeDevice(o) {
|
|
|
172
174
|
device: toHex(o.device),
|
|
173
175
|
root: toHex(R),
|
|
174
176
|
reason: String(o.reason),
|
|
177
|
+
// §8.4: the device's binding in I decides where its revocation is relevant,
|
|
178
|
+
// also for a resumed self-retirement that can no longer read I.
|
|
179
|
+
...bindingIntent(I.state.devices.get(toHex(o.device))),
|
|
175
180
|
targets: (await rosterTargets(o, channel, I)).map((t) => ({
|
|
176
181
|
resource: toHex(t.resource),
|
|
177
182
|
url: t.url,
|
|
@@ -179,8 +184,13 @@ export async function revokeDevice(o) {
|
|
|
179
184
|
},
|
|
180
185
|
});
|
|
181
186
|
const roster = targetsOf(await journal.load(operation));
|
|
187
|
+
const binding = I.state.devices.get(toHex(o.device));
|
|
182
188
|
const results = [];
|
|
183
|
-
|
|
189
|
+
// I as this operation leaves it: a rotation seals nothing to a device it has revoked.
|
|
190
|
+
let identityChain = I;
|
|
191
|
+
const one = async (c, X, inRoster = true) => {
|
|
192
|
+
if (inRoster && !(await relevantIn(o, c, X, R, binding)))
|
|
193
|
+
return { resource: X, revoke: "not-relevant" };
|
|
184
194
|
let outcome;
|
|
185
195
|
try {
|
|
186
196
|
outcome = await runControlPhase(o.storage, c, journal, operation, {
|
|
@@ -209,12 +219,20 @@ export async function revokeDevice(o) {
|
|
|
209
219
|
return { resource: X, revoke: "superseded" };
|
|
210
220
|
if (outcome.state === "refused")
|
|
211
221
|
return { resource: X, revoke: "refused" };
|
|
222
|
+
if (!inRoster)
|
|
223
|
+
identityChain = outcome.chain;
|
|
212
224
|
const revoke = outcome.state === "committed-own" ? "revoked" : "complete";
|
|
213
225
|
if (selfRetire && o.reason === 0n)
|
|
214
226
|
return { resource: X, revoke };
|
|
215
227
|
let rotation;
|
|
216
228
|
try {
|
|
217
|
-
rotation = await rotateIfExposed({
|
|
229
|
+
rotation = await rotateIfExposed({
|
|
230
|
+
...o,
|
|
231
|
+
channel: c,
|
|
232
|
+
operation,
|
|
233
|
+
resource: X,
|
|
234
|
+
identityChain,
|
|
235
|
+
});
|
|
218
236
|
}
|
|
219
237
|
catch (e) {
|
|
220
238
|
// The revocation stands; the rotation resumes from the journal (§9.1).
|
|
@@ -224,7 +242,7 @@ export async function revokeDevice(o) {
|
|
|
224
242
|
}
|
|
225
243
|
return { resource: X, revoke, rotation };
|
|
226
244
|
};
|
|
227
|
-
const first = await one(channel, target.resource);
|
|
245
|
+
const first = await one(channel, target.resource, false);
|
|
228
246
|
results.push(first);
|
|
229
247
|
if (first.revoke === "superseded") {
|
|
230
248
|
await journal.setStatus(operation, "superseded");
|
|
@@ -272,6 +290,35 @@ export async function revokeDevice(o) {
|
|
|
272
290
|
pool.closeAll();
|
|
273
291
|
}
|
|
274
292
|
}
|
|
293
|
+
/** The device's kind and scope in I, journaled with a revocation (§8.4). */
|
|
294
|
+
const bindingIntent = (b) => b === undefined ? {} : { binding: { kind: b.kind, scope: b.scope.map(toHex) } };
|
|
295
|
+
/**
|
|
296
|
+
* The journaled binding; `null` when the operation predates it (0.4.0),
|
|
297
|
+
* which revokes in every roster Resource as before.
|
|
298
|
+
*/
|
|
299
|
+
function bindingOf(op) {
|
|
300
|
+
if (!Object.hasOwn(op.intent, "targets"))
|
|
301
|
+
return null;
|
|
302
|
+
const b = op.intent.binding;
|
|
303
|
+
if (b === undefined)
|
|
304
|
+
return Object.hasOwn(op.intent, "binding") ? undefined : null;
|
|
305
|
+
return {
|
|
306
|
+
kind: b.kind,
|
|
307
|
+
scope: b.scope.map((h) => resourceId(fromHex(h))),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* §8.4: whether the revocation of the device is proposed on the roster
|
|
312
|
+
* Resource `X`: when it is already revoked there (the phase classifies it
|
|
313
|
+
* as complete), or when it is relevant there by §7.1 step 1.
|
|
314
|
+
*/
|
|
315
|
+
async function relevantIn(o, c, X, R, binding) {
|
|
316
|
+
if (binding === null)
|
|
317
|
+
return true;
|
|
318
|
+
const x = await syncChain(o.storage, c, X);
|
|
319
|
+
return (revocationOf(x.state, R, o.device) !== undefined ||
|
|
320
|
+
revocationRelevant(binding, x.state, o.device));
|
|
321
|
+
}
|
|
275
322
|
const targetsOf = (op) => (op.intent.targets ?? []).map((t) => ({
|
|
276
323
|
resource: resourceId(fromHex(t.resource)),
|
|
277
324
|
url: t.url,
|
|
@@ -282,9 +329,14 @@ async function retireElsewhere(o, pool, journal, op) {
|
|
|
282
329
|
const results = [
|
|
283
330
|
{ resource: resourceId(fromHex(op.intent.identity)), revoke: "complete" },
|
|
284
331
|
];
|
|
332
|
+
const binding = bindingOf(op);
|
|
285
333
|
for (const t of targetsOf(op)) {
|
|
286
334
|
try {
|
|
287
335
|
const c = await pool.get(o.installation.signer, t.url);
|
|
336
|
+
if (!(await relevantIn(o, c, t.resource, R, binding))) {
|
|
337
|
+
results.push({ resource: t.resource, revoke: "not-relevant" });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
288
340
|
const outcome = await runControlPhase(o.storage, c, journal, op.id, {
|
|
289
341
|
id: `revoke:${toHex(t.resource)}`,
|
|
290
342
|
kind: "revoke",
|
|
@@ -354,10 +406,11 @@ async function markRevoked(o, channel, journal, operation, I, device) {
|
|
|
354
406
|
build: async () => {
|
|
355
407
|
const now = await current();
|
|
356
408
|
gone = now.entry === undefined;
|
|
357
|
-
|
|
409
|
+
const retirement = now.doc.retirement;
|
|
410
|
+
const values = retirement === undefined ? undefined : (k) => retirement.revokedValues(k);
|
|
411
|
+
if (!retiredFlagMissing(now.doc.devices(), key, values))
|
|
358
412
|
return null;
|
|
359
|
-
// The device's missing_keys hint is
|
|
360
|
-
const { missing_keys: _hint, ...display } = now.entry;
|
|
413
|
+
// The flag alone (§8.2); the device's missing_keys hint is its own (§3 AI5).
|
|
361
414
|
return sealIdentityUnit({
|
|
362
415
|
storage: o.storage,
|
|
363
416
|
chain: now.chain,
|
|
@@ -365,7 +418,8 @@ async function markRevoked(o, channel, journal, operation, I, device) {
|
|
|
365
418
|
signer: o.installation.signer,
|
|
366
419
|
doc: now.doc,
|
|
367
420
|
latest: now.load.latest,
|
|
368
|
-
intents: [
|
|
421
|
+
intents: [],
|
|
422
|
+
retire: [key],
|
|
369
423
|
});
|
|
370
424
|
},
|
|
371
425
|
});
|
|
@@ -112,13 +112,19 @@ export interface RotationOutcome {
|
|
|
112
112
|
* reuse it (§9.4). After the ACK the DEK goes to every reader of the new
|
|
113
113
|
* head, each package recorded in the ledger; the revoked device, a
|
|
114
114
|
* consumed or cancelled pairing device and suspended devices are not
|
|
115
|
-
* readers and receive nothing.
|
|
115
|
+
* readers and receive nothing. With `identityChain`, neither does a reader that
|
|
116
|
+
* the rotator's Identity Resource has revoked, or whose chain in the
|
|
117
|
+
* Resource passes through such a device: a pair not yet propagated there
|
|
118
|
+
* (IDENTITY-PROFILE-01 §7.1 step 1) is treated as revoked, and the rotation
|
|
119
|
+
* closes the epoch for it as for the device just revoked.
|
|
116
120
|
*/
|
|
117
121
|
export declare function rotateAfter(o: FlowOptions & {
|
|
118
122
|
readonly channel: IdentityChannel;
|
|
119
123
|
readonly operation: string;
|
|
120
124
|
readonly resource: ResourceId;
|
|
121
125
|
readonly revokedAt: bigint;
|
|
126
|
+
/** The Identity Resource of the rotator's root (§7.1 step 1), when the flow knows it. */
|
|
127
|
+
readonly identityChain?: LinearChain;
|
|
122
128
|
}): Promise<RotationOutcome>;
|
|
123
129
|
/** The flow options of a rotation: 0.3.1's bare `dataProfiles` is refused, never checked less. */
|
|
124
130
|
export declare function checkFlowProfiles(o: FlowOptions): void;
|
|
@@ -130,6 +136,8 @@ export declare function checkFlowProfiles(o: FlowOptions): void;
|
|
|
130
136
|
*/
|
|
131
137
|
export declare function resumeRotations(o: FlowOptions & {
|
|
132
138
|
readonly pool: ChannelPool;
|
|
139
|
+
/** The Identity Resource of this installation's root: see rotateAfter. */
|
|
140
|
+
readonly identityChain?: LinearChain;
|
|
133
141
|
}): Promise<readonly RotationOutcome[]>;
|
|
134
142
|
/** The control sequence of a record ID (hex) on a chain. */
|
|
135
143
|
export declare const seqOf: (chain: LinearChain, recordIdHex: string) => bigint | undefined;
|
|
@@ -3,7 +3,7 @@ import { generateResourceDEK, sha256 } from "@openlfcp/crypto";
|
|
|
3
3
|
import { ABILITY, actsFor, addSequence, checkDataUnit, hasAbility, hasSequence, InMemorySeenUnits, isExposingRevocation, KEY_EPOCH_REASON, parseDataUnit, receiveDataUnit, rotateEpoch, } from "@openlfcp/wire";
|
|
4
4
|
import { snapshotFrontier } from "../outbound.js";
|
|
5
5
|
import { loadControlChain } from "../storage.js";
|
|
6
|
-
import { deliverKeys, fetchDeks, readersAt, runControlPhase, storeDek, withoutEquivocations, } from "./context.js";
|
|
6
|
+
import { deliverKeys, fetchDeks, readersAt, revokedByIdentity, runControlPhase, storeDek, withoutEquivocations, } from "./context.js";
|
|
7
7
|
import { getPendingDek, OperationJournal, PackageLedger, pendingDekRef, phaseOf, putPendingDek, } from "./journal.js";
|
|
8
8
|
import { IdentityUnavailable } from "./network.js";
|
|
9
9
|
import { keyEpochAfter, recordSeq } from "./phases.js";
|
|
@@ -191,7 +191,11 @@ async function acceptedAsData(chain, bytes, unitId, actor, local, data) {
|
|
|
191
191
|
* reuse it (§9.4). After the ACK the DEK goes to every reader of the new
|
|
192
192
|
* head, each package recorded in the ledger; the revoked device, a
|
|
193
193
|
* consumed or cancelled pairing device and suspended devices are not
|
|
194
|
-
* readers and receive nothing.
|
|
194
|
+
* readers and receive nothing. With `identityChain`, neither does a reader that
|
|
195
|
+
* the rotator's Identity Resource has revoked, or whose chain in the
|
|
196
|
+
* Resource passes through such a device: a pair not yet propagated there
|
|
197
|
+
* (IDENTITY-PROFILE-01 §7.1 step 1) is treated as revoked, and the rotation
|
|
198
|
+
* closes the epoch for it as for the device just revoked.
|
|
195
199
|
*/
|
|
196
200
|
export async function rotateAfter(o) {
|
|
197
201
|
checkFlowProfiles(o);
|
|
@@ -266,7 +270,8 @@ export async function rotateAfter(o) {
|
|
|
266
270
|
ledger: new PackageLedger(o.storage),
|
|
267
271
|
chain,
|
|
268
272
|
sender: me,
|
|
269
|
-
recipients: readersAt(chain.state)
|
|
273
|
+
recipients: readersAt(chain.state).filter((r) => o.identityChain === undefined ||
|
|
274
|
+
!revokedByIdentity(o.identityChain, chain.state, r.principalId)),
|
|
270
275
|
deks: send,
|
|
271
276
|
});
|
|
272
277
|
const phase = phaseOf(await journal.load(o.operation), id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "LFCP client: session, Control Plane and Data Plane synchronization.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"tag": "latest"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@openlfcp/core": "^0.
|
|
46
|
-
"@openlfcp/
|
|
47
|
-
"@openlfcp/
|
|
48
|
-
"@openlfcp/
|
|
45
|
+
"@openlfcp/core": "^0.5.0",
|
|
46
|
+
"@openlfcp/crypto": "^0.5.0",
|
|
47
|
+
"@openlfcp/storage": "^0.5.0",
|
|
48
|
+
"@openlfcp/wire": "^0.5.0"
|
|
49
49
|
}
|
|
50
50
|
}
|