@graphorin/store-sqlite-encrypted 0.6.0 → 0.7.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.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -3
- package/dist/encrypt.d.ts +21 -1
- package/dist/encrypt.d.ts.map +1 -1
- package/dist/encrypt.js +42 -1
- package/dist/encrypt.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/package.json +6 -6
- package/src/cipher-config.ts +80 -0
- package/src/cipher-peer.ts +77 -0
- package/src/connection.ts +49 -0
- package/src/encrypt.ts +277 -0
- package/src/index.ts +68 -0
- package/src/integrity-check.ts +62 -0
- package/src/rekey.ts +130 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @graphorin/store-sqlite-encrypted
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#154](https://github.com/o-stepper/graphorin/pull/154) [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04) Thanks [@o-stepper](https://github.com/o-stepper)! - W-012: `encryptDatabase({ swap: true })` now probes for a live writer BEFORE touching the source and refuses with the new typed `EncryptSwapLiveWriterError` when another connection holds the database. The swap path renames the source file; a running server would keep writing into the renamed `.bak.<ts>` inode, silently diverging from the encrypted copy until `storage cleanup-backups` deletes those writes. The probe (a journal-mode switch, which sqlite3mc refuses with "database is locked" under any other open connection) restores WAL in `finally`; the probe-to-rename window remains a documented best-effort residual. CLI `--swap` help and the storage guide now state the stopped-server requirement.
|
|
8
|
+
|
|
9
|
+
- [#154](https://github.com/o-stepper/graphorin/pull/154) [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04) Thanks [@o-stepper](https://github.com/o-stepper)! - W-072: every export map's `import` condition becomes `default`, and the Node floor rises to `>=22.12.0`.
|
|
10
|
+
|
|
11
|
+
CJS consumers previously hit a bewildering `ERR_PACKAGE_PATH_NOT_EXPORTED` instead of a clear ESM-only signal. With the `default` condition, plain `require('@graphorin/core')` works via Node's stable `require(esm)` - which shipped in 22.12, hence the engines bump across every workspace manifest (packages, examples, benchmarks, docs; enforced by the widened mvp-readiness sweep). No dual-instance hazard: there is no CJS build, `require()` returns the same ESM module instance. ESM consumers are unaffected (`default` serves both paths; `types` stays first). The pack gate now runs attw under the full `node16` profile (was `esm-only`) and adds a runtime `require(esm)` smoke against the packed tarballs. Installs on Node 22.0-22.11 with `engine-strict` will refuse - upgrade Node (see the migration guide).
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- [#162](https://github.com/o-stepper/graphorin/pull/162) [`73b19ca`](https://github.com/o-stepper/graphorin/commit/73b19caeda388bda628a48138cb7d70b1db839a3) Thanks [@o-stepper](https://github.com/o-stepper)! - Remove phantom workspace dependencies that no source file imports: agent no longer depends on provider and observability, mcp/workflow/server no longer depend on observability, sessions no longer depends on security (and its memory edge moves to devDependencies where the single test import lives), skills no longer depends on tools. Dead tsdown `external` entries for the removed edges are gone too, so a future import can no longer build as external without a declared dependency. Consumer install graphs shrink accordingly; a new repo-wide `check-phantom-deps` CI gate keeps the manifest graph honest from here on.
|
|
16
|
+
|
|
17
|
+
- [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Tarballs now ship `src/` so the published `dist/**/*.d.ts.map` files actually work (W-136): the maps referenced `../src/*.ts` that the `files` whitelist excluded, so go-to-definition fell back into `.d.ts` and the shipped maps were dead weight. The pack gate gains a `map-integrity` leg: every source referenced by a shipped map must resolve inside the tarball (or be embedded via `sourcesContent`), with an anti-vacuous guard - a package whose tsdown config emits declaration maps must contain a non-zero number of `.d.ts.map` files, so a cache-restored dist that silently dropped maps fails the gate instead of passing vacuously. `mvp-readiness` now requires `src` in every publishable `files` array.
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [[`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534), [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`73b19ca`](https://github.com/o-stepper/graphorin/commit/73b19caeda388bda628a48138cb7d70b1db839a3), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00)]:
|
|
20
|
+
- @graphorin/store-sqlite@0.7.0
|
|
21
|
+
|
|
22
|
+
## 0.6.1
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- [#142](https://github.com/o-stepper/graphorin/pull/142) [`436d6ca`](https://github.com/o-stepper/graphorin/commit/436d6ca5ebbd16df094e915682d3915c279a8430) Thanks [@o-stepper](https://github.com/o-stepper)! - Version constants and version-bearing strings now derive from each package's manifest at build time (`VERSION = pkg.version`; writer ids, client/server info, OTLP framework attributes, build-info metrics interpolate it). No behavioral change at the current version: the rendered strings are byte-identical. A release bump no longer edits source; the new `check-version-consistency` gate fails any reintroduced hardcoded framework version.
|
|
27
|
+
|
|
28
|
+
- Updated dependencies [[`436d6ca`](https://github.com/o-stepper/graphorin/commit/436d6ca5ebbd16df094e915682d3915c279a8430)]:
|
|
29
|
+
- @graphorin/store-sqlite@0.6.1
|
|
30
|
+
|
|
3
31
|
## 0.6.0
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
> encrypt / rekey / integrity-check runners that back the `graphorin storage`
|
|
8
8
|
> CLI subcommand group.
|
|
9
9
|
>
|
|
10
|
-
> Project Graphorin · v0.
|
|
10
|
+
> Project Graphorin · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko ·
|
|
11
11
|
> <https://github.com/o-stepper/graphorin>
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
## Status
|
|
16
16
|
|
|
17
|
-
- **Published:** v0.
|
|
17
|
+
- **Published:** v0.7.0 (optional sub-pack)
|
|
18
18
|
- **Default cipher:** `sqlcipher` (SQLCipher v4 compatible, `legacy=4`)
|
|
19
19
|
- **Defaults:** encryption-at-rest is **OFF by default**. Opt in through
|
|
20
20
|
`graphorin init --encrypted`.
|
|
@@ -159,4 +159,4 @@ MIT © 2026 Oleksiy Stepurenko
|
|
|
159
159
|
|
|
160
160
|
---
|
|
161
161
|
|
|
162
|
-
**Project Graphorin** · v0.
|
|
162
|
+
**Project Graphorin** · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
|
package/dist/encrypt.d.ts
CHANGED
|
@@ -21,6 +21,14 @@ interface EncryptDatabaseOptions {
|
|
|
21
21
|
* integrity check passes. The original `sourcePath` is renamed to
|
|
22
22
|
* `${sourcePath}.bak.${timestamp}` so an operator can recover.
|
|
23
23
|
* Default `false` - the CLI does the swap explicitly.
|
|
24
|
+
*
|
|
25
|
+
* REQUIRES A STOPPED SERVER (W-012): a live writer keeps its file
|
|
26
|
+
* descriptor on the renamed `.bak` inode and every post-snapshot
|
|
27
|
+
* commit silently diverges from the new encrypted file (and is later
|
|
28
|
+
* deleted by `storage cleanup-backups`). A best-effort live-writer
|
|
29
|
+
* probe refuses the swap with {@link EncryptSwapLiveWriterError}
|
|
30
|
+
* when another connection holds the database; the probe-to-rename
|
|
31
|
+
* window remains a documented residual race.
|
|
24
32
|
*/
|
|
25
33
|
readonly swap?: boolean;
|
|
26
34
|
/**
|
|
@@ -54,7 +62,19 @@ interface EncryptDatabaseResult {
|
|
|
54
62
|
*
|
|
55
63
|
* @stable
|
|
56
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* Thrown when `encryptDatabase({ swap: true })` detects another live
|
|
67
|
+
* connection on the source database (W-012). The swap path renames the
|
|
68
|
+
* source file; a live writer would keep writing into the renamed
|
|
69
|
+
* `.bak.<ts>` inode and silently diverge from the encrypted copy.
|
|
70
|
+
*
|
|
71
|
+
* @stable
|
|
72
|
+
*/
|
|
73
|
+
declare class EncryptSwapLiveWriterError extends Error {
|
|
74
|
+
readonly sourcePath: string;
|
|
75
|
+
constructor(sourcePath: string, cause: unknown);
|
|
76
|
+
}
|
|
57
77
|
declare function encryptDatabase(options: EncryptDatabaseOptions): Promise<EncryptDatabaseResult>;
|
|
58
78
|
//#endregion
|
|
59
|
-
export { EncryptDatabaseOptions, EncryptDatabaseResult, encryptDatabase };
|
|
79
|
+
export { EncryptDatabaseOptions, EncryptDatabaseResult, EncryptSwapLiveWriterError, encryptDatabase };
|
|
60
80
|
//# sourceMappingURL=encrypt.d.ts.map
|
package/dist/encrypt.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"encrypt.d.ts","names":[],"sources":["../src/encrypt.ts"],"sourcesContent":[],"mappings":";;;;;;;;;UAuCiB,sBAAA;;;;;;gCAMe;;oBAEZ
|
|
1
|
+
{"version":3,"file":"encrypt.d.ts","names":[],"sources":["../src/encrypt.ts"],"sourcesContent":[],"mappings":";;;;;;;;;UAuCiB,sBAAA;;;;;;gCAMe;;oBAEZ;;;;;;;;;;;;;;;;;;;;;;;;;;;UA4BH,qBAAA;;;mBAGE;;;mBAC+C;;;;;;;;;;;;;;;;;;;;;;cAoBrD,0BAAA,SAAmC,KAAA;;;;iBAc1B,eAAA,UACX,yBACR,QAAQ"}
|
package/dist/encrypt.js
CHANGED
|
@@ -34,6 +34,22 @@ import { isAbsolute, resolve } from "node:path";
|
|
|
34
34
|
*
|
|
35
35
|
* @stable
|
|
36
36
|
*/
|
|
37
|
+
/**
|
|
38
|
+
* Thrown when `encryptDatabase({ swap: true })` detects another live
|
|
39
|
+
* connection on the source database (W-012). The swap path renames the
|
|
40
|
+
* source file; a live writer would keep writing into the renamed
|
|
41
|
+
* `.bak.<ts>` inode and silently diverge from the encrypted copy.
|
|
42
|
+
*
|
|
43
|
+
* @stable
|
|
44
|
+
*/
|
|
45
|
+
var EncryptSwapLiveWriterError = class extends Error {
|
|
46
|
+
sourcePath;
|
|
47
|
+
constructor(sourcePath, cause) {
|
|
48
|
+
super(`[graphorin/store-sqlite-encrypted] ${sourcePath} appears to be open by another process; stop the server before running storage encrypt with --swap (writes committed after the snapshot would land in the renamed .bak file and be lost).`, { cause });
|
|
49
|
+
this.name = "EncryptSwapLiveWriterError";
|
|
50
|
+
this.sourcePath = sourcePath;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
37
53
|
async function encryptDatabase(options) {
|
|
38
54
|
const sourcePath = absolute(options.sourcePath);
|
|
39
55
|
const targetPath = absolute(options.targetPath);
|
|
@@ -45,6 +61,24 @@ async function encryptDatabase(options) {
|
|
|
45
61
|
unlinkSync(targetPath);
|
|
46
62
|
}
|
|
47
63
|
const Ctor = await loadCipherPeer();
|
|
64
|
+
if (options.swap === true) {
|
|
65
|
+
const probe = new Ctor(sourcePath);
|
|
66
|
+
let switched = false;
|
|
67
|
+
try {
|
|
68
|
+
try {
|
|
69
|
+
probe.pragma("journal_mode = DELETE");
|
|
70
|
+
switched = true;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (isBusyError(err)) throw new EncryptSwapLiveWriterError(sourcePath, err);
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
if (switched) try {
|
|
77
|
+
probe.pragma("journal_mode = WAL");
|
|
78
|
+
} catch {}
|
|
79
|
+
if (probe.open) probe.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
48
82
|
try {
|
|
49
83
|
const source = new Ctor(sourcePath, { readonly: true });
|
|
50
84
|
try {
|
|
@@ -112,7 +146,14 @@ async function encryptDatabase(options) {
|
|
|
112
146
|
function absolute(p) {
|
|
113
147
|
return isAbsolute(p) ? p : resolve(p);
|
|
114
148
|
}
|
|
149
|
+
function isBusyError(err) {
|
|
150
|
+
if (typeof err !== "object" || err === null) return false;
|
|
151
|
+
const code = err.code;
|
|
152
|
+
if (typeof code === "string" && code.includes("SQLITE_BUSY")) return true;
|
|
153
|
+
const message = err.message;
|
|
154
|
+
return typeof message === "string" && /database is locked|busy/i.test(message);
|
|
155
|
+
}
|
|
115
156
|
|
|
116
157
|
//#endregion
|
|
117
|
-
export { encryptDatabase };
|
|
158
|
+
export { EncryptSwapLiveWriterError, encryptDatabase };
|
|
118
159
|
//# sourceMappingURL=encrypt.js.map
|
package/dist/encrypt.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"encrypt.js","names":["integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> }","swap: { readonly originalRenamedTo: string } | undefined"],"sources":["../src/encrypt.ts"],"sourcesContent":["/**\n * `encryptDatabase` - converts an unencrypted SQLite database file\n * into an encrypted one. Backs `graphorin storage encrypt` per\n * ADR-030 § 8.\n *\n * Strategy (CS-7): sqlite3mc ships **no** `sqlcipher_export` function\n * (verified against the real peer - the old ATTACH+export path threw\n * \"no such function\" on every real run), so the export is a\n * **checkpoint → file copy → in-place `PRAGMA rekey`** sequence:\n *\n * 1. open the plaintext source, `wal_checkpoint(TRUNCATE)`, close;\n * 2. byte-copy the file to the target (this trivially preserves every\n * rowid, so FTS5 external-content mappings stay intact - CS-10);\n * 3. open the copy through the cipher peer with NO key, apply the\n * cipher-selection pragmas, then `PRAGMA rekey = <key>` - sqlite3mc\n * encrypts a plaintext database in place;\n * 4. re-open with the key and verify via `PRAGMA integrity_check`.\n *\n * @packageDocumentation\n */\n\nimport { existsSync, renameSync, unlinkSync } from 'node:fs';\nimport { isAbsolute, resolve } from 'node:path';\n\nimport {\n DEFAULT_CIPHER,\n type EncryptionCipher,\n encodePassphraseForPragma,\n pragmaSequenceForCipher,\n} from './cipher-config.js';\nimport { loadCipherPeer } from './cipher-peer.js';\nimport { createEncryptedConnection } from './connection.js';\nimport { cipherIntegrityCheck } from './integrity-check.js';\n\n/**\n * Options for {@link encryptDatabase}.\n *\n * @stable\n */\nexport interface EncryptDatabaseOptions {\n /** Path to the existing unencrypted source DB. */\n readonly sourcePath: string;\n /** Path the encrypted output is written to. Must not exist. */\n readonly targetPath: string;\n /** Passphrase for the new encrypted DB. */\n readonly passphrase: string | Buffer;\n /** Cipher selection. Default `'sqlcipher'` (SQLCipher v4 compatible). */\n readonly cipher?: EncryptionCipher;\n /**\n * If `true`, atomically rename `targetPath` -> `sourcePath` after the\n * integrity check passes. The original `sourcePath` is renamed to\n * `${sourcePath}.bak.${timestamp}` so an operator can recover.\n * Default `false` - the CLI does the swap explicitly.\n */\n readonly swap?: boolean;\n /**\n * If `true`, overwrite an existing `targetPath` instead of failing.\n * Default `false`.\n */\n readonly overwriteTarget?: boolean;\n}\n\n/**\n * Result of a successful {@link encryptDatabase} run.\n *\n * @stable\n */\nexport interface EncryptDatabaseResult {\n readonly sourcePath: string;\n readonly targetPath: string;\n readonly cipher: EncryptionCipher;\n readonly integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> };\n readonly swap?: { readonly originalRenamedTo: string };\n}\n\n/**\n * Encrypts an unencrypted SQLite database. Returns once the target\n * file has been written and verified. Throws if the source is missing,\n * the target already exists (and `overwriteTarget` is unset), the\n * cipher peer is missing, or the integrity check fails.\n *\n * @stable\n */\nexport async function encryptDatabase(\n options: EncryptDatabaseOptions,\n): Promise<EncryptDatabaseResult> {\n const sourcePath = absolute(options.sourcePath);\n const targetPath = absolute(options.targetPath);\n const cipher = options.cipher ?? DEFAULT_CIPHER;\n\n if (!existsSync(sourcePath)) {\n throw new Error(`[graphorin/store-sqlite-encrypted] source DB not found: ${sourcePath}`);\n }\n if (sourcePath === targetPath) {\n throw new Error(\n '[graphorin/store-sqlite-encrypted] sourcePath and targetPath must differ. ' +\n 'Pass a temporary targetPath then enable `swap: true` to atomically replace the source.',\n );\n }\n if (existsSync(targetPath)) {\n if (options.overwriteTarget !== true) {\n throw new Error(\n `[graphorin/store-sqlite-encrypted] target DB already exists: ${targetPath}. ` +\n 'Pass `overwriteTarget: true` to replace it.',\n );\n }\n unlinkSync(targetPath);\n }\n\n const Ctor = await loadCipherPeer();\n try {\n // 1+2. Online page-level copy via the driver's backup API\n // (store-05). The old checkpoint-close-then-copyFileSync left a\n // window in which a concurrent writer (a running daemon) could\n // commit WAL frames the byte-copy silently missed; `backup()`\n // is consistent under live writers, preserves rowids (FTS5\n // mappings survive), and includes WAL content.\n const source = new Ctor(sourcePath, { readonly: true });\n try {\n await source.backup(targetPath);\n } finally {\n if (source.open) source.close();\n }\n\n // 3. In-place conversion: cipher pragmas first, then `rekey` -\n // sqlite3mc encrypts a plaintext database in place.\n const target = new Ctor(targetPath);\n try {\n for (const pragma of pragmaSequenceForCipher(cipher)) {\n target.pragma(pragma);\n }\n // sqlite3mc refuses `rekey` in WAL journal mode (real-peer\n // verified) - drop to DELETE for the conversion, restore after.\n target.pragma('journal_mode = DELETE');\n const encodedKey = encodePassphraseForPragma(options.passphrase);\n target.pragma(`rekey = ${encodedKey}`);\n target.pragma('journal_mode = WAL');\n } finally {\n if (target.open) target.close();\n }\n } catch (err) {\n if (existsSync(targetPath)) {\n try {\n unlinkSync(targetPath);\n } catch {\n // best-effort cleanup\n }\n }\n throw new Error(\n `[graphorin/store-sqlite-encrypted] encryption failed: ${(err as Error).message}`,\n { cause: err },\n );\n }\n\n const verify = await createEncryptedConnection({\n path: targetPath,\n skipSqliteVec: true,\n disableWalHardening: true,\n encryption: {\n enabled: true,\n cipher,\n passphraseResolver: async () => options.passphrase,\n },\n });\n let integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> };\n try {\n const result = cipherIntegrityCheck(verify);\n integrityCheck = { ok: result.ok, rows: result.rows };\n } finally {\n verify.close();\n }\n\n if (!integrityCheck.ok) {\n if (existsSync(targetPath)) {\n try {\n unlinkSync(targetPath);\n } catch {\n // best-effort cleanup\n }\n }\n throw new Error(\n '[graphorin/store-sqlite-encrypted] post-encrypt integrity check failed: ' +\n integrityCheck.rows.join('; '),\n );\n }\n\n let swap: { readonly originalRenamedTo: string } | undefined;\n if (options.swap === true) {\n const ts = Date.now();\n const renamedTo = `${sourcePath}.bak.${ts}`;\n renameSync(sourcePath, renamedTo);\n renameSync(targetPath, sourcePath);\n swap = Object.freeze({ originalRenamedTo: renamedTo });\n }\n\n const out: EncryptDatabaseResult = Object.freeze({\n sourcePath,\n targetPath: swap !== undefined ? sourcePath : targetPath,\n cipher,\n integrityCheck: Object.freeze(integrityCheck),\n ...(swap !== undefined ? { swap } : {}),\n });\n return out;\n}\n\nfunction absolute(p: string): string {\n return isAbsolute(p) ? p : resolve(p);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmFA,eAAsB,gBACpB,SACgC;CAChC,MAAM,aAAa,SAAS,QAAQ,WAAW;CAC/C,MAAM,aAAa,SAAS,QAAQ,WAAW;CAC/C,MAAM,SAAS,QAAQ,UAAU;AAEjC,KAAI,CAAC,WAAW,WAAW,CACzB,OAAM,IAAI,MAAM,2DAA2D,aAAa;AAE1F,KAAI,eAAe,WACjB,OAAM,IAAI,MACR,mKAED;AAEH,KAAI,WAAW,WAAW,EAAE;AAC1B,MAAI,QAAQ,oBAAoB,KAC9B,OAAM,IAAI,MACR,gEAAgE,WAAW,iDAE5E;AAEH,aAAW,WAAW;;CAGxB,MAAM,OAAO,MAAM,gBAAgB;AACnC,KAAI;EAOF,MAAM,SAAS,IAAI,KAAK,YAAY,EAAE,UAAU,MAAM,CAAC;AACvD,MAAI;AACF,SAAM,OAAO,OAAO,WAAW;YACvB;AACR,OAAI,OAAO,KAAM,QAAO,OAAO;;EAKjC,MAAM,SAAS,IAAI,KAAK,WAAW;AACnC,MAAI;AACF,QAAK,MAAM,UAAU,wBAAwB,OAAO,CAClD,QAAO,OAAO,OAAO;AAIvB,UAAO,OAAO,wBAAwB;GACtC,MAAM,aAAa,0BAA0B,QAAQ,WAAW;AAChE,UAAO,OAAO,WAAW,aAAa;AACtC,UAAO,OAAO,qBAAqB;YAC3B;AACR,OAAI,OAAO,KAAM,QAAO,OAAO;;UAE1B,KAAK;AACZ,MAAI,WAAW,WAAW,CACxB,KAAI;AACF,cAAW,WAAW;UAChB;AAIV,QAAM,IAAI,MACR,yDAA0D,IAAc,WACxE,EAAE,OAAO,KAAK,CACf;;CAGH,MAAM,SAAS,MAAM,0BAA0B;EAC7C,MAAM;EACN,eAAe;EACf,qBAAqB;EACrB,YAAY;GACV,SAAS;GACT;GACA,oBAAoB,YAAY,QAAQ;GACzC;EACF,CAAC;CACF,IAAIA;AACJ,KAAI;EACF,MAAM,SAAS,qBAAqB,OAAO;AAC3C,mBAAiB;GAAE,IAAI,OAAO;GAAI,MAAM,OAAO;GAAM;WAC7C;AACR,SAAO,OAAO;;AAGhB,KAAI,CAAC,eAAe,IAAI;AACtB,MAAI,WAAW,WAAW,CACxB,KAAI;AACF,cAAW,WAAW;UAChB;AAIV,QAAM,IAAI,MACR,6EACE,eAAe,KAAK,KAAK,KAAK,CACjC;;CAGH,IAAIC;AACJ,KAAI,QAAQ,SAAS,MAAM;EAEzB,MAAM,YAAY,GAAG,WAAW,OADrB,KAAK,KAAK;AAErB,aAAW,YAAY,UAAU;AACjC,aAAW,YAAY,WAAW;AAClC,SAAO,OAAO,OAAO,EAAE,mBAAmB,WAAW,CAAC;;AAUxD,QAPmC,OAAO,OAAO;EAC/C;EACA,YAAY,SAAS,SAAY,aAAa;EAC9C;EACA,gBAAgB,OAAO,OAAO,eAAe;EAC7C,GAAI,SAAS,SAAY,EAAE,MAAM,GAAG,EAAE;EACvC,CAAC;;AAIJ,SAAS,SAAS,GAAmB;AACnC,QAAO,WAAW,EAAE,GAAG,IAAI,QAAQ,EAAE"}
|
|
1
|
+
{"version":3,"file":"encrypt.js","names":["integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> }","swap: { readonly originalRenamedTo: string } | undefined"],"sources":["../src/encrypt.ts"],"sourcesContent":["/**\n * `encryptDatabase` - converts an unencrypted SQLite database file\n * into an encrypted one. Backs `graphorin storage encrypt` per\n * ADR-030 § 8.\n *\n * Strategy (CS-7): sqlite3mc ships **no** `sqlcipher_export` function\n * (verified against the real peer - the old ATTACH+export path threw\n * \"no such function\" on every real run), so the export is a\n * **checkpoint → file copy → in-place `PRAGMA rekey`** sequence:\n *\n * 1. open the plaintext source, `wal_checkpoint(TRUNCATE)`, close;\n * 2. byte-copy the file to the target (this trivially preserves every\n * rowid, so FTS5 external-content mappings stay intact - CS-10);\n * 3. open the copy through the cipher peer with NO key, apply the\n * cipher-selection pragmas, then `PRAGMA rekey = <key>` - sqlite3mc\n * encrypts a plaintext database in place;\n * 4. re-open with the key and verify via `PRAGMA integrity_check`.\n *\n * @packageDocumentation\n */\n\nimport { existsSync, renameSync, unlinkSync } from 'node:fs';\nimport { isAbsolute, resolve } from 'node:path';\n\nimport {\n DEFAULT_CIPHER,\n type EncryptionCipher,\n encodePassphraseForPragma,\n pragmaSequenceForCipher,\n} from './cipher-config.js';\nimport { loadCipherPeer } from './cipher-peer.js';\nimport { createEncryptedConnection } from './connection.js';\nimport { cipherIntegrityCheck } from './integrity-check.js';\n\n/**\n * Options for {@link encryptDatabase}.\n *\n * @stable\n */\nexport interface EncryptDatabaseOptions {\n /** Path to the existing unencrypted source DB. */\n readonly sourcePath: string;\n /** Path the encrypted output is written to. Must not exist. */\n readonly targetPath: string;\n /** Passphrase for the new encrypted DB. */\n readonly passphrase: string | Buffer;\n /** Cipher selection. Default `'sqlcipher'` (SQLCipher v4 compatible). */\n readonly cipher?: EncryptionCipher;\n /**\n * If `true`, atomically rename `targetPath` -> `sourcePath` after the\n * integrity check passes. The original `sourcePath` is renamed to\n * `${sourcePath}.bak.${timestamp}` so an operator can recover.\n * Default `false` - the CLI does the swap explicitly.\n *\n * REQUIRES A STOPPED SERVER (W-012): a live writer keeps its file\n * descriptor on the renamed `.bak` inode and every post-snapshot\n * commit silently diverges from the new encrypted file (and is later\n * deleted by `storage cleanup-backups`). A best-effort live-writer\n * probe refuses the swap with {@link EncryptSwapLiveWriterError}\n * when another connection holds the database; the probe-to-rename\n * window remains a documented residual race.\n */\n readonly swap?: boolean;\n /**\n * If `true`, overwrite an existing `targetPath` instead of failing.\n * Default `false`.\n */\n readonly overwriteTarget?: boolean;\n}\n\n/**\n * Result of a successful {@link encryptDatabase} run.\n *\n * @stable\n */\nexport interface EncryptDatabaseResult {\n readonly sourcePath: string;\n readonly targetPath: string;\n readonly cipher: EncryptionCipher;\n readonly integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> };\n readonly swap?: { readonly originalRenamedTo: string };\n}\n\n/**\n * Encrypts an unencrypted SQLite database. Returns once the target\n * file has been written and verified. Throws if the source is missing,\n * the target already exists (and `overwriteTarget` is unset), the\n * cipher peer is missing, or the integrity check fails.\n *\n * @stable\n */\n/**\n * Thrown when `encryptDatabase({ swap: true })` detects another live\n * connection on the source database (W-012). The swap path renames the\n * source file; a live writer would keep writing into the renamed\n * `.bak.<ts>` inode and silently diverge from the encrypted copy.\n *\n * @stable\n */\nexport class EncryptSwapLiveWriterError extends Error {\n readonly sourcePath: string;\n constructor(sourcePath: string, cause: unknown) {\n super(\n `[graphorin/store-sqlite-encrypted] ${sourcePath} appears to be open by another process; ` +\n 'stop the server before running storage encrypt with --swap (writes committed after the ' +\n 'snapshot would land in the renamed .bak file and be lost).',\n { cause },\n );\n this.name = 'EncryptSwapLiveWriterError';\n this.sourcePath = sourcePath;\n }\n}\n\nexport async function encryptDatabase(\n options: EncryptDatabaseOptions,\n): Promise<EncryptDatabaseResult> {\n const sourcePath = absolute(options.sourcePath);\n const targetPath = absolute(options.targetPath);\n const cipher = options.cipher ?? DEFAULT_CIPHER;\n\n if (!existsSync(sourcePath)) {\n throw new Error(`[graphorin/store-sqlite-encrypted] source DB not found: ${sourcePath}`);\n }\n if (sourcePath === targetPath) {\n throw new Error(\n '[graphorin/store-sqlite-encrypted] sourcePath and targetPath must differ. ' +\n 'Pass a temporary targetPath then enable `swap: true` to atomically replace the source.',\n );\n }\n if (existsSync(targetPath)) {\n if (options.overwriteTarget !== true) {\n throw new Error(\n `[graphorin/store-sqlite-encrypted] target DB already exists: ${targetPath}. ` +\n 'Pass `overwriteTarget: true` to replace it.',\n );\n }\n unlinkSync(targetPath);\n }\n\n const Ctor = await loadCipherPeer();\n\n // W-012: --swap renames the source out from under any live writer,\n // so probe for one FIRST. Empirically (real sqlite3mc peer): a\n // journal-mode switch is refused with \"database is locked\" while any\n // other connection is open, making it a cheap liveness probe. The\n // mode is restored in `finally` so a crash mid-probe cannot leave the\n // file in DELETE mode. Best-effort: the probe-to-rename window stays.\n if (options.swap === true) {\n const probe = new Ctor(sourcePath);\n let switched = false;\n try {\n try {\n probe.pragma('journal_mode = DELETE');\n switched = true;\n } catch (err) {\n if (isBusyError(err)) throw new EncryptSwapLiveWriterError(sourcePath, err);\n throw err;\n }\n } finally {\n if (switched) {\n try {\n probe.pragma('journal_mode = WAL');\n } catch {\n // The WAL restore is best-effort; the conversion below sets\n // journal modes explicitly anyway.\n }\n }\n if (probe.open) probe.close();\n }\n }\n\n try {\n // 1+2. Online page-level copy via the driver's backup API\n // (store-05). The old checkpoint-close-then-copyFileSync left a\n // window in which a concurrent writer (a running daemon) could\n // commit WAL frames the byte-copy silently missed; `backup()`\n // is consistent under live writers, preserves rowids (FTS5\n // mappings survive), and includes WAL content.\n const source = new Ctor(sourcePath, { readonly: true });\n try {\n await source.backup(targetPath);\n } finally {\n if (source.open) source.close();\n }\n\n // 3. In-place conversion: cipher pragmas first, then `rekey` -\n // sqlite3mc encrypts a plaintext database in place.\n const target = new Ctor(targetPath);\n try {\n for (const pragma of pragmaSequenceForCipher(cipher)) {\n target.pragma(pragma);\n }\n // sqlite3mc refuses `rekey` in WAL journal mode (real-peer\n // verified) - drop to DELETE for the conversion, restore after.\n target.pragma('journal_mode = DELETE');\n const encodedKey = encodePassphraseForPragma(options.passphrase);\n target.pragma(`rekey = ${encodedKey}`);\n target.pragma('journal_mode = WAL');\n } finally {\n if (target.open) target.close();\n }\n } catch (err) {\n if (existsSync(targetPath)) {\n try {\n unlinkSync(targetPath);\n } catch {\n // best-effort cleanup\n }\n }\n throw new Error(\n `[graphorin/store-sqlite-encrypted] encryption failed: ${(err as Error).message}`,\n { cause: err },\n );\n }\n\n const verify = await createEncryptedConnection({\n path: targetPath,\n skipSqliteVec: true,\n disableWalHardening: true,\n encryption: {\n enabled: true,\n cipher,\n passphraseResolver: async () => options.passphrase,\n },\n });\n let integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> };\n try {\n const result = cipherIntegrityCheck(verify);\n integrityCheck = { ok: result.ok, rows: result.rows };\n } finally {\n verify.close();\n }\n\n if (!integrityCheck.ok) {\n if (existsSync(targetPath)) {\n try {\n unlinkSync(targetPath);\n } catch {\n // best-effort cleanup\n }\n }\n throw new Error(\n '[graphorin/store-sqlite-encrypted] post-encrypt integrity check failed: ' +\n integrityCheck.rows.join('; '),\n );\n }\n\n let swap: { readonly originalRenamedTo: string } | undefined;\n if (options.swap === true) {\n const ts = Date.now();\n const renamedTo = `${sourcePath}.bak.${ts}`;\n renameSync(sourcePath, renamedTo);\n renameSync(targetPath, sourcePath);\n swap = Object.freeze({ originalRenamedTo: renamedTo });\n }\n\n const out: EncryptDatabaseResult = Object.freeze({\n sourcePath,\n targetPath: swap !== undefined ? sourcePath : targetPath,\n cipher,\n integrityCheck: Object.freeze(integrityCheck),\n ...(swap !== undefined ? { swap } : {}),\n });\n return out;\n}\n\nfunction absolute(p: string): string {\n return isAbsolute(p) ? p : resolve(p);\n}\n\nfunction isBusyError(err: unknown): boolean {\n if (typeof err !== 'object' || err === null) return false;\n const code = (err as { code?: unknown }).code;\n if (typeof code === 'string' && code.includes('SQLITE_BUSY')) return true;\n const message = (err as { message?: unknown }).message;\n return typeof message === 'string' && /database is locked|busy/i.test(message);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmGA,IAAa,6BAAb,cAAgD,MAAM;CACpD,AAAS;CACT,YAAY,YAAoB,OAAgB;AAC9C,QACE,sCAAsC,WAAW,4LAGjD,EAAE,OAAO,CACV;AACD,OAAK,OAAO;AACZ,OAAK,aAAa;;;AAItB,eAAsB,gBACpB,SACgC;CAChC,MAAM,aAAa,SAAS,QAAQ,WAAW;CAC/C,MAAM,aAAa,SAAS,QAAQ,WAAW;CAC/C,MAAM,SAAS,QAAQ,UAAU;AAEjC,KAAI,CAAC,WAAW,WAAW,CACzB,OAAM,IAAI,MAAM,2DAA2D,aAAa;AAE1F,KAAI,eAAe,WACjB,OAAM,IAAI,MACR,mKAED;AAEH,KAAI,WAAW,WAAW,EAAE;AAC1B,MAAI,QAAQ,oBAAoB,KAC9B,OAAM,IAAI,MACR,gEAAgE,WAAW,iDAE5E;AAEH,aAAW,WAAW;;CAGxB,MAAM,OAAO,MAAM,gBAAgB;AAQnC,KAAI,QAAQ,SAAS,MAAM;EACzB,MAAM,QAAQ,IAAI,KAAK,WAAW;EAClC,IAAI,WAAW;AACf,MAAI;AACF,OAAI;AACF,UAAM,OAAO,wBAAwB;AACrC,eAAW;YACJ,KAAK;AACZ,QAAI,YAAY,IAAI,CAAE,OAAM,IAAI,2BAA2B,YAAY,IAAI;AAC3E,UAAM;;YAEA;AACR,OAAI,SACF,KAAI;AACF,UAAM,OAAO,qBAAqB;WAC5B;AAKV,OAAI,MAAM,KAAM,OAAM,OAAO;;;AAIjC,KAAI;EAOF,MAAM,SAAS,IAAI,KAAK,YAAY,EAAE,UAAU,MAAM,CAAC;AACvD,MAAI;AACF,SAAM,OAAO,OAAO,WAAW;YACvB;AACR,OAAI,OAAO,KAAM,QAAO,OAAO;;EAKjC,MAAM,SAAS,IAAI,KAAK,WAAW;AACnC,MAAI;AACF,QAAK,MAAM,UAAU,wBAAwB,OAAO,CAClD,QAAO,OAAO,OAAO;AAIvB,UAAO,OAAO,wBAAwB;GACtC,MAAM,aAAa,0BAA0B,QAAQ,WAAW;AAChE,UAAO,OAAO,WAAW,aAAa;AACtC,UAAO,OAAO,qBAAqB;YAC3B;AACR,OAAI,OAAO,KAAM,QAAO,OAAO;;UAE1B,KAAK;AACZ,MAAI,WAAW,WAAW,CACxB,KAAI;AACF,cAAW,WAAW;UAChB;AAIV,QAAM,IAAI,MACR,yDAA0D,IAAc,WACxE,EAAE,OAAO,KAAK,CACf;;CAGH,MAAM,SAAS,MAAM,0BAA0B;EAC7C,MAAM;EACN,eAAe;EACf,qBAAqB;EACrB,YAAY;GACV,SAAS;GACT;GACA,oBAAoB,YAAY,QAAQ;GACzC;EACF,CAAC;CACF,IAAIA;AACJ,KAAI;EACF,MAAM,SAAS,qBAAqB,OAAO;AAC3C,mBAAiB;GAAE,IAAI,OAAO;GAAI,MAAM,OAAO;GAAM;WAC7C;AACR,SAAO,OAAO;;AAGhB,KAAI,CAAC,eAAe,IAAI;AACtB,MAAI,WAAW,WAAW,CACxB,KAAI;AACF,cAAW,WAAW;UAChB;AAIV,QAAM,IAAI,MACR,6EACE,eAAe,KAAK,KAAK,KAAK,CACjC;;CAGH,IAAIC;AACJ,KAAI,QAAQ,SAAS,MAAM;EAEzB,MAAM,YAAY,GAAG,WAAW,OADrB,KAAK,KAAK;AAErB,aAAW,YAAY,UAAU;AACjC,aAAW,YAAY,WAAW;AAClC,SAAO,OAAO,OAAO,EAAE,mBAAmB,WAAW,CAAC;;AAUxD,QAPmC,OAAO,OAAO;EAC/C;EACA,YAAY,SAAS,SAAY,aAAa;EAC9C;EACA,gBAAgB,OAAO,OAAO,eAAe;EAC7C,GAAI,SAAS,SAAY,EAAE,MAAM,GAAG,EAAE;EACvC,CAAC;;AAIJ,SAAS,SAAS,GAAmB;AACnC,QAAO,WAAW,EAAE,GAAG,IAAI,QAAQ,EAAE;;AAGvC,SAAS,YAAY,KAAuB;AAC1C,KAAI,OAAO,QAAQ,YAAY,QAAQ,KAAM,QAAO;CACpD,MAAM,OAAQ,IAA2B;AACzC,KAAI,OAAO,SAAS,YAAY,KAAK,SAAS,cAAc,CAAE,QAAO;CACrE,MAAM,UAAW,IAA8B;AAC/C,QAAO,OAAO,YAAY,YAAY,2BAA2B,KAAK,QAAQ"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_CIPHER, EncryptionCipher, encodePassphraseForPragma, pragmaSequenceForCipher } from "./cipher-config.js";
|
|
2
2
|
import { EncryptedStorePeerMissingError, _resetCipherPeerCacheForTesting, _setCipherPeerForTesting, loadCipherPeer } from "./cipher-peer.js";
|
|
3
3
|
import { createEncryptedConnection } from "./connection.js";
|
|
4
|
-
import { EncryptDatabaseOptions, EncryptDatabaseResult, encryptDatabase } from "./encrypt.js";
|
|
4
|
+
import { EncryptDatabaseOptions, EncryptDatabaseResult, EncryptSwapLiveWriterError, encryptDatabase } from "./encrypt.js";
|
|
5
5
|
import { CipherIntegrityCheckResult, cipherIntegrityCheck } from "./integrity-check.js";
|
|
6
6
|
import { RekeyDatabaseOptions, RekeyDatabaseResult, rekeyDatabase } from "./rekey.js";
|
|
7
7
|
|
|
@@ -44,8 +44,7 @@ import { RekeyDatabaseOptions, RekeyDatabaseResult, rekeyDatabase } from "./reke
|
|
|
44
44
|
*
|
|
45
45
|
* @packageDocumentation
|
|
46
46
|
*/
|
|
47
|
-
|
|
48
|
-
declare const VERSION = "0.6.0";
|
|
47
|
+
declare const VERSION: string;
|
|
49
48
|
//#endregion
|
|
50
|
-
export { type CipherIntegrityCheckResult, DEFAULT_CIPHER, type EncryptDatabaseOptions, type EncryptDatabaseResult, EncryptedStorePeerMissingError, type EncryptionCipher, type RekeyDatabaseOptions, type RekeyDatabaseResult, VERSION, _resetCipherPeerCacheForTesting, _setCipherPeerForTesting, cipherIntegrityCheck, createEncryptedConnection, encodePassphraseForPragma, encryptDatabase, loadCipherPeer, pragmaSequenceForCipher, rekeyDatabase };
|
|
49
|
+
export { type CipherIntegrityCheckResult, DEFAULT_CIPHER, type EncryptDatabaseOptions, type EncryptDatabaseResult, EncryptSwapLiveWriterError, EncryptedStorePeerMissingError, type EncryptionCipher, type RekeyDatabaseOptions, type RekeyDatabaseResult, VERSION, _resetCipherPeerCacheForTesting, _setCipherPeerForTesting, cipherIntegrityCheck, createEncryptedConnection, encodePassphraseForPragma, encryptDatabase, loadCipherPeer, pragmaSequenceForCipher, rekeyDatabase };
|
|
51
50
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;;;AAyCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAAa"}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { version } from "./package.js";
|
|
1
2
|
import { DEFAULT_CIPHER, encodePassphraseForPragma, pragmaSequenceForCipher } from "./cipher-config.js";
|
|
2
3
|
import { EncryptedStorePeerMissingError, _resetCipherPeerCacheForTesting, _setCipherPeerForTesting, loadCipherPeer } from "./cipher-peer.js";
|
|
3
4
|
import { createEncryptedConnection } from "./connection.js";
|
|
4
5
|
import { cipherIntegrityCheck } from "./integrity-check.js";
|
|
5
|
-
import { encryptDatabase } from "./encrypt.js";
|
|
6
|
+
import { EncryptSwapLiveWriterError, encryptDatabase } from "./encrypt.js";
|
|
6
7
|
import { rekeyDatabase } from "./rekey.js";
|
|
7
8
|
|
|
8
9
|
//#region src/index.ts
|
|
@@ -43,9 +44,9 @@ import { rekeyDatabase } from "./rekey.js";
|
|
|
43
44
|
*
|
|
44
45
|
* @packageDocumentation
|
|
45
46
|
*/
|
|
46
|
-
/** Canonical version constant
|
|
47
|
-
const VERSION =
|
|
47
|
+
/** Canonical version constant, derived from `package.json` at build time. */
|
|
48
|
+
const VERSION = version;
|
|
48
49
|
|
|
49
50
|
//#endregion
|
|
50
|
-
export { DEFAULT_CIPHER, EncryptedStorePeerMissingError, VERSION, _resetCipherPeerCacheForTesting, _setCipherPeerForTesting, cipherIntegrityCheck, createEncryptedConnection, encodePassphraseForPragma, encryptDatabase, loadCipherPeer, pragmaSequenceForCipher, rekeyDatabase };
|
|
51
|
+
export { DEFAULT_CIPHER, EncryptSwapLiveWriterError, EncryptedStorePeerMissingError, VERSION, _resetCipherPeerCacheForTesting, _setCipherPeerForTesting, cipherIntegrityCheck, createEncryptedConnection, encodePassphraseForPragma, encryptDatabase, loadCipherPeer, pragmaSequenceForCipher, rekeyDatabase };
|
|
51
52
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @graphorin/store-sqlite-encrypted - optional encryption-at-rest\n * sub-pack for the Graphorin framework's default SQLite store.\n *\n * Installing this package pulls in the cipher peer driver\n * (`better-sqlite3-multiple-ciphers@^12.9.0`), which is a drop-in fork\n * of `better-sqlite3` that bundles the SQLite3MultipleCiphers\n * extension (SQLCipher v4 / wxSQLite3 / AES-256-CBC / AES-128-CBC /\n * RC4 cipher modes).\n *\n * The package exposes:\n *\n * - {@link createEncryptedConnection} - convenience wrapper around\n * `openConnection` from `@graphorin/store-sqlite/connection` that\n * pre-loads the cipher peer.\n * - {@link encryptDatabase} - converts an unencrypted SQLite file\n * into an encrypted one. Backs `graphorin storage encrypt`.\n * - {@link rekeyDatabase} - re-keys an already encrypted file. Backs\n * `graphorin storage rekey`.\n * - {@link cipherIntegrityCheck} - runs `PRAGMA cipher_integrity_\n * check`. Used by the triggers daemon's daily verification cron\n * and the `/v1/health/storage` endpoint.\n * - {@link DEFAULT_CIPHER}, {@link pragmaSequenceForCipher},\n * {@link encodePassphraseForPragma} - cipher-config helpers shared\n * by the runners and consumable for advanced setups.\n * - {@link loadCipherPeer} / {@link EncryptedStorePeerMissingError} -\n * explicit peer-loader surface for callers that want to fail-fast\n * at startup before opening the DB.\n *\n * Defaults follow ADR-030 / DEC-129:\n * - Cipher: `'sqlcipher'` (SQLCipher v4 compatible, `legacy=4`).\n * - Default OFF; opt-in through `graphorin init --encrypted`.\n * - audit.db is ALWAYS encrypted regardless of this opt-in\n * (DEC-124); this package satisfies that requirement too.\n *\n * @packageDocumentation\n */\n\n/** Canonical version constant
|
|
1
|
+
{"version":3,"file":"index.js","names":["VERSION: string","pkg.version"],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @graphorin/store-sqlite-encrypted - optional encryption-at-rest\n * sub-pack for the Graphorin framework's default SQLite store.\n *\n * Installing this package pulls in the cipher peer driver\n * (`better-sqlite3-multiple-ciphers@^12.9.0`), which is a drop-in fork\n * of `better-sqlite3` that bundles the SQLite3MultipleCiphers\n * extension (SQLCipher v4 / wxSQLite3 / AES-256-CBC / AES-128-CBC /\n * RC4 cipher modes).\n *\n * The package exposes:\n *\n * - {@link createEncryptedConnection} - convenience wrapper around\n * `openConnection` from `@graphorin/store-sqlite/connection` that\n * pre-loads the cipher peer.\n * - {@link encryptDatabase} - converts an unencrypted SQLite file\n * into an encrypted one. Backs `graphorin storage encrypt`.\n * - {@link rekeyDatabase} - re-keys an already encrypted file. Backs\n * `graphorin storage rekey`.\n * - {@link cipherIntegrityCheck} - runs `PRAGMA cipher_integrity_\n * check`. Used by the triggers daemon's daily verification cron\n * and the `/v1/health/storage` endpoint.\n * - {@link DEFAULT_CIPHER}, {@link pragmaSequenceForCipher},\n * {@link encodePassphraseForPragma} - cipher-config helpers shared\n * by the runners and consumable for advanced setups.\n * - {@link loadCipherPeer} / {@link EncryptedStorePeerMissingError} -\n * explicit peer-loader surface for callers that want to fail-fast\n * at startup before opening the DB.\n *\n * Defaults follow ADR-030 / DEC-129:\n * - Cipher: `'sqlcipher'` (SQLCipher v4 compatible, `legacy=4`).\n * - Default OFF; opt-in through `graphorin init --encrypted`.\n * - audit.db is ALWAYS encrypted regardless of this opt-in\n * (DEC-124); this package satisfies that requirement too.\n *\n * @packageDocumentation\n */\n\n/** Canonical version constant, derived from `package.json` at build time. */\nimport pkg from '../package.json' with { type: 'json' };\n\nexport const VERSION: string = pkg.version;\n\nexport {\n DEFAULT_CIPHER,\n type EncryptionCipher,\n encodePassphraseForPragma,\n pragmaSequenceForCipher,\n} from './cipher-config.js';\nexport {\n _resetCipherPeerCacheForTesting,\n _setCipherPeerForTesting,\n EncryptedStorePeerMissingError,\n loadCipherPeer,\n} from './cipher-peer.js';\nexport { createEncryptedConnection } from './connection.js';\nexport {\n type EncryptDatabaseOptions,\n type EncryptDatabaseResult,\n EncryptSwapLiveWriterError,\n encryptDatabase,\n} from './encrypt.js';\nexport { type CipherIntegrityCheckResult, cipherIntegrityCheck } from './integrity-check.js';\nexport {\n type RekeyDatabaseOptions,\n type RekeyDatabaseResult,\n rekeyDatabase,\n} from './rekey.js';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,MAAaA,UAAkBC"}
|
package/dist/package.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"package.js","names":[],"sources":["../package.json"],"sourcesContent":["{\n \"name\": \"@graphorin/store-sqlite-encrypted\",\n \"version\": \"0.7.0\",\n \"description\": \"Optional encryption-at-rest sub-pack for the Graphorin framework's default SQLite store. Pulls in `better-sqlite3-multiple-ciphers@^12.9.0` (a drop-in fork of `better-sqlite3` that bundles SQLite3MultipleCiphers / SQLCipher v4), exposes the `encryptDatabase` / `rekeyDatabase` / `cipherIntegrityCheck` runners that back `graphorin storage encrypt` / `graphorin storage rekey` / `graphorin storage status`, and provides a `createEncryptedConnection` convenience wrapper around `@graphorin/store-sqlite/connection` for callers that bypass the CLI. Default cipher: `sqlcipher` (SQLCipher v4 compatible). Opt-in alternatives: `chacha20` (the peer default), `aes256cbc`, `aes128cbc`, `rc4`. Default OFF; opt-in through `graphorin init --encrypted` per ADR-030 / DEC-129. Created and maintained by Oleksiy Stepurenko.\",\n \"license\": \"MIT\",\n \"author\": \"Oleksiy Stepurenko\",\n \"homepage\": \"https://github.com/o-stepper/graphorin/tree/main/packages/store-sqlite-encrypted\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/o-stepper/graphorin.git\",\n \"directory\": \"packages/store-sqlite-encrypted\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/o-stepper/graphorin/issues\"\n },\n \"keywords\": [\n \"graphorin\",\n \"ai\",\n \"agents\",\n \"framework\",\n \"sqlite\",\n \"sqlcipher\",\n \"encryption\",\n \"encryption-at-rest\",\n \"better-sqlite3-multiple-ciphers\",\n \"rekey\",\n \"vacuum-into\"\n ],\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=22.12.0\"\n },\n \"main\": \"./dist/index.js\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"sideEffects\": false,\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"default\": \"./dist/index.js\"\n },\n \"./package.json\": \"./package.json\"\n },\n \"files\": [\n \"dist\",\n \"src\",\n \"README.md\",\n \"CHANGELOG.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsdown\",\n \"typecheck\": \"tsc --noEmit && tsc -p tsconfig.tests.json\",\n \"test\": \"vitest run\",\n \"lint\": \"biome check .\",\n \"clean\": \"rimraf dist .turbo *.tsbuildinfo\"\n },\n \"dependencies\": {\n \"@graphorin/store-sqlite\": \"workspace:*\"\n },\n \"peerDependencies\": {\n \"better-sqlite3-multiple-ciphers\": \"^12.9.0\"\n },\n \"peerDependenciesMeta\": {\n \"better-sqlite3-multiple-ciphers\": {\n \"optional\": false\n }\n },\n \"publishConfig\": {\n \"access\": \"public\",\n \"provenance\": true\n },\n \"devDependencies\": {\n \"@types/better-sqlite3\": \"^7.6.13\"\n }\n}\n"],"mappings":";cAEa"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphorin/store-sqlite-encrypted",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Optional encryption-at-rest sub-pack for the Graphorin framework's default SQLite store. Pulls in `better-sqlite3-multiple-ciphers@^12.9.0` (a drop-in fork of `better-sqlite3` that bundles SQLite3MultipleCiphers / SQLCipher v4), exposes the `encryptDatabase` / `rekeyDatabase` / `cipherIntegrityCheck` runners that back `graphorin storage encrypt` / `graphorin storage rekey` / `graphorin storage status`, and provides a `createEncryptedConnection` convenience wrapper around `@graphorin/store-sqlite/connection` for callers that bypass the CLI. Default cipher: `sqlcipher` (SQLCipher v4 compatible). Opt-in alternatives: `chacha20` (the peer default), `aes256cbc`, `aes128cbc`, `rc4`. Default OFF; opt-in through `graphorin init --encrypted` per ADR-030 / DEC-129. Created and maintained by Oleksiy Stepurenko.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Oleksiy Stepurenko",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"type": "module",
|
|
30
30
|
"engines": {
|
|
31
|
-
"node": ">=22.
|
|
31
|
+
"node": ">=22.12.0"
|
|
32
32
|
},
|
|
33
33
|
"main": "./dist/index.js",
|
|
34
34
|
"module": "./dist/index.js",
|
|
@@ -37,18 +37,19 @@
|
|
|
37
37
|
"exports": {
|
|
38
38
|
".": {
|
|
39
39
|
"types": "./dist/index.d.ts",
|
|
40
|
-
"
|
|
40
|
+
"default": "./dist/index.js"
|
|
41
41
|
},
|
|
42
42
|
"./package.json": "./package.json"
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
|
45
45
|
"dist",
|
|
46
|
+
"src",
|
|
46
47
|
"README.md",
|
|
47
48
|
"CHANGELOG.md",
|
|
48
49
|
"LICENSE"
|
|
49
50
|
],
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@graphorin/store-sqlite": "0.
|
|
52
|
+
"@graphorin/store-sqlite": "0.7.0"
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|
|
54
55
|
"better-sqlite3-multiple-ciphers": "^12.9.0"
|
|
@@ -63,8 +64,7 @@
|
|
|
63
64
|
"provenance": true
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
|
-
"@types/better-sqlite3": "^7.6.13"
|
|
67
|
-
"@graphorin/security": "0.6.0"
|
|
67
|
+
"@types/better-sqlite3": "^7.6.13"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"build": "tsdown",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cipher selection + passphrase encoding helpers shared by the
|
|
3
|
+
* `createEncryptedConnection`, `encryptDatabase`, and `rekeyDatabase`
|
|
4
|
+
* runners. The cipher peer reads the passphrase via `PRAGMA key`; this
|
|
5
|
+
* module owns the SQL-literal escaping path so all three runners agree.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { EncryptionCipher } from '@graphorin/store-sqlite/encryption';
|
|
11
|
+
|
|
12
|
+
export type { EncryptionCipher };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default cipher. Matches ADR-030 § 2 - SQLCipher v4 compatible
|
|
16
|
+
* (AES-256-CBC + HMAC-SHA1, `legacy=4` parameter set).
|
|
17
|
+
*
|
|
18
|
+
* @stable
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_CIPHER: EncryptionCipher = 'sqlcipher';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cipher-specific PRAGMAs that must be applied **before**
|
|
24
|
+
* `PRAGMA key = ...` for SQLite3MultipleCiphers to interpret the key
|
|
25
|
+
* correctly. The list mirrors the upstream documentation.
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
const CIPHER_PRAGMAS: Readonly<Record<EncryptionCipher, ReadonlyArray<string>>> = Object.freeze({
|
|
30
|
+
sqlcipher: Object.freeze(["cipher = 'sqlcipher'", 'legacy = 4']),
|
|
31
|
+
chacha20: Object.freeze(["cipher = 'chacha20'"]),
|
|
32
|
+
aes256cbc: Object.freeze(["cipher = 'aes256cbc'"]),
|
|
33
|
+
aes128cbc: Object.freeze(["cipher = 'aes128cbc'"]),
|
|
34
|
+
rc4: Object.freeze(["cipher = 'rc4'"]),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the PRAGMA statements that select a cipher. The list is
|
|
39
|
+
* applied **before** `PRAGMA key = ...` so the cipher peer knows which
|
|
40
|
+
* KDF / mode to use when interpreting the key bytes.
|
|
41
|
+
*
|
|
42
|
+
* @stable
|
|
43
|
+
*/
|
|
44
|
+
export function pragmaSequenceForCipher(cipher: EncryptionCipher): ReadonlyArray<string> {
|
|
45
|
+
const seq = CIPHER_PRAGMAS[cipher];
|
|
46
|
+
if (seq === undefined) {
|
|
47
|
+
throw new TypeError(
|
|
48
|
+
`[graphorin/store-sqlite-encrypted] unknown cipher '${String(cipher)}'. ` +
|
|
49
|
+
`Supported: ${Object.keys(CIPHER_PRAGMAS).join(', ')}.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return seq;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* SQL-literal-encodes a passphrase for use as the right-hand side of
|
|
57
|
+
* `PRAGMA key = ...`.
|
|
58
|
+
*
|
|
59
|
+
* - String input is wrapped in single quotes with internal `'` doubled
|
|
60
|
+
* per the SQL specification.
|
|
61
|
+
* - Buffer input is encoded as the cipher peer's `x'<hex>'` blob form
|
|
62
|
+
* so binary keys round-trip exactly.
|
|
63
|
+
*
|
|
64
|
+
* Empty inputs are rejected at this layer so callers cannot
|
|
65
|
+
* accidentally open an unencrypted DB with an empty key.
|
|
66
|
+
*
|
|
67
|
+
* @stable
|
|
68
|
+
*/
|
|
69
|
+
export function encodePassphraseForPragma(value: string | Buffer): string {
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
if (value.length === 0) {
|
|
72
|
+
throw new Error('[graphorin/store-sqlite-encrypted] passphrase is empty');
|
|
73
|
+
}
|
|
74
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
75
|
+
}
|
|
76
|
+
if (value.length === 0) {
|
|
77
|
+
throw new Error('[graphorin/store-sqlite-encrypted] passphrase buffer is empty');
|
|
78
|
+
}
|
|
79
|
+
return `x'${value.toString('hex')}'`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the `better-sqlite3-multiple-ciphers` cipher peer driver. The
|
|
3
|
+
* peer is declared `optional: false` in this package's `package.json`
|
|
4
|
+
* (the whole point of installing this sub-pack is to opt in to
|
|
5
|
+
* encryption-at-rest), but we still wrap the import in a friendly
|
|
6
|
+
* fail-fast helper so consumers see an actionable error rather than a
|
|
7
|
+
* raw `ERR_MODULE_NOT_FOUND`.
|
|
8
|
+
*
|
|
9
|
+
* The peer is loaded lazily - importing this package on its own does
|
|
10
|
+
* NOT load the native addon, so callers that only inspect the public
|
|
11
|
+
* helpers (e.g. for documentation generation) pay no native-load cost.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { BetterSqlite3Constructor } from '@graphorin/store-sqlite/connection';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Raised when the cipher peer driver cannot be loaded. Distinct from
|
|
20
|
+
* the matching `CipherPeerMissingError` in `@graphorin/store-sqlite/
|
|
21
|
+
* encryption` so consumers can catch the two layers independently.
|
|
22
|
+
*
|
|
23
|
+
* @stable
|
|
24
|
+
*/
|
|
25
|
+
export class EncryptedStorePeerMissingError extends Error {
|
|
26
|
+
override readonly name = 'EncryptedStorePeerMissingError';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @internal */
|
|
30
|
+
let CACHED_CTOR: BetterSqlite3Constructor | null = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Loads `better-sqlite3-multiple-ciphers`. The result is cached for
|
|
34
|
+
* the lifetime of the process so repeat callers (encrypt + rekey +
|
|
35
|
+
* connection-open in the same process) share one native handle.
|
|
36
|
+
*
|
|
37
|
+
* @stable
|
|
38
|
+
*/
|
|
39
|
+
export async function loadCipherPeer(): Promise<BetterSqlite3Constructor> {
|
|
40
|
+
if (CACHED_CTOR !== null) return CACHED_CTOR;
|
|
41
|
+
try {
|
|
42
|
+
const mod = (await import('better-sqlite3-multiple-ciphers')) as unknown as {
|
|
43
|
+
default: BetterSqlite3Constructor;
|
|
44
|
+
};
|
|
45
|
+
CACHED_CTOR = mod.default;
|
|
46
|
+
return CACHED_CTOR;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new EncryptedStorePeerMissingError(
|
|
49
|
+
"the cipher peer 'better-sqlite3-multiple-ciphers' could not be loaded. " +
|
|
50
|
+
'This sub-pack declares it as a required peer dependency; install it via ' +
|
|
51
|
+
'`pnpm add better-sqlite3-multiple-ciphers@^12.9.0` (or rerun `pnpm install` ' +
|
|
52
|
+
'after adding @graphorin/store-sqlite-encrypted).',
|
|
53
|
+
{ cause: err },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Test-only escape hatch. Drops the cached constructor so the next
|
|
60
|
+
* {@link loadCipherPeer} call re-imports the peer.
|
|
61
|
+
*
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
export function _resetCipherPeerCacheForTesting(): void {
|
|
65
|
+
CACHED_CTOR = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Test-only escape hatch. Pre-populates the cache with a stub driver
|
|
70
|
+
* so unit tests can exercise the encrypt / rekey runners without
|
|
71
|
+
* touching the native cipher addon.
|
|
72
|
+
*
|
|
73
|
+
* @internal
|
|
74
|
+
*/
|
|
75
|
+
export function _setCipherPeerForTesting(ctor: BetterSqlite3Constructor): void {
|
|
76
|
+
CACHED_CTOR = ctor;
|
|
77
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convenience wrapper around `openConnection` from `@graphorin/store-
|
|
3
|
+
* sqlite/connection` that pre-loads the cipher peer driver and applies
|
|
4
|
+
* the cipher PRAGMAs in the right order.
|
|
5
|
+
*
|
|
6
|
+
* Most callers never reach this directly - the standard path is to
|
|
7
|
+
* pass `{ encryption: { enabled: true, passphraseResolver } }` to the
|
|
8
|
+
* store-sqlite facade and let it consult the canonical
|
|
9
|
+
* `loadCipherDriver`. This helper exists for the encrypt / rekey
|
|
10
|
+
* runners (which need a "raw" handle on a freshly created cipher DB
|
|
11
|
+
* before the standard schema migrations run) and for advanced
|
|
12
|
+
* consumers that want to build a custom store on top of an encrypted
|
|
13
|
+
* connection.
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
type OpenConnectionOptions,
|
|
20
|
+
openConnection,
|
|
21
|
+
type SqliteConnection,
|
|
22
|
+
} from '@graphorin/store-sqlite/connection';
|
|
23
|
+
|
|
24
|
+
import { loadCipherPeer } from './cipher-peer.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Opens an encrypted SQLite connection. Differs from `openConnection`
|
|
28
|
+
* only in that the cipher peer driver is preloaded - callers that
|
|
29
|
+
* supply an `encryption.passphraseResolver` get the same behaviour as
|
|
30
|
+
* `openConnection({ encryption })` plus an explicit fail-fast on a
|
|
31
|
+
* missing cipher peer.
|
|
32
|
+
*
|
|
33
|
+
* @stable
|
|
34
|
+
*/
|
|
35
|
+
export async function createEncryptedConnection(
|
|
36
|
+
options: OpenConnectionOptions,
|
|
37
|
+
): Promise<SqliteConnection> {
|
|
38
|
+
if (options.encryption?.enabled !== true) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'[graphorin/store-sqlite-encrypted] createEncryptedConnection requires ' +
|
|
41
|
+
'`encryption.enabled: true`. Use `openConnection` directly for unencrypted DBs.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const ctor = await loadCipherPeer();
|
|
45
|
+
return openConnection({
|
|
46
|
+
...options,
|
|
47
|
+
cipherLoader: async () => ctor,
|
|
48
|
+
});
|
|
49
|
+
}
|
package/src/encrypt.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `encryptDatabase` - converts an unencrypted SQLite database file
|
|
3
|
+
* into an encrypted one. Backs `graphorin storage encrypt` per
|
|
4
|
+
* ADR-030 § 8.
|
|
5
|
+
*
|
|
6
|
+
* Strategy (CS-7): sqlite3mc ships **no** `sqlcipher_export` function
|
|
7
|
+
* (verified against the real peer - the old ATTACH+export path threw
|
|
8
|
+
* "no such function" on every real run), so the export is a
|
|
9
|
+
* **checkpoint → file copy → in-place `PRAGMA rekey`** sequence:
|
|
10
|
+
*
|
|
11
|
+
* 1. open the plaintext source, `wal_checkpoint(TRUNCATE)`, close;
|
|
12
|
+
* 2. byte-copy the file to the target (this trivially preserves every
|
|
13
|
+
* rowid, so FTS5 external-content mappings stay intact - CS-10);
|
|
14
|
+
* 3. open the copy through the cipher peer with NO key, apply the
|
|
15
|
+
* cipher-selection pragmas, then `PRAGMA rekey = <key>` - sqlite3mc
|
|
16
|
+
* encrypts a plaintext database in place;
|
|
17
|
+
* 4. re-open with the key and verify via `PRAGMA integrity_check`.
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, renameSync, unlinkSync } from 'node:fs';
|
|
23
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_CIPHER,
|
|
27
|
+
type EncryptionCipher,
|
|
28
|
+
encodePassphraseForPragma,
|
|
29
|
+
pragmaSequenceForCipher,
|
|
30
|
+
} from './cipher-config.js';
|
|
31
|
+
import { loadCipherPeer } from './cipher-peer.js';
|
|
32
|
+
import { createEncryptedConnection } from './connection.js';
|
|
33
|
+
import { cipherIntegrityCheck } from './integrity-check.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options for {@link encryptDatabase}.
|
|
37
|
+
*
|
|
38
|
+
* @stable
|
|
39
|
+
*/
|
|
40
|
+
export interface EncryptDatabaseOptions {
|
|
41
|
+
/** Path to the existing unencrypted source DB. */
|
|
42
|
+
readonly sourcePath: string;
|
|
43
|
+
/** Path the encrypted output is written to. Must not exist. */
|
|
44
|
+
readonly targetPath: string;
|
|
45
|
+
/** Passphrase for the new encrypted DB. */
|
|
46
|
+
readonly passphrase: string | Buffer;
|
|
47
|
+
/** Cipher selection. Default `'sqlcipher'` (SQLCipher v4 compatible). */
|
|
48
|
+
readonly cipher?: EncryptionCipher;
|
|
49
|
+
/**
|
|
50
|
+
* If `true`, atomically rename `targetPath` -> `sourcePath` after the
|
|
51
|
+
* integrity check passes. The original `sourcePath` is renamed to
|
|
52
|
+
* `${sourcePath}.bak.${timestamp}` so an operator can recover.
|
|
53
|
+
* Default `false` - the CLI does the swap explicitly.
|
|
54
|
+
*
|
|
55
|
+
* REQUIRES A STOPPED SERVER (W-012): a live writer keeps its file
|
|
56
|
+
* descriptor on the renamed `.bak` inode and every post-snapshot
|
|
57
|
+
* commit silently diverges from the new encrypted file (and is later
|
|
58
|
+
* deleted by `storage cleanup-backups`). A best-effort live-writer
|
|
59
|
+
* probe refuses the swap with {@link EncryptSwapLiveWriterError}
|
|
60
|
+
* when another connection holds the database; the probe-to-rename
|
|
61
|
+
* window remains a documented residual race.
|
|
62
|
+
*/
|
|
63
|
+
readonly swap?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* If `true`, overwrite an existing `targetPath` instead of failing.
|
|
66
|
+
* Default `false`.
|
|
67
|
+
*/
|
|
68
|
+
readonly overwriteTarget?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Result of a successful {@link encryptDatabase} run.
|
|
73
|
+
*
|
|
74
|
+
* @stable
|
|
75
|
+
*/
|
|
76
|
+
export interface EncryptDatabaseResult {
|
|
77
|
+
readonly sourcePath: string;
|
|
78
|
+
readonly targetPath: string;
|
|
79
|
+
readonly cipher: EncryptionCipher;
|
|
80
|
+
readonly integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> };
|
|
81
|
+
readonly swap?: { readonly originalRenamedTo: string };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Encrypts an unencrypted SQLite database. Returns once the target
|
|
86
|
+
* file has been written and verified. Throws if the source is missing,
|
|
87
|
+
* the target already exists (and `overwriteTarget` is unset), the
|
|
88
|
+
* cipher peer is missing, or the integrity check fails.
|
|
89
|
+
*
|
|
90
|
+
* @stable
|
|
91
|
+
*/
|
|
92
|
+
/**
|
|
93
|
+
* Thrown when `encryptDatabase({ swap: true })` detects another live
|
|
94
|
+
* connection on the source database (W-012). The swap path renames the
|
|
95
|
+
* source file; a live writer would keep writing into the renamed
|
|
96
|
+
* `.bak.<ts>` inode and silently diverge from the encrypted copy.
|
|
97
|
+
*
|
|
98
|
+
* @stable
|
|
99
|
+
*/
|
|
100
|
+
export class EncryptSwapLiveWriterError extends Error {
|
|
101
|
+
readonly sourcePath: string;
|
|
102
|
+
constructor(sourcePath: string, cause: unknown) {
|
|
103
|
+
super(
|
|
104
|
+
`[graphorin/store-sqlite-encrypted] ${sourcePath} appears to be open by another process; ` +
|
|
105
|
+
'stop the server before running storage encrypt with --swap (writes committed after the ' +
|
|
106
|
+
'snapshot would land in the renamed .bak file and be lost).',
|
|
107
|
+
{ cause },
|
|
108
|
+
);
|
|
109
|
+
this.name = 'EncryptSwapLiveWriterError';
|
|
110
|
+
this.sourcePath = sourcePath;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function encryptDatabase(
|
|
115
|
+
options: EncryptDatabaseOptions,
|
|
116
|
+
): Promise<EncryptDatabaseResult> {
|
|
117
|
+
const sourcePath = absolute(options.sourcePath);
|
|
118
|
+
const targetPath = absolute(options.targetPath);
|
|
119
|
+
const cipher = options.cipher ?? DEFAULT_CIPHER;
|
|
120
|
+
|
|
121
|
+
if (!existsSync(sourcePath)) {
|
|
122
|
+
throw new Error(`[graphorin/store-sqlite-encrypted] source DB not found: ${sourcePath}`);
|
|
123
|
+
}
|
|
124
|
+
if (sourcePath === targetPath) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
'[graphorin/store-sqlite-encrypted] sourcePath and targetPath must differ. ' +
|
|
127
|
+
'Pass a temporary targetPath then enable `swap: true` to atomically replace the source.',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (existsSync(targetPath)) {
|
|
131
|
+
if (options.overwriteTarget !== true) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`[graphorin/store-sqlite-encrypted] target DB already exists: ${targetPath}. ` +
|
|
134
|
+
'Pass `overwriteTarget: true` to replace it.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
unlinkSync(targetPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const Ctor = await loadCipherPeer();
|
|
141
|
+
|
|
142
|
+
// W-012: --swap renames the source out from under any live writer,
|
|
143
|
+
// so probe for one FIRST. Empirically (real sqlite3mc peer): a
|
|
144
|
+
// journal-mode switch is refused with "database is locked" while any
|
|
145
|
+
// other connection is open, making it a cheap liveness probe. The
|
|
146
|
+
// mode is restored in `finally` so a crash mid-probe cannot leave the
|
|
147
|
+
// file in DELETE mode. Best-effort: the probe-to-rename window stays.
|
|
148
|
+
if (options.swap === true) {
|
|
149
|
+
const probe = new Ctor(sourcePath);
|
|
150
|
+
let switched = false;
|
|
151
|
+
try {
|
|
152
|
+
try {
|
|
153
|
+
probe.pragma('journal_mode = DELETE');
|
|
154
|
+
switched = true;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (isBusyError(err)) throw new EncryptSwapLiveWriterError(sourcePath, err);
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
if (switched) {
|
|
161
|
+
try {
|
|
162
|
+
probe.pragma('journal_mode = WAL');
|
|
163
|
+
} catch {
|
|
164
|
+
// The WAL restore is best-effort; the conversion below sets
|
|
165
|
+
// journal modes explicitly anyway.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (probe.open) probe.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
// 1+2. Online page-level copy via the driver's backup API
|
|
174
|
+
// (store-05). The old checkpoint-close-then-copyFileSync left a
|
|
175
|
+
// window in which a concurrent writer (a running daemon) could
|
|
176
|
+
// commit WAL frames the byte-copy silently missed; `backup()`
|
|
177
|
+
// is consistent under live writers, preserves rowids (FTS5
|
|
178
|
+
// mappings survive), and includes WAL content.
|
|
179
|
+
const source = new Ctor(sourcePath, { readonly: true });
|
|
180
|
+
try {
|
|
181
|
+
await source.backup(targetPath);
|
|
182
|
+
} finally {
|
|
183
|
+
if (source.open) source.close();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3. In-place conversion: cipher pragmas first, then `rekey` -
|
|
187
|
+
// sqlite3mc encrypts a plaintext database in place.
|
|
188
|
+
const target = new Ctor(targetPath);
|
|
189
|
+
try {
|
|
190
|
+
for (const pragma of pragmaSequenceForCipher(cipher)) {
|
|
191
|
+
target.pragma(pragma);
|
|
192
|
+
}
|
|
193
|
+
// sqlite3mc refuses `rekey` in WAL journal mode (real-peer
|
|
194
|
+
// verified) - drop to DELETE for the conversion, restore after.
|
|
195
|
+
target.pragma('journal_mode = DELETE');
|
|
196
|
+
const encodedKey = encodePassphraseForPragma(options.passphrase);
|
|
197
|
+
target.pragma(`rekey = ${encodedKey}`);
|
|
198
|
+
target.pragma('journal_mode = WAL');
|
|
199
|
+
} finally {
|
|
200
|
+
if (target.open) target.close();
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (existsSync(targetPath)) {
|
|
204
|
+
try {
|
|
205
|
+
unlinkSync(targetPath);
|
|
206
|
+
} catch {
|
|
207
|
+
// best-effort cleanup
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
throw new Error(
|
|
211
|
+
`[graphorin/store-sqlite-encrypted] encryption failed: ${(err as Error).message}`,
|
|
212
|
+
{ cause: err },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const verify = await createEncryptedConnection({
|
|
217
|
+
path: targetPath,
|
|
218
|
+
skipSqliteVec: true,
|
|
219
|
+
disableWalHardening: true,
|
|
220
|
+
encryption: {
|
|
221
|
+
enabled: true,
|
|
222
|
+
cipher,
|
|
223
|
+
passphraseResolver: async () => options.passphrase,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
let integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> };
|
|
227
|
+
try {
|
|
228
|
+
const result = cipherIntegrityCheck(verify);
|
|
229
|
+
integrityCheck = { ok: result.ok, rows: result.rows };
|
|
230
|
+
} finally {
|
|
231
|
+
verify.close();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!integrityCheck.ok) {
|
|
235
|
+
if (existsSync(targetPath)) {
|
|
236
|
+
try {
|
|
237
|
+
unlinkSync(targetPath);
|
|
238
|
+
} catch {
|
|
239
|
+
// best-effort cleanup
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new Error(
|
|
243
|
+
'[graphorin/store-sqlite-encrypted] post-encrypt integrity check failed: ' +
|
|
244
|
+
integrityCheck.rows.join('; '),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let swap: { readonly originalRenamedTo: string } | undefined;
|
|
249
|
+
if (options.swap === true) {
|
|
250
|
+
const ts = Date.now();
|
|
251
|
+
const renamedTo = `${sourcePath}.bak.${ts}`;
|
|
252
|
+
renameSync(sourcePath, renamedTo);
|
|
253
|
+
renameSync(targetPath, sourcePath);
|
|
254
|
+
swap = Object.freeze({ originalRenamedTo: renamedTo });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const out: EncryptDatabaseResult = Object.freeze({
|
|
258
|
+
sourcePath,
|
|
259
|
+
targetPath: swap !== undefined ? sourcePath : targetPath,
|
|
260
|
+
cipher,
|
|
261
|
+
integrityCheck: Object.freeze(integrityCheck),
|
|
262
|
+
...(swap !== undefined ? { swap } : {}),
|
|
263
|
+
});
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function absolute(p: string): string {
|
|
268
|
+
return isAbsolute(p) ? p : resolve(p);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isBusyError(err: unknown): boolean {
|
|
272
|
+
if (typeof err !== 'object' || err === null) return false;
|
|
273
|
+
const code = (err as { code?: unknown }).code;
|
|
274
|
+
if (typeof code === 'string' && code.includes('SQLITE_BUSY')) return true;
|
|
275
|
+
const message = (err as { message?: unknown }).message;
|
|
276
|
+
return typeof message === 'string' && /database is locked|busy/i.test(message);
|
|
277
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @graphorin/store-sqlite-encrypted - optional encryption-at-rest
|
|
3
|
+
* sub-pack for the Graphorin framework's default SQLite store.
|
|
4
|
+
*
|
|
5
|
+
* Installing this package pulls in the cipher peer driver
|
|
6
|
+
* (`better-sqlite3-multiple-ciphers@^12.9.0`), which is a drop-in fork
|
|
7
|
+
* of `better-sqlite3` that bundles the SQLite3MultipleCiphers
|
|
8
|
+
* extension (SQLCipher v4 / wxSQLite3 / AES-256-CBC / AES-128-CBC /
|
|
9
|
+
* RC4 cipher modes).
|
|
10
|
+
*
|
|
11
|
+
* The package exposes:
|
|
12
|
+
*
|
|
13
|
+
* - {@link createEncryptedConnection} - convenience wrapper around
|
|
14
|
+
* `openConnection` from `@graphorin/store-sqlite/connection` that
|
|
15
|
+
* pre-loads the cipher peer.
|
|
16
|
+
* - {@link encryptDatabase} - converts an unencrypted SQLite file
|
|
17
|
+
* into an encrypted one. Backs `graphorin storage encrypt`.
|
|
18
|
+
* - {@link rekeyDatabase} - re-keys an already encrypted file. Backs
|
|
19
|
+
* `graphorin storage rekey`.
|
|
20
|
+
* - {@link cipherIntegrityCheck} - runs `PRAGMA cipher_integrity_
|
|
21
|
+
* check`. Used by the triggers daemon's daily verification cron
|
|
22
|
+
* and the `/v1/health/storage` endpoint.
|
|
23
|
+
* - {@link DEFAULT_CIPHER}, {@link pragmaSequenceForCipher},
|
|
24
|
+
* {@link encodePassphraseForPragma} - cipher-config helpers shared
|
|
25
|
+
* by the runners and consumable for advanced setups.
|
|
26
|
+
* - {@link loadCipherPeer} / {@link EncryptedStorePeerMissingError} -
|
|
27
|
+
* explicit peer-loader surface for callers that want to fail-fast
|
|
28
|
+
* at startup before opening the DB.
|
|
29
|
+
*
|
|
30
|
+
* Defaults follow ADR-030 / DEC-129:
|
|
31
|
+
* - Cipher: `'sqlcipher'` (SQLCipher v4 compatible, `legacy=4`).
|
|
32
|
+
* - Default OFF; opt-in through `graphorin init --encrypted`.
|
|
33
|
+
* - audit.db is ALWAYS encrypted regardless of this opt-in
|
|
34
|
+
* (DEC-124); this package satisfies that requirement too.
|
|
35
|
+
*
|
|
36
|
+
* @packageDocumentation
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Canonical version constant, derived from `package.json` at build time. */
|
|
40
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
41
|
+
|
|
42
|
+
export const VERSION: string = pkg.version;
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
DEFAULT_CIPHER,
|
|
46
|
+
type EncryptionCipher,
|
|
47
|
+
encodePassphraseForPragma,
|
|
48
|
+
pragmaSequenceForCipher,
|
|
49
|
+
} from './cipher-config.js';
|
|
50
|
+
export {
|
|
51
|
+
_resetCipherPeerCacheForTesting,
|
|
52
|
+
_setCipherPeerForTesting,
|
|
53
|
+
EncryptedStorePeerMissingError,
|
|
54
|
+
loadCipherPeer,
|
|
55
|
+
} from './cipher-peer.js';
|
|
56
|
+
export { createEncryptedConnection } from './connection.js';
|
|
57
|
+
export {
|
|
58
|
+
type EncryptDatabaseOptions,
|
|
59
|
+
type EncryptDatabaseResult,
|
|
60
|
+
EncryptSwapLiveWriterError,
|
|
61
|
+
encryptDatabase,
|
|
62
|
+
} from './encrypt.js';
|
|
63
|
+
export { type CipherIntegrityCheckResult, cipherIntegrityCheck } from './integrity-check.js';
|
|
64
|
+
export {
|
|
65
|
+
type RekeyDatabaseOptions,
|
|
66
|
+
type RekeyDatabaseResult,
|
|
67
|
+
rekeyDatabase,
|
|
68
|
+
} from './rekey.js';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integrity-check runner for encrypted databases. sqlite3mc ships no
|
|
3
|
+
* `PRAGMA cipher_integrity_check` (CS-7 - the old call returned an
|
|
4
|
+
* empty row-set on every real run, so `ok` was always false); the
|
|
5
|
+
* standard `PRAGMA integrity_check` works through the cipher layer
|
|
6
|
+
* once the connection is keyed, which is exactly the property the
|
|
7
|
+
* CLI / health endpoint needs ("can this DB be read with this key,
|
|
8
|
+
* and is it internally consistent").
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SqliteConnection } from '@graphorin/store-sqlite/connection';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Result of {@link cipherIntegrityCheck}.
|
|
17
|
+
*
|
|
18
|
+
* @stable
|
|
19
|
+
*/
|
|
20
|
+
export interface CipherIntegrityCheckResult {
|
|
21
|
+
/** `true` when SQLite reported a single `'ok'` row. */
|
|
22
|
+
readonly ok: boolean;
|
|
23
|
+
/** Raw rows returned by `PRAGMA integrity_check`. */
|
|
24
|
+
readonly rows: ReadonlyArray<string>;
|
|
25
|
+
/** Wall-clock duration of the pragma call in milliseconds. */
|
|
26
|
+
readonly durationMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs `PRAGMA integrity_check` against the provided connection. The
|
|
31
|
+
* connection MUST already be open with the cipher key applied
|
|
32
|
+
* (typically via {@link createEncryptedConnection}) - a wrong key
|
|
33
|
+
* surfaces as an open/read error before the pragma runs.
|
|
34
|
+
*
|
|
35
|
+
* The pragma is read-only so it is safe to run from a triggers daemon
|
|
36
|
+
* cron without taking a write lock.
|
|
37
|
+
*
|
|
38
|
+
* @stable
|
|
39
|
+
*/
|
|
40
|
+
export function cipherIntegrityCheck(conn: SqliteConnection): CipherIntegrityCheckResult {
|
|
41
|
+
const started = performance.now();
|
|
42
|
+
const raw = conn.pragma('integrity_check');
|
|
43
|
+
const durationMs = performance.now() - started;
|
|
44
|
+
const rows = normalizeRows(raw);
|
|
45
|
+
const ok = rows.length === 1 && rows[0] === 'ok';
|
|
46
|
+
return Object.freeze({ ok, rows: Object.freeze(rows), durationMs });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeRows(raw: unknown): string[] {
|
|
50
|
+
if (!Array.isArray(raw)) return [];
|
|
51
|
+
const out: string[] = [];
|
|
52
|
+
for (const row of raw as ReadonlyArray<unknown>) {
|
|
53
|
+
if (typeof row === 'string') {
|
|
54
|
+
out.push(row);
|
|
55
|
+
} else if (row !== null && typeof row === 'object') {
|
|
56
|
+
const obj = row as Record<string, unknown>;
|
|
57
|
+
const value = obj.cipher_integrity_check ?? obj.integrity_check ?? Object.values(obj)[0];
|
|
58
|
+
if (typeof value === 'string') out.push(value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
package/src/rekey.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rekeyDatabase` - re-keys an already encrypted SQLite database. The
|
|
3
|
+
* runner backs `graphorin storage rekey`.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: open the DB through the cipher peer with the **old** key,
|
|
6
|
+
* then issue `PRAGMA rekey = <new>;`. The cipher peer rewrites every
|
|
7
|
+
* page transactionally. After the pragma we re-open the DB with the
|
|
8
|
+
* new key and run `PRAGMA cipher_integrity_check` to confirm the
|
|
9
|
+
* rotation succeeded.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_CIPHER,
|
|
19
|
+
type EncryptionCipher,
|
|
20
|
+
encodePassphraseForPragma,
|
|
21
|
+
} from './cipher-config.js';
|
|
22
|
+
import { loadCipherPeer } from './cipher-peer.js';
|
|
23
|
+
import { createEncryptedConnection } from './connection.js';
|
|
24
|
+
import { cipherIntegrityCheck } from './integrity-check.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Options for {@link rekeyDatabase}.
|
|
28
|
+
*
|
|
29
|
+
* @stable
|
|
30
|
+
*/
|
|
31
|
+
export interface RekeyDatabaseOptions {
|
|
32
|
+
/** Path to the encrypted DB. */
|
|
33
|
+
readonly path: string;
|
|
34
|
+
/** Existing passphrase the DB is currently encrypted with. */
|
|
35
|
+
readonly oldPassphrase: string | Buffer;
|
|
36
|
+
/** New passphrase to apply. */
|
|
37
|
+
readonly newPassphrase: string | Buffer;
|
|
38
|
+
/** Cipher selection. Default `'sqlcipher'`. */
|
|
39
|
+
readonly cipher?: EncryptionCipher;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Result of a successful {@link rekeyDatabase} run.
|
|
44
|
+
*
|
|
45
|
+
* @stable
|
|
46
|
+
*/
|
|
47
|
+
export interface RekeyDatabaseResult {
|
|
48
|
+
readonly path: string;
|
|
49
|
+
readonly cipher: EncryptionCipher;
|
|
50
|
+
readonly integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Re-keys an encrypted SQLite database. Throws if the file is missing,
|
|
55
|
+
* the cipher peer cannot be loaded, the old passphrase is wrong (the
|
|
56
|
+
* cipher peer raises `SQLITE_NOTADB` on the first read), or the
|
|
57
|
+
* post-rekey integrity check fails.
|
|
58
|
+
*
|
|
59
|
+
* @stable
|
|
60
|
+
*/
|
|
61
|
+
export async function rekeyDatabase(options: RekeyDatabaseOptions): Promise<RekeyDatabaseResult> {
|
|
62
|
+
const path = absolute(options.path);
|
|
63
|
+
const cipher = options.cipher ?? DEFAULT_CIPHER;
|
|
64
|
+
|
|
65
|
+
if (!existsSync(path)) {
|
|
66
|
+
throw new Error(`[graphorin/store-sqlite-encrypted] DB not found: ${path}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await loadCipherPeer();
|
|
70
|
+
|
|
71
|
+
const conn = await createEncryptedConnection({
|
|
72
|
+
path,
|
|
73
|
+
skipSqliteVec: true,
|
|
74
|
+
disableWalHardening: true,
|
|
75
|
+
encryption: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
cipher,
|
|
78
|
+
passphraseResolver: async () => options.oldPassphrase,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
// Sanity-read forces the cipher peer to verify the old key before
|
|
83
|
+
// we rewrite pages with the new one.
|
|
84
|
+
conn.pragma('user_version');
|
|
85
|
+
// sqlite3mc refuses `rekey` in WAL journal mode (real-peer
|
|
86
|
+
// verified) - drop to DELETE for the rotation, restore after.
|
|
87
|
+
conn.pragma('journal_mode = DELETE');
|
|
88
|
+
const newEncoded = encodePassphraseForPragma(options.newPassphrase);
|
|
89
|
+
conn.pragma(`rekey = ${newEncoded}`);
|
|
90
|
+
conn.pragma('journal_mode = WAL');
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (conn.raw().open) conn.close();
|
|
93
|
+
throw new Error(`[graphorin/store-sqlite-encrypted] rekey failed: ${(err as Error).message}`, {
|
|
94
|
+
cause: err,
|
|
95
|
+
});
|
|
96
|
+
} finally {
|
|
97
|
+
if (conn.raw().open) conn.close();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const verify = await createEncryptedConnection({
|
|
101
|
+
path,
|
|
102
|
+
skipSqliteVec: true,
|
|
103
|
+
disableWalHardening: true,
|
|
104
|
+
encryption: {
|
|
105
|
+
enabled: true,
|
|
106
|
+
cipher,
|
|
107
|
+
passphraseResolver: async () => options.newPassphrase,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
let integrityCheck: { readonly ok: boolean; readonly rows: ReadonlyArray<string> };
|
|
111
|
+
try {
|
|
112
|
+
const result = cipherIntegrityCheck(verify);
|
|
113
|
+
integrityCheck = { ok: result.ok, rows: result.rows };
|
|
114
|
+
} finally {
|
|
115
|
+
verify.close();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!integrityCheck.ok) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
'[graphorin/store-sqlite-encrypted] post-rekey integrity check failed: ' +
|
|
121
|
+
integrityCheck.rows.join('; '),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Object.freeze({ path, cipher, integrityCheck: Object.freeze(integrityCheck) });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function absolute(p: string): string {
|
|
129
|
+
return isAbsolute(p) ? p : resolve(p);
|
|
130
|
+
}
|