@openlfcp/wire 0.1.0-rc.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/LICENSE +201 -0
- package/README.md +27 -0
- package/dist/capability.d.ts +143 -0
- package/dist/capability.js +409 -0
- package/dist/cbor/decode.d.ts +23 -0
- package/dist/cbor/decode.js +163 -0
- package/dist/cbor/encode.d.ts +10 -0
- package/dist/cbor/encode.js +149 -0
- package/dist/cbor/index.d.ts +10 -0
- package/dist/cbor/index.js +10 -0
- package/dist/cbor/text.d.ts +11 -0
- package/dist/cbor/text.js +5 -0
- package/dist/cbor/value.d.ts +30 -0
- package/dist/cbor/value.js +23 -0
- package/dist/chain.d.ts +140 -0
- package/dist/chain.js +339 -0
- package/dist/control-sync.d.ts +69 -0
- package/dist/control-sync.js +44 -0
- package/dist/control.d.ts +173 -0
- package/dist/control.js +369 -0
- package/dist/cose.d.ts +81 -0
- package/dist/cose.js +133 -0
- package/dist/data-unit.d.ts +204 -0
- package/dist/data-unit.js +314 -0
- package/dist/endpoint.d.ts +51 -0
- package/dist/endpoint.js +116 -0
- package/dist/epoch.d.ts +109 -0
- package/dist/epoch.js +128 -0
- package/dist/fields.d.ts +18 -0
- package/dist/fields.js +78 -0
- package/dist/handshake.d.ts +175 -0
- package/dist/handshake.js +297 -0
- package/dist/have.d.ts +101 -0
- package/dist/have.js +268 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/invite.d.ts +80 -0
- package/dist/invite.js +247 -0
- package/dist/key-package.d.ts +92 -0
- package/dist/key-package.js +132 -0
- package/dist/message.d.ts +367 -0
- package/dist/message.js +690 -0
- package/dist/objects.d.ts +154 -0
- package/dist/objects.js +156 -0
- package/dist/principal.d.ts +43 -0
- package/dist/principal.js +81 -0
- package/dist/session-state.d.ts +47 -0
- package/dist/session-state.js +49 -0
- package/dist/snapshot.d.ts +86 -0
- package/dist/snapshot.js +189 -0
- package/dist/transition.d.ts +97 -0
- package/dist/transition.js +130 -0
- package/package.json +53 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type ResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import { type Signer } from "./cose.js";
|
|
4
|
+
import { type ControlView } from "./epoch.js";
|
|
5
|
+
import { type DataUnitPayload, type Parsed } from "./objects.js";
|
|
6
|
+
import type { PrincipalDescriptor } from "./principal.js";
|
|
7
|
+
/** The fields of a Data Unit that its AAD binds (§26.1). */
|
|
8
|
+
export type DataUnitAadFields = Pick<DataUnitPayload, "resourceId" | "dataEpoch" | "actor" | "actorSeq" | "prevDataUnitId" | "controlHead">;
|
|
9
|
+
/**
|
|
10
|
+
* §26.1: the AAD is the deterministic CBOR array ["LFCP-DATA-v1",
|
|
11
|
+
* resource_id, data_epoch, actor, actor_sequence, previous unit or null,
|
|
12
|
+
* control_head]; nothing else.
|
|
13
|
+
*/
|
|
14
|
+
export declare function dataUnitAad(header: DataUnitAadFields): Uint8Array;
|
|
15
|
+
/** The deterministic §26 payload: keys 0-6 exactly. Checked by the receiver rules before it is returned. */
|
|
16
|
+
export declare function encodeDataUnitPayload(header: DataUnitAadFields & {
|
|
17
|
+
readonly ciphertext: Uint8Array;
|
|
18
|
+
}): Uint8Array;
|
|
19
|
+
/** A newly sealed Data Unit: its exact bytes and its ID, SHA-256 of those bytes (§26, §10.6). */
|
|
20
|
+
export interface SealedDataUnit {
|
|
21
|
+
readonly bytes: Uint8Array;
|
|
22
|
+
readonly unitId: DataUnitId;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Encrypts `plaintext` and signs the Data Unit as `actor` (the actor field
|
|
26
|
+
* is the signer's Principal, so actor and signer cannot differ).
|
|
27
|
+
*
|
|
28
|
+
* BUILDING BLOCK: the sequence must come from an ActorSequenceReservation
|
|
29
|
+
* and never be used twice for one (Resource, actor) (§8, §12: nonce
|
|
30
|
+
* reuse). Use createDataUnit (@openlfcp/client), which enforces that.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sealDataUnit(header: Omit<DataUnitAadFields, "actor">, plaintext: Uint8Array, dek: ResourceDEK, actor: Signer): SealedDataUnit;
|
|
33
|
+
/**
|
|
34
|
+
* The Data Profile boundary (§27). LFCP never interprets plaintext: the
|
|
35
|
+
* profile turns application values into plaintext bytes and validates and
|
|
36
|
+
* decodes received plaintext. Servers never need one.
|
|
37
|
+
*/
|
|
38
|
+
export interface DataProfileCodec<T> {
|
|
39
|
+
/** The Genesis data_profile this codec implements (§15, §27). */
|
|
40
|
+
readonly dataProfile: string;
|
|
41
|
+
/** The plaintext of `value`. */
|
|
42
|
+
encode(value: T): Uint8Array;
|
|
43
|
+
/** Validates and decodes received plaintext; throws to reject it. */
|
|
44
|
+
decode(plaintext: Uint8Array): T;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* What a receiver remembers of Data Units, per (resource, actor, seq), to
|
|
48
|
+
* detect equivocation and replays and to follow actor hash chains (§26.2).
|
|
49
|
+
* Durable implementations belong to the storage tasks (LFCP-034 to
|
|
50
|
+
* LFCP-036).
|
|
51
|
+
*/
|
|
52
|
+
export interface SeenUnits {
|
|
53
|
+
/**
|
|
54
|
+
* Records a signature-valid unit. Resolves to every Data Unit ID recorded
|
|
55
|
+
* for its (resource, actor, seq), this one included, sorted by bytes
|
|
56
|
+
* (more than one is equivocation), and whether this ID is new.
|
|
57
|
+
*/
|
|
58
|
+
recordSignatureValid(resource: ResourceId, actor: PrincipalId, seq: ActorSequence, unitId: DataUnitId): Promise<SeenRecord>;
|
|
59
|
+
/**
|
|
60
|
+
* The actor's accepted (merged) units with `from <= seq <= to`, by
|
|
61
|
+
* sequence (for §26.2: the latest accepted unit of an actor).
|
|
62
|
+
*/
|
|
63
|
+
acceptedIn(resource: ResourceId, actor: PrincipalId, from: ActorSequence, to: ActorSequence): Promise<readonly {
|
|
64
|
+
readonly seq: ActorSequence;
|
|
65
|
+
readonly unitId: DataUnitId;
|
|
66
|
+
}[]>;
|
|
67
|
+
/** The ID of the actor's accepted (merged) unit at `seq`, if any. */
|
|
68
|
+
acceptedAt(resource: ResourceId, actor: PrincipalId, seq: ActorSequence): Promise<DataUnitId | undefined>;
|
|
69
|
+
/** Records that a unit was accepted (merged). */
|
|
70
|
+
markAccepted(resource: ResourceId, actor: PrincipalId, seq: ActorSequence, unitId: DataUnitId): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
/** The result of SeenUnits.recordSignatureValid. */
|
|
73
|
+
export interface SeenRecord {
|
|
74
|
+
readonly unitIds: readonly DataUnitId[];
|
|
75
|
+
readonly firstSeen: boolean;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* FOR TESTS AND DEVELOPMENT ONLY: memory is lost on restart, so a restarted
|
|
79
|
+
* receiver forgets which units it saw and accepted and cannot detect an
|
|
80
|
+
* equivocation against them. Production receivers need a durable SeenUnits
|
|
81
|
+
* (LFCP-034 to LFCP-036).
|
|
82
|
+
*/
|
|
83
|
+
export declare class InMemorySeenUnits implements SeenUnits {
|
|
84
|
+
#private;
|
|
85
|
+
recordSignatureValid(resource: ResourceId, actor: PrincipalId, seq: ActorSequence, unitId: DataUnitId): Promise<SeenRecord>;
|
|
86
|
+
acceptedAt(resource: ResourceId, actor: PrincipalId, seq: ActorSequence): Promise<DataUnitId | undefined>;
|
|
87
|
+
acceptedIn(resource: ResourceId, actor: PrincipalId, from: ActorSequence, to: ActorSequence): Promise<readonly {
|
|
88
|
+
readonly seq: ActorSequence;
|
|
89
|
+
readonly unitId: DataUnitId;
|
|
90
|
+
}[]>;
|
|
91
|
+
markAccepted(resource: ResourceId, actor: PrincipalId, seq: ActorSequence, unitId: DataUnitId): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
export type DataUnitRejectReason = "MALFORMED" | "UNKNOWN_ACTOR" | "SIGNATURE" | "OTHER_RESOURCE" | "UNKNOWN_CONTROL_HEAD" | "UNAUTHORIZED" | "UNKNOWN_EPOCH";
|
|
94
|
+
/** A unit refused with a wire code (steps 1-7). */
|
|
95
|
+
export interface DataUnitRejected {
|
|
96
|
+
readonly kind: "rejected";
|
|
97
|
+
readonly reason: DataUnitRejectReason;
|
|
98
|
+
readonly wireCode: "MALFORMED_MESSAGE" | "MISSING_DEPENDENCY" | "INVALID_SIGNATURE" | "AUTHORIZATION_FAILED";
|
|
99
|
+
readonly message: string;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Two or more signature-valid units for one (resource, actor, seq) (§26.2).
|
|
103
|
+
* No winner is chosen: every ID is listed, sorted by bytes only for
|
|
104
|
+
* reproducible output. `accepted` is the one already merged, if any, so
|
|
105
|
+
* the application can review it.
|
|
106
|
+
*/
|
|
107
|
+
export interface DataUnitEquivocation {
|
|
108
|
+
readonly kind: "equivocation";
|
|
109
|
+
readonly wireCode: "ACTOR_EQUIVOCATION";
|
|
110
|
+
readonly resourceId: ResourceId;
|
|
111
|
+
readonly actor: PrincipalId;
|
|
112
|
+
readonly seq: ActorSequence;
|
|
113
|
+
readonly unitIds: readonly DataUnitId[];
|
|
114
|
+
readonly accepted?: DataUnitId;
|
|
115
|
+
}
|
|
116
|
+
/** Stale offline work (§19.1, LFCP-023): never merged automatically, never dropped silently. */
|
|
117
|
+
export interface DataUnitQuarantined {
|
|
118
|
+
readonly kind: "quarantined";
|
|
119
|
+
readonly code: "STALE_DATA_EPOCH";
|
|
120
|
+
readonly reason: "BEYOND_CUTOFF" | "ACTOR_ABSENT";
|
|
121
|
+
readonly unitId: DataUnitId;
|
|
122
|
+
readonly epoch: DataEpoch;
|
|
123
|
+
readonly closedBy: ControlRecordId;
|
|
124
|
+
}
|
|
125
|
+
/** The DEK-free checks (steps 1-8) that a server runs as well as a client. */
|
|
126
|
+
export type DataUnitCheck = {
|
|
127
|
+
readonly kind: "valid";
|
|
128
|
+
readonly parsed: Parsed<DataUnitPayload>;
|
|
129
|
+
readonly unitId: DataUnitId;
|
|
130
|
+
/** False when this exact unit was recorded before: a replay, harmless. */
|
|
131
|
+
readonly firstSeen: boolean;
|
|
132
|
+
} | DataUnitRejected | DataUnitEquivocation | DataUnitQuarantined;
|
|
133
|
+
export interface DataUnitCheckOptions {
|
|
134
|
+
/** Resolves an actor that no record of the chain describes. */
|
|
135
|
+
readonly resolvePrincipal?: (id: PrincipalId) => PrincipalDescriptor | undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The DEK-free Data Unit checks (steps 1-8), for servers and clients:
|
|
139
|
+
* structure, the actor's signature, equivocation, the referenced head,
|
|
140
|
+
* data/write at that head, and the epoch rules. Records the unit in
|
|
141
|
+
* `seen` once its signature verifies.
|
|
142
|
+
*/
|
|
143
|
+
export declare function checkDataUnit(view: ControlView, bytes: Uint8Array, seen: SeenUnits, options?: DataUnitCheckOptions): Promise<DataUnitCheck>;
|
|
144
|
+
/**
|
|
145
|
+
* Why a cryptographically valid unit is held, not merged (§26.2, G-DP1,
|
|
146
|
+
* G-DP1-GAP): it does not link to the actor's latest accepted unit.
|
|
147
|
+
*/
|
|
148
|
+
export type DataUnitHoldReason =
|
|
149
|
+
/** `previous` names a unit the receiver has not accepted (yet). */
|
|
150
|
+
"GAP"
|
|
151
|
+
/** `previous` names an accepted unit that is not the actor's latest, or the latest is not below this unit. */
|
|
152
|
+
| "PREV_MISMATCH"
|
|
153
|
+
/** `previous` is not null at sequence 1. */
|
|
154
|
+
| "PREV_AT_SEQ1"
|
|
155
|
+
/** `previous` is null although the receiver has accepted a unit of the actor. */
|
|
156
|
+
| "NULL_PREV_AFTER_ACCEPTED";
|
|
157
|
+
export type ReceivedDataUnit<T> = {
|
|
158
|
+
readonly kind: "accepted";
|
|
159
|
+
readonly unitId: DataUnitId;
|
|
160
|
+
readonly actor: PrincipalId;
|
|
161
|
+
readonly seq: ActorSequence;
|
|
162
|
+
readonly epoch: DataEpoch;
|
|
163
|
+
readonly value: T;
|
|
164
|
+
}
|
|
165
|
+
/** The same unit was accepted before: an exact replay, harmless. */
|
|
166
|
+
| {
|
|
167
|
+
readonly kind: "duplicate";
|
|
168
|
+
readonly unitId: DataUnitId;
|
|
169
|
+
} | DataUnitEquivocation
|
|
170
|
+
/**
|
|
171
|
+
* Cryptographically valid and authorized, but its actor-chain context is
|
|
172
|
+
* incomplete: report it to the sync engine and retry when the unit at
|
|
173
|
+
* seq - 1 arrives and links (§26.2, G-DP1).
|
|
174
|
+
*/
|
|
175
|
+
| {
|
|
176
|
+
readonly kind: "held";
|
|
177
|
+
readonly reason: DataUnitHoldReason;
|
|
178
|
+
readonly unitId: DataUnitId;
|
|
179
|
+
readonly actor: PrincipalId;
|
|
180
|
+
readonly seq: ActorSequence;
|
|
181
|
+
readonly previous: DataUnitId | null;
|
|
182
|
+
} | DataUnitQuarantined | DataUnitRejected
|
|
183
|
+
/** Client-local: no wire code (§26.3, ADR 0001 N3); surface it to the application. */
|
|
184
|
+
| {
|
|
185
|
+
readonly kind: "local-failure";
|
|
186
|
+
readonly reason: "NO_DEK" | "DEK_COMMITMENT_MISMATCH" | "AEAD" | "PROFILE_REJECTED";
|
|
187
|
+
readonly unitId: DataUnitId;
|
|
188
|
+
readonly message: string;
|
|
189
|
+
/** PROFILE_REJECTED: what the profile codec threw, e.g. an error carrying a profile diagnostic. */
|
|
190
|
+
readonly error?: unknown;
|
|
191
|
+
};
|
|
192
|
+
export interface ReceiveDataUnitOptions<T> extends DataUnitCheckOptions {
|
|
193
|
+
readonly seen: SeenUnits;
|
|
194
|
+
/** The DEK of a Data Epoch, if this client holds it (from a Key Package, LFCP-024). */
|
|
195
|
+
readonly dek: (epoch: DataEpoch) => ResourceDEK | undefined | Promise<ResourceDEK | undefined>;
|
|
196
|
+
readonly profile: DataProfileCodec<T>;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A received Data Unit end to end (steps 1-10, then the actor hash chain).
|
|
200
|
+
* Only an "accepted" result may be merged; it is recorded in `seen` as
|
|
201
|
+
* accepted. A held unit is retried by calling this again later.
|
|
202
|
+
*/
|
|
203
|
+
export declare function receiveDataUnit<T>(view: ControlView, bytes: Uint8Array, options: ReceiveDataUnitOptions<T>): Promise<ReceivedDataUnit<T>>;
|
|
204
|
+
//# sourceMappingURL=data-unit.d.ts.map
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { actorSequence, bytesEqual, dataEpoch, dataUnitId, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { decryptDataUnit, dekCommitment, deriveActorDataKey, encryptDataUnit, } from "@openlfcp/crypto";
|
|
3
|
+
import { ABILITY, hasAbility } from "./capability.js";
|
|
4
|
+
import { cborMap, decodeStrict, encode } from "./cbor/index.js";
|
|
5
|
+
import { signObject, verifySignedObject } from "./cose.js";
|
|
6
|
+
import { classifyDataUnit } from "./epoch.js";
|
|
7
|
+
import { dataUnitPayloadFromCbor, parseDataUnit, } from "./objects.js";
|
|
8
|
+
/**
|
|
9
|
+
* Data Units (LFCP-WIRE-01 §12, §26): the encrypted, signed unit of
|
|
10
|
+
* application replication. There is no plaintext network mode: the Data
|
|
11
|
+
* Profile produces plaintext locally, and only the encrypted, signed bytes
|
|
12
|
+
* leave the client.
|
|
13
|
+
*
|
|
14
|
+
* Creation: profile plaintext → actor key (§12) → exact AAD (§26.1) →
|
|
15
|
+
* ChaCha20-Poly1305 with the sequence nonce → deterministic payload (§26)
|
|
16
|
+
* → canonical COSE_Sign1 by the actor (§10) → exact bytes → SHA-256 = the
|
|
17
|
+
* Data Unit ID. The public creation API is createDataUnit
|
|
18
|
+
* (@openlfcp/client), which takes the sequence only from an
|
|
19
|
+
* ActorSequenceReservation; sealDataUnit here is its building block.
|
|
20
|
+
*
|
|
21
|
+
* Receipt, in this order (§26.2, §26.3):
|
|
22
|
+
* 1. structure: canonical COSE_Sign1, deterministic payload, exact bytes
|
|
23
|
+
* kept (parseDataUnit) → MALFORMED_MESSAGE;
|
|
24
|
+
* 2. the actor resolves to a Principal Descriptor on the chain →
|
|
25
|
+
* otherwise MISSING_DEPENDENCY;
|
|
26
|
+
* 3, 4. kid = actor and a strict Ed25519 signature (§10.5, §10.5.1) →
|
|
27
|
+
* INVALID_SIGNATURE;
|
|
28
|
+
* then equivocation: another signature-valid unit for the same
|
|
29
|
+
* (resource, actor, seq) → ACTOR_EQUIVOCATION, whatever its
|
|
30
|
+
* authorization or decryptability (§26.2);
|
|
31
|
+
* 5. the referenced Control Head is on the valid chain → MISSING_DEPENDENCY;
|
|
32
|
+
* 6. the actor held data/write at that head (not at the latest one, so
|
|
33
|
+
* offline work on an older head stays valid) → AUTHORIZATION_FAILED;
|
|
34
|
+
* 7, 8. the epoch is recognized at that head, and a closed epoch's unit
|
|
35
|
+
* is within the final frontier (classifyDataUnit, LFCP-023) →
|
|
36
|
+
* MISSING_DEPENDENCY or quarantine (STALE_DATA_EPOCH);
|
|
37
|
+
* 9. AEAD authentication → client-local failure, no wire code (N3);
|
|
38
|
+
* 10. the Data Profile accepts the plaintext → client-local failure.
|
|
39
|
+
* Steps 1-8 need no DEK and no plaintext, so servers run them too
|
|
40
|
+
* (checkDataUnit). After step 10 the actor hash chain decides between
|
|
41
|
+
* accepted and held (§26.2, G-DP1): a cryptographically valid unit whose
|
|
42
|
+
* chain context is incomplete is held, not merged, until it links.
|
|
43
|
+
*/
|
|
44
|
+
const DATA_LABEL = "LFCP-DATA-v1";
|
|
45
|
+
/**
|
|
46
|
+
* §26.1: the AAD is the deterministic CBOR array ["LFCP-DATA-v1",
|
|
47
|
+
* resource_id, data_epoch, actor, actor_sequence, previous unit or null,
|
|
48
|
+
* control_head]; nothing else.
|
|
49
|
+
*/
|
|
50
|
+
export function dataUnitAad(header) {
|
|
51
|
+
return encode([
|
|
52
|
+
DATA_LABEL,
|
|
53
|
+
header.resourceId,
|
|
54
|
+
dataEpoch(header.dataEpoch),
|
|
55
|
+
header.actor,
|
|
56
|
+
actorSequence(header.actorSeq),
|
|
57
|
+
header.prevDataUnitId,
|
|
58
|
+
header.controlHead,
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
/** The deterministic §26 payload: keys 0-6 exactly. Checked by the receiver rules before it is returned. */
|
|
62
|
+
export function encodeDataUnitPayload(header) {
|
|
63
|
+
const bytes = encode(cborMap([
|
|
64
|
+
[0, header.resourceId],
|
|
65
|
+
[1, dataEpoch(header.dataEpoch)],
|
|
66
|
+
[2, header.actor],
|
|
67
|
+
[3, actorSequence(header.actorSeq)],
|
|
68
|
+
[4, header.prevDataUnitId],
|
|
69
|
+
[5, header.controlHead],
|
|
70
|
+
[6, header.ciphertext],
|
|
71
|
+
]));
|
|
72
|
+
dataUnitPayloadFromCbor(decodeStrict(bytes));
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Encrypts `plaintext` and signs the Data Unit as `actor` (the actor field
|
|
77
|
+
* is the signer's Principal, so actor and signer cannot differ).
|
|
78
|
+
*
|
|
79
|
+
* BUILDING BLOCK: the sequence must come from an ActorSequenceReservation
|
|
80
|
+
* and never be used twice for one (Resource, actor) (§8, §12: nonce
|
|
81
|
+
* reuse). Use createDataUnit (@openlfcp/client), which enforces that.
|
|
82
|
+
*/
|
|
83
|
+
export function sealDataUnit(header, plaintext, dek, actor) {
|
|
84
|
+
const seq = actorSequence(header.actorSeq);
|
|
85
|
+
if (seq === 1n && header.prevDataUnitId !== null)
|
|
86
|
+
throw new LfcpError("INVALID_STRUCTURE", "sequence 1 must have a null previous unit (§26.2)");
|
|
87
|
+
const full = { ...header, actor: actor.descriptor.principalId };
|
|
88
|
+
const key = deriveActorDataKey(dek, full.resourceId, full.dataEpoch, full.actor);
|
|
89
|
+
const ciphertext = encryptDataUnit(key, seq, dataUnitAad(full), plaintext);
|
|
90
|
+
const signed = signObject(encodeDataUnitPayload({ ...full, ciphertext }), actor);
|
|
91
|
+
return Object.freeze({ bytes: signed.bytes, unitId: dataUnitId(signed.id) });
|
|
92
|
+
}
|
|
93
|
+
const tupleKey = (resource, actor, seq) => `${toHex(resource)}:${toHex(actor)}:${seq}`;
|
|
94
|
+
/**
|
|
95
|
+
* FOR TESTS AND DEVELOPMENT ONLY: memory is lost on restart, so a restarted
|
|
96
|
+
* receiver forgets which units it saw and accepted and cannot detect an
|
|
97
|
+
* equivocation against them. Production receivers need a durable SeenUnits
|
|
98
|
+
* (LFCP-034 to LFCP-036).
|
|
99
|
+
*/
|
|
100
|
+
export class InMemorySeenUnits {
|
|
101
|
+
#seen = new Map();
|
|
102
|
+
#accepted = new Map();
|
|
103
|
+
recordSignatureValid(resource, actor, seq, unitId) {
|
|
104
|
+
const key = tupleKey(resource, actor, seq);
|
|
105
|
+
let ids = this.#seen.get(key);
|
|
106
|
+
if (ids === undefined) {
|
|
107
|
+
ids = new Map();
|
|
108
|
+
this.#seen.set(key, ids);
|
|
109
|
+
}
|
|
110
|
+
const firstSeen = !ids.has(toHex(unitId));
|
|
111
|
+
ids.set(toHex(unitId), dataUnitId(unitId));
|
|
112
|
+
const unitIds = [...ids.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([, id]) => id);
|
|
113
|
+
return Promise.resolve(Object.freeze({ unitIds: Object.freeze(unitIds), firstSeen }));
|
|
114
|
+
}
|
|
115
|
+
acceptedAt(resource, actor, seq) {
|
|
116
|
+
return Promise.resolve(this.#accepted.get(tupleKey(resource, actor, seq)));
|
|
117
|
+
}
|
|
118
|
+
acceptedIn(resource, actor, from, to) {
|
|
119
|
+
const prefix = `${toHex(resource)}:${toHex(actor)}:`;
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const [key, unitId] of this.#accepted) {
|
|
122
|
+
if (!key.startsWith(prefix))
|
|
123
|
+
continue;
|
|
124
|
+
const seq = BigInt(key.slice(prefix.length));
|
|
125
|
+
if (seq >= from && seq <= to)
|
|
126
|
+
out.push({ seq: actorSequence(seq), unitId });
|
|
127
|
+
}
|
|
128
|
+
return Promise.resolve(out.sort((a, b) => (a.seq < b.seq ? -1 : a.seq > b.seq ? 1 : 0)));
|
|
129
|
+
}
|
|
130
|
+
markAccepted(resource, actor, seq, unitId) {
|
|
131
|
+
this.#accepted.set(tupleKey(resource, actor, seq), dataUnitId(unitId));
|
|
132
|
+
return Promise.resolve();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const rejected = (reason, wireCode, message) => Object.freeze({ kind: "rejected", reason, wireCode, message });
|
|
136
|
+
/** Steps 1-4 and the equivocation check. */
|
|
137
|
+
async function verifyUnit(view, bytes, seen, options) {
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = parseDataUnit(bytes);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
return rejected("MALFORMED", "MALFORMED_MESSAGE", e instanceof Error ? e.message : String(e));
|
|
144
|
+
}
|
|
145
|
+
const p = parsed.payload;
|
|
146
|
+
const actor = view.state.principals.get(toHex(p.actor)) ?? options.resolvePrincipal?.(p.actor) ?? undefined;
|
|
147
|
+
if (actor === undefined || !bytesEqual(actor.principalId, p.actor))
|
|
148
|
+
return rejected("UNKNOWN_ACTOR", "MISSING_DEPENDENCY", "no Principal Descriptor is known for the actor (§13.1)");
|
|
149
|
+
const signature = verifySignedObject(parsed.signed, actor);
|
|
150
|
+
if (!signature.valid)
|
|
151
|
+
return rejected("SIGNATURE", "INVALID_SIGNATURE", `the unit is not signed by its actor (${signature.reason}, §10.5, §26.3)`);
|
|
152
|
+
const unitId = dataUnitId(parsed.signed.id);
|
|
153
|
+
const record = await seen.recordSignatureValid(p.resourceId, p.actor, p.actorSeq, unitId);
|
|
154
|
+
if (record.unitIds.length > 1) {
|
|
155
|
+
const accepted = await seen.acceptedAt(p.resourceId, p.actor, p.actorSeq);
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
kind: "equivocation",
|
|
158
|
+
wireCode: "ACTOR_EQUIVOCATION",
|
|
159
|
+
resourceId: p.resourceId,
|
|
160
|
+
actor: p.actor,
|
|
161
|
+
seq: p.actorSeq,
|
|
162
|
+
unitIds: record.unitIds,
|
|
163
|
+
...(accepted === undefined ? {} : { accepted }),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return { kind: "verified", parsed, unitId, firstSeen: record.firstSeen };
|
|
167
|
+
}
|
|
168
|
+
/** Steps 5-8. */
|
|
169
|
+
function eligibility(view, parsed, unitId) {
|
|
170
|
+
const p = parsed.payload;
|
|
171
|
+
if (!bytesEqual(p.resourceId, view.state.resourceId))
|
|
172
|
+
return rejected("OTHER_RESOURCE", "MALFORMED_MESSAGE", "the unit is for another Resource");
|
|
173
|
+
const atHead = view.stateAt(p.controlHead);
|
|
174
|
+
if (atHead === undefined)
|
|
175
|
+
return rejected("UNKNOWN_CONTROL_HEAD", "MISSING_DEPENDENCY", `Control Head ${toHex(p.controlHead)} is not on the chain (§13.1, §26.3 rule 3)`);
|
|
176
|
+
if (!hasAbility(atHead, p.actor, ABILITY.DATA_WRITE))
|
|
177
|
+
return rejected("UNAUTHORIZED", "AUTHORIZATION_FAILED", "the actor did not hold data/write at the referenced Control Head (§26.3 rule 2)");
|
|
178
|
+
const c = classifyDataUnit(view, p);
|
|
179
|
+
if (c.kind === "reject")
|
|
180
|
+
return rejected(c.reason, c.wireCode, `${c.reason} (§26.3 rule 4)`);
|
|
181
|
+
if (c.kind === "quarantine")
|
|
182
|
+
return Object.freeze({
|
|
183
|
+
kind: "quarantined",
|
|
184
|
+
code: c.code,
|
|
185
|
+
reason: c.reason,
|
|
186
|
+
unitId,
|
|
187
|
+
epoch: c.epoch,
|
|
188
|
+
closedBy: c.closedBy,
|
|
189
|
+
});
|
|
190
|
+
return { kind: "eligible", atHead };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The DEK-free Data Unit checks (steps 1-8), for servers and clients:
|
|
194
|
+
* structure, the actor's signature, equivocation, the referenced head,
|
|
195
|
+
* data/write at that head, and the epoch rules. Records the unit in
|
|
196
|
+
* `seen` once its signature verifies.
|
|
197
|
+
*/
|
|
198
|
+
export async function checkDataUnit(view, bytes, seen, options = {}) {
|
|
199
|
+
const v = await verifyUnit(view, bytes, seen, options);
|
|
200
|
+
if (v.kind !== "verified")
|
|
201
|
+
return v;
|
|
202
|
+
const e = eligibility(view, v.parsed, v.unitId);
|
|
203
|
+
if (e.kind !== "eligible")
|
|
204
|
+
return e;
|
|
205
|
+
return Object.freeze({
|
|
206
|
+
kind: "valid",
|
|
207
|
+
parsed: v.parsed,
|
|
208
|
+
unitId: v.unitId,
|
|
209
|
+
firstSeen: v.firstSeen,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A received Data Unit end to end (steps 1-10, then the actor hash chain).
|
|
214
|
+
* Only an "accepted" result may be merged; it is recorded in `seen` as
|
|
215
|
+
* accepted. A held unit is retried by calling this again later.
|
|
216
|
+
*/
|
|
217
|
+
export async function receiveDataUnit(view, bytes, options) {
|
|
218
|
+
if (options.profile.dataProfile !== view.state.dataProfile)
|
|
219
|
+
throw new LfcpError("DATA_PROFILE_MISMATCH", `the profile codec is for ${options.profile.dataProfile}, the Resource uses ${view.state.dataProfile}`);
|
|
220
|
+
const v = await verifyUnit(view, bytes, options.seen, options);
|
|
221
|
+
if (v.kind !== "verified")
|
|
222
|
+
return v;
|
|
223
|
+
const p = v.parsed.payload;
|
|
224
|
+
const unitId = v.unitId;
|
|
225
|
+
const accepted = await options.seen.acceptedAt(p.resourceId, p.actor, p.actorSeq);
|
|
226
|
+
if (accepted !== undefined && bytesEqual(accepted, unitId))
|
|
227
|
+
return Object.freeze({ kind: "duplicate", unitId });
|
|
228
|
+
const e = eligibility(view, v.parsed, unitId);
|
|
229
|
+
if (e.kind !== "eligible")
|
|
230
|
+
return e;
|
|
231
|
+
const local = (reason, message, error) => Object.freeze({
|
|
232
|
+
kind: "local-failure",
|
|
233
|
+
reason,
|
|
234
|
+
unitId,
|
|
235
|
+
message,
|
|
236
|
+
...(error !== undefined ? { error } : {}),
|
|
237
|
+
});
|
|
238
|
+
const dek = await options.dek(p.dataEpoch);
|
|
239
|
+
if (dek === undefined)
|
|
240
|
+
return local("NO_DEK", `no DEK is held for epoch ${p.dataEpoch}`);
|
|
241
|
+
const commitment = view.state.epochs.get(String(p.dataEpoch))?.dekCommitment;
|
|
242
|
+
if (commitment === undefined ||
|
|
243
|
+
!bytesEqual(dekCommitment(p.resourceId, p.dataEpoch, dek), commitment))
|
|
244
|
+
return local("DEK_COMMITMENT_MISMATCH", "the DEK does not match the epoch's commitment");
|
|
245
|
+
let plaintext;
|
|
246
|
+
try {
|
|
247
|
+
const key = deriveActorDataKey(dek, p.resourceId, p.dataEpoch, p.actor);
|
|
248
|
+
plaintext = decryptDataUnit(key, p.actorSeq, dataUnitAad(p), p.ciphertext);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
if (err instanceof LfcpError && err.code === "AEAD_AUTHENTICATION_FAILED")
|
|
252
|
+
return local("AEAD", err.message);
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
let value;
|
|
256
|
+
try {
|
|
257
|
+
value = options.profile.decode(plaintext);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
return local("PROFILE_REJECTED", `the Data Profile rejects the plaintext: ${err instanceof Error ? err.message : String(err)}`, err);
|
|
261
|
+
}
|
|
262
|
+
const hold = await holdReason(options.seen, p);
|
|
263
|
+
if (hold !== undefined)
|
|
264
|
+
return Object.freeze({
|
|
265
|
+
kind: "held",
|
|
266
|
+
reason: hold,
|
|
267
|
+
unitId,
|
|
268
|
+
actor: p.actor,
|
|
269
|
+
seq: p.actorSeq,
|
|
270
|
+
previous: p.prevDataUnitId,
|
|
271
|
+
});
|
|
272
|
+
await options.seen.markAccepted(p.resourceId, p.actor, p.actorSeq, unitId);
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
kind: "accepted",
|
|
275
|
+
unitId,
|
|
276
|
+
actor: p.actor,
|
|
277
|
+
seq: p.actorSeq,
|
|
278
|
+
epoch: p.dataEpoch,
|
|
279
|
+
value,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/** §26.2: why the unit cannot link to the actor's accepted unit at seq - 1, or undefined when it links. */
|
|
283
|
+
const MAX_SEQ = actorSequence(2n ** 64n - 1n);
|
|
284
|
+
/**
|
|
285
|
+
* §26.2 (G-DP1-GAP): a unit links when its `previous` names the actor's
|
|
286
|
+
* latest accepted unit, or is null while none is accepted, across any
|
|
287
|
+
* sequence gap (abandoned sequences are holes that never block). Checked
|
|
288
|
+
* cheapest first: units at or above this one, then seq - 1, and only for
|
|
289
|
+
* an actual gap the accepted units further down.
|
|
290
|
+
*/
|
|
291
|
+
async function holdReason(seen, p) {
|
|
292
|
+
const prev = p.prevDataUnitId;
|
|
293
|
+
if (p.actorSeq === 1n)
|
|
294
|
+
return prev === null ? undefined : "PREV_AT_SEQ1";
|
|
295
|
+
// The latest accepted unit is at or above this one: nothing below can link.
|
|
296
|
+
if ((await seen.acceptedIn(p.resourceId, p.actor, p.actorSeq, MAX_SEQ)).length > 0)
|
|
297
|
+
return prev === null ? "NULL_PREV_AFTER_ACCEPTED" : "PREV_MISMATCH";
|
|
298
|
+
const below = actorSequence(p.actorSeq - 1n);
|
|
299
|
+
const atBelow = await seen.acceptedAt(p.resourceId, p.actor, below);
|
|
300
|
+
if (atBelow !== undefined) {
|
|
301
|
+
if (prev === null)
|
|
302
|
+
return "NULL_PREV_AFTER_ACCEPTED";
|
|
303
|
+
return bytesEqual(atBelow, prev) ? undefined : "PREV_MISMATCH";
|
|
304
|
+
}
|
|
305
|
+
// A gap below this unit: the latest accepted unit is further down, if any.
|
|
306
|
+
const accepted = await seen.acceptedIn(p.resourceId, p.actor, actorSequence(1n), below);
|
|
307
|
+
const latest = accepted.at(-1);
|
|
308
|
+
if (prev === null)
|
|
309
|
+
return latest === undefined ? undefined : "NULL_PREV_AFTER_ACCEPTED";
|
|
310
|
+
if (latest !== undefined && bytesEqual(latest.unitId, prev))
|
|
311
|
+
return undefined;
|
|
312
|
+
return accepted.some((a) => bytesEqual(a.unitId, prev)) ? "PREV_MISMATCH" : "GAP";
|
|
313
|
+
}
|
|
314
|
+
//# sourceMappingURL=data-unit.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type CborValue } from "./cbor/index.js";
|
|
2
|
+
/** A sync endpoint (LFCP-WIRE-01 §16). */
|
|
3
|
+
export interface Endpoint {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
/** Lower is preferred. */
|
|
6
|
+
readonly priority: bigint;
|
|
7
|
+
/** §16 flag bits; undefined when the CBOR omits key 2. */
|
|
8
|
+
readonly flags?: bigint;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Decodes an `endpoint` map: exactly keys 0 (tstr) and 1 (uint), optional
|
|
12
|
+
* 2 (uint). A violation is INVALID_STRUCTURE (MALFORMED_MESSAGE on the wire).
|
|
13
|
+
*
|
|
14
|
+
* Structure only. Reserved flag bits are kept: "A writer sets reserved bits
|
|
15
|
+
* to 0; a receiver ignores them" (§16). The receiver's URL scheme rule is
|
|
16
|
+
* checkReceivedUrl, applied by the Control Record decoders.
|
|
17
|
+
*/
|
|
18
|
+
export declare function endpointFromCbor(value: CborValue): Endpoint;
|
|
19
|
+
/** The §16 flag bits LFCP-WIRE-01 defines (0-5); all other bits are reserved. */
|
|
20
|
+
export declare const ENDPOINT_FLAGS: Readonly<{
|
|
21
|
+
DATA_PLANE_STORAGE: bigint;
|
|
22
|
+
CONTROL_PLANE_STORAGE: bigint;
|
|
23
|
+
SNAPSHOTS: bigint;
|
|
24
|
+
PRESENCE: bigint;
|
|
25
|
+
PREFERRED_FOR_READS: bigint;
|
|
26
|
+
PREFERRED_FOR_WRITES: bigint;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* §16, receiver side: "A receiver MUST reject a record carrying such a URL
|
|
30
|
+
* [an endpoint or Control Coordinator URL in a Control Record] with any
|
|
31
|
+
* scheme other than ws or wss with MALFORMED_MESSAGE." The scheme is
|
|
32
|
+
* compared case-insensitively (RFC 3986 §3.1) and must be followed by
|
|
33
|
+
* "://", so a URL without an authority (wss:host) is rejected too. The
|
|
34
|
+
* loopback rule is the sender's. Throws INVALID_STRUCTURE.
|
|
35
|
+
*/
|
|
36
|
+
export declare function checkReceivedUrl(url: string): void;
|
|
37
|
+
/**
|
|
38
|
+
* Checks a URL an LFCP writer puts in an endpoint or a coordinator field
|
|
39
|
+
* (§16: "A sender uses wss://, except ws:// for a loopback address").
|
|
40
|
+
*
|
|
41
|
+
* Accepted: an absolute URI (RFC 3986 §4.3: no fragment) with scheme wss,
|
|
42
|
+
* or ws on a loopback host (localhost, 127.0.0.0/8, [::1]).
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkWriterUrl(url: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Encodes an endpoint for a record this SDK writes. Writer rules: the URL
|
|
47
|
+
* passes checkWriterUrl, the priority and flags are uint64, and no
|
|
48
|
+
* reserved flag bit (6 and up) is set.
|
|
49
|
+
*/
|
|
50
|
+
export declare function endpointToCbor(endpoint: Endpoint): CborValue;
|
|
51
|
+
//# sourceMappingURL=endpoint.d.ts.map
|