wire-mesh-core 0.0.0 → 1.0.2
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 +32 -0
- package/dist/adapters/frame-codec.cjs +37 -0
- package/dist/adapters/frame-codec.d.cts +12 -0
- package/dist/adapters/frame-codec.d.mts +12 -0
- package/dist/adapters/frame-codec.mjs +33 -0
- package/dist/adapters/memory-storage.cjs +14 -0
- package/dist/adapters/memory-storage.d.cts +5 -0
- package/dist/adapters/memory-storage.d.mts +5 -0
- package/dist/adapters/memory-storage.mjs +13 -0
- package/dist/adapters/node-identity.cjs +55 -0
- package/dist/adapters/node-identity.d.cts +11 -0
- package/dist/adapters/node-identity.d.mts +11 -0
- package/dist/adapters/node-identity.mjs +52 -0
- package/dist/adapters/system-clock.cjs +8 -0
- package/dist/adapters/system-clock.d.cts +5 -0
- package/dist/adapters/system-clock.d.mts +5 -0
- package/dist/adapters/system-clock.mjs +7 -0
- package/dist/adapters/tcp-transport.cjs +154 -0
- package/dist/adapters/tcp-transport.d.cts +5 -0
- package/dist/adapters/tcp-transport.d.mts +5 -0
- package/dist/adapters/tcp-transport.mjs +153 -0
- package/dist/adapters/tls-transport.cjs +178 -0
- package/dist/adapters/tls-transport.d.cts +9 -0
- package/dist/adapters/tls-transport.d.mts +9 -0
- package/dist/adapters/tls-transport.mjs +177 -0
- package/dist/clock-DiSx-WKM.d.cts +7 -0
- package/dist/clock-DiSx-WKM.d.mts +7 -0
- package/dist/domain/device-id.cjs +27 -0
- package/dist/domain/device-id.d.cts +9 -0
- package/dist/domain/device-id.d.mts +9 -0
- package/dist/domain/device-id.mjs +24 -0
- package/dist/domain/handshake.cjs +23 -0
- package/dist/domain/handshake.d.cts +16 -0
- package/dist/domain/handshake.d.mts +16 -0
- package/dist/domain/handshake.mjs +21 -0
- package/dist/domain/mesh-session.cjs +439 -0
- package/dist/domain/mesh-session.d.cts +101 -0
- package/dist/domain/mesh-session.d.mts +101 -0
- package/dist/domain/mesh-session.mjs +436 -0
- package/dist/domain/relay-hub.cjs +99 -0
- package/dist/domain/relay-hub.d.cts +10 -0
- package/dist/domain/relay-hub.d.mts +10 -0
- package/dist/domain/relay-hub.mjs +98 -0
- package/dist/domain/revocation-view.cjs +22 -0
- package/dist/domain/revocation-view.d.cts +12 -0
- package/dist/domain/revocation-view.d.mts +12 -0
- package/dist/domain/revocation-view.mjs +21 -0
- package/dist/domain/tokens.cjs +300 -0
- package/dist/domain/tokens.d.cts +86 -0
- package/dist/domain/tokens.d.mts +86 -0
- package/dist/domain/tokens.mjs +296 -0
- package/dist/generated/protocol.cjs +466 -0
- package/dist/generated/protocol.d.cts +2 -0
- package/dist/generated/protocol.d.mts +2 -0
- package/dist/generated/protocol.mjs +382 -0
- package/dist/generated/runtime.cjs +8 -0
- package/dist/generated/runtime.d.cts +2 -0
- package/dist/generated/runtime.d.mts +2 -0
- package/dist/generated/runtime.mjs +2 -0
- package/dist/identity-BRLEUfVY.d.cts +17 -0
- package/dist/identity-qNkmGytv.d.mts +17 -0
- package/dist/ports/clock.cjs +0 -0
- package/dist/ports/clock.d.cts +2 -0
- package/dist/ports/clock.d.mts +2 -0
- package/dist/ports/clock.mjs +1 -0
- package/dist/ports/identity.cjs +0 -0
- package/dist/ports/identity.d.cts +2 -0
- package/dist/ports/identity.d.mts +2 -0
- package/dist/ports/identity.mjs +1 -0
- package/dist/ports/storage.cjs +0 -0
- package/dist/ports/storage.d.cts +9 -0
- package/dist/ports/storage.d.mts +9 -0
- package/dist/ports/storage.mjs +1 -0
- package/dist/ports/transport.cjs +0 -0
- package/dist/ports/transport.d.cts +29 -0
- package/dist/ports/transport.d.mts +29 -0
- package/dist/ports/transport.mjs +1 -0
- package/dist/protocol-B26-5VX7.d.cts +1112 -0
- package/dist/protocol-B26-5VX7.d.mts +1112 -0
- package/package.json +130 -2
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { capabilityTokenSchema, revocationClaimsSchema, tokenClaimsSchema } from "../generated/protocol.mjs";
|
|
2
|
+
import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
|
|
3
|
+
//#region src/domain/tokens.ts
|
|
4
|
+
function bytesEqual(a, b) {
|
|
5
|
+
if (a.length !== b.length) return false;
|
|
6
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
/** True when the path contains a "." or ".." segment. Purely lexical prefix comparison would let "/work/../org" pass under "/work" -- a path that normalises outside the parent -- so any relative segment fails the narrowing comparison wholesale: fail-closed rather than reimplementing path normalisation, consistent with how empty, case-different, and non-boundary-prefixed paths already behave. */
|
|
10
|
+
function hasRelativeSegment(path) {
|
|
11
|
+
return path.split("/").some((segment) => segment === "." || segment === "..");
|
|
12
|
+
}
|
|
13
|
+
/** True when childPath is parentPath or a descendant of it, compared on "/"-segment boundaries: "/work/sub" narrows "/work", but "/workbook" does NOT narrow "/work" despite the string prefix, because "book" continues the same segment. Paths containing "." or ".." segments never narrow anything (see hasRelativeSegment). */
|
|
14
|
+
function pathNarrows(childPath, parentPath) {
|
|
15
|
+
if (hasRelativeSegment(childPath) || hasRelativeSegment(parentPath)) return false;
|
|
16
|
+
if (childPath === parentPath) return true;
|
|
17
|
+
if (!childPath.startsWith(parentPath)) return false;
|
|
18
|
+
if (parentPath.endsWith("/")) return true;
|
|
19
|
+
return childPath.charAt(parentPath.length) === "/";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* True when childScope narrows parentScope per tokens.cddl ("each hop can only narrow authority, never widen it"): the kind must be identical (a different kind is a different kind of authority, not a narrower one), and a parent with a path requires the child to carry an equal-or-descendant path -- an absent child path means the kind's whole-scope root, which is wider than any path-narrowed parent. A parent with no path (whole-scope root) lets any child path under the same kind through.
|
|
23
|
+
*/
|
|
24
|
+
function scopeNarrows(parent, child) {
|
|
25
|
+
if (parent.kind !== child.kind) return false;
|
|
26
|
+
if (parent.path === void 0) return true;
|
|
27
|
+
if (child.path === void 0) return false;
|
|
28
|
+
return pathNarrows(child.path, parent.path);
|
|
29
|
+
}
|
|
30
|
+
/** RFC 9052 §4.4 Sig_structure for a COSE_Sign1 with no external AAD: ["Signature1", protected, external_aad, payload]. */
|
|
31
|
+
function sig1ToBeSigned(protectedHeader, payload) {
|
|
32
|
+
return encode([
|
|
33
|
+
"Signature1",
|
|
34
|
+
protectedHeader,
|
|
35
|
+
/* @__PURE__ */ new Uint8Array(0),
|
|
36
|
+
payload
|
|
37
|
+
], cdeEncodeOptions);
|
|
38
|
+
}
|
|
39
|
+
/** Normalises cbor2's encode() (and any other Uint8Array<ArrayBufferLike>-typed construction) to a fresh, non-shared, whole-buffer Uint8Array<ArrayBuffer> -- what the generated schemas' concrete-typed fields require. */
|
|
40
|
+
function buf(bytes) {
|
|
41
|
+
return Uint8Array.from(bytes);
|
|
42
|
+
}
|
|
43
|
+
function encodeBuf(value) {
|
|
44
|
+
return buf(encode(value, cdeEncodeOptions));
|
|
45
|
+
}
|
|
46
|
+
/** The COSE protected header every capability-token/revocation-entry envelope in this codebase actually signs over: label 1 (alg) and label 4 (kid, the issuer's own device-id) -- matching the frozen conformance vectors, not the empty header a token merely needs to verify against itself. */
|
|
47
|
+
function protectedHeaderFor(identity) {
|
|
48
|
+
return encodeBuf({
|
|
49
|
+
1: identity.identityKey.alg,
|
|
50
|
+
4: identity.deviceId
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Decodes and validates a parent token's own claims from its raw CapabilityToken tuple -- the same decode `verifyTokenChain` performs on `claims.parent`, extracted here so mint can check narrowing against a parent's real claims without duplicating the CBOR/schema plumbing. Returns undefined for anything that doesn't parse; the caller turns that into its own refusal reason since "malformed" means something different at mint time than at verify time. */
|
|
54
|
+
function decodeTokenClaims(token) {
|
|
55
|
+
const [, , payload] = token;
|
|
56
|
+
if (payload === null) return void 0;
|
|
57
|
+
let decoded;
|
|
58
|
+
try {
|
|
59
|
+
decoded = decode(payload, cdeDecodeOptions);
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const result = tokenClaimsSchema.safeParse(decoded);
|
|
64
|
+
return result.success ? result.data : void 0;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Verifies one capability token per tokens.cddl's own documented rules: the token is a well-formed COSE_Sign1 whose signature actually verifies against its own embedded issuer-key, that issuer-key is self-certifying (sha256(issuer-key.public-key) equals the claimed issuer device-id -- no shared secret needed to check this), the token is currently valid (not expired, not before not-before, not revoked by its own issuer), and -- recursively -- any parent delegation narrows rather than widens across all three axes of authority: the parent's bearer must be this token's issuer (the delegation chain is unbroken), this token's expiry must not exceed its parent's, and this token's scope must narrow its parent's (same kind; equal-or-descendant path when the parent carries one) with an identical capability verb (the capability-verb grammar has no sub-verb relation, so a different verb is a different authority, not a narrower one). Undecodable payload bytes return "malformed" and undecodable parent bytes return "parent_invalid" -- hostile input produces a verdict, never a throw.
|
|
68
|
+
*/
|
|
69
|
+
async function verifyCapabilityToken(token, options) {
|
|
70
|
+
const verdict = await verifyTokenChain(token, {
|
|
71
|
+
identity: options.identity,
|
|
72
|
+
clock: options.clock,
|
|
73
|
+
revocation: options.revocation
|
|
74
|
+
});
|
|
75
|
+
if (!verdict.ok) return verdict;
|
|
76
|
+
if (options.expectedBearer !== void 0 && !bytesEqual(verdict.claims.bearer, options.expectedBearer)) return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: "bearer_mismatch"
|
|
79
|
+
};
|
|
80
|
+
return verdict;
|
|
81
|
+
}
|
|
82
|
+
async function verifyTokenChain(token, options) {
|
|
83
|
+
const [protectedHeader, , payload, signature] = token;
|
|
84
|
+
if (payload === null) return {
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: "malformed"
|
|
87
|
+
};
|
|
88
|
+
let decodedClaims;
|
|
89
|
+
try {
|
|
90
|
+
decodedClaims = decode(payload, cdeDecodeOptions);
|
|
91
|
+
} catch {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
reason: "malformed"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const claimsResult = tokenClaimsSchema.safeParse(decodedClaims);
|
|
98
|
+
if (!claimsResult.success) return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: "malformed"
|
|
101
|
+
};
|
|
102
|
+
const claims = claimsResult.data;
|
|
103
|
+
if (!await options.identity.verify(claims["issuer-key"], sig1ToBeSigned(protectedHeader, payload), signature)) return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: "bad_signature"
|
|
106
|
+
};
|
|
107
|
+
if (!bytesEqual(await options.identity.deriveDeviceId(claims["issuer-key"]["public-key"]), claims.issuer)) return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: "wrong_issuer"
|
|
110
|
+
};
|
|
111
|
+
const now = options.clock.now();
|
|
112
|
+
if (claims.expires <= now) return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: "expired"
|
|
115
|
+
};
|
|
116
|
+
if (claims["not-before"] !== void 0 && claims["not-before"] > now) return {
|
|
117
|
+
ok: false,
|
|
118
|
+
reason: "not_yet_valid"
|
|
119
|
+
};
|
|
120
|
+
if (await options.revocation.isRevoked(claims["token-id"], claims.issuer)) return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: "revoked"
|
|
123
|
+
};
|
|
124
|
+
if (claims.parent !== void 0) {
|
|
125
|
+
let decodedParent;
|
|
126
|
+
try {
|
|
127
|
+
decodedParent = decode(claims.parent, cdeDecodeOptions);
|
|
128
|
+
} catch {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
reason: "parent_invalid"
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const parentResult = capabilityTokenSchema.safeParse(decodedParent);
|
|
135
|
+
if (!parentResult.success) return {
|
|
136
|
+
ok: false,
|
|
137
|
+
reason: "parent_invalid"
|
|
138
|
+
};
|
|
139
|
+
const parentVerdict = await verifyTokenChain(parentResult.data, options);
|
|
140
|
+
if (!parentVerdict.ok) return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: "parent_invalid"
|
|
143
|
+
};
|
|
144
|
+
if (!bytesEqual(parentVerdict.claims.bearer, claims.issuer)) return {
|
|
145
|
+
ok: false,
|
|
146
|
+
reason: "delegation_exceeds_parent"
|
|
147
|
+
};
|
|
148
|
+
if (claims.expires > parentVerdict.claims.expires) return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: "delegation_exceeds_parent"
|
|
151
|
+
};
|
|
152
|
+
if (!scopeNarrows(parentVerdict.claims.scope, claims.scope)) return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: "delegation_exceeds_parent"
|
|
155
|
+
};
|
|
156
|
+
if (parentVerdict.claims.capability !== claims.capability) return {
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: "delegation_exceeds_parent"
|
|
159
|
+
};
|
|
160
|
+
const parentRemaining = parentVerdict.claims["delegations-remaining"];
|
|
161
|
+
if (parentRemaining !== void 0 && (claims["delegations-remaining"] === void 0 || claims["delegations-remaining"] >= parentRemaining)) return {
|
|
162
|
+
ok: false,
|
|
163
|
+
reason: "delegation_exceeds_parent"
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
claims,
|
|
168
|
+
rootIssuer: parentVerdict.rootIssuer,
|
|
169
|
+
depth: parentVerdict.depth + 1
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
claims,
|
|
175
|
+
rootIssuer: claims.issuer,
|
|
176
|
+
depth: 0
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Verifies one gossiped revocation-entry (management.cddl): a well-formed COSE_Sign1 whose signature verifies against its own embedded issuer-key, where that issuer-key is self-certifying (sha256(issuer-key.public-key) equals the claimed issuer device-id). A verifier that ingests a revocation-announce frame runs each entry through this before recording it in its revocation view; entries failing here are dropped, not stored. The issuer-match against a specific token's own issuer (only a token's own issuer may revoke it) is deliberately NOT checked here -- it happens at lookup time in RevocationCheck, against whichever token is being verified.
|
|
181
|
+
*/
|
|
182
|
+
async function verifyRevocationEntry(entry, options) {
|
|
183
|
+
const [protectedHeader, , payload, signature] = entry;
|
|
184
|
+
if (payload === null) return {
|
|
185
|
+
ok: false,
|
|
186
|
+
reason: "malformed"
|
|
187
|
+
};
|
|
188
|
+
let decodedClaims;
|
|
189
|
+
try {
|
|
190
|
+
decodedClaims = decode(payload, cdeDecodeOptions);
|
|
191
|
+
} catch {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
reason: "malformed"
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const claimsResult = revocationClaimsSchema.safeParse(decodedClaims);
|
|
198
|
+
if (!claimsResult.success) return {
|
|
199
|
+
ok: false,
|
|
200
|
+
reason: "malformed"
|
|
201
|
+
};
|
|
202
|
+
const claims = claimsResult.data;
|
|
203
|
+
if (!await options.identity.verify(claims["issuer-key"], sig1ToBeSigned(protectedHeader, payload), signature)) return {
|
|
204
|
+
ok: false,
|
|
205
|
+
reason: "bad_signature"
|
|
206
|
+
};
|
|
207
|
+
if (!bytesEqual(await options.identity.deriveDeviceId(claims["issuer-key"]["public-key"]), claims.issuer)) return {
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: "wrong_issuer"
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
claims
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Mints one capability token: builds token-claims from the given fields, signs it as a COSE_Sign1 under `identity`'s own key, with a protected header matching what the frozen conformance vectors actually encode (`{1: alg, 4: issuer device-id}`, not the empty header a token merely needs to verify against itself).
|
|
218
|
+
*
|
|
219
|
+
* When `parent` is given, every one of `tokens.cddl`'s own narrowing obligations is enforced here, at issuance, rather than left for the far end to discover minutes or hours later as a bare `delegation_exceeds_parent` from `verifyCapabilityToken` -- the same "fail loudly, fail early" reasoning that governs every other boundary in this codebase. An issuer minting an invalid delegation is a bug in the caller; this function refuses rather than producing a token indistinguishable from a valid one until someone else verifies it.
|
|
220
|
+
*/
|
|
221
|
+
async function mintCapabilityToken(options) {
|
|
222
|
+
if (options.expires <= options.clock.now()) return {
|
|
223
|
+
ok: false,
|
|
224
|
+
reason: "already_expired"
|
|
225
|
+
};
|
|
226
|
+
let parentBytes;
|
|
227
|
+
if (options.parent !== void 0) {
|
|
228
|
+
const parentClaims = decodeTokenClaims(options.parent);
|
|
229
|
+
if (parentClaims === void 0) return {
|
|
230
|
+
ok: false,
|
|
231
|
+
reason: "parent_malformed"
|
|
232
|
+
};
|
|
233
|
+
if (!bytesEqual(parentClaims.bearer, options.identity.deviceId)) return {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: "parent_bearer_mismatch"
|
|
236
|
+
};
|
|
237
|
+
if (options.expires > parentClaims.expires) return {
|
|
238
|
+
ok: false,
|
|
239
|
+
reason: "expires_exceeds_parent"
|
|
240
|
+
};
|
|
241
|
+
if (!scopeNarrows(parentClaims.scope, options.scope)) return {
|
|
242
|
+
ok: false,
|
|
243
|
+
reason: "scope_does_not_narrow"
|
|
244
|
+
};
|
|
245
|
+
if (parentClaims.capability !== options.capability) return {
|
|
246
|
+
ok: false,
|
|
247
|
+
reason: "capability_mismatch"
|
|
248
|
+
};
|
|
249
|
+
const parentRemaining = parentClaims["delegations-remaining"];
|
|
250
|
+
if (parentRemaining !== void 0 && (options.delegationsRemaining === void 0 || options.delegationsRemaining >= parentRemaining)) return {
|
|
251
|
+
ok: false,
|
|
252
|
+
reason: "delegation_exceeds_parent"
|
|
253
|
+
};
|
|
254
|
+
parentBytes = encodeBuf(options.parent);
|
|
255
|
+
}
|
|
256
|
+
const payload = encodeBuf({
|
|
257
|
+
"token-id": options.tokenId,
|
|
258
|
+
issuer: options.identity.deviceId,
|
|
259
|
+
"issuer-key": options.identity.identityKey,
|
|
260
|
+
bearer: options.bearer,
|
|
261
|
+
capability: options.capability,
|
|
262
|
+
scope: options.scope,
|
|
263
|
+
expires: options.expires,
|
|
264
|
+
...options.notBefore !== void 0 ? { "not-before": options.notBefore } : {},
|
|
265
|
+
...parentBytes !== void 0 ? { parent: parentBytes } : {},
|
|
266
|
+
...options.delegationsRemaining !== void 0 ? { "delegations-remaining": options.delegationsRemaining } : {}
|
|
267
|
+
});
|
|
268
|
+
const protectedHeader = protectedHeaderFor(options.identity);
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
token: [
|
|
272
|
+
protectedHeader,
|
|
273
|
+
{},
|
|
274
|
+
payload,
|
|
275
|
+
await options.identity.sign(sig1ToBeSigned(protectedHeader, payload))
|
|
276
|
+
]
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Mints one revocation-entry (management.cddl): a COSE_Sign1 over revocation-claims, signed the same way mintCapabilityToken signs a token. No narrowing chain to check -- a revocation entry has no parent and cannot fail to be issuable the way a delegated token can, so this returns the entry directly rather than a verdict. */
|
|
280
|
+
async function mintRevocationEntry(options) {
|
|
281
|
+
const payload = encodeBuf({
|
|
282
|
+
"token-id": options.tokenId,
|
|
283
|
+
issuer: options.identity.deviceId,
|
|
284
|
+
"issuer-key": options.identity.identityKey,
|
|
285
|
+
"revoked-at": options.revokedAt
|
|
286
|
+
});
|
|
287
|
+
const protectedHeader = protectedHeaderFor(options.identity);
|
|
288
|
+
return [
|
|
289
|
+
protectedHeader,
|
|
290
|
+
{},
|
|
291
|
+
payload,
|
|
292
|
+
await options.identity.sign(sig1ToBeSigned(protectedHeader, payload))
|
|
293
|
+
];
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
export { mintCapabilityToken, mintRevocationEntry, verifyCapabilityToken, verifyRevocationEntry };
|