OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-vdrive commits / fda1abe

Encryption defeats virtualisation, safely, and recoverably
Measured on the same 8MB payload with and without an encryptionKey: 0 of 105 blocks redirect instead of 102 of 105, and the store goes from 0.14MB to 8.53MB. Readback stays correct and the drive key is unchanged. The cause is that hypercore encrypts in _preappend, above the storage layer, so putBlock sees ciphertext with 8 bytes of prepended padding whose hash is not a chunk hash. Everything falls through to real storage — the fall-through-by-hash design degrades to a normal drive rather than corrupting anything, which is the good failure mode. Recoverable: the cipher is a keyed stream XOR nonced by index and blinded fork, and the probe confirms it is deterministic per index. An adapter holding the key could decrypt in putBlock to recover the plaintext hash and re-encrypt in getBlock. Dedup would survive, since hyperblobs hashes what the caller writes (plaintext) and only storage sees ciphertext. Caveats: CoreTX does not obviously carry the fork that participates in the nonce, and it hands storage a key it never sees today. Until then, publish public apps unencrypted — which is what pear:// distribution is anyway. Adds test/encryption.mjs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fda1abe8bdf1e09db364aaa9060c492ecdafde9a
by plankatron <fleeky@gmail.com> · 2026-08-24T14:28:45.000Z

Changed files (3) +152 -4

README.md +7 -4
……@@ -105,13 +105,16 @@ Known gaps, in the order they'd bite:
105105 from the content store's refcounts, never from a version's block range.
1061062. **Block streams** (`createBlockStream`, used by audit/copy-prologue) yield raw tagged
107107 records; they need decoding like `getBlock` does.
1083. **Encryption** sits above storage and prepends 8 bytes of padding, so block bytes stop
109 equalling chunk bytes. Run unencrypted, or teach the adapter to re-derive.
1083. **Encryption defeats virtualisation** — measured: 0 of 105 blocks redirect, the store
109 goes from 0.14 MB to 8.53 MB. Correctness is unaffected (fall-through-by-hash
110 degrades to a normal drive). Recoverable in principle: the cipher is deterministic
111 per (index, fork, key), so an adapter holding the key could decrypt on write and
112 re-encrypt on read, with dedup surviving because hyperblobs hashes plaintext. Until
113 then, publish public apps unencrypted.
1101144. **Unsupported seam.** `hypercore-storage` has no public pluggable-storage contract
111115 since `random-access-storage` was dropped. Pin versions and run the tests as a canary.
1121165. **clear-then-truncate is unsupported upstream** — `clear()` prunes merkle tree nodes,
113117 so a later `truncate()` fails with `Expected tree node N from storage, got (nil)`.
114118 Same on a stock drive. And `clear()` reclaims no disk promptly: on a virtual drive
115119 that does not matter, because the bytes are the overlay's to free.
1166. Untested: power loss (SIGKILL is covered and passes; fsync durability is not),
117 and encryption.
1206. Untested: power loss — SIGKILL is covered and passes, fsync durability is not.
RESULTS.md +33 -0
……@@ -198,6 +198,39 @@ Two things worth noting:
198198Caveat: SIGKILL ends the process but the OS still flushes its page cache. This is not a
199199power-loss test, and nothing here should be read as an fsync-durability claim.
200200
201## Encryption
202
203Same 8 MB payload, same adapter, with and without `encryptionKey`. Sizes compacted.
204
205| | plaintext | encrypted |
206|---|---|---|
207| blocks redirected | 102 | **0** |
208| blocks stored literally | 3 | **105** |
209| store | 0.14 MB | **8.53 MB** |
210| readback | ok | ok |
211| drive key | identical | identical |
212
213**Virtualisation is lost completely under encryption, and correctness is unaffected.**
214Hypercore encrypts in `_preappend`, above the storage layer, so the bytes reaching
215`putBlock` are ciphertext with 8 bytes of prepended padding. Their hashes are not chunk
216hashes, so every block falls through to real storage — the fall-through-by-hash design
217degrades safely to a normal drive rather than corrupting anything.
218
219**It is recoverable.** The cipher is a keyed stream XOR nonced by block index and
220blinded fork (`hypercore/lib/default-encryption.js:49-69`), and the probe confirms it:
221same plaintext at the same index gives identical ciphertext, a different index gives
222different ciphertext. So an adapter holding the encryption key could decrypt in
223`putBlock` to recover the plaintext hash, and re-encrypt in `getBlock` from the stored
224plaintext chunk. Dedup survives that, because hyperblobs hashes what the *caller*
225writes — plaintext — and only storage ever sees ciphertext.
226
227Two things to get right if that is built: `CoreRX` already carries `fork`, but `CoreTX`
228does not obviously, and the fork participates in the nonce. And it hands the storage
229layer a key it currently never sees, which is a real trust-boundary change.
230
231Until then: **publish public apps unencrypted** — which is what `pear://` distribution
232is anyway — and treat encrypted private repos as a separate track.
233
201234## GC / bitfield
202235
203236```
test/encryption.mjs +112 -0
……@@ -0,0 +1,112 @@
1// Encryption — hypercore encrypts ABOVE storage, so the bytes reaching putBlock
2// are ciphertext with 8 bytes of padding, not our chunk bytes. Expected outcome:
3// still correct, but nothing virtualises. This measures how completely.
4// node test/encryption.mjs <workDir>
5import { createRequire } from 'module'
6const require = createRequire(import.meta.url)
7
8const fs = require('fs')
9const { execFileSync } = require('child_process')
10const crypto = require('hypercore-crypto')
11const Corestore = require('corestore')
12const Hyperdrive = require('hyperdrive')
13const CorestoreStorage = require('hypercore-storage')
14const DefaultEncryption = require('hypercore/lib/default-encryption')
15const { ContentStore, build, stats } = require('../lib/storage.cjs')
16const { chunk } = await import('../../hyper-overlay/lib/chunker.js')
17
18const ROOT = process.argv[2]
19const PRIMARY = Buffer.alloc(32, 7)
20const ENCRYPTION_KEY = Buffer.alloc(32, 42)
21const du = (p) => Number(execFileSync('du', ['-sb', p]).toString().split('\t')[0])
22
23async function compactedSize (dir) {
24 const s = new CorestoreStorage(dir)
25 await s.ready()
26 await s.compact()
27 await s.close()
28 return du(dir)
29}
30
31const srcDir = ROOT + '/src'
32fs.mkdirSync(srcDir, { recursive: true })
33const payload = crypto.randomBytes(8 * 1024 * 1024)
34const file = srcDir + '/payload.bin'
35fs.writeFileSync(file, payload)
36
37const chunks = chunk(payload)
38const content = new ContentStore()
39for (const c of chunks) content.add(c.hash, file, c.offset, c.length)
40
41function write (ws, buf) {
42 return new Promise((resolve) => {
43 if (ws.write(buf)) return resolve()
44 ws.once('drain', resolve)
45 })
46}
47
48async function run (dir, { encrypted }) {
49 const before = { ...stats }
50 const Storage = build(content)
51 const store = new Corestore(new Storage(dir), { primaryKey: PRIMARY, unsafe: true })
52 const drive = new Hyperdrive(store, encrypted ? { encryptionKey: ENCRYPTION_KEY } : {})
53 await drive.ready()
54
55 const ws = drive.createWriteStream('/payload.bin', { dedup: true })
56 for (const c of chunks) await write(ws, c.data)
57 await new Promise((resolve, reject) => {
58 ws.once('error', reject)
59 ws.once('close', resolve)
60 ws.end()
61 })
62
63 await drive.getBlobs()
64 const got = await drive.get('/payload.bin')
65 const out = {
66 driveKey: drive.key.toString('hex'),
67 blobsLength: drive.blobs.core.length,
68 readbackOk: got !== null && got.equals(payload),
69 redirected: stats.putRedirected - before.putRedirected,
70 literal: stats.putStored - before.putStored
71 }
72 await drive.close()
73 await store.close()
74 out.storeBytes = await compactedSize(dir)
75 return out
76}
77
78const plain = await run(ROOT + '/plain', { encrypted: false })
79const enc = await run(ROOT + '/encrypted', { encrypted: true })
80
81// Is the cipher deterministic? If so, an adapter given the key could re-derive
82// ciphertext from plaintext on read, and virtualisation would work encrypted too.
83const keys = DefaultEncryption.deriveKeys(ENCRYPTION_KEY, Buffer.alloc(32, 1), {})
84function encryptOnce (plaintext, index, fork) {
85 const block = Buffer.concat([Buffer.alloc(DefaultEncryption.PADDING), plaintext])
86 DefaultEncryption.encrypt(index, block, fork, keys.block, keys.blinding)
87 return block
88}
89const sample = payload.subarray(0, 4096)
90const a = encryptOnce(sample, 7, 0)
91const b = encryptOnce(sample, 7, 0)
92const c = encryptOnce(sample, 8, 0)
93
94content.close()
95
96console.log(
97 JSON.stringify(
98 {
99 plain,
100 encrypted: enc,
101 sameDriveKey: plain.driveKey === enc.driveKey,
102 virtualisationLostWhenEncrypted: enc.redirected === 0,
103 cipher: {
104 deterministicSameIndex: a.equals(b),
105 differsAcrossIndex: !a.equals(c),
106 paddingBytes: DefaultEncryption.PADDING
107 }
108 },
109 null,
110 2
111 )
112)