Skip to content
Merged
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
43 changes: 33 additions & 10 deletions src/domain/vaults/local_encryption_vault_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,19 +266,42 @@ export class LocalEncryptionVaultProvider implements VaultProvider {
false,
["deriveKey"],
);
} catch {
// Generate new key if it doesn't exist
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw error;
}
// Key file doesn't exist — generate a new one with exclusive creation
await this.ensureVaultDirectory();
const generatedKey = crypto.randomUUID() + crypto.randomUUID(); // 72 chars
await atomicWriteTextFile(keyFile, generatedKey, { mode: 0o600 });

return await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(generatedKey),
{ name: "PBKDF2" },
false,
["deriveKey"],
);
try {
// createNew: true uses O_CREAT | O_EXCL — atomic exclusive creation
// prevents TOCTOU race where two processes both generate different keys
await Deno.writeTextFile(keyFile, generatedKey, {
createNew: true,
mode: 0o600,
});
return await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(generatedKey),
{ name: "PBKDF2" },
false,
["deriveKey"],
);
} catch (writeError) {
if (!(writeError instanceof Deno.errors.AlreadyExists)) {
throw writeError;
}
// Another process won the race — read back their key
const winnerKey = await Deno.readTextFile(keyFile);
return await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(winnerKey),
{ name: "PBKDF2" },
false,
["deriveKey"],
);
}
}
}

Expand Down
43 changes: 43 additions & 0 deletions src/domain/vaults/local_encryption_vault_provider_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,49 @@ Deno.test("LocalEncryptionVaultProvider - auto-generated keys", async (t) => {
});
});

await t.step(
"should handle concurrent key generation safely",
async () => {
await withTempDir(async (dir) => {
const vaultSecretsDir = secretsDir(dir, "concurrent-vault");
const config: LocalEncryptionConfig = {
auto_generate: true,
base_dir: dir,
};

// Create two vault instances pointing at the same vault directory
const vault1 = new LocalEncryptionVaultProvider(
"concurrent-vault",
config,
);
const vault2 = new LocalEncryptionVaultProvider(
"concurrent-vault",
config,
);

// Concurrently put secrets from both instances
await Promise.all([
vault1.put("secret-from-1", "value1"),
vault2.put("secret-from-2", "value2"),
]);

// Both vaults should be able to read each other's secrets,
// proving they share the same encryption key
assertEquals(await vault1.get("secret-from-2"), "value2");
assertEquals(await vault2.get("secret-from-1"), "value1");

// Only one .key file should exist on disk
const keyEntries: string[] = [];
for await (const entry of Deno.readDir(vaultSecretsDir)) {
if (entry.name === ".key") {
keyEntries.push(entry.name);
}
}
assertEquals(keyEntries.length, 1);
});
},
);

await t.step(
"should fall back to auto-generate when SSH key fails",
async () => {
Expand Down