471 lines
21 KiB
Markdown
471 lines
21 KiB
Markdown
# Dehydrated Drives — Templates, Policies, and CDN Fallback
|
|
|
|
A **dehydrated drive** is a drive whose folder structure and file metadata are pre-configured by a server-signed template, while the actual file bytes arrive later from peers, the CDN, or local writes. The user sees the full directory tree on day one — files have correct names, sizes, and lock states — and chunks populate transparently as they're requested.
|
|
|
|
This is the answer to "ship a 2 GB game asset bundle to N players" and "subscribe a user to a curated drive without copying everything up front." It is the resolution of [`system-review.md`](../system-review.md) §3, §4, §5, §8, §9, §12, §13, §16. The spec belongs in `openspec/specs/drive-template/spec.md` once the proposal lands.
|
|
|
|
---
|
|
|
|
## 1. The lifecycle
|
|
|
|
```text
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ Server │
|
|
│ - Curates the drive: which files, which folders, which chunks │
|
|
│ - Computes hashes, sizes, chunk counts │
|
|
│ - Signs DriveTemplate with server Ed25519 key │
|
|
│ - Publishes template (via API, share link, or peer broadcast) │
|
|
└──────────────────────────┬───────────────────────────────────────┘
|
|
│ signed DriveTemplate (~10-100 KB)
|
|
▼
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ Client │
|
|
│ drive.hydrateFrom(template) │
|
|
│ ├─ verify server signature │
|
|
│ ├─ instantiate DirIndex from FolderTemplate tree │
|
|
│ ├─ instantiate FileLocationIndex from FileTemplate stubs │
|
|
│ │ (every entry's `storageHints` starts with kind:'mesh' and │
|
|
│ │ optionally kind:'cdn') │
|
|
│ ├─ unwrap inline keys → KeyRing (lock icon flips per file) │
|
|
│ ├─ for each file with policy.autoDownload === true: │
|
|
│ │ schedule a fetch via mesh-protocol (see mesh-protocol.md) │
|
|
│ │ on miss, fall through to CDN │
|
|
│ └─ emit `drive:hydrated` event │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
After hydration, the drive looks identical to one that was populated by writes. The user calls `read("/photo.jpg")` the same way; the read pipeline transparently fetches missing chunks. The dehydrated state is invisible at the API surface — only the `presence` field on DirEntries hints that bytes haven't arrived.
|
|
|
|
---
|
|
|
|
## 2. The `DriveTemplate` shape
|
|
|
|
```ts
|
|
interface DriveTemplate {
|
|
schemaVersion: 1; // pinned; future versions are new packages
|
|
driveId: string; // unique drive identity (32-byte hex)
|
|
rootFolder: FolderTemplate;
|
|
cdnFallback?: CdnFallbackConfig;
|
|
policy: GlobalPolicy;
|
|
issuer: string; // server pubkey (Ed25519, 32-byte hex)
|
|
issuedAt: number; // unix ms
|
|
expiresAt: number; // unix ms
|
|
nonce: Uint8Array; // 16 bytes; replay protection
|
|
serverSignature: Uint8Array; // Ed25519 over canonicalSerialize(this without sig)
|
|
}
|
|
|
|
interface FolderTemplate {
|
|
name: string; // single path component
|
|
children: (FolderTemplate | FileTemplate)[];
|
|
// depth is capped at 32 (validated by DriveTemplateSchema)
|
|
}
|
|
|
|
interface FileTemplate {
|
|
name: string;
|
|
fileLocId: string; // primary key into FileLocationIndex
|
|
fileId: string; // 32-byte hex; stable across versions
|
|
size: number; // plaintext bytes
|
|
totalChunks: number;
|
|
chunkSize: number;
|
|
checksum: string; // 'blake3:<hex>' over plaintext
|
|
flags: PerFilePolicy;
|
|
encryption?: FileEncryption;
|
|
manifestKey: string; // where the chunk manifest lives
|
|
manifestSig: Uint8Array; // detached Ed25519 sig over the manifest's content
|
|
}
|
|
|
|
interface PerFilePolicy {
|
|
autoDownload: boolean; // eagerly fetch on hydrate?
|
|
meshAllowed: boolean; // may we cd:request from peers?
|
|
cdnAllowed: boolean; // may we hit the CDN?
|
|
retention?: RetentionPolicy;
|
|
}
|
|
|
|
type RetentionPolicy =
|
|
| 'permanent' // never evict
|
|
| 'lru' // global byte-budget LRU
|
|
| { maxAgeMs: number }; // chunks older than N ms eligible for eviction
|
|
|
|
interface FileEncryption {
|
|
algorithm: 'xchacha20-poly1305';
|
|
keyDistribution:
|
|
| { kind: 'inline'; wrappedKey: Uint8Array } // shipped in template, see §7
|
|
| { kind: 'retroactive'; keyAuthority: string }; // peer pubkey will issue grant later
|
|
}
|
|
|
|
interface GlobalPolicy {
|
|
defaultPolicy: PerFilePolicy; // applied to files whose `flags` is partial
|
|
maxLocalCacheBytes?: number; // global LRU budget (excludes 'permanent' files)
|
|
maxConcurrentFetches?: number; // backpressure on hydrate
|
|
trustedKeyAuthorities?: string[]; // pubkeys whose key:grant we accept
|
|
}
|
|
|
|
interface CdnFallbackConfig {
|
|
baseUrl: string; // e.g. 'https://chunks.example.com'
|
|
pathScheme: 'reverse-id'; // see §4 for the URL convention
|
|
levels: number; // directory depth (typically 3)
|
|
padding: number; // chunk ID zero-pad width (typically 9)
|
|
uploadEndpoint?: string; // for write-back of newly-fetched chunks
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Signature, replay protection, and validation
|
|
|
|
### Canonical serialization
|
|
|
|
The server computes the signature over a deterministic byte representation:
|
|
|
|
```text
|
|
canonicalSerialize(template) =
|
|
schemaVersion(varint) ‖
|
|
driveId(32) ‖
|
|
rootFolder(canonical-folder) ‖
|
|
cdnFallback(canonical-cdn-or-empty) ‖
|
|
policy(canonical-policy) ‖
|
|
issuer(32) ‖
|
|
issuedAt(8) ‖
|
|
expiresAt(8) ‖
|
|
nonce(16)
|
|
```
|
|
|
|
Encoded via `@suaveplan/bitpacker-schema` with deterministic field order. JSON is **not** used — it doesn't have a canonical form across implementations.
|
|
|
|
### Validation order
|
|
|
|
`drive.hydrateFrom(template)` validates in this exact order; any failure aborts before instantiating anything:
|
|
|
|
1. `schemaVersion === 1` (else `DRIVE.TEMPLATE_VERSION_UNSUPPORTED`).
|
|
2. `Date.now() < expiresAt` (else `DRIVE.TEMPLATE_EXPIRED`).
|
|
3. `Date.now() - issuedAt < 24*60*60*1000` (24-hour issuance window — `DRIVE.TEMPLATE_STALE`).
|
|
4. `nonce` not seen in last 1024 templates (LRU cache; `DRIVE.TEMPLATE_REPLAY`).
|
|
5. `issuer` ∈ `globalPolicy.trustedKeyAuthorities` (or matches drive-creation key — `DRIVE.TEMPLATE_UNTRUSTED_ISSUER`).
|
|
6. Ed25519 verify `serverSignature` over `canonicalSerialize(template - sig)` (`DRIVE.TEMPLATE_SIG_INVALID`).
|
|
7. Recursive folder depth ≤ 32 (`DRIVE.TEMPLATE_TOO_DEEP`).
|
|
8. Per-file: `chunkSize > 164` (header overhead), `totalChunks * chunkSize >= size`, `manifestKey` non-empty (`DRIVE.TEMPLATE_MALFORMED`).
|
|
|
|
Only after all eight pass does hydration begin.
|
|
|
|
### Why nonce + issuedAt
|
|
|
|
A captured template, even one within its `expiresAt`, cannot be replayed against the same drive because the nonce is recorded. A client moving between devices must request a fresh template; this is by design — it gives the server a chance to update policy.
|
|
|
|
---
|
|
|
|
## 4. CDN fallback URL convention
|
|
|
|
When `policy.cdnAllowed === true` and a chunk is not available from the mesh, the chunks plugin falls back to HTTPS via `drive-transport-https`. The URL convention is **reversed-ID** with configurable depth and padding:
|
|
|
|
```text
|
|
<baseUrl>/<reversed[0]>/<reversed[1]>/...<reversed[levels-1]>/<fileId>_<chunkIdPadded>.chunk
|
|
```
|
|
|
|
### Worked example
|
|
|
|
```text
|
|
fileId = "abcdef1234567890fedcba0987654321abcdef1234567890fedcba0987654321"
|
|
reversed = "1234567890abcdef1234567890abcdef1234567890fedcba0987654321fedcba"
|
|
levels = 3
|
|
padding = 9
|
|
chunkId = 47
|
|
|
|
URL = https://chunks.example.com/1/2/3/<full-fileId>_000000047.chunk
|
|
```
|
|
|
|
### Why reversed
|
|
|
|
File IDs in practice cluster by prefix:
|
|
|
|
- Sequential issuance (`f-000001`, `f-000002`, ...) makes prefix-based hashing put millions of files in the same directory.
|
|
- Timestamp-prefixed IDs (`1729a4f72c3b...` from a sortable UUID) cluster by issuance time.
|
|
- Hash prefixes are uniform but humans tend to name files in consistent ways (creation tools embed magic bytes, etc.).
|
|
|
|
Reversing distributes leaves randomly across the directory tree regardless of how IDs were generated. The first three reversed characters give 16³ = 4,096 leaf directories — enough to keep any single directory under 250 entries even at 1 M files.
|
|
|
|
### Configuration trade-offs
|
|
|
|
| `levels` | Leaf dirs | Chunks/dir at 1M files | When to use |
|
|
|---|---|---|---|
|
|
| 2 | 256 | 3,906 | <100 K files; minimize HTTP path length |
|
|
| 3 | 4,096 | 244 | **default — good balance** |
|
|
| 4 | 65,536 | 15 | >10 M files; inode budget allows |
|
|
|
|
Padding (9 digits) supports up to 999,999,999 chunks per file (~64 PB at 64 KiB chunks). Not the bottleneck.
|
|
|
|
### Read protocol
|
|
|
|
```text
|
|
GET https://chunks.example.com/1/2/3/<fileId>_000000047.chunk
|
|
Range: bytes=0- (optional — supports partial chunk fetch)
|
|
Accept: application/octet-stream
|
|
```
|
|
|
|
Response body is the locked v0x0001 chunk format (`metadata(100) ‖ sig(64) ‖ ciphertext`). Same bytes the mesh would have returned. The drive's `chunks` plugin verifies the Ed25519 sig and AEAD tag identically — the CDN never sees plaintext.
|
|
|
|
### CDN as cache vs CDN as authority
|
|
|
|
The CDN is treated as a **cache, not an authority**. If a chunk's CDN copy fails signature verification, the drive:
|
|
|
|
1. Discards the bytes.
|
|
2. Falls back to the mesh.
|
|
3. Optionally reports the corruption to a configured endpoint.
|
|
|
|
This means a compromised CDN cannot poison a drive — it can only deny service.
|
|
|
|
---
|
|
|
|
## 5. CDN write-back
|
|
|
|
`CdnFallbackConfig.uploadEndpoint` (optional) lets a drive contribute newly-fetched chunks back to the CDN. This is the path that lets a small group of users seed a dehydrated drive without needing every chunk on the CDN at issuance time.
|
|
|
|
```text
|
|
POST <baseUrl>/upload
|
|
Content-Type: application/octet-stream
|
|
Authorization: Bearer <CdnUploadToken>
|
|
Body: <chunk bytes — same locked v0x0001 format>
|
|
```
|
|
|
|
```ts
|
|
interface CdnUploadToken {
|
|
fileId: string;
|
|
chunkId: number;
|
|
chunkHash: string; // BLAKE3 of the chunk bytes (the CDN will verify)
|
|
uploaderPubkey: string;
|
|
expiresAt: number; // 60-second expiry by default
|
|
serverSignature: Uint8Array; // Ed25519 by the drive's issuing server
|
|
}
|
|
```
|
|
|
|
The CDN:
|
|
|
|
1. Validates the token signature against the drive's known issuer key.
|
|
2. Hashes the body, compares to `chunkHash` — reject on mismatch.
|
|
3. Writes to the reversed-ID URL.
|
|
|
|
A CDN that doesn't validate signatures can decline writes (read-only mode); the drive falls back to peer-only seeding for that file.
|
|
|
|
### Why server-issued tokens
|
|
|
|
Direct PUT (no token) lets anyone poison the CDN. CORS won't help — attackers bypass it. The token couples the upload to a server-attested intent and a specific `(fileId, chunkId, chunkHash)`, and expires fast enough that a stolen token isn't useful.
|
|
|
|
---
|
|
|
|
## 6. Per-file policies in detail
|
|
|
|
### `autoDownload`
|
|
|
|
- `true` → on `hydrateFrom()`, schedule a fetch for all chunks not present locally. Honors `maxConcurrentFetches`.
|
|
- `false` → wait until first `read()`. The read triggers the same fetch path; the user pays the latency on first access.
|
|
|
|
UX: a drive showing a directory listing renders progress for `autoDownload=true` files even if the user hasn't opened them yet.
|
|
|
|
### `meshAllowed`
|
|
|
|
- `true` → `drive-mesh` may emit `cd:request` for this file's chunks.
|
|
- `false` → mesh layer skipped entirely; only local + CDN paths attempted.
|
|
|
|
Use `meshAllowed=false` for confidential files where peers shouldn't even *know* the file exists. The drive's gossip stops at hydration; a peer scraping the network bus never sees a request for this file.
|
|
|
|
### `cdnAllowed`
|
|
|
|
- `true` → `drive-transport-https` may fetch from the CDN.
|
|
- `false` → CDN path skipped. Use for files restricted to peer-only distribution (e.g., user-shared content that the CDN doesn't host).
|
|
|
|
### `retention`
|
|
|
|
The cache eviction policy. See [`../system-review.md`](../system-review.md) §9.
|
|
|
|
| Mode | Behavior |
|
|
|---|---|
|
|
| `'permanent'` | Never evict. The drive's job is to keep these chunks forever. |
|
|
| `'lru'` | Eligible for global LRU eviction. When `maxLocalCacheBytes` is exceeded, evict oldest-touched chunks (NOT whole files) until under budget. The file remains visible with reduced `chunksPresent`; re-fetches on next read. |
|
|
| `{ maxAgeMs }` | Chunks older than N ms eligible for eviction at the next GC pass. Useful for ephemeral content (tournaments, event streams). |
|
|
|
|
Eviction is at chunk granularity, never file granularity. The DirEntry, FileLocation, and manifest stay; only the bytes go. This means a "dehydrated" file gracefully transitions back to "needs-fetch" without index churn.
|
|
|
|
### Defaults via `GlobalPolicy.defaultPolicy`
|
|
|
|
A `FileTemplate.flags` may omit any field; the missing fields fill from `defaultPolicy`. This keeps the template compact for drives where every file shares the same policy.
|
|
|
|
---
|
|
|
|
## 7. Encryption keys — inline vs retroactive
|
|
|
|
### Inline (key shipped in template)
|
|
|
|
```ts
|
|
encryption: {
|
|
algorithm: 'xchacha20-poly1305',
|
|
keyDistribution: {
|
|
kind: 'inline',
|
|
wrappedKey: Uint8Array, // wrapped to recipient's X25519 pubkey via crypto_box_seal
|
|
},
|
|
}
|
|
```
|
|
|
|
On `hydrateFrom()`, the drive unwraps `wrappedKey` with the recipient's secret key (held in `@suaveplan/crypto-vault`). On success, the unwrapped `fileKey` lands in the drive's KeyRing under `fileId`. The lock icon flips from 🔒 to 🔓 immediately.
|
|
|
|
This is the simple case: drive operator decides at template-issuance time who gets the key, and the recipient has it from day one.
|
|
|
|
### Retroactive (key arrives later)
|
|
|
|
```ts
|
|
encryption: {
|
|
algorithm: 'xchacha20-poly1305',
|
|
keyDistribution: {
|
|
kind: 'retroactive',
|
|
keyAuthority: '<peer-pubkey>', // the peer that will issue grants
|
|
},
|
|
}
|
|
```
|
|
|
|
On `hydrateFrom()`, the drive:
|
|
|
|
1. Records the file as encrypted-locked.
|
|
2. Subscribes to `bus:key:grant` events filtered by `fileId`.
|
|
3. Renders 🔒 in the UI.
|
|
|
|
When the key authority decides to grant access (or the user requests it via a side channel — e.g., paying for content, joining a group), it issues:
|
|
|
|
```ts
|
|
interface KeyGrantEnvelope extends MessageEnvelope {
|
|
type: 'key:grant';
|
|
payload: KeyGrantPayload;
|
|
}
|
|
|
|
interface KeyGrantPayload {
|
|
fileId: string;
|
|
recipient: string; // recipient pubkey
|
|
wrappedKey: Uint8Array; // wrapped to recipient via crypto_box_seal
|
|
issuer: string; // keyAuthority pubkey from the template
|
|
issuerSignature: Uint8Array; // Ed25519 over (fileId, recipient, wrappedKey, expiresAt)
|
|
expiresAt: number; // optional — for time-limited grants
|
|
}
|
|
```
|
|
|
|
The drive validates:
|
|
|
|
1. `issuer === template.encryption.keyAuthority` for this file.
|
|
2. `recipient === ourPubkey`.
|
|
3. Ed25519 sig.
|
|
4. `Date.now() < expiresAt` (if set).
|
|
|
|
On success, unwrap, store in KeyRing, flip 🔒 → 🔓, emit `drive:key:granted` event. UI updates.
|
|
|
|
### Revocation
|
|
|
|
A retroactive key grant can carry an `expiresAt`. After that timestamp:
|
|
|
|
- The drive evicts the unwrapped key from the KeyRing.
|
|
- The lock icon flips back 🔓 → 🔒.
|
|
- A new grant can be issued to extend access.
|
|
|
|
This is forward revocation only — anything the recipient already decrypted, they have. (See [`mesh-and-sharing.md`](./mesh-and-sharing.md) §"What revocation actually does".) For destructive revocation, rotate the `fileKey` server-side and re-issue the template with a new `manifestSig`.
|
|
|
|
### Trust roots
|
|
|
|
`GlobalPolicy.trustedKeyAuthorities` is the set of issuer pubkeys whose `key:grant` envelopes the drive will accept. Typically just `[server-pubkey]`, but multi-issuer setups (a game with multiple content vendors) can list several. The set is fixed at template issuance — adding new issuers requires a fresh template.
|
|
|
|
---
|
|
|
|
## 8. The `Drive.hydrateFrom()` API
|
|
|
|
```ts
|
|
class Drive {
|
|
hydrateFrom(template: DriveTemplate, options?: HydrateOptions): Promise<HydrateResult>;
|
|
}
|
|
|
|
interface HydrateOptions {
|
|
signal?: AbortSignal;
|
|
onProgress?: (event: HydrateProgressEvent) => void;
|
|
preFetch?: 'none' | 'metadata-only' | 'auto-download-files';
|
|
}
|
|
|
|
interface HydrateResult {
|
|
filesAdded: number;
|
|
bytesScheduled: number; // sum of sizes of autoDownload files
|
|
unlocked: number; // count of inline-key files unlocked
|
|
locked: number; // count of files awaiting retroactive grant
|
|
}
|
|
|
|
type HydrateProgressEvent =
|
|
| { kind: 'verified' }
|
|
| { kind: 'index-built'; entries: number }
|
|
| { kind: 'fetch-scheduled'; fileId: string }
|
|
| { kind: 'key-unlocked'; fileId: string };
|
|
```
|
|
|
|
`preFetch` controls hydration aggressiveness:
|
|
|
|
- `'none'` — instantiate indexes only; fetch on first read.
|
|
- `'metadata-only'` — fetch all manifests but no chunks. Lets the drive verify file integrity claims without the byte cost.
|
|
- `'auto-download-files'` (default) — honor each file's `policy.autoDownload`.
|
|
|
|
---
|
|
|
|
## 9. Worked example — game asset bundle
|
|
|
|
A game wants to ship a 2 GB asset bundle to 10K players. The server:
|
|
|
|
1. Chunks every asset file at 64 KiB. ~32K chunks across 200 files.
|
|
2. Computes Ed25519-signed chunk format for each chunk; uploads to the CDN at `/chunks.game.example.com/<reversed-id>/<file>_<chunk>.chunk`.
|
|
3. Computes a `DriveTemplate` listing every asset path, fileId, size, manifest, encryption (none for assets, inline for premium DLC).
|
|
4. Signs the template; expiry 30 days; nonce per player.
|
|
5. Distributes the template via the game launcher.
|
|
|
|
On the player's machine:
|
|
|
|
```ts
|
|
const drive = new Drive(opfsStorage)
|
|
.usePlugin(chunksPlugin({ ... }))
|
|
.usePlugin(meshPlugin({ networkNode }))
|
|
.usePlugin(transportWebrtcPlugin({ ... }))
|
|
.usePlugin(transportHttpsPlugin({ baseUrl: 'https://chunks.game.example.com' }));
|
|
|
|
await drive.initialize();
|
|
const result = await drive.hydrateFrom(template, {
|
|
preFetch: 'auto-download-files',
|
|
onProgress: (e) => updateLoadingScreen(e),
|
|
});
|
|
// result.filesAdded === 200; result.bytesScheduled === 2_000_000_000;
|
|
```
|
|
|
|
What happens next:
|
|
|
|
- For files with `autoDownload=true` and `meshAllowed=true`: drive-mesh broadcasts `cd:request`. Other players already running the game (with chunks cached) respond. Bandwidth comes from peers, not the CDN.
|
|
- For files no peer has yet (newest player): drive-transport-https hits the CDN.
|
|
- For files with `autoDownload=false`: deferred until the player navigates to the corresponding game zone; first-access pays a fetch.
|
|
- For DLC with inline encryption: keys unwrap immediately; UI shows 🔓.
|
|
- For event-bound DLC (retroactive keys, e.g., "this skin unlocks during the seasonal event"): UI shows 🔒. When the season starts, the server broadcasts `key:grant`; UIs flip to 🔓.
|
|
|
|
The CDN's bandwidth bill drops dramatically once the player base has any seed. The mesh handles the cold-start of new geographic clusters. The drive UX is identical regardless of where bytes came from.
|
|
|
|
---
|
|
|
|
## 10. Test contract
|
|
|
|
The `drive-template-v1` change folder will ship:
|
|
|
|
- `tests/template-roundtrip.test.ts` — canonicalSerialize / sign / verify cycle.
|
|
- `tests/template-replay.test.ts` — second hydrate with same nonce rejected.
|
|
- `tests/template-expired.test.ts` — expired template rejected with correct error code.
|
|
- `tests/template-tampered.test.ts` — bit flip anywhere in the template breaks the sig.
|
|
- `tests/policy-meshallowed-false.test.ts` — `meshAllowed: false` skips bus emit (verified via test bus interceptor).
|
|
- `tests/policy-cdnallowed-false.test.ts` — same for CDN.
|
|
- `tests/retention-lru.test.ts` — eviction respects `maxLocalCacheBytes`; permanent files never evicted.
|
|
- `tests/inline-key-unlock.test.ts` — hydrate flips lock icon; `read()` returns plaintext.
|
|
- `tests/retroactive-key-grant.test.ts` — incoming `key:grant` flips lock icon; `read()` works.
|
|
- `tests/cdn-reverse-id-url.test.ts` — URL builder produces correct path for various `levels`/`padding` combos.
|
|
- `tests/cdn-bad-sig.test.ts` — corrupted CDN response falls back to mesh.
|
|
|
|
---
|
|
|
|
## 11. Reading further
|
|
|
|
- [`mesh-protocol.md`](./mesh-protocol.md) — the chunk-request/response/advertise envelopes that hydration uses.
|
|
- [`encryption.md`](./encryption.md) — chunk format that the templates reference.
|
|
- [`mesh-and-sharing.md`](./mesh-and-sharing.md) — partition-based sharing as the alternative to dehydrated drives.
|
|
- [`../system-review.md`](../system-review.md) — the review this doc resolves.
|
|
- [`../drive-storage-network.md`](../drive-storage-network.md) — the whole composition map; hydration plugs into the read pipeline at the `mesh` layer.
|