@fkn/package-manifest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/build/fixtures.cjs +122 -0
- package/build/fixtures.d.ts +48 -0
- package/build/fixtures.js +121 -0
- package/build/index.cjs +462 -0
- package/build/index.d.ts +144 -0
- package/build/index.js +438 -0
- package/fixtures/vectors.json +261 -0
- package/package.json +56 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
/** The derivation version, the leading character of every fingerprint and app id. */
|
|
5
|
+
var APP_ID_VERSION = "1";
|
|
6
|
+
/** RFC 4648 base32, lowercase, no padding. Lowercase because DNS folds case. */
|
|
7
|
+
var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
8
|
+
/** The whole app id grammar: 61 characters. The api refuses anything else under `fkn:`. */
|
|
9
|
+
var APP_ID = /^fkn:app:1[a-z2-7]{52}$/;
|
|
10
|
+
/** A root fingerprint: the same 53 characters without the `fkn:app:` prefix. */
|
|
11
|
+
var ROOT_FINGERPRINT = /^1[a-z2-7]{52}$/;
|
|
12
|
+
/** An app slug: 1 to 32 characters, permanent. A new slug is a new app. */
|
|
13
|
+
var SLUG = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
14
|
+
/** How far ahead of the verifier's clock an `issuedAt` may sit, so CI and devices judge alike. */
|
|
15
|
+
var ISSUED_AT_TOLERANCE_SECONDS = 300;
|
|
16
|
+
/** The cap on a manifest's UTF-8 length, matching the broker's fetch cap. */
|
|
17
|
+
var MANIFEST_MAX_BYTES = 16384;
|
|
18
|
+
/** The label every signature is domain separated by. */
|
|
19
|
+
var LABELS = {
|
|
20
|
+
keys: "fkn/app/v1/keys",
|
|
21
|
+
manifest: "fkn/app/v1/manifest",
|
|
22
|
+
succeed: "fkn/app/v1/succeed",
|
|
23
|
+
register: "fkn/app/v1/register"
|
|
24
|
+
};
|
|
25
|
+
var refuse = (code, message) => ({
|
|
26
|
+
ok: false,
|
|
27
|
+
code,
|
|
28
|
+
message
|
|
29
|
+
});
|
|
30
|
+
var encoder = new TextEncoder();
|
|
31
|
+
var utf8 = (text) => encoder.encode(text);
|
|
32
|
+
var concat = (...parts) => {
|
|
33
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
34
|
+
const out = new Uint8Array(total);
|
|
35
|
+
let at = 0;
|
|
36
|
+
for (const part of parts) {
|
|
37
|
+
out.set(part, at);
|
|
38
|
+
at += part.length;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
var compareBytes = (a, b) => {
|
|
43
|
+
const shared = Math.min(a.length, b.length);
|
|
44
|
+
for (let i = 0; i < shared; i++) {
|
|
45
|
+
const left = a[i];
|
|
46
|
+
const right = b[i];
|
|
47
|
+
if (left !== right) return left - right;
|
|
48
|
+
}
|
|
49
|
+
return a.length - b.length;
|
|
50
|
+
};
|
|
51
|
+
var sameBytes = (a, b) => compareBytes(a, b) === 0;
|
|
52
|
+
var isBytes = (value) => value instanceof Uint8Array;
|
|
53
|
+
/** RFC 4648 base32, lowercase, no padding. 32 bytes give exactly 52 characters. */
|
|
54
|
+
var base32 = (bytes) => {
|
|
55
|
+
if (!isBytes(bytes)) throw new TypeError("base32 takes bytes");
|
|
56
|
+
let value = 0;
|
|
57
|
+
let bits = 0;
|
|
58
|
+
let out = "";
|
|
59
|
+
for (const byte of bytes) {
|
|
60
|
+
value = value << 8 | byte;
|
|
61
|
+
bits += 8;
|
|
62
|
+
while (bits >= 5) {
|
|
63
|
+
bits -= 5;
|
|
64
|
+
out += BASE32_ALPHABET[value >>> bits & 31];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (bits > 0) out += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
71
|
+
/** Standard base64, padded. The one spelling anything derived from bytes is stored or signed in. */
|
|
72
|
+
var base64 = (bytes) => {
|
|
73
|
+
let out = "";
|
|
74
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
75
|
+
const a = bytes[i];
|
|
76
|
+
const b = bytes[i + 1];
|
|
77
|
+
const c = bytes[i + 2];
|
|
78
|
+
out += BASE64_ALPHABET[a >>> 2];
|
|
79
|
+
out += BASE64_ALPHABET[(a & 3) << 4 | (b ?? 0) >>> 4];
|
|
80
|
+
out += b === void 0 ? "=" : BASE64_ALPHABET[(b & 15) << 2 | (c ?? 0) >>> 6];
|
|
81
|
+
out += c === void 0 ? "=" : BASE64_ALPHABET[c & 63];
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Standard base64 to bytes, or null. Padding is optional, so an unpadded input decodes; the
|
|
87
|
+
* url-safe alphabet is NOT accepted, because one key must have one spelling.
|
|
88
|
+
*/
|
|
89
|
+
var fromBase64 = (text) => {
|
|
90
|
+
if (typeof text !== "string") return null;
|
|
91
|
+
const body = text.replace(/={0,2}$/, "");
|
|
92
|
+
if (body.length % 4 === 1) return null;
|
|
93
|
+
const out = new Uint8Array(Math.floor(body.length * 6 / 8));
|
|
94
|
+
let value = 0;
|
|
95
|
+
let bits = 0;
|
|
96
|
+
let at = 0;
|
|
97
|
+
for (const char of body) {
|
|
98
|
+
const index = BASE64_ALPHABET.indexOf(char);
|
|
99
|
+
if (index === -1) return null;
|
|
100
|
+
value = value << 6 | index;
|
|
101
|
+
bits += 6;
|
|
102
|
+
if (bits >= 8) {
|
|
103
|
+
bits -= 8;
|
|
104
|
+
out[at++] = value >>> bits & 255;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
};
|
|
109
|
+
var bytesOfLength = (value, length) => {
|
|
110
|
+
if (typeof value !== "string") return null;
|
|
111
|
+
const bytes = fromBase64(value);
|
|
112
|
+
return bytes !== null && bytes.length === length ? bytes : null;
|
|
113
|
+
};
|
|
114
|
+
/** `1` + base32(sha256(root)). Throws unless the root is 32 bytes. */
|
|
115
|
+
var rootFingerprintOf = (rootPub) => {
|
|
116
|
+
if (!isBytes(rootPub) || rootPub.length !== 32) throw new TypeError("A root public key is 32 bytes");
|
|
117
|
+
return "1" + base32(sha256(rootPub));
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* `fkn:app:1` + base32(sha256(root || 0x00 || utf8(slug))). Throws on a root that is not 32 bytes
|
|
121
|
+
* or a slug outside the grammar. The id commits to the root, so a different root for one slug is
|
|
122
|
+
* simply a different app.
|
|
123
|
+
*/
|
|
124
|
+
var appIdOf = (rootPub, slug) => {
|
|
125
|
+
if (!isBytes(rootPub) || rootPub.length !== 32) throw new TypeError("A root public key is 32 bytes");
|
|
126
|
+
if (typeof slug !== "string" || !SLUG.test(slug)) throw new TypeError(`'${String(slug)}' is not an app slug`);
|
|
127
|
+
return `fkn:app:1${base32(sha256(concat(rootPub, new Uint8Array([0]), utf8(slug))))}`;
|
|
128
|
+
};
|
|
129
|
+
var NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
130
|
+
var NPM_VERSION = /^[0-9A-Za-z.+-]{1,64}$/;
|
|
131
|
+
var GH_REPO = /^[A-Za-z0-9-_.]+\/[A-Za-z0-9-_.]+$/;
|
|
132
|
+
var GH_REF = /^[A-Za-z0-9-_./]{1,64}$/;
|
|
133
|
+
var HTTPS_HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+(:\d{1,5})?$/;
|
|
134
|
+
var HTTPS_PATH = /^\/[A-Za-z0-9\-_.~/]*$/;
|
|
135
|
+
var BASE64URL = /^[A-Za-z0-9\-_]+$/;
|
|
136
|
+
var canonicalSlashes = (rest) => rest.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
137
|
+
var encodeUriPath = (path) => base64(utf8(path)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
138
|
+
var decodeUriPath = (encoded) => {
|
|
139
|
+
const bytes = fromBase64(encoded.replace(/-/g, "+").replace(/_/g, "/"));
|
|
140
|
+
if (bytes === null) return null;
|
|
141
|
+
const path = Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
|
|
142
|
+
return HTTPS_PATH.test(path) && !path.includes("..") ? path : null;
|
|
143
|
+
};
|
|
144
|
+
var npmSource = (rest) => {
|
|
145
|
+
const at = rest.indexOf("@", rest.startsWith("@") ? 1 : 0);
|
|
146
|
+
const identifier = at === -1 ? rest : rest.slice(0, at);
|
|
147
|
+
const version = at === -1 ? null : rest.slice(at + 1);
|
|
148
|
+
if (!NPM_NAME.test(identifier) || identifier.length > 214) return null;
|
|
149
|
+
if (version !== null && !NPM_VERSION.test(version)) return null;
|
|
150
|
+
return `npm:${identifier}`;
|
|
151
|
+
};
|
|
152
|
+
var ghSource = (rest) => {
|
|
153
|
+
const at = rest.indexOf("@");
|
|
154
|
+
const identifier = at === -1 ? rest : rest.slice(0, at);
|
|
155
|
+
const version = at === -1 ? null : rest.slice(at + 1);
|
|
156
|
+
if (!GH_REPO.test(identifier)) return null;
|
|
157
|
+
if (version !== null && !GH_REF.test(version)) return null;
|
|
158
|
+
return `gh:${identifier}`;
|
|
159
|
+
};
|
|
160
|
+
var httpsPackageSource = (rest) => {
|
|
161
|
+
const at = rest.indexOf("@");
|
|
162
|
+
const host = at === -1 ? rest : rest.slice(0, at);
|
|
163
|
+
if (!HTTPS_HOST.test(host) || host.includes("..")) return null;
|
|
164
|
+
if (at === -1) return `https:${host}`;
|
|
165
|
+
const encoded = rest.slice(at + 1);
|
|
166
|
+
if (!BASE64URL.test(encoded)) return null;
|
|
167
|
+
const decoded = decodeUriPath(encoded);
|
|
168
|
+
if (decoded === null) return null;
|
|
169
|
+
const path = canonicalSlashes(decoded) || "/";
|
|
170
|
+
return path === "/" ? `https:${host}` : `https:${host}@${encodeUriPath(path)}`;
|
|
171
|
+
};
|
|
172
|
+
var httpsOriginSource = (input) => {
|
|
173
|
+
let url;
|
|
174
|
+
try {
|
|
175
|
+
url = new URL(input);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "") return null;
|
|
180
|
+
const host = url.port === "" ? url.hostname : `${url.hostname}:${url.port}`;
|
|
181
|
+
return HTTPS_HOST.test(host) && !host.includes("..") ? `https:${host}` : null;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* The one canonical source id a caller id collapses to, or null when the caller can never own an
|
|
185
|
+
* app: `http:`, `localhost:`, a bare name, and every `fkn:` form. Both the broker and the api call
|
|
186
|
+
* this before comparing a source against a manifest's `sources`.
|
|
187
|
+
*/
|
|
188
|
+
var canonicalSourceOf = (callerId) => {
|
|
189
|
+
if (typeof callerId !== "string" || callerId.length === 0 || callerId.length > 512) return null;
|
|
190
|
+
const colon = callerId.indexOf(":");
|
|
191
|
+
if (colon <= 0) return null;
|
|
192
|
+
const scheme = callerId.slice(0, colon);
|
|
193
|
+
const rest = callerId.slice(colon + 1);
|
|
194
|
+
if (scheme === "https") return rest.startsWith("//") ? httpsOriginSource(callerId) : httpsPackageSource(rest);
|
|
195
|
+
if (scheme === "npm") return npmSource(rest);
|
|
196
|
+
if (scheme === "gh") return ghSource(rest);
|
|
197
|
+
return null;
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* RFC 8785 (JCS): keys sorted by UTF-16 code units, no whitespace, ES6 number serialisation.
|
|
201
|
+
* Throws on `undefined`, a non-finite number, or anything JSON has no form for.
|
|
202
|
+
*/
|
|
203
|
+
var canonicalJson = (value) => {
|
|
204
|
+
if (value === null) return "null";
|
|
205
|
+
if (typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
|
|
206
|
+
if (typeof value === "number") {
|
|
207
|
+
if (!Number.isFinite(value)) throw new TypeError("canonicalJson refuses a non-finite number");
|
|
208
|
+
return JSON.stringify(value === 0 ? 0 : value);
|
|
209
|
+
}
|
|
210
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
211
|
+
if (typeof value === "object") {
|
|
212
|
+
const object = value;
|
|
213
|
+
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
|
|
214
|
+
}
|
|
215
|
+
throw new TypeError(`canonicalJson refuses a ${typeof value}`);
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* The member set of a key list: each entry base64 of exactly 32 bytes, sorted bytewise, named once,
|
|
219
|
+
* 1 to 64 of them, and never the root, because the key that names an app must not sign its releases.
|
|
220
|
+
*/
|
|
221
|
+
var memberSetOf = (members, rootPub) => {
|
|
222
|
+
if (!isBytes(rootPub) || rootPub.length !== 32) throw new TypeError("A root public key is 32 bytes");
|
|
223
|
+
if (!Array.isArray(members)) return refuse("KEYS_MEMBERS", "A key list names its members as an array");
|
|
224
|
+
if (members.length === 0) return refuse("KEYS_MEMBERS", "A key list names at least one member");
|
|
225
|
+
if (members.length > 64) return refuse("KEYS_MEMBERS", "A key list names at most 64 members");
|
|
226
|
+
const decoded = [];
|
|
227
|
+
for (const member of members) {
|
|
228
|
+
const bytes = bytesOfLength(member, 32);
|
|
229
|
+
if (bytes === null) return refuse("KEYS_MEMBERS", "Every member is base64 of a 32 byte public key");
|
|
230
|
+
if (sameBytes(bytes, rootPub)) return refuse("KEYS_MEMBERS", "The root is never a member of its own key list");
|
|
231
|
+
decoded.push(bytes);
|
|
232
|
+
}
|
|
233
|
+
decoded.sort(compareBytes);
|
|
234
|
+
let previous = null;
|
|
235
|
+
for (const member of decoded) {
|
|
236
|
+
if (previous !== null && sameBytes(previous, member)) return refuse("KEYS_MEMBERS", "A key list names each member once");
|
|
237
|
+
previous = member;
|
|
238
|
+
}
|
|
239
|
+
return decoded;
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* `fkn/app/v1/keys\0<app>\0<serial>\0` followed by the canonical JSON of the member set in standard
|
|
243
|
+
* padded base64. The re-encoding is what lets an unpadded input sign the same bytes, and the signer
|
|
244
|
+
* builds the statement through this same function.
|
|
245
|
+
*/
|
|
246
|
+
var keyListStatement = (appId, serial, members) => {
|
|
247
|
+
if (!APP_ID.test(appId)) throw new TypeError(`'${String(appId)}' is not an app id`);
|
|
248
|
+
if (!Number.isSafeInteger(serial) || serial < 0) throw new TypeError("A key list serial is a non-negative safe integer");
|
|
249
|
+
const sorted = [...members].sort(compareBytes);
|
|
250
|
+
for (const member of sorted) if (!isBytes(member) || member.length !== 32) throw new TypeError("A key list member is 32 bytes");
|
|
251
|
+
return concat(utf8(`${LABELS.keys}\0${appId}\0${serial}\0`), utf8(canonicalJson(sorted.map(base64))));
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* `fkn/app/v1/manifest\0` followed by the canonical JSON of the manifest WITHOUT its `sig`. Throws
|
|
255
|
+
* when `sig` is still present, because the quiet version of that mistake is a statement nobody can
|
|
256
|
+
* reproduce.
|
|
257
|
+
*/
|
|
258
|
+
var manifestStatement = (manifestWithoutSig) => {
|
|
259
|
+
if (manifestWithoutSig === null || typeof manifestWithoutSig !== "object" || Array.isArray(manifestWithoutSig)) throw new TypeError("A manifest statement is built over an object");
|
|
260
|
+
if ("sig" in manifestWithoutSig) throw new TypeError("A manifest statement excludes sig");
|
|
261
|
+
return concat(utf8(`${LABELS.manifest}\0`), utf8(canonicalJson(manifestWithoutSig)));
|
|
262
|
+
};
|
|
263
|
+
/** `fkn/app/v1/succeed\0<old>\0<new>`, signed by the OLD root and by nothing else. */
|
|
264
|
+
var succeedStatement = (oldId, newId) => {
|
|
265
|
+
if (!APP_ID.test(oldId) || !APP_ID.test(newId)) throw new TypeError("A succession names two app ids");
|
|
266
|
+
return utf8(`${LABELS.succeed}\0${oldId}\0${newId}`);
|
|
267
|
+
};
|
|
268
|
+
/** `fkn/app/v1/register\0<challenge>`, signed by the root proving itself once at registration. */
|
|
269
|
+
var registerStatement = (challengeId) => {
|
|
270
|
+
if (typeof challengeId !== "string" || challengeId.length === 0 || challengeId.length > 256 || challengeId.includes("\0")) throw new TypeError("A registration challenge is 1 to 256 characters and carries no NUL");
|
|
271
|
+
return utf8(`${LABELS.register}\0${challengeId}`);
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* The single verification path. `zip215: false` is RFC 8032 / FIPS 186-5 rules; noble's ed25519
|
|
275
|
+
* default is the permissive ZIP-215 one, which accepts non-canonical encodings and small-order keys,
|
|
276
|
+
* so the option is what decides acceptance and is never left off.
|
|
277
|
+
*/
|
|
278
|
+
var verifySignature = (statement, sig, pub) => {
|
|
279
|
+
try {
|
|
280
|
+
return ed25519.verify(sig, statement, pub, { zip215: false });
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
/**
|
|
286
|
+
* The root's word on who may sign this app's manifests. Verifies the presented list; adoption and
|
|
287
|
+
* membership order are the caller's, because only a store knows which list it holds.
|
|
288
|
+
*/
|
|
289
|
+
var verifyKeyList = (keys, rootPub, appId) => {
|
|
290
|
+
if (keys === null || typeof keys !== "object" || Array.isArray(keys)) return refuse("MALFORMED", "keys is an object");
|
|
291
|
+
const record = keys;
|
|
292
|
+
const serial = record["serial"];
|
|
293
|
+
if (typeof serial !== "number" || !Number.isSafeInteger(serial) || serial < 0) return refuse("MALFORMED", "keys.serial is a non-negative safe integer");
|
|
294
|
+
const members = memberSetOf(record["members"], rootPub);
|
|
295
|
+
if (!Array.isArray(members)) return members;
|
|
296
|
+
const sig = bytesOfLength(record["sig"], 64);
|
|
297
|
+
if (sig === null) return refuse("MALFORMED", "keys.sig is base64 of 64 bytes");
|
|
298
|
+
if (!verifySignature(keyListStatement(appId, serial, members), sig, rootPub)) return refuse("KEYS_SIG", "keys.sig is not the root's signature over this key list");
|
|
299
|
+
return {
|
|
300
|
+
serial,
|
|
301
|
+
members,
|
|
302
|
+
membersB64: members.map(base64),
|
|
303
|
+
sig: base64(sig)
|
|
304
|
+
};
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* One succession entry. The old id is DERIVED from the old root and slug the entry carries, so an
|
|
308
|
+
* app can never name an id it cannot prove; only the old root can produce the signature.
|
|
309
|
+
*/
|
|
310
|
+
var verifyPrevious = (entry, newAppId) => {
|
|
311
|
+
if (!APP_ID.test(newAppId)) throw new TypeError(`'${String(newAppId)}' is not an app id`);
|
|
312
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return refuse("BAD_PREVIOUS", "A previous entry is an object");
|
|
313
|
+
const record = entry;
|
|
314
|
+
const root = bytesOfLength(record["root"], 32);
|
|
315
|
+
if (root === null) return refuse("BAD_PREVIOUS", "previous.root is base64 of 32 bytes");
|
|
316
|
+
const slug = record["slug"];
|
|
317
|
+
if (typeof slug !== "string" || !SLUG.test(slug)) return refuse("BAD_PREVIOUS", "previous.slug is not an app slug");
|
|
318
|
+
const oldId = record["app"];
|
|
319
|
+
if (typeof oldId !== "string" || !APP_ID.test(oldId)) return refuse("BAD_PREVIOUS", "previous.app is not an app id");
|
|
320
|
+
if (oldId !== appIdOf(root, slug)) return refuse("BAD_PREVIOUS", "previous.app is not the id of its own root and slug");
|
|
321
|
+
if (oldId === newAppId) return refuse("BAD_PREVIOUS", "An app cannot succeed itself");
|
|
322
|
+
const sig = bytesOfLength(record["sig"], 64);
|
|
323
|
+
if (sig === null) return refuse("BAD_PREVIOUS", "previous.sig is base64 of 64 bytes");
|
|
324
|
+
if (!verifySignature(succeedStatement(oldId, newAppId), sig, root)) return refuse("BAD_PREVIOUS", "previous.sig is not the old root's signature over this succession");
|
|
325
|
+
return {
|
|
326
|
+
oldId,
|
|
327
|
+
root,
|
|
328
|
+
slug
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
var textOf = (raw) => {
|
|
332
|
+
try {
|
|
333
|
+
const text = JSON.stringify(raw);
|
|
334
|
+
return typeof text === "string" ? text : refuse("MALFORMED", "A manifest is a JSON object");
|
|
335
|
+
} catch {
|
|
336
|
+
return refuse("MALFORMED", "A manifest is a JSON object");
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* Every stateless check, in one order, over a manifest's text or its already-parsed value. The
|
|
341
|
+
* caller supplies `now` in unix seconds; `source` and `webOrigin` are the api's two extra checks and
|
|
342
|
+
* are skipped when absent. Everything a store decides (key list adoption, the newest manifest's
|
|
343
|
+
* sources, the freshness window) stays with the caller.
|
|
344
|
+
*/
|
|
345
|
+
var verifyManifest = (raw, opts) => {
|
|
346
|
+
if (typeof opts.now !== "number" || !Number.isFinite(opts.now)) throw new TypeError("verifyManifest needs a numeric now");
|
|
347
|
+
let value;
|
|
348
|
+
let text;
|
|
349
|
+
if (typeof raw === "string") {
|
|
350
|
+
if (utf8(raw).length > 16384) return refuse("MALFORMED", `A manifest is at most ${MANIFEST_MAX_BYTES} bytes`);
|
|
351
|
+
try {
|
|
352
|
+
value = JSON.parse(raw);
|
|
353
|
+
} catch {
|
|
354
|
+
return refuse("MALFORMED", "A manifest is JSON");
|
|
355
|
+
}
|
|
356
|
+
text = raw;
|
|
357
|
+
} else {
|
|
358
|
+
value = raw;
|
|
359
|
+
const serialised = textOf(raw);
|
|
360
|
+
if (typeof serialised !== "string") return serialised;
|
|
361
|
+
if (utf8(serialised).length > 16384) return refuse("MALFORMED", `A manifest is at most ${MANIFEST_MAX_BYTES} bytes`);
|
|
362
|
+
text = serialised;
|
|
363
|
+
}
|
|
364
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return refuse("MALFORMED", "A manifest is a JSON object");
|
|
365
|
+
const manifest = value;
|
|
366
|
+
if (typeof manifest["v"] !== "number") return refuse("MALFORMED", "A manifest carries a numeric v");
|
|
367
|
+
if (manifest["v"] !== 1) return refuse("UNKNOWN_VERSION", `Manifest version ${String(manifest["v"])} is not known here`);
|
|
368
|
+
if (manifest["app"] === null) return {
|
|
369
|
+
ok: true,
|
|
370
|
+
kind: "released"
|
|
371
|
+
};
|
|
372
|
+
const root = bytesOfLength(manifest["root"], 32);
|
|
373
|
+
if (root === null) return refuse("BAD_ROOT", "root is base64 of a 32 byte public key");
|
|
374
|
+
const slug = manifest["slug"];
|
|
375
|
+
if (typeof slug !== "string" || !SLUG.test(slug)) return refuse("BAD_SLUG", `'${String(slug)}' is not an app slug`);
|
|
376
|
+
const app = manifest["app"];
|
|
377
|
+
if (typeof app !== "string" || !APP_ID.test(app)) return refuse("BAD_APP_ID", `'${String(app)}' is not an app id`);
|
|
378
|
+
if (app !== appIdOf(root, slug)) return refuse("BAD_APP_ID", "app is not the id of its own root and slug");
|
|
379
|
+
const key = bytesOfLength(manifest["key"], 32);
|
|
380
|
+
if (key === null) return refuse("MALFORMED", "key is base64 of a 32 byte public key");
|
|
381
|
+
const sources = manifest["sources"];
|
|
382
|
+
if (!Array.isArray(sources) || sources.length === 0 || sources.length > 32) return refuse("MALFORMED", "sources names 1 to 32 canonical source ids");
|
|
383
|
+
for (const source of sources) if (typeof source !== "string" || canonicalSourceOf(source) !== source) return refuse("MALFORMED", `'${String(source)}' is not a canonical source id`);
|
|
384
|
+
if (new Set(sources).size !== sources.length) return refuse("MALFORMED", "sources names each source once");
|
|
385
|
+
const rawName = manifest["name"];
|
|
386
|
+
if (rawName !== void 0 && (typeof rawName !== "string" || rawName.length === 0 || rawName.length > 64)) return refuse("MALFORMED", "name is 1 to 64 characters");
|
|
387
|
+
const name = rawName === void 0 ? null : rawName;
|
|
388
|
+
const issuedAt = manifest["issuedAt"];
|
|
389
|
+
if (typeof issuedAt !== "number" || !Number.isSafeInteger(issuedAt) || issuedAt < 0) return refuse("MALFORMED", "issuedAt is a non-negative safe integer");
|
|
390
|
+
const rawPrevious = manifest["previous"];
|
|
391
|
+
const previousEntries = [];
|
|
392
|
+
if (rawPrevious !== void 0) {
|
|
393
|
+
if (!Array.isArray(rawPrevious) || rawPrevious.length > 8) return refuse("MALFORMED", "previous names at most 8 apps");
|
|
394
|
+
previousEntries.push(...rawPrevious);
|
|
395
|
+
}
|
|
396
|
+
const sig = bytesOfLength(manifest["sig"], 64);
|
|
397
|
+
if (sig === null) return refuse("MALFORMED", "sig is base64 of 64 bytes");
|
|
398
|
+
const keys = verifyKeyList(manifest["keys"], root, app);
|
|
399
|
+
if ("ok" in keys) return keys;
|
|
400
|
+
if (!keys.members.some((member) => sameBytes(member, key))) return refuse("KEY_NOT_MEMBER", "key is not a member of the key list this manifest carries");
|
|
401
|
+
const withoutSig = { ...manifest };
|
|
402
|
+
delete withoutSig["sig"];
|
|
403
|
+
let statement;
|
|
404
|
+
try {
|
|
405
|
+
statement = manifestStatement(withoutSig);
|
|
406
|
+
} catch {
|
|
407
|
+
return refuse("MALFORMED", "A manifest carries only JSON values");
|
|
408
|
+
}
|
|
409
|
+
if (!verifySignature(statement, sig, key)) return refuse("BAD_SIG", "sig is not key's signature over this manifest");
|
|
410
|
+
if (opts.source !== void 0 && !sources.includes(opts.source)) return refuse("SOURCE_NOT_LISTED", `'${opts.source}' is not one of this manifest's sources`);
|
|
411
|
+
if (issuedAt > opts.now + 300) return refuse("ISSUED_AT_FUTURE", `issuedAt is more than 300 seconds ahead`);
|
|
412
|
+
if (opts.webOrigin !== void 0) {
|
|
413
|
+
const webSource = canonicalSourceOf(opts.webOrigin);
|
|
414
|
+
if (webSource !== null && sources.includes(webSource)) return refuse("WEB_ORIGIN_SOURCE", `'${webSource}' is the web origin and can never be an app's source`);
|
|
415
|
+
}
|
|
416
|
+
const previous = [];
|
|
417
|
+
for (const entry of previousEntries) {
|
|
418
|
+
const verified = verifyPrevious(entry, app);
|
|
419
|
+
if ("ok" in verified) return verified;
|
|
420
|
+
previous.push(verified);
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
kind: "manifest",
|
|
425
|
+
app,
|
|
426
|
+
root,
|
|
427
|
+
slug,
|
|
428
|
+
key,
|
|
429
|
+
keys,
|
|
430
|
+
sources,
|
|
431
|
+
name,
|
|
432
|
+
issuedAt,
|
|
433
|
+
previous,
|
|
434
|
+
raw: text
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
//#endregion
|
|
438
|
+
export { APP_ID, APP_ID_VERSION, BASE32_ALPHABET, ISSUED_AT_TOLERANCE_SECONDS, LABELS, MANIFEST_MAX_BYTES, ROOT_FINGERPRINT, SLUG, appIdOf, base32, base64, canonicalJson, canonicalSourceOf, fromBase64, keyListStatement, manifestStatement, memberSetOf, registerStatement, rootFingerprintOf, succeedStatement, verifyKeyList, verifyManifest, verifyPrevious, verifySignature };
|