@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
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { bytesEqual, compareCanonicalFrontierOrder, dataEpoch, hash32, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { decryptSnapshot, dekCommitment, deriveSnapshotKey, encryptSnapshot, } 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 { canonicalFrontierToCbor, missingFrom } from "./have.js";
|
|
7
|
+
import { parseSnapshot, snapshotPayloadFromCbor, } from "./objects.js";
|
|
8
|
+
/**
|
|
9
|
+
* Encrypted, signed Snapshots (LFCP-WIRE-01 §29, §29.1, §29.2): a
|
|
10
|
+
* materialization of profile state at a canonical Data frontier. The
|
|
11
|
+
* plaintext is opaque here; its framing belongs to the Data Profile.
|
|
12
|
+
*
|
|
13
|
+
* Creation: canonical frontier → Snapshot key (§29.1.1) → nonce from the
|
|
14
|
+
* Snapshot Sequence (§29.1.2) → the exact seven-element AAD (§29.1.3) →
|
|
15
|
+
* ChaCha20-Poly1305 (§29.1.4) → deterministic payload → COSE_Sign1 by the
|
|
16
|
+
* publisher → exact bytes → SHA-256 = Snapshot ID. The public creation API
|
|
17
|
+
* is createSnapshot (@openlfcp/client), which takes the sequence from a
|
|
18
|
+
* SnapshotSequenceReservation; sealSnapshot is its building block.
|
|
19
|
+
*
|
|
20
|
+
* Receipt (checkSnapshot without a DEK, receiveSnapshot with one):
|
|
21
|
+
* 1. structure: canonical COSE_Sign1, deterministic payload, Snapshot
|
|
22
|
+
* Sequence ≥ 1 and a canonical frontier, as received (never
|
|
23
|
+
* re-normalized) → MALFORMED_MESSAGE;
|
|
24
|
+
* 2. the Resource is the chain's → MALFORMED_MESSAGE;
|
|
25
|
+
* 3. the publisher resolves to a descriptor → MISSING_DEPENDENCY;
|
|
26
|
+
* 4. kid = publisher and a strict signature → INVALID_SIGNATURE;
|
|
27
|
+
* 5. the Control Head is on the valid chain → MISSING_DEPENDENCY;
|
|
28
|
+
* 6. the publisher held snapshot/publish at that head (§29.2) →
|
|
29
|
+
* AUTHORIZATION_FAILED;
|
|
30
|
+
* 7. the epoch is known at that head → MISSING_DEPENDENCY;
|
|
31
|
+
* 8. when the epoch has since been closed, the frontier covers no unit
|
|
32
|
+
* beyond its final frontier → STALE_DATA_EPOCH (§29, G-EP4);
|
|
33
|
+
* 9. the DEK, the exact AAD rebuilt from the received fields, AEAD
|
|
34
|
+
* (one layout only, §29.1.4) → client-local failure, no wire code;
|
|
35
|
+
* 10. the Data Profile's Snapshot codec accepts the plaintext.
|
|
36
|
+
*/
|
|
37
|
+
const SNAPSHOT_LABEL = "LFCP-SNAPSHOT-v1";
|
|
38
|
+
/**
|
|
39
|
+
* §29.1.3: deterministic CBOR of ["LFCP-SNAPSHOT-v1", resource_id,
|
|
40
|
+
* data_epoch, publisher, snapshot_sequence, control_head,
|
|
41
|
+
* canonical_frontier]. The frontier must already be canonical: it is
|
|
42
|
+
* encoded as given and refused otherwise, never re-ordered.
|
|
43
|
+
*/
|
|
44
|
+
export function snapshotAad(fields) {
|
|
45
|
+
if (fields.snapshotSeq < 1n)
|
|
46
|
+
throw new LfcpError("OUT_OF_RANGE", "a Snapshot Sequence starts at 1 (§29)");
|
|
47
|
+
return encode([
|
|
48
|
+
SNAPSHOT_LABEL,
|
|
49
|
+
fields.resourceId,
|
|
50
|
+
dataEpoch(fields.dataEpoch),
|
|
51
|
+
fields.publisher,
|
|
52
|
+
fields.snapshotSeq,
|
|
53
|
+
fields.controlHead,
|
|
54
|
+
canonicalFrontierExact(fields.frontier),
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The frontier's canonical CBOR (§28.1, §28.2). The frontier must already
|
|
59
|
+
* be canonical: entries in strictly ascending raw Principal ID order (one
|
|
60
|
+
* per actor) and each entry canonical. It is never re-ordered.
|
|
61
|
+
*/
|
|
62
|
+
function canonicalFrontierExact(frontier) {
|
|
63
|
+
for (let i = 1; i < frontier.length; i++)
|
|
64
|
+
if (compareCanonicalFrontierOrder(frontier[i - 1].principalId, frontier[i].principalId) >= 0)
|
|
65
|
+
throw new LfcpError("INVALID_STRUCTURE", "the frontier must be sorted by raw Principal ID with one entry per actor (§28.2)");
|
|
66
|
+
return canonicalFrontierToCbor(frontier);
|
|
67
|
+
}
|
|
68
|
+
/** The deterministic §29 payload, keys 0-6; checked by the receiver rules before it is returned. */
|
|
69
|
+
export function encodeSnapshotPayload(fields) {
|
|
70
|
+
const bytes = encode(cborMap([
|
|
71
|
+
[0, fields.resourceId],
|
|
72
|
+
[1, dataEpoch(fields.dataEpoch)],
|
|
73
|
+
[2, fields.publisher],
|
|
74
|
+
[3, fields.snapshotSeq],
|
|
75
|
+
[4, fields.controlHead],
|
|
76
|
+
[5, canonicalFrontierExact(fields.frontier)],
|
|
77
|
+
[6, fields.ciphertext],
|
|
78
|
+
]));
|
|
79
|
+
snapshotPayloadFromCbor(decodeStrict(bytes));
|
|
80
|
+
return bytes;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Encrypts `plaintext` and signs the Snapshot as `publisher` (the publisher
|
|
84
|
+
* field is the signer's Principal).
|
|
85
|
+
*
|
|
86
|
+
* BUILDING BLOCK: the sequence must come from a SnapshotSequenceReservation
|
|
87
|
+
* and never be reused for one (resource, epoch, publisher) (§29.1.2). Use
|
|
88
|
+
* createSnapshot (@openlfcp/client), which enforces that.
|
|
89
|
+
*/
|
|
90
|
+
export function sealSnapshot(fields, plaintext, dek, publisher) {
|
|
91
|
+
const full = { ...fields, publisher: publisher.descriptor.principalId };
|
|
92
|
+
const key = deriveSnapshotKey(dek, full.resourceId, full.dataEpoch, full.publisher);
|
|
93
|
+
const ciphertext = encryptSnapshot(key, full.snapshotSeq, snapshotAad(full), plaintext);
|
|
94
|
+
const signed = signObject(encodeSnapshotPayload({ ...full, ciphertext }), publisher);
|
|
95
|
+
return Object.freeze({ bytes: signed.bytes, snapshotId: hash32(signed.id) });
|
|
96
|
+
}
|
|
97
|
+
const rejected = (reason, wireCode, message) => Object.freeze({ kind: "rejected", reason, wireCode, message });
|
|
98
|
+
/** Steps 1-8: everything that needs no DEK, for servers and clients. */
|
|
99
|
+
export function checkSnapshot(view, bytes, options = {}) {
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = parseSnapshot(bytes);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return rejected("MALFORMED", "MALFORMED_MESSAGE", e instanceof Error ? e.message : String(e));
|
|
106
|
+
}
|
|
107
|
+
const p = parsed.payload;
|
|
108
|
+
if (!bytesEqual(p.resourceId, view.state.resourceId))
|
|
109
|
+
return rejected("OTHER_RESOURCE", "MALFORMED_MESSAGE", "the Snapshot is for another Resource");
|
|
110
|
+
const publisher = view.state.principals.get(toHex(p.publisher)) ?? options.resolvePrincipal?.(p.publisher);
|
|
111
|
+
if (publisher === undefined || !bytesEqual(publisher.principalId, p.publisher))
|
|
112
|
+
return rejected("UNKNOWN_PUBLISHER", "MISSING_DEPENDENCY", "no Principal Descriptor is known for the publisher");
|
|
113
|
+
const signature = verifySignedObject(parsed.signed, publisher);
|
|
114
|
+
if (!signature.valid)
|
|
115
|
+
return rejected("SIGNATURE", "INVALID_SIGNATURE", `the Snapshot is not signed by its publisher (${signature.reason})`);
|
|
116
|
+
const atHead = view.stateAt(p.controlHead);
|
|
117
|
+
if (atHead === undefined)
|
|
118
|
+
return rejected("UNKNOWN_CONTROL_HEAD", "MISSING_DEPENDENCY", `Control Head ${toHex(p.controlHead)} is not on the chain`);
|
|
119
|
+
if (!hasAbility(atHead, p.publisher, ABILITY.SNAPSHOT_PUBLISH))
|
|
120
|
+
return rejected("UNAUTHORIZED", "AUTHORIZATION_FAILED", "the publisher did not hold snapshot/publish at the referenced Control Head (§29.2)");
|
|
121
|
+
if (!atHead.epochs.has(String(p.dataEpoch)))
|
|
122
|
+
return rejected("UNKNOWN_EPOCH", "MISSING_DEPENDENCY", `epoch ${p.dataEpoch} is not known at the referenced Control Head`);
|
|
123
|
+
const beyond = beyondCutoff(view, p.dataEpoch, p.frontier);
|
|
124
|
+
if (beyond !== undefined)
|
|
125
|
+
return rejected("BEYOND_CUTOFF", "STALE_DATA_EPOCH", beyond);
|
|
126
|
+
return Object.freeze({ kind: "valid", parsed, snapshotId: hash32(parsed.signed.id) });
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* §29 (G-EP4): once a Key Epoch closes the Snapshot's epoch, the Snapshot
|
|
130
|
+
* may cover only units within that epoch's final frontier; units beyond it
|
|
131
|
+
* are quarantined (§19.1), so a Snapshot that includes them would merge
|
|
132
|
+
* stale work. Evaluated against the latest known state, as §19.1 does for
|
|
133
|
+
* Data Units. Returns why, or undefined when within.
|
|
134
|
+
*/
|
|
135
|
+
export function beyondCutoff(view, epoch, frontier) {
|
|
136
|
+
const history = view.state.epochs.get(String(epoch));
|
|
137
|
+
if (history?.finalFrontier === null || history?.finalFrontier === undefined)
|
|
138
|
+
return undefined;
|
|
139
|
+
const extra = missingFrom(history.finalFrontier, frontier);
|
|
140
|
+
if (extra.length === 0)
|
|
141
|
+
return undefined;
|
|
142
|
+
const first = extra[0];
|
|
143
|
+
return `the frontier covers units beyond epoch ${epoch}'s final frontier (actor ${toHex(first.actor).slice(0, 16)}… ${first.start}..${first.end}; G-EP4)`;
|
|
144
|
+
}
|
|
145
|
+
/** A received Snapshot end to end (steps 1-10). Only "accepted" may be loaded. */
|
|
146
|
+
export async function receiveSnapshot(view, bytes, options) {
|
|
147
|
+
if (options.profile.dataProfile !== view.state.dataProfile)
|
|
148
|
+
throw new LfcpError("DATA_PROFILE_MISMATCH", `the profile codec is for ${options.profile.dataProfile}, the Resource uses ${view.state.dataProfile}`);
|
|
149
|
+
const c = checkSnapshot(view, bytes, options);
|
|
150
|
+
if (c.kind !== "valid")
|
|
151
|
+
return c;
|
|
152
|
+
const p = c.parsed.payload;
|
|
153
|
+
const local = (reason, message) => Object.freeze({ kind: "local-failure", reason, snapshotId: c.snapshotId, message });
|
|
154
|
+
const dek = await options.dek(p.dataEpoch);
|
|
155
|
+
if (dek === undefined)
|
|
156
|
+
return local("NO_DEK", `no DEK is held for epoch ${p.dataEpoch}`);
|
|
157
|
+
const commitment = view.state.epochs.get(String(p.dataEpoch))?.dekCommitment;
|
|
158
|
+
if (commitment === undefined ||
|
|
159
|
+
!bytesEqual(dekCommitment(p.resourceId, p.dataEpoch, dek), commitment))
|
|
160
|
+
return local("DEK_COMMITMENT_MISMATCH", "the DEK does not match the epoch's commitment");
|
|
161
|
+
let plaintext;
|
|
162
|
+
try {
|
|
163
|
+
const key = deriveSnapshotKey(dek, p.resourceId, p.dataEpoch, p.publisher);
|
|
164
|
+
plaintext = decryptSnapshot(key, p.snapshotSeq, snapshotAad(p), p.ciphertext);
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
if (e instanceof LfcpError && e.code === "AEAD_AUTHENTICATION_FAILED")
|
|
168
|
+
return local("AEAD", e.message);
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
let value;
|
|
172
|
+
try {
|
|
173
|
+
value = options.profile.decode(plaintext);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
return local("PROFILE_REJECTED", `the Data Profile rejects the Snapshot plaintext: ${e instanceof Error ? e.message : String(e)}`);
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
kind: "accepted",
|
|
180
|
+
snapshotId: c.snapshotId,
|
|
181
|
+
publisher: p.publisher,
|
|
182
|
+
seq: p.snapshotSeq,
|
|
183
|
+
epoch: p.dataEpoch,
|
|
184
|
+
controlHead: p.controlHead,
|
|
185
|
+
frontier: p.frontier,
|
|
186
|
+
value,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type ControlRecordId, LfcpError, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type CborValue } from "./cbor/index.js";
|
|
3
|
+
import { type ChainProblem, type ControlState } from "./chain.js";
|
|
4
|
+
import { type ControlRecord } from "./control.js";
|
|
5
|
+
/**
|
|
6
|
+
* Control transitions with compare-and-swap semantics (LFCP-WIRE-01 §21,
|
|
7
|
+
* §47, §18.1 rule 6). Library logic only: a pure function of the
|
|
8
|
+
* validated current state, the expected head and the candidate's exact
|
|
9
|
+
* bytes. No network, clock, randomness or storage; the caller commits an
|
|
10
|
+
* accepted transition atomically (durable coordinator CAS is server work).
|
|
11
|
+
*
|
|
12
|
+
* One head takes one successor: a candidate is accepted only against the
|
|
13
|
+
* exact current head, so of two candidates built on the same head H, the
|
|
14
|
+
* first accepted moves the head to H2 and the second then fails its
|
|
15
|
+
* expected-head check. A caller that refreshes and rebuilds on H2 is
|
|
16
|
+
* validated again from H2's state, so a one-time invitation already
|
|
17
|
+
* consumed at H2 refuses the second claim (INVITE_CLAIM_EXHAUSTED).
|
|
18
|
+
*
|
|
19
|
+
* Validation reuses validateControlChain from the current state (the
|
|
20
|
+
* `start` path), with the LFCP-021 capability engine and applyRecord:
|
|
21
|
+
* there is no second rule set here.
|
|
22
|
+
*/
|
|
23
|
+
export type TransitionResult = {
|
|
24
|
+
readonly kind: "accepted";
|
|
25
|
+
readonly nextState: ControlState;
|
|
26
|
+
readonly record: ControlRecord;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The candidate is the current head record itself (same record ID, so the
|
|
30
|
+
* same exact bytes): it is already committed. A server may answer a
|
|
31
|
+
* repeated put idempotently; how it answers is the server's decision.
|
|
32
|
+
*/
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: "already-committed";
|
|
35
|
+
readonly head: ControlRecordId;
|
|
36
|
+
}
|
|
37
|
+
/** §47: NACK(CONTROL_HEAD_MISMATCH) "with the current head". */
|
|
38
|
+
| {
|
|
39
|
+
readonly kind: "head-mismatch";
|
|
40
|
+
readonly wireCode: "CONTROL_HEAD_MISMATCH";
|
|
41
|
+
readonly currentHead: ControlRecordId;
|
|
42
|
+
}
|
|
43
|
+
/** §47: Genesis is created with RESOURCE_HOST, never through a Control transition. */
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: "genesis";
|
|
46
|
+
readonly wireCode: "MALFORMED_MESSAGE";
|
|
47
|
+
readonly reason: string;
|
|
48
|
+
} | {
|
|
49
|
+
readonly kind: "unauthorized";
|
|
50
|
+
readonly wireCode: "AUTHORIZATION_FAILED";
|
|
51
|
+
readonly reason: string;
|
|
52
|
+
}
|
|
53
|
+
/** §18.1 rule 3 after serialization: the invitation is used up. AUTHORIZATION_FAILED on the wire. */
|
|
54
|
+
| {
|
|
55
|
+
readonly kind: "claim-exhausted";
|
|
56
|
+
readonly wireCode: "AUTHORIZATION_FAILED";
|
|
57
|
+
readonly code: "INVITE_CLAIM_EXHAUSTED";
|
|
58
|
+
readonly reason: string;
|
|
59
|
+
} | {
|
|
60
|
+
readonly kind: "invalid";
|
|
61
|
+
readonly problem: ChainProblem | "NULL_EXPECTED_HEAD";
|
|
62
|
+
readonly wireCode: string;
|
|
63
|
+
readonly error: LfcpError;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Evaluates one candidate Control Record against the validated current
|
|
67
|
+
* state and the head the submitter expected. Checks, in order: a non-null
|
|
68
|
+
* expected head; the candidate decodes; it is not already the head; it is
|
|
69
|
+
* not a Genesis; the expected head is the current head; then the full
|
|
70
|
+
* successor rules (seq = current + 1, prev = current head, same Resource,
|
|
71
|
+
* signature by the issuer, authority, claim limit) from the current state.
|
|
72
|
+
*/
|
|
73
|
+
export declare function proposeControlTransition(state: ControlState, expectedHead: Uint8Array | null, candidate: Uint8Array): TransitionResult;
|
|
74
|
+
/** The CONTROL_PUT body (§47). */
|
|
75
|
+
export interface ControlPutBody {
|
|
76
|
+
readonly resourceId: ResourceId;
|
|
77
|
+
/** The expected current Control Head (§47: always present). */
|
|
78
|
+
readonly expectedHead: ControlRecordId;
|
|
79
|
+
/** The exact signed Control Record bytes. */
|
|
80
|
+
readonly record: Uint8Array;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Decodes a control-put-body: {0 resource-id, 1 hash32, 2 bstr} (§47).
|
|
84
|
+
* "A CONTROL_PUT always names an expected head; a null expected head is
|
|
85
|
+
* invalid" (§47): null is INVALID_STRUCTURE (MALFORMED_MESSAGE).
|
|
86
|
+
* Structure only; decodeMessage (message.ts) reads it from a CONTROL_PUT.
|
|
87
|
+
*/
|
|
88
|
+
export declare function controlPutBodyFromCbor(value: CborValue): ControlPutBody;
|
|
89
|
+
/**
|
|
90
|
+
* A CONTROL_PUT against the coordinator's current state: the body's
|
|
91
|
+
* Resource must be the state's, and the record must be of that Resource
|
|
92
|
+
* too; then proposeControlTransition.
|
|
93
|
+
*/
|
|
94
|
+
export declare function proposeControlPut(state: ControlState, body: ControlPutBody): TransitionResult;
|
|
95
|
+
/** Decodes a CONTROL_PUT body from its CBOR bytes (a bare body; decodeMessage decodes whole messages). */
|
|
96
|
+
export declare const decodeControlPutBody: (bytes: Uint8Array) => ControlPutBody;
|
|
97
|
+
//# sourceMappingURL=transition.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { bytesEqual, controlRecordId, LfcpError, resourceId, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { authorizeControlRecord } from "./capability.js";
|
|
3
|
+
import { decodeStrict } from "./cbor/index.js";
|
|
4
|
+
import { validateControlChain } from "./chain.js";
|
|
5
|
+
import { decodeControlRecord } from "./control.js";
|
|
6
|
+
import { Fields } from "./fields.js";
|
|
7
|
+
/**
|
|
8
|
+
* Evaluates one candidate Control Record against the validated current
|
|
9
|
+
* state and the head the submitter expected. Checks, in order: a non-null
|
|
10
|
+
* expected head; the candidate decodes; it is not already the head; it is
|
|
11
|
+
* not a Genesis; the expected head is the current head; then the full
|
|
12
|
+
* successor rules (seq = current + 1, prev = current head, same Resource,
|
|
13
|
+
* signature by the issuer, authority, claim limit) from the current state.
|
|
14
|
+
*/
|
|
15
|
+
export function proposeControlTransition(state, expectedHead, candidate) {
|
|
16
|
+
if (expectedHead === null) {
|
|
17
|
+
// §47: "A CONTROL_PUT always names an expected head; a null expected head is invalid."
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
kind: "invalid",
|
|
20
|
+
problem: "NULL_EXPECTED_HEAD",
|
|
21
|
+
wireCode: "MALFORMED_MESSAGE",
|
|
22
|
+
error: new LfcpError("INVALID_STRUCTURE", "an ordinary Control transition needs an expected head"),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
let record;
|
|
26
|
+
try {
|
|
27
|
+
record = decodeControlRecord(candidate);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
const error = e instanceof LfcpError ? e : new LfcpError("INVALID_STRUCTURE", String(e));
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
kind: "invalid",
|
|
33
|
+
problem: error.code === "UNSUPPORTED_VALUE"
|
|
34
|
+
? "UNSUPPORTED_TYPE"
|
|
35
|
+
: error.code === "INVALID_CONTROL_CHAIN"
|
|
36
|
+
? "SEQUENCE"
|
|
37
|
+
: "MALFORMED",
|
|
38
|
+
wireCode: error.code === "UNSUPPORTED_VALUE" || error.code === "INVALID_CONTROL_CHAIN"
|
|
39
|
+
? "INVALID_CONTROL_CHAIN"
|
|
40
|
+
: "MALFORMED_MESSAGE",
|
|
41
|
+
error,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (bytesEqual(record.signed.id, state.head))
|
|
45
|
+
return Object.freeze({ kind: "already-committed", head: state.head });
|
|
46
|
+
if (record.body.type === "GENESIS")
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
kind: "genesis",
|
|
49
|
+
wireCode: "MALFORMED_MESSAGE",
|
|
50
|
+
reason: "Genesis is created with RESOURCE_HOST, not as a Control transition (§47)",
|
|
51
|
+
});
|
|
52
|
+
if (!bytesEqual(expectedHead, state.head))
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
kind: "head-mismatch",
|
|
55
|
+
wireCode: "CONTROL_HEAD_MISMATCH",
|
|
56
|
+
currentHead: state.head,
|
|
57
|
+
});
|
|
58
|
+
let refusal;
|
|
59
|
+
const result = validateControlChain([candidate], {
|
|
60
|
+
start: state,
|
|
61
|
+
authorize: (r, s) => {
|
|
62
|
+
const decision = authorizeControlRecord(r, s);
|
|
63
|
+
if (!decision.allowed)
|
|
64
|
+
refusal = decision;
|
|
65
|
+
return decision;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
if (result.kind === "linear") {
|
|
69
|
+
const accepted = result.records[0];
|
|
70
|
+
if (accepted === undefined)
|
|
71
|
+
// The only candidate was the head itself, which is handled above.
|
|
72
|
+
throw new Error("unreachable: an accepted transition without a record");
|
|
73
|
+
return Object.freeze({ kind: "accepted", nextState: result.state, record: accepted });
|
|
74
|
+
}
|
|
75
|
+
if (result.kind === "conflict")
|
|
76
|
+
// One candidate from one head cannot fork; a second Genesis is refused above.
|
|
77
|
+
throw new Error("unreachable: a single candidate produced a conflict");
|
|
78
|
+
if (result.problem === "UNAUTHORIZED" && refusal !== undefined && !refusal.allowed) {
|
|
79
|
+
if (refusal.code === "INVITE_CLAIM_EXHAUSTED")
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
kind: "claim-exhausted",
|
|
82
|
+
wireCode: "AUTHORIZATION_FAILED",
|
|
83
|
+
code: "INVITE_CLAIM_EXHAUSTED",
|
|
84
|
+
reason: refusal.reason,
|
|
85
|
+
});
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
kind: "unauthorized",
|
|
88
|
+
wireCode: "AUTHORIZATION_FAILED",
|
|
89
|
+
reason: refusal.reason,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
kind: "invalid",
|
|
94
|
+
problem: result.problem,
|
|
95
|
+
wireCode: result.wireCode,
|
|
96
|
+
error: result.error,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Decodes a control-put-body: {0 resource-id, 1 hash32, 2 bstr} (§47).
|
|
101
|
+
* "A CONTROL_PUT always names an expected head; a null expected head is
|
|
102
|
+
* invalid" (§47): null is INVALID_STRUCTURE (MALFORMED_MESSAGE).
|
|
103
|
+
* Structure only; decodeMessage (message.ts) reads it from a CONTROL_PUT.
|
|
104
|
+
*/
|
|
105
|
+
export function controlPutBodyFromCbor(value) {
|
|
106
|
+
const f = new Fields(value, "control-put-body", [0, 1, 2]);
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
resourceId: resourceId(f.bytes(0, 32)),
|
|
109
|
+
expectedHead: controlRecordId(f.bytes(1, 32)),
|
|
110
|
+
record: f.bytes(2),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A CONTROL_PUT against the coordinator's current state: the body's
|
|
115
|
+
* Resource must be the state's, and the record must be of that Resource
|
|
116
|
+
* too; then proposeControlTransition.
|
|
117
|
+
*/
|
|
118
|
+
export function proposeControlPut(state, body) {
|
|
119
|
+
if (!bytesEqual(body.resourceId, state.resourceId))
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
kind: "invalid",
|
|
122
|
+
problem: "RESOURCE",
|
|
123
|
+
wireCode: "INVALID_CONTROL_CHAIN",
|
|
124
|
+
error: new LfcpError("INVALID_CONTROL_CHAIN", `the put names Resource ${toHex(body.resourceId)}, not this chain's Resource`),
|
|
125
|
+
});
|
|
126
|
+
return proposeControlTransition(state, body.expectedHead, body.record);
|
|
127
|
+
}
|
|
128
|
+
/** Decodes a CONTROL_PUT body from its CBOR bytes (a bare body; decodeMessage decodes whole messages). */
|
|
129
|
+
export const decodeControlPutBody = (bytes) => controlPutBodyFromCbor(decodeStrict(bytes));
|
|
130
|
+
//# sourceMappingURL=transition.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openlfcp/wire",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "Deterministic CBOR, COSE and LFCP Wire structures and codecs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"openlfcp",
|
|
7
|
+
"lfcp",
|
|
8
|
+
"local-first",
|
|
9
|
+
"end-to-end-encryption",
|
|
10
|
+
"protocol",
|
|
11
|
+
"cbor",
|
|
12
|
+
"cose",
|
|
13
|
+
"wire-format"
|
|
14
|
+
],
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"homepage": "https://github.com/openlfcp/sdk-ts/tree/main/packages/wire#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/openlfcp/sdk-ts/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/openlfcp/sdk-ts.git",
|
|
23
|
+
"directory": "packages/wire"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=24"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./cbor": {
|
|
36
|
+
"types": "./dist/cbor/index.d.ts",
|
|
37
|
+
"import": "./dist/cbor/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"!dist/**/*.map",
|
|
43
|
+
"!dist/**/*.tsbuildinfo"
|
|
44
|
+
],
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"tag": "next"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@openlfcp/core": "^0.1.0-rc.1",
|
|
51
|
+
"@openlfcp/crypto": "^0.1.0-rc.1"
|
|
52
|
+
}
|
|
53
|
+
}
|