companion-keychain-adapter.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
2 days ago
| 1 | /** |
| 2 | * Companion App — Phase 5 OS keychain adapter. |
| 3 | * |
| 4 | * This module is the real custody I/O boundary for the four Phase 3 keychain accounts. |
| 5 | * It exposes only `{ get, set, delete }`, rejects every unknown account, never lists the |
| 6 | * store, and never falls back to plaintext files or environment variables. |
| 7 | * |
| 8 | * Backend choices: |
| 9 | * - macOS: a Security.framework Swift bridge, invoked with secrets over stdin so token |
| 10 | * material is not placed in process argv. The bridge stores generic-password items with |
| 11 | * kSecAttrAccessibleWhenUnlockedThisDeviceOnly. |
| 12 | * - Windows: per-user DPAPI protected blobs. The blob is encrypted by the OS user profile; |
| 13 | * LOCAL_MACHINE scope is never used. |
| 14 | * - Linux: libsecret through `secret-tool`, with the secret delivered on stdin. |
| 15 | */ |
| 16 | |
| 17 | import { execFile as nodeExecFile } from 'node:child_process'; |
| 18 | import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; |
| 19 | import os from 'node:os'; |
| 20 | import path from 'node:path'; |
| 21 | import { promisify } from 'node:util'; |
| 22 | |
| 23 | import { KEYCHAIN_ACCOUNTS } from './companion-token-custody.mjs'; |
| 24 | |
| 25 | const execFileAsync = promisify(nodeExecFile); |
| 26 | |
| 27 | export const KEYCHAIN_SERVICE = 'knowtation.companion'; |
| 28 | export const KEYCHAIN_MAX_SECRET_LEN = 8192; |
| 29 | export const KEYCHAIN_ALLOWED_ACCOUNTS = Object.freeze(Object.values(KEYCHAIN_ACCOUNTS)); |
| 30 | |
| 31 | export const KEYCHAIN_ADAPTER_REASONS = Object.freeze({ |
| 32 | UNKNOWN_ACCOUNT: 'unknown_account', |
| 33 | INVALID_SECRET: 'invalid_secret', |
| 34 | BACKEND_UNAVAILABLE: 'backend_unavailable', |
| 35 | }); |
| 36 | |
| 37 | const MACOS_SWIFT_BRIDGE = String.raw` |
| 38 | import Foundation |
| 39 | import Security |
| 40 | |
| 41 | struct Request: Decodable { |
| 42 | let op: String |
| 43 | let service: String |
| 44 | let account: String |
| 45 | let secret: String? |
| 46 | } |
| 47 | |
| 48 | func fail(_ code: String) -> Never { |
| 49 | FileHandle.standardError.write(Data((code + "\n").utf8)) |
| 50 | exit(1) |
| 51 | } |
| 52 | |
| 53 | let input = FileHandle.standardInput.readDataToEndOfFile() |
| 54 | guard let req = try? JSONDecoder().decode(Request.self, from: input) else { fail("backend_unavailable") } |
| 55 | let serviceData = Data(req.service.utf8) |
| 56 | let accountData = Data(req.account.utf8) |
| 57 | |
| 58 | var query: [String: Any] = [ |
| 59 | kSecClass as String: kSecClassGenericPassword, |
| 60 | kSecAttrService as String: req.service, |
| 61 | kSecAttrAccount as String: req.account |
| 62 | ] |
| 63 | |
| 64 | if req.op == "get" { |
| 65 | query[kSecReturnData as String] = true |
| 66 | query[kSecMatchLimit as String] = kSecMatchLimitOne |
| 67 | var item: CFTypeRef? |
| 68 | let status = SecItemCopyMatching(query as CFDictionary, &item) |
| 69 | if status == errSecItemNotFound { exit(0) } |
| 70 | guard status == errSecSuccess, let data = item as? Data, let text = String(data: data, encoding: .utf8) else { |
| 71 | fail("backend_unavailable") |
| 72 | } |
| 73 | FileHandle.standardOutput.write(Data(text.utf8)) |
| 74 | exit(0) |
| 75 | } |
| 76 | |
| 77 | if req.op == "set" { |
| 78 | guard let secret = req.secret else { fail("invalid_secret") } |
| 79 | let secretData = Data(secret.utf8) |
| 80 | SecItemDelete(query as CFDictionary) |
| 81 | query[kSecValueData as String] = secretData |
| 82 | query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
| 83 | let status = SecItemAdd(query as CFDictionary, nil) |
| 84 | guard status == errSecSuccess else { fail("backend_unavailable") } |
| 85 | exit(0) |
| 86 | } |
| 87 | |
| 88 | if req.op == "delete" { |
| 89 | let status = SecItemDelete(query as CFDictionary) |
| 90 | guard status == errSecSuccess || status == errSecItemNotFound else { fail("backend_unavailable") } |
| 91 | exit(0) |
| 92 | } |
| 93 | |
| 94 | fail("backend_unavailable") |
| 95 | `; |
| 96 | |
| 97 | /** |
| 98 | * Validate that callers only address the four Phase 3 custody accounts. |
| 99 | * @param {unknown} account |
| 100 | * @returns {string} |
| 101 | */ |
| 102 | export function requireKnownKeychainAccount(account) { |
| 103 | if (typeof account !== 'string' || !KEYCHAIN_ALLOWED_ACCOUNTS.includes(account)) { |
| 104 | throw new TypeError(KEYCHAIN_ADAPTER_REASONS.UNKNOWN_ACCOUNT); |
| 105 | } |
| 106 | return account; |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Validate secret shape before handing it to an OS backend. |
| 111 | * @param {unknown} secret |
| 112 | * @returns {string} |
| 113 | */ |
| 114 | export function requireKeychainSecret(secret) { |
| 115 | if (typeof secret !== 'string' || secret.length === 0 || secret.length > KEYCHAIN_MAX_SECRET_LEN) { |
| 116 | throw new TypeError(KEYCHAIN_ADAPTER_REASONS.INVALID_SECRET); |
| 117 | } |
| 118 | return secret; |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Create the narrow keychain adapter used by `createTokenCustody`. |
| 123 | * @param {{ platform?: string, execFile?: Function, baseDir?: string }} [opts] |
| 124 | * @returns {{ get: Function, set: Function, delete: Function }} |
| 125 | */ |
| 126 | export function createCompanionKeychainAdapter(opts = {}) { |
| 127 | const backend = selectKeychainBackend(opts); |
| 128 | return { |
| 129 | async get(account) { |
| 130 | return backend.get(requireKnownKeychainAccount(account)); |
| 131 | }, |
| 132 | async set(account, secret) { |
| 133 | return backend.set(requireKnownKeychainAccount(account), requireKeychainSecret(secret)); |
| 134 | }, |
| 135 | async delete(account) { |
| 136 | return backend.delete(requireKnownKeychainAccount(account)); |
| 137 | }, |
| 138 | }; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Select the platform backend. Unknown platforms fail closed. |
| 143 | * @param {{ platform?: string, execFile?: Function, baseDir?: string }} [opts] |
| 144 | * @returns {{ get: Function, set: Function, delete: Function }} |
| 145 | */ |
| 146 | export function selectKeychainBackend(opts = {}) { |
| 147 | const platform = opts.platform ?? process.platform; |
| 148 | if (platform === 'darwin') return createMacOSKeychainBackend(opts); |
| 149 | if (platform === 'win32') return createWindowsDpapiBackend(opts); |
| 150 | if (platform === 'linux') return createLinuxSecretServiceBackend(opts); |
| 151 | throw new Error(KEYCHAIN_ADAPTER_REASONS.BACKEND_UNAVAILABLE); |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * macOS Keychain backend through a Swift Security.framework bridge. |
| 156 | * @param {{ execFile?: Function }} [opts] |
| 157 | */ |
| 158 | export function createMacOSKeychainBackend(opts = {}) { |
| 159 | const execFile = opts.execFile ?? execFileAsync; |
| 160 | async function run(op, account, secret) { |
| 161 | try { |
| 162 | const input = JSON.stringify({ op, service: KEYCHAIN_SERVICE, account, secret }); |
| 163 | const result = await execFile('/usr/bin/swift', ['-e', MACOS_SWIFT_BRIDGE], { |
| 164 | input, |
| 165 | encoding: 'utf8', |
| 166 | maxBuffer: KEYCHAIN_MAX_SECRET_LEN + 4096, |
| 167 | }); |
| 168 | return typeof result === 'string' ? result : result.stdout; |
| 169 | } catch { |
| 170 | throw new Error(KEYCHAIN_ADAPTER_REASONS.BACKEND_UNAVAILABLE); |
| 171 | } |
| 172 | } |
| 173 | return { |
| 174 | async get(account) { |
| 175 | const value = await run('get', account, null); |
| 176 | return value === '' ? null : value; |
| 177 | }, |
| 178 | async set(account, secret) { |
| 179 | await run('set', account, secret); |
| 180 | }, |
| 181 | async delete(account) { |
| 182 | await run('delete', account, null); |
| 183 | }, |
| 184 | }; |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Windows per-user DPAPI backend. The persisted bytes are DPAPI ciphertext only. |
| 189 | * @param {{ baseDir?: string }} [opts] |
| 190 | */ |
| 191 | export function createWindowsDpapiBackend(opts = {}) { |
| 192 | const execFile = opts.execFile ?? execFileAsync; |
| 193 | const baseDir = opts.baseDir ?? path.join(os.homedir(), 'AppData', 'Local', 'Knowtation', 'Companion', 'dpapi'); |
| 194 | const accountFile = (account) => path.join(baseDir, Buffer.from(account, 'utf8').toString('base64url')); |
| 195 | async function dpapiProtect(secret) { |
| 196 | const script = [ |
| 197 | 'Add-Type -AssemblyName System.Security;', |
| 198 | '$i=[Console]::In.ReadToEnd();', |
| 199 | '$b=[Text.Encoding]::UTF8.GetBytes($i);', |
| 200 | '$p=[Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);', |
| 201 | '[Console]::Out.Write([Convert]::ToBase64String($p));', |
| 202 | ].join(''); |
| 203 | const result = await execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { |
| 204 | input: secret, |
| 205 | encoding: 'utf8', |
| 206 | maxBuffer: KEYCHAIN_MAX_SECRET_LEN + 4096, |
| 207 | }); |
| 208 | return typeof result === 'string' ? result : result.stdout; |
| 209 | } |
| 210 | async function dpapiUnprotect(encoded) { |
| 211 | const script = [ |
| 212 | 'Add-Type -AssemblyName System.Security;', |
| 213 | '$i=[Console]::In.ReadToEnd();', |
| 214 | '$p=[Convert]::FromBase64String($i);', |
| 215 | '$b=[Security.Cryptography.ProtectedData]::Unprotect($p,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);', |
| 216 | '[Console]::Out.Write([Text.Encoding]::UTF8.GetString($b));', |
| 217 | ].join(''); |
| 218 | const result = await execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { |
| 219 | input: encoded, |
| 220 | encoding: 'utf8', |
| 221 | maxBuffer: KEYCHAIN_MAX_SECRET_LEN + 4096, |
| 222 | }); |
| 223 | return typeof result === 'string' ? result : result.stdout; |
| 224 | } |
| 225 | return { |
| 226 | async get(account) { |
| 227 | try { |
| 228 | const encoded = await readFile(accountFile(account), 'utf8'); |
| 229 | return await dpapiUnprotect(encoded); |
| 230 | } catch (err) { |
| 231 | if (err && err.code === 'ENOENT') return null; |
| 232 | throw new Error(KEYCHAIN_ADAPTER_REASONS.BACKEND_UNAVAILABLE); |
| 233 | } |
| 234 | }, |
| 235 | async set(account, secret) { |
| 236 | try { |
| 237 | await mkdir(baseDir, { recursive: true, mode: 0o700 }); |
| 238 | await writeFile(accountFile(account), await dpapiProtect(secret), { mode: 0o600 }); |
| 239 | } catch { |
| 240 | throw new Error(KEYCHAIN_ADAPTER_REASONS.BACKEND_UNAVAILABLE); |
| 241 | } |
| 242 | }, |
| 243 | async delete(account) { |
| 244 | await rm(accountFile(account), { force: true }); |
| 245 | }, |
| 246 | }; |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * Linux Secret Service backend through `secret-tool`. |
| 251 | * @param {{ execFile?: Function }} [opts] |
| 252 | */ |
| 253 | export function createLinuxSecretServiceBackend(opts = {}) { |
| 254 | const execFile = opts.execFile ?? execFileAsync; |
| 255 | async function run(args, input) { |
| 256 | try { |
| 257 | const result = await execFile('/usr/bin/secret-tool', args, { |
| 258 | input, |
| 259 | encoding: 'utf8', |
| 260 | maxBuffer: KEYCHAIN_MAX_SECRET_LEN + 4096, |
| 261 | }); |
| 262 | return typeof result === 'string' ? result : result.stdout; |
| 263 | } catch (err) { |
| 264 | if (err && err.code === 1) return ''; |
| 265 | throw new Error(KEYCHAIN_ADAPTER_REASONS.BACKEND_UNAVAILABLE); |
| 266 | } |
| 267 | } |
| 268 | return { |
| 269 | async get(account) { |
| 270 | const value = await run(['lookup', 'service', KEYCHAIN_SERVICE, 'account', account], undefined); |
| 271 | return value === '' ? null : value.replace(/\n$/, ''); |
| 272 | }, |
| 273 | async set(account, secret) { |
| 274 | await run(['store', '--label', KEYCHAIN_SERVICE, 'service', KEYCHAIN_SERVICE, 'account', account], secret); |
| 275 | }, |
| 276 | async delete(account) { |
| 277 | await run(['clear', 'service', KEYCHAIN_SERVICE, 'account', account], undefined); |
| 278 | }, |
| 279 | }; |
| 280 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
2 days ago