diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/.vscode/settings.json b/.vscode/settings.json index 529dee1..2a6fc4a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,4 +8,10 @@ "editor.formatOnPaste": true, "editor.formatOnSaveMode": "file", "githubIssues.issueBranchTitle": "${issueNumber}-${sanitizedLowercaseIssueTitle}", + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, } diff --git a/bun.lock b/bun.lock index b9fbded..1f992b8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,13 @@ "name": "lpop", "devDependencies": { "@biomejs/biome": "2.1.4", + "@dajiaji/mlkem": "npm:@jsr/dajiaji__mlkem", "@napi-rs/keyring": "^1.1.8", "@types/bun": "latest", "@types/node": "^24.2.0", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", + "bs58": "^6.0.0", "chalk": "^5.4.1", "commander": "^14.0.0", "dotenv": "^17.2.0", @@ -61,6 +63,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-tBc+W7anBPSFXGAoQW+f/+svkpt8/uXfRwDzN1DvnatkRMt16KIYpEi/iw8u9GahJlFv98kgHcIrSsZHZTR0sw=="], + "@dajiaji/mlkem": ["@jsr/dajiaji__mlkem@2.5.0", "https://npm.jsr.io/~/11/@jsr/dajiaji__mlkem/2.5.0.tgz", {}, "sha512-aBGEEWeKcCgzOfBaIkGHvdh3CJTOVfSMHaYB3bIamU5fWkXQmLyFbqT/gllXZb7dtIMCUdRiZvS2JCQ6LrpmcA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], @@ -253,8 +257,12 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw=="], + "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], diff --git a/package.json b/package.json index 9135418..ade7c73 100644 --- a/package.json +++ b/package.json @@ -1,50 +1,20 @@ { "name": "@loggipop/lpop", "version": "0.3.1", - "description": "A CLI tool for managing environment variables in the system keychain", - "type": "module", - "bin": { - "lpop": "./bin/lpop" - }, - "scripts": { - "build:binaries": "./scripts/build-binary.sh", - "build:sign-notarize": "./scripts/build-sign-notarize.sh", - "prepare-packages": "node scripts/prepare-packages.js", - "dev": "bun run src/index.ts", - "clean": "rm -rf dist/ lpop lpop-*", - "lint": "biome check", - "lint:fix": "biome check --write", - "prepack": "bun run build:binaries && bun run prepare-packages", - "postinstall": "node scripts/postinstall.js", - "test": "vitest --run", - "test:watch": "vitest --watch", - "test:ui": "vitest --ui", - "test:coverage": "vitest --coverage" - }, - "keywords": [ - "cli", - "environment", - "variables", - "keychain", - "git" - ], "author": "Loggipop", - "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/loggipop/lpop.git" }, - "bugs": { - "url": "https://github.com/loggipop/lpop/issues" - }, - "homepage": "https://github.com/loggipop/lpop#readme", "devDependencies": { "@biomejs/biome": "2.1.4", + "@dajiaji/mlkem": "npm:@jsr/dajiaji__mlkem", "@napi-rs/keyring": "^1.1.8", "@types/bun": "latest", "@types/node": "^24.2.0", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", + "bs58": "^6.0.0", "chalk": "^5.4.1", "commander": "^14.0.0", "dotenv": "^17.2.0", @@ -63,10 +33,43 @@ "@loggipop/lpop-darwin-arm64": "latest", "@loggipop/lpop-windows-x64": "latest" }, + "bin": { + "lpop": "./bin/lpop" + }, + "bugs": { + "url": "https://github.com/loggipop/lpop/issues" + }, + "description": "A CLI tool for managing environment variables in the system keychain", "files": [ "bin/lpop", "scripts/postinstall.js", "README.md", "LICENSE" - ] + ], + "homepage": "https://github.com/loggipop/lpop#readme", + "keywords": [ + "cli", + "environment", + "variables", + "keychain", + "git" + ], + "license": "MIT", + "scripts": { + "build:binaries": "./scripts/build-binary.sh", + "build:sign-notarize": "./scripts/build-sign-notarize.sh", + "prepare-packages": "node scripts/prepare-packages.js", + "dev": "bun run src/index.ts", + "clean": "rm -rf dist/ lpop lpop-*", + "lint": "biome check", + "lint:fix": "biome check --write", + "prepack": "bun run build:binaries && bun run prepare-packages", + "postinstall": "node scripts/postinstall.js", + "test": "vitest --run", + "test:watch": "vitest --watch", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" + }, + "type": "module" + } diff --git a/src/quantum-keys.ts b/src/quantum-keys.ts new file mode 100644 index 0000000..4fd2f89 --- /dev/null +++ b/src/quantum-keys.ts @@ -0,0 +1,229 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { MlKem768 } from '@dajiaji/mlkem'; +import bs58 from 'bs58'; + +interface DeviceKeyPair { + publicKey: string; + privateKey: string; + createdAt: number; + expiresAt: number; +} + +interface EncryptedData { + encryptedKey: string; + ciphertext: string; +} + +const KEY_EXPIRY_DAYS = 7; +const LPOP_DIR = join(homedir(), '.lpop'); +const DEVICE_KEY_FILE = join(LPOP_DIR, 'device-key.json'); + +/** + * Ensures the .lpop directory exists in the user's home directory + */ +function ensureLpopDirectory(): void { + if (!existsSync(LPOP_DIR)) { + mkdirSync(LPOP_DIR, { recursive: true }); + } +} + +/** + * Generates a new ML-KEM768 key pair + */ +export const generatePublicPrivateKeyPair = async (): Promise<{ + publicKey: string; + privateKey: string; +}> => { + const kem = new MlKem768(); + const [publicKey, privateKey] = await kem.generateKeyPair(); + const publicKeyBase58 = bs58.encode(publicKey); + const privateKeyBase58 = bs58.encode(privateKey); + return { publicKey: publicKeyBase58, privateKey: privateKeyBase58 }; +}; + +/** + * Stores device key pair locally with expiration timestamp + */ +export const storeDeviceKey = async (keyPair: { + publicKey: string; + privateKey: string; +}): Promise => { + ensureLpopDirectory(); + + const now = Date.now(); + const deviceKey: DeviceKeyPair = { + ...keyPair, + createdAt: now, + expiresAt: now + KEY_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + }; + + writeFileSync(DEVICE_KEY_FILE, JSON.stringify(deviceKey, null, 2), 'utf8'); +}; + +/** + * Retrieves stored device key pair if it exists and hasn't expired + */ +export const getStoredDeviceKey = (): DeviceKeyPair | null => { + if (!existsSync(DEVICE_KEY_FILE)) { + return null; + } + + try { + const keyData = JSON.parse( + readFileSync(DEVICE_KEY_FILE, 'utf8'), + ) as DeviceKeyPair; + + // Check if key has expired + if (Date.now() > keyData.expiresAt) { + // Remove expired key + unlinkSync(DEVICE_KEY_FILE); + return null; + } + + return keyData; + } catch { + // If file is corrupted, remove it + unlinkSync(DEVICE_KEY_FILE); + return null; + } +}; + +/** + * Gets or generates device key pair, automatically handling expiration + */ +export const getOrCreateDeviceKey = async (): Promise => { + let deviceKey = getStoredDeviceKey(); + + if (!deviceKey) { + const keyPair = await generatePublicPrivateKeyPair(); + await storeDeviceKey(keyPair); + deviceKey = getStoredDeviceKey(); + + if (!deviceKey) { + throw new Error('Failed to store or retrieve device key'); + } + } + + return deviceKey; +}; + +/** + * Encrypts data using ML-KEM with the recipient's public key + */ +export const encryptForPublicKey = async ( + data: string, + publicKeyBase58: string, +): Promise => { + const kem = new MlKem768(); + const publicKey = bs58.decode(publicKeyBase58); + + // Generate shared secret using KEM + const [encryptedKey, sharedSecret] = await kem.encap(publicKey); + + // Use AES-256-GCM with the shared secret as key + // Derive a 256-bit key from the shared secret + const aesKey = sharedSecret.slice(0, 32); + + // Generate a random 12-byte IV for GCM + const iv = randomBytes(12); + + // Create cipher with AES-256-GCM + const cipher = createCipheriv('aes-256-gcm', aesKey, iv); + + // Encrypt the data + const encrypted = Buffer.concat([ + cipher.update(data, 'utf8'), + cipher.final(), + ]); + + // Get the authentication tag + const authTag = cipher.getAuthTag(); + + // Combine IV, authTag, and ciphertext + const combined = Buffer.concat([iv, authTag, encrypted]); + + return { + encryptedKey: bs58.encode(encryptedKey), + ciphertext: bs58.encode(combined), + }; +}; + +/** + * Decrypts data using ML-KEM with the local private key + */ +export const decryptWithPrivateKey = async ( + encryptedData: EncryptedData, + privateKeyBase58: string, +): Promise => { + const kem = new MlKem768(); + const privateKey = bs58.decode(privateKeyBase58); + const encryptedKey = bs58.decode(encryptedData.encryptedKey); + + // Recover shared secret using KEM + const sharedSecret = await kem.decap(encryptedKey, privateKey); + + // Derive the same AES key from the shared secret + const aesKey = sharedSecret.slice(0, 32); + + // Decode and extract components + const combined = bs58.decode(encryptedData.ciphertext); + + // Extract IV (first 12 bytes), authTag (next 16 bytes), and ciphertext (rest) + const iv = combined.slice(0, 12); + const authTag = combined.slice(12, 28); + const ciphertext = combined.slice(28); + + // Create decipher with AES-256-GCM + const decipher = createDecipheriv('aes-256-gcm', aesKey, iv); + decipher.setAuthTag(authTag); + + // Decrypt the data + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + + return decrypted.toString('utf8'); +}; + +/** + * Removes expired or invalid device keys + */ +export const cleanupExpiredKeys = (): boolean => { + const deviceKey = getStoredDeviceKey(); + return deviceKey === null; // Returns true if key was removed/expired +}; + +/** + * Gets device key status information + */ +export const getDeviceKeyStatus = (): { + exists: boolean; + expiresAt?: number; + daysUntilExpiry?: number; +} => { + const deviceKey = getStoredDeviceKey(); + + if (!deviceKey) { + return { exists: false }; + } + + const daysUntilExpiry = Math.ceil( + (deviceKey.expiresAt - Date.now()) / (24 * 60 * 60 * 1000), + ); + + return { + exists: true, + expiresAt: deviceKey.expiresAt, + daysUntilExpiry, + }; +}; diff --git a/tests/quantum-keys.test.ts b/tests/quantum-keys.test.ts new file mode 100644 index 0000000..70f55a0 --- /dev/null +++ b/tests/quantum-keys.test.ts @@ -0,0 +1,224 @@ +import { existsSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import bs58 from 'bs58'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + decryptWithPrivateKey, + encryptForPublicKey, + generatePublicPrivateKeyPair, +} from '../src/quantum-keys.js'; + +const LPOP_DIR = join(homedir(), '.lpop'); + +describe('Quantum Keys', () => { + afterEach(() => { + // Clean up any keys created during testing + if (existsSync(LPOP_DIR)) { + rmSync(LPOP_DIR, { recursive: true, force: true }); + } + }); + + describe('generatePublicPrivateKeyPair', () => { + it('should generate a valid ML-KEM key pair with base58 encoding', async () => { + const keyPair = await generatePublicPrivateKeyPair(); + + expect(keyPair).toHaveProperty('publicKey'); + expect(keyPair).toHaveProperty('privateKey'); + expect(typeof keyPair.publicKey).toBe('string'); + expect(typeof keyPair.privateKey).toBe('string'); + + // Base58 encoded keys should be shorter than hex + expect(keyPair.publicKey.length).toBeGreaterThan(1000); + expect(keyPair.publicKey.length).toBeLessThan(2000); + expect(keyPair.privateKey.length).toBeGreaterThan(3000); + expect(keyPair.privateKey.length).toBeLessThan(4000); + + // Should not contain characters not in base58 alphabet + expect(keyPair.publicKey).toMatch( + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/, + ); + expect(keyPair.privateKey).toMatch( + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/, + ); + }); + + it('should generate different key pairs on each call', async () => { + const keyPair1 = await generatePublicPrivateKeyPair(); + const keyPair2 = await generatePublicPrivateKeyPair(); + + expect(keyPair1.publicKey).not.toBe(keyPair2.publicKey); + expect(keyPair1.privateKey).not.toBe(keyPair2.privateKey); + }); + }); + + describe('encrypt and decrypt', () => { + it('should encrypt and decrypt data successfully', async () => { + await generatePublicPrivateKeyPair(); + const recipientKeys = await generatePublicPrivateKeyPair(); + const originalData = JSON.stringify({ + DATABASE_URL: 'postgres://user:pass@localhost:5432/mydb', + API_KEY: 'secret-api-key-12345', + NODE_ENV: 'production', + }); + + const encrypted = await encryptForPublicKey( + originalData, + recipientKeys.publicKey, + ); + + expect(encrypted).toHaveProperty('encryptedKey'); + expect(encrypted).toHaveProperty('ciphertext'); + expect(typeof encrypted.encryptedKey).toBe('string'); + expect(typeof encrypted.ciphertext).toBe('string'); + + // Should be base58 encoded + expect(encrypted.encryptedKey).toMatch( + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/, + ); + expect(encrypted.ciphertext).toMatch( + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/, + ); + + const decrypted = await decryptWithPrivateKey( + encrypted, + recipientKeys.privateKey, + ); + expect(decrypted).toBe(originalData); + }); + + it('should fail to decrypt with wrong private key', async () => { + const recipientKeys = await generatePublicPrivateKeyPair(); + const wrongKeys = await generatePublicPrivateKeyPair(); + const originalData = 'secret message'; + + const encrypted = await encryptForPublicKey( + originalData, + recipientKeys.publicKey, + ); + + // With AES-GCM, decryption with wrong key should throw authentication error + await expect( + decryptWithPrivateKey(encrypted, wrongKeys.privateKey), + ).rejects.toThrow('Unsupported state or unable to authenticate data'); + }); + + it('should handle empty string data', async () => { + const keys = await generatePublicPrivateKeyPair(); + const originalData = ''; + + const encrypted = await encryptForPublicKey(originalData, keys.publicKey); + const decrypted = await decryptWithPrivateKey(encrypted, keys.privateKey); + + expect(decrypted).toBe(originalData); + }); + + it('should handle large data', async () => { + const keys = await generatePublicPrivateKeyPair(); + const originalData = 'a'.repeat(10000); // 10KB of data + + const encrypted = await encryptForPublicKey(originalData, keys.publicKey); + const decrypted = await decryptWithPrivateKey(encrypted, keys.privateKey); + + expect(decrypted).toBe(originalData); + }); + }); + + describe('base58 encoding validation', () => { + it('should only contain valid base58 characters', async () => { + const keyPair = await generatePublicPrivateKeyPair(); + + // Base58 alphabet (Bitcoin): 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz + const base58Regex = + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; + + expect(keyPair.publicKey).toMatch(base58Regex); + expect(keyPair.privateKey).toMatch(base58Regex); + }); + + it('should be significantly shorter than hex encoding', async () => { + const keyPair = await generatePublicPrivateKeyPair(); + + // Base58 should be ~32% shorter than hex for ML-KEM keys + expect(keyPair.publicKey.length).toBeLessThan(2000); // vs 2368 for hex + expect(keyPair.privateKey.length).toBeLessThan(4000); // vs 4800 for hex + expect(keyPair.publicKey.length).toBeGreaterThan(1500); + expect(keyPair.privateKey.length).toBeGreaterThan(3000); + }); + }); + + describe('security improvements', () => { + it('should generate unique ciphertexts for the same plaintext (IV randomization)', async () => { + const keys = await generatePublicPrivateKeyPair(); + const originalData = 'sensitive data that should be protected'; + + const encrypted1 = await encryptForPublicKey( + originalData, + keys.publicKey, + ); + const encrypted2 = await encryptForPublicKey( + originalData, + keys.publicKey, + ); + + // Same plaintext should produce different ciphertexts due to random IV + expect(encrypted1.ciphertext).not.toBe(encrypted2.ciphertext); + + // Both should decrypt to the same value + const decrypted1 = await decryptWithPrivateKey( + encrypted1, + keys.privateKey, + ); + const decrypted2 = await decryptWithPrivateKey( + encrypted2, + keys.privateKey, + ); + expect(decrypted1).toBe(originalData); + expect(decrypted2).toBe(originalData); + }); + + it('should detect tampered ciphertext', async () => { + const keys = await generatePublicPrivateKeyPair(); + const originalData = 'integrity protected data'; + + const encrypted = await encryptForPublicKey(originalData, keys.publicKey); + + // Tamper with the ciphertext + const tamperedCiphertext = bs58.decode(encrypted.ciphertext); + tamperedCiphertext[tamperedCiphertext.length - 1] ^= 0xff; // Flip last byte + encrypted.ciphertext = bs58.encode(tamperedCiphertext); + + // Should fail authentication due to tampered data + await expect( + decryptWithPrivateKey(encrypted, keys.privateKey), + ).rejects.toThrow('Unsupported state or unable to authenticate data'); + }); + + it('should handle special characters and unicode correctly', async () => { + const keys = await generatePublicPrivateKeyPair(); + const originalData = '🔐 Encryption with émojis and spëcial çhars: €£¥'; + + const encrypted = await encryptForPublicKey(originalData, keys.publicKey); + const decrypted = await decryptWithPrivateKey(encrypted, keys.privateKey); + + expect(decrypted).toBe(originalData); + }); + + it('should protect against authentication tag manipulation', async () => { + const keys = await generatePublicPrivateKeyPair(); + const originalData = 'auth tag protected'; + + const encrypted = await encryptForPublicKey(originalData, keys.publicKey); + + // Tamper with the auth tag (bytes 12-28 in the combined buffer) + const tamperedCiphertext = bs58.decode(encrypted.ciphertext); + tamperedCiphertext[15] ^= 0x01; // Flip a bit in the auth tag + encrypted.ciphertext = bs58.encode(tamperedCiphertext); + + // Should fail authentication + await expect( + decryptWithPrivateKey(encrypted, keys.privateKey), + ).rejects.toThrow('Unsupported state or unable to authenticate data'); + }); + }); +});