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