@openlfcp/wire 0.2.2 → 0.3.0

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.
@@ -0,0 +1,268 @@
1
+ import { LfcpError } from "@openlfcp/core";
2
+ import { MAX_DEPTH } from "./cbor/index.js";
3
+ /**
4
+ * Bounds of Device Certificates and certificate chains checked on the raw
5
+ * bytes, before any CBOR value is decoded or any byte string copied
6
+ * (LFCP-WIRE-01 §7.3: chain 1–4, mask 1–32, scope at most 64, fixed field
7
+ * sizes, a managing leaf at most three deep). The full decoders run only
8
+ * after this scan, so a declared count beyond a limit is refused without
9
+ * walking or allocating its elements.
10
+ *
11
+ * The COSE envelope (§10) and the descriptor's fixed fields are checked
12
+ * here too, and a Genesis's null prev is skipped, so no shape reaches the
13
+ * decoder unscanned. The scan refuses only what the full decoder refuses
14
+ * too (COSE_MALFORMED for the envelope, INVALID_STRUCTURE otherwise, both
15
+ * MALFORMED_MESSAGE on the wire, §62 class 1); it accepts nothing the
16
+ * decoder would not check again.
17
+ */
18
+ const MAX_CHAIN = 4;
19
+ const MAX_MANAGING = 3;
20
+ const MAX_MASK = 32;
21
+ const MAX_SCOPE = 64;
22
+ const LABEL_LENGTH = 19; // "LFCP-DEVICE-CERT-v1"
23
+ const DEVICE_MANAGE = 12;
24
+ function fail(why) {
25
+ throw new LfcpError("INVALID_STRUCTURE", `invalid device certificate (scan): ${why}`);
26
+ }
27
+ /** One CBOR head at `pos`, not past `end`. Indefinite and reserved forms are refused. */
28
+ function head(bytes, pos, end) {
29
+ if (pos >= end)
30
+ fail("truncated");
31
+ const b = bytes[pos];
32
+ const major = b >> 5;
33
+ const ai = b & 31;
34
+ if (ai < 24)
35
+ return { major, value: ai, next: pos + 1 };
36
+ if (ai > 27)
37
+ fail("indefinite or reserved length");
38
+ const n = 1 << (ai - 24);
39
+ if (pos + 1 + n > end)
40
+ fail("truncated");
41
+ let value = 0;
42
+ for (let i = 0; i < n; i++)
43
+ value = value * 256 + bytes[pos + 1 + i];
44
+ return {
45
+ major,
46
+ value: Number.isSafeInteger(value) ? value : Number.POSITIVE_INFINITY,
47
+ next: pos + 1 + n,
48
+ };
49
+ }
50
+ /** The end of the item at `pos`, without decoding it. */
51
+ function skip(bytes, pos, end, depth = 0) {
52
+ if (depth > MAX_DEPTH)
53
+ fail("nested too deeply");
54
+ const h = head(bytes, pos, end);
55
+ switch (h.major) {
56
+ case 0:
57
+ case 1:
58
+ return h.next;
59
+ case 2:
60
+ case 3:
61
+ if (h.value > end - h.next)
62
+ fail("truncated");
63
+ return h.next + h.value;
64
+ case 4:
65
+ case 5: {
66
+ const items = h.major === 5 ? h.value * 2 : h.value;
67
+ // Every item takes at least one byte: a count beyond the input is refused at once.
68
+ if (items > end - h.next)
69
+ fail("truncated");
70
+ let p = h.next;
71
+ for (let i = 0; i < items; i++)
72
+ p = skip(bytes, p, end, depth + 1);
73
+ return p;
74
+ }
75
+ case 7:
76
+ // false, true and null (a Genesis's prev is null, §13.1); the
77
+ // decoder refuses every other simple value and float too (§5.2).
78
+ if (h.value >= 20 && h.value <= 22 && h.next === pos + 1)
79
+ return h.next;
80
+ return fail("a float or an unused simple value");
81
+ default:
82
+ return fail("a tag"); // LFCP uses none (§5.2 rule 6)
83
+ }
84
+ }
85
+ /** A byte string head at `pos`: its content range. */
86
+ function bstr(bytes, pos, end, length) {
87
+ const h = head(bytes, pos, end);
88
+ if (h.major !== 2)
89
+ fail("a byte string was expected");
90
+ if (h.value > end - h.next)
91
+ fail("truncated");
92
+ if (length !== undefined && h.value !== length)
93
+ fail(`a ${length}-byte string was expected`);
94
+ return [h.next, h.next + h.value];
95
+ }
96
+ function arrayHead(bytes, pos, end, what) {
97
+ const h = head(bytes, pos, end);
98
+ if (h.major !== 4)
99
+ fail(`${what} is not an array`);
100
+ return h;
101
+ }
102
+ /** The certificate payload array; returns whether its mask lists device/manage. */
103
+ function scanPayload(bytes, start, end) {
104
+ const a = arrayHead(bytes, start, end, "the payload");
105
+ if (a.value !== 7)
106
+ fail("the payload has not seven elements");
107
+ let p = a.next;
108
+ const label = head(bytes, p, end);
109
+ if (label.major !== 3 || label.value !== LABEL_LENGTH)
110
+ fail("the label is not LFCP-DEVICE-CERT-v1");
111
+ p = skip(bytes, p, end);
112
+ p = bstr(bytes, p, end, 32)[1]; // root
113
+ p = scanDescriptor(bytes, p, end);
114
+ const kind = head(bytes, p, end);
115
+ if (kind.major !== 0 || kind.value > 2)
116
+ fail("the kind is not 0, 1 or 2");
117
+ p = kind.next;
118
+ const mask = arrayHead(bytes, p, end, "the mask");
119
+ if (mask.value < 1 || mask.value > MAX_MASK)
120
+ fail(`the mask holds 1 to ${MAX_MASK} codes`);
121
+ p = mask.next;
122
+ let managing = false;
123
+ for (let i = 0; i < mask.value; i++) {
124
+ const code = head(bytes, p, end);
125
+ if (code.major !== 0)
126
+ fail("a mask code is not an unsigned integer");
127
+ if (code.value === DEVICE_MANAGE)
128
+ managing = true;
129
+ p = code.next;
130
+ }
131
+ const scope = arrayHead(bytes, p, end, "the scope");
132
+ if (scope.value > MAX_SCOPE)
133
+ fail(`the scope holds at most ${MAX_SCOPE} Resources`);
134
+ p = scope.next;
135
+ for (let i = 0; i < scope.value; i++)
136
+ p = bstr(bytes, p, end, 32)[1];
137
+ p = bstr(bytes, p, end, 16)[1]; // serial
138
+ if (p !== end)
139
+ fail("bytes follow the payload");
140
+ return managing;
141
+ }
142
+ /** The device descriptor at `pos`: the closed map {0, 1, 2} of 32-byte strings (§7, §7.3 rule 2). */
143
+ function scanDescriptor(bytes, pos, end) {
144
+ const m = head(bytes, pos, end);
145
+ if (m.major !== 5 || m.value !== 3)
146
+ fail("the descriptor is not a three-field map");
147
+ let p = m.next;
148
+ for (let key = 0; key < 3; key++) {
149
+ const k = head(bytes, p, end);
150
+ if (k.major !== 0 || k.value !== key)
151
+ fail("the descriptor's keys are not 0, 1 and 2");
152
+ p = bstr(bytes, k.next, end, 32)[1];
153
+ }
154
+ return p;
155
+ }
156
+ function malformed(why) {
157
+ throw new LfcpError("COSE_MALFORMED", `not a canonical LFCP COSE_Sign1 (scan): ${why}`);
158
+ }
159
+ /**
160
+ * The payload range of the COSE_Sign1 in `[start, end)`, its envelope
161
+ * checked as parseSignedObject does (§10): a protected header of exactly
162
+ * {1: -8, 4: 32-byte kid}, the empty unprotected map, a present payload
163
+ * and a 64-byte signature. Nothing is decoded or copied.
164
+ */
165
+ function envelope(bytes, start, end) {
166
+ const a = head(bytes, start, end);
167
+ if (a.major !== 4 || a.value !== 4)
168
+ malformed("not a four-element array");
169
+ const [hs, he] = bstr(bytes, a.next, end);
170
+ const h = head(bytes, hs, he);
171
+ if (h.major !== 5 || h.value !== 2)
172
+ malformed("protected header must be exactly {1: -8, 4: kid}");
173
+ const alg = head(bytes, h.next, he);
174
+ const algValue = head(bytes, alg.next, he);
175
+ if (alg.major !== 0 || alg.value !== 1 || algValue.major !== 1 || algValue.value !== 7)
176
+ malformed("alg must be -8 (EdDSA)");
177
+ const kid = head(bytes, algValue.next, he);
178
+ if (kid.major !== 0 || kid.value !== 4)
179
+ malformed("protected header must be exactly {1: -8, 4: kid}");
180
+ const k = head(bytes, kid.next, he);
181
+ if (k.major !== 2 || k.value !== 32 || k.next + 32 !== he)
182
+ malformed("kid must be a 32-byte Principal ID");
183
+ const u = head(bytes, he, end);
184
+ if (u.major !== 5 || u.value !== 0)
185
+ malformed("unprotected header must be the empty map");
186
+ const payload = bstr(bytes, u.next, end);
187
+ const sig = head(bytes, payload[1], end);
188
+ if (sig.major !== 2 || sig.value !== 64)
189
+ malformed("signature must be 64 bytes");
190
+ if (sig.next + 64 !== end)
191
+ fail("bytes follow the signature");
192
+ return payload;
193
+ }
194
+ /** A Device Certificate's COSE_Sign1 in `[start, end)`; returns whether its mask lists 12. */
195
+ function scanCertificateAt(bytes, start, end) {
196
+ const [ps, pe] = envelope(bytes, start, end);
197
+ return scanPayload(bytes, ps, pe);
198
+ }
199
+ /**
200
+ * Checks the envelope and the bounds of a standalone certificate on its
201
+ * exact bytes, before parseSignedObject decodes anything.
202
+ */
203
+ export function scanDeviceCertificate(bytes) {
204
+ scanCertificateAt(bytes, 0, bytes.length);
205
+ }
206
+ /** A certificate-chain at `pos`: 1–4 certificates, a managing leaf at most three deep. */
207
+ function scanChainAt(bytes, pos, end) {
208
+ const a = arrayHead(bytes, pos, end, "the certificate chain");
209
+ if (a.value < 1 || a.value > MAX_CHAIN)
210
+ fail(`a chain holds 1 to ${MAX_CHAIN} certificates`);
211
+ let p = a.next;
212
+ for (let i = 0; i < a.value; i++) {
213
+ const [cs, ce] = bstr(bytes, p, end);
214
+ const managing = scanCertificateAt(bytes, cs, ce);
215
+ if (i === 0 && managing && a.value > MAX_MANAGING)
216
+ fail(`a managing leaf has at most ${MAX_MANAGING} certificates`);
217
+ p = ce;
218
+ }
219
+ return p;
220
+ }
221
+ /**
222
+ * The value of unsigned key `key` in the map at `pos`, walking every
223
+ * entry without decoding it (the decoder checks order and duplicates).
224
+ */
225
+ function field(bytes, pos, end, key) {
226
+ const m = head(bytes, pos, end);
227
+ if (m.major !== 5)
228
+ fail("a map was expected");
229
+ if (m.value * 2 > end - m.next)
230
+ fail("truncated");
231
+ let p = m.next;
232
+ let found;
233
+ for (let i = 0; i < m.value; i++) {
234
+ const k = head(bytes, p, end);
235
+ p = skip(bytes, p, end);
236
+ if (k.major === 0 && k.value === key)
237
+ found = p;
238
+ p = skip(bytes, p, end);
239
+ }
240
+ return found;
241
+ }
242
+ /** The body key that holds a certificate chain, by Control Record type (§15, §23.3, §17.4). */
243
+ const CHAIN_KEY = { 0: 5, 6: 2, 9: 1 };
244
+ /**
245
+ * Checks the envelope of a Control Record and the bounds of the
246
+ * certificate chain a record of type 0, 6 or 9 carries, on its exact
247
+ * bytes. Every refusal found is final: nothing is left to the decoder
248
+ * once the scan has seen it.
249
+ */
250
+ export function scanControlRecord(bytes) {
251
+ const [ps, pe] = envelope(bytes, 0, bytes.length);
252
+ const typeAt = field(bytes, ps, pe, 3);
253
+ if (typeAt === undefined)
254
+ return;
255
+ const t = head(bytes, typeAt, pe);
256
+ const chainKey = t.major === 0 ? CHAIN_KEY[t.value] : undefined;
257
+ if (chainKey === undefined)
258
+ return;
259
+ const bodyAt = field(bytes, ps, pe, 5);
260
+ if (bodyAt === undefined)
261
+ return;
262
+ if (head(bytes, bodyAt, pe).major !== 5)
263
+ return; // the decoder names the wrong body type
264
+ const chainAt = field(bytes, bodyAt, pe, chainKey);
265
+ if (chainAt !== undefined)
266
+ scanChainAt(bytes, chainAt, pe);
267
+ }
268
+ //# sourceMappingURL=device-scan.js.map
@@ -0,0 +1,154 @@
1
+ import { type ControlRecordId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
+ import { type Authorization } from "./capability.js";
3
+ import type { ControlState } from "./chain.js";
4
+ import type { ControlRecord } from "./control.js";
5
+ import { type CertificateChain, type ChainStateChecks, type DeviceKind } from "./device.js";
6
+ import { PersistentMap } from "./persistent-map.js";
7
+ import type { PrincipalDescriptor } from "./principal.js";
8
+ /**
9
+ * Resource-local device state and its transitions (LFCP-WIRE-01 §7.3,
10
+ * §17.4, §17.5, §79, §80.1). Every answer is a pure function of the
11
+ * Resource's accepted Control Chain (N3): no clock, no server table, no
12
+ * Identity Resource, no other Resource.
13
+ *
14
+ * A chain without device records has no bindings, so `bound` is false for
15
+ * every Principal and every question here reduces to §80.
16
+ */
17
+ /** §79: a Principal's role in a Resource; assigned once, never changed. */
18
+ export type DeviceRole = "device" | "non-device";
19
+ /** §79 `Binding`: what an accepted chain fixed for a device in this Resource. */
20
+ export interface DeviceBinding {
21
+ readonly root: PrincipalId;
22
+ readonly kind: DeviceKind;
23
+ /** The certificate's codes, unknown ones included; immutable, compared as a set (N8). */
24
+ readonly mask: readonly bigint[];
25
+ /** Immutable, compared as a set (N8). */
26
+ readonly scope: readonly ResourceId[];
27
+ /** Leaf of an accepted DEVICE_GRANT whose scope admits this Resource, of Genesis field 5 or of commit field 2. */
28
+ readonly registered: boolean;
29
+ /** The chain of the latest accepted record whose leaf this device is; null for an intermediate only. */
30
+ readonly chain: CertificateChain | null;
31
+ /** The certificate ID of that chain's leaf. */
32
+ readonly leafId: Hash32 | null;
33
+ /** Device IDs of `chain[1..]`, nearest first; empty when the root issued the leaf. */
34
+ readonly issuers: readonly PrincipalId[];
35
+ /** Accepted DEVICE_GRANT records whose leaf this device is (§17.4 rule 8: at most 8). */
36
+ readonly grantsAccepted: number;
37
+ }
38
+ /** §79 `Revocation` of an exact (root, device) pair; permanent (N5). */
39
+ export interface DeviceRevocation {
40
+ readonly root: PrincipalId;
41
+ readonly device: PrincipalId;
42
+ /** As received; 4 and above are kept (§17.5). */
43
+ readonly reason: bigint;
44
+ /** The approved certificate payload digest, exactly for reason 3. */
45
+ readonly approval: Hash32 | null;
46
+ /** The DEVICE_REVOKE record. */
47
+ readonly revokedBy: ControlRecordId;
48
+ }
49
+ /** The device part of ControlState (§79). */
50
+ export interface DeviceState {
51
+ /** Roles by Principal ID hex. */
52
+ readonly roles: ReadonlyMap<string, DeviceRole>;
53
+ /** Bindings by device ID hex: leaves, intermediates, suspended and revoked devices. */
54
+ readonly devices: ReadonlyMap<string, DeviceBinding>;
55
+ /** Revocations by `revocationKey(root, device)`. */
56
+ readonly revoked: ReadonlyMap<string, DeviceRevocation>;
57
+ /**
58
+ * §17.4 rule 8, by root ID hex: the live Principals bound to the root,
59
+ * entries of `devices` whose pair is not in `revoked`. Every binding path
60
+ * adds (Genesis, transfer, Device Grant); a direct revocation releases.
61
+ */
62
+ readonly liveBound: ReadonlyMap<string, number>;
63
+ /** §17.5 rule 5, by root ID hex: revoked pairs (R, E) with `devices[E]` absent. */
64
+ readonly preemptive: ReadonlyMap<string, number>;
65
+ /** By device ID hex: the roots (hex) that revoked it while it was unbound. */
66
+ readonly preemptiveRoots: ReadonlyMap<string, ReadonlyMap<string, true>>;
67
+ }
68
+ export declare const EMPTY_DEVICE_STATE: DeviceState;
69
+ /** §17.4 rule 8: live Principals bound to one root in a Resource. */
70
+ export declare const MAX_LIVE_BOUND = 32;
71
+ /** §17.4 rule 8: accepted Device Grants per (root, leaf) pair. */
72
+ export declare const MAX_DEVICE_GRANTS = 8;
73
+ /** §17.5 rule 5: pre-emptive revocations of unbound Principals per root. */
74
+ export declare const MAX_PREEMPTIVE_REVOCATIONS = 64;
75
+ /** The key of `revoked` for the pair (root, device). */
76
+ export declare const revocationKey: (root: PrincipalId, device: PrincipalId) => string;
77
+ /** `bound(P)` (§80.1): P appeared as a device, at any level, in an accepted chain. */
78
+ export declare const isBound: (state: ControlState, p: PrincipalId) => boolean;
79
+ /** `revoked[(root, device)]`, if any. */
80
+ export declare const revocationOf: (state: ControlState, root: PrincipalId, device: PrincipalId) => DeviceRevocation | undefined;
81
+ /**
82
+ * `rootStanding(R)` (§17.5): R is the owner, or the subject of at least one
83
+ * active grant that does not list `invite/claim`.
84
+ */
85
+ export declare function rootStanding(state: ControlState, root: PrincipalId): boolean;
86
+ /**
87
+ * `activeDevice(P)` (§17.5): registered, `(root, P)` not revoked, no
88
+ * revoked issuer in its current chain (the cascade), and its root in good
89
+ * standing. Undefined otherwise.
90
+ */
91
+ export declare function activeDevice(state: ControlState, p: PrincipalId): DeviceBinding | undefined;
92
+ /** `actsFor(P)` (§80.1): the root of an active device, else P itself. */
93
+ export declare const actsFor: (state: ControlState, p: PrincipalId) => PrincipalId;
94
+ /**
95
+ * `rootOf(P)` (§80.1): the root of a bound P (registered, suspended,
96
+ * revoked or intermediate), else P itself. For attribution, never for
97
+ * authority.
98
+ */
99
+ export declare const rootOf: (state: ControlState, p: PrincipalId) => PrincipalId;
100
+ /**
101
+ * `ownerAction(P)` (§80.1): for a bound P, an active device of the owner
102
+ * with `owner/act` in its mask; otherwise P is the owner.
103
+ */
104
+ export declare function ownerAction(state: ControlState, p: PrincipalId): boolean;
105
+ /**
106
+ * The descriptor a transfer offer with `kid` is verified under (§62 class
107
+ * 4, §23.3): the kid's, when it is the owner or a device bound to the
108
+ * owner, active or not. Any other kid, known here or not, is neither a
109
+ * dependency nor signature-checked: the offer fails §23.3 item 1 in the
110
+ * authority class (ownerAction), AUTHORIZATION_FAILED.
111
+ */
112
+ export declare function offerSigner(state: ControlState, kid: PrincipalId): PrincipalDescriptor | undefined;
113
+ /** `sameRoot(a, b)` (§25.2): the same `actsFor`, and at least one of them an active device. */
114
+ export declare const sameRoot: (state: ControlState, a: PrincipalId, b: PrincipalId) => boolean;
115
+ /** §7.3 step 4.2–4.4 against `state`, for verifyChain. */
116
+ export declare function deviceChainChecks(state: ControlState): ChainStateChecks;
117
+ /**
118
+ * The device chain of a Genesis (field 5) or a transfer commit (field 2):
119
+ * verifyChain step 4 at `state`, then the leaf is a personal device with
120
+ * `owner/act` (§15, §23.3).
121
+ */
122
+ export declare function authorizeOwnerChain(state: ControlState, chain: CertificateChain): Authorization;
123
+ type DeviceGrantBody = Extract<ControlRecord["body"], {
124
+ type: "DEVICE_GRANT";
125
+ }>;
126
+ type DeviceRevokeBody = Extract<ControlRecord["body"], {
127
+ type: "DEVICE_REVOKE";
128
+ }>;
129
+ /** §17.4 rules 4–7, in order (rules 1–3 are structure, dependencies and signatures). */
130
+ export declare function authorizeDeviceGrant(state: ControlState, issuer: PrincipalId, body: DeviceGrantBody): Authorization;
131
+ /** §17.5 rules 3–5, in order. */
132
+ export declare function authorizeDeviceRevoke(state: ControlState, issuer: PrincipalId, body: DeviceRevokeBody): Authorization;
133
+ /** The device part of a state, with the descriptors bindChain records. */
134
+ type BindingState = DeviceState & Pick<ControlState, "principals">;
135
+ /**
136
+ * Binds every device of an accepted chain to its root and records the
137
+ * leaf's chain (§17.4 rule 9, §15, §23.3). Intermediates keep their own
138
+ * registration and current chains. `keepSameLeaf`: a transfer commit
139
+ * leaves a leaf registered with the same certificate ID unchanged.
140
+ * `grant`: a Device Grant, which counts towards its leaf's eight.
141
+ */
142
+ export declare function bindChain(state: BindingState, chain: CertificateChain, inScope: boolean, keepSameLeaf: boolean, grant?: boolean): BindingState;
143
+ /** The in-scope result of an accepted DEVICE_GRANT's chain at the state it was accepted on. */
144
+ export declare const grantInScope: (state: ControlState, body: DeviceGrantBody) => boolean;
145
+ /**
146
+ * Records the revocation of an accepted DEVICE_REVOKE (§17.5 rule 6): a
147
+ * bound device releases its live place (§17.4 rule 8), an unbound one
148
+ * spends one of the root's pre-emptive revocations (§17.5 rule 5).
149
+ */
150
+ export declare function recordRevocation(state: DeviceState, record: ControlRecord, body: DeviceRevokeBody): Pick<DeviceState, "revoked" | "liveBound" | "preemptive" | "preemptiveRoots">;
151
+ /** Assigns the `non-device` role (§79). */
152
+ export declare function nonDevice(roles: ReadonlyMap<string, DeviceRole>, p: PrincipalId): PersistentMap<DeviceRole>;
153
+ export {};
154
+ //# sourceMappingURL=device-state.d.ts.map