Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,79 @@
# Changelog

## 1.2.1 — 2026-07-28

### Added

- **Client-side encrypted storage** — both the Kubo and S3 backends now support transparent AES-256-GCM encryption. Data is encrypted locally before it leaves the device; the IPFS network and storage provider only ever see the ciphertext.

**How it works — upload path:**
1. Caller passes `{ encrypt: { password, iterations? } }` to `upload()`.
2. A fresh 128-bit random salt and a fresh 96-bit random nonce are generated using `globalThis.crypto.getRandomValues` (CSPRNG — works on Node.js ≥ 20, browsers, and React Native).
3. A 256-bit AES key is derived from the password + salt using **PBKDF2-SHA256** with the configured iteration count (default: 200,000 — the OWASP 2023 minimum). Because salt and iteration count are random/configurable per call, the same password + plaintext always produces a different key.
4. The plaintext is encrypted with **AES-256-GCM**. GCM appends a 128-bit authentication tag that covers both the ciphertext and the associated header fields, so any bit-level tampering is detected at decryption time.
5. The output is assembled as an **EMSH** (Encrypted MeSHkit) blob with a self-describing 37-byte header:

```
┌────────┬─────────┬────────────┬──────────┬──────────┬──────────────────────┐
│ MAGIC │ VERSION │ ITERATIONS │ SALT │ NONCE │ CIPHERTEXT + TAG │
│ 4 B │ 1 B │ 4 B uint32 │ 16 B │ 12 B │ n + 16 B │
│ "EMSH" │ 0x01 │ big-endian │ random │ random │ AES-256-GCM output │
└────────┴─────────┴────────────┴──────────┴──────────┴──────────────────────┘
```

6. The EMSH blob is uploaded to IPFS (or the S3 bucket). Because salt and nonce are randomised per call, uploading the same plaintext twice yields two different CIDs — content is not linkable across uploads.

**How it works — retrieve path:**
1. Caller passes `{ password }` to `retrieve()`.
2. The raw bytes are fetched from IPFS / S3 by CID.
3. `isEncryptedPayload()` checks the 4-byte EMSH magic prefix and the version byte. If they match, decryption proceeds; otherwise the raw bytes are returned as-is (plain-content round-trips are unaffected).
4. The iteration count, salt, and nonce are read directly from the EMSH header — no out-of-band metadata is needed.
5. The AES-256 key is re-derived from the password + header salt + header iteration count via PBKDF2-SHA256.
6. AES-256-GCM decryption verifies the GCM authentication tag. Any wrong password or any ciphertext / header corruption causes decryption to throw a generic `MeshkitError` (`"Decryption failed: wrong password or corrupted data"`) — wrong-password and tampered-payload errors are intentionally indistinguishable to prevent oracle attacks.
7. The original plaintext bytes are returned to the caller.

- **New exports:**
- `encrypt(data, { password, iterations? }): Promise<Uint8Array>` — standalone encrypt; returns an EMSH payload
- `decrypt(data, password): Promise<Uint8Array>` — standalone decrypt; throws `MeshkitError` on wrong password or tampering
- `isEncryptedPayload(data): boolean` — structural check (magic + version bytes only; never touches the password)
- `DEFAULT_ITERATIONS` — `200_000`; re-exported constant for callers who want to reference the default explicitly
- `EncryptOptions` — TypeScript type: `{ password: string; iterations?: number }`

- **Encryption on `upload()` / `retrieve()`** — both `MeshkitClient` implementations (`createMeshkitClient` for Kubo and `createS3Client` / `createFilOneClient` for S3) now accept:
- `upload(data, { encrypt: { password, iterations? } })` — encrypts before upload
- `retrieve(cid, { password })` — decrypts after fetch; omitting `password` returns the raw EMSH bytes

- **MCP server encryption tools** (`@ipfs-meshkit/mcp`) — `ipfs_upload` now accepts optional `password` and `pbkdf2Iterations` parameters; `ipfs_retrieve` now accepts optional `password`. AI agents can store and retrieve encrypted content without the plaintext ever reaching the IPFS network.

- **Crypto dependencies** — `@noble/ciphers@2.2.0` (AES-256-GCM) and `@noble/hashes@2.2.0` (PBKDF2-SHA256 + SHA-256) added as runtime dependencies. Both are from the `@noble` suite and covered by the published [Cure53 security audit](https://cure53.de/pentest-report_noble-crypto.pdf).

### Security

- Resolved **7 CVEs** across transitive dependencies (0 remaining):
- `brace-expansion` ≤5.0.7 — DoS via unbounded expansion (HIGH, CVSS 7.5) — fixed by upgrading `vitest` + `@vitest/coverage-v8` to v4
- `brace-expansion` ≥2.0.0 <2.1.2 — DoS via exponential expansion (HIGH, CVSS 5.3) — bumped to 2.1.2
- `fast-uri` 3.0.0–3.1.3 — host confusion via backslash (HIGH, CVSS 7.5) — bumped to 3.1.4
- `postcss` ≤8.5.17 — path traversal in source map loading (HIGH, CVSS 7.5) — bumped to 8.5.23
- `shell-quote` ≤1.8.4 — quadratic-complexity DoS (HIGH, CVSS 7.5) — bumped to 1.10.0
- `@hono/node-server` <2.0.5 — path traversal via encoded backslash (MODERATE, CVSS 5.9) — bumped to 2.0.12
- `esbuild` 0.27.3–0.28.0 — arbitrary file read via dev server (LOW, CVSS 2.5) — resolved via `overrides`
- Added **iteration count guards** to `crypto.ts`:
- `encrypt()` now rejects `iterations < 1_000` — catches accidental typos that would produce dangerously weak key derivation
- `decrypt()` now rejects payloads whose header declares `iterations > 10_000_000` — prevents a crafted EMSH blob from hanging a server with a multi-hour PBKDF2 run

### Changed

- `@vitest/coverage-v8` and `vitest` upgraded from v3 to v4.1.10
- `zod` upgraded from 3.x to 4.4.3 in `@ipfs-meshkit/mcp` (`@modelcontextprotocol/sdk` supports `^3.25 || ^4.0`)
- `@types/node` upgraded from `^25` to `^26.1.2` across all packages
- `multiformats` floor bumped to `^14.0.5`
- `@modelcontextprotocol/sdk` floor bumped to `^1.30.0`
- `@ipfs-meshkit/meshkit` dependency in `@ipfs-meshkit/mcp` changed from pinned registry version `1.0.2` to workspace `^1.2.0`

### Fixed

- Integration test phase 1 passed `localNode: true, ...localNodeOptions` to `init()` which caused the daemon to start on the default port 5001 instead of the test port 15005; changed to `localNode: localNodeOptions` (object form) so the port is correctly forwarded to `startIPFSNode()`

## 1.2.0 — 2026-07-22

### Added
Expand Down
150 changes: 149 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# IPFS Meshkit0
# IPFS Meshkit

**@ipfs-meshkit/meshkit** is a TypeScript SDK for decentralized storage with two backends and one unified interface:

Expand All @@ -7,6 +7,8 @@

Both backends implement the same `MeshkitClient` interface (`upload`, `retrieve`, `pin`, `list`, `listPins`, …) so you can swap backends without changing application logic.

**Built-in client-side encryption** — any upload can be transparently encrypted with AES-256-GCM before it leaves the device. The IPFS network and the storage provider only ever see the ciphertext.

[![test](https://github.com/IPFS-Meshkit/meshkit0/actions/workflows/test.yml/badge.svg)](https://github.com/IPFS-Meshkit/meshkit0/actions/workflows/test.yml)
[![npm version](https://img.shields.io/npm/v/@ipfs-meshkit/meshkit.svg)](https://www.npmjs.com/package/@ipfs-meshkit/meshkit)
[![license](https://img.shields.io/npm/l/@ipfs-meshkit/meshkit.svg)](https://github.com/IPFS-Meshkit/meshkit0/blob/main/LICENSE)
Expand Down Expand Up @@ -98,6 +100,132 @@ const objects = await client.list();

---

## Quick start — Encrypted storage

Encryption works on **both backends** with a single option. The content is encrypted client-side before upload and decrypted client-side after retrieval — the IPFS network and storage provider never see the plaintext.

```typescript
import { init, isEncryptedPayload } from '@ipfs-meshkit/meshkit';

const { meshkit } = await init({ localNode: true });
const PASSWORD = 'correct-horse-battery-staple';

// Encrypt on upload
const data = new TextEncoder().encode('confidential manifest data');
const cid = await meshkit.upload(data, {
encrypt: { password: PASSWORD },
});

// Retrieve + decrypt in one call
const plaintext = await meshkit.retrieve(cid, { password: PASSWORD });
console.log(new TextDecoder().decode(plaintext)); // confidential manifest data

// Retrieve without password — you get the raw encrypted blob
const raw = await meshkit.retrieve(cid);
console.log(isEncryptedPayload(raw)); // true

// Wrong password throws MeshkitError
await meshkit.retrieve(cid, { password: 'wrong' }); // throws
```

The S3 path works identically:

```typescript
import { createFilOneClient } from '@ipfs-meshkit/meshkit';

const client = createFilOneClient({ /* credentials */ });

const cid = await client.upload(data, {
encrypt: { password: PASSWORD },
});
const plaintext = await client.retrieve(cid, { password: PASSWORD });
```

### How encryption works

**Upload path**

```
password + plaintext
├── CSPRNG ──────────────────────► 128-bit salt (fresh every call)
│ 96-bit nonce (fresh every call)
├── PBKDF2-SHA256(password, salt, iterations)
│ └──────────────────────► 256-bit AES key
└── AES-256-GCM(key, nonce, plaintext)
└──────────────────────► ciphertext + 128-bit auth tag
packed as EMSH blob
upload to IPFS / S3 ──► CID
```

**Retrieve path**

```
CID ──► fetch raw bytes from IPFS / S3
├── isEncryptedPayload()? ──► no ──► return as-is
└── yes: read iterations, salt, nonce from EMSH header
├── PBKDF2-SHA256(password, salt, iterations)
│ └──────────────────────► 256-bit AES key
└── AES-256-GCM decrypt + verify auth tag
├── tag valid ──► plaintext
└── tag invalid ──► MeshkitError (wrong password or tampered)
```

**EMSH wire format** — every encrypted payload starts with a 37-byte self-describing header:

| Offset | Size | Field | Value |
|--------|------|-------|-------|
| 0 | 4 B | Magic | `EMSH` (`0x45 0x4D 0x53 0x48`) — identifies encrypted content |
| 4 | 1 B | Version | `0x01` |
| 5 | 4 B | Iterations | PBKDF2 iteration count, uint32 big-endian |
| 9 | 16 B | Salt | Random, unique per `encrypt()` call |
| 25 | 12 B | Nonce | Random, unique per `encrypt()` call |
| 37 | n + 16 B | Ciphertext + Tag | AES-256-GCM output; last 16 bytes are the authentication tag |

Because salt and nonce are random per call, uploading the same plaintext twice produces two different CIDs — content is not linkable across uploads.

**Algorithm properties**

| Property | Value |
|---|---|
| Cipher | AES-256-GCM — authenticated; any bit-flip in the ciphertext or header is detected |
| Key derivation | PBKDF2-SHA256 |
| Default iterations | 200,000 (OWASP 2023 minimum for PBKDF2-SHA256) |
| Iteration floor | 1,000 — `encrypt()` rejects lower values (typo guard) |
| Iteration ceiling | 10,000,000 — `decrypt()` rejects higher values in the header (DoS guard) |
| Dependencies | `@noble/ciphers` + `@noble/hashes` — [Cure53-audited](https://cure53.de/pentest-report_noble-crypto.pdf) |

```typescript
// Custom iteration count (higher = more brute-force resistant, slower)
const cid = await meshkit.upload(data, {
encrypt: { password: PASSWORD, iterations: 600_000 },
});
```

### Standalone encrypt / decrypt

If you need to encrypt bytes outside of an upload/retrieve flow:

```typescript
import { encrypt, decrypt, isEncryptedPayload, DEFAULT_ITERATIONS } from '@ipfs-meshkit/meshkit';

const payload = await encrypt(data, { password: PASSWORD });
console.log(isEncryptedPayload(payload)); // true

const recovered = await decrypt(payload, PASSWORD);
```

---

## Usage

### Local Kubo daemon (recommended for Node.js servers)
Expand Down Expand Up @@ -345,6 +473,26 @@ const { init } = require('@ipfs-meshkit/meshkit');
| `toIpfsPath(cid)` / `toIpnsPath(name)` | Normalize to `/ipfs/` or `/ipns/` path |
| `MeshkitError` / `MeshkitNodeError` | Error classes |

### Encryption

| Export | Purpose |
|---|---|
| `encrypt(data, { password, iterations? })` | Encrypt bytes with AES-256-GCM / PBKDF2-SHA256; returns EMSH payload |
| `decrypt(data, password)` | Decrypt an EMSH payload; throws `MeshkitError` on wrong password or tampering |
| `isEncryptedPayload(data)` | Returns `true` if `data` starts with the EMSH magic bytes |
| `DEFAULT_ITERATIONS` | `200_000` — OWASP 2023 minimum for PBKDF2-SHA256 |
| `EncryptOptions` | Type: `{ password: string; iterations?: number }` (iterations ≥ 1,000) |

Both `upload()` and `retrieve()` accept inline encryption options so you do not need to call `encrypt` / `decrypt` directly in most cases:

```typescript
// Upload option
meshkit.upload(bytes, { encrypt: { password, iterations? } })

// Retrieve option
meshkit.retrieve(cid, { password })
```

TypeScript types are included — no `@types/` package needed:

```typescript
Expand Down
17 changes: 16 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,22 @@

| Version | Supported |
| ------- | --------- |
| 1.0.x | Yes |
| 1.2.x | Yes |
| 1.1.x | No |
| 1.0.x | No |

## Encryption security properties

All encrypted payloads use the **EMSH** (Encrypted MeSHkit) wire format:

| Property | Detail |
|---|---|
| Cipher | AES-256-GCM — authenticated encryption, detects any bit-level tampering |
| Key derivation | PBKDF2-SHA256, default 200,000 iterations (OWASP 2023 minimum) |
| Salt | 128-bit random, unique per `encrypt()` call |
| Nonce | 96-bit random, unique per `encrypt()` call |
| Iteration bounds | Minimum 1,000 on encrypt; maximum 10,000,000 on decrypt (DoS guard) |
| Dependencies | `@noble/ciphers` and `@noble/hashes` — [Cure53-audited](https://cure53.de/pentest-report_noble-crypto.pdf) |

## Reporting a vulnerability

Expand Down
Loading
Loading