companion-token-custody.mjs
297 lines 13.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Companion token CUSTODY — pure lifecycle logic over an INJECTED keychain adapter.
3 *
4 * Phase 3 of the Companion App build plan (feat/companion-app).
5 * See docs/COMPANION-APP-PHASE-3-OAUTH-PKCE.md for the accepted design and the custody/rotation
6 * rules this module encodes.
7 *
8 * WHAT THIS MODULE IS
9 * The decision + orchestration layer for what the companion stores at rest and when it rotates
10 * or clears it. It governs three secrets:
11 * 1. the OAuth access token (the JWT the companion presents to the hosted gateway/canister),
12 * 2. the OAuth refresh token (opaque; used to mint a new JWT without a fresh browser login),
13 * 3. the Phase 2 per-session LOOPBACK bearer token (custody of which this phase defines —
14 * the loopback inference endpoint's credential, generated by the companion at start).
15 * It also stores non-secret session metadata (expiry, refresh ceiling, scope, issuer) needed to
16 * drive the refresh decision (decideTokenRefresh, from companion-oauth-pkce.mjs).
17 *
18 * WHAT THIS MODULE IS NOT
19 * - It performs NO real OS-keychain I/O. Every read/write/delete is delegated to an INJECTED
20 * adapter (`{ get, set, delete }`). Phase 5 supplies the real adapter backed by macOS
21 * Keychain / Windows DPAPI / Linux libsecret; tests supply an in-memory fake. This keeps the
22 * custody LOGIC pure and exhaustively testable, and keeps the gate's "no real keychain in
23 * this phase" line intact.
24 * - It NEVER writes a secret to a plaintext file, and NEVER logs a token, JWT, refresh token,
25 * loopback token, or any error message containing one. Thrown errors carry fixed messages.
26 *
27 * THREAT MODEL (custody-specific; full model in the design doc):
28 * - JWT/refresh theft at rest → only the OS keychain is used (never a dotfile / env / log).
29 * The adapter is the sole persistence path; this module hands the adapter only the named
30 * secret, never a derived plaintext copy.
31 * - Refresh-token replay → on a detected reuse/`invalid_grant` the caller invokes clearSession()
32 * which removes BOTH tokens (the family is dead; force a fresh login).
33 * - Loopback-token leakage → the loopback token is per-session and rotated at each companion
34 * start (rotateLoopbackToken); it is stored under its own account, separate from the JWT, so
35 * a compromise of one is not automatically a compromise of the other.
36 *
37 * DESIGN CONSTRAINTS:
38 * - Adapter calls are always awaited, so a synchronous OR Promise-returning adapter both work
39 * (awaiting a non-Promise is a no-op) — one code path drives tests and every real OS backend.
40 * - FAIL-CLOSED: a partial/absent session loads as null (→ caller treats as 'reauth'); invalid
41 * inputs to store* throw a fixed-message error rather than persisting something unusable.
42 */
43
44 import { decideTokenRefresh } from './companion-oauth-pkce.mjs';
45
46 /**
47 * Keychain account names (the "service"/"account" keys under which secrets are stored).
48 * Stable, namespaced, and non-secret. Phase 5's real adapter maps these to Keychain/DPAPI/
49 * libsecret entries.
50 * @readonly
51 */
52 export const KEYCHAIN_ACCOUNTS = Object.freeze({
53 ACCESS_TOKEN: 'knowtation.companion.accessToken',
54 REFRESH_TOKEN: 'knowtation.companion.refreshToken',
55 SESSION_META: 'knowtation.companion.sessionMeta',
56 LOOPBACK_TOKEN: 'knowtation.companion.loopbackToken',
57 });
58
59 /** Upper bound on any single stored secret (defense against an unbounded write). */
60 const MAX_SECRET_LEN = 8192;
61 /** Upper bound on the serialized metadata blob. */
62 const MAX_META_LEN = 8192;
63
64 /**
65 * Validate that an injected adapter exposes the required interface. Throws a fixed-message error
66 * (carrying no secret) if not.
67 * @param {unknown} keychain
68 * @returns {{ get: Function, set: Function, delete: Function }}
69 */
70 function requireAdapter(keychain) {
71 if (
72 !keychain ||
73 typeof keychain !== 'object' ||
74 typeof (/** @type {any} */ (keychain).get) !== 'function' ||
75 typeof (/** @type {any} */ (keychain).set) !== 'function' ||
76 typeof (/** @type {any} */ (keychain).delete) !== 'function'
77 ) {
78 throw new TypeError('createTokenCustody: keychain adapter must implement { get, set, delete }');
79 }
80 return /** @type {{ get: Function, set: Function, delete: Function }} */ (keychain);
81 }
82
83 /**
84 * Validate a secret string before it is handed to the adapter.
85 * @param {unknown} value
86 * @param {string} label - used only in the fixed error message (never the value)
87 * @returns {string}
88 */
89 function requireSecret(value, label) {
90 if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SECRET_LEN) {
91 throw new TypeError(`token-custody: ${label} must be a non-empty string within length bounds`);
92 }
93 return value;
94 }
95
96 /**
97 * Build a non-secret session-metadata record from a validated token response (the result of
98 * companion-oauth-pkce.validateTokenResponse) plus the current clock. PURE.
99 *
100 * Computes:
101 * - expiresAt = now + expiresIn*1000 (epoch-ms),
102 * - refreshExpiresAt = now + refreshTtlMs when a refresh token exists and a TTL is supplied
103 * (the absolute ceiling after which a refresh is no longer attempted),
104 * - scope, tokenType, and optional issuer for audit/refresh decisions.
105 *
106 * @param {{ expiresIn: number, refreshToken: string | null, scope: string | null, tokenType: string }} tokenResponse
107 * @param {{ now: number, refreshTtlMs?: number, issuer?: string }} ctx
108 * @returns {{ expiresAt: number, refreshExpiresAt: number | null, scope: string | null, tokenType: string, issuer: string | null, storedAt: number }}
109 */
110 export function buildSessionMeta(tokenResponse, ctx) {
111 const tr = tokenResponse ?? {};
112 const now = ctx?.now;
113 if (typeof now !== 'number' || !Number.isFinite(now)) {
114 throw new TypeError('buildSessionMeta: now must be a finite number');
115 }
116 if (typeof tr.expiresIn !== 'number' || !Number.isInteger(tr.expiresIn) || tr.expiresIn <= 0) {
117 throw new TypeError('buildSessionMeta: tokenResponse.expiresIn must be a positive integer');
118 }
119 const hasRefresh = typeof tr.refreshToken === 'string' && tr.refreshToken.length > 0;
120 const refreshTtlMs = ctx?.refreshTtlMs;
121 const refreshExpiresAt = hasRefresh && typeof refreshTtlMs === 'number' && Number.isFinite(refreshTtlMs) && refreshTtlMs > 0
122 ? now + refreshTtlMs
123 : null;
124 return {
125 expiresAt: now + tr.expiresIn * 1000,
126 refreshExpiresAt,
127 scope: typeof tr.scope === 'string' ? tr.scope : null,
128 tokenType: typeof tr.tokenType === 'string' ? tr.tokenType : 'Bearer',
129 issuer: typeof ctx?.issuer === 'string' && ctx.issuer.length > 0 ? ctx.issuer : null,
130 storedAt: now,
131 };
132 }
133
134 /**
135 * Create a token-custody handle bound to an injected keychain adapter.
136 *
137 * @param {{ get: (account: string) => (string|null|Promise<string|null>),
138 * set: (account: string, secret: string) => (void|Promise<void>),
139 * delete: (account: string) => (void|Promise<void>) }} keychain
140 * @returns {{
141 * storeSession: (args: { accessToken: string, refreshToken?: string|null, meta: object }) => Promise<void>,
142 * loadSession: () => Promise<null | { accessToken: string, refreshToken: string|null, expiresAt: number, refreshExpiresAt: number|null, scope: string|null, tokenType: string, issuer: string|null }>,
143 * clearSession: () => Promise<void>,
144 * decide: (args: { now: number, skewMs?: number }) => Promise<'valid'|'refresh'|'reauth'>,
145 * updateAccessToken: (args: { accessToken: string, meta: object, refreshToken?: string|null }) => Promise<void>,
146 * storeLoopbackToken: (token: string) => Promise<void>,
147 * getLoopbackToken: () => Promise<string|null>,
148 * rotateLoopbackToken: (token: string) => Promise<void>,
149 * clearLoopbackToken: () => Promise<void>,
150 * }}
151 */
152 export function createTokenCustody(keychain) {
153 const kc = requireAdapter(keychain);
154
155 /** @param {object} meta */
156 function serializeMeta(meta) {
157 if (!meta || typeof meta !== 'object') {
158 throw new TypeError('token-custody: session meta must be an object');
159 }
160 if (typeof meta.expiresAt !== 'number' || !Number.isFinite(meta.expiresAt)) {
161 throw new TypeError('token-custody: session meta.expiresAt must be a finite number');
162 }
163 const json = JSON.stringify(meta);
164 if (json.length > MAX_META_LEN) {
165 throw new TypeError('token-custody: session meta exceeds length bounds');
166 }
167 return json;
168 }
169
170 /**
171 * Persist a freshly-acquired session: the JWT, the optional refresh token, and the metadata.
172 * Overwrites any prior session for these accounts.
173 */
174 async function storeSession({ accessToken, refreshToken = null, meta } = {}) {
175 const at = requireSecret(accessToken, 'accessToken');
176 const metaJson = serializeMeta(meta);
177 await kc.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, at);
178 if (refreshToken === null || refreshToken === undefined) {
179 // No refresh token in this grant — ensure no stale one survives.
180 await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN);
181 } else {
182 await kc.set(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN, requireSecret(refreshToken, 'refreshToken'));
183 }
184 await kc.set(KEYCHAIN_ACCOUNTS.SESSION_META, metaJson);
185 }
186
187 /**
188 * Replace the access token (and metadata) after a successful refresh, rotating the refresh
189 * token when the server returns a new one (rotation). The prior refresh token is overwritten.
190 */
191 async function updateAccessToken({ accessToken, meta, refreshToken } = {}) {
192 const at = requireSecret(accessToken, 'accessToken');
193 const metaJson = serializeMeta(meta);
194 await kc.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, at);
195 if (refreshToken !== undefined) {
196 if (refreshToken === null) {
197 await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN);
198 } else {
199 await kc.set(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN, requireSecret(refreshToken, 'refreshToken'));
200 }
201 }
202 await kc.set(KEYCHAIN_ACCOUNTS.SESSION_META, metaJson);
203 }
204
205 /**
206 * Load the current session. Returns null (fail-closed) if the access token or metadata is
207 * missing or unparsable — the caller then treats it as 'reauth'.
208 */
209 async function loadSession() {
210 const accessToken = await kc.get(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN);
211 if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
212 const metaRaw = await kc.get(KEYCHAIN_ACCOUNTS.SESSION_META);
213 if (typeof metaRaw !== 'string' || metaRaw.length === 0 || metaRaw.length > MAX_META_LEN) return null;
214 let meta;
215 try {
216 meta = JSON.parse(metaRaw);
217 } catch {
218 return null;
219 }
220 if (!meta || typeof meta !== 'object' || typeof meta.expiresAt !== 'number' || !Number.isFinite(meta.expiresAt)) {
221 return null;
222 }
223 const refreshTokenRaw = await kc.get(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN);
224 const refreshToken = typeof refreshTokenRaw === 'string' && refreshTokenRaw.length > 0 ? refreshTokenRaw : null;
225 return {
226 accessToken,
227 refreshToken,
228 expiresAt: meta.expiresAt,
229 refreshExpiresAt: typeof meta.refreshExpiresAt === 'number' && Number.isFinite(meta.refreshExpiresAt) ? meta.refreshExpiresAt : null,
230 scope: typeof meta.scope === 'string' ? meta.scope : null,
231 tokenType: typeof meta.tokenType === 'string' ? meta.tokenType : 'Bearer',
232 issuer: typeof meta.issuer === 'string' ? meta.issuer : null,
233 };
234 }
235
236 /**
237 * Remove the OAuth session secrets (access + refresh + metadata). Idempotent. Used on logout
238 * and on refresh-reuse / invalid_grant (family is dead → force a fresh login). Does NOT touch
239 * the loopback token, which has an independent lifecycle.
240 */
241 async function clearSession() {
242 await kc.delete(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN);
243 await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN);
244 await kc.delete(KEYCHAIN_ACCOUNTS.SESSION_META);
245 }
246
247 /**
248 * Decide whether the stored session is valid / should refresh / requires re-auth, given the
249 * clock. Loads metadata and delegates to the pure decideTokenRefresh. No session → 'reauth'.
250 */
251 async function decide({ now, skewMs } = {}) {
252 const session = await loadSession();
253 if (!session) return 'reauth';
254 return decideTokenRefresh({
255 expiresAt: session.expiresAt,
256 now,
257 skewMs,
258 refreshExpiresAt: session.refreshExpiresAt ?? undefined,
259 });
260 }
261
262 /** Store the Phase 2 per-session loopback bearer token. */
263 async function storeLoopbackToken(token) {
264 await kc.set(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, requireSecret(token, 'loopbackToken'));
265 }
266
267 /** Read the Phase 2 per-session loopback bearer token, or null if absent. */
268 async function getLoopbackToken() {
269 const t = await kc.get(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN);
270 return typeof t === 'string' && t.length > 0 ? t : null;
271 }
272
273 /**
274 * Rotate the loopback token (call at each companion start): overwrite the prior per-session
275 * token with a fresh one. Identical to store, named for intent + the rotation contract.
276 */
277 async function rotateLoopbackToken(token) {
278 await kc.set(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, requireSecret(token, 'loopbackToken'));
279 }
280
281 /** Remove the loopback token (companion shutdown). Idempotent. */
282 async function clearLoopbackToken() {
283 await kc.delete(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN);
284 }
285
286 return {
287 storeSession,
288 loadSession,
289 clearSession,
290 decide,
291 updateAccessToken,
292 storeLoopbackToken,
293 getLoopbackToken,
294 rotateLoopbackToken,
295 clearLoopbackToken,
296 };
297 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago