@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/endpoint.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { LfcpError } from "@openlfcp/core";
|
|
2
|
+
import { cborMap } from "./cbor/index.js";
|
|
3
|
+
import { Fields } from "./fields.js";
|
|
4
|
+
/**
|
|
5
|
+
* Decodes an `endpoint` map: exactly keys 0 (tstr) and 1 (uint), optional
|
|
6
|
+
* 2 (uint). A violation is INVALID_STRUCTURE (MALFORMED_MESSAGE on the wire).
|
|
7
|
+
*
|
|
8
|
+
* Structure only. Reserved flag bits are kept: "A writer sets reserved bits
|
|
9
|
+
* to 0; a receiver ignores them" (§16). The receiver's URL scheme rule is
|
|
10
|
+
* checkReceivedUrl, applied by the Control Record decoders.
|
|
11
|
+
*/
|
|
12
|
+
export function endpointFromCbor(value) {
|
|
13
|
+
const f = new Fields(value, "endpoint", [0, 1], [2]);
|
|
14
|
+
const url = f.text(0);
|
|
15
|
+
const priority = f.uint(1);
|
|
16
|
+
return Object.freeze(f.has(2) ? { url, priority, flags: f.uint(2) } : { url, priority });
|
|
17
|
+
}
|
|
18
|
+
/** The §16 flag bits LFCP-WIRE-01 defines (0-5); all other bits are reserved. */
|
|
19
|
+
export const ENDPOINT_FLAGS = Object.freeze({
|
|
20
|
+
DATA_PLANE_STORAGE: 1n << 0n,
|
|
21
|
+
CONTROL_PLANE_STORAGE: 1n << 1n,
|
|
22
|
+
SNAPSHOTS: 1n << 2n,
|
|
23
|
+
PRESENCE: 1n << 3n,
|
|
24
|
+
PREFERRED_FOR_READS: 1n << 4n,
|
|
25
|
+
PREFERRED_FOR_WRITES: 1n << 5n,
|
|
26
|
+
});
|
|
27
|
+
const DEFINED_FLAGS = 63n;
|
|
28
|
+
const UINT64_MAX = 2n ** 64n - 1n;
|
|
29
|
+
function refuse(why) {
|
|
30
|
+
throw new LfcpError("INVALID_STRUCTURE", `refusing to write an endpoint: ${why}`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* §16, receiver side: "A receiver MUST reject a record carrying such a URL
|
|
34
|
+
* [an endpoint or Control Coordinator URL in a Control Record] with any
|
|
35
|
+
* scheme other than ws or wss with MALFORMED_MESSAGE." The scheme is
|
|
36
|
+
* compared case-insensitively (RFC 3986 §3.1) and must be followed by
|
|
37
|
+
* "://", so a URL without an authority (wss:host) is rejected too. The
|
|
38
|
+
* loopback rule is the sender's. Throws INVALID_STRUCTURE.
|
|
39
|
+
*/
|
|
40
|
+
export function checkReceivedUrl(url) {
|
|
41
|
+
const m = /^([A-Za-z][A-Za-z0-9+.-]*):(\/\/)?/.exec(url);
|
|
42
|
+
const scheme = m?.[1]?.toLowerCase();
|
|
43
|
+
if (scheme !== "ws" && scheme !== "wss")
|
|
44
|
+
throw new LfcpError("INVALID_STRUCTURE", `an endpoint or coordinator URL must use ws or wss, not ${scheme ?? "no scheme"} (§16)`);
|
|
45
|
+
if (m?.[2] === undefined)
|
|
46
|
+
throw new LfcpError("INVALID_STRUCTURE", `an endpoint or coordinator URL must start with ${scheme}:// (§16)`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Checks a URL an LFCP writer puts in an endpoint or a coordinator field
|
|
50
|
+
* (§16: "A sender uses wss://, except ws:// for a loopback address").
|
|
51
|
+
*
|
|
52
|
+
* Accepted: an absolute URI (RFC 3986 §4.3: no fragment) with scheme wss,
|
|
53
|
+
* or ws on a loopback host (localhost, 127.0.0.0/8, [::1]).
|
|
54
|
+
*/
|
|
55
|
+
export function checkWriterUrl(url) {
|
|
56
|
+
if (typeof url !== "string")
|
|
57
|
+
refuse("the URL must be a text string");
|
|
58
|
+
const control = [...url].some((ch) => {
|
|
59
|
+
const c = ch.codePointAt(0);
|
|
60
|
+
return c < 0x20 || c === 0x7f;
|
|
61
|
+
});
|
|
62
|
+
if (control || /\s/.test(url))
|
|
63
|
+
refuse("the URL contains whitespace or control characters");
|
|
64
|
+
// Split by hand: the former /^scheme:\/\/([^/?#]+)([^#]*)$/ backtracked
|
|
65
|
+
// quadratically on a long URL ending in "#" (security review, H3 pass).
|
|
66
|
+
// Same result: the authority runs to the first "/", "?" or "#" and must
|
|
67
|
+
// not be empty, and no "#" (fragment) may follow.
|
|
68
|
+
const m = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url);
|
|
69
|
+
const rest = m === null ? "" : url.slice(m[0].length);
|
|
70
|
+
const cut = rest.search(/[/?#]/);
|
|
71
|
+
const authority = cut < 0 ? rest : rest.slice(0, cut);
|
|
72
|
+
if (m === null || authority === "" || rest.includes("#"))
|
|
73
|
+
refuse("not an absolute URL with an authority and no fragment");
|
|
74
|
+
const scheme = m[1].toLowerCase();
|
|
75
|
+
if (authority.includes("@"))
|
|
76
|
+
refuse("user information is not allowed in the URL");
|
|
77
|
+
const host = (/^(\[[^\]]*\]|[^:]*)(:\d*)?$/.exec(authority)?.[1] ?? "").toLowerCase();
|
|
78
|
+
if (host === "")
|
|
79
|
+
refuse("the URL has no host");
|
|
80
|
+
const loopback = host === "localhost" || host === "[::1]" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
81
|
+
if (scheme === "wss")
|
|
82
|
+
return;
|
|
83
|
+
if (scheme === "ws" && loopback)
|
|
84
|
+
return;
|
|
85
|
+
refuse(scheme === "ws"
|
|
86
|
+
? "ws:// is only for loopback hosts; use wss://"
|
|
87
|
+
: `scheme ${scheme} is not wss`);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Encodes an endpoint for a record this SDK writes. Writer rules: the URL
|
|
91
|
+
* passes checkWriterUrl, the priority and flags are uint64, and no
|
|
92
|
+
* reserved flag bit (6 and up) is set.
|
|
93
|
+
*/
|
|
94
|
+
export function endpointToCbor(endpoint) {
|
|
95
|
+
checkWriterUrl(endpoint.url);
|
|
96
|
+
const uint = (what, n) => {
|
|
97
|
+
if (typeof n !== "bigint" || n < 0n || n > UINT64_MAX)
|
|
98
|
+
refuse(`${what} must be a uint64 bigint`);
|
|
99
|
+
return n;
|
|
100
|
+
};
|
|
101
|
+
const priority = uint("the priority", endpoint.priority);
|
|
102
|
+
if (endpoint.flags === undefined)
|
|
103
|
+
return cborMap([
|
|
104
|
+
[0, endpoint.url],
|
|
105
|
+
[1, priority],
|
|
106
|
+
]);
|
|
107
|
+
const flags = uint("the flags", endpoint.flags);
|
|
108
|
+
if ((flags & ~DEFINED_FLAGS) !== 0n)
|
|
109
|
+
refuse("reserved flag bits (6 and up) must not be set");
|
|
110
|
+
return cborMap([
|
|
111
|
+
[0, endpoint.url],
|
|
112
|
+
[1, priority],
|
|
113
|
+
[2, flags],
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=endpoint.js.map
|
package/dist/epoch.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type ControlRecordId, type DataEpoch, type Hash32, type PrincipalId } from "@openlfcp/core";
|
|
2
|
+
import { type ResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import type { ChainResult, ControlState } from "./chain.js";
|
|
4
|
+
import type { Signer } from "./cose.js";
|
|
5
|
+
import type { ActorHave } from "./have.js";
|
|
6
|
+
import type { DataUnitPayload } from "./objects.js";
|
|
7
|
+
/**
|
|
8
|
+
* Data Epoch rotation and the strict previous-epoch cutoff (LFCP-WIRE-01
|
|
9
|
+
* §19, §19.1, §26.3 steps 4-5, §75, §88 step 7).
|
|
10
|
+
*
|
|
11
|
+
* After KEY_EPOCH(E + 1) closes epoch E with a final frontier, a Data Unit
|
|
12
|
+
* of epoch E is automatically acceptable only when its actor and sequence
|
|
13
|
+
* are inside that frontier. Anything else is stale offline work: it is
|
|
14
|
+
* quarantined (STALE_DATA_EPOCH), never merged automatically and never
|
|
15
|
+
* dropped silently, and never re-encrypted into a newer epoch. Units within
|
|
16
|
+
* the cutoff stay valid history, and rotation does not take back what a
|
|
17
|
+
* removed member already decrypted (§75).
|
|
18
|
+
*
|
|
19
|
+
* Everything except rotateEpoch (which draws a fresh DEK) is pure.
|
|
20
|
+
*/
|
|
21
|
+
/** §19 Key Epoch reason codes. Unknown codes are kept by the decoder (structure only). */
|
|
22
|
+
export declare const KEY_EPOCH_REASON: Readonly<{
|
|
23
|
+
ROUTINE: 0n;
|
|
24
|
+
MEMBER_REVOKED: 1n;
|
|
25
|
+
COMPROMISE: 2n;
|
|
26
|
+
OWNERSHIP_TRANSFER: 3n;
|
|
27
|
+
MANUAL_SECURITY: 4n;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Whether `seq` of `principal` is inside a final frontier: within the
|
|
31
|
+
* actor's contiguous run (1..contiguous) or one of its explicit extra
|
|
32
|
+
* ranges. An actor absent from the frontier covers nothing (§19.1).
|
|
33
|
+
*/
|
|
34
|
+
export declare function isSequenceWithinFrontier(principal: PrincipalId, seq: bigint, frontier: readonly ActorHave[]): boolean;
|
|
35
|
+
/** A validated chain, as needed to place a unit: its latest state and its state at any head. */
|
|
36
|
+
export type ControlView = Pick<Extract<ChainResult, {
|
|
37
|
+
kind: "linear";
|
|
38
|
+
}>, "state" | "stateAt">;
|
|
39
|
+
/** The Data Unit fields the epoch rules read. */
|
|
40
|
+
export type DataUnitHeader = Pick<DataUnitPayload, "resourceId" | "dataEpoch" | "actor" | "actorSeq" | "controlHead">;
|
|
41
|
+
export type EpochClassification = {
|
|
42
|
+
readonly kind: "accept";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Stale offline work: never merged automatically, never dropped
|
|
46
|
+
* silently; surface it to the application for manual review (§19.1).
|
|
47
|
+
*/
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: "quarantine";
|
|
50
|
+
readonly code: "STALE_DATA_EPOCH";
|
|
51
|
+
readonly reason: "BEYOND_CUTOFF" | "ACTOR_ABSENT";
|
|
52
|
+
readonly epoch: DataEpoch;
|
|
53
|
+
/** The Key Epoch record whose final frontier excludes the unit. */
|
|
54
|
+
readonly closedBy: ControlRecordId;
|
|
55
|
+
} | {
|
|
56
|
+
readonly kind: "reject";
|
|
57
|
+
readonly reason: "UNKNOWN_CONTROL_HEAD" | "UNKNOWN_EPOCH" | "OTHER_RESOURCE";
|
|
58
|
+
readonly wireCode: "MISSING_DEPENDENCY" | "MALFORMED_MESSAGE";
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The epoch-eligibility of a Data Unit (§26.3 steps 4-5).
|
|
62
|
+
*
|
|
63
|
+
* - The unit's referenced Control Head must be on the chain, and its epoch
|
|
64
|
+
* recognized at that head. Otherwise MISSING_DEPENDENCY (§26.3, G-EP2):
|
|
65
|
+
* the receiver lacks Control records, or the unit claims a future epoch.
|
|
66
|
+
* - The cutoff is evaluated against the LATEST known state (§19.1, §26.3,
|
|
67
|
+
* G-EP1). Once a Key Epoch closing the unit's epoch is known, the unit is
|
|
68
|
+
* held to its final frontier, whichever head the unit referenced (vector
|
|
69
|
+
* §12.5: D3 is stale "after C6 is known").
|
|
70
|
+
*
|
|
71
|
+
* Signature, data/write authority at the referenced head and AEAD are
|
|
72
|
+
* checked by the Data Unit verifier (LFCP-025), which calls this hook.
|
|
73
|
+
*/
|
|
74
|
+
export declare function classifyDataUnit(view: ControlView, unit: DataUnitHeader): EpochClassification;
|
|
75
|
+
export type DataPutDecision = {
|
|
76
|
+
readonly ok: true;
|
|
77
|
+
} | {
|
|
78
|
+
readonly ok: false;
|
|
79
|
+
readonly nack: "STALE_DATA_EPOCH" | "MISSING_DEPENDENCY" | "MALFORMED_MESSAGE";
|
|
80
|
+
readonly reason: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* The server's epoch check for one unit of a DATA_PUT (§75: "A server that
|
|
84
|
+
* receives such a unit in DATA_PUT responds NACK(STALE_DATA_EPOCH)"). The
|
|
85
|
+
* other §51 checks (structure, signature, size, hosting policy) are the
|
|
86
|
+
* caller's.
|
|
87
|
+
*/
|
|
88
|
+
export declare function serverAcceptsDataPut(view: ControlView, unit: DataUnitHeader): DataPutDecision;
|
|
89
|
+
/** A new Key Epoch: the record to submit, and the fresh DEK, which never goes into the record. */
|
|
90
|
+
export interface EpochRotation {
|
|
91
|
+
readonly bytes: Uint8Array;
|
|
92
|
+
readonly recordId: ControlRecordId;
|
|
93
|
+
readonly epoch: DataEpoch;
|
|
94
|
+
/** The new epoch's DEK: deliver it in Key Packages (LFCP-024), never in a Control record. */
|
|
95
|
+
readonly dek: ResourceDEK;
|
|
96
|
+
readonly dekCommitment: Hash32;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Builds KEY_EPOCH(current + 1) on the current head: a fresh random DEK
|
|
100
|
+
* (LFCP-018; never the previous one), its §11 commitment, the closing
|
|
101
|
+
* epoch's canonical final frontier and a §19 reason. Submit it with
|
|
102
|
+
* CONTROL_PUT (proposeControlTransition); the issuer needs key/rotate.
|
|
103
|
+
*/
|
|
104
|
+
export declare function rotateEpoch(state: ControlState, signer: Signer, options: {
|
|
105
|
+
readonly reason: bigint;
|
|
106
|
+
readonly finalFrontier: readonly ActorHave[];
|
|
107
|
+
readonly dek?: ResourceDEK;
|
|
108
|
+
}): EpochRotation;
|
|
109
|
+
//# sourceMappingURL=epoch.d.ts.map
|
package/dist/epoch.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { bytesEqual, dataEpoch, } from "@openlfcp/core";
|
|
2
|
+
import { dekCommitment as commitmentOf, generateResourceDEK, } from "@openlfcp/crypto";
|
|
3
|
+
import { signControlRecord } from "./control.js";
|
|
4
|
+
/**
|
|
5
|
+
* Data Epoch rotation and the strict previous-epoch cutoff (LFCP-WIRE-01
|
|
6
|
+
* §19, §19.1, §26.3 steps 4-5, §75, §88 step 7).
|
|
7
|
+
*
|
|
8
|
+
* After KEY_EPOCH(E + 1) closes epoch E with a final frontier, a Data Unit
|
|
9
|
+
* of epoch E is automatically acceptable only when its actor and sequence
|
|
10
|
+
* are inside that frontier. Anything else is stale offline work: it is
|
|
11
|
+
* quarantined (STALE_DATA_EPOCH), never merged automatically and never
|
|
12
|
+
* dropped silently, and never re-encrypted into a newer epoch. Units within
|
|
13
|
+
* the cutoff stay valid history, and rotation does not take back what a
|
|
14
|
+
* removed member already decrypted (§75).
|
|
15
|
+
*
|
|
16
|
+
* Everything except rotateEpoch (which draws a fresh DEK) is pure.
|
|
17
|
+
*/
|
|
18
|
+
/** §19 Key Epoch reason codes. Unknown codes are kept by the decoder (structure only). */
|
|
19
|
+
export const KEY_EPOCH_REASON = Object.freeze({
|
|
20
|
+
ROUTINE: 0n,
|
|
21
|
+
MEMBER_REVOKED: 1n,
|
|
22
|
+
COMPROMISE: 2n,
|
|
23
|
+
OWNERSHIP_TRANSFER: 3n,
|
|
24
|
+
MANUAL_SECURITY: 4n,
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* Whether `seq` of `principal` is inside a final frontier: within the
|
|
28
|
+
* actor's contiguous run (1..contiguous) or one of its explicit extra
|
|
29
|
+
* ranges. An actor absent from the frontier covers nothing (§19.1).
|
|
30
|
+
*/
|
|
31
|
+
export function isSequenceWithinFrontier(principal, seq, frontier) {
|
|
32
|
+
const have = frontier.find((h) => bytesEqual(h.principalId, principal));
|
|
33
|
+
if (have === undefined || seq < 1n)
|
|
34
|
+
return false;
|
|
35
|
+
return seq <= have.contiguous || have.extras.some(([start, end]) => start <= seq && seq <= end);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The epoch-eligibility of a Data Unit (§26.3 steps 4-5).
|
|
39
|
+
*
|
|
40
|
+
* - The unit's referenced Control Head must be on the chain, and its epoch
|
|
41
|
+
* recognized at that head. Otherwise MISSING_DEPENDENCY (§26.3, G-EP2):
|
|
42
|
+
* the receiver lacks Control records, or the unit claims a future epoch.
|
|
43
|
+
* - The cutoff is evaluated against the LATEST known state (§19.1, §26.3,
|
|
44
|
+
* G-EP1). Once a Key Epoch closing the unit's epoch is known, the unit is
|
|
45
|
+
* held to its final frontier, whichever head the unit referenced (vector
|
|
46
|
+
* §12.5: D3 is stale "after C6 is known").
|
|
47
|
+
*
|
|
48
|
+
* Signature, data/write authority at the referenced head and AEAD are
|
|
49
|
+
* checked by the Data Unit verifier (LFCP-025), which calls this hook.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyDataUnit(view, unit) {
|
|
52
|
+
if (!bytesEqual(unit.resourceId, view.state.resourceId))
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
kind: "reject",
|
|
55
|
+
reason: "OTHER_RESOURCE",
|
|
56
|
+
wireCode: "MALFORMED_MESSAGE",
|
|
57
|
+
});
|
|
58
|
+
const atHead = view.stateAt(unit.controlHead);
|
|
59
|
+
if (atHead === undefined)
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
kind: "reject",
|
|
62
|
+
reason: "UNKNOWN_CONTROL_HEAD",
|
|
63
|
+
wireCode: "MISSING_DEPENDENCY",
|
|
64
|
+
});
|
|
65
|
+
if (!atHead.epochs.has(String(unit.dataEpoch)))
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
kind: "reject",
|
|
68
|
+
reason: "UNKNOWN_EPOCH",
|
|
69
|
+
wireCode: "MISSING_DEPENDENCY",
|
|
70
|
+
});
|
|
71
|
+
const latest = view.state.epochs.get(String(unit.dataEpoch));
|
|
72
|
+
if (latest === undefined || latest.finalFrontier === null || latest.closedBy === null)
|
|
73
|
+
return Object.freeze({ kind: "accept" });
|
|
74
|
+
if (isSequenceWithinFrontier(unit.actor, unit.actorSeq, latest.finalFrontier))
|
|
75
|
+
return Object.freeze({ kind: "accept" });
|
|
76
|
+
const absent = !latest.finalFrontier.some((h) => bytesEqual(h.principalId, unit.actor));
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
kind: "quarantine",
|
|
79
|
+
code: "STALE_DATA_EPOCH",
|
|
80
|
+
reason: absent ? "ACTOR_ABSENT" : "BEYOND_CUTOFF",
|
|
81
|
+
epoch: unit.dataEpoch,
|
|
82
|
+
closedBy: latest.closedBy,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The server's epoch check for one unit of a DATA_PUT (§75: "A server that
|
|
87
|
+
* receives such a unit in DATA_PUT responds NACK(STALE_DATA_EPOCH)"). The
|
|
88
|
+
* other §51 checks (structure, signature, size, hosting policy) are the
|
|
89
|
+
* caller's.
|
|
90
|
+
*/
|
|
91
|
+
export function serverAcceptsDataPut(view, unit) {
|
|
92
|
+
const c = classifyDataUnit(view, unit);
|
|
93
|
+
if (c.kind === "accept")
|
|
94
|
+
return Object.freeze({ ok: true });
|
|
95
|
+
if (c.kind === "quarantine")
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
ok: false,
|
|
98
|
+
nack: "STALE_DATA_EPOCH",
|
|
99
|
+
reason: `${c.reason}: outside the final frontier of epoch ${c.epoch}`,
|
|
100
|
+
});
|
|
101
|
+
return Object.freeze({ ok: false, nack: c.wireCode, reason: c.reason });
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Builds KEY_EPOCH(current + 1) on the current head: a fresh random DEK
|
|
105
|
+
* (LFCP-018; never the previous one), its §11 commitment, the closing
|
|
106
|
+
* epoch's canonical final frontier and a §19 reason. Submit it with
|
|
107
|
+
* CONTROL_PUT (proposeControlTransition); the issuer needs key/rotate.
|
|
108
|
+
*/
|
|
109
|
+
export function rotateEpoch(state, signer, options) {
|
|
110
|
+
const epoch = dataEpoch(state.epoch.epoch + 1n);
|
|
111
|
+
const dek = options.dek ?? generateResourceDEK();
|
|
112
|
+
const dekCommitment = commitmentOf(state.resourceId, epoch, dek);
|
|
113
|
+
const signed = signControlRecord({ resourceId: state.resourceId, controlSeq: state.seq + 1n, prevControlId: state.head }, {
|
|
114
|
+
type: "KEY_EPOCH",
|
|
115
|
+
epoch,
|
|
116
|
+
dekCommitment,
|
|
117
|
+
finalFrontier: options.finalFrontier,
|
|
118
|
+
reason: options.reason,
|
|
119
|
+
}, signer);
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
bytes: signed.bytes,
|
|
122
|
+
recordId: signed.recordId,
|
|
123
|
+
epoch,
|
|
124
|
+
dek,
|
|
125
|
+
dekCommitment,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=epoch.js.map
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CborValue } from "./cbor/index.js";
|
|
2
|
+
export declare function invalid(what: string, why: string): never;
|
|
3
|
+
/** Fields of a map whose key set must be `required` plus any of `optional`, nothing else. */
|
|
4
|
+
export declare class Fields {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(value: CborValue, what: string, required: readonly number[], optional?: readonly number[]);
|
|
7
|
+
has(key: number): boolean;
|
|
8
|
+
fail(key: number, why: string): never;
|
|
9
|
+
any(key: number): CborValue;
|
|
10
|
+
uint(key: number): bigint;
|
|
11
|
+
bytes(key: number, length?: number): Uint8Array;
|
|
12
|
+
bytesOrNull(key: number, length: number): Uint8Array | null;
|
|
13
|
+
text(key: number): string;
|
|
14
|
+
array(key: number): readonly CborValue[];
|
|
15
|
+
/** An array of unsigned integers; `nonEmpty` for CDDL `[1* uint]`. */
|
|
16
|
+
uintArray(key: number, nonEmpty: boolean): readonly bigint[];
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=fields.d.ts.map
|
package/dist/fields.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { LfcpError } from "@openlfcp/core";
|
|
2
|
+
import { isCborMap } from "./cbor/index.js";
|
|
3
|
+
// Readers for the integer-keyed maps of LFCP-WIRE-01 structures. Every
|
|
4
|
+
// failure is INVALID_STRUCTURE (MALFORMED_MESSAGE on the wire). Values are
|
|
5
|
+
// copied out of the decoded CBOR; nothing here encodes.
|
|
6
|
+
export function invalid(what, why) {
|
|
7
|
+
throw new LfcpError("INVALID_STRUCTURE", `invalid ${what}: ${why}`);
|
|
8
|
+
}
|
|
9
|
+
/** Fields of a map whose key set must be `required` plus any of `optional`, nothing else. */
|
|
10
|
+
export class Fields {
|
|
11
|
+
#what;
|
|
12
|
+
#map;
|
|
13
|
+
constructor(value, what, required, optional = []) {
|
|
14
|
+
this.#what = what;
|
|
15
|
+
if (!isCborMap(value))
|
|
16
|
+
invalid(what, "not a map");
|
|
17
|
+
const allowed = new Set([...required, ...optional]);
|
|
18
|
+
this.#map = new Map();
|
|
19
|
+
for (const [key, field] of value.entries) {
|
|
20
|
+
if (typeof key !== "number" || !allowed.has(key))
|
|
21
|
+
invalid(what, `field ${String(key)} is not allowed`);
|
|
22
|
+
this.#map.set(key, field);
|
|
23
|
+
}
|
|
24
|
+
for (const key of required)
|
|
25
|
+
if (!this.#map.has(key))
|
|
26
|
+
invalid(what, `field ${key} is missing`);
|
|
27
|
+
}
|
|
28
|
+
has(key) {
|
|
29
|
+
return this.#map.has(key);
|
|
30
|
+
}
|
|
31
|
+
fail(key, why) {
|
|
32
|
+
invalid(this.#what, `field ${key} ${why}`);
|
|
33
|
+
}
|
|
34
|
+
any(key) {
|
|
35
|
+
return this.#map.get(key);
|
|
36
|
+
}
|
|
37
|
+
uint(key) {
|
|
38
|
+
const v = this.#map.get(key);
|
|
39
|
+
if ((typeof v === "number" || typeof v === "bigint") && v >= 0)
|
|
40
|
+
return BigInt(v);
|
|
41
|
+
this.fail(key, "must be an unsigned integer");
|
|
42
|
+
}
|
|
43
|
+
bytes(key, length) {
|
|
44
|
+
const v = this.#map.get(key);
|
|
45
|
+
if (!(v instanceof Uint8Array))
|
|
46
|
+
this.fail(key, "must be a byte string");
|
|
47
|
+
if (length !== undefined && v.length !== length)
|
|
48
|
+
this.fail(key, `must be ${length} bytes`);
|
|
49
|
+
return Uint8Array.from(v);
|
|
50
|
+
}
|
|
51
|
+
bytesOrNull(key, length) {
|
|
52
|
+
return this.#map.get(key) === null ? null : this.bytes(key, length);
|
|
53
|
+
}
|
|
54
|
+
text(key) {
|
|
55
|
+
const v = this.#map.get(key);
|
|
56
|
+
if (typeof v !== "string")
|
|
57
|
+
this.fail(key, "must be a text string");
|
|
58
|
+
return v;
|
|
59
|
+
}
|
|
60
|
+
array(key) {
|
|
61
|
+
const v = this.#map.get(key);
|
|
62
|
+
if (!Array.isArray(v))
|
|
63
|
+
this.fail(key, "must be an array");
|
|
64
|
+
return v;
|
|
65
|
+
}
|
|
66
|
+
/** An array of unsigned integers; `nonEmpty` for CDDL `[1* uint]`. */
|
|
67
|
+
uintArray(key, nonEmpty) {
|
|
68
|
+
const list = this.array(key);
|
|
69
|
+
if (nonEmpty && list.length === 0)
|
|
70
|
+
this.fail(key, "must not be empty");
|
|
71
|
+
return Object.freeze(list.map((v) => {
|
|
72
|
+
if ((typeof v === "number" || typeof v === "bigint") && v >= 0)
|
|
73
|
+
return BigInt(v);
|
|
74
|
+
return this.fail(key, "must hold unsigned integers only");
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=fields.js.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { type PrincipalId } from "@openlfcp/core";
|
|
2
|
+
import { type Signer } from "./cose.js";
|
|
3
|
+
import { type AnyMessage, type LfcpMessage } from "./message.js";
|
|
4
|
+
import { type PrincipalDescriptor } from "./principal.js";
|
|
5
|
+
/**
|
|
6
|
+
* The LFCP session handshake (LFCP-WIRE-01 §34-§37): HELLO → CHALLENGE →
|
|
7
|
+
* AUTH → READY, as pure functions over decoded messages. No sockets.
|
|
8
|
+
*
|
|
9
|
+
* The handshake authenticates the session Principal: it proves possession
|
|
10
|
+
* of the Ed25519 key of the descriptor sent in HELLO, bound to this
|
|
11
|
+
* session's nonces, session ID and server ID. It grants no Resource
|
|
12
|
+
* authority: AuthenticatedSession has no abilities, and the optional
|
|
13
|
+
* hosting credential is an opaque server-policy value, never a capability
|
|
14
|
+
* (§36). Resource authority comes only from Control Chains (capability.ts).
|
|
15
|
+
*/
|
|
16
|
+
/** The wire profile this SDK implements (§34). */
|
|
17
|
+
export declare const WIRE_PROFILE = "LFCP-WIRE-01";
|
|
18
|
+
/**
|
|
19
|
+
* A source of random bytes. Production code leaves it out (the platform
|
|
20
|
+
* CSPRNG, core secureRandom). Passing one is FOR TESTS ONLY: reproducible
|
|
21
|
+
* nonces and session IDs defeat the handshake's freshness.
|
|
22
|
+
*/
|
|
23
|
+
export type RandomSource = (length: number) => Uint8Array;
|
|
24
|
+
/** The values the §36 transcript binds. */
|
|
25
|
+
export interface AuthTranscriptFields {
|
|
26
|
+
readonly sessionId: Uint8Array;
|
|
27
|
+
readonly clientNonce: Uint8Array;
|
|
28
|
+
readonly serverNonce: Uint8Array;
|
|
29
|
+
readonly serverId: Uint8Array;
|
|
30
|
+
readonly principalId: PrincipalId;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* §36: deterministic CBOR of ["LFCP-AUTH-v1", session_id, client_nonce,
|
|
34
|
+
* server_nonce, server_id, principal_id]; nothing else.
|
|
35
|
+
*/
|
|
36
|
+
export declare function authTranscript(f: AuthTranscriptFields): Uint8Array;
|
|
37
|
+
/** Decodes an auth transcript strictly: deterministic CBOR, the label, six elements, exact sizes. */
|
|
38
|
+
export declare function decodeAuthTranscript(bytes: Uint8Array): AuthTranscriptFields;
|
|
39
|
+
/** The AUTH proof: a §10 COSE_Sign1 by the session Principal over the exact transcript. */
|
|
40
|
+
export declare function signAuthProof(fields: AuthTranscriptFields, signer: Signer): Uint8Array;
|
|
41
|
+
export type AuthProofCheck = {
|
|
42
|
+
readonly valid: true;
|
|
43
|
+
} | {
|
|
44
|
+
readonly valid: false;
|
|
45
|
+
readonly reason: "MALFORMED" | "TRANSCRIPT_MISMATCH" | "KID_MISMATCH" | "BAD_SIGNATURE";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* §36: the proof must be a §10 signed object whose kid is the HELLO
|
|
49
|
+
* Principal, whose payload is exactly this session's transcript, and whose
|
|
50
|
+
* signature verifies (§10.5.1). Every failure is AUTH_FAILED (G-MSG4).
|
|
51
|
+
*/
|
|
52
|
+
export declare function verifyAuthProof(proof: Uint8Array, expected: AuthTranscriptFields, principal: PrincipalDescriptor): AuthProofCheck;
|
|
53
|
+
/** The result of a successful handshake: who the session is, never what it may do. */
|
|
54
|
+
export interface AuthenticatedSession {
|
|
55
|
+
readonly principal: PrincipalDescriptor;
|
|
56
|
+
readonly wireProfile: string;
|
|
57
|
+
readonly sessionId: Uint8Array;
|
|
58
|
+
readonly serverId: Uint8Array;
|
|
59
|
+
readonly dataProfiles?: readonly string[];
|
|
60
|
+
/** The opaque hosting/account credential from AUTH: server policy only, not Resource authority. */
|
|
61
|
+
readonly credential?: Uint8Array;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* §34 profile negotiation: the first profile the client offers that the
|
|
65
|
+
* server supports, or undefined.
|
|
66
|
+
*/
|
|
67
|
+
export declare const selectWireProfile: (offered: readonly string[], supported: readonly string[]) => string | undefined;
|
|
68
|
+
/** Whether a message type is a Resource, Control, Data, Key, Snapshot or Presence message. */
|
|
69
|
+
export declare const isResourceMessage: (type: AnyMessage["type"]) => boolean;
|
|
70
|
+
export interface ServerHandshakeConfig {
|
|
71
|
+
/** The stable 32-byte server ID, from server state; never derived from host, TLS or session values. */
|
|
72
|
+
readonly serverId: Uint8Array;
|
|
73
|
+
readonly wireProfiles: readonly string[];
|
|
74
|
+
readonly maxMessageBytes: bigint;
|
|
75
|
+
/** §37 durability level 0-3; an ACK must never claim more. */
|
|
76
|
+
readonly durability: bigint;
|
|
77
|
+
readonly heartbeatMs: bigint;
|
|
78
|
+
readonly extensions?: readonly string[];
|
|
79
|
+
/** TESTS ONLY (see RandomSource). */
|
|
80
|
+
readonly random?: RandomSource;
|
|
81
|
+
}
|
|
82
|
+
export type ServerSession = {
|
|
83
|
+
readonly phase: "WAIT_HELLO";
|
|
84
|
+
} | {
|
|
85
|
+
readonly phase: "WAIT_AUTH";
|
|
86
|
+
readonly principal: PrincipalDescriptor;
|
|
87
|
+
readonly clientNonce: Uint8Array;
|
|
88
|
+
readonly dataProfiles?: readonly string[];
|
|
89
|
+
readonly wireProfile: string;
|
|
90
|
+
readonly serverNonce: Uint8Array;
|
|
91
|
+
readonly sessionId: Uint8Array;
|
|
92
|
+
} | {
|
|
93
|
+
readonly phase: "READY";
|
|
94
|
+
readonly session: AuthenticatedSession;
|
|
95
|
+
} | {
|
|
96
|
+
readonly phase: "CLOSED";
|
|
97
|
+
readonly reason: string;
|
|
98
|
+
};
|
|
99
|
+
/** What the server does after one received message. */
|
|
100
|
+
export interface ServerStep {
|
|
101
|
+
readonly session: ServerSession;
|
|
102
|
+
/** Messages to send, in order. */
|
|
103
|
+
readonly send: readonly AnyMessage[];
|
|
104
|
+
/** Close the connection after sending. */
|
|
105
|
+
readonly close: boolean;
|
|
106
|
+
/** A READY-state message for the server's Resource logic. */
|
|
107
|
+
readonly deliver?: AnyMessage;
|
|
108
|
+
}
|
|
109
|
+
/** §64: ACCEPTED moves straight to WAIT_HELLO. */
|
|
110
|
+
export declare const startServerSession: () => ServerSession;
|
|
111
|
+
/**
|
|
112
|
+
* One received message on a server session (§34-§37, §64). Before READY:
|
|
113
|
+
* PING is answered with PONG, PONG and ERROR are accepted (G-SM4); Resource,
|
|
114
|
+
* Control, Data, Key, Snapshot and Presence messages get
|
|
115
|
+
* NACK(AUTHORIZATION_FAILED) (G-MSG7); any other out-of-order message is a
|
|
116
|
+
* protocol violation: ERROR(MALFORMED_MESSAGE) and close. An invalid HELLO
|
|
117
|
+
* descriptor or any AUTH proof failure is ERROR(AUTH_FAILED) and close
|
|
118
|
+
* (P3, G-MSG4); no common profile is ERROR(PROTOCOL_UNSUPPORTED) and close.
|
|
119
|
+
*/
|
|
120
|
+
export declare function serverReceive(session: ServerSession, message: AnyMessage, config: ServerHandshakeConfig): ServerStep;
|
|
121
|
+
export interface ClientHandshakeConfig {
|
|
122
|
+
readonly signer: Signer;
|
|
123
|
+
readonly wireProfiles?: readonly string[];
|
|
124
|
+
readonly dataProfiles?: readonly string[];
|
|
125
|
+
/** An opaque hosting/account credential for AUTH (§36); server policy only. */
|
|
126
|
+
readonly credential?: Uint8Array;
|
|
127
|
+
/** The server ID the client expects, when it knows it (e.g. from a Route Manifest). */
|
|
128
|
+
readonly expectedServerId?: Uint8Array;
|
|
129
|
+
/** TESTS ONLY (see RandomSource). */
|
|
130
|
+
readonly random?: RandomSource;
|
|
131
|
+
}
|
|
132
|
+
/** The parameters READY grants the session (§37). */
|
|
133
|
+
export interface ReadySession {
|
|
134
|
+
readonly wireProfile: string;
|
|
135
|
+
readonly serverId: Uint8Array;
|
|
136
|
+
readonly sessionId: Uint8Array;
|
|
137
|
+
readonly maxMessageBytes: bigint;
|
|
138
|
+
readonly durability: bigint;
|
|
139
|
+
readonly heartbeatMs: bigint;
|
|
140
|
+
readonly extensions: readonly string[];
|
|
141
|
+
}
|
|
142
|
+
export type ClientSession = {
|
|
143
|
+
readonly phase: "NEGOTIATING";
|
|
144
|
+
readonly hello: LfcpMessage<"HELLO">;
|
|
145
|
+
} | {
|
|
146
|
+
readonly phase: "AUTHENTICATING";
|
|
147
|
+
readonly hello: LfcpMessage<"HELLO">;
|
|
148
|
+
readonly wireProfile: string;
|
|
149
|
+
readonly serverId: Uint8Array;
|
|
150
|
+
readonly sessionId: Uint8Array;
|
|
151
|
+
} | {
|
|
152
|
+
readonly phase: "READY";
|
|
153
|
+
readonly ready: ReadySession;
|
|
154
|
+
} | {
|
|
155
|
+
readonly phase: "DISCONNECTED";
|
|
156
|
+
readonly reason: string;
|
|
157
|
+
};
|
|
158
|
+
export interface ClientStep {
|
|
159
|
+
readonly session: ClientSession;
|
|
160
|
+
readonly send: readonly AnyMessage[];
|
|
161
|
+
readonly close: boolean;
|
|
162
|
+
readonly deliver?: AnyMessage;
|
|
163
|
+
}
|
|
164
|
+
/** Starts the handshake once the WebSocket with lfcp-1 is open: the HELLO to send (§34). */
|
|
165
|
+
export declare function startClientHandshake(config: ClientHandshakeConfig): ClientStep;
|
|
166
|
+
/**
|
|
167
|
+
* One received message on a client session. CHALLENGE must select a
|
|
168
|
+
* profile the client offered (else PROTOCOL_UNSUPPORTED) and, when the
|
|
169
|
+
* client expects one, name its server ID; the client then signs the
|
|
170
|
+
* transcript. READY must repeat the selected profile and the CHALLENGE's
|
|
171
|
+
* server ID (else MALFORMED_MESSAGE). An ERROR before READY ends the
|
|
172
|
+
* handshake. Failures close the connection.
|
|
173
|
+
*/
|
|
174
|
+
export declare function clientReceive(session: ClientSession, message: AnyMessage, config: ClientHandshakeConfig): ClientStep;
|
|
175
|
+
//# sourceMappingURL=handshake.d.ts.map
|