|
| 1 | +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; |
| 2 | +import { InvalidArgumentError, SambarError } from '../../common/errors'; |
| 3 | +import { currentPlatform } from '../../common/platform'; |
| 4 | +import { linuxLibsecretBackend } from '../platform/linux/libsecret-keyring'; |
| 5 | +import { macosKeychainBackend } from '../platform/macos/cocoa-safe-storage'; |
| 6 | + |
| 7 | +/** |
| 8 | + * Encryption of strings tied to an OS-protected key — the drop-in equivalent of |
| 9 | + * Electron's `safeStorage`. |
| 10 | + * |
| 11 | + * The key is a random 32-byte secret kept in the OS keyring (macOS Keychain, |
| 12 | + * Linux libsecret) and never written to disk by Sambar. Strings are sealed with |
| 13 | + * AES-256-GCM (authenticated — tampering throws on decrypt). |
| 14 | + * |
| 15 | + * DIVERGENCE FROM ELECTRON (deliberate): Electron falls back to a `basic_text` |
| 16 | + * scheme (an obfuscated, effectively-plaintext key) when no OS keyring exists. |
| 17 | + * Sambar does NOT — a key sitting next to the ciphertext is not protection. With |
| 18 | + * no keyring, `isEncryptionAvailable()` returns `false` and |
| 19 | + * `encryptString`/`decryptString` throw. Sambar also does not promise |
| 20 | + * Electron-blob compatibility: a native, versioned blob format is used. |
| 21 | + */ |
| 22 | + |
| 23 | +export type SafeStorage = { |
| 24 | + /** Whether a keyring-backed key is available so encrypt/decrypt can run. Never throws. */ |
| 25 | + isEncryptionAvailable(): boolean; |
| 26 | + /** Seal `plainText` (UTF-8) into an authenticated blob. Throws if encryption is unavailable. */ |
| 27 | + encryptString(plainText: string): Buffer; |
| 28 | + /** Open a blob produced by {@link encryptString}. Throws on tamper, bad format, or unavailability. */ |
| 29 | + decryptString(encrypted: Buffer): string; |
| 30 | +}; |
| 31 | + |
| 32 | +/** |
| 33 | + * The keyring seam the crypto layer delegates to for its 32-byte key. Injectable |
| 34 | + * so the format/crypto logic is unit-tested with an in-memory fake — no FFI, no |
| 35 | + * real keyring, never a blocking call in CI. |
| 36 | + */ |
| 37 | +export type KeyringBackend = { |
| 38 | + /** Whether this host can store/retrieve a key. MUST be cheap + non-blocking + never throw. */ |
| 39 | + isAvailable(): boolean; |
| 40 | + /** Fetch the existing 32-byte key or create+persist one. May throw (surfaced by encrypt/decrypt). */ |
| 41 | + getOrCreateKey(): Buffer; |
| 42 | +}; |
| 43 | + |
| 44 | +const KEY_LENGTH = 32; |
| 45 | +/** GCM nonce length (96-bit IV — the GCM standard / fastest path). */ |
| 46 | +const IV_LENGTH = 12; |
| 47 | +/** GCM authentication tag length. */ |
| 48 | +const TAG_LENGTH = 16; |
| 49 | +/** Blob format version, so a future format can co-exist. */ |
| 50 | +const VERSION = 0x01; |
| 51 | +/** Smallest valid blob: version + IV + (≥0 ciphertext) + tag. */ |
| 52 | +const MIN_BLOB_LENGTH = 1 + IV_LENGTH + TAG_LENGTH; |
| 53 | + |
| 54 | +/** |
| 55 | + * Blob layout: `[version:1][iv:12][ciphertext:N][tag:16]`. A random IV per |
| 56 | + * encryption (never reused) + the GCM tag make the blob tamper-evident. |
| 57 | + */ |
| 58 | +const encryptWithKey = (key: Buffer, plainText: string): Buffer => { |
| 59 | + const iv = randomBytes(IV_LENGTH); |
| 60 | + const cipher = createCipheriv('aes-256-gcm', key, iv); |
| 61 | + const ciphertext = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]); |
| 62 | + const tag = cipher.getAuthTag(); |
| 63 | + return Buffer.concat([Buffer.from([VERSION]), iv, ciphertext, tag]); |
| 64 | +}; |
| 65 | + |
| 66 | +const decryptWithKey = (key: Buffer, blob: Buffer): string => { |
| 67 | + if (blob.length < MIN_BLOB_LENGTH) { |
| 68 | + throw new InvalidArgumentError('safeStorage: corrupt blob (too short)'); |
| 69 | + } |
| 70 | + if (blob[0] !== VERSION) { |
| 71 | + throw new InvalidArgumentError( |
| 72 | + `safeStorage: unsupported blob version 0x${blob[0]?.toString(16)}`, |
| 73 | + ); |
| 74 | + } |
| 75 | + const iv = blob.subarray(1, 1 + IV_LENGTH); |
| 76 | + const tag = blob.subarray(blob.length - TAG_LENGTH); |
| 77 | + const ciphertext = blob.subarray(1 + IV_LENGTH, blob.length - TAG_LENGTH); |
| 78 | + const decipher = createDecipheriv('aes-256-gcm', key, iv); |
| 79 | + decipher.setAuthTag(tag); |
| 80 | + // GCM auth failure (tamper / wrong key) makes final() THROW — surface it loudly, |
| 81 | + // never return garbage plaintext. |
| 82 | + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); |
| 83 | +}; |
| 84 | + |
| 85 | +/** Built-in backend for platforms with no keyring: reports unavailable, throws if forced. */ |
| 86 | +const unavailableBackend: KeyringBackend = { |
| 87 | + isAvailable: () => false, |
| 88 | + getOrCreateKey: () => { |
| 89 | + throw new SambarError(`safeStorage has no keyring backend on ${currentPlatform()}`); |
| 90 | + }, |
| 91 | +}; |
| 92 | + |
| 93 | +let backend: KeyringBackend | undefined; |
| 94 | +let cachedKey: Buffer | undefined; |
| 95 | +let cachedAvailable: boolean | undefined; |
| 96 | + |
| 97 | +const getBackend = (): KeyringBackend => { |
| 98 | + if (backend !== undefined) { |
| 99 | + return backend; |
| 100 | + } |
| 101 | + const platform = currentPlatform(); |
| 102 | + if (platform === 'macos') { |
| 103 | + return macosKeychainBackend; |
| 104 | + } |
| 105 | + if (platform === 'linux') { |
| 106 | + return linuxLibsecretBackend; |
| 107 | + } |
| 108 | + return unavailableBackend; |
| 109 | +}; |
| 110 | + |
| 111 | +/** Whether encryption is available — probed once, then memoised (Electron caches at startup). */ |
| 112 | +const isAvailable = (): boolean => { |
| 113 | + if (cachedAvailable === undefined) { |
| 114 | + cachedAvailable = getBackend().isAvailable(); |
| 115 | + } |
| 116 | + return cachedAvailable; |
| 117 | +}; |
| 118 | + |
| 119 | +/** |
| 120 | + * Read the keyring ONCE and cache the key for the process — only the first op |
| 121 | + * pays the round-trip (on Linux, the one blocking D-Bus call); later ops are pure |
| 122 | + * in-memory AES. |
| 123 | + */ |
| 124 | +const getKey = (): Buffer => { |
| 125 | + if (cachedKey === undefined) { |
| 126 | + const key = getBackend().getOrCreateKey(); |
| 127 | + if (key.length !== KEY_LENGTH) { |
| 128 | + throw new SambarError( |
| 129 | + `safeStorage: keyring returned a ${key.length}-byte key, expected ${KEY_LENGTH}`, |
| 130 | + ); |
| 131 | + } |
| 132 | + cachedKey = key; |
| 133 | + } |
| 134 | + return cachedKey; |
| 135 | +}; |
| 136 | + |
| 137 | +/** Override the keyring backend AND clear the cached key + availability. Test-only. */ |
| 138 | +export const setSafeStorageBackendForTesting = (fake: KeyringBackend | undefined): void => { |
| 139 | + backend = fake; |
| 140 | + cachedKey = undefined; |
| 141 | + cachedAvailable = undefined; |
| 142 | +}; |
| 143 | + |
| 144 | +export const safeStorage: SafeStorage = { |
| 145 | + isEncryptionAvailable() { |
| 146 | + return isAvailable(); |
| 147 | + }, |
| 148 | + encryptString(plainText) { |
| 149 | + if (!isAvailable()) { |
| 150 | + throw new SambarError('safeStorage: encryption is not available (no OS keyring)'); |
| 151 | + } |
| 152 | + return encryptWithKey(getKey(), plainText); |
| 153 | + }, |
| 154 | + decryptString(encrypted) { |
| 155 | + if (!isAvailable()) { |
| 156 | + throw new SambarError('safeStorage: encryption is not available (no OS keyring)'); |
| 157 | + } |
| 158 | + return decryptWithKey(getKey(), encrypted); |
| 159 | + }, |
| 160 | +}; |
0 commit comments