diff --git a/src/domain/vaults/local_encryption_vault_provider.ts b/src/domain/vaults/local_encryption_vault_provider.ts index dad17b2..36e6a0a 100644 --- a/src/domain/vaults/local_encryption_vault_provider.ts +++ b/src/domain/vaults/local_encryption_vault_provider.ts @@ -200,20 +200,7 @@ export class LocalEncryptionVaultProvider implements VaultProvider { // Try SSH key if explicitly configured if (this.config.ssh_key_path) { try { - const expandedPath = this.config.ssh_key_path.startsWith("~/") - ? this.config.ssh_key_path.replace("~/", `${Deno.env.get("HOME")}/`) - : this.config.ssh_key_path; - - const sshKeyContent = await Deno.readTextFile(expandedPath); - const keyBytes = new TextEncoder().encode(sshKeyContent); - - return await crypto.subtle.importKey( - "raw", - keyBytes, - { name: "PBKDF2" }, - false, - ["deriveKey"], - ); + return await this.readAndValidateSshKey(this.config.ssh_key_path); } catch (error) { if (!this.config.auto_generate) { throw new Error( @@ -230,20 +217,7 @@ export class LocalEncryptionVaultProvider implements VaultProvider { if (!this.config.ssh_key_path && !this.config.auto_generate) { const defaultSshKeyPath = "~/.ssh/id_rsa"; try { - const expandedPath = defaultSshKeyPath.replace( - "~/", - `${Deno.env.get("HOME")}/`, - ); - const sshKeyContent = await Deno.readTextFile(expandedPath); - const keyBytes = new TextEncoder().encode(sshKeyContent); - - return await crypto.subtle.importKey( - "raw", - keyBytes, - { name: "PBKDF2" }, - false, - ["deriveKey"], - ); + return await this.readAndValidateSshKey(defaultSshKeyPath); } catch (error) { throw new Error( `Failed to read default SSH key from '${defaultSshKeyPath}' for local vault '${this.name}': ${ @@ -312,6 +286,128 @@ export class LocalEncryptionVaultProvider implements VaultProvider { ); } + /** + * Reads an SSH private key file, validates its permissions and encryption + * status, extracts binary key material from the PEM envelope, and imports + * it as PBKDF2 key material. + */ + private async readAndValidateSshKey( + sshKeyPath: string, + ): Promise { + const expandedPath = sshKeyPath.startsWith("~/") + ? sshKeyPath.replace("~/", `${Deno.env.get("HOME")}/`) + : sshKeyPath; + + await this.validateSshKeyPermissions(expandedPath); + const content = await Deno.readTextFile(expandedPath); + + // Detect PEM encryption before extraction (text-based check avoids + // base64 decode failures on header lines like Proc-Type) + this.detectEncryptedPemKey(content); + + const decodedBytes = this.extractPemKeyMaterial(content); + + // Detect OpenSSH encryption after extraction (needs decoded bytes + // to read the binary cipher name field) + this.detectEncryptedOpenSshKey(decodedBytes); + + return await crypto.subtle.importKey( + "raw", + decodedBytes.buffer as ArrayBuffer, + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); + } + + /** + * Extracts the binary key material from a PEM-encoded SSH key. + * Strips the PEM header/footer lines and decodes the base64 body, + * returning only the raw key bytes for use as PBKDF2 input. + */ + private extractPemKeyMaterial(content: string): Uint8Array { + const lines = content.split(/\r?\n/); + const base64Lines = lines.filter((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 && !trimmed.startsWith("-----"); + }); + const base64String = base64Lines.join(""); + const binaryString = atob(base64String); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + + /** + * Validates that an SSH key file has restrictive permissions (no group/other access). + * Skipped on Windows where POSIX permissions are not available. + */ + private async validateSshKeyPermissions(path: string): Promise { + if (Deno.build.os === "windows") return; + const stat = await Deno.stat(path); + if (stat.mode === null) return; + if ((stat.mode & 0o077) !== 0) { + const octal = "0o" + (stat.mode & 0o777).toString(8); + throw new Error( + `SSH key '${path}' has insecure permissions (${octal}). ` + + `Expected permissions no wider than 0o600. ` + + `Run 'chmod 600 ${path}' to fix.`, + ); + } + } + + /** + * Detects passphrase-encrypted legacy PEM keys (RSA/DSA/EC) by checking + * for the Proc-Type encryption header. + */ + private detectEncryptedPemKey(content: string): void { + if (content.includes("Proc-Type: 4,ENCRYPTED")) { + throw new Error( + `SSH key is encrypted (legacy PEM format). ` + + `Swamp cannot use passphrase-protected SSH keys for vault encryption. ` + + `Use an unencrypted SSH key or enable 'auto_generate' in vault configuration.`, + ); + } + } + + /** + * Detects passphrase-encrypted OpenSSH keys by reading the cipher name + * from the binary key format (magic "openssh-key-v1\0", then uint32 + * length-prefixed cipher name). Only rejects confirmed encrypted keys; + * parsing failures are silently allowed through. + */ + private detectEncryptedOpenSshKey(decodedBytes: Uint8Array): void { + const magic = new TextEncoder().encode("openssh-key-v1\0"); + + // Verify we have enough bytes and the magic matches + if (decodedBytes.length < magic.length + 4) return; + for (let i = 0; i < magic.length; i++) { + if (decodedBytes[i] !== magic[i]) return; + } + + // Read cipher name length (uint32 big-endian) + const offset = magic.length; + const cipherNameLen = (decodedBytes[offset] << 24) | + (decodedBytes[offset + 1] << 16) | + (decodedBytes[offset + 2] << 8) | + decodedBytes[offset + 3]; + + if (offset + 4 + cipherNameLen > decodedBytes.length) return; + const cipherName = new TextDecoder().decode( + decodedBytes.slice(offset + 4, offset + 4 + cipherNameLen), + ); + + if (cipherName !== "none") { + throw new Error( + `SSH key is encrypted (cipher: ${cipherName}). ` + + `Swamp cannot use passphrase-protected SSH keys for vault encryption. ` + + `Use an unencrypted SSH key or enable 'auto_generate' in vault configuration.`, + ); + } + } + /** * Encrypts a value using AES-GCM. */ @@ -336,7 +432,7 @@ export class LocalEncryptionVaultProvider implements VaultProvider { iv: this.arrayBufferToBase64(iv), data: this.arrayBufferToBase64(encrypted), salt: this.arrayBufferToBase64(salt), - version: 1, + version: 2, }; } diff --git a/src/domain/vaults/local_encryption_vault_provider_test.ts b/src/domain/vaults/local_encryption_vault_provider_test.ts index 1f4037f..b9c11e3 100644 --- a/src/domain/vaults/local_encryption_vault_provider_test.ts +++ b/src/domain/vaults/local_encryption_vault_provider_test.ts @@ -84,7 +84,9 @@ Deno.test("LocalEncryptionVaultProvider - SSH key-based encryption", async (t) = await t.step("should encrypt and decrypt secrets with SSH key", async () => { await withTempDir(async (dir) => { // Create a mock SSH key file - await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY); + await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); const config: LocalEncryptionConfig = { ssh_key_path: "test_ssh_key", @@ -104,7 +106,9 @@ Deno.test("LocalEncryptionVaultProvider - SSH key-based encryption", async (t) = "should create vault directory with proper permissions", async () => { await withTempDir(async (dir) => { - await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY); + await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); const vaultSecretsDir = secretsDir(dir, "secure-vault"); const config: LocalEncryptionConfig = { @@ -123,7 +127,9 @@ Deno.test("LocalEncryptionVaultProvider - SSH key-based encryption", async (t) = await t.step("should store secrets in separate encrypted files", async () => { await withTempDir(async (dir) => { - await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY); + await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); const vaultSecretsDir = secretsDir(dir, "multi-vault"); const config: LocalEncryptionConfig = { @@ -156,7 +162,7 @@ Deno.test("LocalEncryptionVaultProvider - SSH key-based encryption", async (t) = assertEquals(typeof parsed1.iv, "string"); assertEquals(typeof parsed1.data, "string"); assertEquals(typeof parsed1.salt, "string"); - assertEquals(parsed1.version, 1); + assertEquals(parsed1.version, 2); // Should not contain plaintext assertStringIncludes(content1, '"data"'); @@ -167,7 +173,9 @@ Deno.test("LocalEncryptionVaultProvider - SSH key-based encryption", async (t) = await t.step("should handle multiple secrets with same SSH key", async () => { await withTempDir(async (dir) => { - await Deno.writeTextFile("shared_ssh_key", MOCK_SSH_PRIVATE_KEY); + await Deno.writeTextFile("shared_ssh_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); const config: LocalEncryptionConfig = { ssh_key_path: "shared_ssh_key", @@ -426,7 +434,9 @@ Deno.test("LocalEncryptionVaultProvider - file permissions", async (t) => { "should create .enc files with 0o600 permissions", async () => { await withTempDir(async (dir) => { - await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY); + await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); const vaultSecretsDir = secretsDir(dir, "perms-vault"); const config: LocalEncryptionConfig = { @@ -447,7 +457,9 @@ Deno.test("LocalEncryptionVaultProvider - file permissions", async (t) => { Deno.test("LocalEncryptionVaultProvider - security properties", async (t) => { await t.step("should use different salts for different secrets", async () => { await withTempDir(async (dir) => { - await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY); + await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); const vaultSecretsDir = secretsDir(dir, "security-vault"); const config: LocalEncryptionConfig = { @@ -481,7 +493,9 @@ Deno.test("LocalEncryptionVaultProvider - security properties", async (t) => { "should use different IVs for same secret updated multiple times", async () => { await withTempDir(async (dir) => { - await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY); + await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); const vaultSecretsDir = secretsDir(dir, "iv-test-vault"); const config: LocalEncryptionConfig = { @@ -519,7 +533,9 @@ Deno.test("LocalEncryptionVaultProvider - security properties", async (t) => { "should handle special characters and unicode in secrets", async () => { await withTempDir(async (dir) => { - await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY); + await Deno.writeTextFile("test_ssh_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); const config: LocalEncryptionConfig = { ssh_key_path: "test_ssh_key", @@ -770,3 +786,178 @@ Deno.test("LocalEncryptionVaultProvider - path traversal prevention", async (t) }, ); }); + +/** + * Builds a mock encrypted OpenSSH private key with the given cipher name + * in the binary format header. + */ +function createMockEncryptedOpenSshKey(cipher: string): string { + const magic = new TextEncoder().encode("openssh-key-v1\0"); + const cipherBytes = new TextEncoder().encode(cipher); + const cipherLenBuf = new DataView(new ArrayBuffer(4)); + cipherLenBuf.setUint32(0, cipherBytes.length, false); // big-endian + const padding = new Uint8Array(64); + + const total = magic.length + 4 + cipherBytes.length + padding.length; + const buf = new Uint8Array(total); + let off = 0; + buf.set(magic, off); + off += magic.length; + buf.set(new Uint8Array(cipherLenBuf.buffer), off); + off += 4; + buf.set(cipherBytes, off); + off += cipherBytes.length; + buf.set(padding, off); + + let binary = ""; + for (let i = 0; i < buf.length; i++) { + binary += String.fromCharCode(buf[i]); + } + const base64 = btoa(binary); + const lines = base64.match(/.{1,70}/g) || []; + return `-----BEGIN OPENSSH PRIVATE KEY-----\n${ + lines.join("\n") + }\n-----END OPENSSH PRIVATE KEY-----`; +} + +const MOCK_ENCRYPTED_PEM_KEY = `-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,1234567890ABCDEF1234567890ABCDEF + +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEA7V3jKJJHtN4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N +4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N4N +-----END RSA PRIVATE KEY-----`; + +Deno.test("LocalEncryptionVaultProvider - SSH key validation", async (t) => { + await t.step( + "should reject encrypted OpenSSH key", + async () => { + await withTempDir(async (_dir) => { + const encryptedKey = createMockEncryptedOpenSshKey("aes256-ctr"); + await Deno.writeTextFile("encrypted_openssh_key", encryptedKey, { + mode: 0o600, + }); + + const config: LocalEncryptionConfig = { + ssh_key_path: "encrypted_openssh_key", + }; + const vault = new LocalEncryptionVaultProvider( + "encrypted-openssh-vault", + config, + ); + + const error = await assertRejects( + () => vault.put("test-key", "test-value"), + Error, + ); + + assertStringIncludes(error.message, "SSH key is encrypted"); + assertStringIncludes(error.message, "aes256-ctr"); + }); + }, + ); + + await t.step( + "should reject encrypted PEM key", + async () => { + await withTempDir(async (_dir) => { + await Deno.writeTextFile("encrypted_pem_key", MOCK_ENCRYPTED_PEM_KEY, { + mode: 0o600, + }); + + const config: LocalEncryptionConfig = { + ssh_key_path: "encrypted_pem_key", + }; + const vault = new LocalEncryptionVaultProvider( + "encrypted-pem-vault", + config, + ); + + const error = await assertRejects( + () => vault.put("test-key", "test-value"), + Error, + ); + + assertStringIncludes(error.message, "SSH key is encrypted"); + assertStringIncludes(error.message, "legacy PEM format"); + }); + }, + ); + + await t.step( + "should reject SSH key with insecure permissions (0644)", + async () => { + await withTempDir(async (_dir) => { + await Deno.writeTextFile("insecure_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o644, + }); + + const config: LocalEncryptionConfig = { + ssh_key_path: "insecure_key", + }; + const vault = new LocalEncryptionVaultProvider( + "insecure-perms-vault", + config, + ); + + const error = await assertRejects( + () => vault.put("test-key", "test-value"), + Error, + ); + + assertStringIncludes(error.message, "insecure permissions"); + assertStringIncludes(error.message, "chmod 600"); + }); + }, + ); + + await t.step( + "should accept SSH key with secure permissions (0600)", + async () => { + await withTempDir(async (dir) => { + await Deno.writeTextFile("secure_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o600, + }); + + const config: LocalEncryptionConfig = { + ssh_key_path: "secure_key", + base_dir: dir, + }; + const vault = new LocalEncryptionVaultProvider( + "secure-perms-vault", + config, + ); + + await vault.put("test-key", "test-value"); + const retrieved = await vault.get("test-key"); + assertEquals(retrieved, "test-value"); + }); + }, + ); + + await t.step( + "should fall back to auto-generate when SSH key has insecure permissions", + async () => { + await withTempDir(async (dir) => { + await Deno.writeTextFile("bad_perms_key", MOCK_SSH_PRIVATE_KEY, { + mode: 0o644, + }); + + const config: LocalEncryptionConfig = { + ssh_key_path: "bad_perms_key", + auto_generate: true, + base_dir: dir, + }; + const vault = new LocalEncryptionVaultProvider( + "fallback-perms-vault", + config, + ); + + await vault.put("fallback-secret", "fallback-value"); + const retrieved = await vault.get("fallback-secret"); + assertEquals(retrieved, "fallback-value"); + }); + }, + ); +});