oauth-token-vault.mjs
167 lines 5.1 KB
Raw
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor ⚠ breaking 11 days ago
1 /**
2 * Encrypted refresh-token vault for Calendar OAuth connectors (Phase 1D).
3 *
4 * AES-256-GCM + scrypt key derivation — same pattern as memory-provider-encrypted.mjs.
5 * Pure crypto round-trip; no logging; secrets never appear in thrown errors.
6 *
7 * @see docs/CALENDAR-OAUTH-CONNECTOR-1D-SPEC.md — D4
8 */
9
10 import crypto from 'crypto';
11 import fs from 'fs';
12 import path from 'path';
13
14 const IV_LENGTH = 12;
15 const KEY_LENGTH = 32;
16 const SALT_LENGTH = 16;
17 const SCRYPT_N = 16384;
18 const MIN_SECRET_LEN = 32;
19
20 /**
21 * @typedef {Object} OAuthTokenPayload
22 * @property {string} refresh_token
23 * @property {string} scope
24 * @property {string} token_type
25 * @property {string} obtained_at — ISO8601
26 * @property {string} account_sub
27 */
28
29 /**
30 * @param {string} secret
31 * @param {Buffer} salt
32 * @returns {Buffer}
33 */
34 function deriveKey(secret, salt) {
35 if (typeof secret !== 'string' || secret.length < MIN_SECRET_LEN) {
36 throw new TypeError('OAuth vault secret must be at least 32 characters');
37 }
38 return crypto.scryptSync(secret, salt, KEY_LENGTH, { N: SCRYPT_N });
39 }
40
41 /**
42 * @param {string} plaintext
43 * @param {Buffer} key
44 * @returns {string} base64url(iv):base64url(tag):base64url(ciphertext)
45 */
46 function encryptPayload(plaintext, key) {
47 const iv = crypto.randomBytes(IV_LENGTH);
48 const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
49 const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
50 const authTag = cipher.getAuthTag();
51 return `${iv.toString('base64url')}:${authTag.toString('base64url')}:${encrypted.toString('base64url')}`;
52 }
53
54 /**
55 * @param {string} line
56 * @param {Buffer} key
57 * @returns {string}
58 */
59 function decryptPayload(line, key) {
60 const parts = line.split(':');
61 if (parts.length !== 3) {
62 throw new Error('Malformed encrypted OAuth blob');
63 }
64 const iv = Buffer.from(parts[0], 'base64url');
65 const authTag = Buffer.from(parts[1], 'base64url');
66 const encrypted = Buffer.from(parts[2], 'base64url');
67 const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
68 decipher.setAuthTag(authTag);
69 return decipher.update(encrypted, null, 'utf8') + decipher.final('utf8');
70 }
71
72 /**
73 * Resolve on-disk path for one connector token blob.
74 *
75 * @param {string} dataDir
76 * @param {string} connectorId
77 * @returns {string}
78 */
79 export function oauthTokenVaultPath(dataDir, connectorId) {
80 if (typeof connectorId !== 'string' || !/^conn_[A-Za-z0-9_-]{8,64}$/.test(connectorId)) {
81 throw new TypeError('Invalid connector id for OAuth vault path');
82 }
83 return path.join(dataDir, 'calendar_oauth', `${connectorId}.enc`);
84 }
85
86 /**
87 * Encrypt and persist a refresh-token payload for one connector.
88 *
89 * @param {string} dataDir
90 * @param {string} connectorId
91 * @param {string} secret — KNOWTATION_CALENDAR_OAUTH_SECRET
92 * @param {OAuthTokenPayload} payload
93 */
94 export function writeOAuthTokenVault(dataDir, connectorId, secret, payload) {
95 const salt = crypto.randomBytes(SALT_LENGTH);
96 const key = deriveKey(secret, salt);
97 const json = JSON.stringify(payload);
98 const enc = encryptPayload(json, key);
99 const blob = `${salt.toString('base64url')}:${enc}`;
100 const filePath = oauthTokenVaultPath(dataDir, connectorId);
101 fs.mkdirSync(path.dirname(filePath), { recursive: true });
102 fs.writeFileSync(filePath, blob, 'utf8');
103 }
104
105 /**
106 * Decrypt a stored refresh-token payload.
107 *
108 * @param {string} dataDir
109 * @param {string} connectorId
110 * @param {string} secret
111 * @returns {OAuthTokenPayload}
112 */
113 export function readOAuthTokenVault(dataDir, connectorId, secret) {
114 const filePath = oauthTokenVaultPath(dataDir, connectorId);
115 if (!fs.existsSync(filePath)) {
116 throw new Error('OAuth token blob not found');
117 }
118 const raw = fs.readFileSync(filePath, 'utf8').trim();
119 const colon = raw.indexOf(':');
120 if (colon <= 0) {
121 throw new Error('Malformed encrypted OAuth blob');
122 }
123 const salt = Buffer.from(raw.slice(0, colon), 'base64url');
124 const enc = raw.slice(colon + 1);
125 const key = deriveKey(secret, salt);
126 const json = decryptPayload(enc, key);
127 const parsed = JSON.parse(json);
128 if (
129 !parsed
130 || typeof parsed.refresh_token !== 'string'
131 || typeof parsed.scope !== 'string'
132 || typeof parsed.token_type !== 'string'
133 || typeof parsed.obtained_at !== 'string'
134 || typeof parsed.account_sub !== 'string'
135 ) {
136 throw new Error('Invalid OAuth token payload shape');
137 }
138 return /** @type {OAuthTokenPayload} */ (parsed);
139 }
140
141 /**
142 * Delete the encrypted blob for one connector (idempotent).
143 *
144 * @param {string} dataDir
145 * @param {string} connectorId
146 */
147 export function deleteOAuthTokenVault(dataDir, connectorId) {
148 const filePath = oauthTokenVaultPath(dataDir, connectorId);
149 if (fs.existsSync(filePath)) {
150 fs.unlinkSync(filePath);
151 }
152 }
153
154 /**
155 * In-memory encrypt/decrypt round-trip for unit tests (no filesystem).
156 *
157 * @param {string} secret
158 * @param {OAuthTokenPayload} payload
159 * @returns {OAuthTokenPayload}
160 */
161 export function oauthTokenVaultRoundTrip(secret, payload) {
162 const salt = crypto.randomBytes(SALT_LENGTH);
163 const key = deriveKey(secret, salt);
164 const enc = encryptPayload(JSON.stringify(payload), key);
165 const json = decryptPayload(enc, key);
166 return /** @type {OAuthTokenPayload} */ (JSON.parse(json));
167 }
File History 1 commit
sha256:93bcf8f9bd56d8c5b9339f4ec73b9ebd66571398d56262d38eedc2cfa9db9882 fix(test): align Band B landing assertion with desktop MCP … Human 11 days ago