@openlfcp/client 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data-unit.d.ts +11 -0
- package/dist/data-unit.js +9 -0
- package/dist/identity/accept.d.ts +115 -0
- package/dist/identity/accept.js +335 -0
- package/dist/identity/context.d.ts +214 -0
- package/dist/identity/context.js +495 -0
- package/dist/identity/devices.d.ts +127 -0
- package/dist/identity/devices.js +221 -0
- package/dist/identity/document.d.ts +112 -0
- package/dist/identity/document.js +54 -0
- package/dist/identity/index.d.ts +17 -0
- package/dist/identity/index.js +17 -0
- package/dist/identity/journal.d.ts +139 -0
- package/dist/identity/journal.js +202 -0
- package/dist/identity/network.d.ts +88 -0
- package/dist/identity/network.js +243 -0
- package/dist/identity/pairing-request.d.ts +147 -0
- package/dist/identity/pairing-request.js +422 -0
- package/dist/identity/pairing.d.ts +225 -0
- package/dist/identity/pairing.js +624 -0
- package/dist/identity/phases.d.ts +75 -0
- package/dist/identity/phases.js +104 -0
- package/dist/identity/proof.d.ts +45 -0
- package/dist/identity/proof.js +71 -0
- package/dist/identity/reconcile.d.ts +73 -0
- package/dist/identity/reconcile.js +273 -0
- package/dist/identity/resource.d.ts +74 -0
- package/dist/identity/resource.js +201 -0
- package/dist/identity/revocation.d.ts +106 -0
- package/dist/identity/revocation.js +403 -0
- package/dist/identity/roster.d.ts +89 -0
- package/dist/identity/roster.js +130 -0
- package/dist/identity/rotation.d.ts +131 -0
- package/dist/identity/rotation.js +363 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/revoke.d.ts +8 -0
- package/dist/revoke.js +12 -8
- package/dist/storage.d.ts +18 -0
- package/dist/storage.js +26 -0
- package/dist/sync-client.d.ts +2 -0
- package/dist/sync-client.js +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { type ControlRecordId, type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type ResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import { type ControlState, type DataProfileCodec, type HaveVector, type PrincipalDescriptor } from "@openlfcp/wire";
|
|
4
|
+
import { type KeyDelivery } from "./context.js";
|
|
5
|
+
import type { FlowOptions } from "./devices.js";
|
|
6
|
+
import { type ChannelPool, type IdentityChannel } from "./network.js";
|
|
7
|
+
import { type LinearChain } from "./phases.js";
|
|
8
|
+
/**
|
|
9
|
+
* Key rotation after device revocations (IDENTITY-PROFILE-01 §8.1–§8.4,
|
|
10
|
+
* §9.2–§9.4): who may rotate, which revocations call for it, the final
|
|
11
|
+
* frontier from the verified accepted set, and the durable KEY_EPOCH
|
|
12
|
+
* phase with its packages.
|
|
13
|
+
*/
|
|
14
|
+
/** §8.1: the rotator test of `candidate` on a state (usually the post-state). */
|
|
15
|
+
export interface RotatorTest {
|
|
16
|
+
readonly holds6: boolean;
|
|
17
|
+
readonly holds7: boolean;
|
|
18
|
+
/** Every reader of the head acts for the candidate's root (W §25.2 same-root delivery). */
|
|
19
|
+
readonly everyReaderOfOwnRoot: boolean;
|
|
20
|
+
readonly rotator: boolean;
|
|
21
|
+
readonly readers: readonly PrincipalDescriptor[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `A` is the rotator of a Resource only when `holds(A, 7)` and it can seal
|
|
25
|
+
* the new DEK to every reader of the head: `holds(A, 6)`, or every reader
|
|
26
|
+
* is of `A`'s root.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rotatorTest(state: ControlState, candidate: PrincipalId): RotatorTest;
|
|
29
|
+
/** An exposing DEVICE_REVOKE on a chain (W §17.5). */
|
|
30
|
+
export interface ExposingRevocation {
|
|
31
|
+
readonly recordId: ControlRecordId;
|
|
32
|
+
readonly seq: bigint;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* §8.4: the exposing revocations committed after the current epoch's
|
|
36
|
+
* KEY_EPOCH (or Genesis). One rotation covers all of them; a revocation
|
|
37
|
+
* that is not exposing never calls for one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function exposingRevocationsSince(chain: LinearChain): readonly ExposingRevocation[];
|
|
40
|
+
/**
|
|
41
|
+
* §8.3: the final frontier of the closing epoch from this installation's
|
|
42
|
+
* locally verified accepted set, after a Data synchronisation: a server's
|
|
43
|
+
* Have Vector is a fetch hint; a sequence that cannot be fetched and
|
|
44
|
+
* verified, or that fails authorization, stays outside. With `data`, a unit
|
|
45
|
+
* counts only when recipients accept it as data (W §26.3 rules 6 and 7: it
|
|
46
|
+
* decrypts under the epoch's DEK and the Data Profile accepts it), so a unit
|
|
47
|
+
* no recipient can merge never pushes an honest later unit of its actor
|
|
48
|
+
* beyond the cutoff; rotateAfter always passes it. Without `data` only
|
|
49
|
+
* signature, authorization and the actor chain are checked (0.3.0).
|
|
50
|
+
*/
|
|
51
|
+
export declare function verifiedFrontier(channel: IdentityChannel, chain: LinearChain, local?: LocalAccepted, data?: FrontierData): Promise<HaveVector>;
|
|
52
|
+
/** This installation's stored units of a Resource and its verified Snapshots' coverage. */
|
|
53
|
+
export interface LocalAccepted {
|
|
54
|
+
/** Every signature-valid unit the storage holds for the Resource (exact bytes). */
|
|
55
|
+
readonly units: readonly Uint8Array[];
|
|
56
|
+
/** The sequences verified Snapshots cover (W §29.3). */
|
|
57
|
+
readonly snapshot: HaveVector;
|
|
58
|
+
/**
|
|
59
|
+
* Unit IDs (hex) whose bytes this installation proved bad as data: a failed
|
|
60
|
+
* AEAD or a Data Profile decode refusal. A missing or mismatching DEK is
|
|
61
|
+
* not such a proof: those units are verified again here.
|
|
62
|
+
*/
|
|
63
|
+
readonly refused?: ReadonlySet<string>;
|
|
64
|
+
/**
|
|
65
|
+
* Unit IDs (hex) this installation accepted on the wire (its actor chain
|
|
66
|
+
* links through them), including units its profile later refused to
|
|
67
|
+
* merge: recipients link through them the same way.
|
|
68
|
+
*/
|
|
69
|
+
readonly accepted?: ReadonlySet<string>;
|
|
70
|
+
}
|
|
71
|
+
/** What the rotating client needs to judge a unit as data (W §26.3 rules 6 and 7). */
|
|
72
|
+
export interface FrontierData {
|
|
73
|
+
/** The DEKs held for the Resource's epochs, by epoch number. */
|
|
74
|
+
readonly deks: ReadonlyMap<string, ResourceDEK>;
|
|
75
|
+
/**
|
|
76
|
+
* The Resource's Data Profile codec, when the application gives it: its
|
|
77
|
+
* `decode` is the profile's acceptance. Without it only decryption is
|
|
78
|
+
* checked here, and a profile refusal this installation recorded still
|
|
79
|
+
* counts (LocalAccepted.refused).
|
|
80
|
+
*/
|
|
81
|
+
readonly profile?: DataProfileCodec<unknown>;
|
|
82
|
+
}
|
|
83
|
+
export type RotationStatus =
|
|
84
|
+
/** This installation committed the KEY_EPOCH and sealed the new DEK to every reader. */
|
|
85
|
+
"rotated"
|
|
86
|
+
/** Another writer closed the epoch after the revocation (§9.3). */
|
|
87
|
+
| "complete"
|
|
88
|
+
/** The rotator test fails here: the root or an owner-acting device rotates (§8.1). */
|
|
89
|
+
| "awaiting-rotation"
|
|
90
|
+
/** The new epoch's DEK is not held here: readers wait for its holder (§9.2). */
|
|
91
|
+
| "awaiting-key-holder"
|
|
92
|
+
/** No exposing revocation since the current epoch opened (§8.4). */
|
|
93
|
+
| "not-needed"
|
|
94
|
+
/** The coordinator stopped answering during the rotation: resume the operation (§9.1). */
|
|
95
|
+
| "unreachable" | "refused";
|
|
96
|
+
export interface RotationOutcome {
|
|
97
|
+
readonly resource: ResourceId;
|
|
98
|
+
readonly status: RotationStatus;
|
|
99
|
+
readonly epoch?: DataEpoch;
|
|
100
|
+
readonly keys?: KeyDelivery;
|
|
101
|
+
readonly code?: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The Rotate phase after a revocation at control sequence `revokedAt`
|
|
105
|
+
* (§8.2, §8.4, §9.2–§9.4), journaled in `operation`. The KEY_EPOCH is
|
|
106
|
+
* always rebuilt on the current head with the current epoch number, the
|
|
107
|
+
* verified frontier and a DEK persisted before the record is sent; a DEK
|
|
108
|
+
* is never sealed before its record is accepted, so a rebuilt record may
|
|
109
|
+
* reuse it (§9.4). After the ACK the DEK goes to every reader of the new
|
|
110
|
+
* head, each package recorded in the ledger; the revoked device, a
|
|
111
|
+
* consumed or cancelled pairing device and suspended devices are not
|
|
112
|
+
* readers and receive nothing.
|
|
113
|
+
*/
|
|
114
|
+
export declare function rotateAfter(o: FlowOptions & {
|
|
115
|
+
readonly channel: IdentityChannel;
|
|
116
|
+
readonly operation: string;
|
|
117
|
+
readonly resource: ResourceId;
|
|
118
|
+
readonly revokedAt: bigint;
|
|
119
|
+
}): Promise<RotationOutcome>;
|
|
120
|
+
/**
|
|
121
|
+
* Resumes every rotation a journaled operation started and did not adopt:
|
|
122
|
+
* a KEY_EPOCH whose ACK was lost leaves its DEK in the operation's pending
|
|
123
|
+
* slot, whatever operation (revocation, reconciliation, pairing) started
|
|
124
|
+
* it (§9.1, §9.2). Run before planning new work on a Resource.
|
|
125
|
+
*/
|
|
126
|
+
export declare function resumeRotations(o: FlowOptions & {
|
|
127
|
+
readonly pool: ChannelPool;
|
|
128
|
+
}): Promise<readonly RotationOutcome[]>;
|
|
129
|
+
/** The control sequence of a record ID (hex) on a chain. */
|
|
130
|
+
export declare const seqOf: (chain: LinearChain, recordIdHex: string) => bigint | undefined;
|
|
131
|
+
//# sourceMappingURL=rotation.d.ts.map
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { bytesEqual, controlRecordId, fromHex, resourceId, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { generateResourceDEK, sha256 } from "@openlfcp/crypto";
|
|
3
|
+
import { ABILITY, actsFor, addSequence, checkDataUnit, hasAbility, hasSequence, InMemorySeenUnits, isExposingRevocation, KEY_EPOCH_REASON, parseDataUnit, receiveDataUnit, rotateEpoch, } from "@openlfcp/wire";
|
|
4
|
+
import { snapshotFrontier } from "../outbound.js";
|
|
5
|
+
import { loadControlChain } from "../storage.js";
|
|
6
|
+
import { deliverKeys, fetchDeks, readersAt, runControlPhase, storeDek, withoutEquivocations, } from "./context.js";
|
|
7
|
+
import { getPendingDek, OperationJournal, PackageLedger, pendingDekRef, phaseOf, putPendingDek, } from "./journal.js";
|
|
8
|
+
import { IdentityUnavailable } from "./network.js";
|
|
9
|
+
import { keyEpochAfter, recordSeq } from "./phases.js";
|
|
10
|
+
/**
|
|
11
|
+
* `A` is the rotator of a Resource only when `holds(A, 7)` and it can seal
|
|
12
|
+
* the new DEK to every reader of the head: `holds(A, 6)`, or every reader
|
|
13
|
+
* is of `A`'s root.
|
|
14
|
+
*/
|
|
15
|
+
export function rotatorTest(state, candidate) {
|
|
16
|
+
const readers = readersAt(state);
|
|
17
|
+
const root = actsFor(state, candidate);
|
|
18
|
+
const holds6 = hasAbility(state, candidate, ABILITY.KEY_DISTRIBUTE);
|
|
19
|
+
const holds7 = hasAbility(state, candidate, ABILITY.KEY_ROTATE);
|
|
20
|
+
const everyReaderOfOwnRoot = readers.every((r) => bytesEqual(actsFor(state, r.principalId), root));
|
|
21
|
+
return Object.freeze({
|
|
22
|
+
holds6,
|
|
23
|
+
holds7,
|
|
24
|
+
everyReaderOfOwnRoot,
|
|
25
|
+
rotator: holds7 && (holds6 || everyReaderOfOwnRoot),
|
|
26
|
+
readers,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* §8.4: the exposing revocations committed after the current epoch's
|
|
31
|
+
* KEY_EPOCH (or Genesis). One rotation covers all of them; a revocation
|
|
32
|
+
* that is not exposing never calls for one.
|
|
33
|
+
*/
|
|
34
|
+
export function exposingRevocationsSince(chain) {
|
|
35
|
+
const opened = chain.state.epochs.get(String(chain.state.epoch.epoch))?.openedBy;
|
|
36
|
+
const from = opened === undefined ? 0n : (recordSeq(chain, opened) ?? 0n);
|
|
37
|
+
const out = [];
|
|
38
|
+
for (let i = 1; i < chain.records.length; i++) {
|
|
39
|
+
const r = chain.records[i];
|
|
40
|
+
if (r === undefined || r.body.type !== "DEVICE_REVOKE" || r.payload.controlSeq <= from)
|
|
41
|
+
continue;
|
|
42
|
+
const before = chain.stateAt(chain.records[i - 1]?.signed.id);
|
|
43
|
+
const after = chain.stateAt(r.signed.id);
|
|
44
|
+
if (before !== undefined && after !== undefined && isExposingRevocation(before, after))
|
|
45
|
+
out.push({ recordId: controlRecordId(r.signed.id), seq: r.payload.controlSeq });
|
|
46
|
+
}
|
|
47
|
+
return Object.freeze(out);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* §8.3: the final frontier of the closing epoch from this installation's
|
|
51
|
+
* locally verified accepted set, after a Data synchronisation: a server's
|
|
52
|
+
* Have Vector is a fetch hint; a sequence that cannot be fetched and
|
|
53
|
+
* verified, or that fails authorization, stays outside. With `data`, a unit
|
|
54
|
+
* counts only when recipients accept it as data (W §26.3 rules 6 and 7: it
|
|
55
|
+
* decrypts under the epoch's DEK and the Data Profile accepts it), so a unit
|
|
56
|
+
* no recipient can merge never pushes an honest later unit of its actor
|
|
57
|
+
* beyond the cutoff; rotateAfter always passes it. Without `data` only
|
|
58
|
+
* signature, authorization and the actor chain are checked (0.3.0).
|
|
59
|
+
*/
|
|
60
|
+
export async function verifiedFrontier(channel, chain, local = { units: [], snapshot: [] }, data) {
|
|
61
|
+
const R = chain.state.resourceId;
|
|
62
|
+
const opened = await channel.open(R);
|
|
63
|
+
const ranges = opened.kind === "nack"
|
|
64
|
+
? []
|
|
65
|
+
: opened.haves.flatMap((h) => {
|
|
66
|
+
const out = h.contiguous > 0n ? [{ actor: h.principalId, start: 1n, end: h.contiguous }] : [];
|
|
67
|
+
for (const [start, end] of h.ranges ?? [])
|
|
68
|
+
out.push({ actor: h.principalId, start, end });
|
|
69
|
+
return out;
|
|
70
|
+
});
|
|
71
|
+
// The fetched and the locally stored units together: an equivocation between a
|
|
72
|
+
// stored unit and a fetched one excludes both, like one within the fetch (W §26.2).
|
|
73
|
+
const all = new Map();
|
|
74
|
+
for (const bytes of [...local.units, ...(await channel.dataUnits(R, ranges))])
|
|
75
|
+
all.set(toHex(sha256(bytes)), bytes);
|
|
76
|
+
const parsedAll = [...all.values()].map((bytes) => ({ bytes, parsed: parseDataUnit(bytes) }));
|
|
77
|
+
// Every unit ID this installation knows of, whatever became of the unit (accepted,
|
|
78
|
+
// excluded as equivocation, or later in this pass): a previous naming one of them
|
|
79
|
+
// is a known fact about the link, never an unknown unit at a Snapshot position.
|
|
80
|
+
const knownIds = new Set(parsedAll.map((u) => toHex(u.parsed.signed.id)));
|
|
81
|
+
const units = withoutEquivocations(chain, parsedAll).sort((a, b) => {
|
|
82
|
+
const x = toHex(a.parsed.payload.actor);
|
|
83
|
+
const y = toHex(b.parsed.payload.actor);
|
|
84
|
+
if (x !== y)
|
|
85
|
+
return x < y ? -1 : 1;
|
|
86
|
+
return a.parsed.payload.actorSeq < b.parsed.payload.actorSeq ? -1 : 1;
|
|
87
|
+
});
|
|
88
|
+
const seen = new InMemorySeenUnits();
|
|
89
|
+
// Per actor: the latest accepted unit held as bytes.
|
|
90
|
+
const latest = new Map();
|
|
91
|
+
// Sequences covered by a verified Snapshot count as accepted (W §29.3).
|
|
92
|
+
let frontier = local.snapshot;
|
|
93
|
+
for (const { bytes, parsed } of units) {
|
|
94
|
+
const p = parsed.payload;
|
|
95
|
+
const check = await checkDataUnit(chain, bytes, seen);
|
|
96
|
+
if (check.kind !== "valid")
|
|
97
|
+
continue;
|
|
98
|
+
const covered = hasSequence(local.snapshot, p.actor, p.actorSeq);
|
|
99
|
+
// W §26.3 rules 6 and 7: a unit recipients cannot decrypt, or whose plaintext
|
|
100
|
+
// the Data Profile refuses, is not accepted data. It is not counted and does
|
|
101
|
+
// not become the actor's latest unit, so the honest unit after it, which names
|
|
102
|
+
// the actor's last good unit (W §26.2), still links. Units a verified Snapshot
|
|
103
|
+
// covers are attested by it (W §29.3).
|
|
104
|
+
if (!covered &&
|
|
105
|
+
data !== undefined &&
|
|
106
|
+
!(await acceptedAsData(chain, bytes, toHex(check.unitId), local, data)))
|
|
107
|
+
continue;
|
|
108
|
+
const actor = toHex(p.actor);
|
|
109
|
+
const last = latest.get(actor);
|
|
110
|
+
let links;
|
|
111
|
+
if (covered)
|
|
112
|
+
links = true;
|
|
113
|
+
else {
|
|
114
|
+
// The actor's last accepted position: its latest held unit or the highest
|
|
115
|
+
// sequence a verified Snapshot covers, whichever is later. A unit at or below
|
|
116
|
+
// an accepted position is not a link of the actor's chain (W §26.2).
|
|
117
|
+
const covered = coveredTop(local.snapshot, p.actor);
|
|
118
|
+
const position = (last?.seq ?? 0n) > covered ? (last?.seq ?? 0n) : covered;
|
|
119
|
+
const prev = p.prevDataUnitId === null ? null : toHex(p.prevDataUnitId);
|
|
120
|
+
// W §26.2: the unit names the actor's latest accepted unit (gaps allowed),
|
|
121
|
+
// or none before the actor's first. When that unit is covered by a
|
|
122
|
+
// Snapshot without its bytes here, its ID cannot be compared: the link is
|
|
123
|
+
// taken only when it names no unit known here at all (not an accepted older
|
|
124
|
+
// one, not an excluded twin, not a later unit of this pass).
|
|
125
|
+
if (position >= p.actorSeq)
|
|
126
|
+
links = false;
|
|
127
|
+
else if (position === 0n)
|
|
128
|
+
links = prev === null;
|
|
129
|
+
else if (prev === null)
|
|
130
|
+
links = false;
|
|
131
|
+
else if (last !== undefined && last.seq === position)
|
|
132
|
+
links = last.id === prev;
|
|
133
|
+
else
|
|
134
|
+
links = !knownIds.has(prev);
|
|
135
|
+
}
|
|
136
|
+
if (!links)
|
|
137
|
+
continue;
|
|
138
|
+
if (last === undefined || last.seq < p.actorSeq)
|
|
139
|
+
latest.set(actor, { id: toHex(check.unitId), seq: p.actorSeq });
|
|
140
|
+
frontier = addSequence(frontier, p.actor, p.actorSeq);
|
|
141
|
+
}
|
|
142
|
+
return frontier;
|
|
143
|
+
}
|
|
144
|
+
/** The highest sequence of `actor` that `vector` covers, or 0. */
|
|
145
|
+
function coveredTop(vector, actor) {
|
|
146
|
+
const h = vector.find((x) => bytesEqual(x.principalId, actor));
|
|
147
|
+
if (h === undefined)
|
|
148
|
+
return 0n;
|
|
149
|
+
let best = h.contiguous;
|
|
150
|
+
for (const [, end] of h.extras)
|
|
151
|
+
if (end > best)
|
|
152
|
+
best = end;
|
|
153
|
+
return best;
|
|
154
|
+
}
|
|
155
|
+
const opaque = (dataProfile) => ({
|
|
156
|
+
dataProfile,
|
|
157
|
+
encode: (v) => v,
|
|
158
|
+
decode: (v) => v,
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* Whether recipients accept the unit as data: its DEK is held and matches
|
|
162
|
+
* the epoch's commitment, it decrypts, and the profile accepts the plaintext
|
|
163
|
+
* (receiveDataUnit's own steps, on a fresh accepted set: "held" for a link
|
|
164
|
+
* not decided here means those steps passed). A unit this client cannot
|
|
165
|
+
* decrypt cannot be verified and stays outside the frontier (W §19).
|
|
166
|
+
*/
|
|
167
|
+
async function acceptedAsData(chain, bytes, unitId, local, data) {
|
|
168
|
+
if (local.accepted?.has(unitId) === true)
|
|
169
|
+
return true;
|
|
170
|
+
if (local.refused?.has(unitId) === true)
|
|
171
|
+
return false;
|
|
172
|
+
const profile = data.profile !== undefined && data.profile.dataProfile === chain.state.dataProfile
|
|
173
|
+
? data.profile
|
|
174
|
+
: opaque(chain.state.dataProfile);
|
|
175
|
+
const r = await receiveDataUnit(chain, bytes, {
|
|
176
|
+
seen: new InMemorySeenUnits(),
|
|
177
|
+
dek: (epoch) => data.deks.get(String(epoch)),
|
|
178
|
+
profile,
|
|
179
|
+
});
|
|
180
|
+
return r.kind === "accepted" || r.kind === "held";
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The Rotate phase after a revocation at control sequence `revokedAt`
|
|
184
|
+
* (§8.2, §8.4, §9.2–§9.4), journaled in `operation`. The KEY_EPOCH is
|
|
185
|
+
* always rebuilt on the current head with the current epoch number, the
|
|
186
|
+
* verified frontier and a DEK persisted before the record is sent; a DEK
|
|
187
|
+
* is never sealed before its record is accepted, so a rebuilt record may
|
|
188
|
+
* reuse it (§9.4). After the ACK the DEK goes to every reader of the new
|
|
189
|
+
* head, each package recorded in the ledger; the revoked device, a
|
|
190
|
+
* consumed or cancelled pairing device and suspended devices are not
|
|
191
|
+
* readers and receive nothing.
|
|
192
|
+
*/
|
|
193
|
+
export async function rotateAfter(o) {
|
|
194
|
+
const journal = new OperationJournal(o.storage);
|
|
195
|
+
const me = o.installation.signer;
|
|
196
|
+
const R = o.resource;
|
|
197
|
+
const id = `rotate:${toHex(R)}`;
|
|
198
|
+
// The epoch of this operation's own accepted KEY_EPOCH, found by its record ID.
|
|
199
|
+
let ownEpoch;
|
|
200
|
+
const outcome = await runControlPhase(o.storage, o.channel, journal, o.operation, {
|
|
201
|
+
id,
|
|
202
|
+
kind: "rotate",
|
|
203
|
+
resource: R,
|
|
204
|
+
facts: { revokedAt: String(o.revokedAt) },
|
|
205
|
+
// §9.3: rebuilt with the current accepted set on every attempt, even on the same head.
|
|
206
|
+
resend: false,
|
|
207
|
+
classify: (chain, isOwn) => {
|
|
208
|
+
const own = chain.records.find((r) => r.body.type === "KEY_EPOCH" &&
|
|
209
|
+
r.payload.controlSeq > o.revokedAt &&
|
|
210
|
+
isOwn(toHex(r.signed.id)));
|
|
211
|
+
if (own?.body.type === "KEY_EPOCH") {
|
|
212
|
+
ownEpoch = own.body.epoch;
|
|
213
|
+
return "committed-own";
|
|
214
|
+
}
|
|
215
|
+
if (keyEpochAfter(chain, o.revokedAt))
|
|
216
|
+
return "complete";
|
|
217
|
+
return rotatorTest(chain.state, me.descriptor.principalId).rotator
|
|
218
|
+
? "pending"
|
|
219
|
+
: "awaiting-rotation";
|
|
220
|
+
},
|
|
221
|
+
build: async (chain) => {
|
|
222
|
+
const frontier = await verifiedFrontier(o.channel, chain, await localAccepted(o, chain), {
|
|
223
|
+
deks: await fetchDeks(o.storage, o.secrets, o.channel, chain, o.installation),
|
|
224
|
+
...profileFor(o, chain),
|
|
225
|
+
});
|
|
226
|
+
const dek = (await getPendingDek(o.secrets, R, o.operation)) ?? generateResourceDEK();
|
|
227
|
+
// The DEK before the record that commits to it (§9.1).
|
|
228
|
+
await putPendingDek(o.secrets, R, o.operation, dek);
|
|
229
|
+
return rotateEpoch(chain.state, me, {
|
|
230
|
+
reason: KEY_EPOCH_REASON.COMPROMISE,
|
|
231
|
+
finalFrontier: frontier,
|
|
232
|
+
dek,
|
|
233
|
+
}).bytes;
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
if (outcome.state === "refused")
|
|
237
|
+
return { resource: R, status: "refused", code: outcome.code ?? "refused" };
|
|
238
|
+
if (outcome.state === "awaiting-rotation")
|
|
239
|
+
return { resource: R, status: "awaiting-rotation" };
|
|
240
|
+
const chain = outcome.chain;
|
|
241
|
+
if (outcome.state === "committed-own" && ownEpoch === undefined)
|
|
242
|
+
// The ACK of this installation's own record (runControlPhase extended the chain with it).
|
|
243
|
+
ownEpoch = chain.state.epoch.epoch;
|
|
244
|
+
if (outcome.state === "committed-own") {
|
|
245
|
+
// The pending DEK is the key of this operation's own epoch, not of the current one:
|
|
246
|
+
// another rotator may have closed it since (§9.2, §9.3).
|
|
247
|
+
const dek = await getPendingDek(o.secrets, R, o.operation);
|
|
248
|
+
if (dek !== undefined && ownEpoch !== undefined) {
|
|
249
|
+
await storeDek(o.storage, o.secrets, chain, ownEpoch, dek);
|
|
250
|
+
await o.secrets.delete(pendingDekRef(R, o.operation));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const epoch = ownEpoch ?? chain.state.epoch.epoch;
|
|
254
|
+
const deks = await fetchDeks(o.storage, o.secrets, o.channel, chain, o.installation);
|
|
255
|
+
if (!deks.has(String(epoch)))
|
|
256
|
+
return { resource: R, status: "awaiting-key-holder", epoch };
|
|
257
|
+
// Every epoch held from the rotated one on, to every reader of the head (§8.2); a takeover
|
|
258
|
+
// seals only what it holds (§9.6).
|
|
259
|
+
const send = new Map([...deks].filter(([e]) => BigInt(e) >= epoch));
|
|
260
|
+
const keys = await deliverKeys({
|
|
261
|
+
channel: o.channel,
|
|
262
|
+
ledger: new PackageLedger(o.storage),
|
|
263
|
+
chain,
|
|
264
|
+
sender: me,
|
|
265
|
+
recipients: readersAt(chain.state),
|
|
266
|
+
deks: send,
|
|
267
|
+
});
|
|
268
|
+
const phase = phaseOf(await journal.load(o.operation), id);
|
|
269
|
+
return {
|
|
270
|
+
resource: R,
|
|
271
|
+
status: phase?.state === "committed-own" ? "rotated" : "complete",
|
|
272
|
+
epoch,
|
|
273
|
+
keys,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The accepted set this installation holds locally for the Resource (its
|
|
278
|
+
* stored accepted units and verified Snapshots, when the storage keeps them),
|
|
279
|
+
* merged into the verified frontier (§8.3).
|
|
280
|
+
*/
|
|
281
|
+
/** The codec the application gave for the Resource's Data Profile, if any. */
|
|
282
|
+
const profileFor = (o, chain) => {
|
|
283
|
+
const profile = o.dataProfiles?.find((p) => p.dataProfile === chain.state.dataProfile);
|
|
284
|
+
return profile === undefined ? {} : { profile };
|
|
285
|
+
};
|
|
286
|
+
async function localAccepted(o, chain) {
|
|
287
|
+
const st = o.storage;
|
|
288
|
+
if (st.dataUnits === undefined || st.snapshots === undefined)
|
|
289
|
+
return { units: [], snapshot: [] };
|
|
290
|
+
const R = chain.state.resourceId;
|
|
291
|
+
// Every stored unit takes part: the storage keeps only signature-valid units,
|
|
292
|
+
// whatever became of their content, and equivocation evidence (both twins,
|
|
293
|
+
// stored as "equivocation") must reach the check (W §26.2).
|
|
294
|
+
const units = [];
|
|
295
|
+
for (const status of [
|
|
296
|
+
"seen",
|
|
297
|
+
"merged",
|
|
298
|
+
"profile-pending",
|
|
299
|
+
"held",
|
|
300
|
+
"quarantined",
|
|
301
|
+
"equivocation",
|
|
302
|
+
"local-failure",
|
|
303
|
+
"profile-held",
|
|
304
|
+
"profile-rejected",
|
|
305
|
+
"profile-unsupported",
|
|
306
|
+
])
|
|
307
|
+
for (const u of await st.dataUnits.withStatus(R, status))
|
|
308
|
+
units.push(u.bytes);
|
|
309
|
+
// What this installation decided as a receiver. Proven bad bytes: AEAD and
|
|
310
|
+
// profile decode failures (a missing or wrong DEK is verified again). Accepted
|
|
311
|
+
// on the wire: the units its actor chain links through, a unit the profile
|
|
312
|
+
// refused when merging included (it keeps the accepted mark).
|
|
313
|
+
const refused = new Set();
|
|
314
|
+
for (const u of await st.dataUnits.withStatus(R, "local-failure"))
|
|
315
|
+
if (/^(AEAD|PROFILE_REJECTED):/.test(u.detail ?? ""))
|
|
316
|
+
refused.add(toHex(u.unitId));
|
|
317
|
+
const accepted = new Set();
|
|
318
|
+
for (const status of ["merged", "profile-pending", "profile-held", "profile-rejected"])
|
|
319
|
+
for (const u of await st.dataUnits.withStatus(R, status))
|
|
320
|
+
if (u.accepted)
|
|
321
|
+
accepted.add(toHex(u.unitId));
|
|
322
|
+
return { units, snapshot: await snapshotFrontier(st, R), refused, accepted };
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Resumes every rotation a journaled operation started and did not adopt:
|
|
326
|
+
* a KEY_EPOCH whose ACK was lost leaves its DEK in the operation's pending
|
|
327
|
+
* slot, whatever operation (revocation, reconciliation, pairing) started
|
|
328
|
+
* it (§9.1, §9.2). Run before planning new work on a Resource.
|
|
329
|
+
*/
|
|
330
|
+
export async function resumeRotations(o) {
|
|
331
|
+
const journal = new OperationJournal(o.storage);
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const op of await journal.list())
|
|
334
|
+
for (const phase of op.phases) {
|
|
335
|
+
if (phase.kind !== "rotate" || phase.facts?.revokedAt === undefined)
|
|
336
|
+
continue;
|
|
337
|
+
const R = resourceId(fromHex(phase.resource));
|
|
338
|
+
if ((await getPendingDek(o.secrets, R, op.id)) === undefined)
|
|
339
|
+
continue;
|
|
340
|
+
const stored = await loadControlChain(o.storage, R);
|
|
341
|
+
if (stored?.kind !== "linear")
|
|
342
|
+
continue;
|
|
343
|
+
try {
|
|
344
|
+
const channel = await o.pool.get(o.installation.signer, stored.state.route.coordinatorUrl);
|
|
345
|
+
out.push(await rotateAfter({
|
|
346
|
+
...o,
|
|
347
|
+
channel,
|
|
348
|
+
operation: op.id,
|
|
349
|
+
resource: R,
|
|
350
|
+
revokedAt: BigInt(phase.facts.revokedAt),
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
if (!(e instanceof IdentityUnavailable))
|
|
355
|
+
throw e;
|
|
356
|
+
out.push({ resource: R, status: "unreachable" });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return Object.freeze(out);
|
|
360
|
+
}
|
|
361
|
+
/** The control sequence of a record ID (hex) on a chain. */
|
|
362
|
+
export const seqOf = (chain, recordIdHex) => recordSeq(chain, fromHex(recordIdHex));
|
|
363
|
+
//# sourceMappingURL=rotation.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { type ClaimJournal, pendingInvitationClaims } from "./claim-journal.js";
|
|
|
7
7
|
export { type ConnectionEvents, ConnectionNotReady, type ConnectionOptions, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, type WebSocketFactory, type WebSocketLike, } from "./connection.js";
|
|
8
8
|
export { type CreateDataUnitOptions, type CreatedDataUnit, createDataUnit } from "./data-unit.js";
|
|
9
9
|
export { EngineGuard, type EngineItem, isEngineTrap, type Suspicion, snapshotItem, unitItem, } from "./engine-guard.js";
|
|
10
|
+
export * from "./identity/index.js";
|
|
10
11
|
export { type AcceptedInvitation, type AcceptInvitationOptions, type AcceptInvitationProgress, type AcceptInvitationStage, abandonInvitationClaim, acceptInvitation, type CreatedInvitation, type CreateInvitationOptions, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, type ResumeInvitationClaimOptions, resumeInvitationClaim, } from "./invite.js";
|
|
11
12
|
export { type AckedItem, type AckOutcome, type AckWrites, type BlockedItem, exponentialBackoff, type NackOutcome, type OutboundMessage, OutboundQueue, type OutboundQueueOptions, type ResourceSyncState, type RetryPolicy, type RetryReason, resourceSyncState, type StaleOutboundUnit, snapshotFrontier, } from "./outbound.js";
|
|
12
13
|
export { controlRecordWrites, createQueuedSnapshot, keyPackageWrites, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
|
|
@@ -16,7 +17,7 @@ export { type ResourcePhase, type ResourcePhaseEvent, resourcePhaseTransition, }
|
|
|
16
17
|
export { planRevocation, type RemainingPath, type RevocationPlan, type RevocationRefusal, RevocationRefused, } from "./revoke.js";
|
|
17
18
|
export { type CatchUp, type ReceivedFact, type ReceivedState, type ReofferReason, receivedState, type SectionState, type StatusEvent, type StatusSnapshot, } from "./section-status.js";
|
|
18
19
|
export { type CreatedSnapshot, type CreateSnapshotOptions, createSnapshot } from "./snapshot.js";
|
|
19
|
-
export { adoptStoredDeks, type CommitOperationOptions, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, localControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
20
|
+
export { adoptStoredDeks, type CommitOperationOptions, checkWriteBarrier, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, localControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, setWriteBarrier, type WriteBarrier, writeBarrier, } from "./storage.js";
|
|
20
21
|
export { type CommitBinding, CommitRefusedError, defaultReconnect, type ReconnectPolicy, type ResourceBinding, type ResourceRefusal, type RevokeAccessResult, type SnapshotBinding, type StagedOperation, SyncClient, type SyncClientOptions, type SyncEvent, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
|
|
21
22
|
export { type Flushed, type Submitted, TypingCoalescer, type TypingCoalescerOptions, } from "./typing.js";
|
|
22
23
|
export { type AccessContext, type AccessState, accessState, type GrantPath, type IssuedInvitation, NotWritableError, type PendingControl, type ServerRefusal, UNKNOWN_ACCESS, type WriteAccess, type WriteDeniedReason, writeAccess, } from "./write-access.js";
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { pendingInvitationClaims } from "./claim-journal.js";
|
|
|
7
7
|
export { ConnectionNotReady, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, } from "./connection.js";
|
|
8
8
|
export { createDataUnit } from "./data-unit.js";
|
|
9
9
|
export { EngineGuard, isEngineTrap, snapshotItem, unitItem, } from "./engine-guard.js";
|
|
10
|
+
export * from "./identity/index.js";
|
|
10
11
|
export { abandonInvitationClaim, acceptInvitation, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, resumeInvitationClaim, } from "./invite.js";
|
|
11
12
|
export { exponentialBackoff, OutboundQueue, resourceSyncState, snapshotFrontier, } from "./outbound.js";
|
|
12
13
|
export { controlRecordWrites, createQueuedSnapshot, keyPackageWrites, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
|
|
@@ -16,7 +17,7 @@ export { resourcePhaseTransition, } from "./resource-state.js";
|
|
|
16
17
|
export { planRevocation, RevocationRefused, } from "./revoke.js";
|
|
17
18
|
export { receivedState, } from "./section-status.js";
|
|
18
19
|
export { createSnapshot } from "./snapshot.js";
|
|
19
|
-
export { adoptStoredDeks, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, localControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
20
|
+
export { adoptStoredDeks, checkWriteBarrier, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, localControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, setWriteBarrier, writeBarrier, } from "./storage.js";
|
|
20
21
|
export { CommitRefusedError, defaultReconnect, SyncClient, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
|
|
21
22
|
export { TypingCoalescer, } from "./typing.js";
|
|
22
23
|
export { accessState, NotWritableError, UNKNOWN_ACCESS, writeAccess, } from "./write-access.js";
|
package/dist/revoke.d.ts
CHANGED
|
@@ -41,6 +41,14 @@ export interface RevocationPlan {
|
|
|
41
41
|
/** The readers who receive them. */
|
|
42
42
|
readonly recipients: readonly PrincipalId[];
|
|
43
43
|
readonly remainingPaths: readonly RemainingPath[];
|
|
44
|
+
/**
|
|
45
|
+
* The member lost read access but the revoker is not the rotator of
|
|
46
|
+
* IDENTITY-PROFILE-01 §8.1 on the post-state (it lacks key/rotate, or it
|
|
47
|
+
* cannot seal to every reader: no key/distribute and a reader of another
|
|
48
|
+
* root): no Key Epoch is planned and the Resource awaits rotation by its
|
|
49
|
+
* owner or an owner-acting device.
|
|
50
|
+
*/
|
|
51
|
+
readonly awaitingRotation: boolean;
|
|
44
52
|
}
|
|
45
53
|
export type RevocationRefusal =
|
|
46
54
|
/** The member is the Resource's owner: ownership is transferred, never revoked. */
|
package/dist/revoke.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { bytesEqual, controlRecordId, toHex, } from "@openlfcp/core";
|
|
2
2
|
import { ABILITY, ABILITY_NAMES, hasAbility, isGrantActive, KEY_EPOCH_REASON, parseKeyPackage, rotateEpoch, sealKeyPackage, signControlRecord, validateControlChain, verifyKeyPackage, } from "@openlfcp/wire";
|
|
3
|
+
import { readersAt } from "./identity/context.js";
|
|
4
|
+
import { rotatorTest } from "./identity/rotation.js";
|
|
3
5
|
export class RevocationRefused extends Error {
|
|
4
6
|
reason;
|
|
5
7
|
constructor(reason, message) {
|
|
@@ -61,7 +63,11 @@ export async function planRevocation(options) {
|
|
|
61
63
|
const keyPackages = [];
|
|
62
64
|
const recipients = [];
|
|
63
65
|
const stillReads = hasAbility(running.state, subject, ABILITY.DATA_READ);
|
|
64
|
-
|
|
66
|
+
// §8.1: rotate only as the rotator of the post-state; else the Resource awaits rotation.
|
|
67
|
+
const awaitingRotation = options.rotate !== false &&
|
|
68
|
+
!stillReads &&
|
|
69
|
+
!rotatorTest(running.state, revoker.descriptor.principalId).rotator;
|
|
70
|
+
if (options.rotate !== false && !stillReads && !awaitingRotation) {
|
|
65
71
|
const r = rotateEpoch(running.state, revoker, {
|
|
66
72
|
reason: KEY_EPOCH_REASON.MEMBER_REVOKED,
|
|
67
73
|
finalFrontier: options.frontier,
|
|
@@ -72,13 +78,10 @@ export async function planRevocation(options) {
|
|
|
72
78
|
running = next;
|
|
73
79
|
records.push({ recordId: controlRecordId(r.recordId), bytes: r.bytes });
|
|
74
80
|
rotation = Object.freeze({ epoch: r.epoch, dek: r.dek });
|
|
75
|
-
// Every
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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));
|
|
81
|
+
// Every Principal with holds(P, 1) at the new head (W §19, §25.2): members,
|
|
82
|
+
// the owner and every active device, revoked and suspended devices
|
|
83
|
+
// excluded; but the revoker, who keeps the DEK it made.
|
|
84
|
+
const readers = readersAt(running.state).filter((d) => !bytesEqual(d.principalId, revoker.descriptor.principalId));
|
|
82
85
|
for (const recipient of readers) {
|
|
83
86
|
const sealed = await sealKeyPackage({
|
|
84
87
|
resourceId: R,
|
|
@@ -103,6 +106,7 @@ export async function planRevocation(options) {
|
|
|
103
106
|
keyPackages: Object.freeze(keyPackages),
|
|
104
107
|
recipients: Object.freeze(recipients),
|
|
105
108
|
remainingPaths: Object.freeze(remainingPaths),
|
|
109
|
+
awaitingRotation,
|
|
106
110
|
});
|
|
107
111
|
}
|
|
108
112
|
//# sourceMappingURL=revoke.js.map
|
package/dist/storage.d.ts
CHANGED
|
@@ -111,6 +111,24 @@ export declare function createQueuedDataUnit<T>(storage: Pick<LfcpStorage, "acto
|
|
|
111
111
|
*/
|
|
112
112
|
readonly onCreated?: (created: CreatedDataUnit, value: T) => void;
|
|
113
113
|
}, also?: readonly StorageWrite[] | ((created: CreatedDataUnit) => readonly StorageWrite[])): Promise<CreatedDataUnit>;
|
|
114
|
+
/**
|
|
115
|
+
* The write barrier of a Resource (IDENTITY-PROFILE-01 §8.6): set once this
|
|
116
|
+
* device has evidence that it lost access there ("access refused") or that
|
|
117
|
+
* it was revoked ("revocation proven"). While it is set, the device creates
|
|
118
|
+
* no Data Unit for that Resource; the local replica stays readable and
|
|
119
|
+
* unsent work stays exportable. Evidence that the device is active again
|
|
120
|
+
* clears it.
|
|
121
|
+
*/
|
|
122
|
+
export interface WriteBarrier {
|
|
123
|
+
readonly state: "access-refused" | "revocation-proven";
|
|
124
|
+
/** The revoking DEVICE_REVOKE (hex), when proven. */
|
|
125
|
+
readonly record?: string;
|
|
126
|
+
readonly reason?: string;
|
|
127
|
+
}
|
|
128
|
+
export declare function writeBarrier(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId): Promise<WriteBarrier | undefined>;
|
|
129
|
+
export declare function setWriteBarrier(storage: Pick<LfcpStorage, "commit">, resource: ResourceId, barrier: WriteBarrier | null): Promise<void>;
|
|
130
|
+
/** Refuses to create a Data Unit behind a write barrier (§8.6), when the storage keeps local marks. */
|
|
131
|
+
export declare function checkWriteBarrier(storage: Partial<Pick<LfcpStorage, "localMarks">>, resource: ResourceId): Promise<void>;
|
|
114
132
|
/**
|
|
115
133
|
* SDK-SECTIONS-INTEGRATION-01 §3.1 (LFCP-02-025): commits one operation, a
|
|
116
134
|
* batch of a profile's changes, as Data Units with their outbound entries,
|
package/dist/storage.js
CHANGED
|
@@ -281,8 +281,33 @@ export async function createQueuedDataUnit(storage, options, also = []) {
|
|
|
281
281
|
const actor = options.actor.descriptor.principalId;
|
|
282
282
|
return serialized(storage, `${toHex(resource)}:${toHex(actor)}`, () => createQueuedDataUnitNow(storage, options, also, resource, actor));
|
|
283
283
|
}
|
|
284
|
+
const BARRIER = "identity-write-barrier:";
|
|
285
|
+
export async function writeBarrier(storage, resource) {
|
|
286
|
+
const v = await storage.localMarks.get(`${BARRIER}${toHex(resource)}`);
|
|
287
|
+
return v === undefined ? undefined : JSON.parse(v);
|
|
288
|
+
}
|
|
289
|
+
export async function setWriteBarrier(storage, resource, barrier) {
|
|
290
|
+
const r = await storage.commit([
|
|
291
|
+
{
|
|
292
|
+
op: "put-local-mark",
|
|
293
|
+
key: `${BARRIER}${toHex(resource)}`,
|
|
294
|
+
value: barrier === null ? null : JSON.stringify(barrier),
|
|
295
|
+
},
|
|
296
|
+
]);
|
|
297
|
+
if (!r.ok)
|
|
298
|
+
throw new Error(`the write barrier was not stored: ${r.reason}`);
|
|
299
|
+
}
|
|
300
|
+
/** Refuses to create a Data Unit behind a write barrier (§8.6), when the storage keeps local marks. */
|
|
301
|
+
export async function checkWriteBarrier(storage, resource) {
|
|
302
|
+
if (storage.localMarks === undefined)
|
|
303
|
+
return;
|
|
304
|
+
const b = await writeBarrier(storage, resource);
|
|
305
|
+
if (b !== undefined)
|
|
306
|
+
throw new LfcpError("AUTHORIZATION_FAILED", `this device must not write to the Resource: ${b.state} (IDENTITY-PROFILE-01 §8.6)`);
|
|
307
|
+
}
|
|
284
308
|
async function createQueuedDataUnitNow(storage, options, also, resource, actor) {
|
|
285
309
|
const { onCreated, previousUnitId, ...create } = options;
|
|
310
|
+
await checkWriteBarrier(storage, resource);
|
|
286
311
|
const latest = await latestAcceptedOwnUnit(storage, resource, actor);
|
|
287
312
|
// §26.2: a null previous only when the writer holds no own unit as
|
|
288
313
|
// accepted (before its first, or after a first that went stale); while it
|
|
@@ -336,6 +361,7 @@ export async function commitOperation(storage, options, also = []) {
|
|
|
336
361
|
return { receipt: existing, committed: false };
|
|
337
362
|
}
|
|
338
363
|
const { onCreated, operationId, intents, values, affectedNodeIds, modelRevision, ...create } = options;
|
|
364
|
+
await checkWriteBarrier(storage, resource);
|
|
339
365
|
void intents;
|
|
340
366
|
const first = await latestAcceptedOwnUnit(storage, resource, actor);
|
|
341
367
|
const created = [];
|