unotoken 0.1.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.
- package/README.md +360 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1207 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +15 -0
- package/dist/client.js.map +1 -0
- package/dist/db.d.ts +52 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +97 -0
- package/dist/db.js.map +1 -0
- package/dist/dotenv.d.ts +69 -0
- package/dist/dotenv.d.ts.map +1 -0
- package/dist/dotenv.js +115 -0
- package/dist/dotenv.js.map +1 -0
- package/dist/env-mapper.d.ts +55 -0
- package/dist/env-mapper.d.ts.map +1 -0
- package/dist/env-mapper.js +97 -0
- package/dist/env-mapper.js.map +1 -0
- package/dist/exec.d.ts +80 -0
- package/dist/exec.d.ts.map +1 -0
- package/dist/exec.js +214 -0
- package/dist/exec.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/commands.d.ts +151 -0
- package/dist/oauth/commands.d.ts.map +1 -0
- package/dist/oauth/commands.js +322 -0
- package/dist/oauth/commands.js.map +1 -0
- package/dist/oauth/config.d.ts +84 -0
- package/dist/oauth/config.d.ts.map +1 -0
- package/dist/oauth/config.js +156 -0
- package/dist/oauth/config.js.map +1 -0
- package/dist/oauth/crypto-helpers.d.ts +44 -0
- package/dist/oauth/crypto-helpers.d.ts.map +1 -0
- package/dist/oauth/crypto-helpers.js +94 -0
- package/dist/oauth/crypto-helpers.js.map +1 -0
- package/dist/oauth/device-secret.d.ts +57 -0
- package/dist/oauth/device-secret.d.ts.map +1 -0
- package/dist/oauth/device-secret.js +106 -0
- package/dist/oauth/device-secret.js.map +1 -0
- package/dist/oauth/flow.d.ts +112 -0
- package/dist/oauth/flow.d.ts.map +1 -0
- package/dist/oauth/flow.js +255 -0
- package/dist/oauth/flow.js.map +1 -0
- package/dist/oauth/index.d.ts +18 -0
- package/dist/oauth/index.d.ts.map +1 -0
- package/dist/oauth/index.js +24 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/oauth/key-wrap.d.ts +146 -0
- package/dist/oauth/key-wrap.d.ts.map +1 -0
- package/dist/oauth/key-wrap.js +275 -0
- package/dist/oauth/key-wrap.js.map +1 -0
- package/dist/oauth/pkce.d.ts +29 -0
- package/dist/oauth/pkce.d.ts.map +1 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/provider.d.ts +79 -0
- package/dist/oauth/provider.d.ts.map +1 -0
- package/dist/oauth/provider.js +10 -0
- package/dist/oauth/provider.js.map +1 -0
- package/dist/oauth/providers/github.d.ts +75 -0
- package/dist/oauth/providers/github.d.ts.map +1 -0
- package/dist/oauth/providers/github.js +119 -0
- package/dist/oauth/providers/github.js.map +1 -0
- package/dist/oauth/providers/google.d.ts +115 -0
- package/dist/oauth/providers/google.d.ts.map +1 -0
- package/dist/oauth/providers/google.js +285 -0
- package/dist/oauth/providers/google.js.map +1 -0
- package/dist/sdk.d.ts +8 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +8 -0
- package/dist/sdk.js.map +1 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +287 -0
- package/dist/server.js.map +1 -0
- package/dist/signatures/approval-codes.d.ts +192 -0
- package/dist/signatures/approval-codes.d.ts.map +1 -0
- package/dist/signatures/approval-codes.js +407 -0
- package/dist/signatures/approval-codes.js.map +1 -0
- package/dist/signatures/commands.d.ts +108 -0
- package/dist/signatures/commands.d.ts.map +1 -0
- package/dist/signatures/commands.js +270 -0
- package/dist/signatures/commands.js.map +1 -0
- package/dist/signatures/devices.d.ts +165 -0
- package/dist/signatures/devices.d.ts.map +1 -0
- package/dist/signatures/devices.js +344 -0
- package/dist/signatures/devices.js.map +1 -0
- package/dist/signatures/email-config.d.ts +102 -0
- package/dist/signatures/email-config.d.ts.map +1 -0
- package/dist/signatures/email-config.js +188 -0
- package/dist/signatures/email-config.js.map +1 -0
- package/dist/signatures/email.d.ts +106 -0
- package/dist/signatures/email.d.ts.map +1 -0
- package/dist/signatures/email.js +180 -0
- package/dist/signatures/email.js.map +1 -0
- package/dist/signatures/fingerprint.d.ts +70 -0
- package/dist/signatures/fingerprint.d.ts.map +1 -0
- package/dist/signatures/fingerprint.js +123 -0
- package/dist/signatures/fingerprint.js.map +1 -0
- package/dist/signatures/guard.d.ts +118 -0
- package/dist/signatures/guard.d.ts.map +1 -0
- package/dist/signatures/guard.js +310 -0
- package/dist/signatures/guard.js.map +1 -0
- package/dist/signatures/resend.d.ts +84 -0
- package/dist/signatures/resend.d.ts.map +1 -0
- package/dist/signatures/resend.js +248 -0
- package/dist/signatures/resend.js.map +1 -0
- package/dist/token-requests.d.ts +80 -0
- package/dist/token-requests.d.ts.map +1 -0
- package/dist/token-requests.js +201 -0
- package/dist/token-requests.js.map +1 -0
- package/dist/tokens.d.ts +80 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +150 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device approval guard for unotoken.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a guard that checks whether the current device is
|
|
5
|
+
* approved before allowing vault operations. It integrates:
|
|
6
|
+
* - Device fingerprinting (fingerprint.ts)
|
|
7
|
+
* - Known devices registry (devices.ts)
|
|
8
|
+
* - Email configuration (email-config.ts)
|
|
9
|
+
* - Approval codes (approval-codes.ts)
|
|
10
|
+
* - Email sending (resend.ts)
|
|
11
|
+
*
|
|
12
|
+
* The guard runs BEFORE vault unlock -- a new device cannot even attempt
|
|
13
|
+
* to unlock without approval.
|
|
14
|
+
*
|
|
15
|
+
* Usage patterns:
|
|
16
|
+
* 1. CLI (TTY): Prompts interactively for the 6-digit code
|
|
17
|
+
* 2. SDK (programmatic): Throws DeviceApprovalRequired with instructions
|
|
18
|
+
*
|
|
19
|
+
* Graceful degradation: If no email is configured, the guard allows the
|
|
20
|
+
* operation to proceed (signatures are opt-in via email config).
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { generateDeviceFingerprint, getDefaultDeviceName } from './fingerprint.js';
|
|
25
|
+
import { checkDevice, DevicesDatabase } from './devices.js';
|
|
26
|
+
import { loadEmailConfig, maskEmail } from './email-config.js';
|
|
27
|
+
import { createApprovalCode, verifyApprovalCode } from './approval-codes.js';
|
|
28
|
+
import { sendApprovalEmail } from './resend.js';
|
|
29
|
+
// ─── Error ──────────────────────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* Error thrown when a device requires approval but is used programmatically
|
|
32
|
+
* (no TTY to prompt the user).
|
|
33
|
+
*
|
|
34
|
+
* The caller (app/cloud service) is responsible for:
|
|
35
|
+
* 1. Sending the code via approveDevice()
|
|
36
|
+
* 2. Collecting the code from the user
|
|
37
|
+
* 3. Calling completeApproval() with the code
|
|
38
|
+
*/
|
|
39
|
+
export class DeviceApprovalRequired extends Error {
|
|
40
|
+
/** The device fingerprint that needs approval */
|
|
41
|
+
fingerprint;
|
|
42
|
+
/** The masked email where the code was sent (or null if not sent) */
|
|
43
|
+
maskedEmail;
|
|
44
|
+
/** Whether a code was actually sent */
|
|
45
|
+
codeSent;
|
|
46
|
+
constructor(fingerprint, maskedEmail, codeSent) {
|
|
47
|
+
const emailPart = maskedEmail
|
|
48
|
+
? ` A code has been sent to ${maskedEmail}.`
|
|
49
|
+
: '';
|
|
50
|
+
super(`Device not approved. This device (${fingerprint.slice(0, 12)}...) must be approved before accessing the vault.${emailPart}` +
|
|
51
|
+
` Use approveDevice() to complete the approval flow.`);
|
|
52
|
+
this.name = 'DeviceApprovalRequired';
|
|
53
|
+
this.fingerprint = fingerprint;
|
|
54
|
+
this.maskedEmail = maskedEmail;
|
|
55
|
+
this.codeSent = codeSent;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ─── Guard Implementation ───────────────────────────────────────────
|
|
59
|
+
/**
|
|
60
|
+
* Check if the current device is approved and handle the approval flow.
|
|
61
|
+
*
|
|
62
|
+
* This is the main entry point for device checks. It should be called
|
|
63
|
+
* before any vault operation (unlock, get, set, list).
|
|
64
|
+
*
|
|
65
|
+
* Flow:
|
|
66
|
+
* 1. Generate device fingerprint
|
|
67
|
+
* 2. Check if device is known (auto-approves first device)
|
|
68
|
+
* 3. If known: allow operation, update last_seen
|
|
69
|
+
* 4. If unknown:
|
|
70
|
+
* a. If no email configured: allow (graceful degradation)
|
|
71
|
+
* b. If TTY: send code, prompt user, verify, register device
|
|
72
|
+
* c. If no TTY: throw DeviceApprovalRequired
|
|
73
|
+
*
|
|
74
|
+
* @param options - Guard configuration
|
|
75
|
+
* @returns Result indicating whether the operation should proceed
|
|
76
|
+
* @throws DeviceApprovalRequired when used programmatically and device is unknown
|
|
77
|
+
*/
|
|
78
|
+
export async function deviceGuard(options = {}) {
|
|
79
|
+
const { baseDir, readLine, writeOutput = (msg) => process.stderr.write(msg), writeError = (msg) => process.stderr.write(msg), } = options;
|
|
80
|
+
const isTTY = options.isTTY ?? (process.stdin.isTTY === true);
|
|
81
|
+
// 1. Generate fingerprint and device name
|
|
82
|
+
const fingerprint = generateDeviceFingerprint(baseDir);
|
|
83
|
+
const deviceName = getDefaultDeviceName();
|
|
84
|
+
// 2. Check device status
|
|
85
|
+
const deviceCheck = await checkDevice(fingerprint, deviceName, baseDir);
|
|
86
|
+
// 3. Device is known -- allow operation
|
|
87
|
+
if (deviceCheck.known) {
|
|
88
|
+
return {
|
|
89
|
+
allowed: true,
|
|
90
|
+
newlyApproved: false,
|
|
91
|
+
autoApproved: deviceCheck.autoApproved,
|
|
92
|
+
deviceName,
|
|
93
|
+
fingerprint,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// 4. Device is unknown -- check email config
|
|
97
|
+
const emailConfig = loadEmailConfig(baseDir);
|
|
98
|
+
const emailConfigured = emailConfig !== null && emailConfig.verified === true;
|
|
99
|
+
// 4a. No email configured -- graceful degradation
|
|
100
|
+
if (!emailConfigured) {
|
|
101
|
+
writeOutput('No email configured. Run `unotoken config email <address>` to enable device signatures.\n');
|
|
102
|
+
return {
|
|
103
|
+
allowed: true,
|
|
104
|
+
newlyApproved: false,
|
|
105
|
+
autoApproved: false,
|
|
106
|
+
deviceName,
|
|
107
|
+
fingerprint,
|
|
108
|
+
reason: 'no_email_configured',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const maskedAddr = maskEmail(emailConfig.email);
|
|
112
|
+
// 4b. TTY mode -- interactive approval
|
|
113
|
+
if (isTTY && readLine) {
|
|
114
|
+
return interactiveApproval({
|
|
115
|
+
fingerprint,
|
|
116
|
+
deviceName,
|
|
117
|
+
email: emailConfig.email,
|
|
118
|
+
maskedEmail: maskedAddr,
|
|
119
|
+
baseDir,
|
|
120
|
+
readLine,
|
|
121
|
+
writeOutput,
|
|
122
|
+
writeError,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// 4c. Non-TTY / SDK mode -- throw error
|
|
126
|
+
// Try to send the code proactively so the caller can collect it
|
|
127
|
+
let codeSent = false;
|
|
128
|
+
try {
|
|
129
|
+
const codeResult = await createApprovalCode(fingerprint, baseDir);
|
|
130
|
+
if (codeResult) {
|
|
131
|
+
// Send the email
|
|
132
|
+
const context = {
|
|
133
|
+
deviceName,
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
};
|
|
136
|
+
const sendResult = await sendApprovalEmail(emailConfig.email, codeResult.code, context, fingerprint, baseDir);
|
|
137
|
+
codeSent = sendResult.success;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Best-effort -- if sending fails, the caller will need to retry
|
|
142
|
+
}
|
|
143
|
+
throw new DeviceApprovalRequired(fingerprint, maskedAddr, codeSent);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Run the interactive device approval flow (TTY mode).
|
|
147
|
+
*
|
|
148
|
+
* Sends an approval code to the user's email and prompts for entry.
|
|
149
|
+
*/
|
|
150
|
+
async function interactiveApproval(opts) {
|
|
151
|
+
const { fingerprint, deviceName, email, maskedEmail, baseDir, readLine, writeOutput, writeError, } = opts;
|
|
152
|
+
// Create an approval code
|
|
153
|
+
const codeResult = await createApprovalCode(fingerprint, baseDir);
|
|
154
|
+
if (!codeResult) {
|
|
155
|
+
writeError('Rate limit exceeded. Too many approval codes requested. Please wait before trying again.\n');
|
|
156
|
+
return {
|
|
157
|
+
allowed: false,
|
|
158
|
+
newlyApproved: false,
|
|
159
|
+
autoApproved: false,
|
|
160
|
+
deviceName,
|
|
161
|
+
fingerprint,
|
|
162
|
+
reason: 'rate_limited',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// Send the email
|
|
166
|
+
const context = {
|
|
167
|
+
deviceName,
|
|
168
|
+
timestamp: new Date().toISOString(),
|
|
169
|
+
};
|
|
170
|
+
const sendResult = await sendApprovalEmail(email, codeResult.code, context, fingerprint, baseDir);
|
|
171
|
+
if (!sendResult.success) {
|
|
172
|
+
writeError(`Failed to send approval code: ${sendResult.error ?? 'Unknown error'}\n`);
|
|
173
|
+
return {
|
|
174
|
+
allowed: false,
|
|
175
|
+
newlyApproved: false,
|
|
176
|
+
autoApproved: false,
|
|
177
|
+
deviceName,
|
|
178
|
+
fingerprint,
|
|
179
|
+
reason: 'email_send_failed',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
writeOutput(`New device detected. Sending approval code to ${maskedEmail}...\n`);
|
|
183
|
+
// Prompt for code (up to 3 attempts handled by approval-codes engine)
|
|
184
|
+
const MAX_PROMPT_ATTEMPTS = 3;
|
|
185
|
+
for (let i = 0; i < MAX_PROMPT_ATTEMPTS; i++) {
|
|
186
|
+
const userInput = await readLine('Enter approval code: ');
|
|
187
|
+
const trimmed = userInput.trim();
|
|
188
|
+
const verifyResult = await verifyApprovalCode(fingerprint, trimmed, baseDir);
|
|
189
|
+
if (verifyResult.valid) {
|
|
190
|
+
// Register the device
|
|
191
|
+
const db = await DevicesDatabase.open(baseDir);
|
|
192
|
+
try {
|
|
193
|
+
db.registerDevice(fingerprint, deviceName, 'email_code');
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
db.close();
|
|
197
|
+
}
|
|
198
|
+
writeOutput(`Device approved: ${deviceName}\n`);
|
|
199
|
+
return {
|
|
200
|
+
allowed: true,
|
|
201
|
+
newlyApproved: true,
|
|
202
|
+
autoApproved: false,
|
|
203
|
+
deviceName,
|
|
204
|
+
fingerprint,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// Check if we should give more attempts
|
|
208
|
+
if (verifyResult.reason?.includes('Maximum attempts') || verifyResult.reason?.includes('No active')) {
|
|
209
|
+
writeError('Code expired. Run the command again to get a new code.\n');
|
|
210
|
+
return {
|
|
211
|
+
allowed: false,
|
|
212
|
+
newlyApproved: false,
|
|
213
|
+
autoApproved: false,
|
|
214
|
+
deviceName,
|
|
215
|
+
fingerprint,
|
|
216
|
+
reason: 'max_attempts_or_expired',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// Extract remaining attempts from reason
|
|
220
|
+
const remainingMatch = verifyResult.reason?.match(/(\d+) attempt/);
|
|
221
|
+
const remaining = remainingMatch ? parseInt(remainingMatch[1], 10) : MAX_PROMPT_ATTEMPTS - i - 1;
|
|
222
|
+
writeError(`Invalid code. ${remaining} attempt${remaining === 1 ? '' : 's'} remaining.\n`);
|
|
223
|
+
}
|
|
224
|
+
// Should not reach here normally (approval-codes engine handles attempt limits)
|
|
225
|
+
writeError('Code expired. Run the command again to get a new code.\n');
|
|
226
|
+
return {
|
|
227
|
+
allowed: false,
|
|
228
|
+
newlyApproved: false,
|
|
229
|
+
autoApproved: false,
|
|
230
|
+
deviceName,
|
|
231
|
+
fingerprint,
|
|
232
|
+
reason: 'max_attempts_or_expired',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// ─── Programmatic Approval ──────────────────────────────────────────
|
|
236
|
+
/**
|
|
237
|
+
* Programmatically approve a device with an approval code.
|
|
238
|
+
*
|
|
239
|
+
* For use by SDK consumers who caught DeviceApprovalRequired and collected
|
|
240
|
+
* the code from the user via their own UI.
|
|
241
|
+
*
|
|
242
|
+
* @param fingerprint - The device fingerprint from the error
|
|
243
|
+
* @param code - The 6-digit code entered by the user
|
|
244
|
+
* @param baseDir - Optional base directory for config/database files
|
|
245
|
+
* @returns Object with `approved` boolean and optional `reason`
|
|
246
|
+
*/
|
|
247
|
+
export async function approveDevice(fingerprint, code, baseDir) {
|
|
248
|
+
const deviceName = getDefaultDeviceName();
|
|
249
|
+
const verifyResult = await verifyApprovalCode(fingerprint, code, baseDir);
|
|
250
|
+
if (!verifyResult.valid) {
|
|
251
|
+
return {
|
|
252
|
+
approved: false,
|
|
253
|
+
deviceName,
|
|
254
|
+
reason: verifyResult.reason,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// Register the device
|
|
258
|
+
const db = await DevicesDatabase.open(baseDir);
|
|
259
|
+
try {
|
|
260
|
+
db.registerDevice(fingerprint, deviceName, 'email_code');
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
db.close();
|
|
264
|
+
}
|
|
265
|
+
return { approved: true, deviceName };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Send an approval code to the user's configured email for a specific device.
|
|
269
|
+
*
|
|
270
|
+
* For use by SDK consumers who need to initiate the approval flow programmatically.
|
|
271
|
+
*
|
|
272
|
+
* @param fingerprint - The device fingerprint
|
|
273
|
+
* @param baseDir - Optional base directory for config/database files
|
|
274
|
+
* @returns Object with `sent` boolean, `maskedEmail`, and optional `error`
|
|
275
|
+
*/
|
|
276
|
+
export async function sendDeviceApprovalCode(fingerprint, baseDir) {
|
|
277
|
+
const emailConfig = loadEmailConfig(baseDir);
|
|
278
|
+
if (!emailConfig || !emailConfig.verified) {
|
|
279
|
+
return {
|
|
280
|
+
sent: false,
|
|
281
|
+
error: 'No verified email configured. Run `unotoken config email <address>` first.',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const codeResult = await createApprovalCode(fingerprint, baseDir);
|
|
285
|
+
if (!codeResult) {
|
|
286
|
+
return {
|
|
287
|
+
sent: false,
|
|
288
|
+
maskedEmail: maskEmail(emailConfig.email),
|
|
289
|
+
error: 'Rate limit exceeded. Too many approval codes requested.',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const deviceName = getDefaultDeviceName();
|
|
293
|
+
const context = {
|
|
294
|
+
deviceName,
|
|
295
|
+
timestamp: new Date().toISOString(),
|
|
296
|
+
};
|
|
297
|
+
const sendResult = await sendApprovalEmail(emailConfig.email, codeResult.code, context, fingerprint, baseDir);
|
|
298
|
+
if (!sendResult.success) {
|
|
299
|
+
return {
|
|
300
|
+
sent: false,
|
|
301
|
+
maskedEmail: maskEmail(emailConfig.email),
|
|
302
|
+
error: sendResult.error,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
sent: true,
|
|
307
|
+
maskedEmail: maskEmail(emailConfig.email),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard.js","sourceRoot":"","sources":["../../src/signatures/guard.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resend API integration for unotoken device approval emails.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Resend REST API directly (no SDK dependency) to send
|
|
5
|
+
* branded 6-digit approval codes for device verification.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Built-in Resend API key (Indigo-hosted, rate-limited) for zero-config setup
|
|
9
|
+
* - Configurable override for users who want their own Resend account
|
|
10
|
+
* - Rate limiting: max 3 emails per device per hour to prevent abuse
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
export interface ApprovalEmailContext {
|
|
15
|
+
/** Human-friendly device name (e.g., "stefan@macbook") */
|
|
16
|
+
deviceName: string;
|
|
17
|
+
/** ISO 8601 timestamp of when the code was generated */
|
|
18
|
+
timestamp: string;
|
|
19
|
+
/** IP address hint (best-effort, may be "unknown") */
|
|
20
|
+
ipHint?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SendEmailResult {
|
|
23
|
+
/** Whether the email was sent successfully */
|
|
24
|
+
success: boolean;
|
|
25
|
+
/** Resend message ID (if successful) */
|
|
26
|
+
messageId?: string;
|
|
27
|
+
/** Error message (if failed) */
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Check if sending another email for this device fingerprint is allowed.
|
|
32
|
+
*
|
|
33
|
+
* @param deviceFingerprint - The device fingerprint to check
|
|
34
|
+
* @returns true if under the rate limit
|
|
35
|
+
*/
|
|
36
|
+
export declare function isRateLimited(deviceFingerprint: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Get remaining sends in the current rate limit window.
|
|
39
|
+
*
|
|
40
|
+
* @param deviceFingerprint - The device fingerprint
|
|
41
|
+
* @returns Number of remaining sends allowed
|
|
42
|
+
*/
|
|
43
|
+
export declare function remainingSends(deviceFingerprint: string): number;
|
|
44
|
+
/**
|
|
45
|
+
* Reset rate limit state (for testing).
|
|
46
|
+
*/
|
|
47
|
+
export declare function resetRateLimits(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Get the effective Resend API key.
|
|
50
|
+
*
|
|
51
|
+
* Checks for a user-configured custom key first, falls back to built-in.
|
|
52
|
+
*
|
|
53
|
+
* @param baseDir - Optional base directory for email config
|
|
54
|
+
* @returns The Resend API key to use
|
|
55
|
+
*/
|
|
56
|
+
export declare function getResendApiKey(baseDir?: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Build the HTML email body for a device approval code.
|
|
59
|
+
*
|
|
60
|
+
* @param code - The 6-digit approval code
|
|
61
|
+
* @param context - Additional context about the request
|
|
62
|
+
* @returns HTML string for the email body
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildApprovalEmailHtml(code: string, context: ApprovalEmailContext): string;
|
|
65
|
+
/**
|
|
66
|
+
* Build the plain text email body for a device approval code.
|
|
67
|
+
*
|
|
68
|
+
* @param code - The 6-digit approval code
|
|
69
|
+
* @param context - Additional context about the request
|
|
70
|
+
* @returns Plain text string for the email body
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildApprovalEmailText(code: string, context: ApprovalEmailContext): string;
|
|
73
|
+
/**
|
|
74
|
+
* Send a device approval email via the Resend API.
|
|
75
|
+
*
|
|
76
|
+
* @param to - Recipient email address
|
|
77
|
+
* @param code - The 6-digit approval code
|
|
78
|
+
* @param context - Additional context (device name, timestamp, IP)
|
|
79
|
+
* @param deviceFingerprint - Device fingerprint for rate limiting
|
|
80
|
+
* @param baseDir - Optional base directory for email config (API key lookup)
|
|
81
|
+
* @returns Result object with success status and optional message ID
|
|
82
|
+
*/
|
|
83
|
+
export declare function sendApprovalEmail(to: string, code: string, context: ApprovalEmailContext, deviceFingerprint: string, baseDir?: string): Promise<SendEmailResult>;
|
|
84
|
+
//# sourceMappingURL=resend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resend.d.ts","sourceRoot":"","sources":["../../src/signatures/resend.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAqBH,MAAM,WAAW,oBAAoB;IACnC,0DAA0D;IAC1D,UAAU,EAAE,MAAM,CAAC;IACnB,wDAAwD;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,OAAO,EAAE,OAAO,CAAC;IACjB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gCAAgC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAeD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,iBAAiB,EAAE,MAAM,GAAG,OAAO,CAQhE;AAeD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,iBAAiB,EAAE,MAAM,GAAG,MAAM,CAKhE;AAED;;GAEG;AACH,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AAID;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,oBAAoB,GAAG,MAAM,CAmC1F;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,oBAAoB,GAAG,MAAM,CA4B1F;AAED;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,oBAAoB,EAC7B,iBAAiB,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,eAAe,CAAC,CA4D1B"}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resend API integration for unotoken device approval emails.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Resend REST API directly (no SDK dependency) to send
|
|
5
|
+
* branded 6-digit approval codes for device verification.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Built-in Resend API key (Indigo-hosted, rate-limited) for zero-config setup
|
|
9
|
+
* - Configurable override for users who want their own Resend account
|
|
10
|
+
* - Rate limiting: max 3 emails per device per hour to prevent abuse
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { loadEmailConfig } from './email-config.js';
|
|
15
|
+
// ─── Constants ──────────────────────────────────────────────────────
|
|
16
|
+
/**
|
|
17
|
+
* Built-in Resend API key for zero-config device approval emails.
|
|
18
|
+
*
|
|
19
|
+
* This is an Indigo-hosted key with rate limits applied. Users who need
|
|
20
|
+
* higher throughput should configure their own key with:
|
|
21
|
+
* unotoken config email --resend-key <key>
|
|
22
|
+
*/
|
|
23
|
+
const BUILT_IN_RESEND_API_KEY = 're_placeholder_builtin_key';
|
|
24
|
+
const RESEND_API_URL = 'https://api.resend.com/emails';
|
|
25
|
+
const FROM_ADDRESS = 'unotoken <noreply@getindigo.ai>';
|
|
26
|
+
// ─── Rate Limiting ──────────────────────────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* In-memory rate limit tracker.
|
|
29
|
+
*
|
|
30
|
+
* Tracks email send timestamps per device fingerprint. Resets on process
|
|
31
|
+
* restart (acceptable since this is a CLI tool, not a long-running server).
|
|
32
|
+
*/
|
|
33
|
+
const rateLimitMap = new Map();
|
|
34
|
+
const MAX_EMAILS_PER_HOUR = 3;
|
|
35
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
36
|
+
/**
|
|
37
|
+
* Check if sending another email for this device fingerprint is allowed.
|
|
38
|
+
*
|
|
39
|
+
* @param deviceFingerprint - The device fingerprint to check
|
|
40
|
+
* @returns true if under the rate limit
|
|
41
|
+
*/
|
|
42
|
+
export function isRateLimited(deviceFingerprint) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const timestamps = rateLimitMap.get(deviceFingerprint) ?? [];
|
|
45
|
+
// Filter to only timestamps within the window
|
|
46
|
+
const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
47
|
+
return recent.length >= MAX_EMAILS_PER_HOUR;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Record a sent email for rate limiting.
|
|
51
|
+
*
|
|
52
|
+
* @param deviceFingerprint - The device fingerprint
|
|
53
|
+
*/
|
|
54
|
+
function recordSend(deviceFingerprint) {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const timestamps = rateLimitMap.get(deviceFingerprint) ?? [];
|
|
57
|
+
const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
58
|
+
recent.push(now);
|
|
59
|
+
rateLimitMap.set(deviceFingerprint, recent);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Get remaining sends in the current rate limit window.
|
|
63
|
+
*
|
|
64
|
+
* @param deviceFingerprint - The device fingerprint
|
|
65
|
+
* @returns Number of remaining sends allowed
|
|
66
|
+
*/
|
|
67
|
+
export function remainingSends(deviceFingerprint) {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const timestamps = rateLimitMap.get(deviceFingerprint) ?? [];
|
|
70
|
+
const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
71
|
+
return Math.max(0, MAX_EMAILS_PER_HOUR - recent.length);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reset rate limit state (for testing).
|
|
75
|
+
*/
|
|
76
|
+
export function resetRateLimits() {
|
|
77
|
+
rateLimitMap.clear();
|
|
78
|
+
}
|
|
79
|
+
// ─── Email Sending ──────────────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* Get the effective Resend API key.
|
|
82
|
+
*
|
|
83
|
+
* Checks for a user-configured custom key first, falls back to built-in.
|
|
84
|
+
*
|
|
85
|
+
* @param baseDir - Optional base directory for email config
|
|
86
|
+
* @returns The Resend API key to use
|
|
87
|
+
*/
|
|
88
|
+
export function getResendApiKey(baseDir) {
|
|
89
|
+
const config = loadEmailConfig(baseDir);
|
|
90
|
+
return config?.resendApiKey ?? BUILT_IN_RESEND_API_KEY;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Build the HTML email body for a device approval code.
|
|
94
|
+
*
|
|
95
|
+
* @param code - The 6-digit approval code
|
|
96
|
+
* @param context - Additional context about the request
|
|
97
|
+
* @returns HTML string for the email body
|
|
98
|
+
*/
|
|
99
|
+
export function buildApprovalEmailHtml(code, context) {
|
|
100
|
+
const timestamp = new Date(context.timestamp).toLocaleString();
|
|
101
|
+
const ipLine = context.ipHint && context.ipHint !== 'unknown'
|
|
102
|
+
? `<p style="color:#666;font-size:13px;margin:4px 0;">IP: ${escapeHtml(context.ipHint)}</p>`
|
|
103
|
+
: '';
|
|
104
|
+
return `
|
|
105
|
+
<!DOCTYPE html>
|
|
106
|
+
<html>
|
|
107
|
+
<head><meta charset="utf-8"></head>
|
|
108
|
+
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;padding:20px;">
|
|
109
|
+
<h2 style="color:#1a1a1a;margin-bottom:4px;">Device Approval Code</h2>
|
|
110
|
+
<p style="color:#666;margin-top:0;">A new device is requesting access to your unotoken vault.</p>
|
|
111
|
+
|
|
112
|
+
<div style="background:#f4f4f5;border-radius:8px;padding:24px;text-align:center;margin:24px 0;">
|
|
113
|
+
<p style="color:#666;font-size:13px;margin:0 0 8px 0;">Your approval code:</p>
|
|
114
|
+
<div style="font-size:36px;font-weight:700;letter-spacing:8px;color:#1a1a1a;font-family:monospace;">${escapeHtml(code)}</div>
|
|
115
|
+
<p style="color:#999;font-size:12px;margin:8px 0 0 0;">Expires in 5 minutes</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div style="background:#fafafa;border-radius:6px;padding:12px 16px;margin:16px 0;">
|
|
119
|
+
<p style="color:#666;font-size:13px;margin:4px 0;"><strong>Device:</strong> ${escapeHtml(context.deviceName)}</p>
|
|
120
|
+
<p style="color:#666;font-size:13px;margin:4px 0;"><strong>Time:</strong> ${escapeHtml(timestamp)}</p>
|
|
121
|
+
${ipLine}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<p style="color:#999;font-size:12px;margin-top:24px;">
|
|
125
|
+
If you did not request this, someone may be trying to access your vault from a new device.
|
|
126
|
+
You can safely ignore this email -- the code will expire automatically.
|
|
127
|
+
</p>
|
|
128
|
+
|
|
129
|
+
<hr style="border:none;border-top:1px solid #eee;margin:24px 0;">
|
|
130
|
+
<p style="color:#bbb;font-size:11px;">Sent by unotoken -- secure secret vault</p>
|
|
131
|
+
</body>
|
|
132
|
+
</html>`.trim();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Build the plain text email body for a device approval code.
|
|
136
|
+
*
|
|
137
|
+
* @param code - The 6-digit approval code
|
|
138
|
+
* @param context - Additional context about the request
|
|
139
|
+
* @returns Plain text string for the email body
|
|
140
|
+
*/
|
|
141
|
+
export function buildApprovalEmailText(code, context) {
|
|
142
|
+
const timestamp = new Date(context.timestamp).toLocaleString();
|
|
143
|
+
const ipLine = context.ipHint && context.ipHint !== 'unknown'
|
|
144
|
+
? `IP: ${context.ipHint}\n`
|
|
145
|
+
: '';
|
|
146
|
+
return [
|
|
147
|
+
'Device Approval Code',
|
|
148
|
+
'====================',
|
|
149
|
+
'',
|
|
150
|
+
'A new device is requesting access to your unotoken vault.',
|
|
151
|
+
'',
|
|
152
|
+
`Your approval code: ${code}`,
|
|
153
|
+
'',
|
|
154
|
+
'This code expires in 5 minutes.',
|
|
155
|
+
'',
|
|
156
|
+
`Device: ${context.deviceName}`,
|
|
157
|
+
`Time: ${timestamp}`,
|
|
158
|
+
ipLine ? ipLine.trim() : null,
|
|
159
|
+
'',
|
|
160
|
+
'If you did not request this, someone may be trying to access your',
|
|
161
|
+
'vault from a new device. You can safely ignore this email.',
|
|
162
|
+
'',
|
|
163
|
+
'---',
|
|
164
|
+
'Sent by unotoken',
|
|
165
|
+
]
|
|
166
|
+
.filter((line) => line !== null)
|
|
167
|
+
.join('\n');
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Send a device approval email via the Resend API.
|
|
171
|
+
*
|
|
172
|
+
* @param to - Recipient email address
|
|
173
|
+
* @param code - The 6-digit approval code
|
|
174
|
+
* @param context - Additional context (device name, timestamp, IP)
|
|
175
|
+
* @param deviceFingerprint - Device fingerprint for rate limiting
|
|
176
|
+
* @param baseDir - Optional base directory for email config (API key lookup)
|
|
177
|
+
* @returns Result object with success status and optional message ID
|
|
178
|
+
*/
|
|
179
|
+
export async function sendApprovalEmail(to, code, context, deviceFingerprint, baseDir) {
|
|
180
|
+
// Rate limit check
|
|
181
|
+
if (isRateLimited(deviceFingerprint)) {
|
|
182
|
+
return {
|
|
183
|
+
success: false,
|
|
184
|
+
error: `Rate limit exceeded: maximum ${MAX_EMAILS_PER_HOUR} approval emails per device per hour. Please wait before requesting a new code.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const apiKey = getResendApiKey(baseDir);
|
|
188
|
+
const htmlBody = buildApprovalEmailHtml(code, context);
|
|
189
|
+
const textBody = buildApprovalEmailText(code, context);
|
|
190
|
+
try {
|
|
191
|
+
const response = await fetch(RESEND_API_URL, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: {
|
|
194
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
195
|
+
'Content-Type': 'application/json',
|
|
196
|
+
},
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
from: FROM_ADDRESS,
|
|
199
|
+
to: [to],
|
|
200
|
+
subject: `${code} is your unotoken device approval code`,
|
|
201
|
+
html: htmlBody,
|
|
202
|
+
text: textBody,
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
const errorBody = await response.text();
|
|
207
|
+
let errorMsg;
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(errorBody);
|
|
210
|
+
errorMsg = parsed.message ?? parsed.error ?? errorBody;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
errorMsg = errorBody;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
error: `Resend API error (${response.status}): ${errorMsg}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const data = (await response.json());
|
|
221
|
+
// Record successful send for rate limiting
|
|
222
|
+
recordSend(deviceFingerprint);
|
|
223
|
+
return {
|
|
224
|
+
success: true,
|
|
225
|
+
messageId: data.id,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: `Failed to send email: ${msg}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
237
|
+
/**
|
|
238
|
+
* Escape HTML entities to prevent XSS in email body.
|
|
239
|
+
*/
|
|
240
|
+
function escapeHtml(str) {
|
|
241
|
+
return str
|
|
242
|
+
.replace(/&/g, '&')
|
|
243
|
+
.replace(/</g, '<')
|
|
244
|
+
.replace(/>/g, '>')
|
|
245
|
+
.replace(/"/g, '"')
|
|
246
|
+
.replace(/'/g, ''');
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=resend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resend.js","sourceRoot":"","sources":["../../src/signatures/resend.ts"],"names":[],"mappings":"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"}
|