aead.mjs raw

   1  // TinyJS Runtime — XChaCha20-Poly1305 AEAD for IDB encryption at rest.
   2  // Key is set once per session; all IDB values pass through seal/open.
   3  
   4  import { MAC as poly1305MAC } from './poly1305.mjs';
   5  
   6  let _key = null; // Uint8Array(32) or null
   7  
   8  export function setKey(keyU8) { _key = keyU8; }
   9  export function hasKey() { return _key !== null; }
  10  
  11  // ── ChaCha20 core (uint32 only — no uint64 issues) ──
  12  
  13  function _le32(b, i) { return (b[i] | b[i+1]<<8 | b[i+2]<<16 | b[i+3]<<24) >>> 0; }
  14  
  15  function _qr(s, a, b, c, d) {
  16    s[a] = (s[a] + s[b]) >>> 0; s[d] ^= s[a]; s[d] = ((s[d] << 16) | (s[d] >>> 16)) >>> 0;
  17    s[c] = (s[c] + s[d]) >>> 0; s[b] ^= s[c]; s[b] = ((s[b] << 12) | (s[b] >>> 20)) >>> 0;
  18    s[a] = (s[a] + s[b]) >>> 0; s[d] ^= s[a]; s[d] = ((s[d] << 8) | (s[d] >>> 24)) >>> 0;
  19    s[c] = (s[c] + s[d]) >>> 0; s[b] ^= s[c]; s[b] = ((s[b] << 7) | (s[b] >>> 25)) >>> 0;
  20  }
  21  
  22  function _rounds(s) {
  23    for (let i = 0; i < 10; i++) {
  24      _qr(s,0,4,8,12); _qr(s,1,5,9,13); _qr(s,2,6,10,14); _qr(s,3,7,11,15);
  25      _qr(s,0,5,10,15); _qr(s,1,6,11,12); _qr(s,2,7,8,13); _qr(s,3,4,9,14);
  26    }
  27  }
  28  
  29  function _initState(key) {
  30    const s = new Uint32Array(16);
  31    s[0]=0x61707865; s[1]=0x3320646e; s[2]=0x79622d32; s[3]=0x6b206574;
  32    for (let i = 0; i < 8; i++) s[4+i] = _le32(key, i*4);
  33    return s;
  34  }
  35  
  36  function _hchacha20(key, nonce) {
  37    const s = _initState(key);
  38    for (let i = 0; i < 4; i++) s[12+i] = _le32(nonce, i*4);
  39    _rounds(s);
  40    const out = new Uint8Array(32);
  41    for (let i = 0; i < 4; i++) {
  42      out[i*4] = s[i]&0xff; out[i*4+1]=(s[i]>>>8)&0xff;
  43      out[i*4+2]=(s[i]>>>16)&0xff; out[i*4+3]=(s[i]>>>24)&0xff;
  44    }
  45    for (let i = 0; i < 4; i++) {
  46      const j = i + 12;
  47      out[16+i*4] = s[j]&0xff; out[16+i*4+1]=(s[j]>>>8)&0xff;
  48      out[16+i*4+2]=(s[j]>>>16)&0xff; out[16+i*4+3]=(s[j]>>>24)&0xff;
  49    }
  50    return out;
  51  }
  52  
  53  function _chacha20Block(state) {
  54    const s = new Uint32Array(state);
  55    _rounds(s);
  56    const out = new Uint8Array(64);
  57    for (let i = 0; i < 16; i++) {
  58      const v = (s[i] + state[i]) >>> 0;
  59      out[i*4] = v&0xff; out[i*4+1]=(v>>>8)&0xff; out[i*4+2]=(v>>>16)&0xff; out[i*4+3]=(v>>>24)&0xff;
  60    }
  61    return out;
  62  }
  63  
  64  function _xorAt(key, nonce12, counter, data) {
  65    const state = _initState(key);
  66    state[12] = counter;
  67    for (let i = 0; i < 3; i++) state[13+i] = _le32(nonce12, i*4);
  68    const out = new Uint8Array(data.length);
  69    let pos = 0;
  70    while (pos < data.length) {
  71      const block = _chacha20Block(state);
  72      state[12]++;
  73      const n = Math.min(data.length - pos, 64);
  74      for (let i = 0; i < n; i++) out[pos+i] = data[pos+i] ^ block[i];
  75      pos += n;
  76    }
  77    return out;
  78  }
  79  
  80  // ── AEAD ──
  81  
  82  function _buildMacInput(ct) {
  83    const padLen = (16 - (ct.length % 16)) % 16;
  84    const mi = new Uint8Array(ct.length + padLen + 16);
  85    mi.set(ct);
  86    const tOff = ct.length + padLen;
  87    mi[tOff+8] = ct.length & 0xff;
  88    mi[tOff+9] = (ct.length >>> 8) & 0xff;
  89    mi[tOff+10] = (ct.length >>> 16) & 0xff;
  90    mi[tOff+11] = (ct.length >>> 24) & 0xff;
  91    return mi;
  92  }
  93  
  94  function _polyKey(subkey, chaNonce) {
  95    return _xorAt(subkey, chaNonce, 0, new Uint8Array(32));
  96  }
  97  
  98  // seal: returns Uint8Array(nonce24 ‖ ciphertext ‖ tag16)
  99  export function sealBytes(key, plaintext) {
 100    const nonce = new Uint8Array(24);
 101    crypto.getRandomValues(nonce);
 102    const subkey = _hchacha20(key, nonce.subarray(0, 16));
 103    const chaN = new Uint8Array(12);
 104    chaN.set(nonce.subarray(16, 24), 4);
 105    const pk = _polyKey(subkey, chaN);
 106    const ct = _xorAt(subkey, chaN, 1, plaintext);
 107    const mi = _buildMacInput(ct);
 108    const tag = poly1305MAC(pk, mi);
 109    const out = new Uint8Array(24 + ct.length + 16);
 110    out.set(nonce, 0);
 111    out.set(ct, 24);
 112    // tag is a Slice from poly1305.mjs — convert
 113    for (let i = 0; i < 16; i++) out[24 + ct.length + i] = tag.$array[tag.$offset + i];
 114    return out;
 115  }
 116  
 117  // open: returns Uint8Array plaintext or null
 118  export function openBytes(key, sealed) {
 119    if (sealed.length < 40) return null;
 120    const nonce = sealed.subarray(0, 24);
 121    const ct = sealed.subarray(24, sealed.length - 16);
 122    const tagGot = sealed.subarray(sealed.length - 16);
 123    const subkey = _hchacha20(key, nonce.subarray(0, 16));
 124    const chaN = new Uint8Array(12);
 125    chaN.set(nonce.subarray(16, 24), 4);
 126    const pk = _polyKey(subkey, chaN);
 127    const mi = _buildMacInput(ct);
 128    const tagExp = poly1305MAC(pk, mi);
 129    let diff = 0;
 130    for (let i = 0; i < 16; i++) diff |= tagGot[i] ^ tagExp.$array[tagExp.$offset + i];
 131    if (diff !== 0) return null;
 132    return _xorAt(subkey, chaN, 1, ct);
 133  }
 134  
 135  // String convenience: seal to base64, open from base64.
 136  const _enc = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
 137  const _dec = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
 138  
 139  export function sealStr(str) {
 140    if (!_key) return str;
 141    const pt = _enc.encode(str);
 142    const sealed = sealBytes(_key, pt);
 143    return btoa(String.fromCharCode.apply(null, sealed));
 144  }
 145  
 146  export function openStr(b64) {
 147    if (!_key || !b64) return b64;
 148    // Detect unencrypted data (starts with '{' or '[' — JSON)
 149    if (b64.charAt(0) === '{' || b64.charAt(0) === '[') return b64;
 150    try {
 151      const raw = atob(b64);
 152      const sealed = new Uint8Array(raw.length);
 153      for (let i = 0; i < raw.length; i++) sealed[i] = raw.charCodeAt(i);
 154      const pt = openBytes(_key, sealed);
 155      if (pt === null) return '';
 156      return _dec.decode(pt);
 157    } catch (e) {
 158      // Not base64 or decryption failed — return as-is (migration path)
 159      return b64;
 160    }
 161  }
 162