decrypt.html raw
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Marmot Email Bridge - Decrypt Attachment</title>
7 <style>
8 :root {
9 --bg: #1a1a2e;
10 --surface: #16213e;
11 --border: #0f3460;
12 --text: #e4e4e4;
13 --muted: #8888aa;
14 --accent: #e94560;
15 --accent-hover: #ff6b81;
16 --success: #2ecc71;
17 --input-bg: #0f1729;
18 }
19
20 * { margin: 0; padding: 0; box-sizing: border-box; }
21
22 body {
23 font-family: 'Courier New', monospace;
24 background: var(--bg);
25 color: var(--text);
26 min-height: 100vh;
27 padding: 2rem;
28 }
29
30 .container {
31 max-width: 640px;
32 margin: 0 auto;
33 }
34
35 h1 {
36 font-size: 1.4rem;
37 color: var(--accent);
38 margin-bottom: 0.5rem;
39 }
40
41 .subtitle {
42 color: var(--muted);
43 font-size: 0.85rem;
44 margin-bottom: 2rem;
45 }
46
47 .form-group {
48 margin-bottom: 1rem;
49 }
50
51 label {
52 display: block;
53 color: var(--muted);
54 font-size: 0.8rem;
55 margin-bottom: 0.3rem;
56 text-transform: uppercase;
57 letter-spacing: 0.05em;
58 }
59
60 input {
61 width: 100%;
62 padding: 0.6rem 0.8rem;
63 background: var(--input-bg);
64 border: 1px solid var(--border);
65 color: var(--text);
66 font-family: inherit;
67 font-size: 0.9rem;
68 border-radius: 4px;
69 }
70
71 input:focus {
72 outline: none;
73 border-color: var(--accent);
74 }
75
76 button {
77 padding: 0.7rem 1.5rem;
78 font-family: inherit;
79 font-size: 0.9rem;
80 border: 1px solid var(--accent);
81 border-radius: 4px;
82 cursor: pointer;
83 transition: all 0.2s;
84 background: var(--accent);
85 color: white;
86 margin-top: 1rem;
87 }
88
89 button:hover {
90 background: var(--accent-hover);
91 }
92
93 button:disabled {
94 opacity: 0.5;
95 cursor: not-allowed;
96 }
97
98 .status {
99 margin-top: 1rem;
100 padding: 0.5rem;
101 border-radius: 4px;
102 font-size: 0.85rem;
103 display: none;
104 }
105
106 .status.success {
107 display: block;
108 color: var(--success);
109 border: 1px solid var(--success);
110 }
111
112 .status.error {
113 display: block;
114 color: var(--accent);
115 border: 1px solid var(--accent);
116 }
117
118 .status.info {
119 display: block;
120 color: var(--muted);
121 border: 1px solid var(--border);
122 }
123
124 .progress {
125 margin-top: 1rem;
126 display: none;
127 }
128
129 .progress.visible {
130 display: block;
131 }
132
133 .progress-bar {
134 height: 4px;
135 background: var(--border);
136 border-radius: 2px;
137 overflow: hidden;
138 }
139
140 .progress-fill {
141 height: 100%;
142 background: var(--accent);
143 transition: width 0.3s;
144 }
145
146 .info-box {
147 margin-top: 2rem;
148 padding: 1rem;
149 background: var(--surface);
150 border-left: 3px solid var(--accent);
151 font-size: 0.8rem;
152 color: var(--muted);
153 line-height: 1.5;
154 }
155 </style>
156 </head>
157 <body>
158 <div class="container">
159 <h1>Marmot Email Bridge</h1>
160 <p class="subtitle">Decrypt an attachment from a Nostr DM</p>
161
162 <div class="form-group">
163 <label for="url">Attachment URL (with #key fragment)</label>
164 <input type="text" id="url" placeholder="https://blossom.example.com/abc123#decryptionkey">
165 </div>
166
167 <button onclick="decryptAndDownload()" id="decrypt-btn">Decrypt & Download</button>
168
169 <div class="progress" id="progress">
170 <div class="progress-bar"><div class="progress-fill" id="progress-fill" style="width:0%"></div></div>
171 </div>
172
173 <div class="status" id="status"></div>
174
175 <div class="info-box">
176 <strong>How this works:</strong> The URL contains a Blossom blob address and an
177 encryption key after the # symbol. The key never leaves your browser — the server
178 only sees an opaque encrypted blob. This page downloads the blob, decrypts it
179 locally using ChaCha20-Poly1305, and offers the decrypted file for download.
180 <br><br>
181 <strong>Privacy:</strong> The encryption key (after #) is never sent to any server.
182 Per RFC 3986, URL fragments are client-side only.
183 </div>
184 </div>
185
186 <script>
187 // Parse URL from fragment on page load
188 (function() {
189 const hash = window.location.hash.substring(1);
190 if (hash && hash.includes('http')) {
191 document.getElementById('url').value = hash;
192 }
193 })();
194
195 async function decryptAndDownload() {
196 const btn = document.getElementById('decrypt-btn');
197 const urlInput = document.getElementById('url').value.trim();
198
199 if (!urlInput) {
200 showStatus('Please enter a URL.', 'error');
201 return;
202 }
203
204 // Parse URL and fragment key
205 const hashIdx = urlInput.indexOf('#');
206 if (hashIdx === -1) {
207 showStatus('URL must contain a #key fragment for decryption.', 'error');
208 return;
209 }
210
211 const blobURL = urlInput.substring(0, hashIdx);
212 const keyHex = urlInput.substring(hashIdx + 1);
213
214 if (keyHex.length !== 64) {
215 showStatus('Invalid key: expected 64 hex characters, got ' + keyHex.length, 'error');
216 return;
217 }
218
219 btn.disabled = true;
220 showProgress(10);
221 showStatus('Downloading encrypted blob...', 'info');
222
223 try {
224 // Download the encrypted blob
225 const response = await fetch(blobURL);
226 if (!response.ok) {
227 throw new Error('Download failed: ' + response.status + ' ' + response.statusText);
228 }
229
230 showProgress(50);
231 showStatus('Decrypting...', 'info');
232
233 const encrypted = new Uint8Array(await response.arrayBuffer());
234
235 // Decode hex key
236 const key = hexToBytes(keyHex);
237
238 // Import key for AES-256-GCM (WebCrypto fallback since ChaCha20 isn't natively supported)
239 // NOTE: The bridge uses ChaCha20-Poly1305. Since WebCrypto doesn't support it,
240 // we implement it here using a minimal JS implementation.
241 const decrypted = await decryptChaCha20Poly1305(key, encrypted);
242
243 showProgress(90);
244
245 // Create download
246 const blob = new Blob([decrypted], { type: 'application/zip' });
247 const url = URL.createObjectURL(blob);
248 const a = document.createElement('a');
249 a.href = url;
250 a.download = 'attachment.zip';
251 a.click();
252 URL.revokeObjectURL(url);
253
254 showProgress(100);
255 showStatus('Decrypted successfully! Check your downloads.', 'success');
256
257 } catch (err) {
258 showStatus('Error: ' + err.message, 'error');
259 } finally {
260 btn.disabled = false;
261 }
262 }
263
264 function hexToBytes(hex) {
265 const bytes = new Uint8Array(hex.length / 2);
266 for (let i = 0; i < hex.length; i += 2) {
267 bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
268 }
269 return bytes;
270 }
271
272 function showProgress(pct) {
273 const el = document.getElementById('progress');
274 const fill = document.getElementById('progress-fill');
275 el.classList.add('visible');
276 fill.style.width = pct + '%';
277 }
278
279 function showStatus(msg, type) {
280 const el = document.getElementById('status');
281 el.textContent = msg;
282 el.className = 'status ' + type;
283 }
284
285 // ============================================================
286 // ChaCha20-Poly1305 AEAD decryption (pure JS)
287 // Nonce is prepended to ciphertext (12 bytes nonce + ciphertext + 16 bytes tag)
288 // ============================================================
289
290 async function decryptChaCha20Poly1305(key, data) {
291 // Nonce size for ChaCha20-Poly1305 is 12 bytes
292 const NONCE_SIZE = 12;
293 const TAG_SIZE = 16;
294
295 if (data.length < NONCE_SIZE + TAG_SIZE) {
296 throw new Error('Data too short for ChaCha20-Poly1305');
297 }
298
299 const nonce = data.slice(0, NONCE_SIZE);
300 const ciphertextWithTag = data.slice(NONCE_SIZE);
301
302 // Since WebCrypto doesn't support ChaCha20-Poly1305 natively,
303 // we use a pure JS implementation. For production, consider inlining
304 // a well-audited library like @stablelib/chacha20poly1305.
305 //
306 // Fallback: use Web Crypto AES-256-GCM if the bridge is configured
307 // to use AES-GCM instead. For now, we provide the ChaCha20 implementation.
308
309 // Minimal ChaCha20-Poly1305 implementation follows.
310 // This is a straightforward port of the RFC 7539 reference.
311
312 const ciphertext = ciphertextWithTag.slice(0, ciphertextWithTag.length - TAG_SIZE);
313 const tag = ciphertextWithTag.slice(ciphertextWithTag.length - TAG_SIZE);
314
315 // Generate Poly1305 key (first 32 bytes of ChaCha20 keystream with counter=0)
316 const polyKey = chacha20Block(key, nonce, 0).slice(0, 32);
317
318 // Verify Poly1305 tag
319 const aad = new Uint8Array(0); // no additional authenticated data
320 const expectedTag = poly1305Mac(polyKey, aad, ciphertext);
321
322 if (!constantTimeEqual(tag, expectedTag)) {
323 throw new Error('Authentication failed: invalid tag');
324 }
325
326 // Decrypt with ChaCha20 (counter starts at 1 for encryption)
327 const plaintext = chacha20Encrypt(key, nonce, 1, ciphertext);
328
329 return plaintext;
330 }
331
332 // ChaCha20 quarter round
333 function quarterRound(state, a, b, c, d) {
334 state[a] = (state[a] + state[b]) | 0; state[d] ^= state[a]; state[d] = rotl32(state[d], 16);
335 state[c] = (state[c] + state[d]) | 0; state[b] ^= state[c]; state[b] = rotl32(state[b], 12);
336 state[a] = (state[a] + state[b]) | 0; state[d] ^= state[a]; state[d] = rotl32(state[d], 8);
337 state[c] = (state[c] + state[d]) | 0; state[b] ^= state[c]; state[b] = rotl32(state[b], 7);
338 }
339
340 function rotl32(v, n) {
341 return ((v << n) | (v >>> (32 - n))) | 0;
342 }
343
344 function chacha20Block(key, nonce, counter) {
345 const state = new Int32Array(16);
346
347 // "expand 32-byte k"
348 state[0] = 0x61707865; state[1] = 0x3320646e;
349 state[2] = 0x79622d32; state[3] = 0x6b206574;
350
351 // Key
352 const kv = new DataView(key.buffer, key.byteOffset, 32);
353 for (let i = 0; i < 8; i++) state[4 + i] = kv.getInt32(i * 4, true);
354
355 // Counter
356 state[12] = counter;
357
358 // Nonce
359 const nv = new DataView(nonce.buffer, nonce.byteOffset, 12);
360 state[13] = nv.getInt32(0, true);
361 state[14] = nv.getInt32(4, true);
362 state[15] = nv.getInt32(8, true);
363
364 const working = new Int32Array(state);
365
366 for (let i = 0; i < 10; i++) {
367 quarterRound(working, 0, 4, 8, 12);
368 quarterRound(working, 1, 5, 9, 13);
369 quarterRound(working, 2, 6, 10, 14);
370 quarterRound(working, 3, 7, 11, 15);
371 quarterRound(working, 0, 5, 10, 15);
372 quarterRound(working, 1, 6, 11, 12);
373 quarterRound(working, 2, 7, 8, 13);
374 quarterRound(working, 3, 4, 9, 14);
375 }
376
377 const output = new Uint8Array(64);
378 const dv = new DataView(output.buffer);
379 for (let i = 0; i < 16; i++) {
380 dv.setInt32(i * 4, (working[i] + state[i]) | 0, true);
381 }
382
383 return output;
384 }
385
386 function chacha20Encrypt(key, nonce, startCounter, input) {
387 const output = new Uint8Array(input.length);
388 let counter = startCounter;
389
390 for (let offset = 0; offset < input.length; offset += 64) {
391 const block = chacha20Block(key, nonce, counter);
392 const remaining = Math.min(64, input.length - offset);
393 for (let i = 0; i < remaining; i++) {
394 output[offset + i] = input[offset + i] ^ block[i];
395 }
396 counter++;
397 }
398
399 return output;
400 }
401
402 // Poly1305 MAC (RFC 7539)
403 function poly1305Mac(key, aad, ciphertext) {
404 // Poly1305 operates on 130-bit numbers. We use BigInt for correctness.
405 const r = clampR(readLE(key.slice(0, 16)));
406 const s = readLE(key.slice(16, 32));
407 const p = (1n << 130n) - 5n;
408
409 let acc = 0n;
410
411 // Process AAD
412 acc = poly1305Process(acc, r, p, aad);
413 // Process ciphertext
414 acc = poly1305Process(acc, r, p, ciphertext);
415
416 // Construct length block: le64(aad.length) || le64(ciphertext.length)
417 const lenBlock = new Uint8Array(16);
418 const lv = new DataView(lenBlock.buffer);
419 lv.setUint32(0, aad.length, true);
420 lv.setUint32(8, ciphertext.length, true);
421 acc = poly1305ProcessBlock(acc, r, p, lenBlock, 16);
422
423 // Final: acc + s
424 acc = (acc + s) & ((1n << 128n) - 1n);
425
426 // Output as 16 bytes little-endian
427 const tag = new Uint8Array(16);
428 for (let i = 0; i < 16; i++) {
429 tag[i] = Number((acc >> BigInt(i * 8)) & 0xFFn);
430 }
431 return tag;
432 }
433
434 function poly1305Process(acc, r, p, data) {
435 // Pad each 16-byte block
436 for (let i = 0; i < data.length; i += 16) {
437 const blockSize = Math.min(16, data.length - i);
438 const block = data.slice(i, i + blockSize);
439 acc = poly1305ProcessBlock(acc, r, p, block, blockSize);
440 }
441 return acc;
442 }
443
444 function poly1305ProcessBlock(acc, r, p, block, blockSize) {
445 // Read block as little-endian number, append 0x01 byte
446 let n = 0n;
447 for (let j = blockSize - 1; j >= 0; j--) {
448 n = (n << 8n) | BigInt(block[j]);
449 }
450 n |= (1n << BigInt(blockSize * 8));
451 acc = ((acc + n) * r) % p;
452 return acc;
453 }
454
455 function clampR(r) {
456 // Clear bits: 4,8,12,16 of each 32-bit word, and top 4 bits of word 1,2,3
457 return r & 0x0ffffffc0ffffffc0ffffffc0fffffffn;
458 }
459
460 function readLE(bytes) {
461 let n = 0n;
462 for (let i = bytes.length - 1; i >= 0; i--) {
463 n = (n << 8n) | BigInt(bytes[i]);
464 }
465 return n;
466 }
467
468 function constantTimeEqual(a, b) {
469 if (a.length !== b.length) return false;
470 let diff = 0;
471 for (let i = 0; i < a.length; i++) {
472 diff |= a[i] ^ b[i];
473 }
474 return diff === 0;
475 }
476 </script>
477 </body>
478 </html>
479