@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.
- package/README.md +18 -2
- package/dist/api.js +210 -11
- 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.
|
|
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://
|
|
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
|
|
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
|
|
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
|
|
235
|
+
await chmod2(this.configDir, 448);
|
|
67
236
|
}
|
|
68
237
|
this.db = new Database(this.storePath);
|
|
69
238
|
if (!isWindows()) {
|
|
70
|
-
await
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
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:
|
|
319
|
+
value: decodedValue,
|
|
121
320
|
updatedAt: row.updated_at
|
|
122
321
|
};
|
|
123
322
|
}
|