Skip to content

Commit f7da864

Browse files
Simplify Interface
1 parent 82a0b0c commit f7da864

10 files changed

Lines changed: 272 additions & 71 deletions

File tree

.github/workflows/release.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77

88
permissions:
99
contents: write
10+
id-token: write
1011

1112
jobs:
1213
publish:
@@ -27,7 +28,7 @@ jobs:
2728

2829
- run: pnpm run build
2930

30-
- run: pnpm publish --no-git-checks
31+
- run: pnpm publish --no-git-checks --provenance
3132
env:
3233
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
3334

README.md

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@
44
[![CI](https://github.com/davidmuggleton/pglite-encrypted-fs/actions/workflows/ci.yml/badge.svg)](https://github.com/davidmuggleton/pglite-encrypted-fs/actions/workflows/ci.yml)
55
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
66

7-
Transparent AES-256-GCM page-level encryption for PGlite PostgreSQL databases.
7+
An encrypted virtual filesystem for [PGlite](https://pglite.dev/). Provides transparent AES-256-GCM page-level encryption so your PGlite database files are encrypted at rest.
8+
9+
> **Status:** Alpha. The on-disk format is not yet versioned and may change before 1.0.
10+
11+
## Why this exists
12+
13+
[PGlite](https://pglite.dev/) gives you a full embedded PostgreSQL in Node.js -- SQL, indexes, transactions, extensions like pgvector, all without a server. But out of the box, database files sit **plaintext on disk**.
14+
15+
This package is an encrypted VFS that plugs into PGlite's filesystem layer, encrypting every page as it's written and decrypting as it's read. Your PGlite code stays the same -- you just pass an `EncryptedFS` instance at creation.
816

917
## Features
1018

1119
- **AES-256-GCM authenticated encryption** -- page-level encryption with integrity verification on every read
12-
- **PBKDF2-SHA512 key derivation** -- 256K iterations, compliant with OWASP recommendations
20+
- **PBKDF2-SHA512 key derivation** -- 256K iterations, aligned with [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) guidance
1321
- **Near-zero read overhead** -- decrypted pages are cached in PostgreSQL's buffer pool; subsequent reads hit the cache
1422
- **AAD binding prevents page swapping/replay attacks** -- each page is bound to its file identity and position
1523
- **Passphrase verification on reopen** -- a wrong key is detected immediately, before any data is served
16-
- **Works with PGlite extensions** -- pgvector, and any other extension supported by PGlite
24+
- **Transparent to PGlite extensions** -- pgvector and other extensions work normally on top of the encrypted VFS
1725

1826
## Install
1927

@@ -34,59 +42,71 @@ yarn add pglite-encrypted-fs @electric-sql/pglite
3442

3543
```typescript
3644
import { PGlite } from '@electric-sql/pglite'
37-
import { EncryptedFS, deriveKeys, randomSalt } from 'pglite-encrypted-fs'
45+
import { EncryptedFS } from 'pglite-encrypted-fs'
3846

3947
const dataDir = './my-encrypted-db'
40-
const passphrase = 'my-secret-passphrase'
41-
42-
// First time: generate a new salt and derive keys
43-
const salt = randomSalt()
44-
const keys = deriveKeys(passphrase, salt)
45-
46-
// Create the encrypted filesystem and pass it to PGlite
47-
const fs = new EncryptedFS(dataDir, keys, salt)
48+
const fs = new EncryptedFS(dataDir, 'my-secret-passphrase')
4849
const db = await PGlite.create({ dataDir, fs })
4950

50-
// Use it like a normal PGlite database
5151
await db.exec('CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)')
5252
await db.exec("INSERT INTO users (name) VALUES ('Alice')")
5353
const result = await db.query('SELECT * FROM users')
5454
console.log(result.rows) // [{ id: 1, name: 'Alice' }]
5555

5656
await db.close()
57-
58-
// IMPORTANT: Store the salt alongside your database path.
59-
// You'll need it to reopen the database later.
6057
```
6158

6259
## Reopening an Existing Database
6360

64-
The salt is required to reopen a database. Store it alongside your database path -- for example, in a config file or as a hex-encoded string in your application's settings.
61+
Use the same passphrase -- the salt is stored automatically.
6562

6663
```typescript
67-
// To reopen, use the same salt and passphrase
68-
const keys = deriveKeys(passphrase, savedSalt)
69-
const fs = new EncryptedFS(dataDir, keys, savedSalt)
64+
const fs = new EncryptedFS(dataDir, 'my-secret-passphrase')
7065
const db = await PGlite.create({ dataDir, fs })
7166
// Your data is still there, decrypted transparently
7267
```
7368

74-
If the passphrase is wrong, the `EncryptedFS` constructor throws immediately with `"Invalid passphrase or corrupted encryption keys"`.
69+
If the passphrase is wrong, the constructor throws immediately with `"Invalid passphrase or corrupted encryption keys"`.
70+
71+
## pgvector Example
72+
73+
PGlite extensions work normally on top of the encrypted VFS. Here's pgvector:
74+
75+
```typescript
76+
import { PGlite } from '@electric-sql/pglite'
77+
import { vector } from '@electric-sql/pglite/vector'
78+
import { EncryptedFS } from 'pglite-encrypted-fs'
79+
80+
const dataDir = './my-encrypted-vectors'
81+
const fs = new EncryptedFS(dataDir, process.env.DB_PASSPHRASE!)
82+
const db = await PGlite.create({ dataDir, fs, extensions: { vector } })
83+
84+
await db.exec('CREATE EXTENSION IF NOT EXISTS vector')
85+
await db.exec('CREATE TABLE docs (id serial PRIMARY KEY, embedding vector(3))')
86+
await db.exec("INSERT INTO docs (embedding) VALUES ('[0.1, 0.2, 0.3]')")
87+
88+
const { rows } = await db.query(
89+
"SELECT * FROM docs ORDER BY embedding <-> '[0.1, 0.2, 0.3]' LIMIT 5"
90+
)
91+
console.log(rows)
92+
93+
await db.close()
94+
fs.destroy()
95+
```
7596

7697
## API Reference
7798

78-
### `EncryptedFS(dataDir, keys, salt, options?)`
99+
### `new EncryptedFS(dataDir, passphrase, options?)`
79100

80101
Creates an encrypted filesystem instance.
81102

82103
| Parameter | Type | Description |
83104
|-----------|------|-------------|
84105
| `dataDir` | `string` | Path to the database directory on disk |
85-
| `keys` | `DerivedKeys` | Encryption keys returned by `deriveKeys()` |
86-
| `salt` | `Buffer` | The 16-byte salt used during key derivation |
106+
| `passphrase` | `string` | Your encryption passphrase |
87107
| `options` | `{ debug?: boolean }` | Optional. Enable debug logging with `{ debug: true }` |
88108

89-
The constructor creates the data directory if it does not exist, and verifies the passphrase against an existing database (or creates the verification token for a new one).
109+
The constructor creates the data directory if it does not exist. On first use, it generates a random salt and creates a verification token. On subsequent opens, it reads the salt from the existing verification token and verifies the passphrase is correct.
90110

91111
### `deriveKeys(passphrase, salt)`
92112

@@ -158,7 +178,7 @@ Each file receives a random 32-byte identifier stored in its header. Because fil
158178

159179
### Passphrase Verification
160180

161-
On first initialization, a `.encryption-verify` file is created containing a known magic value encrypted with the derived key. On every subsequent open, this file is decrypted and checked. A wrong passphrase fails immediately rather than silently serving corrupted data.
181+
On first initialization, a `.encryption-verify` file is created containing the 16-byte salt followed by a known magic value encrypted with the derived key. On every subsequent open, the salt is read from this file, the key is re-derived, and the magic value is decrypted and checked. A wrong passphrase fails immediately rather than silently serving corrupted data.
162182

163183
### Unencrypted Files
164184

@@ -172,6 +192,17 @@ The following PostgreSQL metadata files are left unencrypted because they contai
172192
- `.lock` files
173193
- `replorigin_checkpoint`
174194

195+
## Threat Model
196+
197+
This provides **at-rest encryption** of PGlite/PostgreSQL database files on disk. It protects against offline theft or unauthorized access to the stored files.
198+
199+
**Non-goals:**
200+
201+
- Does not protect against an attacker who can run code in your process
202+
- Data is decrypted in memory during query execution (like any database encryption-at-rest)
203+
- JavaScript runtimes cannot guarantee secure key erasure (`destroy()` is best-effort)
204+
- This package has not been independently audited. If you find a vulnerability, please report it privately via GitHub.
205+
175206
## Performance
176207

177208
Benchmarks measured on Node.js (see `pnpm run bench` for your own results):
@@ -190,7 +221,7 @@ Benchmarks measured on Node.js (see `pnpm run bench` for your own results):
190221
| Single page decrypt | -- | 3.9us | -- |
191222
| Key derivation (PBKDF2) | -- | 48ms | -- |
192223

193-
Read operations have near-zero overhead because data is decrypted when pages are loaded into PostgreSQL's buffer pool. Subsequent reads hit the cache. Write overhead comes from per-page encryption. Run benchmarks yourself with `pnpm run bench`.
224+
Read operations have near-zero overhead because data is decrypted when pages are loaded into PostgreSQL's buffer pool. Subsequent reads hit the cache, so reads are only slow the first time a page is loaded. Write overhead comes from per-page encryption. Run benchmarks yourself with `pnpm run bench`.
194225

195226
## Platform Support
196227

@@ -205,6 +236,20 @@ Read operations have near-zero overhead because data is decrypted when pages are
205236

206237
This package uses Node.js `crypto` and `fs` modules and is not compatible with browser environments.
207238

239+
## FAQ
240+
241+
**Can I rotate the passphrase?**
242+
243+
Not in-place. Export your data with `pg_dump` (or application-level export), create a new encrypted database with the new passphrase, and re-import.
244+
245+
**Can I migrate an existing plaintext PGlite database?**
246+
247+
Same approach -- dump and re-import into a new encrypted database.
248+
249+
**Why not SQLCipher?**
250+
251+
SQLCipher encrypts **SQLite** databases. This package encrypts **PGlite/PostgreSQL** databases. If you're already using PGlite for its PostgreSQL features (extensions, SQL semantics, pgvector), this adds at-rest encryption without changing databases.
252+
208253
## Contributing
209254

210255
```bash

bench/helpers/bench-utils.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export interface BenchPair {
1212
encDb: PGlite
1313
plainDir: string
1414
encDir: string
15-
encSalt: Buffer
1615
cleanup: () => Promise<void>
1716
}
1817

@@ -32,7 +31,7 @@ export async function createBenchPair(
3231
...(extensions ? { extensions } : {}),
3332
})
3433

35-
const { db: encDb, salt: encSalt } = await createEncryptedPGlite(
34+
const { db: encDb } = await createEncryptedPGlite(
3635
encDir,
3736
'bench-passphrase',
3837
extensions,
@@ -50,7 +49,7 @@ export async function createBenchPair(
5049
cleanupTestDir(encDir)
5150
}
5251

53-
return { plainDb, encDb, plainDir, encDir, encSalt, cleanup }
52+
return { plainDb, encDb, plainDir, encDir, cleanup }
5453
}
5554

5655
/**

bench/startup.bench.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, bench } from 'vitest'
22
import { PGlite } from '@electric-sql/pglite'
3+
import { EncryptedFS } from '../src/index.js'
34
import { createTestDir, createEncryptedPGlite } from './helpers/bench-utils.js'
4-
import { reopenEncryptedPGlite } from '../test/helpers/test-utils.js'
55

66
describe('fresh init', () => {
77
bench(
@@ -28,7 +28,6 @@ describe('fresh init', () => {
2828
describe('reopen', () => {
2929
let plainDir: string
3030
let encDir: string
31-
let encSalt: Buffer
3231

3332
bench(
3433
'plain - reopen',
@@ -49,20 +48,11 @@ describe('reopen', () => {
4948
async () => {
5049
if (!encDir) {
5150
encDir = createTestDir()
52-
const { salt } = await createEncryptedPGlite(encDir, 'bench-passphrase')
53-
encSalt = salt
54-
const { db } = await reopenEncryptedPGlite(
55-
encDir,
56-
encSalt,
57-
'bench-passphrase',
58-
)
51+
const { db } = await createEncryptedPGlite(encDir, 'bench-passphrase')
5952
await db.close()
6053
}
61-
const { db } = await reopenEncryptedPGlite(
62-
encDir,
63-
encSalt,
64-
'bench-passphrase',
65-
)
54+
const encFs = new EncryptedFS(encDir, 'bench-passphrase')
55+
const db = await PGlite.create({ dataDir: encDir, fs: encFs })
6656
await db.close()
6757
},
6858
{ iterations: 3, warmupIterations: 1, time: 0 },

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pglite-encrypted-fs",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Encrypted filesystem for PGlite — AES-256-GCM page-level encryption for PostgreSQL databases",
55
"type": "module",
66
"main": "./dist/index.js",

src/encrypted-fs.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
type DerivedKeys,
1919
encryptPage,
2020
decryptPage,
21+
deriveKeys,
22+
randomSalt,
2123
VERIFICATION_MAGIC,
2224
VERIFICATION_FILE_ID,
2325
} from './crypto.js'
@@ -156,30 +158,43 @@ interface OpenFile {
156158
fileId: Buffer
157159
}
158160

161+
export interface EncryptedFSOptions {
162+
debug?: boolean
163+
}
164+
159165
export class EncryptedFS extends BaseFilesystem {
160-
private readonly keys: DerivedKeys
161-
private readonly salt: Buffer
166+
private readonly keys!: DerivedKeys
167+
private readonly salt!: Buffer
162168
private openFiles: Map<number, OpenFile> = new Map()
163169
private cwd: string = '/'
164170
private nextFd: number = 100
165171

166172
private destroyed = false
167173

168-
constructor(
169-
dataDir: string,
170-
keys: DerivedKeys,
171-
salt: Buffer,
172-
options?: { debug?: boolean },
173-
) {
174+
constructor(dataDir: string, passphrase: string, options?: EncryptedFSOptions) {
174175
super(dataDir, options)
175-
this.keys = keys
176-
this.salt = salt
177176

178177
const baseDir = this.dataDir ?? dataDir
179178
if (!fs.existsSync(baseDir)) {
180179
fs.mkdirSync(baseDir, { recursive: true })
181180
}
182181

182+
const tokenPath = path.join(baseDir, '.encryption-verify')
183+
if (fs.existsSync(tokenPath)) {
184+
const header = Buffer.alloc(SALT_SIZE)
185+
const fd = fs.openSync(tokenPath, 'r')
186+
try {
187+
fs.readSync(fd, header, 0, SALT_SIZE, 0)
188+
} finally {
189+
fs.closeSync(fd)
190+
}
191+
this.salt = header
192+
} else {
193+
this.salt = randomSalt()
194+
}
195+
196+
this.keys = deriveKeys(passphrase, this.salt)
197+
183198
this.verifyOrCreateToken(baseDir)
184199

185200
if (this.debug) {
@@ -211,17 +226,24 @@ export class EncryptedFS extends BaseFilesystem {
211226
const magic = Buffer.alloc(PAGE_SIZE)
212227
VERIFICATION_MAGIC.copy(magic)
213228
const encrypted = encryptPage(magic, 0, this.keys, VERIFICATION_FILE_ID)
214-
fs.writeFileSync(tokenPath, encrypted)
229+
fs.writeFileSync(tokenPath, Buffer.concat([this.salt, encrypted]))
215230
return
216231
}
217232

218233
const data = fs.readFileSync(tokenPath)
219-
if (data.length !== ENCRYPTED_PAGE_SIZE) {
234+
if (data.length !== SALT_SIZE + ENCRYPTED_PAGE_SIZE) {
220235
throw new Error('Invalid passphrase or corrupted encryption keys')
221236
}
222237

238+
const encryptedPage = data.subarray(SALT_SIZE)
239+
223240
try {
224-
const decrypted = decryptPage(data, 0, this.keys, VERIFICATION_FILE_ID)
241+
const decrypted = decryptPage(
242+
encryptedPage,
243+
0,
244+
this.keys,
245+
VERIFICATION_FILE_ID,
246+
)
225247
if (
226248
!decrypted
227249
.subarray(0, VERIFICATION_MAGIC.length)

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { EncryptedFS } from './encrypted-fs.js'
1+
export { EncryptedFS, type EncryptedFSOptions } from './encrypted-fs.js'
22

33
export {
44
PAGE_SIZE,

0 commit comments

Comments
 (0)