-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
258 lines (220 loc) · 7.22 KB
/
server.js
File metadata and controls
258 lines (220 loc) · 7.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import express from "express";
import Redis from "ioredis";
import crypto from "crypto";
import rateLimit from "express-rate-limit";
import helmet from "helmet";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import { readFileSync } from "fs";
import SRIInjector from "./middleware/sri-injector.js";
import cspNonceMiddleware from "./middleware/csp-nonce.js";
import createStaticHtmlMiddleware from "./middleware/static-html.js";
// ES module __dirname equivalent
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load package.json for version info
const pkg = JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
const app = express();
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const TTL = Number.parseInt(process.env.PIN_TTL, 10) || 120; // seconds
const PIN_LENGTH = 4; // PIN code length in digits
const REQUIRED_USER_AGENT = "Cloud Phone";
// SRI Injector inserts integrity="sha-384" hashes into <script> and <link> tags
const publicDir = path.join(__dirname, "public");
const sriInjector = new SRIInjector(publicDir);
app.set("sriInjector", sriInjector);
// Security middleware - disable helmet's CSP since we're using custom nonce-based CSP
app.use(
helmet({
contentSecurityPolicy: false,
}),
);
// Custom nonce-based CSP middleware (must come before static files)
app.use(cspNonceMiddleware);
app.use(
cors({
origin: process.env.ALLOWED_ORIGINS?.split(",") || "*",
methods: ["GET", "POST", "DELETE"],
}),
);
app.use(express.json());
// Restrict access to sync.html to Cloud Phone user agent only
app.get("/sync.html", (req, res, next) => {
const userAgent = req.get("User-Agent") || "";
if (!userAgent.includes(REQUIRED_USER_AGENT)) {
return res.redirect("/");
}
next();
});
// Custom static file middleware that injects SRI hashes into HTML files
app.use(createStaticHtmlMiddleware(publicDir, sriInjector));
// Rate limiting
const createSecretLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 secrets per 15 min
message: "Too many secrets created, try again later",
});
const retrieveSecretLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 20, // 20 attempts per 5 min
message: "Too many retrieval attempts, try again later",
});
// Generate a random PIN
function generatePIN(length = PIN_LENGTH) {
const min = Math.pow(10, length - 1);
const max = Math.pow(10, length) - 1;
return crypto.randomInt(min, max).toString();
}
// Encrypt secret before storing (optional extra layer)
function encryptSecret(secret, key) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
"aes-256-gcm",
Buffer.from(key, "hex"),
iv,
);
let encrypted = cipher.update(secret, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return {
encrypted,
iv: iv.toString("hex"),
authTag: authTag.toString("hex"),
};
}
function decryptSecret(encryptedData, key) {
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
Buffer.from(key, "hex"),
Buffer.from(encryptedData.iv, "hex"),
);
decipher.setAuthTag(Buffer.from(encryptedData.authTag, "hex"));
let decrypted = decipher.update(encryptedData.encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
// Store MFA secret with PIN
app.post("/api/store", createSecretLimiter, async (req, res) => {
try {
const { secret, label, issuer } = req.body;
if (!secret || !secret.match(/^[A-Z2-7]+=*$/)) {
return res.status(400).json({ error: "Invalid TOTP secret format" });
}
// Generate PIN and encryption key
const pin = generatePIN();
const encryptionKey = crypto.randomBytes(32).toString("hex");
// Encrypt the secret
const encrypted = encryptSecret(secret, encryptionKey);
// Store in Redis with TTL
const data = {
encrypted: encrypted.encrypted,
iv: encrypted.iv,
authTag: encrypted.authTag,
encryptionKey,
label: label || "MFA Secret",
issuer: issuer || "Unknown",
createdAt: Date.now(),
};
await redis.setex(`mfa:${pin}`, TTL, JSON.stringify(data));
// Track retrieval status
await redis.setex(`mfa:${pin}:retrieved`, TTL, "false");
res.json({
pin,
expiresIn: TTL,
expiresAt: new Date(Date.now() + TTL * 1000).toISOString(),
});
} catch (error) {
console.error("Store error:", error);
res.status(500).json({ error: "Failed to store secret" });
}
});
// Retrieve MFA secret with PIN (one-time use)
app.post("/api/retrieve", retrieveSecretLimiter, async (req, res) => {
try {
const { pin } = req.body;
const pinRegex = new RegExp(`^\\d{${PIN_LENGTH}}$`);
if (!pin || !pin.match(pinRegex)) {
return res.status(400).json({ error: "Invalid PIN format" });
}
// Check User-Agent header
const userAgent = req.get("User-Agent") || "";
if (!userAgent.includes(REQUIRED_USER_AGENT)) {
return res.status(403).json({ error: "Access denied: Invalid client" });
}
// Check if already retrieved
const retrieved = await redis.get(`mfa:${pin}:retrieved`);
if (retrieved === "true") {
return res.status(410).json({ error: "Secret already retrieved" });
}
// Get the secret
const dataStr = await redis.get(`mfa:${pin}`);
if (!dataStr) {
return res.status(404).json({ error: "PIN not found or expired" });
}
const data = JSON.parse(dataStr);
// Decrypt the secret
const secret = decryptSecret(
{
encrypted: data.encrypted,
iv: data.iv,
authTag: data.authTag,
},
data.encryptionKey,
);
// Mark as retrieved and delete immediately
await redis.set(`mfa:${pin}:retrieved`, "true", "KEEPTTL");
await redis.del(`mfa:${pin}`);
res.json({
secret,
label: data.label,
issuer: data.issuer,
type: "totp",
algorithm: "SHA1",
digits: 6,
period: 30,
});
} catch (error) {
console.error("Retrieve error:", error);
res.status(500).json({ error: "Failed to retrieve secret" });
}
});
// Health check
app.get("/api/health", async (req, res) => {
try {
await redis.ping();
res.json({ status: "healthy", redis: "connected" });
} catch (error) {
res.status(503).json({ status: "unhealthy", redis: "disconnected" });
}
});
// Version info
app.get("/api/version", (req, res) => {
res.json({ version: pkg.version });
});
// Admin: Check if PIN exists (without revealing secret)
app.get("/api/check/:pin", async (req, res) => {
try {
const { pin } = req.params;
const exists = await redis.exists(`mfa:${pin}`);
const retrieved = await redis.get(`mfa:${pin}:retrieved`);
const ttl = exists ? await redis.ttl(`mfa:${pin}`) : 0;
res.json({
exists: !!exists,
retrieved: retrieved === "true",
expiresIn: ttl > 0 ? ttl : 0,
});
} catch (error) {
res.status(500).json({ error: "Check failed" });
}
});
// Cleanup on shutdown
process.on("SIGTERM", async () => {
sriInjector.destroy();
await redis.quit();
process.exit(0);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`MFA Sync Service running on port ${PORT}`);
});