@openlfcp/client 0.1.5 → 0.2.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/access-recovery.d.ts +69 -0
- package/dist/access-recovery.js +45 -0
- package/dist/batch-status.d.ts +104 -0
- package/dist/batch-status.js +171 -0
- package/dist/claim-journal.d.ts +54 -0
- package/dist/claim-journal.js +52 -0
- package/dist/connection.d.ts +9 -0
- package/dist/connection.js +12 -1
- package/dist/data-unit.js +6 -4
- package/dist/index.d.ts +14 -6
- package/dist/index.js +13 -5
- package/dist/invite.d.ts +42 -3
- package/dist/invite.js +155 -10
- package/dist/outbound.d.ts +25 -3
- package/dist/outbound.js +38 -4
- package/dist/queue.d.ts +4 -0
- package/dist/queue.js +16 -10
- package/dist/receipts.d.ts +60 -0
- package/dist/receipts.js +118 -0
- package/dist/repropose.d.ts +81 -0
- package/dist/repropose.js +221 -0
- package/dist/revoke.d.ts +71 -0
- package/dist/revoke.js +108 -0
- package/dist/section-status.d.ts +94 -0
- package/dist/section-status.js +16 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.js +105 -4
- package/dist/sync-client.d.ts +161 -3
- package/dist/sync-client.js +807 -14
- package/dist/typing.d.ts +60 -0
- package/dist/typing.js +110 -0
- package/dist/write-access.d.ts +115 -0
- package/dist/write-access.js +87 -0
- package/package.json +6 -6
package/dist/receipts.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { dataUnitId, fromHex, toHex } from "@openlfcp/core";
|
|
2
|
+
import { sha256 } from "@openlfcp/crypto";
|
|
3
|
+
import { cborMap, encode } from "@openlfcp/wire/cbor";
|
|
4
|
+
import { releaseWrites } from "./batch-status.js";
|
|
5
|
+
/** §3.3: an operation ID that already has a receipt for different intents. A local SDK error. */
|
|
6
|
+
export class OperationIdReusedError extends Error {
|
|
7
|
+
operationId;
|
|
8
|
+
code = "OPERATION_ID_REUSED";
|
|
9
|
+
constructor(operationId) {
|
|
10
|
+
super(`operation ${operationId} already committed different intents (§3.3)`);
|
|
11
|
+
this.operationId = operationId;
|
|
12
|
+
this.name = "OperationIdReusedError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const PREFIX = "receipt:";
|
|
16
|
+
const markKey = (resource, operationId) => `${PREFIX}${toHex(resource)}:${operationId}`;
|
|
17
|
+
const RELEASED = "released-batch:";
|
|
18
|
+
const releasedKey = (resource, operationId) => `${RELEASED}${toHex(resource)}:${operationId}`;
|
|
19
|
+
/** A plain value as the CBOR data model: objects become maps, absent fields are left out. */
|
|
20
|
+
function cbor(value, depth = 0) {
|
|
21
|
+
if (depth > 64)
|
|
22
|
+
throw new Error("an intent nests too deep to hash");
|
|
23
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
24
|
+
return value;
|
|
25
|
+
if (typeof value === "number") {
|
|
26
|
+
if (!Number.isSafeInteger(value))
|
|
27
|
+
throw new Error(`an intent holds a non-integer number: ${value}`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === "bigint" || value instanceof Uint8Array)
|
|
31
|
+
return value;
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.map((v) => cbor(v, depth + 1));
|
|
34
|
+
if (typeof value === "object")
|
|
35
|
+
return cborMap(Object.entries(value)
|
|
36
|
+
.filter(([, v]) => v !== undefined)
|
|
37
|
+
.map(([k, v]) => [k, cbor(v, depth + 1)]));
|
|
38
|
+
throw new Error(`an intent holds a ${typeof value}, which has no canonical form`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* §3.3: SHA-256 (hex) of the deterministic CBOR (RFC 8949 §4.2.1) of the
|
|
42
|
+
* list of intents in order, each a map from its field names to their
|
|
43
|
+
* values, Text as given, nothing normalized.
|
|
44
|
+
*/
|
|
45
|
+
export function intentsHash(intents) {
|
|
46
|
+
return toHex(sha256(encode(cbor(intents))));
|
|
47
|
+
}
|
|
48
|
+
/** The receipt as stored in a local mark. */
|
|
49
|
+
function storedReceipt(receipt) {
|
|
50
|
+
const stored = {
|
|
51
|
+
operationId: receipt.operationId,
|
|
52
|
+
unitIds: receipt.unitIds.map((u) => toHex(u)),
|
|
53
|
+
affectedNodeIds: [...receipt.affectedNodeIds],
|
|
54
|
+
modelRevision: receipt.modelRevision,
|
|
55
|
+
intentsHash: receipt.intentsHash,
|
|
56
|
+
};
|
|
57
|
+
return JSON.stringify(stored);
|
|
58
|
+
}
|
|
59
|
+
/** The local-mark write that stores `receipt` (for the commit's transaction). */
|
|
60
|
+
export function receiptWrite(resource, receipt) {
|
|
61
|
+
return {
|
|
62
|
+
op: "put-local-mark",
|
|
63
|
+
key: markKey(resource, receipt.operationId),
|
|
64
|
+
value: storedReceipt(receipt),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const parse = (raw) => {
|
|
68
|
+
const s = JSON.parse(raw);
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
operationId: s.operationId,
|
|
71
|
+
unitIds: Object.freeze(s.unitIds.map((u) => dataUnitId(fromHex(u)))),
|
|
72
|
+
affectedNodeIds: Object.freeze([...s.affectedNodeIds]),
|
|
73
|
+
modelRevision: s.modelRevision,
|
|
74
|
+
intentsHash: s.intentsHash,
|
|
75
|
+
durable: true,
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
/** §3.4: the receipt of an operation, definitive across restarts; undefined when nothing was committed. */
|
|
79
|
+
export async function receiptOf(storage, resource, operationId) {
|
|
80
|
+
const raw = await storage.localMarks.get(markKey(resource, operationId));
|
|
81
|
+
return raw === undefined ? undefined : parse(raw);
|
|
82
|
+
}
|
|
83
|
+
/** The receipt of a released batch that is not final yet; undefined otherwise. */
|
|
84
|
+
export async function releasedReceiptOf(storage, resource, operationId) {
|
|
85
|
+
const raw = await storage.localMarks.get(releasedKey(resource, operationId));
|
|
86
|
+
return raw === undefined ? undefined : parse(raw);
|
|
87
|
+
}
|
|
88
|
+
/** The receipts of a Resource's released batches that are not final yet. */
|
|
89
|
+
export async function releasedReceiptsOf(storage, resource) {
|
|
90
|
+
return (await storage.localMarks.list(`${RELEASED}${toHex(resource)}:`)).map((m) => parse(m.value));
|
|
91
|
+
}
|
|
92
|
+
/** Keeps a released receipt for its batch's status (or, with null, forgets it). */
|
|
93
|
+
export function releasedWrite(resource, operationId, receipt) {
|
|
94
|
+
return {
|
|
95
|
+
op: "put-local-mark",
|
|
96
|
+
key: releasedKey(resource, operationId),
|
|
97
|
+
value: receipt === null ? null : storedReceipt(receipt),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Every receipt of a Resource that is not released yet. */
|
|
101
|
+
export async function receiptsOf(storage, resource) {
|
|
102
|
+
return (await storage.localMarks.list(`${PREFIX}${toHex(resource)}:`)).map((m) => parse(m.value));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* §3.5: forgets a receipt the caller has finished with. Its batch status
|
|
106
|
+
* goes with it when the batch is final (accepted or rejected); otherwise
|
|
107
|
+
* the batch is still reported until it is final.
|
|
108
|
+
*/
|
|
109
|
+
export async function releaseReceipt(storage, resource, operationId) {
|
|
110
|
+
const receipt = await receiptOf(storage, resource, operationId);
|
|
111
|
+
const r = await storage.commit([
|
|
112
|
+
{ op: "put-local-mark", key: markKey(resource, operationId), value: null },
|
|
113
|
+
...(receipt === undefined ? [] : await releaseWrites(storage, resource, receipt)),
|
|
114
|
+
]);
|
|
115
|
+
if (!r.ok)
|
|
116
|
+
throw new Error(`the receipt was not released: ${r.reason}`);
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=receipts.js.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type ControlRecordId, type Hash32, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type LfcpStorage, type SecretStore } from "@openlfcp/storage";
|
|
3
|
+
import { type Signer } from "@openlfcp/wire";
|
|
4
|
+
import type { OutboundQueue } from "./outbound.js";
|
|
5
|
+
/**
|
|
6
|
+
* Re-proposing this client's own Control Records that the coordinator
|
|
7
|
+
* refused with CONTROL_HEAD_MISMATCH (LFCP-WIRE-01 §21, §47; the LFCP-036
|
|
8
|
+
* design). A record proposed on a head that another writer moved is blocked
|
|
9
|
+
* "repropose" by the OutboundQueue; without this it — and every object that
|
|
10
|
+
* depends on it, such as a grant's invitation Key Package — would wait
|
|
11
|
+
* forever. Here each blocked record is re-authored on the current local
|
|
12
|
+
* head, keeping its intent, re-signed with a new record ID, and its
|
|
13
|
+
* dependent Key Packages are re-sealed to the new head; a record that is no
|
|
14
|
+
* longer authorized or meaningful is dropped with a typed outcome instead.
|
|
15
|
+
*
|
|
16
|
+
* Only the GRANT and REVOKE intents are re-authored: a revocation/rotation
|
|
17
|
+
* is serialized at the author (SyncClient.revokeAccess refuses while a
|
|
18
|
+
* Control Record is pending), so a KEY_EPOCH never reaches re-proposal; one
|
|
19
|
+
* that somehow does is dropped (its epoch number, DEK commitment and final
|
|
20
|
+
* frontier are bound to the head it was built on) and surfaced, so the
|
|
21
|
+
* application re-runs the high-level operation.
|
|
22
|
+
*
|
|
23
|
+
* Re-authoring is idempotent (a grant already on the chain with the same
|
|
24
|
+
* subject and abilities is treated as superseded, not duplicated) and
|
|
25
|
+
* bounded (an item re-proposed `maxAttempts` times without landing is
|
|
26
|
+
* dropped), so a head that keeps moving cannot loop.
|
|
27
|
+
*/
|
|
28
|
+
export type ReproposeDropReason =
|
|
29
|
+
/** The blocked record was not authored by this client, so it cannot be re-signed. */
|
|
30
|
+
"not-author"
|
|
31
|
+
/** An equivalent grant is already on the chain: this one was re-proposed before. */
|
|
32
|
+
| "superseded"
|
|
33
|
+
/** The intent is no longer authorized on the moved head. */
|
|
34
|
+
| "unauthorized"
|
|
35
|
+
/** A revocation whose target grant is already inactive on the moved head. */
|
|
36
|
+
| "already-revoked"
|
|
37
|
+
/** A dependent Key Package's DEK is not held, so it cannot be re-sealed. */
|
|
38
|
+
| "no-dek"
|
|
39
|
+
/** A dependent Key Package no longer verifies on the moved head. */
|
|
40
|
+
| "key-unauthorized"
|
|
41
|
+
/** A KEY_EPOCH or other intent that is not re-authored here. */
|
|
42
|
+
| "unsupported-intent"
|
|
43
|
+
/** Re-proposed too many times without landing (the head kept moving). */
|
|
44
|
+
| "attempts-exhausted";
|
|
45
|
+
export type ReproposeOutcome = {
|
|
46
|
+
readonly kind: "reproposed";
|
|
47
|
+
readonly oldId: ControlRecordId;
|
|
48
|
+
readonly newId: ControlRecordId;
|
|
49
|
+
/** The dependent Key Packages re-sealed to the new head, old ID → new ID. */
|
|
50
|
+
readonly keyPackages: readonly {
|
|
51
|
+
readonly oldId: Hash32;
|
|
52
|
+
readonly newId: Hash32;
|
|
53
|
+
}[];
|
|
54
|
+
} | {
|
|
55
|
+
readonly kind: "dropped";
|
|
56
|
+
readonly oldId: ControlRecordId;
|
|
57
|
+
readonly reason: ReproposeDropReason;
|
|
58
|
+
};
|
|
59
|
+
export interface ReproposeResult {
|
|
60
|
+
readonly outcomes: readonly ReproposeOutcome[];
|
|
61
|
+
}
|
|
62
|
+
export interface ReproposeOptions {
|
|
63
|
+
readonly storage: Pick<LfcpStorage, "control" | "outbound" | "commit">;
|
|
64
|
+
readonly secrets: SecretStore;
|
|
65
|
+
readonly signer: Signer;
|
|
66
|
+
readonly resource: ResourceId;
|
|
67
|
+
/** For dropping in-memory tracking of the items a re-proposal replaces. */
|
|
68
|
+
readonly queue: OutboundQueue;
|
|
69
|
+
/** Re-proposal attempts per blocked record (by item ID hex), owned by the caller. */
|
|
70
|
+
readonly attempts: Map<string, number>;
|
|
71
|
+
/** The cap after which an item is dropped "attempts-exhausted" (default 8). */
|
|
72
|
+
readonly maxAttempts?: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Re-proposes every blocked "repropose" Control Record of `resource`, in
|
|
76
|
+
* record order, each on the local head left by the ones before it. Returns
|
|
77
|
+
* one outcome per blocked record. Call it under the same serial lock as the
|
|
78
|
+
* queue, so no other record is authored meanwhile.
|
|
79
|
+
*/
|
|
80
|
+
export declare function reproposeControlRecords(options: ReproposeOptions): Promise<ReproposeResult>;
|
|
81
|
+
//# sourceMappingURL=repropose.d.ts.map
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { bytesEqual, controlRecordId, hash32, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { importResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import { dekSecretRef, } from "@openlfcp/storage";
|
|
4
|
+
import { controlRecordSigner, decodeControlRecord, isGrantActive, parseKeyPackage, sealKeyPackage, signControlRecord, validateControlChain, verifyKeyPackage, } from "@openlfcp/wire";
|
|
5
|
+
import { controlRecordWrites, keyPackageWrites } from "./queue.js";
|
|
6
|
+
import { localControlChain } from "./storage.js";
|
|
7
|
+
/** The descriptor of `principal` from the validated chain (its principals, owner or a grant subject). */
|
|
8
|
+
function descriptorOf(state, principal) {
|
|
9
|
+
const known = state.principals.get(toHex(principal));
|
|
10
|
+
if (known !== undefined)
|
|
11
|
+
return known;
|
|
12
|
+
if (bytesEqual(state.owner.principalId, principal))
|
|
13
|
+
return state.owner;
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/** An active grant on `state` with the same subject and ability set (a prior re-proposal of this grant). */
|
|
17
|
+
function equivalentGrant(state, subject, abilities) {
|
|
18
|
+
const want = [...abilities].map(String).sort().join(",");
|
|
19
|
+
for (const g of state.grants.values())
|
|
20
|
+
if (bytesEqual(g.subject, subject) &&
|
|
21
|
+
isGrantActive(state, g.id) &&
|
|
22
|
+
[...g.abilities].map(String).sort().join(",") === want)
|
|
23
|
+
return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
/** Re-signs a record on the local head and checks it extends the chain linearly; undefined when it does not. */
|
|
27
|
+
function reauthor(local, sign) {
|
|
28
|
+
const signed = sign({
|
|
29
|
+
resourceId: local.state.resourceId,
|
|
30
|
+
controlSeq: local.state.seq + 1n,
|
|
31
|
+
prevControlId: local.state.head,
|
|
32
|
+
});
|
|
33
|
+
const next = validateControlChain([...local.records.map((r) => r.signed.bytes), signed.bytes]);
|
|
34
|
+
if (next.kind !== "linear")
|
|
35
|
+
return undefined;
|
|
36
|
+
return { bytes: signed.bytes, newId: signed.recordId, next };
|
|
37
|
+
}
|
|
38
|
+
/** Re-seals every queued Key Package that depended on `oldHead` to the re-proposed `newHead`/`next`. */
|
|
39
|
+
async function resealDependents(options, next, oldHead, newHead) {
|
|
40
|
+
const removed = [];
|
|
41
|
+
const added = [];
|
|
42
|
+
const mapping = [];
|
|
43
|
+
for (const item of await options.storage.outbound.list(options.resource)) {
|
|
44
|
+
if (item.kind !== "key-package")
|
|
45
|
+
continue;
|
|
46
|
+
let controlHead;
|
|
47
|
+
let recipientId;
|
|
48
|
+
let epoch;
|
|
49
|
+
try {
|
|
50
|
+
const p = parseKeyPackage(item.bytes).payload;
|
|
51
|
+
controlHead = p.controlHead;
|
|
52
|
+
recipientId = p.recipient;
|
|
53
|
+
epoch = p.dataEpoch;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!bytesEqual(controlHead, oldHead))
|
|
59
|
+
continue;
|
|
60
|
+
const recipient = descriptorOf(next.state, recipientId);
|
|
61
|
+
const bytes = await options.secrets.get(dekSecretRef(options.resource, epoch));
|
|
62
|
+
if (recipient === undefined || bytes === undefined)
|
|
63
|
+
return { ok: false, reason: "no-dek" };
|
|
64
|
+
const sealed = await sealKeyPackage({
|
|
65
|
+
resourceId: options.resource,
|
|
66
|
+
epoch,
|
|
67
|
+
controlHead: newHead,
|
|
68
|
+
recipient,
|
|
69
|
+
dek: importResourceDEK(bytes),
|
|
70
|
+
signer: options.signer,
|
|
71
|
+
});
|
|
72
|
+
if (verifyKeyPackage(next, parseKeyPackage(sealed.bytes)).kind !== "authorized")
|
|
73
|
+
return { ok: false, reason: "key-unauthorized" };
|
|
74
|
+
removed.push(item.itemId);
|
|
75
|
+
added.push(sealed.bytes);
|
|
76
|
+
mapping.push({ oldId: item.itemId, newId: hash32(parseKeyPackage(sealed.bytes).signed.id) });
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze({ ok: true, removed, added, mapping });
|
|
79
|
+
}
|
|
80
|
+
/** The queued Key Packages of `resource` whose control head is `controlHead`. */
|
|
81
|
+
async function dependentKeyPackages(storage, resource, controlHead) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const item of await storage.outbound.list(resource)) {
|
|
84
|
+
if (item.kind !== "key-package")
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
if (bytesEqual(parseKeyPackage(item.bytes).payload.controlHead, controlHead))
|
|
88
|
+
out.push(item.itemId);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// not a parsable Key Package: never a dependent of this head.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** Enqueues the re-authored record and its re-sealed Key Packages and dequeues the old ones, in one transaction. */
|
|
97
|
+
async function swap(storage, queue, change) {
|
|
98
|
+
const writes = [
|
|
99
|
+
{ op: "dequeue", itemId: change.removeControl },
|
|
100
|
+
...change.removeKeyPackages.map((itemId) => ({ op: "dequeue", itemId })),
|
|
101
|
+
...controlRecordWrites(change.addControl),
|
|
102
|
+
...change.addKeyPackages.flatMap(keyPackageWrites),
|
|
103
|
+
];
|
|
104
|
+
const r = await storage.commit(writes);
|
|
105
|
+
if (!r.ok)
|
|
106
|
+
throw new Error(`the re-proposal was not committed: ${r.reason}`);
|
|
107
|
+
queue.release([change.removeControl, ...change.removeKeyPackages]);
|
|
108
|
+
}
|
|
109
|
+
/** Dequeues a blocked record and its dependents and drops the queue's in-memory tracking of them. */
|
|
110
|
+
async function dropItem(storage, queue, ids) {
|
|
111
|
+
const r = await storage.commit(ids.map((itemId) => ({ op: "dequeue", itemId })));
|
|
112
|
+
if (!r.ok)
|
|
113
|
+
throw new Error(`a re-proposal drop was not committed: ${r.reason}`);
|
|
114
|
+
queue.release(ids);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Re-proposes every blocked "repropose" Control Record of `resource`, in
|
|
118
|
+
* record order, each on the local head left by the ones before it. Returns
|
|
119
|
+
* one outcome per blocked record. Call it under the same serial lock as the
|
|
120
|
+
* queue, so no other record is authored meanwhile.
|
|
121
|
+
*/
|
|
122
|
+
export async function reproposeControlRecords(options) {
|
|
123
|
+
const { storage, signer, resource, queue } = options;
|
|
124
|
+
const maxAttempts = options.maxAttempts ?? 8;
|
|
125
|
+
const blocked = (await storage.outbound.list(resource))
|
|
126
|
+
.filter((i) => i.kind === "control-record" && i.blocked?.reason === "repropose")
|
|
127
|
+
.map((i) => ({ itemId: i.itemId, record: decodeControlRecord(i.bytes) }))
|
|
128
|
+
.sort((a, b) => (a.record.payload.controlSeq < b.record.payload.controlSeq ? -1 : 1));
|
|
129
|
+
const outcomes = [];
|
|
130
|
+
const mine = signer.descriptor.principalId;
|
|
131
|
+
for (const { itemId, record } of blocked) {
|
|
132
|
+
const oldId = controlRecordId(record.signed.id);
|
|
133
|
+
const drop = async (reason) => {
|
|
134
|
+
options.attempts.delete(toHex(itemId));
|
|
135
|
+
await dropItem(storage, queue, [
|
|
136
|
+
itemId,
|
|
137
|
+
...(await dependentKeyPackages(storage, resource, oldId)),
|
|
138
|
+
]);
|
|
139
|
+
outcomes.push(Object.freeze({ kind: "dropped", oldId, reason }));
|
|
140
|
+
};
|
|
141
|
+
if (!bytesEqual(controlRecordSigner(record), mine)) {
|
|
142
|
+
await drop("not-author");
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const attempt = (options.attempts.get(toHex(itemId)) ?? 0) + 1;
|
|
146
|
+
if (attempt > maxAttempts) {
|
|
147
|
+
await drop("attempts-exhausted");
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
options.attempts.set(toHex(itemId), attempt);
|
|
151
|
+
const local = await localControlChain(storage, resource);
|
|
152
|
+
if (local === undefined || local.kind !== "linear") {
|
|
153
|
+
await drop("unauthorized");
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const body = record.body;
|
|
157
|
+
if (body.type === "CAPABILITY_GRANT") {
|
|
158
|
+
if (equivalentGrant(local.state, body.subject.principalId, body.abilities)) {
|
|
159
|
+
await drop("superseded");
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const reauthored = reauthor(local, (header) => signControlRecord(header, {
|
|
163
|
+
type: "CAPABILITY_GRANT",
|
|
164
|
+
subject: body.subject,
|
|
165
|
+
abilities: body.abilities,
|
|
166
|
+
delegable: body.delegable,
|
|
167
|
+
...(body.claimLimit === undefined ? {} : { claimLimit: body.claimLimit }),
|
|
168
|
+
}, signer));
|
|
169
|
+
if (reauthored === undefined) {
|
|
170
|
+
await drop("unauthorized");
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const resealed = await resealDependents(options, reauthored.next, oldId, reauthored.newId);
|
|
174
|
+
if (!resealed.ok) {
|
|
175
|
+
await drop(resealed.reason);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
await swap(storage, queue, {
|
|
179
|
+
removeControl: itemId,
|
|
180
|
+
removeKeyPackages: resealed.removed,
|
|
181
|
+
addControl: reauthored.bytes,
|
|
182
|
+
addKeyPackages: resealed.added,
|
|
183
|
+
});
|
|
184
|
+
// Carry the attempt count onto the re-authored record, so a head that
|
|
185
|
+
// keeps moving is still bounded (the count is cleared on its ACK).
|
|
186
|
+
options.attempts.delete(toHex(itemId));
|
|
187
|
+
options.attempts.set(toHex(reauthored.newId), attempt);
|
|
188
|
+
outcomes.push(Object.freeze({
|
|
189
|
+
kind: "reproposed",
|
|
190
|
+
oldId,
|
|
191
|
+
newId: reauthored.newId,
|
|
192
|
+
keyPackages: resealed.mapping,
|
|
193
|
+
}));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (body.type === "CAPABILITY_REVOKE") {
|
|
197
|
+
if (!isGrantActive(local.state, body.grantId)) {
|
|
198
|
+
await drop("already-revoked");
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const reauthored = reauthor(local, (header) => signControlRecord(header, { type: "CAPABILITY_REVOKE", grantId: body.grantId }, signer));
|
|
202
|
+
if (reauthored === undefined) {
|
|
203
|
+
await drop("unauthorized");
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
await swap(storage, queue, {
|
|
207
|
+
removeControl: itemId,
|
|
208
|
+
removeKeyPackages: [],
|
|
209
|
+
addControl: reauthored.bytes,
|
|
210
|
+
addKeyPackages: [],
|
|
211
|
+
});
|
|
212
|
+
options.attempts.delete(toHex(itemId));
|
|
213
|
+
options.attempts.set(toHex(reauthored.newId), attempt);
|
|
214
|
+
outcomes.push(Object.freeze({ kind: "reproposed", oldId, newId: reauthored.newId, keyPackages: [] }));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
await drop("unsupported-intent");
|
|
218
|
+
}
|
|
219
|
+
return Object.freeze({ outcomes: Object.freeze(outcomes) });
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=repropose.js.map
|
package/dist/revoke.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ControlRecordId, type DataEpoch, type PrincipalId } from "@openlfcp/core";
|
|
2
|
+
import type { ResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import { type ChainResult, type HaveVector, type Signer } from "@openlfcp/wire";
|
|
4
|
+
/**
|
|
5
|
+
* Removing a member's access (LFCP-02-060, LFCP-WIRE-01 §17.3, §19, §25):
|
|
6
|
+
* every active grant naming the member that the revoker may revoke is
|
|
7
|
+
* revoked, which also deactivates the grants delegated from it (§17.2);
|
|
8
|
+
* then, when the member has no read access left, the Data Epoch is rotated
|
|
9
|
+
* with a fresh DEK (§19, reason "member revoked"), and the new DEK goes in
|
|
10
|
+
* Key Packages to every remaining reader, so the member cannot read new
|
|
11
|
+
* data. Every record is built on the validated head and checked by
|
|
12
|
+
* extending the chain before anything is queued.
|
|
13
|
+
*/
|
|
14
|
+
type Linear = Extract<ChainResult, {
|
|
15
|
+
kind: "linear";
|
|
16
|
+
}>;
|
|
17
|
+
/** A grant naming the member that the revoker may not revoke: the member keeps access through it. */
|
|
18
|
+
export interface RemainingPath {
|
|
19
|
+
readonly grantId: ControlRecordId;
|
|
20
|
+
readonly issuer: PrincipalId;
|
|
21
|
+
readonly abilities: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
/** What a revocation writes, before it is queued. */
|
|
24
|
+
export interface RevocationPlan {
|
|
25
|
+
/** The grants revoked, in record order. */
|
|
26
|
+
readonly revoked: readonly ControlRecordId[];
|
|
27
|
+
/** Grants delegated from them that the revocations deactivate (§17.2). */
|
|
28
|
+
readonly deactivated: readonly ControlRecordId[];
|
|
29
|
+
/** The signed Control Records in order: the revocations, then the Key Epoch if any. */
|
|
30
|
+
readonly records: readonly {
|
|
31
|
+
readonly recordId: ControlRecordId;
|
|
32
|
+
readonly bytes: Uint8Array;
|
|
33
|
+
}[];
|
|
34
|
+
/** The new epoch and its DEK, when the epoch is rotated. */
|
|
35
|
+
readonly rotation: {
|
|
36
|
+
readonly epoch: DataEpoch;
|
|
37
|
+
readonly dek: ResourceDEK;
|
|
38
|
+
} | null;
|
|
39
|
+
/** Sealed Key Packages of the new DEK, one per remaining reader. */
|
|
40
|
+
readonly keyPackages: readonly Uint8Array[];
|
|
41
|
+
/** The readers who receive them. */
|
|
42
|
+
readonly recipients: readonly PrincipalId[];
|
|
43
|
+
readonly remainingPaths: readonly RemainingPath[];
|
|
44
|
+
}
|
|
45
|
+
export type RevocationRefusal =
|
|
46
|
+
/** The member is the Resource's owner: ownership is transferred, never revoked. */
|
|
47
|
+
"would-remove-owner"
|
|
48
|
+
/** No active grant names the member. */
|
|
49
|
+
| "not-member"
|
|
50
|
+
/** The revoker may revoke none of the member's grants, or may not rotate the epoch. */
|
|
51
|
+
| "not-authorized";
|
|
52
|
+
export declare class RevocationRefused extends Error {
|
|
53
|
+
readonly reason: RevocationRefusal;
|
|
54
|
+
constructor(reason: RevocationRefusal, message: string);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Plans the removal of `subject` by `revoker` on the validated chain
|
|
58
|
+
* `view`. `frontier` is the closing epoch's accepted final frontier (the
|
|
59
|
+
* local Have). With `rotate: false`, or when the member keeps read access
|
|
60
|
+
* through a remaining path, the epoch is not rotated. Throws
|
|
61
|
+
* RevocationRefused.
|
|
62
|
+
*/
|
|
63
|
+
export declare function planRevocation(options: {
|
|
64
|
+
readonly view: Linear;
|
|
65
|
+
readonly revoker: Signer;
|
|
66
|
+
readonly subject: PrincipalId;
|
|
67
|
+
readonly frontier: HaveVector;
|
|
68
|
+
readonly rotate?: boolean;
|
|
69
|
+
}): Promise<RevocationPlan>;
|
|
70
|
+
export {};
|
|
71
|
+
//# sourceMappingURL=revoke.d.ts.map
|
package/dist/revoke.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { bytesEqual, controlRecordId, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { ABILITY, ABILITY_NAMES, hasAbility, isGrantActive, KEY_EPOCH_REASON, parseKeyPackage, rotateEpoch, sealKeyPackage, signControlRecord, validateControlChain, verifyKeyPackage, } from "@openlfcp/wire";
|
|
3
|
+
export class RevocationRefused extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
constructor(reason, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
this.name = "RevocationRefused";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const name = (a) => ABILITY_NAMES.get(a) ?? `${a}`;
|
|
12
|
+
/**
|
|
13
|
+
* Plans the removal of `subject` by `revoker` on the validated chain
|
|
14
|
+
* `view`. `frontier` is the closing epoch's accepted final frontier (the
|
|
15
|
+
* local Have). With `rotate: false`, or when the member keeps read access
|
|
16
|
+
* through a remaining path, the epoch is not rotated. Throws
|
|
17
|
+
* RevocationRefused.
|
|
18
|
+
*/
|
|
19
|
+
export async function planRevocation(options) {
|
|
20
|
+
const { view, revoker, subject } = options;
|
|
21
|
+
const state0 = view.state;
|
|
22
|
+
const R = state0.resourceId;
|
|
23
|
+
if (bytesEqual(state0.owner.principalId, subject))
|
|
24
|
+
throw new RevocationRefused("would-remove-owner", "the owner's access is not revoked (§17.3)");
|
|
25
|
+
const grants = [...state0.grants.values()].filter((g) => bytesEqual(g.subject, subject) && isGrantActive(state0, g.id));
|
|
26
|
+
if (grants.length === 0)
|
|
27
|
+
throw new RevocationRefused("not-member", "no active grant names this Principal");
|
|
28
|
+
let bytes = view.records.map((r) => r.signed.bytes);
|
|
29
|
+
let running = view;
|
|
30
|
+
const records = [];
|
|
31
|
+
const revoked = [];
|
|
32
|
+
const remainingPaths = [];
|
|
33
|
+
for (const g of grants) {
|
|
34
|
+
// Already inactive through an earlier revocation of its ancestor (§17.2).
|
|
35
|
+
if (!isGrantActive(running.state, g.id))
|
|
36
|
+
continue;
|
|
37
|
+
const signed = signControlRecord({ resourceId: R, controlSeq: running.state.seq + 1n, prevControlId: running.state.head }, { type: "CAPABILITY_REVOKE", grantId: g.id }, revoker);
|
|
38
|
+
const next = validateControlChain([...bytes, signed.bytes]);
|
|
39
|
+
if (next.kind !== "linear") {
|
|
40
|
+
remainingPaths.push(Object.freeze({
|
|
41
|
+
grantId: g.id,
|
|
42
|
+
issuer: g.issuer,
|
|
43
|
+
abilities: Object.freeze(g.abilities.map(name)),
|
|
44
|
+
}));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
bytes = [...bytes, signed.bytes];
|
|
48
|
+
running = next;
|
|
49
|
+
records.push({ recordId: signed.recordId, bytes: signed.bytes });
|
|
50
|
+
revoked.push(g.id);
|
|
51
|
+
}
|
|
52
|
+
if (revoked.length === 0)
|
|
53
|
+
throw new RevocationRefused("not-authorized", "the revoker may revoke none of this Principal's grants (§17.3)");
|
|
54
|
+
const revokedKeys = new Set(revoked.map((id) => toHex(id)));
|
|
55
|
+
const deactivated = [...state0.grants.values()]
|
|
56
|
+
.filter((g) => !revokedKeys.has(toHex(g.id)) &&
|
|
57
|
+
isGrantActive(state0, g.id) &&
|
|
58
|
+
!isGrantActive(running.state, g.id))
|
|
59
|
+
.map((g) => g.id);
|
|
60
|
+
let rotation = null;
|
|
61
|
+
const keyPackages = [];
|
|
62
|
+
const recipients = [];
|
|
63
|
+
const stillReads = hasAbility(running.state, subject, ABILITY.DATA_READ);
|
|
64
|
+
if (options.rotate !== false && !stillReads) {
|
|
65
|
+
const r = rotateEpoch(running.state, revoker, {
|
|
66
|
+
reason: KEY_EPOCH_REASON.MEMBER_REVOKED,
|
|
67
|
+
finalFrontier: options.frontier,
|
|
68
|
+
});
|
|
69
|
+
const next = validateControlChain([...bytes, r.bytes]);
|
|
70
|
+
if (next.kind !== "linear")
|
|
71
|
+
throw new RevocationRefused("not-authorized", "the revoker may not rotate the Data Epoch (key/rotate, §19)");
|
|
72
|
+
running = next;
|
|
73
|
+
records.push({ recordId: controlRecordId(r.recordId), bytes: r.bytes });
|
|
74
|
+
rotation = Object.freeze({ epoch: r.epoch, dek: r.dek });
|
|
75
|
+
// Every remaining reader but the revoker, who keeps the DEK it made.
|
|
76
|
+
const readers = [
|
|
77
|
+
running.state.owner,
|
|
78
|
+
...[...running.state.principals.values()],
|
|
79
|
+
].filter((d, i, all) => all.findIndex((x) => bytesEqual(x.principalId, d.principalId)) === i &&
|
|
80
|
+
!bytesEqual(d.principalId, revoker.descriptor.principalId) &&
|
|
81
|
+
hasAbility(running.state, d.principalId, ABILITY.DATA_READ));
|
|
82
|
+
for (const recipient of readers) {
|
|
83
|
+
const sealed = await sealKeyPackage({
|
|
84
|
+
resourceId: R,
|
|
85
|
+
epoch: r.epoch,
|
|
86
|
+
controlHead: r.recordId,
|
|
87
|
+
recipient,
|
|
88
|
+
dek: r.dek,
|
|
89
|
+
signer: revoker,
|
|
90
|
+
});
|
|
91
|
+
const check = verifyKeyPackage(running, parseKeyPackage(sealed.bytes));
|
|
92
|
+
if (check.kind !== "authorized")
|
|
93
|
+
throw new RevocationRefused("not-authorized", `the revoker may not distribute the new key: ${check.message}`);
|
|
94
|
+
keyPackages.push(sealed.bytes);
|
|
95
|
+
recipients.push(recipient.principalId);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
revoked: Object.freeze(revoked),
|
|
100
|
+
deactivated: Object.freeze(deactivated),
|
|
101
|
+
records: Object.freeze(records),
|
|
102
|
+
rotation,
|
|
103
|
+
keyPackages: Object.freeze(keyPackages),
|
|
104
|
+
recipients: Object.freeze(recipients),
|
|
105
|
+
remainingPaths: Object.freeze(remainingPaths),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=revoke.js.map
|