nothumanallowed 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Encrypted token storage — AES-256-GCM with machine-derived key.
3
+ * Stores OAuth tokens at ~/.nha/ops/tokens.enc
4
+ * Zero dependencies — uses Node.js native crypto.
5
+ */
6
+
7
+ import crypto from 'crypto';
8
+ import fs from 'fs';
9
+ import os from 'os';
10
+ import path from 'path';
11
+ import { NHA_DIR } from '../constants.mjs';
12
+
13
+ const OPS_DIR = path.join(NHA_DIR, 'ops');
14
+ const TOKENS_FILE = path.join(OPS_DIR, 'tokens.enc');
15
+
16
+ /** Derive encryption key from machine-specific fingerprint */
17
+ function deriveKey() {
18
+ const fingerprint = os.hostname() + os.userInfo().username + os.homedir();
19
+ return crypto.createHash('sha256').update(fingerprint).digest();
20
+ }
21
+
22
+ /** Encrypt JSON data with AES-256-GCM */
23
+ function encrypt(data) {
24
+ const key = deriveKey();
25
+ const iv = crypto.randomBytes(16);
26
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
27
+ const plaintext = JSON.stringify(data);
28
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
29
+ const tag = cipher.getAuthTag();
30
+ return {
31
+ iv: iv.toString('base64'),
32
+ tag: tag.toString('base64'),
33
+ ciphertext: encrypted.toString('base64'),
34
+ };
35
+ }
36
+
37
+ /** Decrypt AES-256-GCM data back to JSON */
38
+ function decrypt(envelope) {
39
+ const key = deriveKey();
40
+ const iv = Buffer.from(envelope.iv, 'base64');
41
+ const tag = Buffer.from(envelope.tag, 'base64');
42
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
43
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
44
+ decipher.setAuthTag(tag);
45
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
46
+ return JSON.parse(decrypted.toString('utf-8'));
47
+ }
48
+
49
+ /**
50
+ * Save OAuth tokens (encrypted).
51
+ * @param {object} tokens — { access_token, refresh_token, expires_at, scope, email }
52
+ */
53
+ export function saveTokens(tokens) {
54
+ fs.mkdirSync(OPS_DIR, { recursive: true });
55
+ const envelope = encrypt(tokens);
56
+ fs.writeFileSync(TOKENS_FILE, JSON.stringify(envelope, null, 2), { mode: 0o600 });
57
+ }
58
+
59
+ /**
60
+ * Load OAuth tokens (decrypted).
61
+ * @returns {object|null} tokens or null if not stored
62
+ */
63
+ export function loadTokens() {
64
+ if (!fs.existsSync(TOKENS_FILE)) return null;
65
+ try {
66
+ const envelope = JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8'));
67
+ return decrypt(envelope);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Delete stored tokens.
75
+ */
76
+ export function deleteTokens() {
77
+ if (fs.existsSync(TOKENS_FILE)) fs.rmSync(TOKENS_FILE);
78
+ }
79
+
80
+ /**
81
+ * Check if access token is expired (with 5-minute buffer).
82
+ * @param {object} tokens
83
+ * @returns {boolean}
84
+ */
85
+ export function isExpired(tokens) {
86
+ if (!tokens?.expires_at) return true;
87
+ return Date.now() >= tokens.expires_at - 300_000;
88
+ }
89
+
90
+ /**
91
+ * Refresh access token using refresh_token.
92
+ * @param {string} clientId
93
+ * @param {string} clientSecret
94
+ * @param {string} refreshToken
95
+ * @returns {Promise<object>} new token set
96
+ */
97
+ export async function refreshAccessToken(clientId, clientSecret, refreshToken) {
98
+ const params = new URLSearchParams({
99
+ client_id: clientId,
100
+ client_secret: clientSecret,
101
+ refresh_token: refreshToken,
102
+ grant_type: 'refresh_token',
103
+ });
104
+
105
+ const res = await fetch('https://oauth2.googleapis.com/token', {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
108
+ body: params.toString(),
109
+ });
110
+
111
+ if (!res.ok) {
112
+ const err = await res.text();
113
+ throw new Error(`Token refresh failed: ${err}`);
114
+ }
115
+
116
+ const data = await res.json();
117
+ return {
118
+ access_token: data.access_token,
119
+ refresh_token: refreshToken, // Google doesn't always return a new refresh token
120
+ expires_at: Date.now() + (data.expires_in * 1000),
121
+ scope: data.scope,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Get a valid access token — refreshes automatically if expired.
127
+ * @param {object} config — NHA config with google.clientId etc.
128
+ * @returns {Promise<string>} access_token
129
+ */
130
+ export async function getAccessToken(config) {
131
+ let tokens = loadTokens();
132
+ if (!tokens) throw new Error('Not authenticated. Run: nha google auth');
133
+
134
+ if (isExpired(tokens)) {
135
+ const clientId = config.google?.clientId;
136
+ const clientSecret = config.google?.clientSecret || '';
137
+ if (!clientId) throw new Error('Google client ID not configured');
138
+
139
+ tokens = await refreshAccessToken(clientId, clientSecret, tokens.refresh_token);
140
+ tokens.email = loadTokens()?.email; // preserve email
141
+ saveTokens(tokens);
142
+ }
143
+
144
+ return tokens.access_token;
145
+ }