@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,409 @@
|
|
|
1
|
+
import { bytesEqual, controlRecordId, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { parseOwnerTransferAccept, parseOwnerTransferOffer, } from "./control.js";
|
|
3
|
+
import { objectId, verifySignedObject } from "./cose.js";
|
|
4
|
+
/**
|
|
5
|
+
* The LFCP capability engine (LFCP-WIRE-01 §17, §18, §20, §19, §25.2).
|
|
6
|
+
*
|
|
7
|
+
* Authority comes only from the validated Control Chain: a Principal ID
|
|
8
|
+
* holds an ability at a Control Head H because of the owner rule or of
|
|
9
|
+
* grants and claims committed through H. There is no server account,
|
|
10
|
+
* user table or hosting identity anywhere in this module, and nothing
|
|
11
|
+
* here takes one.
|
|
12
|
+
*
|
|
13
|
+
* Everything is a pure function of a ControlState (the state at some head
|
|
14
|
+
* H). Evaluating at an older H means using the state at that H (see
|
|
15
|
+
* `stateAt` on a linear chain result), so a later revocation never
|
|
16
|
+
* changes an earlier answer.
|
|
17
|
+
*/
|
|
18
|
+
/** §17.1 standard ability codes. */
|
|
19
|
+
export const ABILITY = Object.freeze({
|
|
20
|
+
DATA_READ: 1n,
|
|
21
|
+
DATA_WRITE: 2n,
|
|
22
|
+
SNAPSHOT_PUBLISH: 3n,
|
|
23
|
+
CAPABILITY_GRANT: 4n,
|
|
24
|
+
CAPABILITY_REVOKE: 5n,
|
|
25
|
+
KEY_DISTRIBUTE: 6n,
|
|
26
|
+
KEY_ROTATE: 7n,
|
|
27
|
+
ROUTE_UPDATE: 8n,
|
|
28
|
+
OWNER_TRANSFER_OFFER: 9n,
|
|
29
|
+
RESOURCE_TOMBSTONE: 10n,
|
|
30
|
+
INVITE_CLAIM: 11n,
|
|
31
|
+
});
|
|
32
|
+
/** §17.1 names of the standard codes. */
|
|
33
|
+
export const ABILITY_NAMES = new Map([
|
|
34
|
+
[1n, "data/read"],
|
|
35
|
+
[2n, "data/write"],
|
|
36
|
+
[3n, "snapshot/publish"],
|
|
37
|
+
[4n, "capability/grant"],
|
|
38
|
+
[5n, "capability/revoke"],
|
|
39
|
+
[6n, "key/distribute"],
|
|
40
|
+
[7n, "key/rotate"],
|
|
41
|
+
[8n, "route/update"],
|
|
42
|
+
[9n, "owner/transfer-offer"],
|
|
43
|
+
[10n, "resource/tombstone"],
|
|
44
|
+
[11n, "invite/claim"],
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Whether `ability` is a §17.1 standard code. §17.1: "A code that is not in
|
|
48
|
+
* the table above is kept as received and confers nothing", so an unknown
|
|
49
|
+
* code is kept in its record but is never held.
|
|
50
|
+
*/
|
|
51
|
+
export const isStandardAbility = (ability) => ABILITY_NAMES.has(ability);
|
|
52
|
+
/**
|
|
53
|
+
* Whether holding `ability` can mean anything: a standard code other than
|
|
54
|
+
* 9. §17.1: "9 | owner/transfer-offer (reserved; confers nothing in
|
|
55
|
+
* WIRE-01)"; §23.1: "Only the current owner creates offers."
|
|
56
|
+
*/
|
|
57
|
+
const confers = (ability) => isStandardAbility(ability) && ability !== ABILITY.OWNER_TRANSFER_OFFER;
|
|
58
|
+
const key = (id) => toHex(id);
|
|
59
|
+
/**
|
|
60
|
+
* Whether a grant is active at this state. §17.2: "A grant is active while
|
|
61
|
+
* it has not been revoked and, when it has a parent grant, while that
|
|
62
|
+
* parent is active. Revoking a grant therefore also deactivates every grant
|
|
63
|
+
* delegated from it, directly or through further delegations."
|
|
64
|
+
*/
|
|
65
|
+
export function isGrantActive(state, grantId) {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
let grant = state.grants.get(key(grantId));
|
|
68
|
+
while (grant !== undefined) {
|
|
69
|
+
if (grant.revokedBy !== null)
|
|
70
|
+
return false;
|
|
71
|
+
if (grant.parentGrantId === null)
|
|
72
|
+
return true;
|
|
73
|
+
if (seen.has(key(grant.id)))
|
|
74
|
+
return false;
|
|
75
|
+
seen.add(key(grant.id));
|
|
76
|
+
grant = state.grants.get(key(grant.parentGrantId));
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const isOwner = (state, principal) => bytesEqual(state.owner.principalId, principal);
|
|
81
|
+
/**
|
|
82
|
+
* Whether an invitation grant's claims are used up. §18.1: "An Invitation
|
|
83
|
+
* Grant whose claims are used up confers no invite/claim; its other
|
|
84
|
+
* abilities stay active until it is revoked."
|
|
85
|
+
*/
|
|
86
|
+
const claimsExhausted = (g) => g.claimLimit !== null && g.claimsUsed >= g.claimLimit;
|
|
87
|
+
/** Whether grant `g` confers `ability` at this state: it lists it, is active, and (for invite/claim) is not used up. */
|
|
88
|
+
function grantConfers(state, g, ability) {
|
|
89
|
+
if (!g.abilities.includes(ability) || !isGrantActive(state, g.id))
|
|
90
|
+
return false;
|
|
91
|
+
return ability !== ABILITY.INVITE_CLAIM || !claimsExhausted(g);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether `principal` holds `ability` at the head of `state`: the owner
|
|
95
|
+
* holds every standard ability implicitly (§17.1, no self-grant); anyone
|
|
96
|
+
* else holds it through an active grant that confers it. Unknown codes and
|
|
97
|
+
* the reserved ability 9 are held by no one (§17.1, §23.1).
|
|
98
|
+
*/
|
|
99
|
+
export function hasAbility(state, principal, ability) {
|
|
100
|
+
if (!confers(ability))
|
|
101
|
+
return false;
|
|
102
|
+
if (isOwner(state, principal))
|
|
103
|
+
return true;
|
|
104
|
+
for (const g of state.grants.values())
|
|
105
|
+
if (bytesEqual(g.subject, principal) && grantConfers(state, g, ability))
|
|
106
|
+
return true;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
/** Every standard ability `principal` holds at the head of `state`, ascending. */
|
|
110
|
+
export function abilitiesOf(state, principal) {
|
|
111
|
+
return [...ABILITY_NAMES.keys()].filter((a) => hasAbility(state, principal, a));
|
|
112
|
+
}
|
|
113
|
+
const ALLOW = Object.freeze({ allowed: true });
|
|
114
|
+
const deny = (reason) => Object.freeze({ allowed: false, reason });
|
|
115
|
+
const badSignature = (reason) => Object.freeze({ allowed: false, reason, code: "INVALID_SIGNATURE" });
|
|
116
|
+
const subset = (list, of) => list.every((a) => of.includes(a));
|
|
117
|
+
/**
|
|
118
|
+
* §17.3: revoke authority "covers a grant when the revoker issued it, or
|
|
119
|
+
* when it was delegated, directly or through further delegations, from a
|
|
120
|
+
* grant the revoker issued. A grant the revoker received is not covered
|
|
121
|
+
* unless the revoker also issued one of its ancestors."
|
|
122
|
+
*/
|
|
123
|
+
function covers(state, target, revoker) {
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
let grant = target;
|
|
126
|
+
while (grant !== undefined && !seen.has(key(grant.id))) {
|
|
127
|
+
if (bytesEqual(grant.issuer, revoker))
|
|
128
|
+
return true;
|
|
129
|
+
seen.add(key(grant.id));
|
|
130
|
+
grant = grant.parentGrantId === null ? undefined : state.grants.get(key(grant.parentGrantId));
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether the issuer of `record` had the authority the record needs, at
|
|
136
|
+
* the state before it (the chain's previous head). Genesis is authorized by
|
|
137
|
+
* verifyGenesis. Coordinator Recovery and Resource Tombstone records never
|
|
138
|
+
* get here: the chain refuses them first (DV1).
|
|
139
|
+
*/
|
|
140
|
+
export function authorizeControlRecord(record, state) {
|
|
141
|
+
const issuer = record.payload.issuer;
|
|
142
|
+
const body = record.body;
|
|
143
|
+
switch (body.type) {
|
|
144
|
+
case "GENESIS":
|
|
145
|
+
return ALLOW;
|
|
146
|
+
case "CAPABILITY_GRANT": {
|
|
147
|
+
// §17.2: "The owner MAY grant any abilities without a parent grant. A
|
|
148
|
+
// non-owner issuer MUST hold capability/grant and MUST reference a
|
|
149
|
+
// parent grant." "If parent grant id is present: the parent grant
|
|
150
|
+
// MUST be active; the issuer MUST be the subject of the parent grant;
|
|
151
|
+
// every granted ability MUST be included in the parent's delegable
|
|
152
|
+
// abilities; every delegable ability of the new grant MUST also be
|
|
153
|
+
// included in the parent's delegable abilities."
|
|
154
|
+
if (body.parentGrantId !== undefined) {
|
|
155
|
+
const parent = state.grants.get(key(body.parentGrantId));
|
|
156
|
+
if (parent === undefined)
|
|
157
|
+
return deny("the parent grant does not exist (§17.2)");
|
|
158
|
+
if (!isGrantActive(state, parent.id))
|
|
159
|
+
return deny("the parent grant is not active (§17.2)");
|
|
160
|
+
if (!bytesEqual(parent.subject, issuer))
|
|
161
|
+
return deny("the issuer is not the subject of the parent grant (§17.2)");
|
|
162
|
+
if (!subset(body.abilities, parent.delegable))
|
|
163
|
+
return deny("a granted ability is not delegable under the parent grant (§17.2)");
|
|
164
|
+
if (!subset(body.delegable, parent.delegable))
|
|
165
|
+
return deny("a delegable ability is not delegable under the parent grant (§17.2)");
|
|
166
|
+
}
|
|
167
|
+
else if (!isOwner(state, issuer)) {
|
|
168
|
+
return deny("a non-owner grant must prove its authority through a parent grant (§17.2)");
|
|
169
|
+
}
|
|
170
|
+
if (!isOwner(state, issuer) && !hasAbility(state, issuer, ABILITY.CAPABILITY_GRANT))
|
|
171
|
+
return deny("the issuer does not hold capability/grant (§17.2)");
|
|
172
|
+
return ALLOW;
|
|
173
|
+
}
|
|
174
|
+
case "CAPABILITY_REVOKE": {
|
|
175
|
+
// §17.3, in order: the target exists, the issuer's authority covers
|
|
176
|
+
// it, it is not already revoked. Each failure is AUTHORIZATION_FAILED.
|
|
177
|
+
const target = state.grants.get(key(body.grantId));
|
|
178
|
+
if (target === undefined)
|
|
179
|
+
return deny("the revoked grant does not exist (§17.3)");
|
|
180
|
+
// "The owner may revoke any grant." Otherwise the issuer MUST "possess
|
|
181
|
+
// capability/revoke authority that covers the target grant".
|
|
182
|
+
if (!isOwner(state, issuer)) {
|
|
183
|
+
if (!hasAbility(state, issuer, ABILITY.CAPABILITY_REVOKE))
|
|
184
|
+
return deny("the issuer does not hold capability/revoke (§17.3)");
|
|
185
|
+
if (!covers(state, target, issuer))
|
|
186
|
+
return deny("the issuer's capability/revoke authority does not cover the grant (§17.3)");
|
|
187
|
+
}
|
|
188
|
+
if (target.revokedBy !== null)
|
|
189
|
+
return deny("the grant is already revoked (§17.3)");
|
|
190
|
+
return ALLOW;
|
|
191
|
+
}
|
|
192
|
+
case "CAPABILITY_CLAIM": {
|
|
193
|
+
// §18.1 validation rules 1-5 (rule 6, coordinator serialization, is LFCP-022).
|
|
194
|
+
const invitation = state.grants.get(key(body.invitationGrantId));
|
|
195
|
+
if (invitation === undefined)
|
|
196
|
+
return deny("the invitation grant does not exist (§18.1)");
|
|
197
|
+
if (!isGrantActive(state, invitation.id))
|
|
198
|
+
return deny("the invitation grant is not active (§18.1 rule 1)");
|
|
199
|
+
if (!invitation.abilities.includes(ABILITY.INVITE_CLAIM))
|
|
200
|
+
return deny("the invitation grant does not grant invite/claim (§18.1 rule 2)");
|
|
201
|
+
// §18: "Only an invitation grant with a claim_limit can be claimed: one
|
|
202
|
+
// without claim_limit is not claimable."
|
|
203
|
+
if (invitation.claimLimit === null)
|
|
204
|
+
return deny("the invitation grant has no claim_limit and is not claimable (§18)");
|
|
205
|
+
if (claimsExhausted(invitation))
|
|
206
|
+
return Object.freeze({
|
|
207
|
+
allowed: false,
|
|
208
|
+
reason: "the invitation grant has no claims left (§18.1 rule 3)",
|
|
209
|
+
code: "INVITE_CLAIM_EXHAUSTED",
|
|
210
|
+
});
|
|
211
|
+
if (!bytesEqual(invitation.subject, issuer))
|
|
212
|
+
return deny("the claim issuer is not the Invitation Principal (§18.1 rule 5)");
|
|
213
|
+
// Rule 4: a subset of the invitation's abilities, excluding invite/claim
|
|
214
|
+
// unless the invitation explicitly delegates it. Unknown codes confer
|
|
215
|
+
// nothing (§17.1), so they request nothing and are not checked.
|
|
216
|
+
const transferable = invitation.abilities.filter((a) => a !== ABILITY.INVITE_CLAIM || invitation.delegable.includes(ABILITY.INVITE_CLAIM));
|
|
217
|
+
if (!subset(body.abilities.filter(isStandardAbility), transferable))
|
|
218
|
+
return deny("the claim requests abilities beyond the invitation grant (§18.1 rule 4)");
|
|
219
|
+
return ALLOW;
|
|
220
|
+
}
|
|
221
|
+
case "KEY_EPOCH":
|
|
222
|
+
// §19: "The issuer MUST possess key/rotate."
|
|
223
|
+
return hasAbility(state, issuer, ABILITY.KEY_ROTATE)
|
|
224
|
+
? ALLOW
|
|
225
|
+
: deny("the issuer does not hold key/rotate (§19)");
|
|
226
|
+
case "OWNER_TRANSFER_COMMIT":
|
|
227
|
+
return verifyOwnerTransfer(record, state).authorization;
|
|
228
|
+
case "ROUTE_UPDATE":
|
|
229
|
+
// §20: "The issuer MUST possess route/update"; "Each Route Update MUST
|
|
230
|
+
// carry a route version strictly greater than the current one: 0 after
|
|
231
|
+
// Genesis (Section 15), otherwise the version of the last committed
|
|
232
|
+
// Route Update." §20 names no code: AUTHORIZATION_FAILED here.
|
|
233
|
+
if (!hasAbility(state, issuer, ABILITY.ROUTE_UPDATE))
|
|
234
|
+
return deny("the issuer does not hold route/update (§20)");
|
|
235
|
+
return body.routeVersion > state.routeVersion
|
|
236
|
+
? ALLOW
|
|
237
|
+
: deny("the route version does not increase (§20)");
|
|
238
|
+
case "EXTENSION":
|
|
239
|
+
// §14: "A Control Record of an extension type (32 or above) requires
|
|
240
|
+
// owner authority: its issuer MUST be the Resource owner at the
|
|
241
|
+
// record's position in the chain, whether or not the receiver supports
|
|
242
|
+
// the extension." §14 names no code: AUTHORIZATION_FAILED here.
|
|
243
|
+
return isOwner(state, issuer)
|
|
244
|
+
? ALLOW
|
|
245
|
+
: deny("an extension record must be issued by the owner (§14)");
|
|
246
|
+
case "COORDINATOR_RECOVERY":
|
|
247
|
+
case "RESOURCE_TOMBSTONE":
|
|
248
|
+
return deny(`${body.type} is refused in MVP 0.1 (scope §4, DV1)`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** The capability transitions of an applied record (called by applyRecord). */
|
|
252
|
+
export function applyCapabilities(grants, record) {
|
|
253
|
+
const body = record.body;
|
|
254
|
+
const id = controlRecordId(record.signed.id);
|
|
255
|
+
switch (body.type) {
|
|
256
|
+
case "CAPABILITY_GRANT": {
|
|
257
|
+
const next = new Map(grants);
|
|
258
|
+
next.set(key(id), Object.freeze({
|
|
259
|
+
id,
|
|
260
|
+
source: "grant",
|
|
261
|
+
issuer: record.payload.issuer,
|
|
262
|
+
subject: body.subject.principalId,
|
|
263
|
+
abilities: body.abilities,
|
|
264
|
+
delegable: body.delegable,
|
|
265
|
+
parentGrantId: body.parentGrantId ?? null,
|
|
266
|
+
claimLimit: body.claimLimit ?? null,
|
|
267
|
+
claimsUsed: 0n,
|
|
268
|
+
revokedBy: null,
|
|
269
|
+
}));
|
|
270
|
+
return next;
|
|
271
|
+
}
|
|
272
|
+
case "CAPABILITY_REVOKE": {
|
|
273
|
+
const target = grants.get(key(body.grantId));
|
|
274
|
+
if (target === undefined)
|
|
275
|
+
return grants;
|
|
276
|
+
const next = new Map(grants);
|
|
277
|
+
next.set(key(target.id), Object.freeze({ ...target, revokedBy: id }));
|
|
278
|
+
return next;
|
|
279
|
+
}
|
|
280
|
+
case "CAPABILITY_CLAIM": {
|
|
281
|
+
// §18.1: "A successful claim creates a new capability grant to the
|
|
282
|
+
// claimant [and] consumes one claim from the Invitation Grant." "The
|
|
283
|
+
// grant a claim creates is identified by the claim record's Control
|
|
284
|
+
// Record ID. Its subject is the claimant and its abilities are the
|
|
285
|
+
// claimed abilities; it has no parent grant and an empty delegable
|
|
286
|
+
// list, so revoking the Invitation Grant later does not revoke it."
|
|
287
|
+
const invitation = grants.get(key(body.invitationGrantId));
|
|
288
|
+
const next = new Map(grants);
|
|
289
|
+
if (invitation !== undefined)
|
|
290
|
+
next.set(key(invitation.id), Object.freeze({ ...invitation, claimsUsed: invitation.claimsUsed + 1n }));
|
|
291
|
+
next.set(key(id), Object.freeze({
|
|
292
|
+
id,
|
|
293
|
+
source: "claim",
|
|
294
|
+
issuer: record.payload.issuer,
|
|
295
|
+
subject: body.claimant.principalId,
|
|
296
|
+
abilities: body.abilities,
|
|
297
|
+
delegable: [],
|
|
298
|
+
parentGrantId: null,
|
|
299
|
+
claimLimit: null,
|
|
300
|
+
claimsUsed: 0n,
|
|
301
|
+
revokedBy: null,
|
|
302
|
+
}));
|
|
303
|
+
return next;
|
|
304
|
+
}
|
|
305
|
+
default:
|
|
306
|
+
return grants;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* §25.2 Key Package authority at the package's referenced Control Head:
|
|
311
|
+
* the sender must hold key/distribute, and the recipient must hold
|
|
312
|
+
* data/read, "except for an Invitation Principal explicitly authorized by
|
|
313
|
+
* an active invite grant: a recipient that is the subject of an active
|
|
314
|
+
* grant that includes and still confers invite/claim". `epoch` is the
|
|
315
|
+
* package's Data Epoch; whether that epoch is recognized at the head is
|
|
316
|
+
* checked with the epoch rules (LFCP-023, LFCP-024).
|
|
317
|
+
*/
|
|
318
|
+
export function canDistributeKey(state, sender, recipient, _epoch) {
|
|
319
|
+
if (!hasAbility(state, sender, ABILITY.KEY_DISTRIBUTE))
|
|
320
|
+
return deny("the sender does not hold key/distribute (§25.2)");
|
|
321
|
+
if (hasAbility(state, recipient, ABILITY.DATA_READ))
|
|
322
|
+
return ALLOW;
|
|
323
|
+
if (hasAbility(state, recipient, ABILITY.INVITE_CLAIM))
|
|
324
|
+
return ALLOW;
|
|
325
|
+
return deny("the recipient holds neither data/read nor an active invite grant (§25.2)");
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The §23.3 verifier for an OWNER_TRANSFER_COMMIT, at the state before it.
|
|
329
|
+
* Verification only: there is no transfer UI or flow (deferred from MVP 0.1).
|
|
330
|
+
*
|
|
331
|
+
* §23.3 "A verifier MUST confirm":
|
|
332
|
+
* 1. the offer is signed by the current owner;
|
|
333
|
+
* 2. the offer references the current Control Head;
|
|
334
|
+
* 3. the offer names the accepting Principal;
|
|
335
|
+
* 4. the acceptance is signed by that Principal;
|
|
336
|
+
* 5. the commit itself is signed by that Principal (here: issued by it;
|
|
337
|
+
* the chain verifies the signature against the issuer);
|
|
338
|
+
* 6. the expected next Control Sequence matches the commit sequence.
|
|
339
|
+
* Also, from §23.1 and §23.2: the offer and accept are canonical signed
|
|
340
|
+
* objects of this Resource, and the accept names the exact offer ID (the
|
|
341
|
+
* §10.6 object ID of the offer bytes). §23.3: "A commit that fails any of
|
|
342
|
+
* these checks is rejected with AUTHORIZATION_FAILED, except that an offer
|
|
343
|
+
* or acceptance whose signature does not verify is rejected with
|
|
344
|
+
* INVALID_SIGNATURE" (the refusal's code).
|
|
345
|
+
*/
|
|
346
|
+
export function verifyOwnerTransfer(record, state) {
|
|
347
|
+
const body = record.body;
|
|
348
|
+
if (body.type !== "OWNER_TRANSFER_COMMIT")
|
|
349
|
+
return { authorization: deny("not an ownership transfer commit") };
|
|
350
|
+
let offer;
|
|
351
|
+
let accept;
|
|
352
|
+
try {
|
|
353
|
+
offer = parseOwnerTransferOffer(body.offer);
|
|
354
|
+
accept = parseOwnerTransferAccept(body.accept);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return {
|
|
358
|
+
authorization: deny("the offer or accept is not a valid signed object (§23.1, §23.2)"),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const o = offer.payload;
|
|
362
|
+
const a = accept.payload;
|
|
363
|
+
if (!bytesEqual(o.resourceId, state.resourceId) || !bytesEqual(a.resourceId, state.resourceId))
|
|
364
|
+
return { authorization: deny("the offer or accept is for another Resource (§23.1, §23.2)") };
|
|
365
|
+
const offerSigned = verifySignedObject(offer.signed, state.owner);
|
|
366
|
+
if (!offerSigned.valid)
|
|
367
|
+
return {
|
|
368
|
+
authorization: badSignature(`the offer is not signed by the current owner (${offerSigned.reason}, §23.3 rule 1)`),
|
|
369
|
+
};
|
|
370
|
+
if (!bytesEqual(o.controlHead, state.head))
|
|
371
|
+
return {
|
|
372
|
+
authorization: deny("the offer does not reference the current Control Head (§23.3 rule 2)"),
|
|
373
|
+
};
|
|
374
|
+
if (!bytesEqual(a.newOwner, o.proposedOwner.principalId))
|
|
375
|
+
return {
|
|
376
|
+
authorization: deny("the accept is not by the Principal the offer names (§23.3 rule 3)"),
|
|
377
|
+
};
|
|
378
|
+
if (!bytesEqual(a.offerId, objectId(offer.signed.bytes)))
|
|
379
|
+
return { authorization: deny("the accept does not name this offer (§23.2)") };
|
|
380
|
+
const acceptSigned = verifySignedObject(accept.signed, o.proposedOwner);
|
|
381
|
+
if (!acceptSigned.valid)
|
|
382
|
+
return {
|
|
383
|
+
authorization: badSignature(`the accept is not signed by the new owner (${acceptSigned.reason}, §23.3 rule 4)`),
|
|
384
|
+
};
|
|
385
|
+
if (!bytesEqual(record.payload.issuer, o.proposedOwner.principalId))
|
|
386
|
+
return { authorization: deny("the commit is not issued by the new owner (§23.3 rule 5)") };
|
|
387
|
+
if (o.expectedControlSeq !== record.payload.controlSeq)
|
|
388
|
+
return {
|
|
389
|
+
authorization: deny("the offer's expected Control Sequence is not the commit's (§23.3 rule 6)"),
|
|
390
|
+
};
|
|
391
|
+
return { authorization: ALLOW, newOwner: o.proposedOwner };
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The descriptor a transfer commit carries for its own issuer: the offer's
|
|
395
|
+
* proposed owner (self-certifying, §7), so a new owner never granted
|
|
396
|
+
* before can still be verified. Undefined for other records.
|
|
397
|
+
*/
|
|
398
|
+
export function transferIssuerDescriptor(record) {
|
|
399
|
+
if (record.body.type !== "OWNER_TRANSFER_COMMIT")
|
|
400
|
+
return undefined;
|
|
401
|
+
try {
|
|
402
|
+
const proposed = parseOwnerTransferOffer(record.body.offer).payload.proposedOwner;
|
|
403
|
+
return bytesEqual(proposed.principalId, record.payload.issuer) ? proposed : undefined;
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
//# sourceMappingURL=capability.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type CborMap, type CborValue } from "./value.js";
|
|
2
|
+
/**
|
|
3
|
+
* Decodes exactly one deterministic CBOR item (LFCP-WIRE-01 §5.2) and
|
|
4
|
+
* rejects anything else: indefinite lengths, non-shortest heads, unsorted
|
|
5
|
+
* or duplicate map keys, tags, floats and other simple values, invalid
|
|
6
|
+
* UTF-8, truncated input and trailing bytes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function decodeStrict(bytes: Uint8Array): CborValue;
|
|
9
|
+
/**
|
|
10
|
+
* The §5.2 receiver check: `bytes` decode strictly and re-encode to exactly
|
|
11
|
+
* the same bytes. Signature and object-ID checks still use the received
|
|
12
|
+
* bytes; this only decides whether they are the deterministic encoding.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Strict decode plus the §5.2 re-encode comparison (N7): the bytes must be
|
|
16
|
+
* well-formed (CBOR_* errors from `decodeStrict` otherwise) and exactly the
|
|
17
|
+
* deterministic encoding of their value (CBOR_NON_CANONICAL otherwise). The
|
|
18
|
+
* re-encoding is only compared, never returned.
|
|
19
|
+
*/
|
|
20
|
+
export declare function decodeDeterministic(bytes: Uint8Array): CborValue;
|
|
21
|
+
export declare function isDeterministic(bytes: Uint8Array): boolean;
|
|
22
|
+
export type { CborMap };
|
|
23
|
+
//# sourceMappingURL=decode.d.ts.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { bytesEqual, LfcpError } from "@openlfcp/core";
|
|
2
|
+
import { compareEncodedKeys, encode } from "./encode.js";
|
|
3
|
+
import { strictUtf8Decoder } from "./text.js";
|
|
4
|
+
import { cborMap, MAX_DEPTH } from "./value.js";
|
|
5
|
+
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
6
|
+
const asInteger = (v) => (v <= MAX_SAFE && v >= -MAX_SAFE ? Number(v) : v);
|
|
7
|
+
class Reader {
|
|
8
|
+
bytes;
|
|
9
|
+
pos = 0;
|
|
10
|
+
constructor(bytes) {
|
|
11
|
+
this.bytes = bytes;
|
|
12
|
+
}
|
|
13
|
+
need(n) {
|
|
14
|
+
if (BigInt(this.pos) + BigInt(n) > BigInt(this.bytes.length))
|
|
15
|
+
throw new LfcpError("CBOR_TRUNCATED", "input ends inside an item");
|
|
16
|
+
return Number(n);
|
|
17
|
+
}
|
|
18
|
+
byte() {
|
|
19
|
+
this.need(1);
|
|
20
|
+
return this.bytes[this.pos++];
|
|
21
|
+
}
|
|
22
|
+
take(n) {
|
|
23
|
+
this.need(n);
|
|
24
|
+
const out = this.bytes.slice(this.pos, this.pos + n);
|
|
25
|
+
this.pos += n;
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Reads an argument and enforces the shortest form (§5.2 rule 1). */
|
|
29
|
+
argument(ai) {
|
|
30
|
+
if (ai < 24)
|
|
31
|
+
return BigInt(ai);
|
|
32
|
+
if (ai === 31)
|
|
33
|
+
throw new LfcpError("CBOR_INDEFINITE_LENGTH", "indefinite lengths are not allowed (§5.2 rule 2)");
|
|
34
|
+
if (ai > 27)
|
|
35
|
+
throw new LfcpError("CBOR_UNSUPPORTED_TYPE", "reserved additional information value");
|
|
36
|
+
const size = 1 << (ai - 24);
|
|
37
|
+
let v = 0n;
|
|
38
|
+
for (const b of this.take(size))
|
|
39
|
+
v = (v << 8n) | BigInt(b);
|
|
40
|
+
const floor = [24n, 0x100n, 0x10000n, 0x100000000n][ai - 24];
|
|
41
|
+
if (v < floor)
|
|
42
|
+
throw new LfcpError("CBOR_NON_CANONICAL", "integer or length not in its shortest form");
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function readKey(r, depth) {
|
|
47
|
+
const v = readValue(r, depth);
|
|
48
|
+
if (typeof v === "number" ||
|
|
49
|
+
typeof v === "bigint" ||
|
|
50
|
+
typeof v === "string" ||
|
|
51
|
+
v instanceof Uint8Array)
|
|
52
|
+
return v;
|
|
53
|
+
throw new LfcpError("CBOR_UNSUPPORTED_TYPE", "map keys must be integers, text or byte strings");
|
|
54
|
+
}
|
|
55
|
+
function readValue(r, depth) {
|
|
56
|
+
if (depth > MAX_DEPTH)
|
|
57
|
+
throw new LfcpError("CBOR_TOO_DEEP", `nesting deeper than ${MAX_DEPTH}`);
|
|
58
|
+
const ib = r.byte();
|
|
59
|
+
const major = ib >> 5;
|
|
60
|
+
const ai = ib & 0x1f;
|
|
61
|
+
switch (major) {
|
|
62
|
+
case 0:
|
|
63
|
+
return asInteger(r.argument(ai));
|
|
64
|
+
case 1:
|
|
65
|
+
return asInteger(-1n - r.argument(ai));
|
|
66
|
+
case 2:
|
|
67
|
+
return r.take(r.need(r.argument(ai)));
|
|
68
|
+
case 3: {
|
|
69
|
+
const raw = r.take(r.need(r.argument(ai)));
|
|
70
|
+
try {
|
|
71
|
+
return strictUtf8Decoder.decode(raw);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new LfcpError("CBOR_INVALID_UTF8", "text string is not well-formed UTF-8");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
case 4: {
|
|
78
|
+
const count = r.argument(ai);
|
|
79
|
+
r.need(count); // every item takes at least one byte
|
|
80
|
+
const items = [];
|
|
81
|
+
for (let i = 0n; i < count; i += 1n)
|
|
82
|
+
items.push(readValue(r, depth + 1));
|
|
83
|
+
return items;
|
|
84
|
+
}
|
|
85
|
+
case 5: {
|
|
86
|
+
const count = r.argument(ai);
|
|
87
|
+
r.need(count * 2n);
|
|
88
|
+
const entries = [];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
let previous = null;
|
|
91
|
+
for (let i = 0n; i < count; i += 1n) {
|
|
92
|
+
const start = r.pos;
|
|
93
|
+
const key = readKey(r, depth + 1);
|
|
94
|
+
const keyBytes = r.bytes.subarray(start, r.pos);
|
|
95
|
+
const id = Array.from(keyBytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
96
|
+
if (seen.has(id))
|
|
97
|
+
throw new LfcpError("CBOR_DUPLICATE_KEY", "map contains a duplicate key");
|
|
98
|
+
if (previous && compareEncodedKeys(previous, keyBytes) > 0) {
|
|
99
|
+
throw new LfcpError("CBOR_NON_CANONICAL", "map keys are not in deterministic order (§5.2 rule 4)");
|
|
100
|
+
}
|
|
101
|
+
seen.add(id);
|
|
102
|
+
previous = keyBytes;
|
|
103
|
+
entries.push([key, readValue(r, depth + 1)]);
|
|
104
|
+
}
|
|
105
|
+
return cborMap(entries);
|
|
106
|
+
}
|
|
107
|
+
case 6:
|
|
108
|
+
throw new LfcpError("CBOR_UNSUPPORTED_TYPE", "CBOR tags are not used by LFCP (§5.2 rule 6)");
|
|
109
|
+
default:
|
|
110
|
+
if (ai === 20)
|
|
111
|
+
return false;
|
|
112
|
+
if (ai === 21)
|
|
113
|
+
return true;
|
|
114
|
+
if (ai === 22)
|
|
115
|
+
return null;
|
|
116
|
+
if (ai === 31)
|
|
117
|
+
throw new LfcpError("CBOR_INDEFINITE_LENGTH", "unexpected break code");
|
|
118
|
+
throw new LfcpError("CBOR_UNSUPPORTED_TYPE", "floats, undefined and other simple values are not used by LFCP");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Decodes exactly one deterministic CBOR item (LFCP-WIRE-01 §5.2) and
|
|
123
|
+
* rejects anything else: indefinite lengths, non-shortest heads, unsorted
|
|
124
|
+
* or duplicate map keys, tags, floats and other simple values, invalid
|
|
125
|
+
* UTF-8, truncated input and trailing bytes.
|
|
126
|
+
*/
|
|
127
|
+
export function decodeStrict(bytes) {
|
|
128
|
+
const r = new Reader(bytes);
|
|
129
|
+
const value = readValue(r, 0);
|
|
130
|
+
if (r.pos !== bytes.length)
|
|
131
|
+
throw new LfcpError("CBOR_TRAILING_BYTES", "bytes remain after the top-level item");
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The §5.2 receiver check: `bytes` decode strictly and re-encode to exactly
|
|
136
|
+
* the same bytes. Signature and object-ID checks still use the received
|
|
137
|
+
* bytes; this only decides whether they are the deterministic encoding.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* Strict decode plus the §5.2 re-encode comparison (N7): the bytes must be
|
|
141
|
+
* well-formed (CBOR_* errors from `decodeStrict` otherwise) and exactly the
|
|
142
|
+
* deterministic encoding of their value (CBOR_NON_CANONICAL otherwise). The
|
|
143
|
+
* re-encoding is only compared, never returned.
|
|
144
|
+
*/
|
|
145
|
+
export function decodeDeterministic(bytes) {
|
|
146
|
+
const value = decodeStrict(bytes);
|
|
147
|
+
if (!bytesEqual(encode(value), bytes))
|
|
148
|
+
throw new LfcpError("CBOR_NON_CANONICAL", "the bytes are not the deterministic encoding");
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
export function isDeterministic(bytes) {
|
|
152
|
+
let value;
|
|
153
|
+
try {
|
|
154
|
+
value = decodeStrict(bytes);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (e instanceof LfcpError)
|
|
158
|
+
return false;
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
return bytesEqual(encode(value), bytes);
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=decode.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type CborValue } from "./value.js";
|
|
2
|
+
/** Byte order of encoded map keys (§5.2 rule 4): shorter encodings first, then bytewise. */
|
|
3
|
+
export declare function compareEncodedKeys(a: Uint8Array, b: Uint8Array): number;
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic CBOR encoding (LFCP-WIRE-01 §5.2): shortest heads,
|
|
6
|
+
* definite lengths, map keys sorted by encoded length then bytewise,
|
|
7
|
+
* duplicate keys rejected, array order kept, no tags.
|
|
8
|
+
*/
|
|
9
|
+
export declare function encode(value: CborValue): Uint8Array;
|
|
10
|
+
//# sourceMappingURL=encode.d.ts.map
|