companion-keychain-adapter.mjs
280 lines 9.8 KB
Raw
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