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