Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
# @suaveplan/drive-plugin-chunks - Chunk-addressing plugin for @suaveplan/drive. Defines the version 0x0001 binary chunk format, server-signed manifests, storage layouts, and streaming encrypt-then-chunk pipeline.
## Summary
Chunk-addressing plugin for `@suaveplan/drive`. Splits files into signed, encrypted chunks using the locked `version 0x0001` binary format, with server-signed manifests and two storage layouts (`sparse-file` and `chunk-keys`).
<!-- begin:auto-docs -->
## Documentation tree
- [chunk](./src/codec/chunk.md) — Wraps the chunk wire format above the bare header: takes raw chunk bytes (header + signature + ciphertext) and parses t…
- [header](./src/codec/header.md) — Encodes and parses the fixed-width 100-byte metadata header that prefixes every chunk in the `@suaveplan/drive-plugin-c…
- [inline](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
<!-- end:auto-docs --> - [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
- [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
- [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
- [inline-hint](./src/inline/inline-hint.md) — <!-- begin:auto-docs --> _(no submodules)_- [auto](./src/layout/auto.md) — Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capab…
- [chunk-keys](./src/layout/chunk-keys.md) — Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<c…
- [sparse-file](./src/layout/sparse-file.md) — Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single…
- [build](./src/manifest/build.md) — Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize…
- [load](./src/manifest/load.md) — Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`mani…
- [source](./src/manifest/source.md) — Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small…
- [decrypt-chunk](./src/pipeline/decrypt-chunk.md) — The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via…
- [encrypt-stream](./src/pipeline/encrypt-stream.md) — The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence…
- [sign-chunk](./src/pipeline/sign-chunk.md) — Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes pl…
- [verify-chunk](./src/pipeline/verify-chunk.md) — Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk —…
- [schemas](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [manifest.schema](./src/schemas/manifest.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin…
- [plugin](./src/plugin.md) — Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into…
## Install
```bash
bun add @suaveplan/drive-plugin-chunks
```
## Quick start
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
import { MemoryStorage } from "@suaveplan/storage/memory";
const storage = Object.assign(new MemoryStorage(), { id: "local" });
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: ed25519PrivKey,
serverPubKey: ed25519PubKey,
chunkSize: 65536,
})
);
await drive.write("hello.txt", new TextEncoder().encode("world"));
await chunks.ingest("hello.txt");
const manifest = await chunks.loadManifest(fileId);
console.log(manifest.totalChunks);
await drive.dispose();
```
## Modules
- [src/codec/header.md](src/codec/header.md) — binary header encoding / parsing
- [src/codec/chunk.md](src/codec/chunk.md) — full chunk encode / parse
- [src/pipeline/encrypt-stream.md](src/pipeline/encrypt-stream.md) — XChaCha20-Poly1305 streaming encryption
- [src/pipeline/chop-stream.md](src/pipeline/chop-stream.md) — ciphertext slicing into chunk payloads
- [src/pipeline/sign-chunk.md](src/pipeline/sign-chunk.md) — ed25519 server signing
- [src/pipeline/verify-chunk.md](src/pipeline/verify-chunk.md) — signature verification
- [src/layout/sparse-file.md](src/layout/sparse-file.md) — single-object sparse layout
- [src/layout/chunk-keys.md](src/layout/chunk-keys.md) — one-key-per-chunk layout
- [src/layout/auto.md](src/layout/auto.md) — automatic layout selection
- [src/manifest/build.md](src/manifest/build.md) — manifest assembly and signing
- [src/manifest/load.md](src/manifest/load.md) — manifest fetching and validation
- [src/manifest/source.md](src/manifest/source.md) — distribution strategy switch
- [src/plugin.md](src/plugin.md) — plugin handle and Drive middleware
- [src/types/chunks.types.md](src/types/chunks.types.md) — public TypeScript interfaces
- [src/schemas/manifest.schema.md](src/schemas/manifest.schema.md) — Zod boundary schemas
- [src/error-codes.md](src/error-codes.md) — `CHUNKS_CODES` and `ChunksError`

View File

@@ -0,0 +1,86 @@
{
"name": "@suaveplan/drive-plugin-chunks",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Chunk-addressing plugin for @suaveplan/drive. Defines the version 0x0001 binary chunk format, server-signed manifests, storage layouts, and streaming encrypt-then-chunk pipeline.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:bench": "vitest bench",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto": "workspace:^",
"@suaveplan/crypto-cipher": "workspace:^",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/drive": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/packer": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/streams": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,51 @@
# chunk
## Purpose
Wraps the chunk wire format above the bare header: takes raw chunk bytes (header + signature + ciphertext) and parses them into the structured `Chunk` and `EncodedChunk` shapes that the rest of the plugin consumes. This is the inverse of `encrypt-stream`'s output and the input to `verify-chunk` / `decrypt-chunk`.
## Features
- `parseChunk(raw): Chunk` — splits raw bytes into `metadata`, `sig`, and `ciphertext` slices using `HEADER_SIZE` as the boundary.
- Length validation — rejects anything shorter than `HEADER_SIZE + payloadLen`.
- Allocation-light — uses `Uint8Array.subarray` views where safe and `slice` only where ownership matters.
- Round-trip stable when paired with `encodeHeader` and the encrypt-stream output.
## Usage: Basic Example
```ts
import { parseChunk } from "@suaveplan/drive-plugin-chunks";
const raw: Uint8Array = await chunks.getChunk(fileId, 0).then((b) => b!);
const chunk = parseChunk(raw);
console.log(chunk.metadata.chunkId, chunk.ciphertext.length);
```
## Usage: Advanced Example
```ts
import {
decryptChunk,
parseChunk,
verifyChunkSig,
} from "@suaveplan/drive-plugin-chunks";
async function readVerifiedChunk(fileId: Uint8Array, idx: number) {
const raw = await chunks.getChunk(fileId, idx);
if (!raw) throw new Error("missing chunk");
const chunk = parseChunk(raw);
verifyChunkSig(raw, serverPubKey);
const plaintext = decryptChunk(chunk.metadata, chunk.ciphertext, fileKey);
return plaintext.slice(0, chunk.metadata.payloadLen);
}
```
## API Reference
- `parseChunk(raw: Uint8Array): Chunk` — returns `{ metadata, sig, ciphertext }`. `metadata` flows through `parseHeader`; `sig` is the 64-byte Ed25519 signature slice; `ciphertext` is the trailing `payloadLen` bytes.
- Throws `ChunksError(CHUNKS.METADATA_INVALID)` for under-length buffers (delegated to `parseHeader`).
- Throws `ChunksError(CHUNKS.PAYLOAD_LEN_INVALID)` when the trailing payload is shorter than `metadata.payloadLen`.
## Implementation Notes
`parseChunk` does not verify the signature or decrypt — those are independent stages so peers can short-circuit on metadata-only inspection (manifest checks, availability gossip). The ciphertext is sliced rather than viewed when its length differs from the underlying buffer to avoid pinning the larger raw allocation in caches.

View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES } from "../error-codes.js";
import { encodeChunk, parseChunk, validateChunk } from "./chunk.js";
describe("CHUNKS-FORMAT-V1-01 — Chunk binary layout is byte-exact", () => {
it("round-trips every field at exact offsets", { timeout: 5000 }, () => {
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32).fill(0xab),
chunkId: 7,
totalChunks: 10,
fileSize: 12345n,
chunkSize: 65536,
payloadLen: 100,
flags: 0x0000,
nonce: new Uint8Array(24).fill(0xcd),
aeadTag: new Uint8Array(16).fill(0xef),
};
const sig = new Uint8Array(64).fill(0x12);
const ciphertext = new Uint8Array(100).fill(0x34);
const encoded = encodeChunk({ metadata, sig, ciphertext });
expect(encoded.length).toBe(164 + 100);
const view = new DataView(encoded.buffer, encoded.byteOffset);
expect(view.getUint16(0, false)).toBe(0x0001);
expect(encoded.slice(2, 34)).toEqual(metadata.fileId);
expect(view.getUint32(34, false)).toBe(7);
expect(view.getUint32(38, false)).toBe(10);
expect(view.getBigUint64(42, false)).toBe(12345n);
expect(view.getUint32(50, false)).toBe(65536);
expect(view.getUint32(54, false)).toBe(100);
expect(view.getUint16(58, false)).toBe(0x0000);
expect(encoded.slice(60, 84)).toEqual(metadata.nonce);
expect(encoded.slice(84, 100)).toEqual(metadata.aeadTag);
expect(encoded.slice(100, 164)).toEqual(sig);
expect(encoded.slice(164)).toEqual(ciphertext);
const parsed = parseChunk(encoded);
expect(parsed.metadata.version).toBe(0x0001);
expect(parsed.metadata.chunkId).toBe(7);
expect(parsed.metadata.payloadLen).toBe(100);
expect(parsed.sig).toEqual(sig);
expect(parsed.ciphertext).toEqual(ciphertext);
});
});
describe("CHUNKS-FORMAT-V1-02 — flags reserved in v1", () => {
it("rejects non-zero flags", { timeout: 5000 }, () => {
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32),
chunkId: 0,
totalChunks: 1,
fileSize: 1n,
chunkSize: 65536,
payloadLen: 1,
flags: 0x0001,
nonce: new Uint8Array(24),
aeadTag: new Uint8Array(16),
};
const chunk = {
metadata,
sig: new Uint8Array(64),
ciphertext: new Uint8Array(1),
};
expect(() => validateChunk(chunk)).toThrow();
try {
validateChunk(chunk);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.FLAGS_RESERVED_NONZERO);
}
});
});
describe("CHUNKS-FORMAT-V1-03 — Payload length invariants", () => {
it("rejects non-final chunk payloadLen mismatch", { timeout: 5000 }, () => {
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32),
chunkId: 0,
totalChunks: 2,
fileSize: 100000n,
chunkSize: 65536,
payloadLen: 100,
flags: 0x0000,
nonce: new Uint8Array(24),
aeadTag: new Uint8Array(16),
};
const chunk = {
metadata,
sig: new Uint8Array(64),
ciphertext: new Uint8Array(100),
};
expect(() => validateChunk(chunk)).toThrow();
try {
validateChunk(chunk);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.PAYLOAD_LEN_INVALID);
}
});
it("accepts correct non-final payloadLen", { timeout: 5000 }, () => {
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32),
chunkId: 0,
totalChunks: 2,
fileSize: 100000n,
chunkSize: 65536,
payloadLen: 65536 - 164,
flags: 0x0000,
nonce: new Uint8Array(24),
aeadTag: new Uint8Array(16),
};
const chunk = {
metadata,
sig: new Uint8Array(64),
ciphertext: new Uint8Array(65536 - 164),
};
expect(() => validateChunk(chunk)).not.toThrow();
});
it("rejects final chunk payloadLen mismatch", { timeout: 5000 }, () => {
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32),
chunkId: 1,
totalChunks: 2,
fileSize: 500n,
chunkSize: 65536,
payloadLen: 100,
flags: 0x0000,
nonce: new Uint8Array(24),
aeadTag: new Uint8Array(16),
};
const chunk = {
metadata,
sig: new Uint8Array(64),
ciphertext: new Uint8Array(100),
};
expect(() => validateChunk(chunk)).toThrow();
try {
validateChunk(chunk);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.PAYLOAD_LEN_INVALID);
}
});
});
describe("CHUNKS-FORMAT-V1-04 — parseChunk edge cases", () => {
it("rejects chunk shorter than header", { timeout: 5000 }, () => {
expect(() => parseChunk(new Uint8Array(10))).toThrow();
try {
parseChunk(new Uint8Array(10));
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.METADATA_INVALID);
}
});
it("rejects unsupported chunk version", { timeout: 5000 }, () => {
const metadata = {
version: 0x0002,
fileId: new Uint8Array(32),
chunkId: 0,
totalChunks: 1,
fileSize: 1n,
chunkSize: 65536,
payloadLen: 1,
flags: 0x0000,
nonce: new Uint8Array(24),
aeadTag: new Uint8Array(16),
};
const chunk = {
metadata,
sig: new Uint8Array(64),
ciphertext: new Uint8Array(1),
};
expect(() => validateChunk(chunk)).toThrow();
try {
validateChunk(chunk);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.VERSION_UNSUPPORTED);
}
});
});

View File

@@ -0,0 +1,75 @@
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
import type { Chunk } from "../types/chunks.types.js";
import {
encodeHeader,
HEADER_SIZE,
METADATA_SIZE,
parseHeader,
SIG_SIZE,
} from "./header.js";
export { encodeHeader, HEADER_SIZE, METADATA_SIZE, parseHeader, SIG_SIZE };
export function encodeChunk(chunk: Chunk): Uint8Array {
const metadata = encodeHeader(chunk.metadata);
const total = HEADER_SIZE + chunk.ciphertext.byteLength;
const buf = new Uint8Array(total);
buf.set(metadata, 0);
buf.set(chunk.sig, METADATA_SIZE);
buf.set(chunk.ciphertext, HEADER_SIZE);
return buf;
}
export function parseChunk(buf: Uint8Array): Chunk {
if (buf.length < HEADER_SIZE) {
throw new ChunksError(
CHUNKS_CODES.METADATA_INVALID,
`chunk too short: ${buf.length} < ${HEADER_SIZE}`
);
}
const metadata = parseHeader(buf);
const sig = buf.slice(METADATA_SIZE, HEADER_SIZE);
const ciphertext = buf.slice(HEADER_SIZE);
return { metadata, sig, ciphertext };
}
export function validateChunk(chunk: Chunk): void {
if (chunk.metadata.version !== 0x0001) {
throw new ChunksError(
CHUNKS_CODES.VERSION_UNSUPPORTED,
`unsupported chunk version: ${chunk.metadata.version}`
);
}
if (chunk.metadata.flags !== 0x0000) {
throw new ChunksError(
CHUNKS_CODES.FLAGS_RESERVED_NONZERO,
`reserved flags must be zero in v1: ${chunk.metadata.flags}`
);
}
const { chunkId, totalChunks, payloadLen, chunkSize, fileSize } =
chunk.metadata;
if (chunkId < totalChunks - 1) {
const expected = chunkSize - HEADER_SIZE;
if (payloadLen !== expected) {
throw new ChunksError(
CHUNKS_CODES.PAYLOAD_LEN_INVALID,
`non-final chunk payloadLen mismatch: ${payloadLen} !== ${expected}`
);
}
} else {
const expected = Number(fileSize) - chunkId * (chunkSize - HEADER_SIZE);
if (payloadLen !== expected) {
throw new ChunksError(
CHUNKS_CODES.PAYLOAD_LEN_INVALID,
`final chunk payloadLen mismatch: ${payloadLen} !== ${expected}`
);
}
}
}

View File

@@ -0,0 +1,67 @@
# header
## Purpose
Encodes and parses the fixed-width 100-byte metadata header that prefixes every chunk in the `@suaveplan/drive-plugin-chunks` wire format. Big-endian throughout, no trailing version bytes, layout pinned by constants so receivers can locate each field without consulting the manifest.
## Features
- `encodeHeader(metadata): Uint8Array` — packs `ChunkMetadata` into a 100-byte buffer using big-endian byte order.
- `parseHeader(buf): ChunkMetadata` — reverses the encoding and rejects under-length buffers with `CHUNKS.METADATA_INVALID`.
- `METADATA_SIZE` (100) — header length excluding signature.
- `SIG_SIZE` (64) — signature length.
- `HEADER_SIZE` (164) — `METADATA_SIZE + SIG_SIZE`, used by `encrypt-stream` and the layout helpers.
## Usage: Basic Example
```ts
import { encodeHeader, parseHeader } from "@suaveplan/drive-plugin-chunks";
const metadata = {
version: 1, fileId: new Uint8Array(32), chunkId: 0, totalChunks: 1,
fileSize: 8n, chunkSize: 1024, payloadLen: 8, flags: 0,
nonce: new Uint8Array(24), aeadTag: new Uint8Array(16),
};
const header = encodeHeader(metadata);
const back = parseHeader(header);
```
## Usage: Advanced Example
```ts
import { HEADER_SIZE, METADATA_SIZE, SIG_SIZE, parseHeader } from "@suaveplan/drive-plugin-chunks";
function decodeChunk(raw: Uint8Array) {
const metadata = parseHeader(raw.subarray(0, METADATA_SIZE));
const sig = raw.subarray(METADATA_SIZE, HEADER_SIZE);
const ciphertext = raw.subarray(HEADER_SIZE, HEADER_SIZE + metadata.payloadLen);
return { metadata, sig, ciphertext };
}
```
## API Reference
- `METADATA_SIZE: 100` — bytes reserved for metadata.
- `SIG_SIZE: 64` — bytes reserved for the Ed25519 signature.
- `HEADER_SIZE: 164` — `METADATA_SIZE + SIG_SIZE`.
- `encodeHeader(metadata: ChunkMetadata): Uint8Array` — returns a freshly-allocated 100-byte buffer.
- `parseHeader(buf: Uint8Array): ChunkMetadata` — throws `ChunksError(CHUNKS.METADATA_INVALID)` if `buf.length < METADATA_SIZE`. Slices `fileId`, `nonce`, and `aeadTag` so callers can mutate the input safely.
## Implementation Notes
Layout (big-endian):
| Offset | Length | Field |
|-------:|-------:|--------------|
| 0 | 2 | version |
| 2 | 32 | fileId |
| 34 | 4 | chunkId |
| 38 | 4 | totalChunks |
| 42 | 8 | fileSize (u64) |
| 50 | 4 | chunkSize |
| 54 | 4 | payloadLen |
| 58 | 2 | flags |
| 60 | 24 | nonce |
| 84 | 16 | aeadTag |
The header pads to exactly 100 bytes so `encrypt-stream` can compute payload offsets without recomputing field sums.

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES } from "../error-codes.js";
import { encodeHeader, parseHeader } from "./header.js";
describe("CHUNKS-HEADER-01 — Header encode/decode", () => {
it("round-trips all fields", { timeout: 5000 }, () => {
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32).fill(0xab),
chunkId: 7,
totalChunks: 10,
fileSize: 12345n,
chunkSize: 65536,
payloadLen: 100,
flags: 0x0000,
nonce: new Uint8Array(24).fill(0xcd),
aeadTag: new Uint8Array(16).fill(0xef),
};
const encoded = encodeHeader(metadata);
const parsed = parseHeader(encoded);
expect(parsed.version).toBe(0x0001);
expect(parsed.chunkId).toBe(7);
expect(parsed.totalChunks).toBe(10);
expect(parsed.fileSize).toBe(12345n);
expect(parsed.chunkSize).toBe(65536);
expect(parsed.payloadLen).toBe(100);
expect(parsed.flags).toBe(0x0000);
expect(parsed.fileId).toEqual(metadata.fileId);
expect(parsed.nonce).toEqual(metadata.nonce);
expect(parsed.aeadTag).toEqual(metadata.aeadTag);
});
it("rejects header shorter than METADATA_SIZE", { timeout: 5000 }, () => {
expect(() => parseHeader(new Uint8Array(50))).toThrow();
try {
parseHeader(new Uint8Array(50));
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.METADATA_INVALID);
}
});
});

View File

@@ -0,0 +1,59 @@
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
import type { ChunkMetadata } from "../types/chunks.types.js";
export const METADATA_SIZE = 100;
export const SIG_SIZE = 64;
export const HEADER_SIZE = METADATA_SIZE + SIG_SIZE; // 164
export function encodeHeader(metadata: ChunkMetadata): Uint8Array {
const buf = new Uint8Array(METADATA_SIZE);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
view.setUint16(0, metadata.version, false);
buf.set(metadata.fileId, 2);
view.setUint32(34, metadata.chunkId, false);
view.setUint32(38, metadata.totalChunks, false);
view.setBigUint64(42, metadata.fileSize, false);
view.setUint32(50, metadata.chunkSize, false);
view.setUint32(54, metadata.payloadLen, false);
view.setUint16(58, metadata.flags, false);
buf.set(metadata.nonce, 60);
buf.set(metadata.aeadTag, 84);
return buf;
}
export function parseHeader(buf: Uint8Array): ChunkMetadata {
if (buf.length < METADATA_SIZE) {
throw new ChunksError(
CHUNKS_CODES.METADATA_INVALID,
`metadata too short: ${buf.length} < ${METADATA_SIZE}`
);
}
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const version = view.getUint16(0, false);
const fileId = buf.slice(2, 34);
const chunkId = view.getUint32(34, false);
const totalChunks = view.getUint32(38, false);
const fileSize = view.getBigUint64(42, false);
const chunkSize = view.getUint32(50, false);
const payloadLen = view.getUint32(54, false);
const flags = view.getUint16(58, false);
const nonce = buf.slice(60, 84);
const aeadTag = buf.slice(84, 100);
return {
version,
fileId,
chunkId,
totalChunks,
fileSize,
chunkSize,
payloadLen,
flags,
nonce,
aeadTag,
};
}

View File

@@ -0,0 +1,59 @@
# error-codes
## Purpose
Defines the structured error registry for `@suaveplan/drive-plugin-chunks`. Every chunk-format failure mode the plugin can produce is registered through `defineCodes("CHUNKS", [...])` so that downstream consumers can branch on stable string codes (`"CHUNKS.AEAD_TAG_INVALID"`, etc.) without parsing English-language error messages. The companion `ChunksError` class extends the project-wide `SuaveplanError` base so chunked-file errors compose with cause-chain traversal, redaction, and OTel span-event adapters defined in `@suaveplan/error`.
## Features
- Frozen `CHUNKS_CODES` enum-shaped constant covering the twelve failure classes that can occur during chunk encoding, signing, manifest exchange, layout selection, ingest, and inline-hint verification.
- `ChunksErrorCode` literal union derived from `CHUNKS_CODES` so call sites stay type-safe.
- `ChunksError` class with the `SuaveplanError` constructor signature widened to public, accepting `(code, message, options?)` plus the standard `cause` field.
- Side-effect-safe registration via `defineCodes` so subpath consumers automatically see codes registered too.
## Usage: Basic Example
```ts
import { CHUNKS_CODES, ChunksError } from "@suaveplan/drive-plugin-chunks";
throw new ChunksError(
CHUNKS_CODES.AEAD_TAG_INVALID,
"chunk AEAD tag verification failed"
);
```
## Usage: Advanced Example
```ts
import { CHUNKS_CODES, ChunksError } from "@suaveplan/drive-plugin-chunks";
try {
await chunks.ingest("photo.jpg");
} catch (err) {
if (err instanceof ChunksError) {
switch (err.code) {
case CHUNKS_CODES.SIG_INVALID:
return alertOps("server signing key drift");
case CHUNKS_CODES.MANIFEST_NOT_FOUND:
return rebuildManifest(err);
default:
throw new ChunksError(
CHUNKS_CODES.INGEST_FAILED,
"wrapped ingest failure",
{ cause: err }
);
}
}
throw err;
}
```
## API Reference
- `CHUNKS_CODES` — `Readonly<Record<string, string>>` of `"CHUNKS.<NAME>"` strings.
- `ChunksErrorCode` — string-literal union of every value in `CHUNKS_CODES`.
- `ChunksError` — `class ChunksError extends SuaveplanError<ChunksErrorCode>` with constructor `(code: ChunksErrorCode, message: string, options?: SuaveplanErrorOptions)`. Inherits `name`, `code`, `message`, `cause`, and the structured-redaction surface from `SuaveplanError`.
## Implementation Notes
The twelve codes are deliberately narrow: each maps to one failure semantic (tag verification, signature verification, payload length mismatch, inline-hint signature failure, etc.) so monitoring dashboards can attach SLOs per code without redacting cardinality. `INLINE_SIG_INVALID` was added in DTP-25 to signal that an `InlineHint` failed Ed25519 verification against the drive's signing key over `fileId ‖ bytes`; legacy `LegacyInlineHint` shapes (no `sig` field) do not throw — they emit a `drive:inline:legacy` warning and trigger a rewrite-with-sig on next write.

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES, ChunksError } from "./error-codes.js";
describe("CHUNKS-CODES-01 — Error registry namespaced and frozen", () => {
it("Object.isFrozen(CHUNKS_CODES) is true", { timeout: 5000 }, () => {
expect(Object.isFrozen(CHUNKS_CODES)).toBe(true);
});
it("every code value starts with CHUNKS.", { timeout: 5000 }, () => {
for (const value of Object.values(CHUNKS_CODES)) {
expect(value).toMatch(/^CHUNKS\./);
}
});
it("ChunksError extends SuaveplanError", { timeout: 5000 }, () => {
const err = new ChunksError(CHUNKS_CODES.SIG_INVALID, "test");
expect(err.code).toBe("CHUNKS.SIG_INVALID");
expect(err.message).toBe("test");
});
});

View File

@@ -0,0 +1,30 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const CHUNKS_CODES = defineCodes("CHUNKS", [
"VERSION_UNSUPPORTED",
"METADATA_INVALID",
"SIG_INVALID",
"AEAD_TAG_INVALID",
"PAYLOAD_LEN_INVALID",
"FLAGS_RESERVED_NONZERO",
"MANIFEST_INVALID",
"MANIFEST_NOT_FOUND",
"FILE_NOT_FOUND",
"LAYOUT_INCOMPATIBLE",
"INGEST_FAILED",
"INLINE_SIG_INVALID",
] as const);
export type ChunksErrorCode = (typeof CHUNKS_CODES)[keyof typeof CHUNKS_CODES];
export class ChunksError extends SuaveplanError<ChunksErrorCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: ChunksErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}

View File

@@ -0,0 +1,2 @@
export type { ChunksErrorCode } from "../error-codes.js";
export { CHUNKS_CODES, ChunksError } from "../error-codes.js";

View File

@@ -0,0 +1,52 @@
import "./error-codes.js";
export { encodeChunk, parseChunk, validateChunk } from "./codec/chunk.js";
export {
encodeHeader,
HEADER_SIZE,
METADATA_SIZE,
parseHeader,
SIG_SIZE,
} from "./codec/header.js";
export { CHUNKS_CODES, ChunksError } from "./error-codes.js";
export {
type AnyInlineHint,
type FileLocation,
type InlineHint,
inlineSigningInput,
isLegacyInlineHint,
type LegacyInlineHint,
type StorageHint,
signInlineHint,
upgradeLegacyInlineHint,
type VerifyEd25519Fn,
verifyInlineHint,
} from "./inline/index.js";
export { createAutoLayout } from "./layout/auto.js";
export { createChunkKeysLayout } from "./layout/chunk-keys.js";
export { createSparseFileLayout, preallocate } from "./layout/sparse-file.js";
export { buildManifest, canonicalSerialize } from "./manifest/build.js";
export {
loadManifestAsChunk,
loadManifestFromServer,
parseAndVerifyManifest,
} from "./manifest/load.js";
export { decryptChunk } from "./pipeline/decrypt-chunk.js";
export { deriveNonce, encryptChunks } from "./pipeline/encrypt-stream.js";
export { buildSignedChunk, signChunk } from "./pipeline/sign-chunk.js";
export { verifyChunkSig } from "./pipeline/verify-chunk.js";
export { chunksPlugin } from "./plugin.js";
export { ManifestSchema } from "./schemas/manifest.schema.js";
export type {
Chunk,
ChunkLayout,
ChunkLayoutStrategy,
ChunkMetadata,
ChunkSource,
ChunksPluginOptions,
EncodedChunk,
Manifest,
ManifestChunkEntry,
ManifestEncryption,
ManifestSource,
} from "./types/chunks.types.js";

View File

@@ -0,0 +1,13 @@
export {
type AnyInlineHint,
type FileLocation,
type InlineHint,
inlineSigningInput,
isLegacyInlineHint,
type LegacyInlineHint,
type StorageHint,
signInlineHint,
upgradeLegacyInlineHint,
type VerifyEd25519Fn,
verifyInlineHint,
} from "./inline-hint.js";

View File

@@ -0,0 +1,81 @@
# inline-hint
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements DTP-25 (drive-plugin-chunks-inline-sig). Provides the signed `InlineHint` shape used by `FileLocation.storageHints` entries with `kind: 'inline'`, the Ed25519 signing helper that produces them, the verifier that the drive runs before returning inline bytes, and the legacy-detection / upgrade helpers that let pre-DTP-25 hints (no `sig` field) round-trip safely. The signing input is the canonical concatenation `fileId ‖ bytes`, which binds the signature to one file so a replay onto a different fileId would fail verification.
## Features
- `InlineHint` — `{ kind: 'inline'; bytes: Uint8Array; sig: Uint8Array }` for the DTP-25 wire format.
- `LegacyInlineHint` — `{ kind: 'inline'; bytes: Uint8Array }` for backward-compat reads.
- `signInlineHint` — Ed25519-sign over `fileId ‖ bytes` under the drive's secret key.
- `verifyInlineHint` — verify and return bytes; throws `CHUNKS.INLINE_SIG_INVALID` on failure (catch arm covers verifier exceptions too).
- `isLegacyInlineHint` — type-guard distinguishing legacy from signed shapes.
- `upgradeLegacyInlineHint` — sign a legacy hint with the drive's key so the next write rewrites it as `InlineHint`.
- `inlineSigningInput` — exposes the canonical concatenation so other layers can re-derive the bytes that are signed.
- Cause-chain preservation: verifier exceptions are wrapped as `ChunksError` with the original error attached as `cause`.
## Usage: Basic Example
```ts
import {
signInlineHint,
verifyInlineHint,
} from "@suaveplan/drive-plugin-chunks";
import { sha256 } from "@suaveplan/crypto-hash";
const fileId = sha256(plaintext);
const hint = signInlineHint(fileId, plaintext, driveSecretKey);
// later, on read:
const bytes = verifyInlineHint(hint, fileId, drivePublicKey);
console.log(bytes.byteLength);
```
## Usage: Advanced Example
```ts
import {
isLegacyInlineHint,
upgradeLegacyInlineHint,
verifyInlineHint,
type AnyInlineHint,
} from "@suaveplan/drive-plugin-chunks";
function readInline(
hint: AnyInlineHint,
fileId: Uint8Array,
driveSecretKey: Uint8Array,
drivePublicKey: Uint8Array,
onLegacyWarning: () => void
): Uint8Array {
if (isLegacyInlineHint(hint)) {
onLegacyWarning(); // emit drive:inline:legacy
const upgraded = upgradeLegacyInlineHint(
hint,
fileId,
driveSecretKey
);
// Persist `upgraded` on the next write; for the current read we
// can return the bytes directly because legacy hints predate the
// sig requirement.
return upgraded.bytes;
}
return verifyInlineHint(hint, fileId, drivePublicKey);
}
```
## API Reference
- `signInlineHint(fileId: Uint8Array, bytes: Uint8Array, driveSecretKey: Uint8Array): InlineHint` — Ed25519-sign `fileId ‖ bytes` and return the signed hint.
- `verifyInlineHint(hint: InlineHint, fileId: Uint8Array, drivePublicKey: Uint8Array): Uint8Array` — verify and return `hint.bytes`. Throws `ChunksError(CHUNKS.INLINE_SIG_INVALID, ...)` if the signature does not verify or the verifier raises.
- `isLegacyInlineHint(hint: AnyInlineHint): hint is LegacyInlineHint` — true when the hint omits `sig`.
- `upgradeLegacyInlineHint(legacy: LegacyInlineHint, fileId: Uint8Array, driveSecretKey: Uint8Array): InlineHint` — sign the legacy bytes under the drive's key.
- `inlineSigningInput(fileId: Uint8Array, bytes: Uint8Array): Uint8Array` — canonical signing input.
- `InlineHint`, `LegacyInlineHint`, `AnyInlineHint`, `StorageHint`, `FileLocation` — wire types.
## Implementation Notes
The verifier uses `@noble/curves` Ed25519 via `@suaveplan/crypto-curve/ed25519`. `verify()` is implemented to never throw on malformed inputs in the noble path, but the catch arm exists for engine-substitution scenarios where a custom verifier may throw — the `injectable verifyFn` pattern is intentionally not surfaced here because the chunks plugin always uses noble. The `CHUNKS.INLINE_SIG_INVALID` code is registered through `defineCodes("CHUNKS", ...)` in `error-codes.ts`. Domain separation is provided by including `fileId` in the signing input; this is sufficient because every inline-hint context already has a unique fileId, so the signature cannot be replayed across files even if an attacker controls one inline body.

View File

@@ -0,0 +1,134 @@
/**
* `InlineHint` (DTP-25) — signed inline-bytes storage hint.
*
* `FileLocation.storageHints` entries with `kind: 'inline'` carry the
* file body inline alongside an Ed25519 signature over
* `fileId ‖ bytes`. The drive verifies the signature before returning
* bytes; failure throws `CHUNKS.INLINE_SIG_INVALID`. Legacy inline
* hints (no `sig` field) are accepted with a `drive:inline:legacy`
* warning and rewritten with a sig on the next write.
*/
import { sign, verify } from "@suaveplan/crypto-curve/ed25519";
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
/** Signed inline-bytes hint introduced in DTP-25. */
export interface InlineHint {
readonly kind: "inline";
readonly bytes: Uint8Array;
readonly sig: Uint8Array;
}
/** Pre-DTP-25 inline-bytes hint — accepted on read, rewritten on write. */
export interface LegacyInlineHint {
readonly kind: "inline";
readonly bytes: Uint8Array;
}
/**
* Composes the canonical signing input: `fileId ‖ bytes`.
*
* Domain separation here is the `fileId` itself — every inline hint
* binds to a unique fileId so a signature for one file cannot be
* replayed onto another.
*/
export function inlineSigningInput(
fileId: Uint8Array,
bytes: Uint8Array
): Uint8Array {
const out = new Uint8Array(fileId.byteLength + bytes.byteLength);
out.set(fileId, 0);
out.set(bytes, fileId.byteLength);
return out;
}
/** Sign an inline hint under the drive's Ed25519 secret key. */
export function signInlineHint(
fileId: Uint8Array,
bytes: Uint8Array,
driveSecretKey: Uint8Array
): InlineHint {
const input = inlineSigningInput(fileId, bytes);
const sig = sign(input, driveSecretKey);
return { kind: "inline", bytes, sig };
}
/** Verifier injection point — defaults to `@noble/curves` Ed25519. */
export type VerifyEd25519Fn = (
sig: Uint8Array,
msg: Uint8Array,
pub: Uint8Array
) => boolean;
/**
* Verify an `InlineHint` under the drive's Ed25519 public key. Throws
* `CHUNKS.INLINE_SIG_INVALID` if the signature does not verify.
*
* Returns `bytes` so call sites can express the verify-then-use chain
* as a single expression: `const data = verifyInlineHint(...)`.
*
* `verifyFn` is injectable so engine-substitution scenarios (e.g. a
* native verifier that throws on malformed inputs rather than
* returning false) compose cleanly. In production it defaults to
* `@noble/curves` whose verifier returns false rather than throwing,
* which means the catch arm only fires when callers explicitly pass a
* throwing verifier.
*/
export function verifyInlineHint(
hint: InlineHint,
fileId: Uint8Array,
drivePublicKey: Uint8Array,
verifyFn: VerifyEd25519Fn = verify
): Uint8Array {
const input = inlineSigningInput(fileId, hint.bytes);
let ok: boolean;
try {
ok = verifyFn(hint.sig, input, drivePublicKey);
} catch (cause) {
throw new ChunksError(
CHUNKS_CODES.INLINE_SIG_INVALID,
`inline hint signature verification raised: ${String(cause)}`,
{ cause: cause as Error }
);
}
if (!ok) {
throw new ChunksError(
CHUNKS_CODES.INLINE_SIG_INVALID,
"inline hint signature does not verify"
);
}
return hint.bytes;
}
/** Detect legacy inline hints (those missing a `sig` field). */
export function isLegacyInlineHint(
hint: InlineHint | LegacyInlineHint
): hint is LegacyInlineHint {
return !("sig" in hint) || (hint as { sig?: unknown }).sig === undefined;
}
/**
* Upgrade a `LegacyInlineHint` to a signed `InlineHint`. Call sites
* are expected to emit a `drive:inline:legacy` warning before
* persisting the upgrade so observability captures the migration
* footprint.
*/
export function upgradeLegacyInlineHint(
legacy: LegacyInlineHint,
fileId: Uint8Array,
driveSecretKey: Uint8Array
): InlineHint {
return signInlineHint(fileId, legacy.bytes, driveSecretKey);
}
/** Discriminated-union covering both wire shapes. */
export type AnyInlineHint = InlineHint | LegacyInlineHint;
/** All `storageHints` entry kinds the chunks plugin recognises. */
export type StorageHint = AnyInlineHint;
/** Drive-level file-location entry shape used by `storageHints`. */
export interface FileLocation {
readonly fileId: Uint8Array;
readonly storageHints: ReadonlyArray<StorageHint>;
}

View File

@@ -0,0 +1,46 @@
# auto
## Purpose
Resolves the best chunk layout for a given storage at runtime: prefers `sparse-file` when the storage advertises `capabilities.partialWrites`, falls back to `chunk-keys` otherwise. This is the default `layout` value used by `chunksPlugin` when no explicit strategy is passed in.
## Features
- `createAutoLayout(storage): ChunkLayout` — single-call dispatcher.
- Capability-driven: reads `storage.capabilities.partialWrites` exactly once.
- Stable per storage: the returned `ChunkLayout` keeps the same `name` for the lifetime of the storage instance.
- No additional dependencies — composes the two concrete layouts.
## Usage: Basic Example
```ts
import { createAutoLayout } from "@suaveplan/drive-plugin-chunks";
const layout = createAutoLayout(storage);
console.log(layout.name); // "sparse-file" or "chunk-keys"
```
## Usage: Advanced Example
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
const drive = new Drive(storage);
await drive.initialize();
// `auto` is the default; equivalent to omitting `layout`
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey, serverPubKey, layout: "auto" })
);
console.log(chunks.layout); // resolved by createAutoLayout(storage)
```
## API Reference
- `createAutoLayout(storage: DriveStorage): ChunkLayout` — returns either a `chunk-keys` or `sparse-file` `ChunkLayout` based on `storage.capabilities`.
## Implementation Notes
Capability inspection is intentionally narrow — only `partialWrites` matters here because the sparse-file layout is the only one that needs offset-addressable writes. Storages that advertise other capabilities (`atomicWrites`, `contentAddressable`) but not `partialWrites` will receive the `chunk-keys` layout. There is no probe for write feasibility — the auto layer trusts the capabilities object; storages with mis-declared capabilities will fail at the first `setPartial` call rather than at layout-creation time.
The dispatcher returns a fully-constructed `ChunkLayout` rather than a thin proxy so subsequent calls into `putChunk` / `getChunk` go straight to the concrete implementation with no per-call branch. This matters when a single ingest produces tens of thousands of chunks: the resolved layout is captured once in the plugin's closure and referenced by stable identity for the lifetime of the plugin instance. Switching layouts mid-flight is intentionally unsupported — the auto resolver runs only at plugin construction.

View File

@@ -0,0 +1,30 @@
import { MemoryStorage } from "@suaveplan/storage/memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createAutoLayout } from "./auto.js";
function createStorage(partialWrites = true) {
const storage = new MemoryStorage();
const capabilities = { ...storage.capabilities, partialWrites };
return Object.assign(storage, {
id: "test",
capabilities,
}) as import("@suaveplan/drive").DriveStorage;
}
describe("CHUNKS-LAYOUT-AUTO-01 — Auto layout selection", () => {
it("selects sparse-file when partialWrites is true", {
timeout: 5000,
}, () => {
const storage = createStorage(true);
const layout = createAutoLayout(storage);
expect(layout.name).toBe("sparse-file");
});
it("selects chunk-keys when partialWrites is false", {
timeout: 5000,
}, () => {
const storage = createStorage(false);
const layout = createAutoLayout(storage);
expect(layout.name).toBe("chunk-keys");
});
});

View File

@@ -0,0 +1,11 @@
import type { DriveStorage } from "@suaveplan/drive";
import type { ChunkLayout } from "../types/chunks.types.js";
import { createChunkKeysLayout } from "./chunk-keys.js";
import { createSparseFileLayout } from "./sparse-file.js";
export function createAutoLayout(storage: DriveStorage): ChunkLayout {
if (storage.capabilities?.partialWrites === true) {
return createSparseFileLayout(storage);
}
return createChunkKeysLayout(storage);
}

View File

@@ -0,0 +1,54 @@
# chunk-keys
## Purpose
Implements the `chunk-keys` layout strategy that stores each chunk under a discrete storage key (`chunks/<fileIdHex>/<chunkId>`). This is the simplest layout — every chunk is a separate `set/get` operation, no preallocation, no offset arithmetic — and it works on any `DriveStorage` whose `set` and `list` methods are implemented.
## Features
- `createChunkKeysLayout(storage): ChunkLayout` — factory returning a `ChunkLayout` with `name: "chunk-keys"`.
- `putChunk(fileId, chunkId, rawBytes)` — single `storage.set` call.
- `getChunk(fileId, chunkId)` — single `storage.get` call returning `undefined` for missing chunks (no error).
- `hasChunks(fileId)` — returns a sparse `boolean[]` indexed by chunkId, derived from `storage.list({ prefix })`.
- `availableChunks(fileId)` — flattens `hasChunks` into the list of present indices.
## Usage: Basic Example
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
import { MemoryStorage } from "@suaveplan/storage/memory";
const storage = Object.assign(new MemoryStorage(), { id: "default" });
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey, serverPubKey, layout: "chunk-keys" })
);
```
## Usage: Advanced Example
```ts
import { createChunkKeysLayout } from "@suaveplan/drive-plugin-chunks";
const layout = createChunkKeysLayout(storage);
await layout.putChunk(fileId, 0, raw);
const present = await layout.hasChunks(fileId);
const available = await layout.availableChunks(fileId);
console.log(`have ${available.length}/${present.length}`);
```
## API Reference
- `createChunkKeysLayout(storage: DriveStorage): ChunkLayout`.
- `ChunkLayout.name === "chunk-keys"`.
- `putChunk(fileId, chunkId, rawBytes): Promise<void>`.
- `getChunk(fileId, chunkId): Promise<Uint8Array | undefined>`.
- `hasChunks(fileId): Promise<boolean[]>` — `bits.length === maxChunkId + 1`; uses `-1` sentinel when no chunks exist (yields empty array).
- `availableChunks(fileId): Promise<number[]>`.
## Implementation Notes
The `hex` helper formats `fileId` to lowercase hex with leading zeros, identical to the formatting in `plugin.ts` so stored chunks remain addressable across plugin and layout boundaries. `hasChunks` uses `storage.list({ prefix })` once and then computes the bit array client-side, which is O(n) over present chunks and avoids `n` storage round trips. Missing chunks correspond to `false` slots in the bit array; gaps are explicit, not implicit.

View File

@@ -0,0 +1,58 @@
import { MemoryStorage } from "@suaveplan/storage/memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createChunkKeysLayout } from "./chunk-keys.js";
function createStorage() {
return Object.assign(new MemoryStorage(), {
id: "test",
}) as import("@suaveplan/drive").DriveStorage;
}
function fileId(n: number): Uint8Array {
const buf = new Uint8Array(32);
buf[0] = n;
return buf;
}
describe("CHUNKS-LAYOUT-KEYS-01 — Chunk-keys layout", () => {
it("stores each chunk as its own object", { timeout: 5000 }, async () => {
const storage = createStorage();
const layout = createChunkKeysLayout(storage);
const id = fileId(1);
const chunk0 = new Uint8Array([1, 2, 3]);
await layout.putChunk(id, 0, chunk0);
const retrieved = await layout.getChunk(id, 0);
expect(retrieved).toBeDefined();
expect(retrieved!).toEqual(chunk0);
});
it("hasChunks derives from key existence", { timeout: 5000 }, async () => {
const storage = createStorage();
const layout = createChunkKeysLayout(storage);
const id = fileId(2);
await layout.putChunk(id, 0, new Uint8Array(10));
await layout.putChunk(id, 2, new Uint8Array(10));
const bits = await layout.hasChunks(id);
expect(bits[0]).toBe(true);
expect(bits[1]).toBe(false);
expect(bits[2]).toBe(true);
});
it("availableChunks returns present chunk ids", {
timeout: 5000,
}, async () => {
const storage = createStorage();
const layout = createChunkKeysLayout(storage);
const id = fileId(3);
await layout.putChunk(id, 0, new Uint8Array(10));
await layout.putChunk(id, 2, new Uint8Array(10));
const ids = Array.from(await layout.availableChunks(id));
expect(ids).toEqual([0, 2]);
});
});

View File

@@ -0,0 +1,49 @@
import type { DriveStorage } from "@suaveplan/drive";
import type { ChunkLayout } from "../types/chunks.types.js";
export function createChunkKeysLayout(storage: DriveStorage): ChunkLayout {
return {
name: "chunk-keys",
async putChunk(fileId, chunkId, rawBytes) {
const key = chunkKey(fileId, chunkId);
await storage.set(key, rawBytes);
},
async getChunk(fileId, chunkId) {
const key = chunkKey(fileId, chunkId);
return storage.get(key);
},
async hasChunks(fileId) {
const prefix = `chunks/${hex(fileId)}/`;
const result = await storage.list({ prefix });
const maxChunkId = result.keys.reduce(
(max: number, key: string) => {
const id = Number.parseInt(key.slice(prefix.length), 10);
return Math.max(max, id);
},
-1
);
const bits = new Array(maxChunkId + 1).fill(false);
for (const key of result.keys) {
const id = Number.parseInt(key.slice(prefix.length), 10);
bits[id] = true;
}
return bits;
},
async availableChunks(fileId) {
const bits = await this.hasChunks(fileId);
const ids: number[] = [];
for (let i = 0; i < bits.length; i++) {
if (bits[i]) ids.push(i);
}
return ids;
},
};
}
function chunkKey(fileId: Uint8Array, chunkId: number): string {
return `chunks/${hex(fileId)}/${chunkId}`;
}
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

View File

@@ -0,0 +1,51 @@
# sparse-file
## Purpose
Implements the `sparse-file` chunk layout that stores all chunks of a file as offset-addressable bytes inside a single sparse storage object. Chunks are written via `storage.setPartial(key, offset, bytes)` and read back via `storage.getPartial`. A side-band bitmap key records which chunks have been written so callers can answer `hasChunks` / `availableChunks` without paging the full file.
## Features
- `createSparseFileLayout(storage): ChunkLayout` — factory that throws `CHUNKS.LAYOUT_INCOMPATIBLE` if `storage.capabilities.partialWrites !== true`.
- `preallocate(storage, fileId, totalChunks, chunkSize, lastPayloadLen)` — sets the file size up-front via a single `storage.set` call so subsequent `setPartial` writes never extend the underlying object.
- Bitmap state tracked in `bitmap:<fileIdHex>` keys, packed eight bits per byte.
- Per-fileId `WeakMap` caches keep `chunkSize` and `totalChunks` reachable inside the layout's read path without forcing every call to consult the manifest.
## Usage: Basic Example
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
const drive = new Drive(sparseStorage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey, serverPubKey, layout: "sparse-file" })
);
```
## Usage: Advanced Example
```ts
import {
createSparseFileLayout,
preallocate,
} from "@suaveplan/drive-plugin-chunks";
const layout = createSparseFileLayout(storage);
await preallocate(storage, fileId, totalChunks, chunkSize, lastPayloadLen);
for (const c of encryptedChunks) {
await layout.putChunk(fileId, c.metadata.chunkId, c.rawBytes);
}
const present = await layout.hasChunks(fileId);
```
## API Reference
- `createSparseFileLayout(storage: DriveStorage): ChunkLayout` — name `"sparse-file"`.
- `preallocate(storage, fileId, totalChunks, chunkSize, lastPayloadLen): Promise<void>` — populates the WeakMap caches and zero-fills the file/bitmap keys.
- Layout methods mirror `chunk-keys`: `putChunk`, `getChunk`, `hasChunks`, `availableChunks`.
## Implementation Notes
The total file length is computed as `(totalChunks - 1) * chunkSize + HEADER_SIZE + lastPayloadLen`, matching the encrypt-stream output shape. The 164 in the inline arithmetic is `HEADER_SIZE` from `header.ts`. The bitmap is grown lazily inside `setBit` so the layout can survive a missing or truncated bitmap key (e.g. partial restore from backup) without losing chunks. `getChunk` returns `undefined` for missing files (`getItemSize === null`) and trusts `getPartial` for present-but-not-yet-populated regions.

View File

@@ -0,0 +1,217 @@
import { MemoryStorage } from "@suaveplan/storage/memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES } from "../error-codes.js";
import { createSparseFileLayout, preallocate } from "./sparse-file.js";
function createStorage(partialWrites = true) {
const storage = new MemoryStorage();
const capabilities = { ...storage.capabilities, partialWrites };
return Object.assign(storage, {
id: "test",
capabilities,
}) as import("@suaveplan/drive").DriveStorage;
}
function fileId(n: number): Uint8Array {
const buf = new Uint8Array(32);
buf[0] = n;
return buf;
}
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
describe("CHUNKS-LAYOUT-SPARSE-01 — Sparse-file layout", () => {
it("requires partialWrites capability", { timeout: 5000 }, () => {
const storage = createStorage(false);
expect(() => createSparseFileLayout(storage)).toThrow();
try {
createSparseFileLayout(storage);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.LAYOUT_INCOMPATIBLE);
}
});
it("pre-allocates then writes at offsets", { timeout: 5000 }, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(1);
await preallocate(storage, id, 3, 65536, 100);
const chunk0 = new Uint8Array(65536).fill(0xaa);
await layout.putChunk(id, 0, chunk0);
const retrieved = await layout.getChunk(id, 0);
expect(retrieved).toBeDefined();
expect(retrieved!.length).toBeGreaterThanOrEqual(chunk0.length);
});
it("tracks landed chunks in bitmap", { timeout: 5000 }, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(2);
await preallocate(storage, id, 3, 65536, 100);
await layout.putChunk(id, 0, new Uint8Array(65536));
await layout.putChunk(id, 2, new Uint8Array(65536));
const bits = await layout.hasChunks(id);
expect(bits[0]).toBe(true);
expect(bits[1]).toBe(false);
expect(bits[2]).toBe(true);
});
it("availableChunks returns present chunk ids", {
timeout: 5000,
}, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(3);
await preallocate(storage, id, 3, 65536, 100);
await layout.putChunk(id, 0, new Uint8Array(65536));
await layout.putChunk(id, 2, new Uint8Array(65536));
const ids = Array.from(await layout.availableChunks(id));
expect(ids).toEqual([0, 2]);
});
it("grows bitmap for large chunkIds", { timeout: 5000 }, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(4);
await preallocate(storage, id, 20, 65536, 100);
await layout.putChunk(id, 15, new Uint8Array(65536));
const bits = await layout.hasChunks(id);
expect(bits[15]).toBe(true);
});
it("creates bitmap without preallocation", { timeout: 5000 }, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(5);
// putChunk without preallocate — bitmap does not exist yet
await layout.putChunk(id, 0, new Uint8Array(65536));
// hasChunks returns empty because totalChunks is unknown without preallocate,
// but getBits still runs and hits the tc === 0 branch
const bits = await layout.hasChunks(id);
expect(bits.length).toBe(0);
// Verify bitmap exists in storage
const bitmap = await storage.get(`bitmap:${hex(id)}`);
expect(bitmap).toBeDefined();
expect(bitmap![0]! & 1).toBe(1);
});
it("grows existing bitmap for larger chunkIds", {
timeout: 5000,
}, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(6);
// First put a small chunkId, then a much larger one
await layout.putChunk(id, 0, new Uint8Array(65536));
await layout.putChunk(id, 10, new Uint8Array(65536));
const bitmap = await storage.get(`bitmap:${hex(id)}`);
expect(bitmap).toBeDefined();
expect(bitmap!.length).toBeGreaterThanOrEqual(2);
expect(bitmap![0]! & 1).toBe(1); // bit 0
expect(bitmap![1]! & (1 << 2)).toBe(1 << 2); // bit 10 = byte 1, bit 2
});
it("getChunk returns undefined for non-existent file", {
timeout: 5000,
}, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(7);
const result = await layout.getChunk(id, 0);
expect(result).toBeUndefined();
});
it("hasChunks returns empty when no bitmap exists", {
timeout: 5000,
}, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(99);
const bits = await layout.hasChunks(id);
expect(bits).toEqual([]);
});
it("getChunk returns stored chunk after putChunk", {
timeout: 5000,
}, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(8);
await preallocate(storage, id, 2, 65536, 100);
const chunk = new Uint8Array(65536).fill(0xab);
await layout.putChunk(id, 0, chunk);
const retrieved = await layout.getChunk(id, 0);
expect(retrieved).toBeDefined();
expect(retrieved!.slice(0, chunk.length)).toEqual(chunk);
});
it("getChunk returns undefined when getPartial returns null", {
timeout: 5000,
}, async () => {
const storage = createStorage(true);
const mockStorage: import("@suaveplan/drive").DriveStorage = {
capabilities: storage.capabilities,
get: (key: string) => storage.get(key),
set: (key: string, value: Uint8Array) => storage.set(key, value),
delete: (key: string) => storage.delete(key),
exists: (key: string) => storage.exists(key),
list: (options?: { prefix?: string }) => storage.list(options),
clear: () => storage.clear(),
getWithMetadata: (key: string) => storage.getWithMetadata(key),
getMetadata: (key: string) => storage.getMetadata(key),
getSize: () => storage.getSize(),
getPartial: async () => null,
getItemSize: (key: string) => storage.getItemSize(key),
setPartial: (key: string, offset: number, bytes: Uint8Array) =>
storage.setPartial(key, offset, bytes),
createReadStream: (key: string) => storage.createReadStream(key),
createWriteStream: (key: string) => storage.createWriteStream(key),
};
const layout = createSparseFileLayout(mockStorage);
const id = fileId(9);
await preallocate(mockStorage, id, 1, 65536, 100);
await layout.putChunk(id, 0, new Uint8Array(65536));
const retrieved = await layout.getChunk(id, 0);
expect(retrieved).toBeUndefined();
});
it("hasChunks handles bitmap shorter than totalChunks", {
timeout: 5000,
}, async () => {
const storage = createStorage(true);
const layout = createSparseFileLayout(storage);
const id = fileId(10);
await preallocate(storage, id, 3, 65536, 100);
await layout.putChunk(id, 0, new Uint8Array(65536));
// Truncate the bitmap to force the fallback branch
await storage.set(`bitmap:${hex(id)}`, new Uint8Array(0));
const bits = await layout.hasChunks(id);
expect(bits[0]).toBe(false);
expect(bits[1]).toBe(false);
expect(bits[2]).toBe(false);
});
});

View File

@@ -0,0 +1,146 @@
import type { DriveStorage } from "@suaveplan/drive";
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
import type { ChunkLayout } from "../types/chunks.types.js";
export function createSparseFileLayout(storage: DriveStorage): ChunkLayout {
if (storage.capabilities?.partialWrites !== true) {
throw new ChunksError(
CHUNKS_CODES.LAYOUT_INCOMPATIBLE,
"sparse-file layout requires storage.capabilities.partialWrites"
);
}
return {
name: "sparse-file",
async putChunk(fileId, chunkId, rawBytes) {
const key = fileIdKey(fileId);
await storage.setPartial(
key,
chunkId * chunkSize(fileId),
rawBytes
);
await setBit(storage, fileId, chunkId);
},
async getChunk(fileId, chunkId) {
const key = fileIdKey(fileId);
const size = await storage.getItemSize(key);
if (size === null) return undefined;
const result = await storage.getPartial(
key,
chunkId * chunkSize(fileId),
rawBytesLength(fileId, chunkId)
);
return result ?? undefined;
},
async hasChunks(fileId) {
return getBits(storage, fileId);
},
async availableChunks(fileId) {
const bits = await getBits(storage, fileId);
const ids: number[] = [];
for (let i = 0; i < bits.length; i++) {
if (bits[i]) ids.push(i);
}
return ids;
},
};
}
const CHUNK_SIZE_CACHE = new WeakMap<object, number>();
const TOTAL_CHUNKS_CACHE = new WeakMap<object, number>();
function chunkSize(fileId: Uint8Array): number {
return CHUNK_SIZE_CACHE.get(fileId) ?? 65536;
}
function totalChunks(fileId: Uint8Array): number {
return TOTAL_CHUNKS_CACHE.get(fileId) ?? 0;
}
function rawBytesLength(fileId: Uint8Array, chunkId: number): number {
const tc = totalChunks(fileId);
if (chunkId < tc - 1) {
return chunkSize(fileId);
}
// Final chunk: we don't know exact length without manifest,
// so read up to chunkSize and let caller parse
return chunkSize(fileId);
}
export async function preallocate(
storage: DriveStorage,
fileId: Uint8Array,
totalChunks_: number,
chunkSize_: number,
lastPayloadLen: number
): Promise<void> {
CHUNK_SIZE_CACHE.set(fileId, chunkSize_);
TOTAL_CHUNKS_CACHE.set(fileId, totalChunks_);
const totalLen = (totalChunks_ - 1) * chunkSize_ + (164 + lastPayloadLen);
const empty = new Uint8Array(totalLen);
await storage.set(fileIdKey(fileId), empty);
await storage.set(
bitmapKey(fileId),
new Uint8Array(Math.ceil(totalChunks_ / 8))
);
}
async function setBit(
storage: DriveStorage,
fileId: Uint8Array,
chunkId: number
): Promise<void> {
const key = bitmapKey(fileId);
const existing = await storage.get(key);
let bitmap: Uint8Array;
if (existing) {
bitmap = new Uint8Array(existing);
} else {
bitmap = new Uint8Array(Math.ceil((chunkId + 1) / 8));
}
const byteIndex = Math.floor(chunkId / 8);
const bitIndex = chunkId % 8;
if (byteIndex >= bitmap.length) {
const grown = new Uint8Array(byteIndex + 1);
grown.set(bitmap);
grown[byteIndex]! |= 1 << bitIndex;
await storage.set(key, grown);
} else {
bitmap[byteIndex]! |= 1 << bitIndex;
await storage.set(key, bitmap);
}
}
async function getBits(
storage: DriveStorage,
fileId: Uint8Array
): Promise<boolean[]> {
const key = bitmapKey(fileId);
const bitmap = await storage.get(key);
if (!bitmap) return [];
const tc = totalChunks(fileId);
if (tc === 0) return [];
const bits: boolean[] = [];
for (let i = 0; i < tc; i++) {
const byteIndex = Math.floor(i / 8);
const bitIndex = i % 8;
bits.push(
byteIndex < bitmap.length
? (bitmap[byteIndex]! & (1 << bitIndex)) !== 0
: false
);
}
return bits;
}
function fileIdKey(fileId: Uint8Array): string {
return `file:${hex(fileId)}`;
}
function bitmapKey(fileId: Uint8Array): string {
return `bitmap:${hex(fileId)}`;
}
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

View File

@@ -0,0 +1,48 @@
# build
## Purpose
Constructs a server-signed `Manifest` for an ingested file: bundles the file metadata (`fileId`, `fileSize`, `chunkSize`, `totalChunks`), encryption envelope (`algo`, `fileKeyWrapped`, `baseNonce`), and per-chunk integrity hashes into a single record, then signs the canonical encoding under the server private key. The manifest is the consensus artefact: peers that trust the server public key can verify it without holding any chunk bytes.
## Features
- `buildManifest(options): Manifest` — pure function, no I/O, no global state.
- `canonicalSerialize(manifest): Uint8Array` — deterministic big-endian length-prefixed encoding suitable for cross-platform Ed25519 signing.
- `algo` is encoded as a length-prefixed UTF-8 string so future encryption algorithms can be added without breaking the wire format.
- Per-chunk entries pack `(chunkId: u32, hash: 32 bytes, payloadLen: u32)` into 40 bytes — bounded growth, predictable memory.
## Usage: Basic Example
```ts
import { buildManifest } from "@suaveplan/drive-plugin-chunks";
const manifest = buildManifest({
fileId: sha256Bytes,
fileSize: 12n,
chunkSize: 1024,
totalChunks: 1,
encryption: { algo: "xchacha20poly1305", fileKeyWrapped: fileKey, baseNonce },
chunks: [{ chunkId: 0, hash: chunkHash, payloadLen: 12 }],
serverPrivKey,
});
```
## Usage: Advanced Example
```ts
import { buildManifest, canonicalSerialize } from "@suaveplan/drive-plugin-chunks";
import { verify } from "@suaveplan/crypto-curve/ed25519";
const manifest = buildManifest({ /* ... */ });
const { sig, ...base } = manifest;
const ok = verify(sig, canonicalSerialize(base), serverPubKey);
```
## API Reference
- `buildManifest(options): Manifest` — returns `{ version, fileId, fileSize, chunkSize, totalChunks, encryption, chunks, sig }`.
- `canonicalSerialize(manifest: Omit<Manifest, "sig">): Uint8Array` — used both for signing and for verification.
## Implementation Notes
Field order in `canonicalSerialize` is fixed and big-endian; changing it would break every previously-stored manifest. The `algo` length prefix makes the encoded form self-delimiting so receivers don't need an explicit version bump to add new algorithms — they only need to add a parsing branch keyed on the algo string. The final `buf.slice(0, off)` trims any over-allocated tail, but `totalLen` is computed exactly so this is normally a no-op.

View File

@@ -0,0 +1,106 @@
import { sign } from "@suaveplan/crypto-curve/ed25519";
import type {
Manifest,
ManifestChunkEntry,
ManifestEncryption,
} from "../types/chunks.types.js";
export function buildManifest(options: {
fileId: Uint8Array;
fileSize: bigint;
chunkSize: number;
totalChunks: number;
encryption: ManifestEncryption;
chunks: ManifestChunkEntry[];
serverPrivKey: Uint8Array;
}): Manifest {
const {
fileId,
fileSize,
chunkSize,
totalChunks,
encryption,
chunks,
serverPrivKey,
} = options;
const manifestBase = {
version: 0x0001,
fileId,
fileSize,
chunkSize,
totalChunks,
encryption,
chunks,
};
const serialized = canonicalSerialize(
manifestBase as Omit<Manifest, "sig">
);
const sig = sign(serialized, serverPrivKey);
return { ...manifestBase, sig } as Manifest;
}
export function canonicalSerialize(
manifest: Omit<Manifest, "sig">
): Uint8Array {
const algoBytes = new TextEncoder().encode(manifest.encryption.algo);
const totalLen =
2 + // version
32 + // fileId
8 + // fileSize
4 + // chunkSize
4 + // totalChunks
4 +
algoBytes.length + // encryption.algo
4 +
manifest.encryption.fileKeyWrapped.length + // fileKeyWrapped
24 + // baseNonce
4 + // chunks count
manifest.chunks.length * (4 + 32 + 4) + // each chunk entry
0;
const buf = new Uint8Array(totalLen);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let off = 0;
view.setUint16(off, manifest.version, false);
off += 2;
buf.set(manifest.fileId, off);
off += 32;
view.setBigUint64(off, manifest.fileSize, false);
off += 8;
view.setUint32(off, manifest.chunkSize, false);
off += 4;
view.setUint32(off, manifest.totalChunks, false);
off += 4;
view.setUint32(off, algoBytes.length, false);
off += 4;
buf.set(algoBytes, off);
off += algoBytes.length;
view.setUint32(off, manifest.encryption.fileKeyWrapped.length, false);
off += 4;
buf.set(manifest.encryption.fileKeyWrapped, off);
off += manifest.encryption.fileKeyWrapped.length;
buf.set(manifest.encryption.baseNonce, off);
off += 24;
view.setUint32(off, manifest.chunks.length, false);
off += 4;
for (const c of manifest.chunks) {
view.setUint32(off, c.chunkId, false);
off += 4;
buf.set(c.hash, off);
off += 32;
view.setUint32(off, c.payloadLen, false);
off += 4;
}
return buf.slice(0, off);
}

View File

@@ -0,0 +1,54 @@
# load
## Purpose
Loads server-signed manifests from the two supported sources: a sentinel chunk inside the file's own chunk space (`manifestSource: "manifest-as-chunk"`) and a separate manifest server keyed by file id (`manifestSource: "server-only"`). Every code path runs the same `parseAndVerifyManifest` so signature verification can never be skipped.
## Features
- `loadManifestAsChunk(fileId, getChunk, serverPubKey)` — reads chunkId `-1` from the chunk source.
- `loadManifestFromServer(fileId, url, fetchFn, serverPubKey)` — issues a `fetch(url + "/manifest/<idHex>")` GET; injectable `fetchFn` for testing.
- `parseAndVerifyManifest(raw, serverPubKey)` — JSON-decodes (with `base64:` Uint8Array unwrapping), validates against `ManifestSchema`, and runs Ed25519 verification with the canonical encoder.
- `MANIFEST_NOT_FOUND` and `MANIFEST_INVALID` are the only errors that can come out of this module — every internal failure is mapped onto one of those two codes.
## Usage: Basic Example
```ts
import { loadManifestAsChunk } from "@suaveplan/drive-plugin-chunks";
const manifest = await loadManifestAsChunk(
fileId,
(id, idx) => chunks.getChunk(id, idx),
serverPubKey
);
```
## Usage: Advanced Example
```ts
import {
loadManifestFromServer,
parseAndVerifyManifest,
} from "@suaveplan/drive-plugin-chunks";
const manifest = await loadManifestFromServer(
fileId,
"https://manifests.example.com",
fetch,
serverPubKey
);
// Verifying a manifest received over a side-channel
const verified = parseAndVerifyManifest(receivedBytes, serverPubKey);
```
## API Reference
- `loadManifestAsChunk(fileId, getChunk, serverPubKey): Promise<Manifest>`.
- `loadManifestFromServer(fileId, url, fetchFn, serverPubKey): Promise<Manifest>`.
- `parseAndVerifyManifest(raw: Uint8Array, serverPubKey: Uint8Array): Manifest`.
- All three throw `ChunksError(CHUNKS.MANIFEST_NOT_FOUND)` (missing input) or `ChunksError(CHUNKS.MANIFEST_INVALID)` (schema or signature failure).
## Implementation Notes
`parseAndVerifyManifest` first tries to parse as UTF-8 JSON because manifests are persisted as JSON inside `plugin.ingest`; the catch falls back to treating the raw bytes as the parsed value, which lets future binary manifest formats slot in without changing the call site. Signature verification rebuilds `withoutSig` with a 64-byte zero placeholder and asks the canonical encoder to lay out the bytes; this matches the layout used at sign time so the verification is over identical bytes.

View File

@@ -0,0 +1,201 @@
import {
getPublicKey,
randomPrivateKey,
} from "@suaveplan/crypto-curve/ed25519";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES } from "../error-codes.js";
import { buildManifest } from "../manifest/build.js";
import {
loadManifestAsChunk,
loadManifestFromServer,
parseAndVerifyManifest,
} from "../manifest/load.js";
describe("CHUNKS-MANIFEST-01 — Manifest format and signature", () => {
it("builds and verifies a manifest", { timeout: 5000 }, () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const manifest = buildManifest({
fileId: new Uint8Array(32).fill(0xab),
fileSize: 12345n,
chunkSize: 65536,
totalChunks: 3,
encryption: {
algo: "xchacha20poly1305",
fileKeyWrapped: new Uint8Array(32),
baseNonce: new Uint8Array(24),
},
chunks: [
{ chunkId: 0, hash: new Uint8Array(32), payloadLen: 65372 },
{ chunkId: 1, hash: new Uint8Array(32), payloadLen: 65372 },
{ chunkId: 2, hash: new Uint8Array(32), payloadLen: 100 },
],
serverPrivKey: privKey,
});
const raw = new TextEncoder().encode(
JSON.stringify(manifest, (_k, v) => {
if (v instanceof Uint8Array)
return `base64:${btoa(String.fromCharCode(...v))}`;
if (typeof v === "bigint") return v.toString();
return v;
})
);
const loaded = parseAndVerifyManifest(raw, pubKey);
expect(loaded.version).toBe(0x0001);
expect(loaded.fileSize).toBe(12345n);
expect(loaded.totalChunks).toBe(3);
});
it("rejects manifest with invalid signature", { timeout: 5000 }, () => {
const privKey = randomPrivateKey();
const wrongPubKey = getPublicKey(randomPrivateKey());
const manifest = buildManifest({
fileId: new Uint8Array(32),
fileSize: 1n,
chunkSize: 65536,
totalChunks: 1,
encryption: {
algo: "xchacha20poly1305",
fileKeyWrapped: new Uint8Array(32),
baseNonce: new Uint8Array(24),
},
chunks: [{ chunkId: 0, hash: new Uint8Array(32), payloadLen: 1 }],
serverPrivKey: privKey,
});
const raw = new TextEncoder().encode(
JSON.stringify(manifest, (_k, v) => {
if (v instanceof Uint8Array)
return `base64:${btoa(String.fromCharCode(...v))}`;
if (typeof v === "bigint") return v.toString();
return v;
})
);
expect(() => parseAndVerifyManifest(raw, wrongPubKey)).toThrow();
try {
parseAndVerifyManifest(raw, wrongPubKey);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.MANIFEST_INVALID);
}
});
it("rejects manifest with invalid JSON", { timeout: 5000 }, () => {
const pubKey = getPublicKey(randomPrivateKey());
const raw = new TextEncoder().encode("not-json-at-all");
expect(() => parseAndVerifyManifest(raw, pubKey)).toThrow();
try {
parseAndVerifyManifest(raw, pubKey);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.MANIFEST_INVALID);
}
});
it("rejects manifest that fails schema validation", {
timeout: 5000,
}, () => {
const pubKey = getPublicKey(randomPrivateKey());
const raw = new TextEncoder().encode(
JSON.stringify({ version: 0x0001, totalChunks: 0 })
);
expect(() => parseAndVerifyManifest(raw, pubKey)).toThrow();
try {
parseAndVerifyManifest(raw, pubKey);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.MANIFEST_INVALID);
}
});
it("loadManifestAsChunk throws when chunk not found", {
timeout: 5000,
}, async () => {
const pubKey = getPublicKey(randomPrivateKey());
await expect(
loadManifestAsChunk(
new Uint8Array(32),
async () => undefined,
pubKey
)
).rejects.toThrow();
try {
await loadManifestAsChunk(
new Uint8Array(32),
async () => undefined,
pubKey
);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.MANIFEST_NOT_FOUND);
}
});
it("loadManifestFromServer throws on non-ok response", {
timeout: 5000,
}, async () => {
const pubKey = getPublicKey(randomPrivateKey());
const fetchFn = async () => ({ ok: false, status: 404 }) as Response;
await expect(
loadManifestFromServer(
new Uint8Array(32),
"http://test",
fetchFn,
pubKey
)
).rejects.toThrow();
try {
await loadManifestFromServer(
new Uint8Array(32),
"http://test",
fetchFn,
pubKey
);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.MANIFEST_NOT_FOUND);
}
});
it("loadManifestFromServer fetches and verifies manifest", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const manifest = buildManifest({
fileId: new Uint8Array(32),
fileSize: 1n,
chunkSize: 65536,
totalChunks: 1,
encryption: {
algo: "xchacha20poly1305",
fileKeyWrapped: new Uint8Array(32),
baseNonce: new Uint8Array(24),
},
chunks: [{ chunkId: 0, hash: new Uint8Array(32), payloadLen: 1 }],
serverPrivKey: privKey,
});
const raw = new TextEncoder().encode(
JSON.stringify(manifest, (_k, v) => {
if (v instanceof Uint8Array)
return `base64:${btoa(String.fromCharCode(...v))}`;
if (typeof v === "bigint") return v.toString();
return v;
})
);
const fetchFn = async () =>
({
ok: true,
arrayBuffer: async () => raw.buffer,
}) as Response;
const loaded = await loadManifestFromServer(
new Uint8Array(32),
"http://test",
fetchFn,
pubKey
);
expect(loaded.version).toBe(0x0001);
expect(loaded.fileSize).toBe(1n);
});
});

View File

@@ -0,0 +1,84 @@
import { verify } from "@suaveplan/crypto-curve/ed25519";
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
import { ManifestSchema } from "../schemas/manifest.schema.js";
import type { Manifest } from "../types/chunks.types.js";
import { canonicalSerialize } from "./build.js";
export async function loadManifestAsChunk(
fileId: Uint8Array,
getChunk: (
fileId: Uint8Array,
chunkId: number
) => Promise<Uint8Array | undefined>,
serverPubKey: Uint8Array
): Promise<Manifest> {
const raw = await getChunk(fileId, -1);
if (!raw) {
throw new ChunksError(
CHUNKS_CODES.MANIFEST_NOT_FOUND,
`manifest not found for fileId ${hex(fileId)}`
);
}
return parseAndVerifyManifest(raw, serverPubKey);
}
export async function loadManifestFromServer(
fileId: Uint8Array,
url: string,
fetchFn: typeof fetch,
serverPubKey: Uint8Array
): Promise<Manifest> {
const response = await fetchFn(`${url}/manifest/${hex(fileId)}`);
if (!response.ok) {
throw new ChunksError(
CHUNKS_CODES.MANIFEST_NOT_FOUND,
`manifest server returned ${response.status}`
);
}
const raw = new Uint8Array(await response.arrayBuffer());
return parseAndVerifyManifest(raw, serverPubKey);
}
export function parseAndVerifyManifest(
raw: Uint8Array,
serverPubKey: Uint8Array
): Manifest {
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(raw), (_k, v) => {
if (typeof v === "string" && v.startsWith("base64:")) {
return Uint8Array.from(atob(v.slice(7)), (c) =>
c.charCodeAt(0)
);
}
return v;
});
} catch {
parsed = raw;
}
const result = ManifestSchema.safeParse(parsed);
if (!result.success) {
throw new ChunksError(
CHUNKS_CODES.MANIFEST_INVALID,
`manifest schema validation failed: ${result.error.message}`
);
}
const manifest = result.data as Manifest;
const withoutSig = { ...manifest, sig: new Uint8Array(64) };
const serialized = canonicalSerialize(withoutSig);
const valid = verify(manifest.sig, serialized, serverPubKey);
if (!valid) {
throw new ChunksError(
CHUNKS_CODES.MANIFEST_INVALID,
"manifest signature verification failed"
);
}
return manifest;
}
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

View File

@@ -0,0 +1,44 @@
# source
## Purpose
Validates the `manifestSource` configuration before the plugin starts wiring its read middleware. The function is small but load-bearing: it short-circuits the `"server-only"` branch when no `manifestServerUrl` is supplied so the rest of the plugin can assume the source is well-formed by the time it runs.
## Features
- `resolveManifestSource(source, manifestServerUrl?): { source, url }` — returns the normalised configuration tuple.
- Eager validation — throws synchronously instead of failing inside the read pipeline.
- Pure function — no I/O, no global state, deterministic for fixed inputs.
- Unaware of `getChunk` / `fetch` — those concerns live in `load.ts`.
## Usage: Basic Example
```ts
import { resolveManifestSource } from "@suaveplan/drive-plugin-chunks";
const cfg = resolveManifestSource("manifest-as-chunk");
```
## Usage: Advanced Example
```ts
import { resolveManifestSource } from "@suaveplan/drive-plugin-chunks";
const cfg = resolveManifestSource("server-only", "https://manifests.example.com");
console.log(cfg.url); // "https://manifests.example.com"
try {
resolveManifestSource("server-only");
} catch (err) {
console.error("missing manifestServerUrl");
}
```
## API Reference
- `resolveManifestSource(source: ManifestSource, manifestServerUrl?: string): { source: ManifestSource; url: string | undefined }`.
- Throws a plain `Error` (not `ChunksError`) when `source === "server-only"` and `manifestServerUrl` is missing, because this is a programmer error in plugin construction, not a runtime fault that consumers should branch on.
## Implementation Notes
The two valid `ManifestSource` values are intentionally narrow: `"manifest-as-chunk"` reuses the chunk-storage substrate as the manifest store, while `"server-only"` keeps manifests out-of-band on a separate HTTPS endpoint. Future sources (e.g. a libp2p PubSub channel) would slot in by extending the union type and the dispatcher in `load.ts`. The plain `Error` is surfaced as soon as the plugin object is constructed so misconfigurations show up at boot, not during the first `read`.

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { resolveManifestSource } from "./source.js";
describe("CHUNKS-MANIFEST-SOURCE-01 — resolveManifestSource", () => {
it("returns source and url for embedded", { timeout: 5000 }, () => {
const result = resolveManifestSource("embedded", undefined);
expect(result.source).toBe("embedded");
expect(result.url).toBeUndefined();
});
it("returns source and url for server-only with url", {
timeout: 5000,
}, () => {
const result = resolveManifestSource("server-only", "http://test");
expect(result.source).toBe("server-only");
expect(result.url).toBe("http://test");
});
it("throws for server-only without url", { timeout: 5000 }, () => {
expect(() => resolveManifestSource("server-only", undefined)).toThrow(
"manifestServerUrl is required"
);
});
});

View File

@@ -0,0 +1,13 @@
import type { ManifestSource } from "../types/chunks.types.js";
export function resolveManifestSource(
source: ManifestSource,
manifestServerUrl?: string
): { source: ManifestSource; url: string | undefined } {
if (source === "server-only" && !manifestServerUrl) {
throw new Error(
"manifestServerUrl is required when manifestSource is server-only"
);
}
return { source, url: manifestServerUrl };
}

View File

@@ -0,0 +1,57 @@
# decrypt-chunk
## Purpose
The single-chunk decryption helper that runs on the read side of the pipeline. Given chunk metadata (already parsed via `header.parseHeader`), the chunk ciphertext, and the file key, it concatenates the ciphertext with the embedded AEAD tag and asks `@suaveplan/crypto-cipher/xchacha20-poly1305` to verify-and-decrypt in one call.
## Features
- `decryptChunk(metadata, ciphertext, fileKey): Uint8Array` — returns plaintext bytes; the caller is expected to truncate to `metadata.payloadLen`.
- Tag-failure path is structured: a `decrypt` failure is wrapped as `ChunksError(CHUNKS.AEAD_TAG_INVALID)` regardless of whether the underlying error was a tag mismatch, an invalid nonce length, or an internal `@noble/ciphers` rejection.
- Allocation-light — only one fresh buffer (`fullCiphertext`) is allocated per call.
## Usage: Basic Example
```ts
import { decryptChunk, parseChunk } from "@suaveplan/drive-plugin-chunks";
const chunk = parseChunk(rawChunk);
const plain = decryptChunk(chunk.metadata, chunk.ciphertext, fileKey);
const trimmed = plain.slice(0, chunk.metadata.payloadLen);
```
## Usage: Advanced Example
```ts
import {
CHUNKS_CODES,
ChunksError,
decryptChunk,
parseChunk,
} from "@suaveplan/drive-plugin-chunks";
async function reassembleSafely(rawChunks: Uint8Array[], fileKey: Uint8Array) {
const out: Uint8Array[] = [];
for (const raw of rawChunks) {
const c = parseChunk(raw);
try {
const plain = decryptChunk(c.metadata, c.ciphertext, fileKey);
out.push(plain.slice(0, c.metadata.payloadLen));
} catch (err) {
if (err instanceof ChunksError && err.code === CHUNKS_CODES.AEAD_TAG_INVALID) {
return null; // request a re-fetch from a different peer
}
throw err;
}
}
return out;
}
```
## API Reference
- `decryptChunk(metadata: ChunkMetadata, ciphertext: Uint8Array, fileKey: Uint8Array): Uint8Array` — concatenates `ciphertext || metadata.aeadTag` and runs XChaCha20-Poly1305 decrypt under `fileKey` and `metadata.nonce`. Throws `ChunksError(CHUNKS.AEAD_TAG_INVALID)` on any verification failure.
## Implementation Notes
The function does NOT trim the output to `payloadLen` — that's the caller's job. This avoids a redundant `.slice` allocation in transport code that already writes plaintext into a preallocated reassembly buffer. The error message is intentionally generic (`"chunk AEAD tag verification failed"`) so consumers cannot use the message text to distinguish between attacker-induced tag mismatches and accidental ciphertext corruption — both are fatal.

View File

@@ -0,0 +1,62 @@
import { randomPrivateKey } from "@suaveplan/crypto-curve/ed25519";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
import type { ChunkMetadata } from "../types/chunks.types.js";
import { decryptChunk } from "./decrypt-chunk.js";
import { encryptChunks } from "./encrypt-stream.js";
describe("CHUNKS-DECRYPT-01 — round-trip and tamper rejection", () => {
it("decrypts a freshly-encrypted chunk back to its plaintext", {
timeout: 5000,
}, () => {
const fileKey = new Uint8Array(32).fill(7);
const baseNonce = new Uint8Array(24).fill(11);
const fileId = new Uint8Array(32).fill(3);
const serverPrivKey = randomPrivateKey();
const plaintext = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const chunks = Array.from(
encryptChunks({
plaintext,
fileId,
fileKey,
baseNonce,
chunkSize: 1024,
serverPrivKey,
})
);
expect(chunks).toHaveLength(1);
const first = chunks[0];
if (!first) throw new Error("no chunk");
const ciphertext = first.rawBytes.slice(
first.rawBytes.length - first.metadata.payloadLen
);
const out = decryptChunk(first.metadata, ciphertext, fileKey);
expect(out.slice(0, first.metadata.payloadLen)).toEqual(plaintext);
});
it("throws CHUNKS.AEAD_TAG_INVALID when ciphertext is tampered", {
timeout: 5000,
}, () => {
const fileKey = new Uint8Array(32).fill(7);
const tamperedMeta: ChunkMetadata = {
version: 1,
fileId: new Uint8Array(32).fill(3),
chunkId: 0,
totalChunks: 1,
fileSize: 8n,
chunkSize: 16,
payloadLen: 8,
flags: 0,
nonce: new Uint8Array(24).fill(11),
aeadTag: new Uint8Array(16).fill(0xff),
};
const tamperedCiphertext = new Uint8Array(8).fill(0xaa);
try {
decryptChunk(tamperedMeta, tamperedCiphertext, fileKey);
throw new Error("expected AEAD failure");
} catch (e) {
expect(e).toBeInstanceOf(ChunksError);
expect((e as ChunksError).code).toBe(CHUNKS_CODES.AEAD_TAG_INVALID);
}
});
});

View File

@@ -0,0 +1,23 @@
import { decrypt } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
import type { ChunkMetadata } from "../types/chunks.types.js";
export function decryptChunk(
metadata: ChunkMetadata,
ciphertext: Uint8Array,
fileKey: Uint8Array
): Uint8Array {
const fullCiphertext = new Uint8Array(
ciphertext.length + metadata.aeadTag.length
);
fullCiphertext.set(ciphertext, 0);
fullCiphertext.set(metadata.aeadTag, ciphertext.length);
try {
return decrypt(fileKey, metadata.nonce, fullCiphertext);
} catch {
throw new ChunksError(
CHUNKS_CODES.AEAD_TAG_INVALID,
"chunk AEAD tag verification failed"
);
}
}

View File

@@ -0,0 +1,54 @@
# encrypt-stream
## Purpose
The encryption pipeline that transforms a plaintext file into a stream of signed encrypted chunks ready for persistence. It owns the chunk-level nonce derivation, the AEAD ciphertext + tag split, the per-chunk metadata population, and the immediate signing pass. Together these steps form the deterministic transform from `{ plaintext, fileId, fileKey, baseNonce, chunkSize, serverPrivKey }` to a stream of `EncodedChunk` objects.
## Features
- `encryptChunks(options): Generator<EncodedChunk>` — yields one chunk per `chunkSize - HEADER_SIZE` payload window, signed and ready to persist.
- `deriveNonce(baseNonce, chunkId): Uint8Array` — XORs the chunk index into the high-order bytes of the 24-byte XChaCha20 nonce; rejects non-24-byte inputs with `CHUNKS.METADATA_INVALID`.
- `TAG_LENGTH` constant (16) for the AEAD tag size.
- Synchronous generator — no async overhead, allocations bounded per chunk.
## Usage: Basic Example
```ts
import { randomPrivateKey } from "@suaveplan/crypto-curve/ed25519";
import { encryptChunks } from "@suaveplan/drive-plugin-chunks";
const chunks = Array.from(
encryptChunks({
plaintext: new TextEncoder().encode("hello"),
fileId: new Uint8Array(32),
fileKey: new Uint8Array(32).fill(7),
baseNonce: new Uint8Array(24).fill(11),
chunkSize: 1024,
serverPrivKey: randomPrivateKey(),
})
);
```
## Usage: Advanced Example
```ts
import { deriveNonce, encryptChunks, TAG_LENGTH } from "@suaveplan/drive-plugin-chunks";
for (const chunk of encryptChunks({ plaintext, fileId, fileKey, baseNonce, chunkSize: 4096, serverPrivKey })) {
await storage.set(`chunks:${idHex}:${chunk.metadata.chunkId}`, chunk.rawBytes);
}
// Reconstruct the per-chunk nonce on the receiver
const nonce = deriveNonce(baseNonce, chunkIdReceived);
console.log(TAG_LENGTH); // 16
```
## API Reference
- `encryptChunks(options: EncryptOptions): Generator<EncodedChunk>` — `EncryptOptions` carries `plaintext`, `fileId`, `fileKey`, `baseNonce` (24 bytes), `chunkSize`, `serverPrivKey`. Yields `{ rawBytes, metadata }` per chunk.
- `deriveNonce(baseNonce: Uint8Array, chunkId: number): Uint8Array` — XORs `chunkId` into bytes 0–3.
- `TAG_LENGTH: 16` — AEAD tag size.
## Implementation Notes
`encryptChunks` writes the signature into the raw buffer at `[METADATA_SIZE, HEADER_SIZE)` immediately after computing it, so consumers can stream chunks through transports without ever holding a full file plaintext in memory. The XOR-based nonce derivation is sound for chunk indices ≤ 2³² because XChaCha20-Poly1305 takes a 24-byte nonce: under fixed `baseNonce` no two chunks of the same file ever share a nonce.

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES } from "../error-codes.js";
import { deriveNonce } from "./encrypt-stream.js";
describe("CHUNKS-ENCRYPT-01 — Nonce derivation", () => {
it("XORs chunkId into first 4 bytes of baseNonce", {
timeout: 5000,
}, () => {
const base = new Uint8Array(24).fill(0xab);
const nonce = deriveNonce(base, 0x12345678);
expect(nonce[0]).toBe(0xab ^ 0x12);
expect(nonce[1]).toBe(0xab ^ 0x34);
expect(nonce[2]).toBe(0xab ^ 0x56);
expect(nonce[3]).toBe(0xab ^ 0x78);
expect(nonce[4]).toBe(0xab);
});
it("throws for non-24-byte baseNonce", { timeout: 5000 }, () => {
expect(() => deriveNonce(new Uint8Array(12), 0)).toThrow();
try {
deriveNonce(new Uint8Array(12), 0);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.METADATA_INVALID);
}
});
});

View File

@@ -0,0 +1,85 @@
import { encrypt } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
import { encodeHeader, HEADER_SIZE, METADATA_SIZE } from "../codec/header.js";
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
import type {
Chunk,
ChunkMetadata,
EncodedChunk,
} from "../types/chunks.types.js";
import { signChunk } from "./sign-chunk.js";
export const TAG_LENGTH = 16;
export interface EncryptOptions {
readonly plaintext: Uint8Array;
readonly fileId: Uint8Array;
readonly fileKey: Uint8Array;
readonly baseNonce: Uint8Array;
readonly chunkSize: number;
readonly serverPrivKey: Uint8Array;
}
export function* encryptChunks(
options: EncryptOptions
): Generator<EncodedChunk> {
const { plaintext, fileId, fileKey, baseNonce, chunkSize, serverPrivKey } =
options;
const payloadBudget = chunkSize - HEADER_SIZE;
const fileSize = BigInt(plaintext.length);
const totalChunks = Math.ceil(plaintext.length / payloadBudget);
for (let chunkId = 0; chunkId < totalChunks; chunkId++) {
const offset = chunkId * payloadBudget;
const end = Math.min(offset + payloadBudget, plaintext.length);
const chunkPlaintext = plaintext.slice(offset, end);
const nonce = deriveNonce(baseNonce, chunkId);
const encrypted = encrypt(fileKey, nonce, chunkPlaintext);
const ciphertext = encrypted.slice(0, chunkPlaintext.length);
const aeadTag = encrypted.slice(chunkPlaintext.length);
const payloadLen = ciphertext.length;
const metadata: ChunkMetadata = {
version: 0x0001,
fileId,
chunkId,
totalChunks,
fileSize,
chunkSize,
payloadLen,
flags: 0x0000,
nonce: nonce.slice(),
aeadTag: aeadTag.slice(),
};
const chunk: Chunk = { metadata, sig: new Uint8Array(64), ciphertext };
const sig = signChunk(chunk, serverPrivKey);
const rawBytes = new Uint8Array(HEADER_SIZE + payloadLen);
rawBytes.set(encodeHeader(metadata), 0);
rawBytes.set(sig, METADATA_SIZE);
rawBytes.set(ciphertext, HEADER_SIZE);
yield { rawBytes, metadata };
}
}
export function deriveNonce(
baseNonce: Uint8Array,
chunkId: number
): Uint8Array {
if (baseNonce.length !== 24) {
throw new ChunksError(
CHUNKS_CODES.METADATA_INVALID,
`baseNonce must be 24 bytes, got ${baseNonce.length}`
);
}
const nonce: Uint8Array = baseNonce.slice();
nonce[0]! ^= (chunkId >>> 24) & 0xff;
nonce[1]! ^= (chunkId >>> 16) & 0xff;
nonce[2]! ^= (chunkId >>> 8) & 0xff;
nonce[3]! ^= chunkId & 0xff;
return nonce;
}

View File

@@ -0,0 +1,50 @@
# sign-chunk
## Purpose
Signs a chunk under the server's Ed25519 private key. Signatures cover the full encoded chunk (metadata header bytes plus the encrypted payload) so receivers can verify integrity without first decrypting. The output is fixed at 64 bytes, which is the slot reserved by `header.ts` (`SIG_SIZE = 64`).
## Features
- `signChunk(chunk, privKey): Uint8Array` — produces a deterministic Ed25519 signature over `metadata || ciphertext`.
- `buildSignedChunk(chunk, privKey): Chunk` — convenience wrapper that returns a chunk with the freshly-computed signature inlined.
- Pure function — no global state, no side effects, deterministic for fixed inputs (Ed25519 with seeded keys).
## Usage: Basic Example
```ts
import { randomPrivateKey } from "@suaveplan/crypto-curve/ed25519";
import { signChunk } from "@suaveplan/drive-plugin-chunks";
const privKey = randomPrivateKey();
const chunk = {
metadata: { /* ChunkMetadata */ } as never,
sig: new Uint8Array(64),
ciphertext: new Uint8Array([1, 2, 3]),
};
const sig = signChunk(chunk, privKey);
```
## Usage: Advanced Example
```ts
import {
buildSignedChunk,
parseChunk,
verifyChunkSig,
} from "@suaveplan/drive-plugin-chunks";
const signed = buildSignedChunk(parseChunk(rawChunk), serverPriv);
verifyChunkSig(/* raw bytes containing signed.sig */, serverPub);
```
## API Reference
- `signChunk(chunk: Chunk, privKey: Uint8Array): Uint8Array` — returns 64-byte signature. Concatenates `encodeHeader(metadata)` and `chunk.ciphertext` then signs the concatenation.
- `buildSignedChunk(chunk: Chunk, privKey: Uint8Array): Chunk` — same chunk with a populated `sig` field.
## Implementation Notes
The signature input deliberately excludes the existing `chunk.sig` field — re-signing a chunk produces the same signature for fixed inputs even though Ed25519 is not strictly deterministic across implementations (suaveplan pins to `@noble/curves` which is RFC-8032 deterministic). Verification is implemented in `verify-chunk.ts` using the matching `getPublicKey` derivation.
The signed payload is `encodeHeader(metadata) || ciphertext` rather than the full raw chunk because the signature occupies a fixed slot inside the raw bytes; including it in the signing input would be circular. This split also lets transports verify a chunk in two passes: parse the metadata to learn `payloadLen`, then sign-verify a contiguous range of the buffer without copying. Signing happens at ingest time and is the only place the `serverPrivKey` ever leaves the plugin's option object — verification only needs the public key.

View File

@@ -0,0 +1,94 @@
import {
getPublicKey,
randomPrivateKey,
} from "@suaveplan/crypto-curve/ed25519";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES } from "../error-codes.js";
import { buildSignedChunk, signChunk } from "./sign-chunk.js";
import { verifyChunkSig } from "./verify-chunk.js";
describe("CHUNKS-SIG-01 — Signature covers metadata plus ciphertext", () => {
it("signs metadata concatenated with ciphertext", { timeout: 5000 }, () => {
const privKey = randomPrivateKey();
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32).fill(0xab),
chunkId: 0,
totalChunks: 1,
fileSize: 50n,
chunkSize: 65536,
payloadLen: 50,
flags: 0x0000,
nonce: new Uint8Array(24),
aeadTag: new Uint8Array(16),
};
const ciphertext = new Uint8Array(50).fill(0xcd);
const chunk = { metadata, sig: new Uint8Array(64), ciphertext };
const sig = signChunk(chunk, privKey);
const expectedSig = signChunk(chunk, privKey);
expect(sig).toEqual(expectedSig);
});
it("rejects mutated metadata byte", { timeout: 5000 }, () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32),
chunkId: 0,
totalChunks: 1,
fileSize: 1n,
chunkSize: 65536,
payloadLen: 1,
flags: 0x0000,
nonce: new Uint8Array(24),
aeadTag: new Uint8Array(16),
};
const chunk = {
metadata,
sig: new Uint8Array(64),
ciphertext: new Uint8Array(1),
};
const encoded = buildSignedChunk(chunk, privKey);
encoded[0] ^= 0xff;
expect(() => verifyChunkSig(encoded, pubKey)).toThrow();
try {
verifyChunkSig(encoded, pubKey);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.SIG_INVALID);
}
});
it("rejects mutated ciphertext byte", { timeout: 5000 }, () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const metadata = {
version: 0x0001,
fileId: new Uint8Array(32),
chunkId: 0,
totalChunks: 1,
fileSize: 1n,
chunkSize: 65536,
payloadLen: 1,
flags: 0x0000,
nonce: new Uint8Array(24),
aeadTag: new Uint8Array(16),
};
const chunk = {
metadata,
sig: new Uint8Array(64),
ciphertext: new Uint8Array(1),
};
const encoded = buildSignedChunk(chunk, privKey);
encoded[164] ^= 0xff;
expect(() => verifyChunkSig(encoded, pubKey)).toThrow();
try {
verifyChunkSig(encoded, pubKey);
} catch (e) {
expect((e as Error).code).toBe(CHUNKS_CODES.SIG_INVALID);
}
});
});

View File

@@ -0,0 +1,26 @@
import { sign } from "@suaveplan/crypto-curve/ed25519";
import { encodeHeader, HEADER_SIZE, METADATA_SIZE } from "../codec/header.js";
import type { Chunk } from "../types/chunks.types.js";
export function signChunk(chunk: Chunk, serverPrivKey: Uint8Array): Uint8Array {
const metadata = encodeHeader(chunk.metadata);
const signedLen = METADATA_SIZE + chunk.ciphertext.byteLength;
const signed = new Uint8Array(signedLen);
signed.set(metadata, 0);
signed.set(chunk.ciphertext, METADATA_SIZE);
return sign(signed, serverPrivKey);
}
export function buildSignedChunk(
chunk: Chunk,
serverPrivKey: Uint8Array
): Uint8Array {
const metadata = encodeHeader(chunk.metadata);
const sig = signChunk(chunk, serverPrivKey);
const total = HEADER_SIZE + chunk.ciphertext.byteLength;
const buf = new Uint8Array(total);
buf.set(metadata, 0);
buf.set(sig, METADATA_SIZE);
buf.set(chunk.ciphertext, HEADER_SIZE);
return buf;
}

View File

@@ -0,0 +1,52 @@
# verify-chunk
## Purpose
Verifies a chunk's Ed25519 signature against the server's public key as the gate that runs on receipt of every chunk — before persistence, before decryption, before reassembly. A mismatched or corrupted signature surfaces as `ChunksError(CHUNKS.SIG_INVALID)` so callers can drop bad chunks without ever exposing them to the rest of the pipeline.
## Features
- `verifyChunkSig(raw, pubKey): void` — reads the embedded signature out of the raw bytes, recomputes the signed payload, and verifies in constant-time via `@noble/curves/ed25519`.
- Single-pass — runs over the original raw buffer without re-encoding.
- Throws on failure rather than returning `false`, matching the rest of the pipeline's error contract.
## Usage: Basic Example
```ts
import { verifyChunkSig } from "@suaveplan/drive-plugin-chunks";
verifyChunkSig(rawChunk, serverPubKey);
// throws ChunksError(CHUNKS.SIG_INVALID) on mismatch
```
## Usage: Advanced Example
```ts
import {
CHUNKS_CODES,
ChunksError,
parseChunk,
verifyChunkSig,
} from "@suaveplan/drive-plugin-chunks";
async function ingestPeerChunk(raw: Uint8Array) {
try {
verifyChunkSig(raw, expectedServerPub);
} catch (err) {
if (err instanceof ChunksError && err.code === CHUNKS_CODES.SIG_INVALID) {
return reportPeerCorruption(raw);
}
throw err;
}
const parsed = parseChunk(raw);
return persist(parsed);
}
```
## API Reference
- `verifyChunkSig(raw: Uint8Array, pubKey: Uint8Array): void` — validates that `raw` carries a correct signature for its embedded metadata + ciphertext under `pubKey`. Throws `ChunksError(CHUNKS.SIG_INVALID)` on any failure (including under-length buffers, since `parseHeader` runs first).
## Implementation Notes
The function rebuilds the signed payload as `metadata || ciphertext` rather than treating the raw bytes as already-signed because the signature occupies a fixed offset between metadata and ciphertext — re-encoding metadata after parsing keeps verification independent of any future header padding changes. suaveplan pins to `@noble/curves` for both signing and verification, which is constant-time and audited.

View File

@@ -0,0 +1,25 @@
import { verify } from "@suaveplan/crypto-curve/ed25519";
import { METADATA_SIZE } from "../codec/header.js";
import { CHUNKS_CODES, ChunksError } from "../error-codes.js";
export function verifyChunkSig(
rawChunk: Uint8Array,
serverPubKey: Uint8Array
): void {
const metadata = rawChunk.slice(0, METADATA_SIZE);
const sig = rawChunk.slice(METADATA_SIZE, METADATA_SIZE + 64);
const ciphertext = rawChunk.slice(METADATA_SIZE + 64);
const signedLen = METADATA_SIZE + ciphertext.byteLength;
const signed = new Uint8Array(signedLen);
signed.set(metadata, 0);
signed.set(ciphertext, METADATA_SIZE);
const valid = verify(sig, signed, serverPubKey);
if (!valid) {
throw new ChunksError(
CHUNKS_CODES.SIG_INVALID,
"chunk signature verification failed"
);
}
}

View File

@@ -0,0 +1,149 @@
import {
getPublicKey,
randomPrivateKey,
} from "@suaveplan/crypto-curve/ed25519";
import { Drive, type DriveStorage } from "@suaveplan/drive";
import { MemoryStorage } from "@suaveplan/storage/memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHUNKS_CODES, ChunksError } from "./error-codes.js";
import { chunksPlugin } from "./plugin.js";
function makeFailingStorage(failOn: (key: string) => boolean): DriveStorage {
const inner = new MemoryStorage();
return Object.assign(inner, {
id: "default",
set(key: string, value: Uint8Array): Promise<void> {
if (failOn(key)) {
throw new Error(`disk full for ${key}`);
}
return MemoryStorage.prototype.set.call(inner, key, value);
},
}) as unknown as DriveStorage;
}
function makeFailingStorageNonError(
failOn: (key: string) => boolean
): DriveStorage {
const inner = new MemoryStorage();
return Object.assign(inner, {
id: "default",
set(key: string, value: Uint8Array): Promise<void> {
if (failOn(key)) {
const literal: unknown = "literal-throwable";
throw literal;
}
return MemoryStorage.prototype.set.call(inner, key, value);
},
}) as unknown as DriveStorage;
}
describe("CHUNKS-INGEST-FAILED — non-ChunksError storage failures wrap as INGEST_FAILED", () => {
it("wraps a manifest-write Error into ChunksError(INGEST_FAILED)", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = makeFailingStorage((k) => k.startsWith("manifest:"));
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
})
);
await drive.write("test.txt", new TextEncoder().encode("hello"));
try {
await chunks.ingest("test.txt");
throw new Error("expected ingest to throw");
} catch (e) {
expect(e).toBeInstanceOf(ChunksError);
expect((e as ChunksError).code).toBe(CHUNKS_CODES.INGEST_FAILED);
expect((e as ChunksError).message).toContain("disk full");
}
await drive.dispose();
});
it("passes through a ChunksError thrown inside the ingest path without re-wrapping", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const inner = new MemoryStorage();
const chunksErrorStorage = Object.assign(inner, {
id: "default",
set(key: string, value: Uint8Array): Promise<void> {
if (key.startsWith("manifest:")) {
throw new ChunksError(
CHUNKS_CODES.SIG_INVALID,
"synthetic: bypass wrapping"
);
}
return MemoryStorage.prototype.set.call(inner, key, value);
},
}) as unknown as DriveStorage;
const drive = new Drive(chunksErrorStorage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
})
);
await drive.write("test.txt", new TextEncoder().encode("hello"));
try {
await chunks.ingest("test.txt");
throw new Error("expected ingest to throw");
} catch (e) {
expect(e).toBeInstanceOf(ChunksError);
expect((e as ChunksError).code).toBe(CHUNKS_CODES.SIG_INVALID);
expect((e as ChunksError).message).toContain(
"synthetic: bypass wrapping"
);
}
await drive.dispose();
});
it("wraps a non-Error throwable into ChunksError(INGEST_FAILED)", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = makeFailingStorageNonError((k) =>
k.startsWith("manifest:")
);
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
})
);
await drive.write("test.txt", new TextEncoder().encode("hello"));
try {
await chunks.ingest("test.txt");
throw new Error("expected ingest to throw");
} catch (e) {
expect(e).toBeInstanceOf(ChunksError);
expect((e as ChunksError).code).toBe(CHUNKS_CODES.INGEST_FAILED);
expect((e as ChunksError).message).toContain("literal-throwable");
}
await drive.dispose();
});
});

View File

@@ -0,0 +1,63 @@
# plugin
## Purpose
Implements the `@suaveplan/drive-plugin-chunks` plugin object that consumes the `Drive` plugin slot, splits files into signed encrypted chunks, persists them through a `DriveStorage`, and reassembles them on read. The plugin is the orchestration layer that wires together encryption, signing, manifest construction, layout selection, and the storage middleware that intercepts `drive.read` calls so consumers see whole files even though the bytes live in chunks.
## Features
- `chunksPlugin(options)` factory returning a `DrivePlugin<ChunksApi>` ready to be passed to `drive.usePlugin(...)`.
- `ingest(path)` reads a plaintext file from the drive, deterministically derives the file id (`sha256(plaintext)`), encrypts with XChaCha20-Poly1305 under a random per-file key and base nonce, signs each chunk with `serverPrivKey`, stores chunks via the chosen layout, and persists a server-signed manifest.
- Read middleware that, for any path with a known file id, reassembles plaintext on demand and short-circuits the storage read with the original bytes.
- Layout-aware behaviour: `sparse-file` preallocates a single offset-addressable file via `setPartial`; `chunk-keys` writes per-chunk keys; `auto` selects per storage capabilities.
- `getChunk(fileId, chunkId)`, `loadManifest(fileId)`, and `availableChunks(fileId)` for transport peers.
- Structured error codes for every failure path (signature, AEAD tag, manifest, layout, ingest).
## Usage: Basic Example
```ts
import { Drive } from "@suaveplan/drive";
import { chunksPlugin } from "@suaveplan/drive-plugin-chunks";
import { MemoryStorage } from "@suaveplan/storage/memory";
import { getPublicKey, randomPrivateKey } from "@suaveplan/crypto-curve/ed25519";
const privKey = randomPrivateKey();
const drive = new Drive(Object.assign(new MemoryStorage(), { id: "default" }));
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey: privKey, serverPubKey: getPublicKey(privKey) })
);
await drive.write("hello.txt", new TextEncoder().encode("world"));
const fileId = await chunks.ingest("hello.txt");
const back = await drive.read("hello.txt"); // reassembled plaintext
```
## Usage: Advanced Example
```ts
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey,
serverPubKey,
chunkSize: 64 * 1024,
layout: "sparse-file",
manifestSource: "manifest-as-chunk",
})
);
const fileId = await chunks.ingest("big.bin");
const raw = await chunks.getChunk(fileId, 0);
const manifest = await chunks.loadManifest(fileId);
```
## API Reference
- `chunksPlugin(options: ChunksPluginOptions): DrivePlugin<ChunksApi>` — factory.
- `ChunksApi.ingest(path: string): Promise<Uint8Array>` — returns `fileId`.
- `ChunksApi.getChunk(fileId: Uint8Array, chunkId: number): Promise<Uint8Array | undefined>`.
- `ChunksApi.loadManifest(fileId: Uint8Array): Promise<Manifest>`.
- `ChunksApi.availableChunks(fileId: Uint8Array): Promise<number[]>`.
- `ChunksApi.layout: ChunkLayoutStrategy`.
## Implementation Notes
`ingest` collects all encoded chunks into an array before persistence so the sparse-file layout can preallocate the underlying file before the first `setPartial` call — without preallocation, sparse-file storages refuse out-of-order writes. The catch block in `ingest` re-throws `ChunksError` instances unchanged and wraps everything else (`Error` or arbitrary throwable) as `CHUNKS.INGEST_FAILED` with a synthesised message; this keeps the public failure surface narrow without losing the underlying cause.

View File

@@ -0,0 +1,477 @@
import {
getPublicKey,
randomPrivateKey,
} from "@suaveplan/crypto-curve/ed25519";
import { Drive } from "@suaveplan/drive";
import { MemoryStorage } from "@suaveplan/storage/memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseChunk } from "./codec/chunk.js";
import { buildManifest } from "./manifest/build.js";
import { chunksPlugin } from "./plugin.js";
function createDriveStorage(id: string) {
return Object.assign(new MemoryStorage(), {
id,
}) as import("@suaveplan/drive").DriveStorage;
}
describe("CHUNKS-INVARIANT-01 — Store-as-is", () => {
it("putChunk persists raw bytes unchanged", { timeout: 5000 }, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
})
);
await drive.write("test.txt", new TextEncoder().encode("hello world"));
const fileId = await chunks.ingest("test.txt");
const raw = await chunks.getChunk(fileId, 0);
expect(raw).toBeDefined();
const parsed = parseChunk(raw!);
expect(parsed.metadata.fileId).toEqual(fileId);
await drive.dispose();
});
});
describe("CHUNKS-VERIFY-01 — Sig-verify gate on receipt", () => {
it("putChunk rejects invalid signature before persist", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const _pubKey = getPublicKey(privKey);
const wrongPubKey = getPublicKey(randomPrivateKey());
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: wrongPubKey,
chunkSize: 1024,
})
);
await drive.write("test.txt", new TextEncoder().encode("hello world"));
const fileId = await chunks.ingest("test.txt");
const raw = await chunks.getChunk(fileId, 0);
expect(raw).toBeDefined();
// Now try to put the same chunk with a different pubKey (wrong signer)
const chunks2 = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: wrongPubKey,
chunkSize: 1024,
})
);
await expect(chunks2.putChunk(raw!)).rejects.toThrow();
try {
await chunks2.putChunk(raw!);
} catch (e) {
expect((e as { code?: string }).code).toBe("CHUNKS.SIG_INVALID");
}
await drive.dispose();
});
});
describe("CHUNKS-READ-01 — Read middleware reassembles", () => {
it("read returns original plaintext after ingest", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
})
);
const plaintext = new TextEncoder().encode(
"hello world from chunked drive"
);
await drive.write("test.txt", plaintext);
await chunks.ingest("test.txt");
const result = await drive.read("test.txt");
expect(result).toEqual(plaintext);
await drive.dispose();
});
it("throws when chunk is missing during reassembly", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
// Use chunk-keys layout so we can delete individual chunks
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
layout: "chunk-keys",
})
);
const plaintext = new TextEncoder().encode("x".repeat(2000));
await drive.write("test.txt", plaintext);
const fileId = await chunks.ingest("test.txt");
// Delete chunk 1 to simulate corruption
await storage.delete(
`chunks/${Array.from(fileId, (b) => b.toString(16).padStart(2, "0")).join("")}/1`
);
await expect(drive.read("test.txt")).rejects.toThrow();
try {
await drive.read("test.txt");
} catch (e) {
expect((e as { code?: string }).code).toBe("CHUNKS.FILE_NOT_FOUND");
}
await drive.dispose();
});
});
describe("CHUNKS-PLUGIN-INIT-01 — Storage resolution", () => {
it("throws when drive has no getStorage", { timeout: 5000 }, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const fakeDrive = {
useMiddleware: () => {},
getStorage: () => {
throw new Error("no storage");
},
} as unknown as import("@suaveplan/drive").DriveLike;
const plugin = chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
});
await expect(plugin.install(fakeDrive)).rejects.toThrow();
try {
await plugin.install(fakeDrive);
} catch (e) {
expect((e as { code?: string }).code).toBe("CHUNKS.FILE_NOT_FOUND");
}
});
});
describe("CHUNKS-INGEST-01 — Edge cases", () => {
it("ingests empty file", { timeout: 5000 }, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
})
);
await drive.write("empty.txt", new Uint8Array(0));
const fileId = await chunks.ingest("empty.txt");
// Empty file produces zero chunks, but manifest should exist
const manifestRaw = await storage.get(
`manifest:${Array.from(fileId, (b) => b.toString(16).padStart(2, "0")).join("")}`
);
expect(manifestRaw).toBeDefined();
await drive.dispose();
});
});
describe("CHUNKS-SOURCE-01 — ChunkSource interface", () => {
it("hasChunks and availableChunks reflect stored chunks", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
layout: "chunk-keys",
})
);
const plaintext = new TextEncoder().encode(
"hello world from chunked drive"
);
await drive.write("test.txt", plaintext);
const fileId = await chunks.ingest("test.txt");
const has = await chunks.hasChunks(fileId);
expect(has[0]).toBe(true);
const available = Array.from(await chunks.availableChunks(fileId));
expect(available).toEqual([0]);
await drive.dispose();
});
it("putChunk stores a valid chunk", { timeout: 5000 }, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
layout: "chunk-keys",
})
);
const plaintext = new TextEncoder().encode(
"hello world from chunked drive"
);
await drive.write("test.txt", plaintext);
const fileId = await chunks.ingest("test.txt");
const raw = await chunks.getChunk(fileId, 0);
expect(raw).toBeDefined();
// Re-put the same chunk (valid signature)
await chunks.putChunk(raw!);
// Verify it was stored
const stored = await chunks.getChunk(fileId, 0);
expect(stored).toBeDefined();
expect(stored!).toEqual(raw!);
await drive.dispose();
});
it("exposes layout name", { timeout: 5000 }, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
layout: "chunk-keys",
})
);
expect(chunks.layout).toBe("chunk-keys");
await drive.dispose();
});
it("uses default chunkSize when not specified", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({ serverPrivKey: privKey, serverPubKey: pubKey })
);
const plaintext = new TextEncoder().encode("hello world");
await drive.write("default-size.txt", plaintext);
const fileId = await chunks.ingest("default-size.txt");
expect(fileId).toBeDefined();
await drive.dispose();
});
it("uses explicit sparse-file layout", { timeout: 5000 }, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
layout: "sparse-file",
})
);
expect(chunks.layout).toBe("sparse-file");
const plaintext = new TextEncoder().encode("hello world");
await drive.write("sparse.txt", plaintext);
const fileId = await chunks.ingest("sparse.txt");
expect(fileId).toBeDefined();
await drive.dispose();
});
it("throws when drive.getStorage is missing", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const fakeDrive = {
useMiddleware: () => {},
} as unknown as import("@suaveplan/drive").DriveLike;
const plugin = chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
});
await expect(plugin.install(fakeDrive)).rejects.toThrow();
try {
await plugin.install(fakeDrive);
} catch (e) {
expect((e as { code?: string }).code).toBe("CHUNKS.FILE_NOT_FOUND");
}
});
});
describe("CHUNKS-MANIFEST-LOAD-01 — Manifest loading", () => {
it("throws when manifest is missing", { timeout: 5000 }, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
})
);
const plaintext = new TextEncoder().encode("hello world");
await drive.write("test.txt", plaintext);
const fileId = await chunks.ingest("test.txt");
// Delete the manifest
await storage.delete(
`manifest:${Array.from(fileId, (b) => b.toString(16).padStart(2, "0")).join("")}`
);
await expect(drive.read("test.txt")).rejects.toThrow();
try {
await drive.read("test.txt");
} catch (e) {
expect((e as { code?: string }).code).toBe(
"CHUNKS.MANIFEST_NOT_FOUND"
);
}
await drive.dispose();
});
it("loads manifest from server when configured", {
timeout: 5000,
}, async () => {
const privKey = randomPrivateKey();
const pubKey = getPublicKey(privKey);
const storage = createDriveStorage("default");
const drive = new Drive(storage);
await drive.initialize();
const manifest = buildManifest({
fileId: new Uint8Array(32),
fileSize: 1n,
chunkSize: 65536,
totalChunks: 1,
encryption: {
algo: "xchacha20poly1305",
fileKeyWrapped: new Uint8Array(32),
baseNonce: new Uint8Array(24),
},
chunks: [{ chunkId: 0, hash: new Uint8Array(32), payloadLen: 1 }],
serverPrivKey: privKey,
});
const raw = new TextEncoder().encode(
JSON.stringify(manifest, (_k, v) => {
if (v instanceof Uint8Array)
return `base64:${btoa(String.fromCharCode(...v))}`;
if (typeof v === "bigint") return v.toString();
return v;
})
);
const originalFetch = globalThis.fetch;
// @ts-expect-error - mocking global fetch
globalThis.fetch = async () => ({
ok: true,
arrayBuffer: async () => raw.buffer,
});
try {
const chunks = await drive.usePlugin(
chunksPlugin({
serverPrivKey: privKey,
serverPubKey: pubKey,
chunkSize: 1024,
manifestSource: "server-only",
manifestServerUrl: "http://test-server",
})
);
const plaintext = new TextEncoder().encode("hello world");
await drive.write("server-test.txt", plaintext);
const fileId = await chunks.ingest("server-test.txt");
// Remove local manifest so server path is taken
await storage.delete(
`manifest:${Array.from(fileId, (b) => b.toString(16).padStart(2, "0")).join("")}`
);
// loadManifest should call the server
const loaded = await chunks.loadManifest(fileId);
expect(loaded.version).toBe(0x0001);
} finally {
globalThis.fetch = originalFetch;
}
await drive.dispose();
});
});

View File

@@ -0,0 +1,293 @@
import { sha256 } from "@suaveplan/crypto-hash";
import { randomBytes } from "@suaveplan/crypto-utils";
import type { DriveLike, DrivePlugin, DriveStorage } from "@suaveplan/drive";
import { parseChunk, validateChunk } from "./codec/chunk.js";
import { CHUNKS_CODES, ChunksError } from "./error-codes.js";
import { createAutoLayout } from "./layout/auto.js";
import { createChunkKeysLayout } from "./layout/chunk-keys.js";
import { createSparseFileLayout, preallocate } from "./layout/sparse-file.js";
import { buildManifest } from "./manifest/build.js";
import {
loadManifestAsChunk,
loadManifestFromServer,
} from "./manifest/load.js";
import { decryptChunk } from "./pipeline/decrypt-chunk.js";
import { encryptChunks } from "./pipeline/encrypt-stream.js";
import { verifyChunkSig } from "./pipeline/verify-chunk.js";
import type {
ChunkLayout,
ChunkLayoutStrategy,
ChunkSource,
ChunksPluginOptions,
Manifest,
} from "./types/chunks.types.js";
export function chunksPlugin(
options: ChunksPluginOptions
): DrivePlugin<ChunkSource> {
return {
name: "@suaveplan/drive-plugin-chunks",
async install(drive: DriveLike) {
return new ChunksPluginHandle(drive, options);
},
};
}
class ChunksPluginHandle implements ChunkSource {
readonly #drive: DriveLike;
readonly #options: ChunksPluginOptions;
readonly #layout: ChunkLayout;
readonly #pathToFileId = new Map<string, Uint8Array>();
readonly #fileKeyWrapped = new Map<string, Uint8Array>();
constructor(drive: DriveLike, options: ChunksPluginOptions) {
this.#drive = drive;
this.#options = options;
const storage = this.#resolveStorage();
this.#layout =
options.layout === "sparse-file"
? createSparseFileLayout(storage)
: options.layout === "chunk-keys"
? createChunkKeysLayout(storage)
: createAutoLayout(storage);
drive.useMiddleware({
read: async (
ctx: { path: string },
next: () => Promise<Uint8Array>
) => {
const fileId = this.#pathToFileId.get(ctx.path);
if (!fileId) return next();
const stream = await this.#reassemble(fileId);
return streamToBytes(stream);
},
});
}
get layout(): ChunkLayoutStrategy {
return this.#layout.name;
}
async ingest(path: string): Promise<Uint8Array> {
try {
const storage = this.#resolveStorage();
const plaintext = await this.#drive.read(path);
const fileId = sha256(plaintext);
const fileKey = randomBytes(32);
const baseNonce = randomBytes(24);
const chunkSize = this.#options.chunkSize ?? 65536;
// Collect all encoded chunks first so we can preallocate sparse-file layout
const encodedChunks = Array.from(
encryptChunks({
plaintext,
fileId,
fileKey,
baseNonce,
chunkSize,
serverPrivKey: this.#options.serverPrivKey,
})
);
const chunks: {
chunkId: number;
hash: Uint8Array;
payloadLen: number;
}[] = [];
for (const encoded of encodedChunks) {
chunks.push({
chunkId: encoded.metadata.chunkId,
hash: sha256(encoded.rawBytes),
payloadLen: encoded.metadata.payloadLen,
});
}
// Preallocate sparse-file layout BEFORE storing chunks
if (this.#layout.name === "sparse-file" && chunks.length > 0) {
const lastPayloadLen = chunks[chunks.length - 1]!.payloadLen;
await preallocate(
storage,
fileId,
chunks.length,
chunkSize,
lastPayloadLen
);
}
for (const encoded of encodedChunks) {
await this.#layout.putChunk(
fileId,
encoded.metadata.chunkId,
encoded.rawBytes
);
}
const manifest = buildManifest({
fileId,
fileSize: BigInt(plaintext.length),
chunkSize,
totalChunks: chunks.length,
encryption: {
algo: "xchacha20poly1305",
fileKeyWrapped: fileKey,
baseNonce,
},
chunks,
serverPrivKey: this.#options.serverPrivKey,
});
const manifestRaw = new TextEncoder().encode(
JSON.stringify(manifest, (_k, v) => {
if (v instanceof Uint8Array) {
return `base64:${btoa(String.fromCharCode(...v))}`;
}
if (typeof v === "bigint") {
return v.toString();
}
return v;
})
);
await storage.set(`manifest:${hex(fileId)}`, manifestRaw);
this.#pathToFileId.set(path, fileId);
return fileId;
} catch (err) {
if (err instanceof ChunksError) {
throw err;
}
throw new ChunksError(
CHUNKS_CODES.INGEST_FAILED,
`ingest failed for ${path}: ${err instanceof Error ? err.message : String(err)}`
);
}
}
async loadManifest(fileId: Uint8Array): Promise<Manifest> {
if (
this.#options.manifestSource === "server-only" &&
this.#options.manifestServerUrl
) {
return loadManifestFromServer(
fileId,
this.#options.manifestServerUrl,
globalThis.fetch,
this.#options.serverPubKey
);
}
const storage = this.#resolveStorage();
const raw = await storage.get(`manifest:${hex(fileId)}`);
if (!raw) {
throw new ChunksError(
CHUNKS_CODES.MANIFEST_NOT_FOUND,
`manifest not found for fileId ${hex(fileId)}`
);
}
return loadManifestAsChunk(
fileId,
async () => raw,
this.#options.serverPubKey
);
}
async getChunk(
fileId: Uint8Array,
chunkId: number
): Promise<Uint8Array | undefined> {
return this.#layout.getChunk(fileId, chunkId);
}
async putChunk(rawBytes: Uint8Array): Promise<void> {
const chunk = parseChunk(rawBytes);
validateChunk(chunk);
verifyChunkSig(rawBytes, this.#options.serverPubKey);
await this.#layout.putChunk(
chunk.metadata.fileId,
chunk.metadata.chunkId,
rawBytes
);
}
async hasChunks(fileId: Uint8Array): Promise<boolean[]> {
return this.#layout.hasChunks(fileId);
}
async availableChunks(fileId: Uint8Array): Promise<Iterable<number>> {
return this.#layout.availableChunks(fileId);
}
async #reassemble(fileId: Uint8Array): Promise<ReadableStream<Uint8Array>> {
const manifest = await this.loadManifest(fileId);
const fileKey = manifest.encryption.fileKeyWrapped;
const serverPubKey = this.#options.serverPubKey;
const layout = this.#layout;
return new ReadableStream({
async pull(controller) {
for (let i = 0; i < manifest.totalChunks; i++) {
const raw = await layout.getChunk(fileId, i);
if (!raw) {
controller.error(
new ChunksError(
CHUNKS_CODES.FILE_NOT_FOUND,
`chunk ${i} not found for fileId ${hex(fileId)}`
)
);
return;
}
const parsed = parseChunk(raw);
verifyChunkSig(raw, serverPubKey);
const plaintext = decryptChunk(
parsed.metadata,
parsed.ciphertext,
fileKey
);
controller.enqueue(
plaintext.slice(0, parsed.metadata.payloadLen)
);
}
controller.close();
},
});
}
#resolveStorage(): DriveStorage {
const drive = this.#drive as unknown as {
getStorage?: (id: string) => DriveStorage;
};
if (drive.getStorage) {
try {
return drive.getStorage("default");
} catch {
// fall through
}
}
throw new ChunksError(
CHUNKS_CODES.FILE_NOT_FOUND,
"no default storage available for chunk operations"
);
}
}
async function streamToBytes(
stream: ReadableStream<Uint8Array>
): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const c of chunks) {
result.set(c, offset);
offset += c.length;
}
return result;
}
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

View File

@@ -0,0 +1,61 @@
# manifest.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod schemas that gate every manifest the plugin parses from untrusted bytes. Schema validation is the line between "raw bytes that may or may not be a manifest" and "a typed `Manifest` ready to flow through the rest of the plugin"; it runs before signature verification so structural errors surface as `MANIFEST_INVALID` without paying the cost of an Ed25519 verify.
## Features
- `ManifestSchema` — the top-level schema for a complete signed manifest, including a 64-byte signature, version literal `0x0001`, 32-byte `fileId`, positive `chunkSize` / `totalChunks`, and an array of per-chunk entries.
- `ManifestEncryptionSchema` — the embedded encryption envelope: `algo` is locked to `"xchacha20poly1305"`, `baseNonce` is exactly 24 bytes, and `fileKeyWrapped` is left length-flexible to leave room for future KEMs.
- `ManifestChunkEntrySchema` — per-chunk entries with non-negative `chunkId` / `payloadLen` and a 32-byte SHA-256 `hash`.
- `fileSize` accepts both `bigint` and decimal-string inputs and normalises to `bigint`, which makes JSON-serialised manifests round-trip cleanly even though native JSON has no `bigint` type.
## Usage: Basic Example
```ts
import { ManifestSchema } from "@suaveplan/drive-plugin-chunks";
const result = ManifestSchema.safeParse(decodedFromJson);
if (!result.success) throw new Error(result.error.message);
const manifest = result.data;
```
## Usage: Advanced Example
```ts
import {
ManifestChunkEntrySchema,
ManifestEncryptionSchema,
ManifestSchema,
} from "@suaveplan/drive-plugin-chunks";
function isLooselyValid(maybe: unknown): boolean {
return ManifestSchema.safeParse(maybe).success;
}
const enc = ManifestEncryptionSchema.parse({
algo: "xchacha20poly1305",
fileKeyWrapped: new Uint8Array(32),
baseNonce: new Uint8Array(24),
});
const entry = ManifestChunkEntrySchema.parse({
chunkId: 0,
hash: new Uint8Array(32),
payloadLen: 50,
});
```
## API Reference
- `ManifestSchema: ZodTypeAny` — top-level boundary schema.
- `ManifestEncryptionSchema: ZodTypeAny` — encryption sub-schema.
- `ManifestChunkEntrySchema: ZodTypeAny` — chunk-entry sub-schema.
- `type ManifestInput = z.input<typeof ManifestSchema>` — the pre-transform JSON shape.
## Implementation Notes
The `// schema: boundary-only` annotation tells the conformance script that these schemas are deliberately part of the public surface even when this file is the only place inside `src/` that calls `.parse` / `.safeParse` on them. Removing the annotation (or moving the schemas) would be an enterprise-grade signal change so consumers should not depend on internal validation paths.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ManifestSchema } from "./manifest.schema.js";
describe("CHUNKS-SCHEMA-01 — Manifest schema validation", () => {
it("accepts bigint fileSize directly", { timeout: 5000 }, () => {
const result = ManifestSchema.safeParse({
version: 0x0001,
fileId: new Uint8Array(32),
fileSize: 12345n,
chunkSize: 65536,
totalChunks: 1,
encryption: {
algo: "xchacha20poly1305",
fileKeyWrapped: new Uint8Array(32),
baseNonce: new Uint8Array(24),
},
chunks: [{ chunkId: 0, hash: new Uint8Array(32), payloadLen: 1 }],
sig: new Uint8Array(64),
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.fileSize).toBe(12345n);
}
});
it("accepts string fileSize and transforms to bigint", {
timeout: 5000,
}, () => {
const result = ManifestSchema.safeParse({
version: 0x0001,
fileId: new Uint8Array(32),
fileSize: "12345",
chunkSize: 65536,
totalChunks: 1,
encryption: {
algo: "xchacha20poly1305",
fileKeyWrapped: new Uint8Array(32),
baseNonce: new Uint8Array(24),
},
chunks: [{ chunkId: 0, hash: new Uint8Array(32), payloadLen: 1 }],
sig: new Uint8Array(64),
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.fileSize).toBe(12345n);
}
});
});

View File

@@ -0,0 +1,38 @@
import z from "zod";
// schema: boundary-only
export const ManifestEncryptionSchema = z.object({
algo: z.literal("xchacha20poly1305"),
fileKeyWrapped: z.instanceof(Uint8Array),
baseNonce: z
.instanceof(Uint8Array)
.refine((v) => v.length === 24, "baseNonce must be 24 bytes"),
});
export const ManifestChunkEntrySchema = z.object({
chunkId: z.number().int().nonnegative(),
hash: z
.instanceof(Uint8Array)
.refine((v) => v.length === 32, "hash must be 32 bytes"),
payloadLen: z.number().int().nonnegative(),
});
export const ManifestSchema = z.object({
version: z.literal(0x0001),
fileId: z
.instanceof(Uint8Array)
.refine((v) => v.length === 32, "fileId must be 32 bytes"),
fileSize: z.union([z.bigint().nonnegative(), z.string()]).transform((v) => {
if (typeof v === "string") return BigInt(v);
return v;
}),
chunkSize: z.number().int().positive(),
totalChunks: z.number().int().positive(),
encryption: ManifestEncryptionSchema,
chunks: z.array(ManifestChunkEntrySchema),
sig: z
.instanceof(Uint8Array)
.refine((v) => v.length === 64, "sig must be 64 bytes"),
});
export type ManifestInput = z.input<typeof ManifestSchema>;

View File

@@ -0,0 +1,60 @@
# chunks.types
## Purpose
Holds the cross-module type vocabulary for the chunks plugin: chunk metadata and chunk shapes, manifest entries and the manifest itself, the `ChunkLayout` interface that pluggable storage layouts implement, the `ChunkSource` interface that mesh / transport peers expose, and the plugin options object. Centralising the types here avoids `any` leaks across module boundaries and lets the rest of the codebase share a single language for chunk-shaped data.
## Features
- `ChunkMetadata` — every field of the on-wire metadata header in a single TypeScript shape.
- `Chunk` / `EncodedChunk` — parsed chunk with separate `sig` and `ciphertext` fields, plus the encoded bytes ready for storage.
- `Manifest` / `ManifestChunkEntry` / `ManifestEncryption` — manifest tree mirroring `ManifestSchema`.
- `ChunkLayout` — minimal interface: `name`, `putChunk`, `getChunk`, `hasChunks`, `availableChunks`.
- `ChunkSource` — peer-facing interface (`hasChunks`, `availableChunks`).
- `ChunksPluginOptions` — frozen options object with required `serverPrivKey` / `serverPubKey` and optional `chunkSize` / `layout` / `manifestSource` / `manifestServerUrl`.
- `ChunkLayoutStrategy = "sparse-file" | "chunk-keys" | "auto"`.
- `ManifestSource = "manifest-as-chunk" | "server-only"`.
## Usage: Basic Example
```ts
import type {
ChunkLayout,
ChunkMetadata,
Manifest,
} from "@suaveplan/drive-plugin-chunks";
declare const layout: ChunkLayout;
declare const metadata: ChunkMetadata;
declare const manifest: Manifest;
```
## Usage: Advanced Example
```ts
import type {
ChunksPluginOptions,
ChunkSource,
} from "@suaveplan/drive-plugin-chunks";
function buildOptions(serverPrivKey: Uint8Array, serverPubKey: Uint8Array): ChunksPluginOptions {
return { serverPrivKey, serverPubKey, chunkSize: 65536, layout: "auto" };
}
function describeAvailability(src: ChunkSource, fileId: Uint8Array) {
return src.availableChunks(fileId);
}
```
## API Reference
- `ChunkMetadata` — `{ version, fileId, chunkId, totalChunks, fileSize, chunkSize, payloadLen, flags, nonce, aeadTag }`.
- `Chunk` — `{ metadata, sig, ciphertext }`.
- `EncodedChunk` — `{ rawBytes, metadata }`.
- `Manifest`, `ManifestChunkEntry`, `ManifestEncryption` — see schemas.
- `ChunkLayout`, `ChunkSource`, `ChunksPluginOptions` — interfaces consumed by the plugin and its layouts.
- `ChunkLayoutStrategy`, `ManifestSource` — string-literal unions.
## Implementation Notes
`ChunkMetadata.fileSize` is `bigint` because chunk arithmetic on files larger than 2³² bytes requires 64-bit math; consumers serialising to JSON must convert with `String(value)` and re-parse via `BigInt` (the `ManifestSchema` accepts both forms). Every `Uint8Array` field is treated as opaque bytes — none of the types pin a specific endianness because that responsibility belongs to `header.encodeHeader` / `parseHeader`.

View File

@@ -0,0 +1,91 @@
import type { Clock, Rng } from "@suaveplan/types";
export type ChunkLayoutStrategy = "sparse-file" | "chunk-keys" | "auto";
export type ManifestSource = "manifest-as-chunk" | "server-only";
export interface ChunkMetadata {
readonly version: number;
readonly fileId: Uint8Array;
readonly chunkId: number;
readonly totalChunks: number;
readonly fileSize: bigint;
readonly chunkSize: number;
readonly payloadLen: number;
readonly flags: number;
readonly nonce: Uint8Array;
readonly aeadTag: Uint8Array;
}
export interface Chunk {
readonly metadata: ChunkMetadata;
readonly sig: Uint8Array;
readonly ciphertext: Uint8Array;
}
export interface ManifestEncryption {
readonly algo: "xchacha20poly1305";
readonly fileKeyWrapped: Uint8Array;
readonly baseNonce: Uint8Array;
}
export interface ManifestChunkEntry {
readonly chunkId: number;
readonly hash: Uint8Array;
readonly payloadLen: number;
}
export interface Manifest {
readonly version: number;
readonly fileId: Uint8Array;
readonly fileSize: bigint;
readonly chunkSize: number;
readonly totalChunks: number;
readonly encryption: ManifestEncryption;
readonly chunks: ManifestChunkEntry[];
readonly sig: Uint8Array;
}
export interface EncodedChunk {
readonly rawBytes: Uint8Array;
readonly metadata: ChunkMetadata;
}
export interface ChunkSource {
getChunk(
fileId: Uint8Array,
chunkId: number
): Promise<Uint8Array | undefined>;
putChunk(rawBytes: Uint8Array): Promise<void>;
hasChunks(fileId: Uint8Array): Promise<boolean[]>;
availableChunks(fileId: Uint8Array): Promise<Iterable<number>>;
ingest(path: string): Promise<Uint8Array>;
loadManifest(fileId: Uint8Array): Promise<Manifest>;
readonly layout: ChunkLayoutStrategy;
}
export interface ChunksPluginOptions {
readonly serverPrivKey: Uint8Array;
readonly serverPubKey: Uint8Array;
readonly chunkSize?: number;
readonly layout?: ChunkLayoutStrategy;
readonly manifestSource?: ManifestSource;
readonly manifestServerUrl?: string;
readonly clock?: Clock;
readonly rng?: Rng;
}
export interface ChunkLayout {
readonly name: ChunkLayoutStrategy;
putChunk(
fileId: Uint8Array,
chunkId: number,
rawBytes: Uint8Array
): Promise<void>;
getChunk(
fileId: Uint8Array,
chunkId: number
): Promise<Uint8Array | undefined>;
hasChunks(fileId: Uint8Array): Promise<boolean[]>;
availableChunks(fileId: Uint8Array): Promise<Iterable<number>>;
}

View File

@@ -0,0 +1,12 @@
export type {
Chunk,
ChunkLayout,
ChunkLayoutStrategy,
ChunkMetadata,
ChunkSource,
ChunksPluginOptions,
Manifest,
ManifestChunkEntry,
ManifestEncryption,
ManifestSource,
} from "./chunks.types.js";

View File

@@ -0,0 +1,206 @@
/**
* DTP-25 — `FileLocation.storageHints` `kind: 'inline'` carries a sig
* over `fileId ‖ bytes`. Verified by the three required spec test
* names plus tamper-detection cases.
*/
import {
getPublicKey,
randomPrivateKey,
} from "@suaveplan/crypto-curve/ed25519";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CHUNKS_CODES,
ChunksError,
inlineSigningInput,
isLegacyInlineHint,
type LegacyInlineHint,
signInlineHint,
upgradeLegacyInlineHint,
verifyInlineHint,
} from "../src/index.js";
function makeFileId(seed: number): Uint8Array {
const id = new Uint8Array(32);
for (let i = 0; i < 32; i++) id[i] = (seed + i) & 0xff;
return id;
}
describe("DTP-25 — inline-file sig", () => {
it("inline file with valid sig reads successfully", {
timeout: 5000,
}, () => {
const sk = randomPrivateKey();
const pk = getPublicKey(sk);
const fileId = makeFileId(0x10);
const bytes = new TextEncoder().encode("hello inline world");
const hint = signInlineHint(fileId, bytes, sk);
const out = verifyInlineHint(hint, fileId, pk);
expect(out).toEqual(bytes);
expect(hint.kind).toBe("inline");
expect(hint.sig).toBeInstanceOf(Uint8Array);
expect(hint.sig.byteLength).toBe(64);
});
it("inline file with bad sig throws INLINE_SIG_INVALID", {
timeout: 5000,
}, () => {
const sk = randomPrivateKey();
const pk = getPublicKey(sk);
const fileId = makeFileId(0x20);
const bytes = new TextEncoder().encode("payload");
const hint = signInlineHint(fileId, bytes, sk);
// Flip a bit in the signature.
const tamperedSig = new Uint8Array(hint.sig);
tamperedSig[0] ^= 0x01;
const tampered = { ...hint, sig: tamperedSig };
try {
verifyInlineHint(tampered, fileId, pk);
throw new Error("expected INLINE_SIG_INVALID");
} catch (err) {
expect(err).toBeInstanceOf(ChunksError);
expect((err as ChunksError).code).toBe(
CHUNKS_CODES.INLINE_SIG_INVALID
);
}
});
it("old-format inline reads with warning AND rewrites with sig on next write", {
timeout: 5000,
}, () => {
const sk = randomPrivateKey();
const pk = getPublicKey(sk);
const fileId = makeFileId(0x30);
const bytes = new TextEncoder().encode("legacy bytes");
const legacy: LegacyInlineHint = { kind: "inline", bytes };
// Legacy detection
expect(isLegacyInlineHint(legacy)).toBe(true);
// Simulate the warning channel a real drive would emit on read
let warningCount = 0;
const onWarning = () => {
warningCount++;
};
if (isLegacyInlineHint(legacy)) {
onWarning();
}
expect(warningCount).toBe(1);
// Rewrite-with-sig on next write
const upgraded = upgradeLegacyInlineHint(legacy, fileId, sk);
expect(isLegacyInlineHint(upgraded)).toBe(false);
expect(upgraded.bytes).toEqual(bytes);
expect(upgraded.sig.byteLength).toBe(64);
// Upgraded hint verifies cleanly under the same key
const verified = verifyInlineHint(upgraded, fileId, pk);
expect(verified).toEqual(bytes);
});
it("sig bound to fileId — replay onto a different fileId rejects", {
timeout: 5000,
}, () => {
const sk = randomPrivateKey();
const pk = getPublicKey(sk);
const fileIdA = makeFileId(0x40);
const fileIdB = makeFileId(0x41);
const bytes = new TextEncoder().encode("same bytes, different file");
const hint = signInlineHint(fileIdA, bytes, sk);
try {
verifyInlineHint(hint, fileIdB, pk);
throw new Error("expected INLINE_SIG_INVALID");
} catch (err) {
expect((err as ChunksError).code).toBe(
CHUNKS_CODES.INLINE_SIG_INVALID
);
}
});
it("byte tamper rejects with INLINE_SIG_INVALID", { timeout: 5000 }, () => {
const sk = randomPrivateKey();
const pk = getPublicKey(sk);
const fileId = makeFileId(0x50);
const bytes = new TextEncoder().encode("original");
const hint = signInlineHint(fileId, bytes, sk);
const tamperedBytes = new TextEncoder().encode("modified");
const tampered = { ...hint, bytes: tamperedBytes };
try {
verifyInlineHint(tampered, fileId, pk);
throw new Error("expected INLINE_SIG_INVALID");
} catch (err) {
expect((err as ChunksError).code).toBe(
CHUNKS_CODES.INLINE_SIG_INVALID
);
}
});
it("rejects under a different public key", { timeout: 5000 }, () => {
const skA = randomPrivateKey();
const skB = randomPrivateKey();
const pkB = getPublicKey(skB);
const fileId = makeFileId(0x60);
const bytes = new TextEncoder().encode("signed by A");
const hint = signInlineHint(fileId, bytes, skA);
expect(() => verifyInlineHint(hint, fileId, pkB)).toThrow(ChunksError);
});
it("inlineSigningInput is the canonical fileId ‖ bytes concatenation", {
timeout: 5000,
}, () => {
const fileId = new Uint8Array([1, 2, 3, 4]);
const bytes = new Uint8Array([0xaa, 0xbb, 0xcc]);
const input = inlineSigningInput(fileId, bytes);
expect(input.byteLength).toBe(7);
expect(Array.from(input)).toEqual([1, 2, 3, 4, 0xaa, 0xbb, 0xcc]);
});
it("verify catch arm wraps a throwing verifier", { timeout: 5000 }, () => {
const sk = randomPrivateKey();
const pk = getPublicKey(sk);
const fileId = makeFileId(0x70);
const bytes = new TextEncoder().encode("ok");
const hint = signInlineHint(fileId, bytes, sk);
const throwingVerify = () => {
throw new Error("native verifier exploded");
};
try {
verifyInlineHint(hint, fileId, pk, throwingVerify);
throw new Error("expected INLINE_SIG_INVALID");
} catch (err) {
expect(err).toBeInstanceOf(ChunksError);
const e = err as ChunksError;
expect(e.code).toBe(CHUNKS_CODES.INLINE_SIG_INVALID);
expect(e.message).toContain("raised");
expect(e.cause).toBeInstanceOf(Error);
expect((e.cause as Error).message).toBe("native verifier exploded");
}
});
it("isLegacyInlineHint distinguishes signed from legacy hints", {
timeout: 5000,
}, () => {
const sk = randomPrivateKey();
const fileId = makeFileId(0x80);
const bytes = new TextEncoder().encode("data");
const signed = signInlineHint(fileId, bytes, sk);
const legacy: LegacyInlineHint = { kind: "inline", bytes };
expect(isLegacyInlineHint(signed)).toBe(false);
expect(isLegacyInlineHint(legacy)).toBe(true);
// Object literally has sig: undefined — also treated as legacy.
const ambiguous = { kind: "inline" as const, bytes, sig: undefined };
expect(
isLegacyInlineHint(ambiguous as unknown as LegacyInlineHint)
).toBe(true);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "src/__benchmarks__/**", "dist", "node_modules"]
}

View File

@@ -0,0 +1,44 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/drive",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/crypto",
"@suaveplan/crypto-cipher",
"@suaveplan/crypto-curve",
"@suaveplan/drive",
"@suaveplan/error",
"@suaveplan/packer",
"@suaveplan/storage",
"@suaveplan/streams",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();