oauth-token-vault.mjs
131 lines 4.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago
1 /**
2 * Encrypted refresh-token vault for document connectors.
3 *
4 * AES-256-GCM provides authenticated encryption and scrypt derives a
5 * per-blob key from the server-side wrapping secret. Secrets are never logged
6 * or included in error messages.
7 */
8
9 import crypto from 'crypto';
10 import fs from 'fs';
11 import path from 'path';
12
13 const IV_LENGTH = 12;
14 const KEY_LENGTH = 32;
15 const SALT_LENGTH = 16;
16 const SCRYPT_N = 16384;
17 const MIN_SECRET_LEN = 32;
18 const CONNECTOR_ID_RE = /^conn_[A-Za-z0-9_-]{8,64}$/;
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
26 * @property {string} account_sub
27 */
28
29 function deriveKey(secret, salt) {
30 if (typeof secret !== 'string' || secret.length < MIN_SECRET_LEN) {
31 throw new TypeError('OAuth vault secret must be at least 32 characters');
32 }
33 return crypto.scryptSync(secret, salt, KEY_LENGTH, { N: SCRYPT_N });
34 }
35
36 function encryptPayload(plaintext, key) {
37 const iv = crypto.randomBytes(IV_LENGTH);
38 const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
39 const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
40 const authTag = cipher.getAuthTag();
41 return `${iv.toString('base64url')}:${authTag.toString('base64url')}:${encrypted.toString('base64url')}`;
42 }
43
44 function decryptPayload(line, key) {
45 const parts = line.split(':');
46 if (parts.length !== 3) throw new Error('Malformed encrypted OAuth blob');
47 const iv = Buffer.from(parts[0], 'base64url');
48 const authTag = Buffer.from(parts[1], 'base64url');
49 const encrypted = Buffer.from(parts[2], 'base64url');
50 const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
51 decipher.setAuthTag(authTag);
52 return decipher.update(encrypted, null, 'utf8') + decipher.final('utf8');
53 }
54
55 /**
56 * Resolve the isolated docs OAuth path for a connector.
57 * @param {string} dataDir
58 * @param {string} connectorId
59 */
60 export function oauthTokenVaultPath(dataDir, connectorId) {
61 if (typeof connectorId !== 'string' || !CONNECTOR_ID_RE.test(connectorId)) {
62 throw new TypeError('Invalid connector id for OAuth vault path');
63 }
64 return path.join(dataDir, 'docs_oauth', `${connectorId}.enc`);
65 }
66
67 /**
68 * Encrypt and persist refresh material.
69 * @param {string} dataDir
70 * @param {string} connectorId
71 * @param {string} secret
72 * @param {OAuthTokenPayload} payload
73 */
74 export function writeOAuthTokenVault(dataDir, connectorId, secret, payload) {
75 const salt = crypto.randomBytes(SALT_LENGTH);
76 const key = deriveKey(secret, salt);
77 const blob = `${salt.toString('base64url')}:${encryptPayload(JSON.stringify(payload), key)}`;
78 const filePath = oauthTokenVaultPath(dataDir, connectorId);
79 fs.mkdirSync(path.dirname(filePath), { recursive: true });
80 fs.writeFileSync(filePath, blob, { encoding: 'utf8', mode: 0o600 });
81 }
82
83 /**
84 * Decrypt stored refresh material.
85 * @param {string} dataDir
86 * @param {string} connectorId
87 * @param {string} secret
88 * @returns {OAuthTokenPayload}
89 */
90 export function readOAuthTokenVault(dataDir, connectorId, secret) {
91 const filePath = oauthTokenVaultPath(dataDir, connectorId);
92 if (!fs.existsSync(filePath)) throw new Error('OAuth token blob not found');
93 const raw = fs.readFileSync(filePath, 'utf8').trim();
94 const colon = raw.indexOf(':');
95 if (colon <= 0) throw new Error('Malformed encrypted OAuth blob');
96 const salt = Buffer.from(raw.slice(0, colon), 'base64url');
97 const parsed = JSON.parse(decryptPayload(raw.slice(colon + 1), deriveKey(secret, salt)));
98 if (
99 !parsed
100 || typeof parsed.refresh_token !== 'string'
101 || typeof parsed.scope !== 'string'
102 || typeof parsed.token_type !== 'string'
103 || typeof parsed.obtained_at !== 'string'
104 || typeof parsed.account_sub !== 'string'
105 ) {
106 throw new Error('Invalid OAuth token payload shape');
107 }
108 return /** @type {OAuthTokenPayload} */ (parsed);
109 }
110
111 /**
112 * Idempotently delete one encrypted blob.
113 * @param {string} dataDir
114 * @param {string} connectorId
115 */
116 export function deleteOAuthTokenVault(dataDir, connectorId) {
117 const filePath = oauthTokenVaultPath(dataDir, connectorId);
118 if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
119 }
120
121 /**
122 * Exercise the cryptographic round trip without filesystem I/O.
123 * @param {string} secret
124 * @param {OAuthTokenPayload} payload
125 * @returns {OAuthTokenPayload}
126 */
127 export function oauthTokenVaultRoundTrip(secret, payload) {
128 const salt = crypto.randomBytes(SALT_LENGTH);
129 const key = deriveKey(secret, salt);
130 return JSON.parse(decryptPayload(encryptPayload(JSON.stringify(payload), key), key));
131 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago