@openlfcp/wire 0.1.2 → 0.1.4
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/README.md +2 -2
- 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 +111 -0
- package/dist/have.js +277 -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 +369 -0
- package/dist/message.js +692 -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 +3 -3
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ ESM only; Node.js 24 or later (browsers and Electron for every package except `@
|
|
|
12
12
|
|
|
13
13
|
Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
|
|
14
14
|
subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
|
|
15
|
-
at `mvp-0.1-baseline.
|
|
15
|
+
at `mvp-0.1-baseline.10`, not every deferred WIRE-01 feature. It does not
|
|
16
16
|
claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
|
|
17
|
-
(`0.1.
|
|
17
|
+
(`0.1.4`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
|
|
18
18
|
|
|
19
19
|
## Links
|
|
20
20
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { type ControlRecordId, type DataEpoch, type PrincipalId } from "@openlfcp/core";
|
|
2
|
+
import type { ControlState } from "./chain.js";
|
|
3
|
+
import { type ControlRecord } from "./control.js";
|
|
4
|
+
import type { PrincipalDescriptor } from "./principal.js";
|
|
5
|
+
/**
|
|
6
|
+
* The LFCP capability engine (LFCP-WIRE-01 §17, §18, §20, §19, §25.2).
|
|
7
|
+
*
|
|
8
|
+
* Authority comes only from the validated Control Chain: a Principal ID
|
|
9
|
+
* holds an ability at a Control Head H because of the owner rule or of
|
|
10
|
+
* grants and claims committed through H. There is no server account,
|
|
11
|
+
* user table or hosting identity anywhere in this module, and nothing
|
|
12
|
+
* here takes one.
|
|
13
|
+
*
|
|
14
|
+
* Everything is a pure function of a ControlState (the state at some head
|
|
15
|
+
* H). Evaluating at an older H means using the state at that H (see
|
|
16
|
+
* `stateAt` on a linear chain result), so a later revocation never
|
|
17
|
+
* changes an earlier answer.
|
|
18
|
+
*/
|
|
19
|
+
/** §17.1 standard ability codes. */
|
|
20
|
+
export declare const ABILITY: Readonly<{
|
|
21
|
+
DATA_READ: 1n;
|
|
22
|
+
DATA_WRITE: 2n;
|
|
23
|
+
SNAPSHOT_PUBLISH: 3n;
|
|
24
|
+
CAPABILITY_GRANT: 4n;
|
|
25
|
+
CAPABILITY_REVOKE: 5n;
|
|
26
|
+
KEY_DISTRIBUTE: 6n;
|
|
27
|
+
KEY_ROTATE: 7n;
|
|
28
|
+
ROUTE_UPDATE: 8n;
|
|
29
|
+
OWNER_TRANSFER_OFFER: 9n;
|
|
30
|
+
RESOURCE_TOMBSTONE: 10n;
|
|
31
|
+
INVITE_CLAIM: 11n;
|
|
32
|
+
}>;
|
|
33
|
+
/** §17.1 names of the standard codes. */
|
|
34
|
+
export declare const ABILITY_NAMES: ReadonlyMap<bigint, string>;
|
|
35
|
+
/**
|
|
36
|
+
* Whether `ability` is a §17.1 standard code. §17.1: "A code that is not in
|
|
37
|
+
* the table above is kept as received and confers nothing", so an unknown
|
|
38
|
+
* code is kept in its record but is never held.
|
|
39
|
+
*/
|
|
40
|
+
export declare const isStandardAbility: (ability: bigint) => boolean;
|
|
41
|
+
/** A grant created by a CAPABILITY_GRANT record or by a successful claim (§17.2, §18.1). */
|
|
42
|
+
export interface Grant {
|
|
43
|
+
/** The ID of the Control Record that created it (§17.2). */
|
|
44
|
+
readonly id: ControlRecordId;
|
|
45
|
+
readonly source: "grant" | "claim";
|
|
46
|
+
readonly issuer: PrincipalId;
|
|
47
|
+
readonly subject: PrincipalId;
|
|
48
|
+
readonly abilities: readonly bigint[];
|
|
49
|
+
readonly delegable: readonly bigint[];
|
|
50
|
+
readonly parentGrantId: ControlRecordId | null;
|
|
51
|
+
/** §17.2 claim_limit, for invitation grants; null when absent. */
|
|
52
|
+
readonly claimLimit: bigint | null;
|
|
53
|
+
/** Claims consumed so far, counted in linear chain order (§18.1). */
|
|
54
|
+
readonly claimsUsed: bigint;
|
|
55
|
+
/** The revoking record, or null while not revoked (§17.3). */
|
|
56
|
+
readonly revokedBy: ControlRecordId | null;
|
|
57
|
+
}
|
|
58
|
+
/** The capability part of ControlState. */
|
|
59
|
+
export interface CapabilityState {
|
|
60
|
+
/** Every grant created through this head, by grant ID hex, active or not. */
|
|
61
|
+
readonly grants: ReadonlyMap<string, Grant>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Whether a grant is active at this state. §17.2: "A grant is active while
|
|
65
|
+
* it has not been revoked and, when it has a parent grant, while that
|
|
66
|
+
* parent is active. Revoking a grant therefore also deactivates every grant
|
|
67
|
+
* delegated from it, directly or through further delegations."
|
|
68
|
+
*/
|
|
69
|
+
export declare function isGrantActive(state: CapabilityState, grantId: Uint8Array): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Whether `principal` holds `ability` at the head of `state`: the owner
|
|
72
|
+
* holds every standard ability implicitly (§17.1, no self-grant); anyone
|
|
73
|
+
* else holds it through an active grant that confers it. Unknown codes and
|
|
74
|
+
* the reserved ability 9 are held by no one (§17.1, §23.1).
|
|
75
|
+
*/
|
|
76
|
+
export declare function hasAbility(state: ControlState, principal: PrincipalId, ability: bigint): boolean;
|
|
77
|
+
/** Every standard ability `principal` holds at the head of `state`, ascending. */
|
|
78
|
+
export declare function abilitiesOf(state: ControlState, principal: PrincipalId): readonly bigint[];
|
|
79
|
+
/**
|
|
80
|
+
* An authorization decision; a refusal names the rule. `code` marks the
|
|
81
|
+
* refusals callers must tell apart: INVITE_CLAIM_EXHAUSTED (§18.1 rule 3,
|
|
82
|
+
* which LFCP-022 reports separately; still AUTHORIZATION_FAILED on the
|
|
83
|
+
* wire) and INVALID_SIGNATURE (§23.3: a transfer offer or acceptance whose
|
|
84
|
+
* signature does not verify, INVALID_SIGNATURE on the wire).
|
|
85
|
+
*/
|
|
86
|
+
export type Authorization = {
|
|
87
|
+
readonly allowed: true;
|
|
88
|
+
} | {
|
|
89
|
+
readonly allowed: false;
|
|
90
|
+
readonly reason: string;
|
|
91
|
+
readonly code?: "INVITE_CLAIM_EXHAUSTED" | "INVALID_SIGNATURE";
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Whether the issuer of `record` had the authority the record needs, at
|
|
95
|
+
* the state before it (the chain's previous head). Genesis is authorized by
|
|
96
|
+
* verifyGenesis. Coordinator Recovery and Resource Tombstone records never
|
|
97
|
+
* get here: the chain refuses them first (DV1).
|
|
98
|
+
*/
|
|
99
|
+
export declare function authorizeControlRecord(record: ControlRecord, state: ControlState): Authorization;
|
|
100
|
+
/** The capability transitions of an applied record (called by applyRecord). */
|
|
101
|
+
export declare function applyCapabilities(grants: ReadonlyMap<string, Grant>, record: ControlRecord): ReadonlyMap<string, Grant>;
|
|
102
|
+
/**
|
|
103
|
+
* §25.2 Key Package authority at the package's referenced Control Head:
|
|
104
|
+
* the sender must hold key/distribute, and the recipient must hold
|
|
105
|
+
* data/read, "except for an Invitation Principal explicitly authorized by
|
|
106
|
+
* an active invite grant: a recipient that is the subject of an active
|
|
107
|
+
* grant that includes and still confers invite/claim". `epoch` is the
|
|
108
|
+
* package's Data Epoch; whether that epoch is recognized at the head is
|
|
109
|
+
* checked with the epoch rules (LFCP-023, LFCP-024).
|
|
110
|
+
*/
|
|
111
|
+
export declare function canDistributeKey(state: ControlState, sender: PrincipalId, recipient: PrincipalId, _epoch: DataEpoch): Authorization;
|
|
112
|
+
/** A verified ownership transfer: the new owner's descriptor, from the offer. */
|
|
113
|
+
export interface VerifiedOwnerTransfer {
|
|
114
|
+
readonly authorization: Authorization;
|
|
115
|
+
readonly newOwner?: PrincipalDescriptor;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The §23.3 verifier for an OWNER_TRANSFER_COMMIT, at the state before it.
|
|
119
|
+
* Verification only: there is no transfer UI or flow (deferred from MVP 0.1).
|
|
120
|
+
*
|
|
121
|
+
* §23.3 "A verifier MUST confirm":
|
|
122
|
+
* 1. the offer is signed by the current owner;
|
|
123
|
+
* 2. the offer references the current Control Head;
|
|
124
|
+
* 3. the offer names the accepting Principal;
|
|
125
|
+
* 4. the acceptance is signed by that Principal;
|
|
126
|
+
* 5. the commit itself is signed by that Principal (here: issued by it;
|
|
127
|
+
* the chain verifies the signature against the issuer);
|
|
128
|
+
* 6. the expected next Control Sequence matches the commit sequence.
|
|
129
|
+
* Also, from §23.1 and §23.2: the offer and accept are canonical signed
|
|
130
|
+
* objects of this Resource, and the accept names the exact offer ID (the
|
|
131
|
+
* §10.6 object ID of the offer bytes). §23.3: "A commit that fails any of
|
|
132
|
+
* these checks is rejected with AUTHORIZATION_FAILED, except that an offer
|
|
133
|
+
* or acceptance whose signature does not verify is rejected with
|
|
134
|
+
* INVALID_SIGNATURE" (the refusal's code).
|
|
135
|
+
*/
|
|
136
|
+
export declare function verifyOwnerTransfer(record: ControlRecord, state: ControlState): VerifiedOwnerTransfer;
|
|
137
|
+
/**
|
|
138
|
+
* The descriptor a transfer commit carries for its own issuer: the offer's
|
|
139
|
+
* proposed owner (self-certifying, §7), so a new owner never granted
|
|
140
|
+
* before can still be verified. Undefined for other records.
|
|
141
|
+
*/
|
|
142
|
+
export declare function transferIssuerDescriptor(record: ControlRecord): PrincipalDescriptor | undefined;
|
|
143
|
+
//# sourceMappingURL=capability.d.ts.map
|
|
@@ -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
|