Encryption & Security
Does the SDK keep or hold any secrets, including wallet secrets?
Some secret material is held in memory for as long as the SDK instance is alive, but none of it is persisted to disk or on-chain:
- Encryption adapter secrets (passwords, mnemonics/seeds, keypairs) are held by their adapter.
MnemonicEncryptionAdapterzeroes the raw seed from the JS heap immediately after construction;KeypairEncryptionAdapterholds its secret key until cleared. MidnightChainAdapter's wallet reference — including the raw wallet seed used to derive keys — is held in-memory by the SDK.ownerSecret(used to prove on-chain ownership) is never stored at all — it's re-derived on demand from the DEK andstorageIdeach time it's needed.
Call sdk.destroy() to wipe all in-memory secret material at once. See Should I call sdk.destroy() when I'm done, and why? for the full breakdown of what gets cleared, and What is ownerSecret and why is it not stored? for detail on that value specifically.
What encryption scheme does the SDK use?
XChaCha20-Poly1305 with a 24-byte random nonce. Its 192-bit nonce space pushes the birthday bound to 2⁸⁰, making random nonce collision a non-issue in practice.
What is the DEK/KEK model?
A fresh random Data Encryption Key (DEK) is generated per upload. The DEK is then wrapped (encrypted) under each adapter's Key Encryption Key (KEK) and stored in the keyEnvelope field on-chain. To decrypt, the SDK retrieves the key envelope, uses one of the registered adapters to unwrap the DEK, then decrypts the payload.
What encryption adapters are available?
| Adapter | Key source | Post-quantum at key layer | Cross-device portable |
|---|---|---|---|
PasswordEncryptionAdapter | scrypt(password, salt) | Conditional on password entropy | Yes — same password + salt on any device |
MnemonicEncryptionAdapter | BIP-39 (24-word) or 64-byte hex seed | 128-bit PQ (unconditional) | Yes — same mnemonic on any device |
KeypairEncryptionAdapter | ML-KEM768 public/secret keypair | 192-bit PQ for a random generateKeypair(); ~128-bit when derived via fromPassword() / fromMnemonic() (bounded by the derivation input's Grover ceiling) | Requires SK; re-derivable via fromPassword() / fromMnemonic() |
WebAuthnPrfEncryptionAdapter | WebAuthn PRF passkey (wraps an internal MnemonicEncryptionAdapter + KeypairEncryptionAdapter) | 128-bit PQ (unconditional — same tier as MnemonicEncryptionAdapter) | Depends on platform passkey sync (e.g. iCloud Keychain, Google Password Manager) — not portable for a device-bound hardware key |
Why does MnemonicEncryptionAdapter require 24 words?
A 12-word BIP-39 mnemonic provides only 128 bits of entropy, which Grover's algorithm halves to 64-bit post-quantum security — below NIST's 128-bit minimum. The adapter rejects anything shorter than 24 words at construction time. It also validates the full BIP-39 checksum, not just word-count and wordlist membership, so a single mistyped word that still happens to be a real wordlist entry is rejected instead of silently deriving a different key.
Can multiple adapters decrypt the same upload?
Yes. Supply an array to encryptionAdapters. The SDK wraps the DEK under every adapter's KEK independently. Any single registered adapter can decrypt any upload made while it was in the list. This is useful for adding a recovery key or shared access:
encryptionAdapters: [
new PasswordEncryptionAdapter({ password, salt }), // primary
new MnemonicEncryptionAdapter({ mnemonic: backupPhrase }), // recovery
];What happens if I lose my encryption key / password?
Data is permanently unrecoverable. The SDK stores no key material — keys are derived deterministically from the adapter's inputs each time. There is no key escrow or recovery mechanism. Follow standard backup practices for whichever secret your adapter uses (password + salt, BIP-39 mnemonic, or SK bytes).
Is PasswordEncryptionAdapter post-quantum safe?
At the data layer, yes — XChaCha20-Poly1305 with a 256-bit key gives 128-bit post-quantum security (NIST minimum). At the key encapsulation layer, it depends on password entropy. Human-chosen passwords cannot reach the 256-bit entropy required to resist Grover's. To reach the 128-bit (NIST-minimum) PQ level, use generateHighEntropyPassword() with KeypairEncryptionAdapter.fromPassword(), or switch to MnemonicEncryptionAdapter (24 words).
What is generateHighEntropyPassword()?
A helper that produces a cryptographically random 43-character base64url string (32 bytes = 256 bits of real entropy). Pass the result to KeypairEncryptionAdapter.fromPassword() to get 128-bit (NIST-minimum) PQ protection at the key layer with no secret key to store. Note the derived keypair is bounded at ~128-bit by the password's Grover ceiling, not ML-KEM-768's ~192-bit — for the full 192-bit use a random generateKeypair() and back up its secret key separately. Store the generated string — it is the only secret needed to re-derive the keypair.
What makes a password acceptable to PasswordEncryptionAdapter?
The adapter enforces four rules at construction time:
- Length — at least 12 characters (configurable via
minPasswordLength). - Character diversity — at least 3 of 4 character classes: uppercase, lowercase, digits, special characters.
- No sequential runs — patterns like
abcdeor12345are rejected. - No keyboard-walk patterns — sequences like
qwertyorasdfgare rejected. - Entropy — charset-based entropy estimate must reach 60 bits (configurable via
minEntropyBits).
If your use case requires bypassing these checks — for example, a machine-generated credential — use generateHighEntropyPassword() to obtain a cryptographically random 43-character base64url string (256 bits of real entropy), then pass it to KeypairEncryptionAdapter.fromPassword() for 128-bit (NIST-minimum) PQ protection at the key layer.
How does the SDK prevent swapping chunks or blobs in transit?
Each ciphertext is bound to its role via Additional Authenticated Data (AAD). A single-shot (non-chunked) upload uses the payload label; once a file is large enough to trigger chunking (see Does dStorage support large files?), the manifest and chunk labels come into play instead. The storage pointer label applies to every upload either way. Labels used:
| Label | Protects |
|---|---|
dstorage:payload:v1 | Single-shot (non-chunked) uploads |
dstorage:manifest:v1 | Chunked upload manifest (large files only) |
dstorage:chunk:v1:<i>/<n> | Each chunk bound to its index and total (large files only) |
dstorage:storageId:v1 | Storage pointer encrypted on-chain |
A reordered chunk, a substituted manifest, or a ciphertext moved from one role to another causes MAC verification to fail before any data is returned.
How does the SDK verify that an Arweave gateway hasn't tampered with the retrieved data?
Every Arweave transaction includes an X-Content-Hash tag containing the BLAKE3 hex digest of the uploaded bytes (ciphertext for private uploads, plaintext for public ones). On retrieval, ArweaveStorageAdapter fetches the transaction metadata, recomputes the hash, and throws [dStorage/arweave] Content hash mismatch if they differ.
When retrieval goes through @dstorage/core (the normal path via retrieveByRefId()/retrieveByStorageId()), the hash it verifies against is anchored to the on-chain reference rather than the gateway's own tag — so the check holds even against a gateway that controls both the returned bytes and its X-Content-Hash tag and could otherwise make them consistent with each other. Using an Arweave adapter directly, without going through core, falls back to trusting the gateway-supplied tag alone.
For private uploads this is defence-in-depth on top of XChaCha20-Poly1305 AEAD authentication. For public uploads it is the primary integrity guarantee.
To opt out when using a fully trusted private gateway, pass skipIntegrityCheck: true to ArweaveStorageAdapter.
Is the storage pointer on-chain encrypted?
Yes. The storageId (the storage network's content address — currently an Arweave transaction ID) is encrypted with the same per-upload DEK using a fresh nonce, then stored in the DataRegistry contract. A blockchain observer cannot learn which storage resource belongs to which wallet even though the on-chain record is public.
What is the on-chain content hash and what does it protect against?
When a reference is written (via store() or registerReference()), the SDK computes a BLAKE3 hash of the ciphertext bytes — the exact bytes as written to the storage network — and stores it in the DataRegistry contract alongside the encrypted storage pointer. On retrieveByRefId(), the SDK re-downloads those bytes, recomputes the hash, and compares it to the on-chain value before attempting decryption.
This catches:
- A compromised or manipulated storage gateway returning bytes that have been altered since upload.
- Any divergence between what was committed to the chain and what the storage network currently serves.
For private uploads, AEAD authentication (XChaCha20-Poly1305) provides a second independent tamper check during decryption. For public uploads (no encryption), the on-chain hash is the primary integrity guarantee beyond the storage adapter's own tag.
If the check fails, retrieveByStorageId() throws a DStorageError with code CONTENT_HASH_MISMATCH before any decryption is attempted. retrieveByRefId() delegates to retrieveByStorageId(), passing the hash from the on-chain reference — so the check fires automatically on every retrieveByRefId() call when a hash is present. You can also call retrieveByStorageId(storageId, dekScheme, knownHash) directly to perform the same check without going through the chain adapter.
Is the on-chain content hash computed from the plaintext or the ciphertext?
Always the ciphertext — the bytes exactly as stored on the storage network. For private uploads this is the encrypted blob; because a fresh random nonce is generated per upload, the ciphertext is non-deterministic even for identical plaintext, so the on-chain hash reveals nothing about content. For public uploads (isPublic: true) the ciphertext equals the plaintext, which is acceptable since the data is world-readable by design.
For chunked uploads, the hash covers the manifest ciphertext (the small encrypted JSON at the storageId pointer), not the concatenated chunks. Individual chunks are already independently protected by their AEAD authentication tags and position-binding AAD.
How does the on-chain hash differ from the X-Content-Hash Arweave tag?
X-Content-Hash (Arweave tag) | On-chain hash (DataRegistry) | |
|---|---|---|
| Where stored | Arweave transaction metadata | Midnight DataRegistry contract |
| Who verifies | Storage adapter (ArweaveStorageAdapter) | Core SDK (retrieveByStorageId, called by retrieveByRefId) |
| Applies to | Arweave adapters only | All storage adapters |
| Skippable | Yes — skipIntegrityCheck: true | No — always checked when present |
| Independent of chain | Yes | Anchored to the on-chain reference |
Both checks run on retrieveByRefId() with Arweave adapters, providing two independent commitments. The on-chain hash covers storage adapters that do not implement their own tag (Mock, future adapters).
What is ownerSecret and why is it not stored?
ownerSecret is an HKDF derivative of the DEK and the raw storageId. It is needed to remove or update a reference on-chain. Because it is derived deterministically from values the SDK already holds, it never needs to be persisted — it is recomputed on demand during removeReference() and update(). update() re-derives the old ownerSecret to prove ownership of the existing reference, and derives a new one (from the fresh per-upload DEK) to register alongside the updated content — both in the same atomic call.
What exactly changes when I call rotateKeys(), and what stays the same?
rotateKeys(refId) re-wraps an existing reference's per-upload DEK under the encryption adapters currently configured on the SDK instance, then writes the new keyEnvelope back on-chain — without re-uploading or re-encrypting the content itself. It's the mechanism behind adding/removing a recovery key and changing a password (see How do I rotate encryption adapters without re-uploading content? in the Developers section).
| Field | Changes? |
|---|---|
keyEnvelope on-chain | Yes — re-wrapped under the new adapter set |
Encrypted storageId on-chain | No |
writtenAt on-chain | No — preserved from the original upload |
encryptionScheme | No |
| Ciphertext on the storage network | No — bytes are never re-uploaded |
ownerSecret / ownership commitment | No — derived from the same DEK + storageId, so identical |
Because the DEK itself is unchanged, any party who previously obtained the DEK retains the ability to decrypt until the corresponding adapter wrapper is pruned from the envelope (see the two-step password-change flow in the Developers section).
If I call update(), what happens to the original content on the storage network?
Nothing — it stays there. update() only changes the on-chain pointer (the storageId field in the DataRegistry reference) to point at the newly encrypted blob. The old blob remains at its original storageId on the storage network; whether it can be garbage-collected depends on the storage provider (Arweave uploads, for example, are permanent and immutable).
Today the SDK doesn't track old storageIds once update() overwrites the on-chain pointer, so recovering a prior version means having saved its storageId yourself beforehand. Native content versioning — the SDK keeping a history of prior storageIds per reference and exposing an API to list or retrieve them — is a possible future addition, not something the SDK supports today.
- Anyone who saved the old
storageIdcan still download the old ciphertext — but without the oldkeyEnvelopethey cannot decrypt it, as the key envelope on-chain now covers the new content's DEK. update()is best suited for mutable application state (user profiles, document drafts) where preserving old versions is not required.