245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
import { once } from "node:events";
|
|
import { Readable, type Readable as NodeReadable } from "node:stream";
|
|
|
|
import type { ReadableStream as WebReadableStream } from "node:stream/web";
|
|
import * as zlib from "node:zlib";
|
|
import { extract, pack, type Pack } from "tar-stream";
|
|
import * as age from "age-encryption";
|
|
import { MANIFEST_PATH, PAYLOAD_DIR, type BundleManifest, type BundlePayload } from "./archive-index.js";
|
|
|
|
/** zstd level for bundle payloads; 3 ≈ gzip -6 speed with a better ratio. */
|
|
export const ZSTD_LEVEL = 3;
|
|
|
|
/** Native age recipient: lowercase bech32 `age1…` (X25519). */
|
|
const AGE_RECIPIENT = /^age1[02-9ac-hj-np-z]{58,}$/;
|
|
|
|
export class BundleError extends Error {}
|
|
|
|
/**
|
|
* Parse and strictly validate `ARCHIVE_EXPORT_AGE_RECIPIENTS` (comma-separated
|
|
* bech32 `age1…` public keys). Private keys never exist on this side: admins
|
|
* generate keypairs and publish only the recipient in the room.
|
|
*/
|
|
export function parseAgeRecipients(raw: string): string[] {
|
|
const recipients = raw.split(",").map((r) => r.trim()).filter((r) => r !== "");
|
|
const seen = new Set<string>();
|
|
recipients.forEach((recipient, index) => {
|
|
if (!AGE_RECIPIENT.test(recipient)) {
|
|
throw new BundleError(
|
|
`invalid age recipient at index ${index} (want a bech32 age1… public key, got "${recipient.slice(0, 16)}…")`,
|
|
);
|
|
}
|
|
if (seen.has(recipient)) throw new BundleError(`duplicate age recipient at index ${index}`);
|
|
seen.add(recipient);
|
|
});
|
|
if (recipients.length === 0) throw new BundleError("at least one age recipient is required");
|
|
return recipients;
|
|
}
|
|
|
|
function toBuffer(value: Uint8Array): Buffer {
|
|
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
}
|
|
|
|
function errorOf(err: unknown): Error {
|
|
return err instanceof Error ? err : new BundleError(String(err));
|
|
}
|
|
|
|
/**
|
|
* tar(zstd) writer. Entry payloads stream into a `tar-stream` Pack piped
|
|
* straight into zstd: bytes exist only in flight, never on local disk. Any
|
|
* failure (payload fetch, tar, zstd) is rethrown from the next writer call and
|
|
* destroys the zstd stream so a consumer sees the error instead of a truncated
|
|
* "successful" bundle.
|
|
*/
|
|
export class TarZstdWriter {
|
|
private readonly packer: Pack;
|
|
private failure: unknown = null;
|
|
|
|
private constructor(
|
|
private readonly pack: Pack,
|
|
readonly nodeStream: NodeReadable,
|
|
) {
|
|
this.packer = pack;
|
|
this.nodeStream.on("error", (err: unknown) => {
|
|
this.failure ??= err;
|
|
});
|
|
this.pack.on("error", (err: unknown) => this.fail(err));
|
|
}
|
|
|
|
static create(level = ZSTD_LEVEL): TarZstdWriter {
|
|
const packer = pack();
|
|
const zstd = packer.pipe(zlib.createZstdCompress({ params: { [zlib.constants.ZSTD_c_compressionLevel]: level } }));
|
|
return new TarZstdWriter(packer, zstd);
|
|
}
|
|
|
|
/** Copy one payload stream into a tar entry, honouring sink backpressure. */
|
|
async writeEntry(name: string, body: ReadableStream<Uint8Array>, size: number): Promise<void> {
|
|
this.assertNotFailed();
|
|
const sink = this.pack.entry({ name, size });
|
|
sink.on("error", (err: unknown) => this.fail(err));
|
|
try {
|
|
const reader = body.getReader();
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (!sink.write(toBuffer(value))) await once(sink, "drain");
|
|
}
|
|
sink.end(null);
|
|
await once(sink, "finish");
|
|
} catch (err) {
|
|
this.fail(err);
|
|
throw errorOf(err);
|
|
}
|
|
this.assertNotFailed();
|
|
}
|
|
|
|
async writeBytes(name: string, bytes: Uint8Array): Promise<void> {
|
|
await this.writeEntry(name, new Blob([toBuffer(bytes)]).stream() as ReadableStream<Uint8Array>, bytes.byteLength);
|
|
}
|
|
|
|
/** Two zero blocks + zstd flush; resolves once the zstd stream has ended. */
|
|
async finalize(): Promise<void> {
|
|
this.assertNotFailed();
|
|
this.pack.finalize();
|
|
await once(this.nodeStream, "end");
|
|
this.assertNotFailed();
|
|
}
|
|
|
|
/** Abort the pipeline after an unrecoverable error (e.g. a WebDAV abort). */
|
|
abort(err: unknown): void {
|
|
this.fail(err);
|
|
}
|
|
|
|
private fail(err: unknown): void {
|
|
this.failure ??= err;
|
|
this.nodeStream.destroy(errorOf(err));
|
|
}
|
|
|
|
private assertNotFailed(): void {
|
|
if (this.failure !== null) throw errorOf(this.failure);
|
|
}
|
|
}
|
|
|
|
interface TarEntry {
|
|
name: string;
|
|
stream: ReadableStream<Uint8Array>;
|
|
}
|
|
|
|
/**
|
|
* tar(zstd) reader for the restore path. Yields `manifest.json` then every
|
|
* `files/…` payload as a byte stream; unknown entries are drained and skipped.
|
|
*
|
|
* The pipeline is pull-driven: tar entries are only emitted as the consumer
|
|
* iterates, so the age-decrypted source is read no faster than files are
|
|
* written out.
|
|
*/
|
|
export async function* readTarZstd(tarZstd: ReadableStream<Uint8Array> | NodeReadable):
|
|
AsyncGenerator<TarEntry> {
|
|
const source = tarZstd instanceof Readable
|
|
? tarZstd
|
|
: Readable.fromWeb(tarZstd as WebReadableStream<Uint8Array>);
|
|
const untar = extract();
|
|
const finished = new Promise<void>((resolve) => {
|
|
untar.on("finish", () => resolve());
|
|
untar.on("close", () => resolve());
|
|
});
|
|
// Never awaited directly: an error surfaces as an iteration throw or via `finished`.
|
|
source.on("error", (err: unknown) => untar.destroy(errorOf(err)));
|
|
const decompressed = source.pipe(zlib.createZstdDecompress());
|
|
decompressed.on("error", (err: unknown) => {
|
|
untar.destroy(errorOf(err));
|
|
});
|
|
decompressed.pipe(untar);
|
|
try {
|
|
for await (const entry of untar) {
|
|
const name = entry.header.name;
|
|
if (name === MANIFEST_PATH || name.startsWith(`${PAYLOAD_DIR}/`)) {
|
|
yield { name, stream: Readable.toWeb(Readable.from(entry)) as ReadableStream<Uint8Array> };
|
|
} else {
|
|
entry.resume();
|
|
}
|
|
}
|
|
await finished;
|
|
} finally {
|
|
if (!untar.destroyed) untar.destroy();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the encrypted bundle stream: `manifest.json` plus one entry per
|
|
* payload, tar → zstd → age. The age file key is wrapped for every recipient,
|
|
* so any admin identity can open the bundle; none of them exists server-side.
|
|
*
|
|
* Returns the ciphertext stream. Payload assembly runs in the background and
|
|
* any failure (payload fetch, tar, zstd) errors the ciphertext stream rather
|
|
* than ending it, so a partial upload can never look like a complete export.
|
|
*/
|
|
export async function encryptBundleStream(
|
|
payload: BundlePayload,
|
|
recipients: readonly string[],
|
|
level = ZSTD_LEVEL,
|
|
): Promise<ReadableStream<Uint8Array>> {
|
|
const writer = TarZstdWriter.create(level);
|
|
const encrypter = new age.Encrypter();
|
|
for (const recipient of recipients) encrypter.addRecipient(recipient);
|
|
// age computes and writes its header before any payload byte is needed.
|
|
const ciphertext = await encrypter.encrypt(Readable.toWeb(writer.nodeStream) as ReadableStream<Uint8Array>);
|
|
void (async () => {
|
|
try {
|
|
const manifest: BundleManifest = { ...payload.manifest, recipients: [...recipients] };
|
|
await writer.writeBytes(MANIFEST_PATH, Buffer.from(JSON.stringify(manifest, null, 2), "utf8"));
|
|
for (const [path, bytes] of payload.payloads) {
|
|
await writer.writeEntry(path, new Blob([toBuffer(bytes)]).stream() as ReadableStream<Uint8Array>, bytes.byteLength);
|
|
}
|
|
await writer.finalize();
|
|
} catch (err) {
|
|
writer.abort(err);
|
|
await ciphertext.cancel().catch(() => undefined);
|
|
}
|
|
})();
|
|
return ciphertext;
|
|
}
|
|
|
|
/**
|
|
* Encrypt one arbitrary byte stream with age. Used by the restorer's self-test
|
|
* and any future single-object encryption need.
|
|
*/
|
|
export async function ageEncryptStream(
|
|
source: ReadableStream<Uint8Array>,
|
|
recipients: readonly string[],
|
|
): Promise<ReadableStream<Uint8Array>> {
|
|
const encrypter = new age.Encrypter();
|
|
for (const recipient of recipients) encrypter.addRecipient(recipient);
|
|
return encrypter.encrypt(source);
|
|
}
|
|
|
|
/** Decrypt with `AGE-SECRET-KEY-1…` identities; fails closed on a bad header MAC. */
|
|
export async function ageDecryptStream(
|
|
ciphertext: ReadableStream<Uint8Array>,
|
|
identities: readonly string[],
|
|
): Promise<ReadableStream<Uint8Array>> {
|
|
if (identities.length === 0) throw new BundleError("no age identities supplied");
|
|
const decrypter = new age.Decrypter();
|
|
for (const identity of identities) decrypter.addIdentity(identity);
|
|
return decrypter.decrypt(ciphertext);
|
|
}
|
|
|
|
/** Fully buffer one stream (bounded use: manifests, test payloads). */
|
|
export async function drainToBuffer(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
|
const chunks: Uint8Array[] = [];
|
|
let total = 0;
|
|
const reader = stream.getReader();
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
chunks.push(value);
|
|
total += value.byteLength;
|
|
}
|
|
const out = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
out.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
return out;
|
|
}
|