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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# burrow

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.
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 disk.

<p align="center">
<img src="demo.gif" alt="burrow demo" width="600">
Expand Down Expand Up @@ -91,6 +91,21 @@ Secrets are stored in your user profile:
- **Linux/macOS:** `$XDG_CONFIG_HOME/burrow` or `~/.config/burrow`
- **Windows:** `%APPDATA%\burrow`

Encryption keys are stored in the OS-specific secure store (not in your Burrow config directory):
- **macOS:** Keychain (via `Bun.secrets`)
- **Linux:** Secret Service / keyring (via `Bun.secrets` + libsecret backend)
- **Windows:** Credential Manager (via `Bun.secrets`)

Burrow will auto-discover a user D-Bus session bus on Linux (`$XDG_RUNTIME_DIR/bus` or `/run/user/$UID/bus`) when `DBUS_SESSION_BUS_ADDRESS` is missing, which helps in SSH sessions.
If your keyring collection is locked, Burrow may fail until you unlock it (for example, via your desktop keyring prompt).

For headless environments (for example, CI), you can provide a key explicitly:

```bash
# 64-char hex (32 bytes)
export BURROW_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

When you request secrets for a directory, burrow:

1. Finds all ancestor paths with stored secrets
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@
<section class="hero">
<div class="container">
<h1>Directory-scoped secrets manager</h1>
<p>Store secrets outside your repos, inherit them through directory ancestry. Export to any shell format.</p>
<p>Store secrets outside your repos, encrypted at rest, and inherited through directory ancestry. Export to any shell format.</p>

<div class="hero-buttons">
<a href="#docs" class="btn-primary">Get Started</a>
Expand Down Expand Up @@ -813,7 +813,7 @@ <h3>Directory Scoping</h3>
</div>
<div class="feature">
<h3>Outside Repos</h3>
<p>Secrets stored in your user profile directory, never accidentally committed.</p>
<p>Secrets stored in your user profile directory, encrypted at rest, with keys managed by your OS secure store.</p>
</div>
<div class="feature">
<h3>Shell Export</h3>
Expand Down
228 changes: 223 additions & 5 deletions src/storage/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,72 @@
import { Database } from "bun:sqlite";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { chmod, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { getConfigDir } from "../platform/index.ts";
import { isWindows } from "../platform/index.ts";

const DEFAULT_STORE_FILE = "store.db";
const ENCRYPTION_KEY_ENV = "BURROW_ENCRYPTION_KEY";
const ENCRYPTION_KEY_SERVICE_NAME = "burrow.safia.dev";
const DBUS_SESSION_BUS_ADDRESS_ENV = "DBUS_SESSION_BUS_ADDRESS";
const XDG_RUNTIME_DIR_ENV = "XDG_RUNTIME_DIR";
const ENCRYPTED_VALUE_PREFIX = "enc:v1:";
const ENCRYPTION_ALGORITHM = "aes-256-gcm";
const ENCRYPTION_KEY_LENGTH_BYTES = 32;
const ENCRYPTION_IV_LENGTH_BYTES = 12;
const ENCRYPTION_AUTH_TAG_LENGTH_BYTES = 16;
const CURRENT_STORE_VERSION = 2;

function resolveSecretStoreBackendName(): string {
switch (process.platform) {
case "darwin":
return "macOS Keychain";
case "linux":
return "Linux Secret Service";
case "win32":
return "Windows Credential Manager";
default:
return "system secret store";
}
}

function discoverLinuxDbusSessionBusAddress(): string | undefined {
const xdgRuntimeDir = process.env[XDG_RUNTIME_DIR_ENV]?.trim();
const candidatePaths: string[] = [];

if (xdgRuntimeDir) {
candidatePaths.push(join(xdgRuntimeDir, "bus"));
}

if (typeof process.getuid === "function") {
candidatePaths.push(join("/run/user", String(process.getuid()), "bus"));
}

for (const candidatePath of candidatePaths) {
if (existsSync(candidatePath)) {
return `unix:path=${candidatePath}`;
}
}

return undefined;
}

function ensureLinuxDbusSessionBusAddress(): void {
if (process.platform !== "linux") {
return;
}

const currentAddress = process.env[DBUS_SESSION_BUS_ADDRESS_ENV]?.trim();
if (currentAddress) {
return;
}

const discoveredAddress = discoverLinuxDbusSessionBusAddress();
if (discoveredAddress) {
process.env[DBUS_SESSION_BUS_ADDRESS_ENV] = discoveredAddress;
}
}

export interface SecretEntry {
value: string | null;
Expand All @@ -29,17 +91,155 @@ export interface StorageOptions {
export class Storage {
private readonly configDir: string;
private readonly storeFileName: string;
private readonly keyIdentifier: string;
private db: Database | null = null;
private encryptionKey: Buffer | null = null;

constructor(options: StorageOptions = {}) {
this.configDir = options.configDir ?? getConfigDir();
this.storeFileName = options.storeFileName ?? DEFAULT_STORE_FILE;
this.keyIdentifier = createHash("sha256")
.update(`${this.configDir}:${this.storeFileName}`)
.digest("hex");
}

private get storePath(): string {
return join(this.configDir, this.storeFileName);
}

private parseStoredKey(content: string): Buffer {
const trimmed = content.trim();
if (!/^[a-fA-F0-9]{64}$/.test(trimmed)) {
throw new Error("Invalid encryption key format");
}
return Buffer.from(trimmed, "hex");
}

private parseEnvEncryptionKey(envValue: string): Buffer {
const trimmed = envValue.trim();

if (/^[a-fA-F0-9]{64}$/.test(trimmed)) {
return Buffer.from(trimmed, "hex");
}

const decoded = Buffer.from(trimmed, "base64");
if (decoded.length === ENCRYPTION_KEY_LENGTH_BYTES) {
return decoded;
}

throw new Error(
`Invalid ${ENCRYPTION_KEY_ENV} value. Expected 64-char hex or base64-encoded 32-byte key.`
);
}

private async ensureEncryptionKey(): Promise<Buffer> {
if (this.encryptionKey) {
return this.encryptionKey;
}

const envKey = process.env[ENCRYPTION_KEY_ENV];
if (envKey) {
this.encryptionKey = this.parseEnvEncryptionKey(envKey);
return this.encryptionKey;
}

ensureLinuxDbusSessionBusAddress();

try {
const existingKey = await Bun.secrets.get({
service: ENCRYPTION_KEY_SERVICE_NAME,
name: this.keyIdentifier,
});
if (existingKey !== null) {
this.encryptionKey = this.parseStoredKey(existingKey);
return this.encryptionKey;
}

const generatedKey = randomBytes(ENCRYPTION_KEY_LENGTH_BYTES);
await Bun.secrets.set({
service: ENCRYPTION_KEY_SERVICE_NAME,
name: this.keyIdentifier,
value: generatedKey.toString("hex"),
});
this.encryptionKey = generatedKey;
return generatedKey;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to access encryption key in ${resolveSecretStoreBackendName()}: ${message}. ` +
`Set ${ENCRYPTION_KEY_ENV} to a 32-byte key as fallback.`
);
}
}

private encryptValue(value: string, key: Buffer): string {
const iv = randomBytes(ENCRYPTION_IV_LENGTH_BYTES);
const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload = Buffer.concat([iv, authTag, ciphertext]).toString("base64");
return `${ENCRYPTED_VALUE_PREFIX}${payload}`;
}

private decryptValue(value: string, key: Buffer): string {
const payload = Buffer.from(value.slice(ENCRYPTED_VALUE_PREFIX.length), "base64");
if (payload.length < ENCRYPTION_IV_LENGTH_BYTES + ENCRYPTION_AUTH_TAG_LENGTH_BYTES) {
throw new Error("Encrypted secret payload is corrupted");
}

const iv = payload.subarray(0, ENCRYPTION_IV_LENGTH_BYTES);
const authTag = payload.subarray(
ENCRYPTION_IV_LENGTH_BYTES,
ENCRYPTION_IV_LENGTH_BYTES + ENCRYPTION_AUTH_TAG_LENGTH_BYTES
);
const ciphertext = payload.subarray(
ENCRYPTION_IV_LENGTH_BYTES + ENCRYPTION_AUTH_TAG_LENGTH_BYTES
);

try {
const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
} catch {
throw new Error(
"Failed to decrypt a stored secret. The encryption key may be missing or mismatched."
);
}
}

private async migrateV1SecretsToEncrypted(db: Database): Promise<void> {
const rows = db
.query<{ path: string; key: string; value: string }, []>(
"SELECT path, key, value FROM secrets WHERE value IS NOT NULL"
)
.all();

if (rows.length === 0) {
return;
}

const encryptionKey = await this.ensureEncryptionKey();
const updateStatement = db.query(
"UPDATE secrets SET value = ? WHERE path = ? AND key = ?"
);

db.run("BEGIN IMMEDIATE");
try {
for (const row of rows) {
if (row.value.startsWith(ENCRYPTED_VALUE_PREFIX)) {
continue;
}

const encryptedValue = this.encryptValue(row.value, encryptionKey);
updateStatement.run(encryptedValue, row.path, row.key);
}
db.run("COMMIT");
} catch (error) {
db.run("ROLLBACK");
throw error;
}
}

private async ensureDb(): Promise<Database> {
if (this.db) {
return this.db;
Expand Down Expand Up @@ -89,10 +289,13 @@ export class Storage {
const currentVersion = versionResult?.user_version ?? 0;

if (currentVersion === 0) {
this.db.run("PRAGMA user_version = 1");
} else if (currentVersion !== 1) {
this.db.run(`PRAGMA user_version = ${CURRENT_STORE_VERSION}`);
} else if (currentVersion === 1) {
await this.migrateV1SecretsToEncrypted(this.db);
this.db.run(`PRAGMA user_version = ${CURRENT_STORE_VERSION}`);
} else if (currentVersion !== CURRENT_STORE_VERSION) {
throw new Error(
`Unsupported store version: ${currentVersion}. Expected: 1`
`Unsupported store version: ${currentVersion}. Expected: ${CURRENT_STORE_VERSION}`
);
}

Expand All @@ -106,14 +309,16 @@ export class Storage {
): Promise<void> {
const db = await this.ensureDb();
const updatedAt = new Date().toISOString();
const encryptedValue =
value === null ? null : this.encryptValue(value, await this.ensureEncryptionKey());

db.query(`
INSERT INTO secrets (path, key, value, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(path, key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`).run(canonicalPath, key, value, updatedAt);
`).run(canonicalPath, key, encryptedValue, updatedAt);
}

async getPathSecrets(canonicalPath: string): Promise<PathSecrets | undefined> {
Expand All @@ -129,10 +334,22 @@ export class Storage {
return undefined;
}

const hasEncryptedRows = rows.some(
(row) => row.value !== null && row.value.startsWith(ENCRYPTED_VALUE_PREFIX)
);
const encryptionKey = hasEncryptedRows ? await this.ensureEncryptionKey() : null;

const secrets: PathSecrets = {};
for (const row of rows) {
const value =
row.value === null
? null
: row.value.startsWith(ENCRYPTED_VALUE_PREFIX)
? this.decryptValue(row.value, encryptionKey!)
: row.value;

secrets[row.key] = {
value: row.value,
value,
updatedAt: row.updated_at,
};
}
Expand Down Expand Up @@ -350,6 +567,7 @@ export class Storage {
this.db.close();
this.db = null;
}
this.encryptionKey = null;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { BurrowClient } from "../src/api.ts";
import { ensureTestEncryptionKey } from "./helpers/encryption-key.ts";

ensureTestEncryptionKey();

describe("BurrowClient", () => {
let testDir: string;
Expand Down
6 changes: 6 additions & 0 deletions tests/helpers/encryption-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const TEST_ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

export function ensureTestEncryptionKey(): void {
process.env["BURROW_ENCRYPTION_KEY"] = TEST_ENCRYPTION_KEY;
}
3 changes: 3 additions & 0 deletions tests/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { BurrowClient } from "../src/api.ts";
import { ensureTestEncryptionKey } from "./helpers/encryption-key.ts";

ensureTestEncryptionKey();

describe("Hook functionality", () => {
let testDir: string;
Expand Down
Loading