@filefreak.io/sdk 0.0.0-stage → 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 +153 -2
- package/dist/client.d.ts +76 -0
- package/dist/client.js +164 -0
- package/dist/generated.d.ts +1005 -0
- package/dist/generated.js +318 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +12 -0
- package/dist/md5.d.ts +18 -0
- package/dist/md5.js +130 -0
- package/dist/node.d.ts +16 -0
- package/dist/node.js +51 -0
- package/dist/ranges.d.ts +34 -0
- package/dist/ranges.js +303 -0
- package/dist/shards.d.ts +59 -0
- package/dist/shards.js +140 -0
- package/dist/transport.d.ts +61 -0
- package/dist/transport.js +173 -0
- package/dist/uploads.d.ts +53 -0
- package/dist/uploads.js +259 -0
- package/package.json +27 -4
package/dist/ranges.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Fetching one file from storage as several ranges at once, each written
|
|
2
|
+
// where it belongs.
|
|
3
|
+
//
|
|
4
|
+
// The website's engine for saving a large file (web/lib/rangedSave.ts),
|
|
5
|
+
// without the page: the same measurements, the same rules. One request from
|
|
6
|
+
// storage was measured at 360 to 420 Mbps on a line that carries about
|
|
7
|
+
// 1 Gbps, and six ranges at once at 560 to 660 (plan 04, B1.5), so a file
|
|
8
|
+
// large enough is fetched as up to six ranges, and a small one as one.
|
|
9
|
+
//
|
|
10
|
+
// One address, asked for once, because asking for another counts the file as
|
|
11
|
+
// downloaded again. An address works for fifteen minutes and a request that
|
|
12
|
+
// has started is not cut off when it passes, so each range is one long
|
|
13
|
+
// request, all of them made at the start. When one finishes early it takes
|
|
14
|
+
// the second half of the largest one left, as a new request. If that request
|
|
15
|
+
// is not answered (the address has passed, say), the half goes back to the
|
|
16
|
+
// range it was taken from, whose own request asked for it in the first place:
|
|
17
|
+
// that range waits at the point it was cut until the new request has been
|
|
18
|
+
// answered, so that it is still there to give back to. A range whose
|
|
19
|
+
// connection breaks, or goes quiet, is asked for again from where it stopped.
|
|
20
|
+
// After the address has passed that is refused, and the download stops with
|
|
21
|
+
// an error saying so.
|
|
22
|
+
import { FileFreakError } from "./transport.js";
|
|
23
|
+
const MIB = 1024 * 1024;
|
|
24
|
+
/** At most this many ranges at once. Six filled the line measured; more only adds requests. */
|
|
25
|
+
export const RANGES_AT_ONCE = 6;
|
|
26
|
+
/** No range is cut smaller than this: each is a request, and a request costs a round trip before its first byte. */
|
|
27
|
+
export const LEAST_RANGE = 16 * MIB;
|
|
28
|
+
/** Written to the sink this much at a time. Fewer writes, for this much memory for each range. */
|
|
29
|
+
const WRITE_BYTES = 4 * MIB;
|
|
30
|
+
/** A connection that breaks is asked for again this many times in a row, from where it stopped. */
|
|
31
|
+
const TRIES = 5;
|
|
32
|
+
/** A request with nothing arriving for this long is given up and asked for again. */
|
|
33
|
+
const QUIET_MS = 30_000;
|
|
34
|
+
/** A connection that broke or went quiet. The only failure worth asking again after. */
|
|
35
|
+
class Broke extends Error {
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fetch `url` as ranges and write each where it belongs. Resolves when every
|
|
39
|
+
* byte has been written; rejects, with every request stopped, when one range
|
|
40
|
+
* cannot be finished.
|
|
41
|
+
*/
|
|
42
|
+
export async function fetchRanges(options) {
|
|
43
|
+
// The first range that fails stops the others: the file cannot be finished without it.
|
|
44
|
+
const stop = new AbortController();
|
|
45
|
+
const outer = options.signal;
|
|
46
|
+
const passOn = () => stop.abort(outer?.reason);
|
|
47
|
+
if (outer?.aborted)
|
|
48
|
+
passOn();
|
|
49
|
+
outer?.addEventListener("abort", passOn);
|
|
50
|
+
let written = 0;
|
|
51
|
+
const run = {
|
|
52
|
+
url: options.url,
|
|
53
|
+
sizeBytes: options.sizeBytes,
|
|
54
|
+
sink: options.sink,
|
|
55
|
+
signal: stop.signal,
|
|
56
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
|
|
57
|
+
sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
58
|
+
quietMs: options.quietMs ?? QUIET_MS,
|
|
59
|
+
wrote: (bytes) => {
|
|
60
|
+
written += bytes;
|
|
61
|
+
options.progress?.(written);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const least = options.leastRange ?? LEAST_RANGE;
|
|
65
|
+
const most = Math.max(1, Math.min(RANGES_AT_ONCE, options.ranges ?? RANGES_AT_ONCE));
|
|
66
|
+
const count = Math.max(1, Math.min(most, Math.floor(options.sizeBytes / least)));
|
|
67
|
+
const ranges = [];
|
|
68
|
+
for (let i = 0; i < count; i++) {
|
|
69
|
+
ranges.push({ at: Math.floor((i * options.sizeBytes) / count), end: Math.floor(((i + 1) * options.sizeBytes) / count), giving: null });
|
|
70
|
+
}
|
|
71
|
+
// One cut at a time, so that a range has at most one half out that could come back.
|
|
72
|
+
let cut = null;
|
|
73
|
+
let cutting = most > 1;
|
|
74
|
+
const worker = async (first) => {
|
|
75
|
+
await read(first, run);
|
|
76
|
+
for (;;) {
|
|
77
|
+
while (cut)
|
|
78
|
+
await cut;
|
|
79
|
+
if (!cutting || stop.signal.aborted)
|
|
80
|
+
return;
|
|
81
|
+
const from = ranges.filter((r) => r.end - r.at >= 2 * least).sort((a, b) => b.end - b.at - (a.end - a.at))[0];
|
|
82
|
+
if (!from)
|
|
83
|
+
return;
|
|
84
|
+
const before = from.end;
|
|
85
|
+
const half = { at: from.at + Math.floor((from.end - from.at) / 2), end: before, giving: null };
|
|
86
|
+
let settle = () => { };
|
|
87
|
+
cut = from.giving = new Promise((resolve) => (settle = resolve));
|
|
88
|
+
const settled = () => {
|
|
89
|
+
from.giving = null;
|
|
90
|
+
cut = null;
|
|
91
|
+
settle();
|
|
92
|
+
};
|
|
93
|
+
from.end = half.at;
|
|
94
|
+
ranges.push(half);
|
|
95
|
+
try {
|
|
96
|
+
await read(half, run, settled);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (!from.giving)
|
|
100
|
+
throw e;
|
|
101
|
+
// Never answered, so nothing of it was written: the range it came
|
|
102
|
+
// from takes it back and carries on to where it was going. No more
|
|
103
|
+
// cuts, since whatever stopped this one would stop the next.
|
|
104
|
+
from.end = before;
|
|
105
|
+
ranges.splice(ranges.indexOf(half), 1);
|
|
106
|
+
cutting = false;
|
|
107
|
+
settled();
|
|
108
|
+
if (stop.signal.aborted)
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
await Promise.all(ranges.slice().map((r) => worker(r).catch((e) => {
|
|
115
|
+
stop.abort(e);
|
|
116
|
+
throw e;
|
|
117
|
+
})));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
outer?.removeEventListener("abort", passOn);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Read one range to its end, asking again from where it stopped when its
|
|
125
|
+
* connection breaks. With `answered`, the range is a half just taken from
|
|
126
|
+
* another: until storage has answered for it, any failure is thrown at once
|
|
127
|
+
* (so that the half can go back) rather than asked again, and `answered` is
|
|
128
|
+
* called when it has.
|
|
129
|
+
*/
|
|
130
|
+
async function read(range, run, answered) {
|
|
131
|
+
let tries = 0;
|
|
132
|
+
for (;;) {
|
|
133
|
+
// What is asked for is what the range holds now, which a cut that has
|
|
134
|
+
// not been answered could still change.
|
|
135
|
+
while (range.giving)
|
|
136
|
+
await range.giving;
|
|
137
|
+
if (range.at >= range.end)
|
|
138
|
+
return;
|
|
139
|
+
const startedAt = range.at;
|
|
140
|
+
const asking = new Asking(run);
|
|
141
|
+
try {
|
|
142
|
+
const res = await asking.fetch(range);
|
|
143
|
+
answered?.();
|
|
144
|
+
answered = undefined;
|
|
145
|
+
await drain(range, res, run, asking);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
if (run.signal.aborted || answered || !(e instanceof Broke))
|
|
149
|
+
throw e;
|
|
150
|
+
tries = await again(range.at > startedAt ? 0 : tries, run, e);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
asking.done();
|
|
155
|
+
}
|
|
156
|
+
if (range.at >= range.end)
|
|
157
|
+
return;
|
|
158
|
+
tries = await again(range.at > startedAt ? 0 : tries, run, new Broke("the connection closed before the end of the range"));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* One request, given up when nothing arrives on it for a while. Stopped with
|
|
163
|
+
* everything else when the download is.
|
|
164
|
+
*/
|
|
165
|
+
class Asking {
|
|
166
|
+
run;
|
|
167
|
+
controller = new AbortController();
|
|
168
|
+
timer;
|
|
169
|
+
stop = () => this.controller.abort(this.run.signal.reason);
|
|
170
|
+
constructor(run) {
|
|
171
|
+
this.run = run;
|
|
172
|
+
run.signal.addEventListener("abort", this.stop);
|
|
173
|
+
}
|
|
174
|
+
/** Something arrived, or is about to be waited for. */
|
|
175
|
+
listen() {
|
|
176
|
+
clearTimeout(this.timer);
|
|
177
|
+
this.timer = setTimeout(() => this.controller.abort(new Broke("nothing arrived for a while")), this.run.quietMs);
|
|
178
|
+
}
|
|
179
|
+
/** Nothing is expected for now: this side is not reading. */
|
|
180
|
+
deaf() {
|
|
181
|
+
clearTimeout(this.timer);
|
|
182
|
+
}
|
|
183
|
+
done() {
|
|
184
|
+
this.deaf();
|
|
185
|
+
this.run.signal.removeEventListener("abort", this.stop);
|
|
186
|
+
}
|
|
187
|
+
/** Ask for the rest of `range`, and check that storage answered with it. */
|
|
188
|
+
async fetch(range) {
|
|
189
|
+
this.listen();
|
|
190
|
+
let res;
|
|
191
|
+
try {
|
|
192
|
+
res = await this.run.fetch(this.run.url, { headers: { Range: `bytes=${range.at}-${range.end - 1}` }, signal: this.controller.signal });
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
throw this.run.signal.aborted ? e : new Broke("the request failed", { cause: e });
|
|
196
|
+
}
|
|
197
|
+
const refuse = async (error) => {
|
|
198
|
+
await res.body?.cancel().catch(() => undefined);
|
|
199
|
+
throw error;
|
|
200
|
+
};
|
|
201
|
+
if (res.status === 403) {
|
|
202
|
+
return refuse(new FileFreakError(403, "address_expired", "The download stopped: a connection broke after the download's address had run out, so the rest could not be asked for. Download it again."));
|
|
203
|
+
}
|
|
204
|
+
if (res.status === 408 || res.status === 429 || res.status >= 500)
|
|
205
|
+
return refuse(new Broke(`storage answered ${res.status}`));
|
|
206
|
+
if (res.status !== 206)
|
|
207
|
+
return refuse(new FileFreakError(res.status, "storage_refused", `Storage answered ${res.status} when asked for part of the file.`));
|
|
208
|
+
// Content-Range is one of the headers storage lets a page read. Without
|
|
209
|
+
// it, a part written at the wrong place would go unnoticed.
|
|
210
|
+
const sent = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(res.headers.get("Content-Range") ?? "");
|
|
211
|
+
if (!sent || !res.body)
|
|
212
|
+
return refuse(new FileFreakError(res.status, "storage_refused", "Storage did not say which part of the file it sent."));
|
|
213
|
+
if (Number(sent[3]) !== this.run.sizeBytes) {
|
|
214
|
+
return refuse(new FileFreakError(res.status, "file_changed", "The file in storage is not the size the API gave. It may have been replaced since; download it again."));
|
|
215
|
+
}
|
|
216
|
+
if (Number(sent[1]) !== range.at)
|
|
217
|
+
return refuse(new FileFreakError(res.status, "storage_refused", "Storage sent another part of the file than the one asked for."));
|
|
218
|
+
return res;
|
|
219
|
+
}
|
|
220
|
+
/** The next piece of the body, or null at its end. */
|
|
221
|
+
async next(reader) {
|
|
222
|
+
this.listen();
|
|
223
|
+
try {
|
|
224
|
+
const { done, value } = await reader.read();
|
|
225
|
+
return done ? null : value;
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
throw this.run.signal.aborted ? e : new Broke("the connection broke", { cause: e });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Write what arrives on `res` into the range, until the range ends or the body does. */
|
|
233
|
+
async function drain(range, res, run, asking) {
|
|
234
|
+
const reader = res.body.getReader();
|
|
235
|
+
const held = [];
|
|
236
|
+
let heldBytes = 0;
|
|
237
|
+
const flush = async () => {
|
|
238
|
+
if (heldBytes === 0)
|
|
239
|
+
return;
|
|
240
|
+
const data = join(held, heldBytes);
|
|
241
|
+
const position = range.at - heldBytes;
|
|
242
|
+
held.length = 0;
|
|
243
|
+
heldBytes = 0;
|
|
244
|
+
asking.deaf();
|
|
245
|
+
await run.sink.write(position, data);
|
|
246
|
+
run.wrote(data.byteLength);
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
for (;;) {
|
|
250
|
+
let chunk = await asking.next(reader);
|
|
251
|
+
if (!chunk)
|
|
252
|
+
break;
|
|
253
|
+
while (chunk.byteLength > 0) {
|
|
254
|
+
if (range.at >= range.end) {
|
|
255
|
+
await flush();
|
|
256
|
+
if (range.giving) {
|
|
257
|
+
// Cut here. This request goes on covering the rest, so wait,
|
|
258
|
+
// reading nothing, to learn whether the rest comes back.
|
|
259
|
+
asking.deaf();
|
|
260
|
+
await range.giving;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
await reader.cancel().catch(() => undefined);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const take = chunk.subarray(0, Math.min(chunk.byteLength, range.end - range.at));
|
|
267
|
+
held.push(take);
|
|
268
|
+
heldBytes += take.byteLength;
|
|
269
|
+
range.at += take.byteLength;
|
|
270
|
+
chunk = chunk.subarray(take.byteLength);
|
|
271
|
+
if (heldBytes >= WRITE_BYTES)
|
|
272
|
+
await flush();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
// What arrived before a break is right, so it is written, and the range
|
|
278
|
+
// is asked for again from after it.
|
|
279
|
+
if (!run.signal.aborted)
|
|
280
|
+
await flush();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Wait before asking again, a little longer each time; give up after TRIES in a row with nothing arriving. */
|
|
284
|
+
async function again(tries, run, why) {
|
|
285
|
+
if (tries + 1 >= TRIES) {
|
|
286
|
+
throw Object.assign(new FileFreakError(0, "storage_unreachable", "The download stopped: storage could not be reached after several tries. Download it again."), { cause: why });
|
|
287
|
+
}
|
|
288
|
+
await run.sleep(Math.min(8000, 500 * 2 ** tries));
|
|
289
|
+
if (run.signal.aborted)
|
|
290
|
+
throw run.signal.reason;
|
|
291
|
+
return tries + 1;
|
|
292
|
+
}
|
|
293
|
+
function join(parts, bytes) {
|
|
294
|
+
if (parts.length === 1)
|
|
295
|
+
return parts[0];
|
|
296
|
+
const out = new Uint8Array(bytes);
|
|
297
|
+
let at = 0;
|
|
298
|
+
for (const p of parts) {
|
|
299
|
+
out.set(p, at);
|
|
300
|
+
at += p.byteLength;
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
package/dist/shards.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export declare const SHARD_VERSION = 1;
|
|
2
|
+
/** What a stored fragment adds to the bytes of the file it holds: the version byte, the nonce and the tag. */
|
|
3
|
+
export declare const SHARD_OVERHEAD = 29;
|
|
4
|
+
/**
|
|
5
|
+
* Why a fragment or a description was refused. A reader checks in this
|
|
6
|
+
* order, and says the first that fails: short, version, length,
|
|
7
|
+
* authentication. layout is a description of a file no fragment can match.
|
|
8
|
+
*/
|
|
9
|
+
export type ShardErrorCode = "short" | "version" | "length" | "authentication" | "layout";
|
|
10
|
+
export declare class ShardError extends Error {
|
|
11
|
+
readonly code: ShardErrorCode;
|
|
12
|
+
constructor(code: ShardErrorCode, message: string);
|
|
13
|
+
}
|
|
14
|
+
/** How a file of `size` bytes is cut into fragments of `fragmentSize`, which the API chooses. */
|
|
15
|
+
export declare class ShardLayout {
|
|
16
|
+
readonly size: number;
|
|
17
|
+
readonly fragmentSize: number;
|
|
18
|
+
readonly count: number;
|
|
19
|
+
constructor(size: number, fragmentSize: number);
|
|
20
|
+
/** Where in the file fragment `place` begins, and where it ends (exclusive). */
|
|
21
|
+
range(place: number): {
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
};
|
|
25
|
+
/** How many bytes of the file fragment `place` holds. */
|
|
26
|
+
length(place: number): number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A content's key as WebCrypto holds it. Named through crypto.subtle and not
|
|
30
|
+
* as CryptoKey, which is the DOM's name for it and not a global in Node's
|
|
31
|
+
* types, which is all the API's typecheck reads this file with.
|
|
32
|
+
*/
|
|
33
|
+
export type ShardKey = Awaited<ReturnType<typeof crypto.subtle.importKey>>;
|
|
34
|
+
/** Which fragment of which file: what a fragment's associated data binds it to. */
|
|
35
|
+
export interface FragmentAt {
|
|
36
|
+
/** The content's own id, a UUID, which a copy of the file keeps. */
|
|
37
|
+
contentId: string;
|
|
38
|
+
layout: ShardLayout;
|
|
39
|
+
place: number;
|
|
40
|
+
}
|
|
41
|
+
/** A content's key, 32 bytes, made usable for its fragments and nothing else. */
|
|
42
|
+
export declare function shardKey(raw: Uint8Array): Promise<ShardKey>;
|
|
43
|
+
/** The bytes a fragment's tag also covers: the label, the content id, the place and the count. */
|
|
44
|
+
export declare function associatedData(at: FragmentAt): Uint8Array<ArrayBuffer>;
|
|
45
|
+
/** One fragment of a file, encrypted and ready to store, under a nonce of its own. */
|
|
46
|
+
export declare function sealFragment(key: ShardKey, at: FragmentAt, plaintext: Uint8Array): Promise<Uint8Array>;
|
|
47
|
+
/**
|
|
48
|
+
* sealFragment with the nonce chosen by the caller, which only the test
|
|
49
|
+
* files may do: two fragments sealed under one key with one nonce give away
|
|
50
|
+
* the difference between them, and the key's authentication with it. It is
|
|
51
|
+
* not exported from the package.
|
|
52
|
+
*/
|
|
53
|
+
export declare function sealWithNonce(key: ShardKey, at: FragmentAt, plaintext: Uint8Array, nonce: Uint8Array): Promise<Uint8Array>;
|
|
54
|
+
/**
|
|
55
|
+
* A stored fragment's bytes of the file, once it is known to be this file's
|
|
56
|
+
* fragment at this place, unchanged. Nothing of a fragment that fails is
|
|
57
|
+
* returned: WebCrypto gives no plaintext until the tag has been checked.
|
|
58
|
+
*/
|
|
59
|
+
export declare function openFragment(key: ShardKey, at: FragmentAt, blob: Uint8Array): Promise<Uint8Array>;
|
package/dist/shards.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The shard format, version 1: how a file under Shard Protection is kept, as
|
|
2
|
+
// fragments each encrypted on its own. docs/reference/shard-format.md is the
|
|
3
|
+
// definition, and docs/reference/shard-format-v1.json holds the test files
|
|
4
|
+
// this is checked against (test/shards.test.ts), as the CLI's copy is: a
|
|
5
|
+
// writer here whose bytes differ by one from the CLI's makes files the CLI
|
|
6
|
+
// cannot read, and the reverse.
|
|
7
|
+
//
|
|
8
|
+
// WebCrypto, so the same code runs in a page, in a worker and in Node.
|
|
9
|
+
export const SHARD_VERSION = 1;
|
|
10
|
+
/** What a stored fragment adds to the bytes of the file it holds: the version byte, the nonce and the tag. */
|
|
11
|
+
export const SHARD_OVERHEAD = 29;
|
|
12
|
+
const NONCE_BYTES = 12;
|
|
13
|
+
const HEADER_BYTES = 1 + NONCE_BYTES;
|
|
14
|
+
const LABEL = new TextEncoder().encode("filefreak-shard-v1");
|
|
15
|
+
/** The associated data numbers places and the count in 32 bits. */
|
|
16
|
+
const MOST_FRAGMENTS = 0xffff_ffff;
|
|
17
|
+
export class ShardError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = "ShardError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** How a file of `size` bytes is cut into fragments of `fragmentSize`, which the API chooses. */
|
|
26
|
+
export class ShardLayout {
|
|
27
|
+
size;
|
|
28
|
+
fragmentSize;
|
|
29
|
+
count;
|
|
30
|
+
constructor(size, fragmentSize) {
|
|
31
|
+
this.size = size;
|
|
32
|
+
this.fragmentSize = fragmentSize;
|
|
33
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
34
|
+
throw new ShardError("layout", `A file's size is a whole number of bytes, not ${size}.`);
|
|
35
|
+
if (!Number.isSafeInteger(fragmentSize) || fragmentSize < 1) {
|
|
36
|
+
throw new ShardError("layout", `A fragment holds at least one byte of the file, not ${fragmentSize}.`);
|
|
37
|
+
}
|
|
38
|
+
// BigInt, so a size past 2^32 is divided exactly; an empty file is one
|
|
39
|
+
// empty fragment, so that even it has something to authenticate.
|
|
40
|
+
const count = size === 0 ? 1 : Number((BigInt(size) + BigInt(fragmentSize) - 1n) / BigInt(fragmentSize));
|
|
41
|
+
if (count > MOST_FRAGMENTS) {
|
|
42
|
+
throw new ShardError("layout", `A file of ${size} bytes in fragments of ${fragmentSize} is ${count} fragments, more than the format can number.`);
|
|
43
|
+
}
|
|
44
|
+
this.count = count;
|
|
45
|
+
}
|
|
46
|
+
/** Where in the file fragment `place` begins, and where it ends (exclusive). */
|
|
47
|
+
range(place) {
|
|
48
|
+
if (!Number.isInteger(place) || place < 0 || place >= this.count) {
|
|
49
|
+
throw new RangeError(`There is no fragment ${place}: this file has ${this.count}, numbered from 0.`);
|
|
50
|
+
}
|
|
51
|
+
const start = place * this.fragmentSize;
|
|
52
|
+
return { start, end: Math.min(start + this.fragmentSize, this.size) };
|
|
53
|
+
}
|
|
54
|
+
/** How many bytes of the file fragment `place` holds. */
|
|
55
|
+
length(place) {
|
|
56
|
+
const { start, end } = this.range(place);
|
|
57
|
+
return end - start;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** A content's key, 32 bytes, made usable for its fragments and nothing else. */
|
|
61
|
+
export function shardKey(raw) {
|
|
62
|
+
if (raw.length !== 32)
|
|
63
|
+
throw new RangeError(`A shard key is 32 bytes, not ${raw.length}.`);
|
|
64
|
+
return crypto.subtle.importKey("raw", new Uint8Array(raw), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
65
|
+
}
|
|
66
|
+
/** The bytes a fragment's tag also covers: the label, the content id, the place and the count. */
|
|
67
|
+
export function associatedData(at) {
|
|
68
|
+
const out = new Uint8Array(LABEL.length + 16 + 8);
|
|
69
|
+
out.set(LABEL, 0);
|
|
70
|
+
out.set(uuidBytes(at.contentId), LABEL.length);
|
|
71
|
+
const view = new DataView(out.buffer);
|
|
72
|
+
view.setUint32(LABEL.length + 16, at.place, false);
|
|
73
|
+
view.setUint32(LABEL.length + 20, at.layout.count, false);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** One fragment of a file, encrypted and ready to store, under a nonce of its own. */
|
|
77
|
+
export function sealFragment(key, at, plaintext) {
|
|
78
|
+
return sealWithNonce(key, at, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* sealFragment with the nonce chosen by the caller, which only the test
|
|
82
|
+
* files may do: two fragments sealed under one key with one nonce give away
|
|
83
|
+
* the difference between them, and the key's authentication with it. It is
|
|
84
|
+
* not exported from the package.
|
|
85
|
+
*/
|
|
86
|
+
export async function sealWithNonce(key, at, plaintext, nonce) {
|
|
87
|
+
const expected = at.layout.length(at.place);
|
|
88
|
+
if (plaintext.length !== expected) {
|
|
89
|
+
throw new ShardError("length", `Fragment ${at.place} holds ${expected} bytes of the file, not ${plaintext.length}.`);
|
|
90
|
+
}
|
|
91
|
+
if (nonce.length !== NONCE_BYTES)
|
|
92
|
+
throw new RangeError(`A nonce is ${NONCE_BYTES} bytes, not ${nonce.length}.`);
|
|
93
|
+
const iv = new Uint8Array(nonce);
|
|
94
|
+
const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: associatedData(at), tagLength: 128 }, key, new Uint8Array(plaintext));
|
|
95
|
+
const out = new Uint8Array(HEADER_BYTES + sealed.byteLength);
|
|
96
|
+
out[0] = SHARD_VERSION;
|
|
97
|
+
out.set(iv, 1);
|
|
98
|
+
out.set(new Uint8Array(sealed), HEADER_BYTES);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A stored fragment's bytes of the file, once it is known to be this file's
|
|
103
|
+
* fragment at this place, unchanged. Nothing of a fragment that fails is
|
|
104
|
+
* returned: WebCrypto gives no plaintext until the tag has been checked.
|
|
105
|
+
*/
|
|
106
|
+
export async function openFragment(key, at, blob) {
|
|
107
|
+
const expected = at.layout.length(at.place);
|
|
108
|
+
if (blob.length < SHARD_OVERHEAD) {
|
|
109
|
+
throw new ShardError("short", `Fragment ${at.place} is ${blob.length} bytes, shorter than any fragment can be (${SHARD_OVERHEAD}).`);
|
|
110
|
+
}
|
|
111
|
+
if (blob[0] !== SHARD_VERSION) {
|
|
112
|
+
throw new ShardError("version", `Fragment ${at.place} is in version ${blob[0]} of the format, which this version of the SDK cannot read.`);
|
|
113
|
+
}
|
|
114
|
+
if (blob.length !== expected + SHARD_OVERHEAD) {
|
|
115
|
+
throw new ShardError("length", `Fragment ${at.place} is ${blob.length} bytes, and holding ${expected} bytes of the file it would be ${expected + SHARD_OVERHEAD}.`);
|
|
116
|
+
}
|
|
117
|
+
let plain;
|
|
118
|
+
try {
|
|
119
|
+
plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: new Uint8Array(blob.subarray(1, HEADER_BYTES)), additionalData: associatedData(at), tagLength: 128 }, key, new Uint8Array(blob.subarray(HEADER_BYTES)));
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
// OperationError is WebCrypto's one answer to a tag that does not match.
|
|
123
|
+
// Anything else (a key made for something else) is not the fragment's fault.
|
|
124
|
+
if (e instanceof DOMException && e.name === "OperationError") {
|
|
125
|
+
throw new ShardError("authentication", `Fragment ${at.place} is not what was written as this file's fragment ${at.place}: it has been changed, or it belongs to another file or place.`);
|
|
126
|
+
}
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
return new Uint8Array(plain);
|
|
130
|
+
}
|
|
131
|
+
function uuidBytes(id) {
|
|
132
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
133
|
+
throw new RangeError(`A content id is a UUID, and ${JSON.stringify(id)} is not one.`);
|
|
134
|
+
}
|
|
135
|
+
const hex = id.replaceAll("-", "");
|
|
136
|
+
const out = new Uint8Array(16);
|
|
137
|
+
for (let i = 0; i < 16; i++)
|
|
138
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** A refusal from the API, or a request that could not be answered at all. Branch on `code`, which does not change; `message` is for a person. */
|
|
2
|
+
export declare class FileFreakError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly body: Record<string, unknown> | null;
|
|
6
|
+
readonly retryAfterSeconds: number | null;
|
|
7
|
+
constructor(status: number, code: string, message: string, body?: Record<string, unknown> | null, retryAfterSeconds?: number | null);
|
|
8
|
+
}
|
|
9
|
+
/** A token as a string, or a function that fetches a fresh one from the developer's own server. */
|
|
10
|
+
export type TokenSource = string | (() => Promise<string>);
|
|
11
|
+
export interface ClientOptions {
|
|
12
|
+
/** An app's key, for a program on a server. Never put one in a web page: the API refuses a key sent from one. */
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
/** For a web page: a token the developer's server made with POST /v1/tokens, or a function that asks the server for one. */
|
|
15
|
+
token?: TokenSource;
|
|
16
|
+
/** Where the API is. */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** Another fetch, for a runtime without one or for tests. */
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
/** How many times a request that failed for a passing reason is sent again. */
|
|
21
|
+
retries?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface CallOptions {
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
/** For an endpoint that makes something: the caller's own key, so that the same request sent later, even from another process, makes nothing twice. */
|
|
26
|
+
idempotencyKey?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface Request {
|
|
29
|
+
method: "GET" | "POST" | "PATCH" | "DELETE";
|
|
30
|
+
path: string;
|
|
31
|
+
query?: Record<string, unknown> | undefined;
|
|
32
|
+
body?: unknown;
|
|
33
|
+
/** The endpoint honours an Idempotency-Key. */
|
|
34
|
+
idempotent?: boolean;
|
|
35
|
+
/** The endpoint says it is safe to call again (finishing an upload). */
|
|
36
|
+
repeatable?: boolean;
|
|
37
|
+
options?: CallOptions;
|
|
38
|
+
}
|
|
39
|
+
export interface Response<T> {
|
|
40
|
+
status: number;
|
|
41
|
+
body: T;
|
|
42
|
+
headers: Headers;
|
|
43
|
+
}
|
|
44
|
+
export declare const DEFAULT_BASE_URL = "https://api.filefreak.io";
|
|
45
|
+
/** Waits, or stops waiting when the signal is aborted. */
|
|
46
|
+
export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
47
|
+
/** A wait that doubles with each try, with some spread so that many clients do not come back at once. */
|
|
48
|
+
export declare function backoff(attempt: number): number;
|
|
49
|
+
export declare class Transport {
|
|
50
|
+
readonly baseUrl: string;
|
|
51
|
+
private readonly fetcher;
|
|
52
|
+
private readonly retries;
|
|
53
|
+
private readonly apiKey;
|
|
54
|
+
private readonly tokenSource;
|
|
55
|
+
private token;
|
|
56
|
+
constructor(options: ClientOptions);
|
|
57
|
+
/** Another fetch, for reaching storage with the same retries and runtime. */
|
|
58
|
+
get fetch(): typeof fetch;
|
|
59
|
+
private credential;
|
|
60
|
+
send<T>(request: Request): Promise<Response<T>>;
|
|
61
|
+
}
|