@openlfcp/client 0.3.0 → 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.
@@ -1,13 +1,13 @@
1
1
  import { bytesEqual, dataEpoch, fromHex, generateResourceId, principalId, resourceId, secureRandom, toHex, } from "@openlfcp/core";
2
2
  import { dekCommitment, generateResourceDEK } from "@openlfcp/crypto";
3
3
  import { ABILITY, activeDevice, DEVICE_ABILITY, DEVICE_KIND, hasAbility, invitationPrincipal, parseInviteUri, signControlRecord, validateControlChain, verifyInvitationSecret, } from "@openlfcp/wire";
4
- import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runControlPhase, runUnitPhase, sealIdentityUnit, storeDek, syncChain, } from "./context.js";
4
+ import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, runControlPhase, storeDek, syncChain, } from "./context.js";
5
5
  import { registerDevice } from "./devices.js";
6
- import { resourceRef } from "./document.js";
7
6
  import { newOperationId, OperationJournal, PackageLedger, phaseOf, } from "./journal.js";
8
7
  import { ChannelPool, IdentityRefused, IdentityUnavailable } from "./network.js";
9
8
  import { classifyRegister } from "./phases.js";
10
9
  import { identityTarget } from "./resource.js";
10
+ import { writeRosterEntry } from "./roster.js";
11
11
  /**
12
12
  * Invitations accepted by a device and Resources created from a device
13
13
  * (IDENTITY-PROFILE-01 §7.2, §7.3). Inviting is unchanged; a device that
@@ -77,36 +77,8 @@ async function keyAndList(o, pool, journal, operation, I, iUrl, X, url, entry) {
77
77
  others.push(...r.results);
78
78
  }
79
79
  // §7.2 step 4, §7.3: the roster entry, so the human's other installations reach X.
80
- const iChannel = await pool.get(me, iUrl);
81
- const openI = async () => {
82
- const chainI = await syncChain(o.storage, iChannel, I.state.resourceId);
83
- const doc = o.document(I.state.resourceId, genesisOwner(chainI));
84
- const deksI = await fetchDeks(o.storage, o.secrets, iChannel, chainI, o.installation);
85
- const load = await loadIdentityDocument({ channel: iChannel, chain: chainI, deks: deksI, doc });
86
- return { chainI, doc, deksI, load };
87
- };
88
- let roster = (await openI()).doc.roster().has(resourceRef(X));
89
- if (!roster) {
90
- const r = await runUnitPhase(iChannel, journal, operation, {
91
- id: "roster",
92
- resource: I.state.resourceId,
93
- // A unit refused after a rotation of I is written again in the new epoch (W §19.1).
94
- rebuildOn: ["STALE_DATA_EPOCH"],
95
- build: async () => {
96
- const { chainI, doc, deksI, load } = await openI();
97
- return sealIdentityUnit({
98
- storage: o.storage,
99
- chain: chainI,
100
- deks: deksI,
101
- signer: me,
102
- doc,
103
- latest: load.latest,
104
- intents: [{ intent: "roster.put", key: resourceRef(X), entry }],
105
- });
106
- },
107
- });
108
- roster = r.state === "committed-own";
109
- }
80
+ const written = await writeRosterEntry(o, await pool.get(me, iUrl), journal, operation, I.state.resourceId, X, entry);
81
+ const roster = written.state !== "refused";
110
82
  return { rootKeys, siblings: Object.freeze(others), roster };
111
83
  }
112
84
  /** §7.2: the claim of an invitation for the root, signed by the Invitation Principal (W §18.1 rule 5). */
@@ -1,6 +1,6 @@
1
1
  import { type PrincipalId, type ResourceId } from "@openlfcp/core";
2
2
  import type { SecretStore } from "@openlfcp/storage";
3
- import { type CertificateChain, type ControlState, type DeviceCertificate, type DeviceCertificatePayload, type DeviceKind, type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
3
+ import { type CertificateChain, type ControlState, type DataProfileCodec, type DeviceCertificate, type DeviceCertificatePayload, type DeviceKind, type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
4
4
  import { type IdentityStorage, type Installation, type KeyDelivery } from "./context.js";
5
5
  import { type IdentityNetwork } from "./network.js";
6
6
  import { type LinearChain } from "./phases.js";
@@ -31,6 +31,12 @@ export interface FlowOptions {
31
31
  readonly secrets: SecretStore;
32
32
  readonly network: IdentityNetwork;
33
33
  readonly now: () => number;
34
+ /**
35
+ * The application's Data Profile codecs. A rotation uses the Resource's
36
+ * codec to judge which units recipients accept as data (W §26.3 rule 7);
37
+ * without one, only decryption and recorded refusals are checked.
38
+ */
39
+ readonly dataProfiles?: readonly DataProfileCodec<unknown>[];
34
40
  }
35
41
  /**
36
42
  * The certificates above a leaf that `issuer` signs for `root` in the
@@ -12,5 +12,6 @@ export { type RevocationStatus, revocationStatus } from "./proof.js";
12
12
  export { planReconciliation, type ReconcileStep, reconcileDevices, reconcileRecord, 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
- export { type ExposingRevocation, exposingRevocationsSince, type RotationOutcome, type RotationStatus, type RotatorTest, rotateAfter, rotatorTest, verifiedFrontier, } from "./rotation.js";
15
+ export { type AddToRosterOptions, type AddToRosterResult, addToRoster, type RosterWrite, writeRosterEntry, } from "./roster.js";
16
+ export { type ExposingRevocation, exposingRevocationsSince, type FrontierData, type LocalAccepted, type RotationOutcome, type RotationStatus, type RotatorTest, rotateAfter, rotatorTest, verifiedFrontier, } from "./rotation.js";
16
17
  //# sourceMappingURL=index.d.ts.map
@@ -12,5 +12,6 @@ export { revocationStatus } from "./proof.js";
12
12
  export { planReconciliation, reconcileDevices, reconcileRecord, } from "./reconcile.js";
13
13
  export { createIdentityResource, identityTarget, localIdentityResource, resumeIdentityCreation, setLocalIdentityResource, } from "./resource.js";
14
14
  export { previewRevocation, revokeDevice, rotateIfExposed, } from "./revocation.js";
15
+ export { addToRoster, writeRosterEntry, } from "./roster.js";
15
16
  export { exposingRevocationsSince, rotateAfter, rotatorTest, verifiedFrontier, } from "./rotation.js";
16
17
  //# sourceMappingURL=index.js.map
@@ -21,7 +21,7 @@ import type { PhaseState } from "./phases.js";
21
21
  * SecretStore only, under the references below, before the row that names
22
22
  * them is written (LFCP-034 ordering).
23
23
  */
24
- export type OperationKind = "create-identity" | "pairing-session" | "pairing-approval" | "pairing-request" | "register-device" | "revoke-device" | "rotate" | "reconcile" | "accept-invitation" | "create-resource";
24
+ export type OperationKind = "create-identity" | "pairing-session" | "pairing-approval" | "pairing-request" | "register-device" | "revoke-device" | "rotate" | "reconcile" | "accept-invitation" | "create-resource" | "add-to-roster";
25
25
  /** The state of a whole operation. */
26
26
  export type OperationStatus =
27
27
  /** Phases remain; resume it. */
@@ -0,0 +1,89 @@
1
+ import { type ResourceId } from "@openlfcp/core";
2
+ import { type RosterEntryValue } from "./document.js";
3
+ import { OperationJournal } from "./journal.js";
4
+ import { type IdentityChannel } from "./network.js";
5
+ import { type IdentityFlowOptions } from "./resource.js";
6
+ /**
7
+ * The roster of the Identity Resource (IDENTITY-PROFILE-01 §2.1): how the
8
+ * human's other installations learn a Resource (§6). An entry is written as
9
+ * its own durable phase of an operation (§9.1): a unit refused as stale
10
+ * after a rotation of `I` is written again in the current epoch (W §19.1),
11
+ * and an entry already present, by this installation or another writer,
12
+ * is not written again.
13
+ */
14
+ export type RosterWrite =
15
+ /** This installation's unit carrying the entry was accepted. */
16
+ {
17
+ readonly state: "written";
18
+ }
19
+ /** The roster already held an entry for the Resource. */
20
+ | {
21
+ readonly state: "present";
22
+ }
23
+ /** The coordinator refused the unit for another reason; the phase stays open. */
24
+ | {
25
+ readonly state: "refused";
26
+ readonly code: string;
27
+ };
28
+ /** Writes `roster[ref(resource)] = entry` in `I` as the phase `roster` of `operation`. */
29
+ export declare function writeRosterEntry(o: IdentityFlowOptions, channel: IdentityChannel, journal: OperationJournal, operation: string, identity: ResourceId, resource: ResourceId, entry: RosterEntryValue): Promise<RosterWrite>;
30
+ export interface AddToRosterOptions extends IdentityFlowOptions {
31
+ /** The Resource to list (a Resource of the human: created or joined here). */
32
+ readonly resource: ResourceId;
33
+ /** A display name for the roster entry. */
34
+ readonly name?: string;
35
+ /**
36
+ * The entry's fields; by default taken from the Resource's Control Chain:
37
+ * its Data Profile, route endpoints and coordinator (stored here, or read
38
+ * from `url`).
39
+ */
40
+ readonly entry?: RosterEntryValue;
41
+ /** The Resource's coordinator, when its chain is not stored here. */
42
+ readonly url?: string;
43
+ /** Resume this operation. */
44
+ readonly operation?: string;
45
+ }
46
+ export type AddToRosterResult =
47
+ /** This installation wrote the entry. */
48
+ {
49
+ readonly kind: "added";
50
+ readonly operation: string;
51
+ readonly resource: ResourceId;
52
+ }
53
+ /** The roster already lists the Resource (written earlier, or by another device). */
54
+ | {
55
+ readonly kind: "present";
56
+ readonly operation: string;
57
+ readonly resource: ResourceId;
58
+ }
59
+ /** This installation cannot write `I` (`holds(D, 2)` is false, e.g. a Read device, §7.2). */
60
+ | {
61
+ readonly kind: "needs-writer";
62
+ }
63
+ /** The coordinator refused the unit: the operation stays open; resume it with `operation`. */
64
+ | {
65
+ readonly kind: "document-incomplete";
66
+ readonly operation: string;
67
+ readonly resource: ResourceId;
68
+ readonly code: string;
69
+ } | {
70
+ readonly kind: "conflict";
71
+ } | {
72
+ readonly kind: "unavailable";
73
+ readonly operation?: string;
74
+ readonly reason: string;
75
+ };
76
+ /**
77
+ * Adds a Resource to the roster of the Identity Resource as one durable
78
+ * operation (IDENTITY-PROFILE-01 §2.1, §6, §9): the entry the human's other
79
+ * installations read to reach the Resource (pairing step 10, reconciliation
80
+ * §7.1). For a Resource created or joined after `I` was filled (§5.1), for
81
+ * example a section created later on any personal device. Idempotent: an
82
+ * entry already present is not written again; a unit refused as stale
83
+ * after a rotation of `I` is written again in the current epoch; any other
84
+ * refusal leaves the operation open. Only a writer of `I` (the root or a
85
+ * personal device holding data/write there) may add entries (§3 AI4: a
86
+ * pairing or service device never writes the roster).
87
+ */
88
+ export declare function addToRoster(o: AddToRosterOptions): Promise<AddToRosterResult>;
89
+ //# sourceMappingURL=roster.d.ts.map
@@ -0,0 +1,130 @@
1
+ import { fromHex, LfcpError, resourceId, secureRandom, toHex, } from "@openlfcp/core";
2
+ import { ABILITY, hasAbility } from "@openlfcp/wire";
3
+ import { loadControlChain } from "../storage.js";
4
+ import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
5
+ import { resourceRef } from "./document.js";
6
+ import { newOperationId, OperationJournal } from "./journal.js";
7
+ import { ChannelPool, IdentityRefused, IdentityUnavailable, } from "./network.js";
8
+ import { identityTarget } from "./resource.js";
9
+ /** Writes `roster[ref(resource)] = entry` in `I` as the phase `roster` of `operation`. */
10
+ export async function writeRosterEntry(o, channel, journal, operation, identity, resource, entry) {
11
+ const me = o.installation.signer;
12
+ const openI = async () => {
13
+ const chain = await syncChain(o.storage, channel, identity);
14
+ const doc = o.document(identity, genesisOwner(chain));
15
+ const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
16
+ const load = await loadIdentityDocument({ channel, chain, deks, doc });
17
+ return { chain, doc, deks, load };
18
+ };
19
+ if ((await openI()).doc.roster().has(resourceRef(resource)))
20
+ return { state: "present" };
21
+ const r = await runUnitPhase(channel, journal, operation, {
22
+ id: "roster",
23
+ resource: identity,
24
+ // A unit refused after a rotation of I is written again in the new epoch (W §19.1).
25
+ rebuildOn: ["STALE_DATA_EPOCH"],
26
+ // A resumed operation whose unit was refused writes the entry again on the
27
+ // current document, previous unit and actor sequence: the refused bytes may
28
+ // name a previous unit this actor has since moved past (W §26.2). Exact
29
+ // bytes are re-sent only after an unknown outcome (§9.1).
30
+ rebuildRefused: true,
31
+ build: async () => {
32
+ const { chain, doc, deks, load } = await openI();
33
+ // Another writer may have listed the Resource since the last check (a stale
34
+ // retry, a resume): its entry stands and the phase is complete (§9.2).
35
+ if (doc.roster().has(resourceRef(resource)))
36
+ return null;
37
+ return sealIdentityUnit({
38
+ storage: o.storage,
39
+ chain,
40
+ deks,
41
+ signer: me,
42
+ doc,
43
+ latest: load.latest,
44
+ intents: [{ intent: "roster.put", key: resourceRef(resource), entry }],
45
+ });
46
+ },
47
+ });
48
+ if (r.state === "committed-own")
49
+ return { state: "written" };
50
+ if (r.state === "complete")
51
+ return { state: "present" };
52
+ return { state: "refused", code: r.code ?? "refused" };
53
+ }
54
+ /**
55
+ * Adds a Resource to the roster of the Identity Resource as one durable
56
+ * operation (IDENTITY-PROFILE-01 §2.1, §6, §9): the entry the human's other
57
+ * installations read to reach the Resource (pairing step 10, reconciliation
58
+ * §7.1). For a Resource created or joined after `I` was filled (§5.1), for
59
+ * example a section created later on any personal device. Idempotent: an
60
+ * entry already present is not written again; a unit refused as stale
61
+ * after a rotation of `I` is written again in the current epoch; any other
62
+ * refusal leaves the operation open. Only a writer of `I` (the root or a
63
+ * personal device holding data/write there) may add entries (§3 AI4: a
64
+ * pairing or service device never writes the roster).
65
+ */
66
+ export async function addToRoster(o) {
67
+ const target = await identityTarget(o);
68
+ const me = o.installation.signer;
69
+ const journal = new OperationJournal(o.storage);
70
+ const pool = new ChannelPool(o.network);
71
+ let operation = o.operation;
72
+ try {
73
+ const channel = await pool.get(me, target.url);
74
+ const I = await syncChain(o.storage, channel, target.resource);
75
+ if (!hasAbility(I.state, me.descriptor.principalId, ABILITY.DATA_WRITE))
76
+ return { kind: "needs-writer" };
77
+ if (operation === undefined || (await journal.get(operation)) === undefined) {
78
+ const entry = o.entry ?? (await entryOf(o, pool, o.resource));
79
+ operation = operation ?? newOperationId("add-to-roster", secureRandom(8));
80
+ await journal.begin({
81
+ id: operation,
82
+ kind: "add-to-roster",
83
+ createdAt: o.now(),
84
+ intent: {
85
+ identity: toHex(target.resource),
86
+ resource: toHex(o.resource),
87
+ entry: { ...entry, ...(o.name === undefined ? {} : { name: o.name }) },
88
+ },
89
+ });
90
+ }
91
+ const op = await journal.load(operation);
92
+ const resource = resourceId(fromHex(op.intent.resource));
93
+ const written = await writeRosterEntry(o, channel, journal, op.id, target.resource, resource, op.intent.entry);
94
+ if (written.state === "refused")
95
+ return { kind: "document-incomplete", operation: op.id, resource, code: written.code };
96
+ await journal.setStatus(op.id, "done");
97
+ return { kind: written.state === "written" ? "added" : "present", operation: op.id, resource };
98
+ }
99
+ catch (e) {
100
+ if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
101
+ return {
102
+ kind: "unavailable",
103
+ ...(operation === undefined ? {} : { operation }),
104
+ reason: e.message,
105
+ };
106
+ if (e instanceof IdentityConflict)
107
+ return { kind: "conflict" };
108
+ throw e;
109
+ }
110
+ finally {
111
+ pool.closeAll();
112
+ }
113
+ }
114
+ /** The roster entry of a Resource from its Control Chain (W §15, §20): profile, routes, coordinator. */
115
+ async function entryOf(o, pool, resource) {
116
+ const stored = await loadControlChain(o.storage, resource);
117
+ let chain;
118
+ if (stored?.kind === "linear")
119
+ chain = stored;
120
+ else if (o.url !== undefined)
121
+ chain = await syncChain(o.storage, await pool.get(o.installation.signer, o.url), resource);
122
+ else
123
+ throw new LfcpError("MISSING_DEPENDENCY", "the Resource's chain is not stored here: give its entry or its coordinator URL");
124
+ return {
125
+ profile: chain.state.dataProfile,
126
+ endpoints: chain.state.route.endpoints.map((e) => e.url),
127
+ coordinator: chain.state.route.coordinatorUrl,
128
+ };
129
+ }
130
+ //# sourceMappingURL=roster.js.map
@@ -1,5 +1,6 @@
1
1
  import { type ControlRecordId, type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
- import { type ControlState, type HaveVector, type PrincipalDescriptor } from "@openlfcp/wire";
2
+ import { type ResourceDEK } from "@openlfcp/crypto";
3
+ import { type ControlState, type DataProfileCodec, type HaveVector, type PrincipalDescriptor } from "@openlfcp/wire";
3
4
  import { type KeyDelivery } from "./context.js";
4
5
  import type { FlowOptions } from "./devices.js";
5
6
  import { type ChannelPool, type IdentityChannel } from "./network.js";
@@ -40,15 +41,44 @@ export declare function exposingRevocationsSince(chain: LinearChain): readonly E
40
41
  * §8.3: the final frontier of the closing epoch from this installation's
41
42
  * locally verified accepted set, after a Data synchronisation: a server's
42
43
  * Have Vector is a fetch hint; a sequence that cannot be fetched and
43
- * verified, or that fails authorization, stays outside.
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).
44
50
  */
45
- export declare function verifiedFrontier(channel: IdentityChannel, chain: LinearChain, local?: LocalAccepted): Promise<HaveVector>;
51
+ export declare function verifiedFrontier(channel: IdentityChannel, chain: LinearChain, local?: LocalAccepted, data?: FrontierData): Promise<HaveVector>;
46
52
  /** This installation's stored units of a Resource and its verified Snapshots' coverage. */
47
53
  export interface LocalAccepted {
48
54
  /** Every signature-valid unit the storage holds for the Resource (exact bytes). */
49
55
  readonly units: readonly Uint8Array[];
50
56
  /** The sequences verified Snapshots cover (W §29.3). */
51
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>;
52
82
  }
53
83
  export type RotationStatus =
54
84
  /** This installation committed the KEY_EPOCH and sealed the new DEK to every reader. */
@@ -1,6 +1,6 @@
1
1
  import { bytesEqual, controlRecordId, fromHex, resourceId, toHex, } from "@openlfcp/core";
2
2
  import { generateResourceDEK, sha256 } from "@openlfcp/crypto";
3
- import { ABILITY, actsFor, addSequence, checkDataUnit, hasAbility, hasSequence, InMemorySeenUnits, isExposingRevocation, KEY_EPOCH_REASON, parseDataUnit, rotateEpoch, } from "@openlfcp/wire";
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
6
  import { deliverKeys, fetchDeks, readersAt, runControlPhase, storeDek, withoutEquivocations, } from "./context.js";
@@ -50,9 +50,14 @@ export function exposingRevocationsSince(chain) {
50
50
  * §8.3: the final frontier of the closing epoch from this installation's
51
51
  * locally verified accepted set, after a Data synchronisation: a server's
52
52
  * Have Vector is a fetch hint; a sequence that cannot be fetched and
53
- * verified, or that fails authorization, stays outside.
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).
54
59
  */
55
- export async function verifiedFrontier(channel, chain, local = { units: [], snapshot: [] }) {
60
+ export async function verifiedFrontier(channel, chain, local = { units: [], snapshot: [] }, data) {
56
61
  const R = chain.state.resourceId;
57
62
  const opened = await channel.open(R);
58
63
  const ranges = opened.kind === "nack"
@@ -90,10 +95,20 @@ export async function verifiedFrontier(channel, chain, local = { units: [], snap
90
95
  const check = await checkDataUnit(chain, bytes, seen);
91
96
  if (check.kind !== "valid")
92
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;
93
108
  const actor = toHex(p.actor);
94
109
  const last = latest.get(actor);
95
110
  let links;
96
- if (hasSequence(local.snapshot, p.actor, p.actorSeq))
111
+ if (covered)
97
112
  links = true;
98
113
  else {
99
114
  // The actor's last accepted position: its latest held unit or the highest
@@ -137,6 +152,33 @@ function coveredTop(vector, actor) {
137
152
  best = end;
138
153
  return best;
139
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
+ }
140
182
  /**
141
183
  * The Rotate phase after a revocation at control sequence `revokedAt`
142
184
  * (§8.2, §8.4, §9.2–§9.4), journaled in `operation`. The KEY_EPOCH is
@@ -177,7 +219,10 @@ export async function rotateAfter(o) {
177
219
  : "awaiting-rotation";
178
220
  },
179
221
  build: async (chain) => {
180
- const frontier = await verifiedFrontier(o.channel, chain, await localAccepted(o, 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
+ });
181
226
  const dek = (await getPendingDek(o.secrets, R, o.operation)) ?? generateResourceDEK();
182
227
  // The DEK before the record that commits to it (§9.1).
183
228
  await putPendingDek(o.secrets, R, o.operation, dek);
@@ -233,6 +278,11 @@ export async function rotateAfter(o) {
233
278
  * stored accepted units and verified Snapshots, when the storage keeps them),
234
279
  * merged into the verified frontier (§8.3).
235
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
+ };
236
286
  async function localAccepted(o, chain) {
237
287
  const st = o.storage;
238
288
  if (st.dataUnits === undefined || st.snapshots === undefined)
@@ -256,7 +306,20 @@ async function localAccepted(o, chain) {
256
306
  ])
257
307
  for (const u of await st.dataUnits.withStatus(R, status))
258
308
  units.push(u.bytes);
259
- return { units, snapshot: await snapshotFrontier(st, R) };
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 };
260
323
  }
261
324
  /**
262
325
  * Resumes every rotation a journaled operation started and did not adopt:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/client",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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.3.0",
46
- "@openlfcp/storage": "^0.3.0",
47
- "@openlfcp/crypto": "^0.3.0",
48
- "@openlfcp/wire": "^0.3.0"
45
+ "@openlfcp/core": "^0.3.1",
46
+ "@openlfcp/crypto": "^0.3.1",
47
+ "@openlfcp/storage": "^0.3.1",
48
+ "@openlfcp/wire": "^0.3.1"
49
49
  }
50
50
  }