Encryption
Encrypt files before they reach the network, then share them the way Alice shares with Bob.
Meshkit encrypts on the client before upload. Kubo, gateways, and any S3-compatible provider only store ciphertext. Decryption happens after retrieve, on the machine that knows the password.
Encryption uses AES-256-GCM. The key comes from the password with PBKDF2-SHA256 (200,000 iterations by default). The same options work on the Kubo client from init() and on S3 clients from createS3Client / createFilOneClient.
Alice and Bob share a file
Alice has a file. Bob should be able to read it. Everyone else on the IPFS network should only see an opaque blob.
They agree on a password out of band — in person, or over a channel that is not the upload itself. The password is never written into the IPFS object.
Alice's machine IPFS network Bob's machine
───────────────── ──────────── ──────────────
file + password
│
├─ AES-256-GCM encrypt
│
└─ upload ciphertext ──────────► EMSH blob (CID)
anyone can fetch it ──────► retrieve(cid)
│
└─ decrypt with
the same password
→ original fileAlice — encrypt and publish
Alice reads the file, encrypts it as part of upload, and pins the CID so her node keeps the ciphertext available.
import { readFile } from 'node:fs/promises';
import { init, setupGracefulShutdown } from '@ipfs-meshkit/meshkit';
// Agreed with Bob separately. Do not put this string in the uploaded file.
const PASSWORD = process.env.SHARE_PASSWORD!;
const { meshkit, localNode } = await init({ localNode: true });
setupGracefulShutdown(localNode);
const file = await readFile('./report.pdf');
// The CID identifies the encrypted blob, not the original PDF.
const cid = await meshkit.upload(file, {
encrypt: { password: PASSWORD },
});
await meshkit.pin(cid);
console.log('Send this CID to Bob:', cid);Alice sends Bob two things:
| What | Where it goes |
|---|---|
| The CID | Anywhere. It can be public. It only names the ciphertext on the network. |
| The password | A private channel. It is not stored in the IPFS object. |
Bob — fetch from the network and decrypt
Bob runs his own node (or any Kubo node that can resolve the CID) and passes the same password. retrieve downloads the ciphertext, checks the authentication tag, and returns the original bytes.
import { writeFile } from 'node:fs/promises';
import { init, setupGracefulShutdown } from '@ipfs-meshkit/meshkit';
const PASSWORD = process.env.SHARE_PASSWORD!;
const cid = process.argv[2]; // the CID Alice published
if (!cid) {
throw new Error('Usage: npx tsx bob.ts <cid>');
}
const { meshkit, localNode } = await init({ localNode: true });
setupGracefulShutdown(localNode);
const file = await meshkit.retrieve(cid, { password: PASSWORD });
await writeFile('./report.pdf', file);
console.log('Wrote report.pdf');Run this on Bob's machine, not in the same process as Alice. He does not need Alice's RPC URL. He needs the CID, the password, and a Kubo node that can fetch that CID from the network. Alice's pin is what keeps her node seeding the ciphertext.
On a single machine, a second localNode: true attaches to the daemon Alice already started. That still decrypts, but both calls share one node. Use two machines, or give Bob his own Kubo API URL, when you want the fetch to cross the network.
What everyone else gets
A third party who only has the CID can still download the object. Without the password they get the encrypted payload, not the PDF:
import { isEncryptedPayload } from '@ipfs-meshkit/meshkit';
const raw = await meshkit.retrieve(cid); // no password
console.log(isEncryptedPayload(raw)); // trueA wrong password throws MeshkitError. The message does not say whether the password was wrong or the bytes were tampered with:
import { MeshkitError } from '@ipfs-meshkit/meshkit';
try {
await meshkit.retrieve(cid, { password: 'wrong' });
} catch (err) {
if (err instanceof MeshkitError) {
console.error(err.message);
// Decryption failed: wrong password or corrupted data
}
}Uploading the same file twice with the same password produces two different CIDs. Each call uses a fresh salt and nonce, so observers cannot tell that the two objects came from the same plaintext.
Upload and retrieve options
import { init, isEncryptedPayload } from '@ipfs-meshkit/meshkit';
const { meshkit } = await init({ localNode: true });
const PASSWORD = 'correct-horse-battery-staple';
const data = new TextEncoder().encode('confidential manifest data');
const cid = await meshkit.upload(data, {
encrypt: { password: PASSWORD },
});
const plaintext = await meshkit.retrieve(cid, { password: PASSWORD });
console.log(new TextDecoder().decode(plaintext)); // confidential manifest data
const raw = await meshkit.retrieve(cid);
console.log(isEncryptedPayload(raw)); // trueRaise the iteration count when you want more resistance to offline guessing. Higher values make encrypt and decrypt slower:
const cid = await meshkit.upload(data, {
encrypt: { password: PASSWORD, iterations: 600_000 },
});iterations must be an integer ≥ 1,000. The default is 200,000 (DEFAULT_ITERATIONS), the OWASP 2023 minimum for PBKDF2-SHA256. decrypt rejects headers that claim more than 10,000,000 iterations so a crafted blob cannot stall the process.
The S3 path uses the same options:
import { createFilOneClient } from '@ipfs-meshkit/meshkit';
const client = createFilOneClient({
accessKeyId: process.env.FIL_ACCESS_KEY!,
secretAccessKey: process.env.FIL_SECRET_KEY!,
bucket: 'shared-files',
});
const cid = await client.upload(data, {
encrypt: { password: PASSWORD },
});
const plaintext = await client.retrieve(cid, { password: PASSWORD });On the S3 path the CID is a content-addressed object key in your bucket. It is not published to the IPFS DHT. Store the CID yourself and send that string to Bob, the same way Alice sends an IPFS CID.
How the bytes are packed
Every encrypted object starts with a 37-byte EMSH (Encrypted MeSHkit) header:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 B | Magic | EMSH (0x45 0x4D 0x53 0x48) |
| 4 | 1 B | Version | 0x01 |
| 5 | 4 B | Iterations | PBKDF2 count, uint32 big-endian |
| 9 | 16 B | Salt | Random, unique per encrypt() |
| 25 | 12 B | Nonce | Random, unique per encrypt() |
| 37 | n + 16 B | Ciphertext + tag | AES-256-GCM output; the last 16 bytes are the auth tag |
AES-256-GCM is authenticated. A flipped bit in the ciphertext or in the salt, nonce, or iteration fields fails decryption. Key derivation uses @noble/hashes and @noble/ciphers.
isEncryptedPayload() only checks the magic bytes and version. It does not try the password.
Standalone encrypt and decrypt
Use these when you want the EMSH bytes before choosing where to store them:
import {
encrypt,
decrypt,
isEncryptedPayload,
DEFAULT_ITERATIONS,
} from '@ipfs-meshkit/meshkit';
const payload = await encrypt(data, { password: PASSWORD });
console.log(isEncryptedPayload(payload)); // true
console.log(DEFAULT_ITERATIONS); // 200000
const recovered = await decrypt(payload, PASSWORD);upload(..., { encrypt }) and retrieve(..., { password }) call these for you. Most apps do not need the standalone functions.
Limits
- This is a shared password, not a per-recipient public key. Anyone who has the password can decrypt. To stop Bob later, encrypt a new copy under a new password and stop sharing the old one.
- Losing the password means the ciphertext cannot be recovered. Meshkit does not escrow keys.
- Pin the CID if you are on Kubo. Encryption does not pin by itself.
- IPNS still points at a CID. Publish the CID of the encrypted blob if the name should resolve to ciphertext.
From an AI agent
@ipfs-meshkit/mcp exposes the same flow as tools. Alice's agent calls ipfs_upload with password. Bob's agent calls ipfs_retrieve with that CID and the same password. Inputs and outputs are in the MCP tools reference.