@le-space/orbitdb-storage-bridge 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * @fileoverview Encrypting what goes to a storage service, and decrypting what
3
+ * comes back.
4
+ *
5
+ * A backup leaves the browser as two opaque blobs — a CAR and a small metadata
6
+ * JSON — and the services that hold them neither need nor should have their
7
+ * contents. This wraps a backend so `putBlob` encrypts and `getBlob` decrypts,
8
+ * and **nothing above it changes**: `dehydrate`, `restoreFromCID`, the mirror,
9
+ * the gateway path and the peer path all move opaque bytes either way.
10
+ *
11
+ * ## The keys are the caller's
12
+ *
13
+ * This package knows nothing about passkeys, and should not: it takes two
14
+ * functions, exactly as `createBackendFromChoice` takes `normaliseAddress`
15
+ * rather than importing viem. A browser consumer derives them from the
16
+ * security key's PRF output — `@le-space/orbitdb-identity-provider-webauthn-did`
17
+ * ships `getPrfOutput`, `encryptWithAESGCM` and `decryptWithAESGCM` — and
18
+ * should derive a **separate** key for this, from the same secret with a
19
+ * different info string, so that a compromised backup key is not a signing key.
20
+ *
21
+ * ## The envelope, and why it is not just ciphertext
22
+ *
23
+ * Bytes on the way back can be one of three things: this envelope, a plaintext
24
+ * CAR from before backups were encrypted, or a plaintext CAR written with
25
+ * `dontEncrypt`. A restore has to tell them apart without being told, because
26
+ * a pointer does not carry that knowledge. So every ciphertext starts with a
27
+ * magic number and a version:
28
+ *
29
+ * ```
30
+ * "OSBE" | version | ivLength | iv | ciphertext
31
+ * 4 1 1 ~12 …
32
+ * ```
33
+ *
34
+ * A CAR begins with a varint length and a dag-cbor header, and JSON with `{`,
35
+ * so neither collides with the magic. The version is there for the day the
36
+ * algorithm changes: a backup from before it can then be refused with a
37
+ * reason rather than decrypted into noise.
38
+ *
39
+ * @module backends/encryption
40
+ */
41
+
42
+ import { defineBackend, BackendError } from "./types.js";
43
+
44
+ /** `OSBE` — orbitdb-storage-bridge, encrypted. */
45
+ // Not frozen: freezing a typed array throws, because its elements live in a
46
+ // buffer the engine will not seal.
47
+ export const ENVELOPE_MAGIC = new Uint8Array([0x4f, 0x53, 0x42, 0x45]);
48
+
49
+ /** Bumped when the envelope or the algorithm changes in a way readers must notice. */
50
+ export const ENVELOPE_VERSION = 1;
51
+
52
+ const HEADER_LENGTH = ENVELOPE_MAGIC.length + 2;
53
+
54
+ /** Do these bytes start with an envelope this module wrote? */
55
+ export function isEncrypted(bytes) {
56
+ if (!(bytes instanceof Uint8Array) || bytes.length < HEADER_LENGTH) return false;
57
+ return ENVELOPE_MAGIC.every((byte, index) => bytes[index] === byte);
58
+ }
59
+
60
+ /**
61
+ * Wrap ciphertext and its IV so a reader can recognise both without being told.
62
+ *
63
+ * @param {Uint8Array} ciphertext
64
+ * @param {Uint8Array} iv
65
+ * @returns {Uint8Array}
66
+ */
67
+ export function wrapEnvelope(ciphertext, iv) {
68
+ if (!(ciphertext instanceof Uint8Array) || !(iv instanceof Uint8Array)) {
69
+ throw new BackendError("INVALID_BACKEND", "encrypt must return Uint8Array ciphertext and iv");
70
+ }
71
+ if (iv.length > 255) {
72
+ throw new BackendError("INVALID_BACKEND", `An IV of ${iv.length} bytes does not fit the envelope`);
73
+ }
74
+ const out = new Uint8Array(HEADER_LENGTH + iv.length + ciphertext.length);
75
+ out.set(ENVELOPE_MAGIC, 0);
76
+ out[ENVELOPE_MAGIC.length] = ENVELOPE_VERSION;
77
+ out[ENVELOPE_MAGIC.length + 1] = iv.length;
78
+ out.set(iv, HEADER_LENGTH);
79
+ out.set(ciphertext, HEADER_LENGTH + iv.length);
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Read an envelope back, refusing a version this build does not know.
85
+ *
86
+ * @param {Uint8Array} bytes
87
+ * @returns {{ ciphertext: Uint8Array, iv: Uint8Array, version: number }}
88
+ */
89
+ export function readEnvelope(bytes) {
90
+ if (!isEncrypted(bytes)) {
91
+ throw new BackendError("INVALID_BACKEND", "These bytes are not an encrypted backup");
92
+ }
93
+ const version = bytes[ENVELOPE_MAGIC.length];
94
+ if (version !== ENVELOPE_VERSION) {
95
+ throw new BackendError(
96
+ "INVALID_BACKEND",
97
+ `This backup was written with envelope version ${version}, and this build reads ${ENVELOPE_VERSION}. ` +
98
+ "Upgrade @le-space/orbitdb-storage-bridge to restore it.",
99
+ );
100
+ }
101
+ const ivLength = bytes[ENVELOPE_MAGIC.length + 1];
102
+ const iv = bytes.subarray(HEADER_LENGTH, HEADER_LENGTH + ivLength);
103
+ const ciphertext = bytes.subarray(HEADER_LENGTH + ivLength);
104
+ return { ciphertext, iv, version };
105
+ }
106
+
107
+ /**
108
+ * Encrypt on the way out, decrypt on the way back.
109
+ *
110
+ * @param {import("./types.js").StorageBackend} backend - the one that stores bytes
111
+ * @param {object} options
112
+ * @param {(plaintext: Uint8Array) => Promise<{ciphertext: Uint8Array, iv: Uint8Array}>} options.encrypt
113
+ * @param {(ciphertext: Uint8Array, iv: Uint8Array) => Promise<Uint8Array>} options.decrypt
114
+ * @param {boolean} [options.allowPlaintextReads=true] - restore a backup written
115
+ * before backups were encrypted, or written with `dontEncrypt`. On by
116
+ * default: a pointer does not say which kind it names, and refusing would
117
+ * make yesterday's backups unreadable for no gain in secrecy.
118
+ * @returns {import("./types.js").StorageBackend}
119
+ */
120
+ export function withEncryption(backend, { encrypt, decrypt, allowPlaintextReads = true } = {}) {
121
+ if (!backend || typeof backend.putBlob !== "function") {
122
+ throw new BackendError("INVALID_BACKEND", "withEncryption needs a backend to wrap");
123
+ }
124
+ if (typeof encrypt !== "function" || typeof decrypt !== "function") {
125
+ throw new BackendError(
126
+ "INVALID_BACKEND",
127
+ "withEncryption needs encrypt and decrypt functions — this package holds no keys of its own",
128
+ );
129
+ }
130
+
131
+ const inner = backend;
132
+
133
+ return defineBackend({
134
+ ...inner,
135
+ name: `encrypted(${inner.name})`,
136
+ capabilities: {
137
+ ...inner.capabilities,
138
+ // The stored bytes are an envelope, not a CAR: nothing downstream may
139
+ // treat them as one, and there are no inner CIDs to preserve.
140
+ carImport: false,
141
+ preservesInnerCids: false,
142
+ },
143
+
144
+ async putBlob(bytes, meta) {
145
+ const { ciphertext, iv } = (await encrypt(bytes)) ?? {};
146
+ return inner.putBlob(wrapEnvelope(ciphertext, iv), meta);
147
+ },
148
+
149
+ async getBlob(handle) {
150
+ const bytes = await inner.getBlob(handle);
151
+ if (!isEncrypted(bytes)) {
152
+ if (allowPlaintextReads) return bytes;
153
+ throw new BackendError(
154
+ "INVALID_BACKEND",
155
+ "This backup is not encrypted, and allowPlaintextReads is off",
156
+ );
157
+ }
158
+ const { ciphertext, iv } = readEnvelope(bytes);
159
+ try {
160
+ return await decrypt(ciphertext, iv);
161
+ } catch (error) {
162
+ // The common cause by far is the wrong key — a different security key,
163
+ // or a key derived with a different info string. Say that, rather than
164
+ // letting an unreadable CAR surface three layers down.
165
+ throw new BackendError(
166
+ "INVALID_BACKEND",
167
+ `Could not decrypt this backup: ${error.message}. ` +
168
+ "It was written with a different key, or by a different derivation.",
169
+ { cause: error },
170
+ );
171
+ }
172
+ },
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Decrypt on the way back, for the path a restore actually takes.
178
+ *
179
+ * `restoreFromCID` does not read through a backend: it takes `fetchBytes(cid)`
180
+ * and gets the bytes from a gateway or from peers, so `withEncryption`'s
181
+ * `getBlob` never runs during a restore. This is the other half — wrap the
182
+ * fetcher, and a backup written through an encrypting backend comes back
183
+ * readable however it was fetched.
184
+ *
185
+ * @param {(cid: string, options?: object) => Promise<Uint8Array>} fetchBytes
186
+ * @param {object} options
187
+ * @param {(ciphertext: Uint8Array, iv: Uint8Array) => Promise<Uint8Array>} options.decrypt
188
+ * @param {boolean} [options.allowPlaintextReads=true]
189
+ * @returns {(cid: string, options?: object) => Promise<Uint8Array>}
190
+ */
191
+ export function decryptingFetch(fetchBytes, { decrypt, allowPlaintextReads = true } = {}) {
192
+ if (typeof fetchBytes !== "function") {
193
+ throw new BackendError("INVALID_BACKEND", "decryptingFetch needs a fetcher to wrap");
194
+ }
195
+ if (typeof decrypt !== "function") {
196
+ throw new BackendError("INVALID_BACKEND", "decryptingFetch needs a decrypt function");
197
+ }
198
+
199
+ return async function fetchAndDecrypt(cid, options) {
200
+ const bytes = await fetchBytes(cid, options);
201
+ if (!isEncrypted(bytes)) {
202
+ if (allowPlaintextReads) return bytes;
203
+ throw new BackendError(
204
+ "INVALID_BACKEND",
205
+ `The backup at ${cid} is not encrypted, and allowPlaintextReads is off`,
206
+ );
207
+ }
208
+ const { ciphertext, iv } = readEnvelope(bytes);
209
+ try {
210
+ return await decrypt(ciphertext, iv);
211
+ } catch (error) {
212
+ throw new BackendError(
213
+ "INVALID_BACKEND",
214
+ `Could not decrypt the backup at ${cid}: ${error.message}. ` +
215
+ "It was written with a different key, or by a different derivation.",
216
+ { cause: error },
217
+ );
218
+ }
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Recognise an encrypted backup when there is no key to open it.
224
+ *
225
+ * Without this, a restore of an encrypted backup without `decrypt` fails deep
226
+ * inside the CAR reader — "unexpected end of data", or a block that will not
227
+ * verify — and the reason has nothing to do with what actually happened.
228
+ *
229
+ * @param {(cid: string, options?: object) => Promise<Uint8Array>} fetchBytes
230
+ * @returns {(cid: string, options?: object) => Promise<Uint8Array>}
231
+ */
232
+ export function explainIfEncrypted(fetchBytes) {
233
+ return async function fetchAndCheck(cid, options) {
234
+ const bytes = await fetchBytes(cid, options);
235
+ if (isEncrypted(bytes)) {
236
+ throw new BackendError(
237
+ "INVALID_BACKEND",
238
+ `The backup at ${cid} is encrypted, and no way to decrypt it was given. ` +
239
+ "Pass `decrypt` to hydrate() — a browser derives it from the security key.",
240
+ );
241
+ }
242
+ return bytes;
243
+ };
244
+ }
245
+
246
+ export default withEncryption;
package/lib/dehydrate.js CHANGED
@@ -61,15 +61,33 @@ export async function dehydrate({
61
61
  seed,
62
62
  label,
63
63
  backend,
64
+ encrypt,
65
+ decrypt,
66
+ dontEncrypt = false,
64
67
  endpoints = DEFAULT_ENDPOINTS,
65
68
  sequence,
66
69
  backup = {},
67
70
  }) {
68
71
  if (!address) throw new Error("dehydrate needs the database address");
72
+
73
+ // Encrypted by default. A caller who wants the backup readable by anyone
74
+ // holding the CID has to write that down, because it is a decision rather
75
+ // than an oversight — and an oversight is what this refusal exists to catch.
76
+ if (!encrypt && !dontEncrypt) {
77
+ throw new Error(
78
+ "Backups are encrypted by default. Pass `encrypt` and `decrypt` — a browser " +
79
+ "derives them from the security key — or `dontEncrypt: true` if this backup " +
80
+ "is meant to be readable by anyone holding the CID.",
81
+ );
82
+ }
83
+
69
84
  const { backupDatabaseCAR } = await import("./backup-car.js");
85
+ const storeIn = encrypt
86
+ ? (await import("./backends/encryption.js")).withEncryption(backend, { encrypt, decrypt })
87
+ : backend;
70
88
 
71
89
  const result = await backupDatabaseCAR(orbitdb, address, {
72
- backend,
90
+ backend: storeIn,
73
91
  ...backup,
74
92
  });
75
93
  if (!result?.success) {
@@ -118,6 +136,7 @@ export async function hydrate({
118
136
  orbitdb,
119
137
  seed,
120
138
  label,
139
+ decrypt,
121
140
  endpoints = DEFAULT_ENDPOINTS,
122
141
  open = {},
123
142
  restore = {},
@@ -126,10 +145,22 @@ export async function hydrate({
126
145
  const pointer = await resolvePointer({ privateKey, endpoints });
127
146
 
128
147
  const { restoreFromCID } = await import("./restore-cid.js");
148
+ const { decryptingFetch, explainIfEncrypted } = await import("./backends/encryption.js");
149
+ const { fetchFromGateways } = await import("./gateway-fetch.js");
150
+
151
+ // A restore does not read through a backend — it fetches from a gateway or
152
+ // from peers — so decryption belongs here, around the fetcher. Without a
153
+ // key, an encrypted backup is still recognised, and says so.
154
+ const fetchBytes = restore.fetchBytes ?? fetchFromGateways;
155
+ const reader = decrypt
156
+ ? decryptingFetch(fetchBytes, { decrypt })
157
+ : explainIfEncrypted(fetchBytes);
158
+
129
159
  const result = await restoreFromCID(orbitdb, {
130
160
  metadataCID: pointer.cid,
131
161
  open,
132
162
  ...restore,
163
+ fetchBytes: reader,
133
164
  });
134
165
 
135
166
  logger.info(
package/lib/peer-fetch.js CHANGED
@@ -33,11 +33,30 @@
33
33
  *
34
34
  * ## What the caller has to bring
35
35
  *
36
- * A Helia with bitswap. This module has no libp2p of its own — the page that
37
- * restores already has a node, and a second one would be a second identity on
38
- * the network. Helia's browser defaults try to listen on `/webrtc` and
39
- * `/p2p-circuit` and **throw on start** when no transport serves them, so a
40
- * fetch-only node wants `addresses: { listen: [] }`.
36
+ * A Helia with bitswap **and identify**. This module has no libp2p of its own —
37
+ * the page that restores already has a node, and a second one would be a second
38
+ * identity on the network.
39
+ *
40
+ * Two things about that node are easy to get wrong, and both were:
41
+ *
42
+ * - **`identify` is not optional.** `withLibp2pLight` does not include it, and
43
+ * without it bitswap never learns that the peer it just dialled speaks
44
+ * bitswap: no want is sent, and the fetch fails with "Failed to load block"
45
+ * after the full timeout. Measured against Aleph: 60 s of nothing without
46
+ * identify, 1.6 s for 400 kB with it, over the same dialled connection.
47
+ * - **Helia's browser defaults try to listen** on `/webrtc` and `/p2p-circuit`
48
+ * and **throw on start** when no transport serves them, so a fetch-only node
49
+ * wants `addresses: { listen: [] }`.
50
+ *
51
+ * ```js
52
+ * const helia = await withBitswap(withLibp2pLight(createHeliaLight({ … }), {
53
+ * addresses: { listen: [] },
54
+ * transports: [webSockets(), webRTCDirect()],
55
+ * connectionEncrypters: [noise()],
56
+ * streamMuxers: [yamux()],
57
+ * services: { identify: identify() }, // ← without this, nothing arrives
58
+ * })).start()
59
+ * ```
41
60
  *
42
61
  * @module peer-fetch
43
62
  */
@@ -90,25 +109,25 @@ export const ALEPH_PEER_ID = "12D3KooWACE5dRw5V9WXuDTcngjE3ZaDSZ4qYJGfuhXZbENnL5
90
109
  /**
91
110
  * Routers that answer a page.
92
111
  *
93
- * `cid.contact` sends `access-control-allow-origin: *` for provider lookups;
94
- * `delegated-ipfs.dev` did not, measured 2026-09-23 — it does for `/routing/v1/ipns`,
95
- * which is why the pointer lookup can use it and this cannot.
112
+ * Both send `access-control-allow-origin: *` for provider lookups. An earlier
113
+ * version of this file said `delegated-ipfs.dev` did not, from one request that
114
+ * came back without the header; repeated from a deployed page it answers with
115
+ * it, so the restriction was wrong and this list is both.
96
116
  *
97
- * They also know different things, which matters more than the CORS header:
117
+ * What is true, and matters more, is that they know **different things**:
98
118
  * `cid.contact` is an IPNI index and knows what is announced to it — Pinata's
99
- * CIDs and Lighthouse's were there — while a CID that Aleph holds appeared
100
- * only at `delegated-ipfs.dev`, because Aleph announces over the DHT. So from
101
- * a page, a lookup finds the two paid services and not Aleph; that is what
102
- * {@link ALEPH_BITSWAP} is for.
119
+ * CIDs and Lighthouse's were there — while a CID that Aleph holds appeared only
120
+ * at `delegated-ipfs.dev`, because Aleph announces over the DHT. Asking one
121
+ * router is asking half the network.
103
122
  */
104
- export const DEFAULT_ROUTERS = Object.freeze(["https://cid.contact"]);
105
-
106
- /** Everything a Node caller can ask, where CORS does not apply. */
107
- export const ALL_ROUTERS = Object.freeze([
123
+ export const DEFAULT_ROUTERS = Object.freeze([
108
124
  "https://cid.contact",
109
125
  "https://delegated-ipfs.dev",
110
126
  ]);
111
127
 
128
+ /** @deprecated Same as {@link DEFAULT_ROUTERS}; kept so an import still works. */
129
+ export const ALL_ROUTERS = DEFAULT_ROUTERS;
130
+
112
131
  /**
113
132
  * Transports a browser can dial.
114
133
  *
@@ -266,16 +285,23 @@ export function createPeerFetch({
266
285
  return helia.libp2p.dial(multiaddr(addr), { signal });
267
286
  });
268
287
 
269
- let dialed = 0;
288
+ // One connection per peer, not one per address. A provider usually
289
+ // advertises the same peer several times — Aleph offers webrtc-direct and
290
+ // webtransport — and dialling both gets two connections to one node, which
291
+ // buys nothing and makes a peer count read double.
292
+ const reached = new Set();
270
293
  for (const addr of addrs) {
294
+ const peer = addr.match(/\/p2p\/([^/]+)/)?.[1] ?? addr;
295
+ if (reached.has(peer)) continue;
271
296
  try {
272
297
  await dialOne(addr);
273
- dialed += 1;
298
+ reached.add(peer);
274
299
  log.debug(` ✅ dialled ${addr}`);
275
300
  } catch (error) {
276
301
  log.debug(` ⚠️ could not dial ${addr}: ${error.message}`);
277
302
  }
278
303
  }
304
+ const dialed = reached.size;
279
305
  if (dialed === 0) {
280
306
  throw new Error(`Could not dial any provider for ${cid} (tried ${addrs.length})`);
281
307
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-storage-bridge",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Back up, restore and replicate OrbitDB databases through pluggable storage backends, with hash and identity preservation",
5
5
  "main": "lib/orbitdb-storacha-bridge.js",
6
6
  "svelte": "dist/components/",
@@ -25,6 +25,7 @@
25
25
  "./backends/pinata": "./lib/backends/pinata.js",
26
26
  "./backends/lighthouse": "./lib/backends/lighthouse.js",
27
27
  "./backends/mirror": "./lib/backends/mirror.js",
28
+ "./backends/encryption": "./lib/backends/encryption.js",
28
29
  "./backends/choose": "./lib/backends/choose.js",
29
30
  "./backends/resolve": "./lib/backends/resolve.js",
30
31
  "./memory-courier": "./lib/memory-courier.js",