@captainsafia/burrow 1.3.0 → 2.0.0-preview.7add0dd

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.
Files changed (3) hide show
  1. package/README.md +18 -2
  2. package/dist/api.js +210 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # burrow
2
2
 
3
- Burrow is a platform-agnostic, directory-scoped secrets manager. Secrets are stored outside your repos in a local SQLite store and exportable to various formats via the CLI. For a nicer dev experience, Burrow currently stores secrets in a plain-text format outside the target repo, which means that secrets can still be leaked to other users on your machine or people who gain access to your device. But, for your day-to-day dev use, this beats keeping secrets in gitignored files in your repo.
3
+ Burrow is a platform-agnostic, directory-scoped secrets manager. Secrets are stored outside your repos in a local SQLite store and exportable to various formats via the CLI. Secret values are encrypted at rest before being written to SQLite.
4
4
 
5
5
  <p align="center">
6
6
  <img src="demo.gif" alt="burrow demo" width="600">
@@ -19,7 +19,7 @@ Burrow is a platform-agnostic, directory-scoped secrets manager. Secrets are sto
19
19
  **Linux/macOS:**
20
20
 
21
21
  ```bash
22
- curl -fsSL https://safia.rocks/burrow/install.sh | sh
22
+ curl -fsSL https://i.captainsafia.sh/captainsafia/burrow | sh
23
23
  ```
24
24
 
25
25
  ## Usage
@@ -98,6 +98,22 @@ When you request secrets for a directory, burrow:
98
98
  3. Deeper scopes override shallower ones
99
99
  4. Tombstones (from `unset`) block inheritance
100
100
 
101
+ ### Encryption at rest
102
+
103
+ Burrow encrypts non-null secret values with AES-256-GCM before writing them to `store.db`.
104
+
105
+ - **Primary key storage:** Burrow uses `Bun.secrets` to store a per-config encryption key in the OS credential store (Keychain on macOS, libsecret providers on Linux, and Credential Manager on Windows).
106
+ - **Headless fallback:** if `Bun.secrets` is unavailable (for example in minimal CI containers without a running secret service), Burrow stores the key in `store.key` in the config directory with restrictive permissions on Unix.
107
+
108
+ ### Migration strategy
109
+
110
+ Burrow uses a versioned database schema and encrypted payload format:
111
+
112
+ 1. **Schema migration (v1 → v2):** on first access, Burrow upgrades legacy stores to schema version 2.
113
+ 2. **In-place value migration:** all non-null plaintext values are encrypted and written back in place.
114
+ 3. **Idempotent migration:** values already marked as encrypted are skipped, so migration can be safely retried.
115
+ 4. **Future encrypted migrations:** encrypted payloads include a version prefix (`burrow:enc:v1:`), so future formats can be migrated deterministically.
116
+
101
117
  ## Library Usage
102
118
 
103
119
  Burrow also works as a TypeScript/JavaScript library:
package/dist/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/storage/index.ts
2
2
  import { Database } from "bun:sqlite";
3
- import { chmod, mkdir } from "node:fs/promises";
4
- import { join as join2 } from "node:path";
3
+ import { chmod as chmod2, mkdir } from "node:fs/promises";
4
+ import { join as join3 } from "node:path";
5
5
 
6
6
  // src/platform/index.ts
7
7
  import { homedir } from "node:os";
@@ -43,19 +43,188 @@ function isWindows() {
43
43
  return process.platform === "win32";
44
44
  }
45
45
 
46
+ // src/storage/encryption.ts
47
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
48
+ import { chmod, readFile, writeFile } from "node:fs/promises";
49
+ import { join as join2 } from "node:path";
50
+ var ENCRYPTION_SERVICE = "burrow.safia.dev";
51
+ var LEGACY_ENCRYPTION_SERVICE = "burrow";
52
+ var ENCRYPTION_KEY_NAME_PREFIX = "store-key";
53
+ var ENCRYPTION_KEY_FILE = "store.key";
54
+ var ENCRYPTION_KEY_BYTES = 32;
55
+ var ENCRYPTION_IV_BYTES = 12;
56
+ var ENCRYPTION_AUTH_TAG_BYTES = 16;
57
+ var ANY_ENCRYPTED_VALUE_PREFIX = "burrow:enc:";
58
+ var ENCRYPTED_VALUE_PREFIX_V1 = "burrow:enc:v1:";
59
+
60
+ class SecretValueEncryptor {
61
+ keySecretName;
62
+ fallbackKeyPath;
63
+ keyPromise;
64
+ constructor(configDir) {
65
+ const configHash = createHash("sha256").update(configDir).digest("hex");
66
+ this.keySecretName = `${ENCRYPTION_KEY_NAME_PREFIX}-${configHash}`;
67
+ this.fallbackKeyPath = join2(configDir, ENCRYPTION_KEY_FILE);
68
+ }
69
+ isEncryptedValue(value) {
70
+ return value.startsWith(ANY_ENCRYPTED_VALUE_PREFIX);
71
+ }
72
+ async encrypt(plainText) {
73
+ const key = await this.getOrCreateKey();
74
+ const iv = randomBytes(ENCRYPTION_IV_BYTES);
75
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
76
+ const ciphertext = Buffer.concat([
77
+ cipher.update(plainText, "utf8"),
78
+ cipher.final()
79
+ ]);
80
+ const authTag = cipher.getAuthTag();
81
+ return `${ENCRYPTED_VALUE_PREFIX_V1}${iv.toString("base64")}.${authTag.toString("base64")}.${ciphertext.toString("base64")}`;
82
+ }
83
+ async decrypt(value) {
84
+ if (!this.isEncryptedValue(value)) {
85
+ return value;
86
+ }
87
+ if (!value.startsWith(ENCRYPTED_VALUE_PREFIX_V1)) {
88
+ throw new Error("Unsupported encrypted value format in secrets store");
89
+ }
90
+ const encoded = value.slice(ENCRYPTED_VALUE_PREFIX_V1.length);
91
+ const [ivBase64, authTagBase64, ciphertextBase64, ...rest] = encoded.split(".");
92
+ if (rest.length > 0 || ivBase64 === undefined || authTagBase64 === undefined || ciphertextBase64 === undefined) {
93
+ throw new Error("Malformed encrypted value in secrets store");
94
+ }
95
+ const iv = Buffer.from(ivBase64, "base64");
96
+ const authTag = Buffer.from(authTagBase64, "base64");
97
+ const ciphertext = Buffer.from(ciphertextBase64, "base64");
98
+ if (iv.byteLength !== ENCRYPTION_IV_BYTES) {
99
+ throw new Error("Malformed encrypted value (invalid IV length)");
100
+ }
101
+ if (authTag.byteLength !== ENCRYPTION_AUTH_TAG_BYTES) {
102
+ throw new Error("Malformed encrypted value (invalid auth tag length)");
103
+ }
104
+ const key = await this.getOrCreateKey();
105
+ try {
106
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
107
+ decipher.setAuthTag(authTag);
108
+ const decrypted = Buffer.concat([
109
+ decipher.update(ciphertext),
110
+ decipher.final()
111
+ ]);
112
+ return decrypted.toString("utf8");
113
+ } catch {
114
+ throw new Error("Failed to decrypt value from secrets store. The encryption key may be unavailable or has changed.");
115
+ }
116
+ }
117
+ async getOrCreateKey() {
118
+ this.keyPromise ??= this.loadOrCreateKey();
119
+ return this.keyPromise;
120
+ }
121
+ async loadOrCreateKey() {
122
+ const fallbackKey = await this.readFallbackKey();
123
+ if (fallbackKey) {
124
+ await this.tryStoreInBunSecrets(fallbackKey);
125
+ return fallbackKey;
126
+ }
127
+ const bunSecretsKey = await this.readKeyFromBunSecrets();
128
+ if (bunSecretsKey) {
129
+ return bunSecretsKey;
130
+ }
131
+ const generatedKey = randomBytes(ENCRYPTION_KEY_BYTES);
132
+ const storedInBunSecrets = await this.tryStoreInBunSecrets(generatedKey);
133
+ if (!storedInBunSecrets) {
134
+ await this.writeFallbackKey(generatedKey);
135
+ }
136
+ return generatedKey;
137
+ }
138
+ async readKeyFromBunSecrets() {
139
+ let keyMaterial;
140
+ try {
141
+ keyMaterial = await Bun.secrets.get({
142
+ service: ENCRYPTION_SERVICE,
143
+ name: this.keySecretName
144
+ });
145
+ } catch {
146
+ return;
147
+ }
148
+ if (keyMaterial === null) {
149
+ keyMaterial = await this.readKeyFromLegacyService();
150
+ if (keyMaterial === null) {
151
+ return;
152
+ }
153
+ const key = parseKeyMaterial(keyMaterial, `Bun.secrets (${LEGACY_ENCRYPTION_SERVICE})`);
154
+ await this.tryStoreInBunSecrets(key);
155
+ return key;
156
+ }
157
+ return parseKeyMaterial(keyMaterial, "Bun.secrets");
158
+ }
159
+ async tryStoreInBunSecrets(key) {
160
+ try {
161
+ await Bun.secrets.set({
162
+ service: ENCRYPTION_SERVICE,
163
+ name: this.keySecretName,
164
+ value: key.toString("base64")
165
+ });
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+ async readKeyFromLegacyService() {
172
+ try {
173
+ return await Bun.secrets.get({
174
+ service: LEGACY_ENCRYPTION_SERVICE,
175
+ name: this.keySecretName
176
+ });
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+ async readFallbackKey() {
182
+ try {
183
+ const keyMaterial = await readFile(this.fallbackKeyPath, "utf8");
184
+ return parseKeyMaterial(keyMaterial, this.fallbackKeyPath);
185
+ } catch (error) {
186
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
187
+ return;
188
+ }
189
+ throw error;
190
+ }
191
+ }
192
+ async writeFallbackKey(key) {
193
+ await writeFile(this.fallbackKeyPath, `${key.toString("base64")}
194
+ `, "utf8");
195
+ if (!isWindows()) {
196
+ await chmod(this.fallbackKeyPath, 384);
197
+ }
198
+ }
199
+ }
200
+ function parseKeyMaterial(value, source) {
201
+ const trimmed = value.trim();
202
+ if (trimmed.length === 0) {
203
+ throw new Error(`Encryption key in ${source} is empty`);
204
+ }
205
+ const key = Buffer.from(trimmed, "base64");
206
+ if (key.byteLength !== ENCRYPTION_KEY_BYTES) {
207
+ throw new Error(`Encryption key in ${source} must be ${ENCRYPTION_KEY_BYTES} bytes`);
208
+ }
209
+ return key;
210
+ }
211
+
46
212
  // src/storage/index.ts
47
213
  var DEFAULT_STORE_FILE = "store.db";
214
+ var STORE_VERSION = 2;
48
215
 
49
216
  class Storage {
50
217
  configDir;
51
218
  storeFileName;
219
+ encryptor;
52
220
  db = null;
53
221
  constructor(options = {}) {
54
222
  this.configDir = options.configDir ?? getConfigDir();
55
223
  this.storeFileName = options.storeFileName ?? DEFAULT_STORE_FILE;
224
+ this.encryptor = new SecretValueEncryptor(this.configDir);
56
225
  }
57
226
  get storePath() {
58
- return join2(this.configDir, this.storeFileName);
227
+ return join3(this.configDir, this.storeFileName);
59
228
  }
60
229
  async ensureDb() {
61
230
  if (this.db) {
@@ -63,11 +232,11 @@ class Storage {
63
232
  }
64
233
  await mkdir(this.configDir, { recursive: true });
65
234
  if (!isWindows()) {
66
- await chmod(this.configDir, 448);
235
+ await chmod2(this.configDir, 448);
67
236
  }
68
237
  this.db = new Database(this.storePath);
69
238
  if (!isWindows()) {
70
- await chmod(this.storePath, 384);
239
+ await chmod2(this.storePath, 384);
71
240
  }
72
241
  this.db.run("PRAGMA journal_mode = WAL");
73
242
  this.db.run(`
@@ -90,23 +259,52 @@ class Storage {
90
259
  this.db.run("CREATE INDEX IF NOT EXISTS idx_trusted_paths_inode ON trusted_paths (inode)");
91
260
  const versionResult = this.db.query("PRAGMA user_version").get();
92
261
  const currentVersion = versionResult?.user_version ?? 0;
93
- if (currentVersion === 0) {
94
- this.db.run("PRAGMA user_version = 1");
95
- } else if (currentVersion !== 1) {
96
- throw new Error(`Unsupported store version: ${currentVersion}. Expected: 1`);
262
+ if (currentVersion === 0 || currentVersion === 1) {
263
+ await this.migrateValuesToEncryption(this.db);
264
+ this.db.run(`PRAGMA user_version = ${STORE_VERSION}`);
265
+ } else if (currentVersion !== STORE_VERSION) {
266
+ throw new Error(`Unsupported store version: ${currentVersion}. Expected: ${STORE_VERSION}`);
97
267
  }
98
268
  return this.db;
99
269
  }
270
+ async migrateValuesToEncryption(db) {
271
+ const rows = db.query("SELECT path, key, value FROM secrets WHERE value IS NOT NULL").all();
272
+ if (rows.length === 0) {
273
+ return;
274
+ }
275
+ const updates = [];
276
+ for (const row of rows) {
277
+ if (this.encryptor.isEncryptedValue(row.value)) {
278
+ continue;
279
+ }
280
+ updates.push({
281
+ path: row.path,
282
+ key: row.key,
283
+ value: await this.encryptor.encrypt(row.value)
284
+ });
285
+ }
286
+ if (updates.length === 0) {
287
+ return;
288
+ }
289
+ const updateSecret = db.query("UPDATE secrets SET value = ? WHERE path = ? AND key = ?");
290
+ const applyUpdates = db.transaction((pendingUpdates) => {
291
+ for (const update of pendingUpdates) {
292
+ updateSecret.run(update.value, update.path, update.key);
293
+ }
294
+ });
295
+ applyUpdates(updates);
296
+ }
100
297
  async setSecret(canonicalPath, key, value) {
101
298
  const db = await this.ensureDb();
102
299
  const updatedAt = new Date().toISOString();
300
+ const storedValue = value === null ? null : await this.encryptor.encrypt(value);
103
301
  db.query(`
104
302
  INSERT INTO secrets (path, key, value, updated_at)
105
303
  VALUES (?, ?, ?, ?)
106
304
  ON CONFLICT(path, key) DO UPDATE SET
107
305
  value = excluded.value,
108
306
  updated_at = excluded.updated_at
109
- `).run(canonicalPath, key, value, updatedAt);
307
+ `).run(canonicalPath, key, storedValue, updatedAt);
110
308
  }
111
309
  async getPathSecrets(canonicalPath) {
112
310
  const db = await this.ensureDb();
@@ -116,8 +314,9 @@ class Storage {
116
314
  }
117
315
  const secrets = {};
118
316
  for (const row of rows) {
317
+ const decodedValue = row.value === null ? null : await this.encryptor.decrypt(row.value);
119
318
  secrets[row.key] = {
120
- value: row.value,
319
+ value: decodedValue,
121
320
  updatedAt: row.updated_at
122
321
  };
123
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@captainsafia/burrow",
3
- "version": "1.3.0",
3
+ "version": "2.0.0-preview.7add0dd",
4
4
  "description": "Platform-agnostic, directory-scoped secrets manager",
5
5
  "type": "module",
6
6
  "main": "dist/api.js",