@mikeargento/bitgraph 1.10.1 → 1.11.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/dist/export.d.ts +88 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +298 -0
- package/dist/export.js.map +1 -0
- package/dist/fuse.d.ts +136 -2
- package/dist/fuse.d.ts.map +1 -1
- package/dist/fuse.js +349 -2
- package/dist/fuse.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/recovery-write.d.ts +58 -0
- package/dist/recovery-write.d.ts.map +1 -0
- package/dist/recovery-write.js +265 -0
- package/dist/recovery-write.js.map +1 -0
- package/dist/recovery.d.ts +490 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +1124 -0
- package/dist/recovery.js.map +1 -0
- package/package.json +2 -2
- package/src/export.ts +365 -0
- package/src/fuse.ts +470 -3
- package/src/index.ts +28 -3
- package/src/recovery-write.ts +334 -0
- package/src/recovery.ts +1261 -0
package/dist/recovery.js
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovering a BitGraph from the file: bitgraph-recovery/1 (2026-10-03).
|
|
3
|
+
*
|
|
4
|
+
* THE RULE (Mike): anyone holding a file can always get its proof back, and
|
|
5
|
+
* export it again, if the export was lost.
|
|
6
|
+
*
|
|
7
|
+
* RECOVERY IS NOT VERIFICATION. Verification needs nothing of ours: an export
|
|
8
|
+
* and the file check themselves (packages/verify, export.ts). Recovery uses
|
|
9
|
+
* our bucket, and only to answer one question for whoever holds the bytes:
|
|
10
|
+
* which BitGraphs hold these bytes, and where is each one's proof? Each entry
|
|
11
|
+
* hands back exactly what a member's export carries (the root document and
|
|
12
|
+
* the member's evidence) plus a locator for the proof itself, which is then
|
|
13
|
+
* fetched from the public proof routes and verifies with nothing of ours.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IS WRITTEN. For every member of a tree, up to two sealed entries: one
|
|
16
|
+
* under the file's origin digest (the file as dropped) and one under its
|
|
17
|
+
* artifact digest (the committed bytes), so either file in hand finds it. An
|
|
18
|
+
* as-is leaf (placement 0x00) has one digest and earns one entry.
|
|
19
|
+
*
|
|
20
|
+
* FORMATS. Hex is lowercase. digest32 is the RAW 32-byte SHA-256, never its
|
|
21
|
+
* text; proofHash32 is the raw 32 bytes of computeProofHash(proof) (the
|
|
22
|
+
* ledger identity, base64 in the verify package).
|
|
23
|
+
*
|
|
24
|
+
* address = hex SHA-256( UTF-8 "bitgraph-lookup" || digest32 )
|
|
25
|
+
* entryId = hex SHA-256( UTF-8 "bitgraph-lookup-entry" || digest32 || proofHash32 || leafIndex u32 big-endian )
|
|
26
|
+
* saltedId = hex SHA-256( UTF-8 "bitgraph-lookup-entry/salted" || digest32 || proofHash32 || leafIndex u32 big-endian || salt32 )
|
|
27
|
+
* the fallback name when the deterministic key is held by another entry (squatting, below);
|
|
28
|
+
* the salt (32 random bytes) is sealed in the plaintext, so a reader checks the name either way
|
|
29
|
+
* key = SHA-256( UTF-8 "bitgraph-lookup-key" || digest32 ) the AES-256-GCM key
|
|
30
|
+
* objectKey = "recovery/v1/" + address + "/" + entryId
|
|
31
|
+
* envelope = 0x01 || nonce (12 random bytes, fresh per entry) || ciphertext || tag (16)
|
|
32
|
+
* AAD = objectKey as UTF-8
|
|
33
|
+
* plaintext = UTF-8 JSON, written in this key order:
|
|
34
|
+
* { "format": "bitgraph-recovery/1",
|
|
35
|
+
* "proofHash": base64 (44),
|
|
36
|
+
* "leafIndex": n,
|
|
37
|
+
* "rootDocument": hex (168),
|
|
38
|
+
* "member": { "index", "count", "leaf", "path" } TreeMemberEvidence
|
|
39
|
+
* "proof": { "epochId", "counter", "artifactDigestB64" } a locator
|
|
40
|
+
* "salt"?: base64 (44), present exactly when the entry is salted
|
|
41
|
+
* "name"?: string } advisory, at most 512 UTF-8 bytes
|
|
42
|
+
*
|
|
43
|
+
* The four labels differ and every input after a label is fixed length, so
|
|
44
|
+
* the four preimages (47, 51, 89 and 128 bytes) can never be confused with
|
|
45
|
+
* one another. The address is public (it is in the object key); the key is
|
|
46
|
+
* not derivable from it without the digest.
|
|
47
|
+
*
|
|
48
|
+
* SQUATTING, AND THE SALTED FALLBACK. Keys are deterministic and writes are
|
|
49
|
+
* create-only, so whoever knows a file's digest and its proof hash can occupy
|
|
50
|
+
* a member's deterministic key first. They cannot make a wrong proof come
|
|
51
|
+
* back (a reader binds every entry to its proof and verifies the member), but
|
|
52
|
+
* they could deny that one entry. So a writer that finds another member's
|
|
53
|
+
* envelope at its deterministic key writes the same plaintext, plus a fresh
|
|
54
|
+
* 32-byte salt, under the salted name instead; a reader accepts either name
|
|
55
|
+
* when the plaintext derives it, and lists one member once however many
|
|
56
|
+
* names it has. The salt is kept by the writer before the salted write, so a
|
|
57
|
+
* retry lands on the same key. Listing spam under an address stays a
|
|
58
|
+
* rate-limit matter.
|
|
59
|
+
*
|
|
60
|
+
* LOOKING UP MANY FILES AT ONCE. recoverFromDigests asks one request for the
|
|
61
|
+
* first page of many addresses. That tells the server which addresses were
|
|
62
|
+
* asked together, a linkage one-by-one requests only hint at by timing; the
|
|
63
|
+
* route logs counts, never addresses, and the SPEC says so.
|
|
64
|
+
*
|
|
65
|
+
* WHY THE ENTRY ID IS A HASH. The first draft keyed entries
|
|
66
|
+
* "<address>/<proofHash>/<leafIndex>", which let anyone listing the bucket
|
|
67
|
+
* group every entry of one drop by the shared proofHash. The entryId is still
|
|
68
|
+
* deterministic (a rewrite after a crash lands on the same key, so writes stay
|
|
69
|
+
* idempotent) and two members of one tree never collide (the leaf index is in
|
|
70
|
+
* it), but it says nothing about which tree it belongs to without the digest.
|
|
71
|
+
*
|
|
72
|
+
* PRIVACY, AND ITS LIMIT. The server sees object keys and opaque envelopes,
|
|
73
|
+
* never a digest, a proof hash, a leaf or a name. But anyone who KNOWS a
|
|
74
|
+
* file's digest (holding the file, a published digest, or guessing among a
|
|
75
|
+
* few candidate files) can derive the address and key and test whether it was
|
|
76
|
+
* recorded; that is the same ability recovery needs. The promise covers these
|
|
77
|
+
* sealed entries only, not older plain-digest indexes, logs or metadata. (One
|
|
78
|
+
* piece of metadata worth naming: the entries of one drop are written in the
|
|
79
|
+
* same requests and land with near-identical write times, so whoever can list
|
|
80
|
+
* the bucket can group them by time. Today that is only BitGraph.)
|
|
81
|
+
*
|
|
82
|
+
* WHAT A STORED ENTRY IS WORTH. Whoever holds the file can write an entry for
|
|
83
|
+
* it; that is the rule working, not a hole. So an entry is never trusted for
|
|
84
|
+
* holding the right key: it is opened (AES-GCM, AAD the object key), parsed
|
|
85
|
+
* strictly, its entryId recomputed, its leaf checked to name the looked-up
|
|
86
|
+
* digest, its path checked to reach the root inside its own root document,
|
|
87
|
+
* and that document checked to hash to the locator's digest. Then the proof is
|
|
88
|
+
* fetched and bound by proofHash and verifyTreeMember (fetchRecoveredProof).
|
|
89
|
+
* A writer who knows the digest can occupy a key; they cannot make a wrong
|
|
90
|
+
* proof come back as this file's. (The queue reports a member whose key was
|
|
91
|
+
* occupied first as "blocked", and keeps writing its other entry.)
|
|
92
|
+
*
|
|
93
|
+
* CREATE-ONLY. Entries are written with If-None-Match: * and never replaced;
|
|
94
|
+
* see recovery-store-s3.ts for the bucket policy that keeps a delete marker
|
|
95
|
+
* from ever letting a second version in under the same key.
|
|
96
|
+
*
|
|
97
|
+
* SHA-256 here is @noble/hashes (as in packages/verify), AES-GCM is WebCrypto.
|
|
98
|
+
* Both run unchanged in the browser and in node 20+. The hashes are noble's
|
|
99
|
+
* rather than crypto.subtle.digest's because a tree of 100,000 files needs
|
|
100
|
+
* 600,000 small hashes: measured on node 24, 300,000 cost 275 ms through
|
|
101
|
+
* noble and 2,368 ms through subtle (one promise and thread hop each). The
|
|
102
|
+
* bytes are identical; the test suite derives every format again with
|
|
103
|
+
* node:crypto to prove it.
|
|
104
|
+
*/
|
|
105
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
106
|
+
import { canonicalize, computeSignedBodyHash, publishedMeasurement, verifyNitroAttestation } from "@mikeargento/bitgraph-verify";
|
|
107
|
+
import { LEAF_AS_IS, MerkleTree, TREE_LEAF_BYTES, base64ToBytes, buildTreeMemberEvidence, bytesEqual, bytesToBase64, bytesToHex, computeProofHash, decodeTreeLeaf, hexToBytes, isTreeProof, merkleLeafHash, parseTreeMemberEvidence, parseTreeRootDocument, readTreeMetadata, treeRootFromMember, verifyTreeMember, } from "@mikeargento/bitgraph-verify";
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Constants (shared by the browser queue, the lookup and the routes)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
export const RECOVERY_FORMAT = "bitgraph-recovery/1";
|
|
112
|
+
export const RECOVERY_PREFIX = "recovery/v1/";
|
|
113
|
+
export const ENVELOPE_VERSION = 0x01;
|
|
114
|
+
export const NONCE_BYTES = 12;
|
|
115
|
+
export const TAG_BYTES = 16;
|
|
116
|
+
/** Version byte, nonce and tag: 29 bytes around the ciphertext. */
|
|
117
|
+
export const ENVELOPE_OVERHEAD = 1 + NONCE_BYTES + TAG_BYTES;
|
|
118
|
+
/**
|
|
119
|
+
* The largest envelope anyone may store. Sized from the response, not the
|
|
120
|
+
* entry: a batch of MAX_BATCH_ENTRIES whose keys all exist comes back with
|
|
121
|
+
* every stored envelope in it, and a Vercel function answers at most 4.5 MB.
|
|
122
|
+
* 500 x 4,096 bytes is 2.7 MB as base64, in both directions. The largest
|
|
123
|
+
* honest plaintext (a 1,000,000-leaf tree's 20-node path, the longest
|
|
124
|
+
* locator, a 512-byte name) is about 3.1 KB; the suite pins that it fits.
|
|
125
|
+
*/
|
|
126
|
+
export const MAX_ENVELOPE_BYTES = 4096;
|
|
127
|
+
export const MIN_ENVELOPE_BYTES = ENVELOPE_OVERHEAD + 1;
|
|
128
|
+
export const MAX_PLAINTEXT_BYTES = MAX_ENVELOPE_BYTES - ENVELOPE_OVERHEAD;
|
|
129
|
+
/** A name longer than this keeps its last 509 bytes behind "…" (a path's tail is the file's own name). */
|
|
130
|
+
export const MAX_NAME_BYTES = 512;
|
|
131
|
+
/** Entries per POST. */
|
|
132
|
+
export const MAX_BATCH_ENTRIES = 500;
|
|
133
|
+
/** A POST body above this is refused before it is parsed. A full batch of the largest envelopes is about 2.8 MB. */
|
|
134
|
+
export const MAX_BODY_BYTES = 4_000_000;
|
|
135
|
+
/** Entries per page of GET /api/recovery/<address>. */
|
|
136
|
+
export const MAX_LIST_LIMIT = 100;
|
|
137
|
+
export const ADDRESS_PATTERN = /^[0-9a-f]{64}$/;
|
|
138
|
+
export const ENTRY_ID_PATTERN = /^[0-9a-f]{64}$/;
|
|
139
|
+
export const OBJECT_KEY_PATTERN = /^recovery\/v1\/[0-9a-f]{64}\/[0-9a-f]{64}$/;
|
|
140
|
+
/** "recovery/v1/" + 64 + "/" + 64. */
|
|
141
|
+
export const OBJECT_KEY_LENGTH = RECOVERY_PREFIX.length + 64 + 1 + 64;
|
|
142
|
+
/** A position counter as a proof writes it: decimal, no leading zero, at most 20 digits. */
|
|
143
|
+
const COUNTER_PATTERN = /^(0|[1-9][0-9]{0,19})$/;
|
|
144
|
+
const encoder = new TextEncoder();
|
|
145
|
+
const utf8 = (s) => encoder.encode(s);
|
|
146
|
+
const LOOKUP_LABEL = utf8("bitgraph-lookup");
|
|
147
|
+
const ENTRY_LABEL = utf8("bitgraph-lookup-entry");
|
|
148
|
+
const SALTED_ENTRY_LABEL = utf8("bitgraph-lookup-entry/salted");
|
|
149
|
+
const KEY_LABEL = utf8("bitgraph-lookup-key");
|
|
150
|
+
/** A salted entry's salt: 32 random bytes, sealed in its plaintext. */
|
|
151
|
+
export const SALT_BYTES = 32;
|
|
152
|
+
/** Addresses one lookup request may ask for (POST /api/recovery/lookup). */
|
|
153
|
+
export const MAX_LOOKUP_ADDRESSES = 1000;
|
|
154
|
+
/** The most a lookup answer carries (under the hosting platform's 4.5 MB response limit); addresses past it come back `truncated` and are listed one by one. */
|
|
155
|
+
export const MAX_LOOKUP_RESPONSE_BYTES = 3_500_000;
|
|
156
|
+
/** The default time one recovery read may take. */
|
|
157
|
+
export const LOOKUP_TIMEOUT_MS = 30_000;
|
|
158
|
+
/** The most a lookup request's body may be (1,000 addresses are 70 KB). */
|
|
159
|
+
export const MAX_LOOKUP_BODY_BYTES = 131_072;
|
|
160
|
+
/** More distinct members than this listed under one address, and the lookup is unknown: each would cost a proof fetch, and nothing honest records one file this often. */
|
|
161
|
+
export const MAX_RECOVERED_PER_DIGEST = 200;
|
|
162
|
+
/** A caller handed the queue or the sealer something that must never be written. */
|
|
163
|
+
export class RecoveryInputError extends Error {
|
|
164
|
+
constructor(message) {
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = "RecoveryInputError";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The recovery entries could not be READ. Never an answer about whether the
|
|
171
|
+
* file was recorded: "we could not check" and "nothing is kept for these
|
|
172
|
+
* bytes" are different claims, and only the second is a finding.
|
|
173
|
+
*/
|
|
174
|
+
export class RecoveryUnavailableError extends Error {
|
|
175
|
+
constructor(where, cause) {
|
|
176
|
+
super(`recovery read failed (${where})${cause instanceof Error ? `: ${cause.message}` : ""}`);
|
|
177
|
+
this.name = "RecoveryUnavailableError";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const defaultFetch = (input, init) => fetch(input, init);
|
|
181
|
+
function concat(...parts) {
|
|
182
|
+
let n = 0;
|
|
183
|
+
for (const p of parts)
|
|
184
|
+
n += p.length;
|
|
185
|
+
const out = new Uint8Array(n);
|
|
186
|
+
let o = 0;
|
|
187
|
+
for (const p of parts) {
|
|
188
|
+
out.set(p, o);
|
|
189
|
+
o += p.length;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
function isPlainObject(x) {
|
|
194
|
+
if (x === null || typeof x !== "object" || Array.isArray(x))
|
|
195
|
+
return false;
|
|
196
|
+
const proto = Object.getPrototypeOf(x);
|
|
197
|
+
return proto === Object.prototype || proto === null;
|
|
198
|
+
}
|
|
199
|
+
const toUrlSafe = (b64) => b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
200
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
201
|
+
function checkDigest(digest32, what = "a digest") {
|
|
202
|
+
if (!(digest32 instanceof Uint8Array) || digest32.length !== 32)
|
|
203
|
+
throw new TypeError(`${what} is the raw 32-byte SHA-256`);
|
|
204
|
+
}
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Derivations
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
/** hex SHA-256("bitgraph-lookup" || digest32): the public folder a file's entries live in. */
|
|
209
|
+
export function recoveryAddress(digest32) {
|
|
210
|
+
checkDigest(digest32);
|
|
211
|
+
return bytesToHex(sha256(concat(LOOKUP_LABEL, digest32)));
|
|
212
|
+
}
|
|
213
|
+
/** SHA-256("bitgraph-lookup-key" || digest32): the AES-256-GCM key every entry under this digest is sealed with. */
|
|
214
|
+
export function recoveryKeyBytes(digest32) {
|
|
215
|
+
checkDigest(digest32);
|
|
216
|
+
return sha256(concat(KEY_LABEL, digest32));
|
|
217
|
+
}
|
|
218
|
+
/** hex SHA-256("bitgraph-lookup-entry" || digest32 || proofHash32 || leafIndex u32 BE): one entry's name inside the address. */
|
|
219
|
+
export function recoveryEntryId(digest32, proofHash32, leafIndex) {
|
|
220
|
+
checkDigest(digest32);
|
|
221
|
+
checkDigest(proofHash32, "a proof hash");
|
|
222
|
+
if (!Number.isInteger(leafIndex) || leafIndex < 0 || leafIndex > 0xffffffff)
|
|
223
|
+
throw new RangeError("a leaf index is a u32");
|
|
224
|
+
const index = new Uint8Array(4);
|
|
225
|
+
new DataView(index.buffer).setUint32(0, leafIndex, false);
|
|
226
|
+
return bytesToHex(sha256(concat(ENTRY_LABEL, digest32, proofHash32, index)));
|
|
227
|
+
}
|
|
228
|
+
/** hex SHA-256("bitgraph-lookup-entry/salted" || digest32 || proofHash32 || leafIndex u32 BE || salt32): the entry's fallback name when its deterministic one is held. */
|
|
229
|
+
export function recoverySaltedEntryId(digest32, proofHash32, leafIndex, salt32) {
|
|
230
|
+
checkDigest(digest32);
|
|
231
|
+
checkDigest(proofHash32, "a proof hash");
|
|
232
|
+
if (!(salt32 instanceof Uint8Array) || salt32.length !== SALT_BYTES)
|
|
233
|
+
throw new TypeError(`a salt is ${SALT_BYTES} bytes`);
|
|
234
|
+
if (!Number.isInteger(leafIndex) || leafIndex < 0 || leafIndex > 0xffffffff)
|
|
235
|
+
throw new RangeError("a leaf index is a u32");
|
|
236
|
+
const index = new Uint8Array(4);
|
|
237
|
+
new DataView(index.buffer).setUint32(0, leafIndex, false);
|
|
238
|
+
return bytesToHex(sha256(concat(SALTED_ENTRY_LABEL, digest32, proofHash32, index, salt32)));
|
|
239
|
+
}
|
|
240
|
+
/** A fresh salt for a salted entry. */
|
|
241
|
+
export function newRecoverySalt() {
|
|
242
|
+
const salt = new Uint8Array(SALT_BYTES);
|
|
243
|
+
crypto.getRandomValues(salt);
|
|
244
|
+
return salt;
|
|
245
|
+
}
|
|
246
|
+
export function recoveryObjectKey(address, entryId) {
|
|
247
|
+
if (!ADDRESS_PATTERN.test(address) || !ENTRY_ID_PATTERN.test(entryId))
|
|
248
|
+
throw new TypeError("an address and an entry id are 64 lowercase hex characters");
|
|
249
|
+
return `${RECOVERY_PREFIX}${address}/${entryId}`;
|
|
250
|
+
}
|
|
251
|
+
/** The address and entry id of a well-formed object key; null for anything else. */
|
|
252
|
+
export function parseRecoveryObjectKey(key) {
|
|
253
|
+
if (typeof key !== "string" || !OBJECT_KEY_PATTERN.test(key))
|
|
254
|
+
return null;
|
|
255
|
+
return { address: key.slice(RECOVERY_PREFIX.length, RECOVERY_PREFIX.length + 64), entryId: key.slice(RECOVERY_PREFIX.length + 65) };
|
|
256
|
+
}
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// The envelope
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
function subtle() {
|
|
261
|
+
const s = globalThis.crypto?.subtle;
|
|
262
|
+
if (!s)
|
|
263
|
+
throw new Error("WebCrypto (crypto.subtle) is not available: recovery needs a secure context or node 20+");
|
|
264
|
+
return s;
|
|
265
|
+
}
|
|
266
|
+
/** A fresh copy on its own ArrayBuffer, which is what WebCrypto's BufferSource type asks for. */
|
|
267
|
+
const own = (u) => new Uint8Array(u);
|
|
268
|
+
async function importKey(keyBytes) {
|
|
269
|
+
if (!(keyBytes instanceof Uint8Array) || keyBytes.length !== 32)
|
|
270
|
+
throw new TypeError("an AES-256 key is 32 bytes");
|
|
271
|
+
return subtle().importKey("raw", own(keyBytes), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Seal one entry: 0x01 || nonce || AES-256-GCM(plaintext) || tag, with the
|
|
275
|
+
* object key as associated data, so an envelope opens only at the key it was
|
|
276
|
+
* written for. The nonce is 12 random bytes per call. A key encrypts one
|
|
277
|
+
* entry per recording of one file, so random nonces are nowhere near their
|
|
278
|
+
* limit.
|
|
279
|
+
*/
|
|
280
|
+
export async function sealRecoveryEnvelope(keyBytes, objectKey, plaintext) {
|
|
281
|
+
if (!OBJECT_KEY_PATTERN.test(objectKey))
|
|
282
|
+
throw new TypeError("not a recovery object key");
|
|
283
|
+
if (plaintext.length === 0 || plaintext.length > MAX_PLAINTEXT_BYTES)
|
|
284
|
+
throw new RangeError(`a plaintext is 1 to ${MAX_PLAINTEXT_BYTES} bytes`);
|
|
285
|
+
const key = await importKey(keyBytes);
|
|
286
|
+
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
|
287
|
+
const sealed = new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv: nonce, additionalData: own(utf8(objectKey)), tagLength: TAG_BYTES * 8 }, key, own(plaintext)));
|
|
288
|
+
const out = new Uint8Array(1 + NONCE_BYTES + sealed.length);
|
|
289
|
+
out[0] = ENVELOPE_VERSION;
|
|
290
|
+
out.set(nonce, 1);
|
|
291
|
+
out.set(sealed, 1 + NONCE_BYTES);
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
async function openWith(key, objectKey, envelope) {
|
|
295
|
+
// The version byte sits outside the AAD (the design binds the object key,
|
|
296
|
+
// whose "v1" segment names the format), so it is checked here, exactly: an
|
|
297
|
+
// envelope that is not version 1 is not opened at all.
|
|
298
|
+
if (envelope.length < MIN_ENVELOPE_BYTES || envelope.length > MAX_ENVELOPE_BYTES || envelope[0] !== ENVELOPE_VERSION)
|
|
299
|
+
return null;
|
|
300
|
+
try {
|
|
301
|
+
const plain = await subtle().decrypt({ name: "AES-GCM", iv: own(envelope.subarray(1, 1 + NONCE_BYTES)), additionalData: own(utf8(objectKey)), tagLength: TAG_BYTES * 8 }, key, own(envelope.subarray(1 + NONCE_BYTES)));
|
|
302
|
+
return new Uint8Array(plain);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** The plaintext, or null when the envelope is not version 1 or does not authenticate under this key at this object key. */
|
|
309
|
+
export async function openRecoveryEnvelope(keyBytes, objectKey, envelope) {
|
|
310
|
+
return openWith(await importKey(keyBytes), objectKey, envelope);
|
|
311
|
+
}
|
|
312
|
+
/** The canonical bytes: keys in the documented order, compact JSON, UTF-8. */
|
|
313
|
+
export function encodeRecoveryPlaintext(p) {
|
|
314
|
+
const ordered = {
|
|
315
|
+
format: RECOVERY_FORMAT,
|
|
316
|
+
proofHash: p.proofHash,
|
|
317
|
+
leafIndex: p.leafIndex,
|
|
318
|
+
rootDocument: p.rootDocument,
|
|
319
|
+
member: { index: p.member.index, count: p.member.count, leaf: p.member.leaf, path: [...p.member.path] },
|
|
320
|
+
proof: { epochId: p.proof.epochId, counter: p.proof.counter, artifactDigestB64: p.proof.artifactDigestB64 },
|
|
321
|
+
...(p.salt !== undefined ? { salt: p.salt } : {}),
|
|
322
|
+
...(p.name !== undefined ? { name: p.name } : {}),
|
|
323
|
+
};
|
|
324
|
+
// Canonical JSON (SPEC section 2): the one serialization every BitGraph document uses.
|
|
325
|
+
return canonicalize(ordered);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The locator carries three of the proof's own signed fields, in the proof's
|
|
329
|
+
* own encodings: `epochId` (commit.epochId, canonical standard base64 of 32
|
|
330
|
+
* bytes), `counter` (commit.counter, decimal without a leading zero) and
|
|
331
|
+
* `artifactDigestB64` (artifact.digestB64, canonical standard base64 of 32
|
|
332
|
+
* bytes). A reader checks each against the proof it fetches.
|
|
333
|
+
*/
|
|
334
|
+
function parseLocator(v) {
|
|
335
|
+
if (!isPlainObject(v) || Object.keys(v).sort().join(",") !== "artifactDigestB64,counter,epochId")
|
|
336
|
+
return null;
|
|
337
|
+
const { epochId, counter, artifactDigestB64 } = v;
|
|
338
|
+
if (typeof epochId !== "string" || typeof counter !== "string" || typeof artifactDigestB64 !== "string")
|
|
339
|
+
return null;
|
|
340
|
+
const epoch = base64ToBytes(epochId);
|
|
341
|
+
if (epoch === null || epoch.length !== 32)
|
|
342
|
+
return null;
|
|
343
|
+
if (!COUNTER_PATTERN.test(counter))
|
|
344
|
+
return null;
|
|
345
|
+
const d = base64ToBytes(artifactDigestB64);
|
|
346
|
+
if (d === null || d.length !== 32)
|
|
347
|
+
return null;
|
|
348
|
+
return { epochId, counter, artifactDigestB64 };
|
|
349
|
+
}
|
|
350
|
+
const PLAINTEXT_KEYS = "format,leafIndex,member,proof,proofHash,rootDocument";
|
|
351
|
+
const PLAINTEXT_KEYS_NAMED = "format,leafIndex,member,name,proof,proofHash,rootDocument";
|
|
352
|
+
const PLAINTEXT_KEYS_SALTED = "format,leafIndex,member,proof,proofHash,rootDocument,salt";
|
|
353
|
+
const PLAINTEXT_KEYS_SALTED_NAMED = "format,leafIndex,member,name,proof,proofHash,rootDocument,salt";
|
|
354
|
+
/**
|
|
355
|
+
* Strict read, and every check that needs nothing but the plaintext: exactly
|
|
356
|
+
* the documented keys, the format, a 32-byte proof hash, a root document that
|
|
357
|
+
* parses, member evidence that parses (verify's own strict reader), the leaf
|
|
358
|
+
* index and the tree size agreeing everywhere, the member's path reaching the
|
|
359
|
+
* root inside this root document, and the locator naming this document's
|
|
360
|
+
* hash. Null on any deviation. It does not say the proof exists or binds
|
|
361
|
+
* this document; fetchRecoveredProof does.
|
|
362
|
+
*/
|
|
363
|
+
export function parseRecoveryPlaintext(bytes) {
|
|
364
|
+
let v;
|
|
365
|
+
try {
|
|
366
|
+
v = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
if (!isPlainObject(v))
|
|
372
|
+
return null;
|
|
373
|
+
const keys = Object.keys(v).sort().join(",");
|
|
374
|
+
if (keys !== PLAINTEXT_KEYS && keys !== PLAINTEXT_KEYS_NAMED && keys !== PLAINTEXT_KEYS_SALTED && keys !== PLAINTEXT_KEYS_SALTED_NAMED)
|
|
375
|
+
return null;
|
|
376
|
+
if (v["format"] !== RECOVERY_FORMAT)
|
|
377
|
+
return null;
|
|
378
|
+
const proofHash = v["proofHash"];
|
|
379
|
+
if (typeof proofHash !== "string")
|
|
380
|
+
return null;
|
|
381
|
+
const ph = base64ToBytes(proofHash);
|
|
382
|
+
if (ph === null || ph.length !== 32)
|
|
383
|
+
return null;
|
|
384
|
+
const rootDocument = v["rootDocument"];
|
|
385
|
+
if (typeof rootDocument !== "string")
|
|
386
|
+
return null;
|
|
387
|
+
const docBytes = hexToBytes(rootDocument);
|
|
388
|
+
const doc = docBytes === null ? null : parseTreeRootDocument(docBytes);
|
|
389
|
+
if (docBytes === null || doc === null)
|
|
390
|
+
return null;
|
|
391
|
+
const ev = parseTreeMemberEvidence(v["member"]);
|
|
392
|
+
if (ev === null)
|
|
393
|
+
return null;
|
|
394
|
+
if (v["leafIndex"] !== ev.index || ev.count !== doc.count)
|
|
395
|
+
return null;
|
|
396
|
+
const reached = treeRootFromMember(ev.leaf, ev.index, ev.count, ev.path);
|
|
397
|
+
if (reached === null || !bytesEqual(reached, doc.root))
|
|
398
|
+
return null;
|
|
399
|
+
const proof = parseLocator(v["proof"]);
|
|
400
|
+
if (proof === null || proof.artifactDigestB64 !== bytesToBase64(sha256(docBytes)))
|
|
401
|
+
return null;
|
|
402
|
+
const out = {
|
|
403
|
+
format: RECOVERY_FORMAT,
|
|
404
|
+
proofHash,
|
|
405
|
+
leafIndex: ev.index,
|
|
406
|
+
rootDocument,
|
|
407
|
+
member: v["member"],
|
|
408
|
+
proof,
|
|
409
|
+
};
|
|
410
|
+
if ("salt" in v) {
|
|
411
|
+
const salt = v["salt"];
|
|
412
|
+
const bytes = typeof salt === "string" ? base64ToBytes(salt) : null;
|
|
413
|
+
if (typeof salt !== "string" || bytes === null || bytes.length !== SALT_BYTES)
|
|
414
|
+
return null;
|
|
415
|
+
out.salt = salt;
|
|
416
|
+
}
|
|
417
|
+
if ("name" in v) {
|
|
418
|
+
const name = v["name"];
|
|
419
|
+
if (typeof name !== "string" || name.length === 0 || utf8(name).length > MAX_NAME_BYTES)
|
|
420
|
+
return null;
|
|
421
|
+
out.name = name;
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
/** The entry id a plaintext derives for digest `d`: the salted name when it carries a salt, else the deterministic one. */
|
|
426
|
+
export function recoveryEntryIdOf(digest32, p) {
|
|
427
|
+
const proofHash32 = base64ToBytes(p.proofHash);
|
|
428
|
+
if (p.salt !== undefined)
|
|
429
|
+
return recoverySaltedEntryId(digest32, proofHash32, p.leafIndex, base64ToBytes(p.salt));
|
|
430
|
+
return recoveryEntryId(digest32, proofHash32, p.leafIndex);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The same member of the same recording, field by field: proof hash, leaf
|
|
434
|
+
* index, root document, the whole evidence (leaf and every path node) and the
|
|
435
|
+
* locator. The design's first test was proof hash and leaf index only, and
|
|
436
|
+
* that is not enough: anyone who knows the digest can seal an envelope that
|
|
437
|
+
* carries the right two numbers around a wrong path or a wrong locator, and a
|
|
438
|
+
* client that accepted it would mark the file recoverable while its only
|
|
439
|
+
* entry is useless. The name is left out on purpose: it is advisory and
|
|
440
|
+
* unsigned, and nothing recovered rests on it; so is the salt, which names
|
|
441
|
+
* the entry and says nothing about the member.
|
|
442
|
+
*/
|
|
443
|
+
export function sameRecoveryMember(a, b) {
|
|
444
|
+
if (a.proofHash !== b.proofHash || a.leafIndex !== b.leafIndex || a.rootDocument !== b.rootDocument)
|
|
445
|
+
return false;
|
|
446
|
+
if (a.member.index !== b.member.index || a.member.count !== b.member.count || a.member.leaf !== b.member.leaf)
|
|
447
|
+
return false;
|
|
448
|
+
if (a.member.path.length !== b.member.path.length || a.member.path.some((n, i) => n !== b.member.path[i]))
|
|
449
|
+
return false;
|
|
450
|
+
return a.proof.epochId === b.proof.epochId && a.proof.counter === b.proof.counter && a.proof.artifactDigestB64 === b.proof.artifactDigestB64;
|
|
451
|
+
}
|
|
452
|
+
/** Keep a long name's tail (a path's tail is the file's own name) inside MAX_NAME_BYTES; undefined for no name. */
|
|
453
|
+
export function clampRecoveryName(name) {
|
|
454
|
+
if (typeof name !== "string" || name.length === 0)
|
|
455
|
+
return undefined;
|
|
456
|
+
if (utf8(name).length <= MAX_NAME_BYTES)
|
|
457
|
+
return name;
|
|
458
|
+
const budget = MAX_NAME_BYTES - utf8("…").length;
|
|
459
|
+
const points = Array.from(name);
|
|
460
|
+
let used = 0;
|
|
461
|
+
let start = points.length;
|
|
462
|
+
while (start > 0) {
|
|
463
|
+
const n = utf8(points[start - 1]).length;
|
|
464
|
+
if (used + n > budget)
|
|
465
|
+
break;
|
|
466
|
+
used += n;
|
|
467
|
+
start--;
|
|
468
|
+
}
|
|
469
|
+
return `…${points.slice(start).join("")}`;
|
|
470
|
+
}
|
|
471
|
+
function compareBytes(a, b) {
|
|
472
|
+
for (let i = 0; i < a.length && i < b.length; i++)
|
|
473
|
+
if (a[i] !== b[i])
|
|
474
|
+
return a[i] - b[i];
|
|
475
|
+
return a.length - b.length;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Everything is checked before anything is sealed, because what is sealed is
|
|
479
|
+
* written create-only under keys nobody can ever reuse: an entry built from a
|
|
480
|
+
* list that does not rebuild the committed root would hold its member's key
|
|
481
|
+
* for ten years and help nobody. The checks are verifyTreeLeaves' own (count,
|
|
482
|
+
* every leaf valid, strictly ascending artifact digests, the rebuilt root
|
|
483
|
+
* equal to the document's; the suite pins the two agreeing), done here so
|
|
484
|
+
* the tree that proves the list is the tree that makes the paths.
|
|
485
|
+
*/
|
|
486
|
+
export function recoveryTreeFromParts(parts) {
|
|
487
|
+
const proofHash32 = typeof parts.proofHash === "string" ? base64ToBytes(parts.proofHash) : null;
|
|
488
|
+
if (proofHash32 === null || proofHash32.length !== 32)
|
|
489
|
+
throw new RecoveryInputError("the proof hash is not the base64 of 32 bytes");
|
|
490
|
+
const locator = parseLocator(parts.locator);
|
|
491
|
+
if (locator === null)
|
|
492
|
+
throw new RecoveryInputError("the locator is not { epochId, counter, artifactDigestB64 }");
|
|
493
|
+
if (!(parts.rootDocument instanceof Uint8Array))
|
|
494
|
+
throw new RecoveryInputError("the root document is bytes");
|
|
495
|
+
const doc = parseTreeRootDocument(parts.rootDocument);
|
|
496
|
+
if (doc === null)
|
|
497
|
+
throw new RecoveryInputError("the root document is not 84 bytes of tree/1");
|
|
498
|
+
if (bytesToBase64(sha256(parts.rootDocument)) !== locator.artifactDigestB64)
|
|
499
|
+
throw new RecoveryInputError("the root document does not hash to the proof's artifact digest");
|
|
500
|
+
const leaves = parts.leaves;
|
|
501
|
+
if (!(leaves instanceof Uint8Array) || leaves.length === 0 || leaves.length % TREE_LEAF_BYTES !== 0)
|
|
502
|
+
throw new RecoveryInputError("the leaves are not a whole number of 65-byte leaves");
|
|
503
|
+
const count = leaves.length / TREE_LEAF_BYTES;
|
|
504
|
+
if (count !== doc.count)
|
|
505
|
+
throw new RecoveryInputError(`the list holds ${count} leaves; the root document states ${doc.count}`);
|
|
506
|
+
const hashes = new Array(count);
|
|
507
|
+
let previous = null;
|
|
508
|
+
for (let i = 0; i < count; i++) {
|
|
509
|
+
const bytes = leaves.subarray(i * TREE_LEAF_BYTES, (i + 1) * TREE_LEAF_BYTES);
|
|
510
|
+
if (decodeTreeLeaf(bytes) === null)
|
|
511
|
+
throw new RecoveryInputError(`leaf ${i} is not a valid tree/1 leaf`);
|
|
512
|
+
const artifact = bytes.subarray(1, 33);
|
|
513
|
+
if (previous !== null) {
|
|
514
|
+
const c = compareBytes(previous, artifact);
|
|
515
|
+
if (c === 0)
|
|
516
|
+
throw new RecoveryInputError(`duplicate artifact digest at leaves ${i - 1} and ${i}`);
|
|
517
|
+
if (c > 0)
|
|
518
|
+
throw new RecoveryInputError(`leaves ${i - 1} and ${i} are out of order`);
|
|
519
|
+
}
|
|
520
|
+
previous = artifact;
|
|
521
|
+
hashes[i] = merkleLeafHash(bytes);
|
|
522
|
+
}
|
|
523
|
+
const tree = new MerkleTree(hashes);
|
|
524
|
+
if (!bytesEqual(tree.root, doc.root))
|
|
525
|
+
throw new RecoveryInputError("the leaves do not rebuild the committed root");
|
|
526
|
+
return {
|
|
527
|
+
proofHash: parts.proofHash,
|
|
528
|
+
proofHash32,
|
|
529
|
+
locator,
|
|
530
|
+
rootDocument: parts.rootDocument.slice(),
|
|
531
|
+
rootDocumentHex: bytesToHex(parts.rootDocument),
|
|
532
|
+
count,
|
|
533
|
+
leaves: leaves.slice(),
|
|
534
|
+
tree,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/** The proof's own parts: its proof hash, its position and its signed artifact digest. Throws for anything that is not a tree/1 proof with a position. */
|
|
538
|
+
export function recoveryProofParts(proof) {
|
|
539
|
+
if (!isTreeProof(proof))
|
|
540
|
+
throw new RecoveryInputError("recovery entries are made for tree/1 proofs only");
|
|
541
|
+
const c = proof.commit;
|
|
542
|
+
const locator = parseLocator({ epochId: c?.epochId, counter: c?.counter, artifactDigestB64: proof.artifact?.digestB64 });
|
|
543
|
+
if (locator === null)
|
|
544
|
+
throw new RecoveryInputError("the proof carries no well-formed position (commit.epochId, commit.counter) or artifact digest");
|
|
545
|
+
return { proofHash: computeProofHash(proof), locator };
|
|
546
|
+
}
|
|
547
|
+
/** A tree/1 proof and its owner's list, checked and ready to seal. */
|
|
548
|
+
export function recoveryTreeFrom(input) {
|
|
549
|
+
const { proofHash, locator } = recoveryProofParts(input.proof);
|
|
550
|
+
const rootDocument = input.rootDocument ?? readTreeMetadata(input.proof);
|
|
551
|
+
if (!rootDocument)
|
|
552
|
+
throw new RecoveryInputError("no root document: neither supplied nor in the proof's metadata");
|
|
553
|
+
return recoveryTreeFromParts({ proofHash, locator, rootDocument, leaves: leavesBytesOf(input) });
|
|
554
|
+
}
|
|
555
|
+
/** The owner's list as bytes, from whichever form the caller holds. */
|
|
556
|
+
export function leavesBytesOf(input) {
|
|
557
|
+
if (input.leavesBytes instanceof Uint8Array)
|
|
558
|
+
return input.leavesBytes;
|
|
559
|
+
const l = input.leaves;
|
|
560
|
+
if (l instanceof Uint8Array)
|
|
561
|
+
return l;
|
|
562
|
+
if (!Array.isArray(l) || l.length === 0)
|
|
563
|
+
throw new RecoveryInputError("no leaves: the owner's list is required to seal entries");
|
|
564
|
+
const out = new Uint8Array(l.length * TREE_LEAF_BYTES);
|
|
565
|
+
l.forEach((leaf, i) => {
|
|
566
|
+
if (!(leaf.artifact instanceof Uint8Array) || leaf.artifact.length !== 32 || !(leaf.origin instanceof Uint8Array) || leaf.origin.length !== 32) {
|
|
567
|
+
throw new RecoveryInputError(`leaf ${i} is not a tree/1 leaf`);
|
|
568
|
+
}
|
|
569
|
+
out[i * TREE_LEAF_BYTES] = leaf.placement;
|
|
570
|
+
out.set(leaf.artifact, i * TREE_LEAF_BYTES + 1);
|
|
571
|
+
out.set(leaf.origin, i * TREE_LEAF_BYTES + 33);
|
|
572
|
+
});
|
|
573
|
+
return out;
|
|
574
|
+
}
|
|
575
|
+
/** Leaf `index` of the list, raw. */
|
|
576
|
+
export function leafBytesAt(tree, index) {
|
|
577
|
+
return tree.leaves.subarray(index * TREE_LEAF_BYTES, (index + 1) * TREE_LEAF_BYTES);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Member `index`'s plaintext and its bytes. A name that would push the
|
|
581
|
+
* plaintext past MAX_PLAINTEXT_BYTES (only possible with hundreds of escaped
|
|
582
|
+
* control characters) is dropped, never the entry.
|
|
583
|
+
*/
|
|
584
|
+
export function recoveryPlaintextFor(tree, index, name, salt) {
|
|
585
|
+
if (!Number.isInteger(index) || index < 0 || index >= tree.count)
|
|
586
|
+
throw new RangeError("member index out of range");
|
|
587
|
+
const leaf = decodeTreeLeaf(leafBytesAt(tree, index));
|
|
588
|
+
if (leaf === null)
|
|
589
|
+
throw new RecoveryInputError(`leaf ${index} is not a valid tree/1 leaf`);
|
|
590
|
+
if (salt !== undefined && salt !== null && salt.length !== SALT_BYTES)
|
|
591
|
+
throw new TypeError(`a salt is ${SALT_BYTES} bytes`);
|
|
592
|
+
const base = {
|
|
593
|
+
format: RECOVERY_FORMAT,
|
|
594
|
+
proofHash: tree.proofHash,
|
|
595
|
+
leafIndex: index,
|
|
596
|
+
rootDocument: tree.rootDocumentHex,
|
|
597
|
+
member: buildTreeMemberEvidence(leaf, index, tree.count, tree.tree.path(index)),
|
|
598
|
+
proof: { ...tree.locator },
|
|
599
|
+
...(salt !== undefined && salt !== null ? { salt: bytesToBase64(salt) } : {}),
|
|
600
|
+
};
|
|
601
|
+
const clamped = clampRecoveryName(name);
|
|
602
|
+
if (clamped !== undefined) {
|
|
603
|
+
const named = { ...base, name: clamped };
|
|
604
|
+
const bytes = encodeRecoveryPlaintext(named);
|
|
605
|
+
if (bytes.length <= MAX_PLAINTEXT_BYTES)
|
|
606
|
+
return { plaintext: named, bytes };
|
|
607
|
+
}
|
|
608
|
+
const bytes = encodeRecoveryPlaintext(base);
|
|
609
|
+
if (bytes.length > MAX_PLAINTEXT_BYTES)
|
|
610
|
+
throw new RecoveryInputError(`member ${index}'s plaintext is ${bytes.length} bytes, over ${MAX_PLAINTEXT_BYTES}`);
|
|
611
|
+
return { plaintext: base, bytes };
|
|
612
|
+
}
|
|
613
|
+
/** The sides member `index` is filed under: ["as-is"] for placement 0x00, else ["origin", "artifact"]. */
|
|
614
|
+
export function recoverySidesOf(tree, index) {
|
|
615
|
+
return leafBytesAt(tree, index)[0] === LEAF_AS_IS ? ["as-is"] : ["origin", "artifact"];
|
|
616
|
+
}
|
|
617
|
+
/** The digest one side of member `index` is filed under. */
|
|
618
|
+
export function recoveryDigestOf(tree, index, side) {
|
|
619
|
+
const leaf = leafBytesAt(tree, index);
|
|
620
|
+
return (side === "artifact" ? leaf.subarray(1, 33) : leaf.subarray(33, 65)).slice();
|
|
621
|
+
}
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// Progress: one byte per member, the same for every writer that resumes
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
/** Bit 0 origin kept, bit 1 committed bytes kept, bit 2 origin blocked, bit 3 committed bytes blocked. An as-is member sets both bits of a kind at once. */
|
|
626
|
+
export const ORIGIN_KEPT = 1;
|
|
627
|
+
export const ARTIFACT_KEPT = 2;
|
|
628
|
+
export const ORIGIN_BLOCKED = 4;
|
|
629
|
+
export const ARTIFACT_BLOCKED = 8;
|
|
630
|
+
export const RECOVERY_SIDE_BITS = {
|
|
631
|
+
origin: { kept: ORIGIN_KEPT, blocked: ORIGIN_BLOCKED },
|
|
632
|
+
artifact: { kept: ARTIFACT_KEPT, blocked: ARTIFACT_BLOCKED },
|
|
633
|
+
"as-is": { kept: ORIGIN_KEPT | ARTIFACT_KEPT, blocked: ORIGIN_BLOCKED | ARTIFACT_BLOCKED },
|
|
634
|
+
};
|
|
635
|
+
/** A member's status from its progress byte alone (so a finished job, whose list is gone, still answers). */
|
|
636
|
+
export function recoveryMemberStatus(b) {
|
|
637
|
+
if ((b & (ORIGIN_KEPT | ARTIFACT_KEPT)) === (ORIGIN_KEPT | ARTIFACT_KEPT))
|
|
638
|
+
return "recoverable";
|
|
639
|
+
if ((b & (ORIGIN_BLOCKED | ARTIFACT_BLOCKED)) !== 0)
|
|
640
|
+
return "blocked";
|
|
641
|
+
return "pending";
|
|
642
|
+
}
|
|
643
|
+
export function recoverySideState(b, side) {
|
|
644
|
+
const s = RECOVERY_SIDE_BITS[side];
|
|
645
|
+
if ((b & s.kept) === s.kept)
|
|
646
|
+
return "kept";
|
|
647
|
+
if ((b & s.blocked) === s.blocked)
|
|
648
|
+
return "blocked";
|
|
649
|
+
return "pending";
|
|
650
|
+
}
|
|
651
|
+
export const recoverySideResolved = (b, side) => recoverySideState(b, side) !== "pending";
|
|
652
|
+
/** The key a member's side has in a writer's salt table: "<leafIndex>:<side>". */
|
|
653
|
+
export const recoverySaltKey = (index, side) => `${index}:${side}`;
|
|
654
|
+
/** A writer's salt table (base64 salts by recoverySaltKey) as the `salts` sealRecoveryMember takes for member `index`; null when it holds none for that member. */
|
|
655
|
+
export function recoverySaltsFor(salts, index) {
|
|
656
|
+
if (salts === null || salts === undefined)
|
|
657
|
+
return null;
|
|
658
|
+
let out = null;
|
|
659
|
+
for (const side of ["origin", "artifact", "as-is"]) {
|
|
660
|
+
const b64 = salts[recoverySaltKey(index, side)];
|
|
661
|
+
if (b64 === undefined)
|
|
662
|
+
continue;
|
|
663
|
+
const salt = base64ToBytes(b64);
|
|
664
|
+
if (salt === null || salt.length !== SALT_BYTES)
|
|
665
|
+
throw new RecoveryInputError(`the salt kept for member ${index} (${side}) is not ${SALT_BYTES} bytes`);
|
|
666
|
+
(out ??= {})[side] = salt;
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
/** The object key one side of member `index` is filed under: deterministic, or salted when a salt is given. */
|
|
671
|
+
export function recoveryObjectKeyFor(tree, index, side, salt) {
|
|
672
|
+
const digest = recoveryDigestOf(tree, index, side);
|
|
673
|
+
const id = salt !== undefined && salt !== null ? recoverySaltedEntryId(digest, tree.proofHash32, index, salt) : recoveryEntryId(digest, tree.proofHash32, index);
|
|
674
|
+
return recoveryObjectKey(recoveryAddress(digest), id);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Seal member `index`: one envelope per side (or only the sides asked for),
|
|
678
|
+
* each under its own key, nonce and AAD. A side named in `salts` is sealed
|
|
679
|
+
* under its salted name, with the salt in its plaintext; `plaintext` is the
|
|
680
|
+
* unsalted one, the member all of them describe.
|
|
681
|
+
*/
|
|
682
|
+
export async function sealRecoveryMember(tree, index, name, only, salts) {
|
|
683
|
+
const { plaintext } = recoveryPlaintextFor(tree, index, name);
|
|
684
|
+
const sides = recoverySidesOf(tree, index).filter((s) => only === undefined || only.includes(s));
|
|
685
|
+
const writes = await Promise.all(sides.map(async (side) => {
|
|
686
|
+
const digest = recoveryDigestOf(tree, index, side);
|
|
687
|
+
const salt = salts?.[side] ?? null;
|
|
688
|
+
const deterministicKey = recoveryObjectKey(recoveryAddress(digest), recoveryEntryId(digest, tree.proofHash32, index));
|
|
689
|
+
const objectKey = salt === null ? deterministicKey : recoveryObjectKey(recoveryAddress(digest), recoverySaltedEntryId(digest, tree.proofHash32, index, salt));
|
|
690
|
+
const sealed = salt === null ? recoveryPlaintextFor(tree, index, name) : recoveryPlaintextFor(tree, index, name, salt);
|
|
691
|
+
return { side, digest, objectKey, envelope: await sealRecoveryEnvelope(recoveryKeyBytes(digest), objectKey, sealed.bytes), deterministicKey, salted: salt !== null, plaintext: sealed.plaintext };
|
|
692
|
+
}));
|
|
693
|
+
return { plaintext, writes };
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* The 412 path's test: the envelope already stored at `objectKey` holds THIS
|
|
697
|
+
* member (sameRecoveryMember), opened with the digest's key at that key. A
|
|
698
|
+
* stored envelope that does not open, does not parse, or holds anything else
|
|
699
|
+
* is somebody else's entry, and the write is not a success.
|
|
700
|
+
*/
|
|
701
|
+
export async function existingEntryHoldsMember(digest32, objectKey, envelope, expected) {
|
|
702
|
+
const plain = await openRecoveryEnvelope(recoveryKeyBytes(digest32), objectKey, envelope);
|
|
703
|
+
if (plain === null)
|
|
704
|
+
return false;
|
|
705
|
+
const found = parseRecoveryPlaintext(plain);
|
|
706
|
+
if (found === null || !sameRecoveryMember(found, expected))
|
|
707
|
+
return false;
|
|
708
|
+
// And it must sit under the name its own plaintext derives, exactly as a
|
|
709
|
+
// reader demands: this member's plaintext with a salt in it, parked at the
|
|
710
|
+
// deterministic name, is nobody's entry (no reader accepts it), and a
|
|
711
|
+
// writer that took it for its own would never write the copy that works.
|
|
712
|
+
const parsed = parseRecoveryObjectKey(objectKey);
|
|
713
|
+
return parsed !== null && recoveryEntryIdOf(digest32, found) === parsed.entryId;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* This member's own entry under its address, if one is already there under
|
|
717
|
+
* any name: the deterministic one, or a salted one from an earlier run whose
|
|
718
|
+
* salt was lost (a job finished and removed, a browser tab's save that
|
|
719
|
+
* failed). Returns the entry's key and, for a salted one, its salt, so the
|
|
720
|
+
* writer counts it kept and reuses the salt instead of leaving a second copy.
|
|
721
|
+
* Null when none is there; raises RecoveryUnavailableError when the address
|
|
722
|
+
* cannot be read (then the writer must not guess).
|
|
723
|
+
*/
|
|
724
|
+
export async function findOwnRecoveryEntry(digest32, expected, fetchFn = defaultFetch, opts = {}) {
|
|
725
|
+
checkDigest(digest32);
|
|
726
|
+
const address = recoveryAddress(digest32);
|
|
727
|
+
const key = await importKey(recoveryKeyBytes(digest32));
|
|
728
|
+
const entries = await collectPages(address, digest32, key, null, fetchFn, opts);
|
|
729
|
+
for (const e of entries) {
|
|
730
|
+
const found = { format: RECOVERY_FORMAT, proofHash: e.proofHash, leafIndex: e.leafIndex, rootDocument: e.rootDocument, member: e.member, proof: e.proof };
|
|
731
|
+
if (!sameRecoveryMember(found, expected))
|
|
732
|
+
continue;
|
|
733
|
+
if (!e.salted)
|
|
734
|
+
return { objectKey: e.objectKey, salt: null };
|
|
735
|
+
const salt = e.salt !== null ? base64ToBytes(e.salt) : null;
|
|
736
|
+
if (salt !== null && salt.length === SALT_BYTES)
|
|
737
|
+
return { objectKey: e.objectKey, salt };
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
/** An abort signal for one read: the caller's limit, or the default. */
|
|
742
|
+
const readSignal = (opts) => AbortSignal.timeout(opts.timeoutMs ?? LOOKUP_TIMEOUT_MS);
|
|
743
|
+
function checkPage(body) {
|
|
744
|
+
if (!isPlainObject(body) || !Array.isArray(body["entries"]) || !(body["next"] === null || typeof body["next"] === "string")) {
|
|
745
|
+
throw new RecoveryUnavailableError("the listing is not { entries, next }");
|
|
746
|
+
}
|
|
747
|
+
return { entries: body["entries"], next: body["next"] };
|
|
748
|
+
}
|
|
749
|
+
/** Run `fn` over `items`, at most `limit` at once, results in item order. */
|
|
750
|
+
async function mapPool(items, limit, fn) {
|
|
751
|
+
const out = new Array(items.length);
|
|
752
|
+
let next = 0;
|
|
753
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
754
|
+
while (next < items.length) {
|
|
755
|
+
const i = next++;
|
|
756
|
+
out[i] = await fn(items[i], i);
|
|
757
|
+
}
|
|
758
|
+
}));
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
const asPlaintext = (e) => ({ format: RECOVERY_FORMAT, proofHash: e.proofHash, leafIndex: e.leafIndex, rootDocument: e.rootDocument, member: e.member, proof: e.proof });
|
|
762
|
+
/**
|
|
763
|
+
* One listing per member: the same member under its deterministic name and
|
|
764
|
+
* under a salted one (a writer that met a held key, or wrote twice) is one
|
|
765
|
+
* recovery. Two entries that name the same proof and leaf but describe
|
|
766
|
+
* different members are both kept; binding each to its proof sorts them out.
|
|
767
|
+
*/
|
|
768
|
+
function dedupeRecovered(entries) {
|
|
769
|
+
// One pass: the member's identity as a key (the same fields sameRecoveryMember compares), so a long listing costs time in proportion, not squared.
|
|
770
|
+
const seen = new Set();
|
|
771
|
+
const out = [];
|
|
772
|
+
for (const e of entries) {
|
|
773
|
+
const key = [e.proofHash, e.leafIndex, e.rootDocument, e.member.index, e.member.count, e.member.leaf, e.member.path.join(","), e.proof.epochId, e.proof.counter, e.proof.artifactDigestB64].join("|");
|
|
774
|
+
if (seen.has(key))
|
|
775
|
+
continue;
|
|
776
|
+
seen.add(key);
|
|
777
|
+
out.push(e);
|
|
778
|
+
}
|
|
779
|
+
return out;
|
|
780
|
+
}
|
|
781
|
+
/** A listing with more distinct members than a reader will bind is not an answer: each member costs a proof fetch, and a file is not honestly recorded this often. */
|
|
782
|
+
function checkRecoveredCount(entries) {
|
|
783
|
+
if (entries.length > MAX_RECOVERED_PER_DIGEST)
|
|
784
|
+
throw new RecoveryUnavailableError(`more than ${MAX_RECOVERED_PER_DIGEST} members listed under one address`);
|
|
785
|
+
return entries;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Every entry under `address` that opens for `digest32`. `first` is a page
|
|
789
|
+
* already in hand (from a batch lookup); otherwise the pages are read with
|
|
790
|
+
* GET /api/recovery/<address> from the start. Either way the listing is
|
|
791
|
+
* followed to its end, or the lookup fails: a partial listing is never an
|
|
792
|
+
* answer.
|
|
793
|
+
*/
|
|
794
|
+
async function collectPages(address, digest32, key, first, fetchFn, opts) {
|
|
795
|
+
const base = opts.baseUrl ?? "";
|
|
796
|
+
const maxPages = opts.maxPages ?? 1000;
|
|
797
|
+
const out = [];
|
|
798
|
+
let after = null;
|
|
799
|
+
let page = first;
|
|
800
|
+
for (let n = 0;; n++) {
|
|
801
|
+
if (n >= maxPages)
|
|
802
|
+
throw new RecoveryUnavailableError(`more than ${maxPages} pages under one address`);
|
|
803
|
+
if (page === null) {
|
|
804
|
+
const url = `${base}/api/recovery/${address}${after !== null ? `?after=${after}` : ""}`;
|
|
805
|
+
let body;
|
|
806
|
+
try {
|
|
807
|
+
// Any status but 200 is a failed read, a 404 included: a route that is
|
|
808
|
+
// missing (a rollback, a routing fault) says nothing about the entries
|
|
809
|
+
// that may sit in storage behind it.
|
|
810
|
+
const res = await fetchFn(url, { headers: { accept: "application/json" }, signal: readSignal(opts) });
|
|
811
|
+
if (!res.ok)
|
|
812
|
+
throw new Error(`HTTP ${res.status}`);
|
|
813
|
+
body = await res.json();
|
|
814
|
+
}
|
|
815
|
+
catch (e) {
|
|
816
|
+
throw new RecoveryUnavailableError(`GET /api/recovery/${address.slice(0, 8)}…`, e);
|
|
817
|
+
}
|
|
818
|
+
page = checkPage(body);
|
|
819
|
+
}
|
|
820
|
+
for (const item of page.entries) {
|
|
821
|
+
const found = await openListed(item, digest32, address, key);
|
|
822
|
+
if (found !== null)
|
|
823
|
+
out.push(found);
|
|
824
|
+
}
|
|
825
|
+
const next = page.next;
|
|
826
|
+
if (next === null)
|
|
827
|
+
break;
|
|
828
|
+
if (!ENTRY_ID_PATTERN.test(next) || (after !== null && next <= after))
|
|
829
|
+
throw new RecoveryUnavailableError("the listing's cursor does not advance");
|
|
830
|
+
after = next;
|
|
831
|
+
page = null;
|
|
832
|
+
}
|
|
833
|
+
return dedupeRecovered(out);
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Every recording of the file whose SHA-256 is `digest32`, from the sealed
|
|
837
|
+
* entries under its address: one entry per member per recording, so the same
|
|
838
|
+
* file recorded twice comes back twice (and the same member under two names
|
|
839
|
+
* once). An entry that does not authenticate, does not parse, sits under an
|
|
840
|
+
* entry id its own contents do not derive, or names a leaf that is not this
|
|
841
|
+
* digest is skipped: it is not ours to show. The list is in the server's
|
|
842
|
+
* listing order; order the recordings by their proofs' own times once
|
|
843
|
+
* fetched.
|
|
844
|
+
*
|
|
845
|
+
* Raises RecoveryUnavailableError when the entries cannot be read, a route
|
|
846
|
+
* that answers 404 included. An empty array is an answer (nothing is kept
|
|
847
|
+
* for these bytes); a failure never is.
|
|
848
|
+
*/
|
|
849
|
+
export async function recoverFromDigest(digest32, fetchFn = defaultFetch, opts = {}) {
|
|
850
|
+
checkDigest(digest32);
|
|
851
|
+
const address = recoveryAddress(digest32);
|
|
852
|
+
const key = await importKey(recoveryKeyBytes(digest32));
|
|
853
|
+
return checkRecoveredCount(await collectPages(address, digest32, key, null, fetchFn, opts));
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* recoverFromDigest for many files at once: the first page of every address
|
|
857
|
+
* in one request (POST /api/recovery/lookup, at most MAX_LOOKUP_ADDRESSES per
|
|
858
|
+
* request), then the rest of any longer listing page by page. One answer per
|
|
859
|
+
* digest, in order; a digest whose entries could not all be read answers
|
|
860
|
+
* { ok: false }, never an empty list. A site without the route (404) is asked
|
|
861
|
+
* one address at a time instead.
|
|
862
|
+
*
|
|
863
|
+
* Asking many addresses in one request tells the server which files were
|
|
864
|
+
* dropped together, which one-by-one requests only hint at by timing. The
|
|
865
|
+
* route logs counts, never addresses (SPEC section 13).
|
|
866
|
+
*/
|
|
867
|
+
export async function recoverFromDigests(digests, fetchFn = defaultFetch, opts = {}) {
|
|
868
|
+
for (const d of digests)
|
|
869
|
+
checkDigest(d);
|
|
870
|
+
const base = opts.baseUrl ?? "";
|
|
871
|
+
const out = new Array(digests.length);
|
|
872
|
+
const messageOf = (e) => (e instanceof Error ? e.message : String(e));
|
|
873
|
+
// The same digest asked twice (an as-is file's original and committed bytes
|
|
874
|
+
// are one digest) is one address in the request and one answer for both.
|
|
875
|
+
const indexesByAddress = new Map();
|
|
876
|
+
for (let i = 0; i < digests.length; i++) {
|
|
877
|
+
const address = recoveryAddress(digests[i]);
|
|
878
|
+
const list = indexesByAddress.get(address);
|
|
879
|
+
if (list === undefined)
|
|
880
|
+
indexesByAddress.set(address, [i]);
|
|
881
|
+
else
|
|
882
|
+
list.push(i);
|
|
883
|
+
}
|
|
884
|
+
const unique = [...indexesByAddress.keys()];
|
|
885
|
+
const slices = [];
|
|
886
|
+
for (let at = 0; at < unique.length; at += MAX_LOOKUP_ADDRESSES)
|
|
887
|
+
slices.push(unique.slice(at, at + MAX_LOOKUP_ADDRESSES));
|
|
888
|
+
const answerAll = (address, a) => {
|
|
889
|
+
for (const i of indexesByAddress.get(address))
|
|
890
|
+
out[i] = a;
|
|
891
|
+
};
|
|
892
|
+
/** POST one slice, trying again after a 429 or a 5xx (Retry-After honoured, at most 30 s) or a network failure, three times in all. A site without the batch route (404, 405, 501) is asked address by address instead. */
|
|
893
|
+
async function postLookup(addresses) {
|
|
894
|
+
let reason = "";
|
|
895
|
+
let retryAfterSec = 0;
|
|
896
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
897
|
+
if (attempt > 0)
|
|
898
|
+
await sleep(Math.min(30_000, retryAfterSec > 0 ? retryAfterSec * 1000 : 500 * 2 ** (attempt - 1)));
|
|
899
|
+
try {
|
|
900
|
+
const res = await fetchFn(`${base}/api/recovery/lookup`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ addresses }), signal: readSignal(opts) });
|
|
901
|
+
if (res.status === 404 || res.status === 405 || res.status === 501)
|
|
902
|
+
return { kind: "no-route" };
|
|
903
|
+
if (res.status === 429 || res.status >= 500) {
|
|
904
|
+
reason = `POST /api/recovery/lookup answered ${res.status}`;
|
|
905
|
+
retryAfterSec = Number(res.headers.get("retry-after")) || 0;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (!res.ok)
|
|
909
|
+
return { kind: "failed", reason: `POST /api/recovery/lookup answered ${res.status}` };
|
|
910
|
+
return { kind: "answered", body: await res.json() };
|
|
911
|
+
}
|
|
912
|
+
catch (e) {
|
|
913
|
+
reason = `POST /api/recovery/lookup: ${messageOf(e)}`;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return { kind: "failed", reason };
|
|
917
|
+
}
|
|
918
|
+
// Up to four requests in flight: 100,000 files are 100 requests.
|
|
919
|
+
await mapPool(slices, 4, async (addresses) => {
|
|
920
|
+
const posted = await postLookup(addresses);
|
|
921
|
+
if (posted.kind === "failed") {
|
|
922
|
+
for (const address of addresses)
|
|
923
|
+
answerAll(address, { ok: false, reason: posted.reason });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const results = new Map();
|
|
927
|
+
if (posted.kind === "answered") {
|
|
928
|
+
if (!isPlainObject(posted.body) || !Array.isArray(posted.body["results"])) {
|
|
929
|
+
for (const address of addresses)
|
|
930
|
+
answerAll(address, { ok: false, reason: "the lookup's answer is not { results }" });
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
for (const r of posted.body["results"])
|
|
934
|
+
if (isPlainObject(r) && typeof r["address"] === "string")
|
|
935
|
+
results.set(r["address"], r);
|
|
936
|
+
}
|
|
937
|
+
// A site without the batch route is asked one address at a time (GET), from the start of each listing; a 404 there is a failed read.
|
|
938
|
+
const oneByOne = posted.kind === "no-route";
|
|
939
|
+
await mapPool(addresses, 8, async (address) => {
|
|
940
|
+
const digest32 = digests[indexesByAddress.get(address)[0]];
|
|
941
|
+
try {
|
|
942
|
+
const key = await importKey(recoveryKeyBytes(digest32));
|
|
943
|
+
let first = null;
|
|
944
|
+
if (!oneByOne) {
|
|
945
|
+
const r = results.get(address);
|
|
946
|
+
if (r === undefined)
|
|
947
|
+
throw new RecoveryUnavailableError("the lookup's answer has no result for this address");
|
|
948
|
+
if (r["error"] !== undefined)
|
|
949
|
+
throw new RecoveryUnavailableError(`the site could not read this address (${String(r["error"])})`);
|
|
950
|
+
if (r["truncated"] !== true)
|
|
951
|
+
first = checkPage(r);
|
|
952
|
+
}
|
|
953
|
+
answerAll(address, { ok: true, entries: checkRecoveredCount(await collectPages(address, digest32, key, first, fetchFn, opts)) });
|
|
954
|
+
}
|
|
955
|
+
catch (e) {
|
|
956
|
+
answerAll(address, { ok: false, reason: messageOf(e) });
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
return out;
|
|
961
|
+
}
|
|
962
|
+
async function openListed(item, digest32, address, key) {
|
|
963
|
+
// The wire shape is the server's to get right: an item that is not { key, envelope } under this
|
|
964
|
+
// address, in canonical base64, is a malformed answer (a failed read), never a candidate to skip.
|
|
965
|
+
if (!isPlainObject(item) || typeof item["key"] !== "string" || typeof item["envelope"] !== "string")
|
|
966
|
+
throw new RecoveryUnavailableError("a listed entry is not { key, envelope }");
|
|
967
|
+
const parsedKey = parseRecoveryObjectKey(item["key"]);
|
|
968
|
+
if (parsedKey === null || parsedKey.address !== address)
|
|
969
|
+
throw new RecoveryUnavailableError("a listed entry's key is not under this address");
|
|
970
|
+
const envelope = base64ToBytes(item["envelope"]);
|
|
971
|
+
if (envelope === null)
|
|
972
|
+
throw new RecoveryUnavailableError("a listed entry's envelope is not canonical base64");
|
|
973
|
+
// From here on, what does not hold is somebody's entry that is not ours: skipped, not a failure.
|
|
974
|
+
const plain = await openWith(key, item["key"], envelope);
|
|
975
|
+
if (plain === null)
|
|
976
|
+
return null;
|
|
977
|
+
const p = parseRecoveryPlaintext(plain);
|
|
978
|
+
if (p === null)
|
|
979
|
+
return null;
|
|
980
|
+
// The name must be the one this plaintext derives: salted when it carries a salt, deterministic when it does not.
|
|
981
|
+
if (recoveryEntryIdOf(digest32, p) !== parsedKey.entryId)
|
|
982
|
+
return null;
|
|
983
|
+
const leaf = parseTreeMemberEvidence(p.member).leaf;
|
|
984
|
+
const isArtifact = bytesEqual(leaf.artifact, digest32);
|
|
985
|
+
const isOrigin = bytesEqual(leaf.origin, digest32);
|
|
986
|
+
if (!isArtifact && !isOrigin)
|
|
987
|
+
return null;
|
|
988
|
+
return {
|
|
989
|
+
objectKey: item["key"],
|
|
990
|
+
entryId: parsedKey.entryId,
|
|
991
|
+
salted: p.salt !== undefined,
|
|
992
|
+
salt: p.salt ?? null,
|
|
993
|
+
matched: leaf.placement === LEAF_AS_IS ? "as-is" : isOrigin ? "origin" : "artifact",
|
|
994
|
+
proofHash: p.proofHash,
|
|
995
|
+
leafIndex: p.leafIndex,
|
|
996
|
+
rootDocument: p.rootDocument,
|
|
997
|
+
member: p.member,
|
|
998
|
+
proof: p.proof,
|
|
999
|
+
name: p.name ?? null,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Whether the proof's attestation verified in full (AWS signature, chain,
|
|
1004
|
+
* root, validity, PCR0 as the proof names it, bound to this proof's signed
|
|
1005
|
+
* body) and attests an image the policy accepts. "undetermined" when the
|
|
1006
|
+
* policy is empty: nothing was judged, and a reader must leave the candidate
|
|
1007
|
+
* unresolved rather than call it negative.
|
|
1008
|
+
*/
|
|
1009
|
+
export function proofTrusted(proof, policy = "published") {
|
|
1010
|
+
if (policy === "none")
|
|
1011
|
+
return "trusted";
|
|
1012
|
+
if (Array.isArray(policy) && policy.length === 0)
|
|
1013
|
+
return "undetermined";
|
|
1014
|
+
const env = proof.environment;
|
|
1015
|
+
const measurement = typeof env?.measurement === "string" ? env.measurement.toLowerCase() : null;
|
|
1016
|
+
const reportB64 = env?.attestation?.reportB64;
|
|
1017
|
+
if (measurement === null || typeof reportB64 !== "string")
|
|
1018
|
+
return "rejected";
|
|
1019
|
+
let n;
|
|
1020
|
+
try {
|
|
1021
|
+
n = verifyNitroAttestation(reportB64, { expectedPcr0: measurement, expectedUserDataB64: computeSignedBodyHash(proof) });
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
return "rejected";
|
|
1025
|
+
}
|
|
1026
|
+
const byName = (prefix) => n.checks.find((c) => c.name.startsWith(prefix));
|
|
1027
|
+
const all = [byName("AWS signature"), byName("Certificate chain"), byName("Chains to") ?? byName("Trust root"), byName("Certificate validity"), byName("PCR0"), byName("Bound to this proof")];
|
|
1028
|
+
if (n.doc === null || !all.every((c) => c?.pass === true))
|
|
1029
|
+
return "rejected";
|
|
1030
|
+
const docPcr0 = n.doc.pcrs?.[0];
|
|
1031
|
+
if (typeof docPcr0 !== "string")
|
|
1032
|
+
return "rejected";
|
|
1033
|
+
const measured = docPcr0.toLowerCase();
|
|
1034
|
+
const accepted = policy === "published" ? publishedMeasurement(measured) !== null : policy.some((p) => p.toLowerCase() === measured);
|
|
1035
|
+
return accepted ? "trusted" : "rejected";
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* The proof a recovered entry points at, from the public proof routes by its
|
|
1039
|
+
* artifact digest, bound by proof hash (a proof whose computeProofHash is not
|
|
1040
|
+
* the entry's is not this recording) and checked with verifyTreeMember.
|
|
1041
|
+
* Null when no proof under that digest has this proof hash. Raises
|
|
1042
|
+
* RecoveryUnavailableError when the route cannot be read.
|
|
1043
|
+
*
|
|
1044
|
+
* With the proof and the entry in hand an export is the existing machinery:
|
|
1045
|
+
* fuse-tree's buildTreeExport(proof, memberTree(rootDocument, member),
|
|
1046
|
+
* await fetchTreeEvidence(proof)).
|
|
1047
|
+
*
|
|
1048
|
+
* ⚠️ THIS READ NEEDS THE LEDGER'S BY-DIGEST INDEX for the tree's artifact
|
|
1049
|
+
* digest, which exists while per-proof writes are on (LEDGER_WRITES). If they
|
|
1050
|
+
* are ever switched off, the entry still names the proof exactly: the
|
|
1051
|
+
* parent's own key is proofs/<url-safe epochId>/<counter, 12 digits>-<url-safe
|
|
1052
|
+
* proofHash>.json, and a read-only route serving that key is all this needs.
|
|
1053
|
+
*/
|
|
1054
|
+
export async function fetchRecoveredProof(entry, fetchFn = defaultFetch, opts = {}) {
|
|
1055
|
+
const url = `${opts.baseUrl ?? ""}/api/proofs/${toUrlSafe(entry.proof.artifactDigestB64)}`;
|
|
1056
|
+
let body;
|
|
1057
|
+
try {
|
|
1058
|
+
const res = await fetchFn(url, { headers: { accept: "application/json" }, signal: readSignal(opts) });
|
|
1059
|
+
if (!res.ok)
|
|
1060
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1061
|
+
body = await res.json();
|
|
1062
|
+
}
|
|
1063
|
+
catch (e) {
|
|
1064
|
+
throw new RecoveryUnavailableError("GET /api/proofs/<artifact digest>", e);
|
|
1065
|
+
}
|
|
1066
|
+
// The answer's shape is the route's to get right: anything but { proofs: [...] } is a failed read, never "no proof".
|
|
1067
|
+
if (!isPlainObject(body) || !Array.isArray(body["proofs"]))
|
|
1068
|
+
throw new RecoveryUnavailableError("the proof route's answer is not { proofs }");
|
|
1069
|
+
const proofs = body["proofs"];
|
|
1070
|
+
// The route says so itself when a miss is not a finding: the ledger no longer
|
|
1071
|
+
// indexes proofs by digest. The entry may well name a real proof; nothing here
|
|
1072
|
+
// can read it, so this is unavailable, never "not this file's recording".
|
|
1073
|
+
if (proofs.length === 0 && isPlainObject(body) && body["discovery"] === "retired")
|
|
1074
|
+
throw new RecoveryUnavailableError("the ledger's index of proofs by digest is retired; the entry's proof cannot be read through this route");
|
|
1075
|
+
const rootDocument = hexToBytes(entry.rootDocument);
|
|
1076
|
+
if (rootDocument === null)
|
|
1077
|
+
return null;
|
|
1078
|
+
for (const item of proofs) {
|
|
1079
|
+
const proof = isPlainObject(item) && isPlainObject(item["proof"]) ? item["proof"] : null;
|
|
1080
|
+
if (proof === null)
|
|
1081
|
+
continue;
|
|
1082
|
+
let hash;
|
|
1083
|
+
try {
|
|
1084
|
+
hash = computeProofHash(proof);
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if (hash !== entry.proofHash)
|
|
1090
|
+
continue;
|
|
1091
|
+
// The locator names this proof's own signed fields, exactly: a plaintext
|
|
1092
|
+
// whose locator disagrees with the proof it hashes to is not an entry a
|
|
1093
|
+
// reader accepts.
|
|
1094
|
+
const p = proof;
|
|
1095
|
+
if (p.commit?.epochId !== entry.proof.epochId || p.commit?.counter !== entry.proof.counter || p.artifact?.digestB64 !== entry.proof.artifactDigestB64)
|
|
1096
|
+
continue;
|
|
1097
|
+
// A recovered BitGraph is a BitGraph: beyond the signature and the tree,
|
|
1098
|
+
// its attestation must verify in full and attest an image the policy
|
|
1099
|
+
// accepts (SPEC sections 5.6 and 16). A proof that fails is nobody's
|
|
1100
|
+
// recording of this file, whatever the index served it from; a policy
|
|
1101
|
+
// that judges nothing leaves the candidate unresolved, never negative.
|
|
1102
|
+
const trust = proofTrusted(proof, opts.trust ?? "published");
|
|
1103
|
+
if (trust === "undetermined")
|
|
1104
|
+
throw new RecoveryUnavailableError("the reader's measurement policy is empty, so the recovered proof's attestation was not judged");
|
|
1105
|
+
if (trust === "rejected")
|
|
1106
|
+
continue;
|
|
1107
|
+
const check = await verifyTreeMember({
|
|
1108
|
+
proof,
|
|
1109
|
+
member: entry.member,
|
|
1110
|
+
rootDocument,
|
|
1111
|
+
...(opts.bytes !== undefined ? { bytes: opts.bytes } : {}),
|
|
1112
|
+
...(opts.source !== undefined ? { source: opts.source } : {}),
|
|
1113
|
+
...(opts.extraSpecHashes !== undefined ? { extraSpecHashes: opts.extraSpecHashes } : {}),
|
|
1114
|
+
});
|
|
1115
|
+
// A proof that pins a spec this reader does not know is not judged, so it is
|
|
1116
|
+
// not negative either: the candidate stays unresolved and the file unknown
|
|
1117
|
+
// (unless another candidate verifies).
|
|
1118
|
+
if (check.category === "UNKNOWN_SPEC")
|
|
1119
|
+
throw new RecoveryUnavailableError(`unresolved: ${check.reason}`);
|
|
1120
|
+
return { proof, check };
|
|
1121
|
+
}
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
//# sourceMappingURL=recovery.js.map
|