8.1 KiB
Drive Encryption — Encrypt / Sign / Decrypt / Verify on the Fly
The user's question: "to be able to encrypt / sign / decrypt / verify on-the-fly."
The drive layer does this through @suaveplan/drive-plugin-chunks, which streams a file through a five-stage pipeline on the way down to storage and reverses it on the way up. Storage never sees plaintext.
Canonical specs:
openspec/specs/packer/spec.md— chunked AEAD format.openspec/changes/drive-plugin-chunks-port/specs/drive-plugin-chunks/spec.md— drive-side requirements.
The chunk binary format (locked, v0x0001)
Every chunk on disk is exactly:
metadata(100) ‖ sig(64) ‖ ciphertext(variable)
| Field | Size | Description |
|---|---|---|
| version | 3 | 0x0001 (locked v1; 0x0002+ reserved for future formats) |
| fileId | 32 | 32-byte stable file identifier |
| chunkId | 4 | 0-indexed chunk number within the file |
| totalChunks | 4 | Total chunks in the file |
| fileSize | 8 | Total file size in bytes |
| chunkSize | 4 | Configured chunk size (e.g. 65536) |
| payloadLen | 4 | Actual ciphertext length for this chunk |
| flags | 2 | Reserved (compression, alg agility — currently 0) |
| nonce | 24 | XChaCha20 nonce, deterministic from (fileId, chunkId) |
| aeadTag | 16 | Poly1305 authentication tag |
| sig | 64 | Ed25519 signature over metadata ‖ aeadTag |
| ciphertext | payloadLen |
XChaCha20-Poly1305 encrypted payload |
Header total: 100 bytes. Signature: 64 bytes. Header + sig = 164 bytes per chunk of overhead, regardless of chunk size.
Non-final chunks are exactly chunkSize - 164 plaintext bytes. The final chunk is naturally short (no zero padding).
The pipeline
Write side
plaintext stream
│
├── split into chunks of (chunkSize - 164) plaintext bytes (chop)
│
├── for each chunk:
│ ├── nonce = HKDF(fileKey, "chunk-nonce", fileId ‖ chunkId)
│ ├── ciphertext, aeadTag = XChaCha20-Poly1305-encrypt(fileKey, nonce, plaintext, AAD=metadata)
│ ├── sig = Ed25519-sign(driveSecretKey, metadata ‖ aeadTag)
│ └── chunk = metadata ‖ sig ‖ ciphertext (164 + payload)
│
└── store each chunk:
if storage.capabilities.partialWrites:
storage.setPartial(fileKey, chunkId * chunkSize, chunk) // sparse-file layout
else:
storage.set(`${fileKey}/${chunkId}`, chunk) // chunk-keys layout
│
├── manifest = { fileId, totalChunks, version, entries: [{ chunkId, hash, payloadLen }, ...] }
├── manifestSig = Ed25519-sign(driveSecretKey, canonicalSerialize(manifest))
└── store manifest at `manifests/${fileId}-v${version}`
Read side
storage read of one chunk
│
├── parse 164-byte header (extract metadata, sig, AAD)
│
├── verify Ed25519 sig over (metadata ‖ aeadTag) using driveId's pubkey
│ → reject with CHUNKS.SIG_INVALID if it doesn't match
│
├── decrypt ciphertext with XChaCha20-Poly1305(fileKey, nonce, AAD=metadata)
│ → reject with CHUNKS.AEAD_FAILED if the tag doesn't match
│
├── reassemble plaintext from contiguous chunkIds
│
└── verify whole-file BLAKE3 hash matches the manifest's checksum
→ reject with CHUNKS.INTEGRITY_FAILED otherwise
The verify-then-decrypt order is intentional: the signature is over the metadata + AEAD tag, not the plaintext, so signature verification can happen on bytes the storage layer just produced — no plaintext needed. If the signature is wrong (tampered chunk, wrong drive), we reject before spending CPU on decrypt.
Where the keys come from
Two key materials per file:
fileKey— symmetric AEAD key. 32 bytes. Used for XChaCha20-Poly1305. Never leaves the drive in plaintext.driveSecretKey— Ed25519 signing key. The drive's identity. Used to sign every chunk and every manifest.
Where they live:
driveSecretKeyis stored in the vault (@suaveplan/crypto-vault). The vault is unlocked with a BIP39 mnemonic + Argon2id-hardened KDF; on lock or process exit, all derived keys are zeroized. The public key —driveId— is derived from this and is what every other drive uses to verify signatures.fileKeyis per-file, generated fresh on first write. It is wrapped with the partition KEK (key-encryption-key) bydrive-sharingand stored alongside the manifest. Authorized peers unwrap it; unauthorized peers see only ciphertext.
For shared files (multiple drives can read), drive-sharing rewraps the fileKey with each authorized drive's public key (X25519 + sealed-box). Revocation rotates fileKey on the next write.
End-to-end encryption properties
| Property | Mechanism |
|---|---|
| Confidentiality at rest | XChaCha20-Poly1305 ciphertext is what storage stores. Any backend (S3, OPFS, FAT) sees only ciphertext. |
| Confidentiality in transit | Same ciphertext flows through drive-transport-*. Network plugins never see plaintext. |
| Authenticity | Per-chunk Ed25519 sig — only the drive that wrote the chunk could have produced it. |
| Integrity | (a) AEAD tag per chunk, (b) BLAKE3 over the whole file in the manifest, (c) Ed25519 over the manifest. Three layers; any one catches tampering. |
| Anti-substitution | Manifest signature pins the exact (chunkId, hash, payloadLen) tuple. A peer can't swap chunk 3's content for an older chunk 3 of the same length. |
| Forward secrecy on revocation | Rotating fileKey on the next write means revoked peers can decrypt only what they've already cached, not future content. |
| Replay resistance | Manifest version monotonically increases; old manifests are rejected once a newer one is signed by the same drive. |
The on-the-fly part
Both directions are streaming. The chunks plugin uses @suaveplan/streams (which uses WHATWG TransformStream). A 1 GiB file never fully lands in memory:
- Write —
createWriteStream(path)returns aWritableStreamthat the plugin pipes throughchop → encrypt+sign → store. - Read —
createReadStream(path)returns aReadableStreamthat pulls chunks lazily, verifies and decrypts each, and yields plaintext bytes.
The pipeline is backpressure-safe end-to-end: if the consumer slows down, the storage reads stop; if the producer is slow, the encrypt stage idles.
What the storage layer sees
A 1 GiB file written through this pipeline, with chunkSize = 65536:
- Sparse-file layout: one storage key (
files/<fileId>), 1 GiB of opaque ciphertext, plus one key (manifests/<fileId>-v<version>) of ~16 KiB. - Chunk-keys layout: 16,384 storage keys (
files/<fileId>/<chunkId>), each ~64 KiB ciphertext, plus the manifest key.
In neither case can the storage backend reconstruct the plaintext, the file size in plaintext, the file path, the owner, or the file's relationship to other files. Storage is a black box that holds ciphertext.
Why this fits enterprise scenarios
- Encrypted backups — set the drive's storage to S3, configure server-side encryption also if you want defence-in-depth. The bytes hitting S3 are already AEAD-sealed; SSE is icing.
- Browser drives without trust in the storage origin — OPFS in a third-party context or IndexedDB in a shared browser is fine: the bytes are ciphertext.
- Multi-tenant servers —
NamespacedStorageper tenant, plusdrive-sharingpartitions, plus per-filefileKey— three layers of isolation. - Peer-to-peer chunk distribution — peers serve ciphertext bytes they cannot themselves read (unless they hold the wrapped
fileKey). The mesh forwards verifiable bytes; only authorised consumers decrypt.
Reading further
plugins.md— where the chunks plugin sits in the chain.mesh-and-sharing.md— partition-based key wrapping and grant/revoke.../network/security-and-tokens.md— message-level signing and the mesh/session token model.