Files
time/ventures/slyng/lib/crypto.js
T

498 lines
18 KiB
JavaScript

// The cryptography SLYNG needs, with no dependencies: SHA-256, HMAC, HKDF, secp256k1 (BIP-340 Schnorr signatures
// and ECDH), ChaCha20, and NIP-44 v2 encryption. Pure functions over Uint8Array, usable in the browser and in Node.
//
// The curve arithmetic uses BigInt and is not constant-time. It signs the occasional event in a browser tab; for a
// key that holds real weight, sign with a nostr extension instead (SLYNG prefers one when it finds one).
const te = new TextEncoder();
const td = new TextDecoder('utf-8', { fatal: true });
export const utf8 = (s) => te.encode(s);
export const fromUtf8 = (b) => td.decode(b);
export function concat(...parts) {
let n = 0;
for (const p of parts) n += p.length;
const out = new Uint8Array(n);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
export function hex(bytes) {
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
export function unhex(s) {
if (typeof s !== 'string' || s.length % 2 || /[^0-9a-f]/i.test(s)) throw new Error('not hex');
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
return out;
}
export const isHex64 = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s);
export function randomBytes(n) {
const out = new Uint8Array(n);
globalThis.crypto.getRandomValues(out);
return out;
}
export function equalBytes(a, b) {
if (a.length !== b.length) return false;
let d = 0;
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
return d === 0;
}
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export function base64(bytes) {
let s = '';
let i = 0;
for (; i + 2 < bytes.length; i += 3) {
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
s += B64[n >> 18] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
}
const rest = bytes.length - i;
if (rest === 1) {
const n = bytes[i] << 16;
s += B64[n >> 18] + B64[(n >> 12) & 63] + '==';
} else if (rest === 2) {
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
s += B64[n >> 18] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + '=';
}
return s;
}
export function unbase64(s) {
if (typeof s !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(s) || s.length % 4) throw new Error('not base64');
const pad = s.endsWith('==') ? 2 : s.endsWith('=') ? 1 : 0;
const out = new Uint8Array((s.length / 4) * 3 - pad);
let o = 0;
for (let i = 0; i < s.length; i += 4) {
const n = (B64.indexOf(s[i]) << 18) | (B64.indexOf(s[i + 1]) << 12) |
((s[i + 2] === '=' ? 0 : B64.indexOf(s[i + 2])) << 6) | (s[i + 3] === '=' ? 0 : B64.indexOf(s[i + 3]));
if (o < out.length) out[o++] = n >> 16;
if (o < out.length) out[o++] = (n >> 8) & 255;
if (o < out.length) out[o++] = n & 255;
}
return out;
}
// ---------------------------------------------------------------------------------------------------- SHA-256
const K256 = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
export function sha256(msg) {
const len = msg.length;
const total = ((len + 9 + 63) >> 6) << 6;
const buf = new Uint8Array(total);
buf.set(msg);
buf[len] = 0x80;
const dv = new DataView(buf.buffer);
dv.setUint32(total - 8, Math.floor(len / 0x20000000));
dv.setUint32(total - 4, (len << 3) >>> 0);
const H = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
const W = new Uint32Array(64);
for (let off = 0; off < total; off += 64) {
for (let i = 0; i < 16; i++) W[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const a = W[i - 15], b = W[i - 2];
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
W[i] = (W[i - 16] + s0 + W[i - 7] + s1) | 0;
}
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
for (let i = 0; i < 64; i++) {
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + K256[i] + W[i]) | 0;
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) | 0;
h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
}
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h;
}
const out = new Uint8Array(32);
const odv = new DataView(out.buffer);
for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]);
return out;
}
export function hmacSha256(key, msg) {
const k = new Uint8Array(64);
k.set(key.length > 64 ? sha256(key) : key);
const inner = new Uint8Array(64 + msg.length);
const outer = new Uint8Array(96);
for (let i = 0; i < 64; i++) { inner[i] = k[i] ^ 0x36; outer[i] = k[i] ^ 0x5c; }
inner.set(msg, 64);
outer.set(sha256(inner), 64);
return sha256(outer);
}
export const hkdfExtract = (salt, ikm) => hmacSha256(salt, ikm);
export function hkdfExpand(prk, info, length) {
if (length > 255 * 32) throw new Error('hkdf length');
const out = new Uint8Array(length);
let prev = new Uint8Array(0);
for (let i = 1, pos = 0; pos < length; i++, pos += 32) {
prev = hmacSha256(prk, concat(prev, info, Uint8Array.of(i)));
out.set(prev.subarray(0, Math.min(32, length - pos)), pos);
}
return out;
}
// ---------------------------------------------------------------------------------------------------- secp256k1
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
export const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
const mod = (a, m = P) => { const r = a % m; return r >= 0n ? r : r + m; };
function inv(a, m = P) {
let r0 = mod(a, m), r1 = m, s0 = 1n, s1 = 0n;
while (r1 !== 0n) {
const q = r0 / r1;
[r0, r1] = [r1, r0 - q * r1];
[s0, s1] = [s1, s0 - q * s1];
}
if (r0 !== 1n) throw new Error('not invertible');
return mod(s0, m);
}
function powMod(b, e, m = P) {
let r = 1n;
b = mod(b, m);
while (e > 0n) {
if (e & 1n) r = (r * b) % m;
b = (b * b) % m;
e >>= 1n;
}
return r;
}
const INF = { X: 0n, Y: 1n, Z: 0n };
function dbl(p) {
if (p.Z === 0n || p.Y === 0n) return INF;
const A = (p.X * p.X) % P;
const B = (p.Y * p.Y) % P;
const C = (B * B) % P;
const xb = p.X + B;
const D = mod(2n * (xb * xb - A - C));
const E = (3n * A) % P;
const F = (E * E) % P;
const X3 = mod(F - 2n * D);
const Y3 = mod(E * (D - X3) - 8n * C);
const Z3 = (2n * p.Y * p.Z) % P;
return { X: X3, Y: Y3, Z: Z3 };
}
function add(p, q) {
if (p.Z === 0n) return q;
if (q.Z === 0n) return p;
const Z1Z1 = (p.Z * p.Z) % P;
const Z2Z2 = (q.Z * q.Z) % P;
const U1 = (p.X * Z2Z2) % P;
const U2 = (q.X * Z1Z1) % P;
const S1 = (((p.Y * q.Z) % P) * Z2Z2) % P;
const S2 = (((q.Y * p.Z) % P) * Z1Z1) % P;
if (U1 === U2) return S1 === S2 ? dbl(p) : INF;
const H = mod(U2 - U1);
const R = mod(S2 - S1);
const H2 = (H * H) % P;
const H3 = (H * H2) % P;
const U1H2 = (U1 * H2) % P;
const X3 = mod(R * R - H3 - 2n * U1H2);
const Y3 = mod(R * (U1H2 - X3) - S1 * H3);
const Z3 = (((H * p.Z) % P) * q.Z) % P;
return { X: X3, Y: Y3, Z: Z3 };
}
const neg = (p) => (p.Z === 0n ? p : { X: p.X, Y: mod(-p.Y), Z: p.Z });
function affine(p) {
if (p.Z === 0n) return null;
const zi = inv(p.Z);
const zi2 = (zi * zi) % P;
return { x: (p.X * zi2) % P, y: (((p.Y * zi2) % P) * zi) % P };
}
// k·p with a 4-bit window: 64 windows of 4 doublings and one addition
function mul(p, k) {
k = mod(k, N);
if (k === 0n || p.Z === 0n) return INF;
const table = [INF, p];
for (let i = 2; i < 16; i++) table.push(add(table[i - 1], p));
let r = INF;
for (let shift = 252n; shift >= 0n; shift -= 4n) {
r = dbl(dbl(dbl(dbl(r))));
const nib = Number((k >> shift) & 15n);
if (nib) r = add(r, table[nib]);
}
return r;
}
// Many points to Z = 1 with one inversion (Montgomery's trick)
function normalizeAll(points) {
const zs = points.map((p) => (p.Z === 0n ? 1n : p.Z));
const prefix = new Array(zs.length);
let acc = 1n;
for (let i = 0; i < zs.length; i++) { prefix[i] = acc; acc = (acc * zs[i]) % P; }
let invAcc = inv(acc);
const out = new Array(points.length);
for (let i = zs.length - 1; i >= 0; i--) {
const zi = (invAcc * prefix[i]) % P;
invAcc = (invAcc * zs[i]) % P;
const p = points[i];
if (p.Z === 0n) { out[i] = INF; continue; }
const zi2 = (zi * zi) % P;
out[i] = { X: (p.X * zi2) % P, Y: (((p.Y * zi2) % P) * zi) % P, Z: 1n };
}
return out;
}
// G multiples, precomputed once: GT[w][j] = j·16^w·G, so k·G is 64 additions and no doublings
let GT = null;
function gTable() {
if (GT) return GT;
const flat = [];
let base = { X: GX, Y: GY, Z: 1n };
for (let w = 0; w < 64; w++) {
const row = [INF, base];
for (let j = 2; j < 16; j++) row.push(add(row[j - 1], base));
flat.push(...row);
base = dbl(dbl(dbl(dbl(base))));
}
const norm = normalizeAll(flat);
GT = [];
for (let w = 0; w < 64; w++) GT.push(norm.slice(w * 16, w * 16 + 16));
return GT;
}
function mulG(k) {
k = mod(k, N);
if (k === 0n) return INF;
const T = gTable();
let r = INF;
for (let w = 0; w < 64; w++) {
const nib = Number((k >> BigInt(w * 4)) & 15n);
if (nib) r = add(r, T[w][nib]);
}
return r;
}
export const bytesToBig = (b) => (b.length ? BigInt('0x' + hex(b)) : 0n);
export const bigToBytes = (n) => unhex(n.toString(16).padStart(64, '0'));
function liftX(x) {
if (x >= P) return null;
const c = mod(x * x * x + 7n);
const y = powMod(c, (P + 1n) / 4n);
if ((y * y) % P !== c) return null;
return { X: x, Y: (y & 1n) === 0n ? y : P - y, Z: 1n };
}
function privToBig(priv) {
const d = bytesToBig(priv);
if (priv.length !== 32 || d <= 0n || d >= N) throw new Error('bad private key');
return d;
}
export function isValidPrivateKey(priv) {
try { privToBig(priv); return true; } catch { return false; }
}
export function generatePrivateKey() {
for (;;) {
const k = randomBytes(32);
if (isValidPrivateKey(k)) return k;
}
}
// x-only public key (32 bytes), as nostr uses
export function getPublicKey(priv) {
return bigToBytes(affine(mulG(privToBig(priv))).x);
}
export function isValidPublicKey(pub) {
return pub.length === 32 && liftX(bytesToBig(pub)) !== null;
}
function taggedHash(tag, ...msgs) {
const t = sha256(utf8(tag));
return sha256(concat(t, t, ...msgs));
}
export function schnorrSign(msg, priv, aux = randomBytes(32)) {
const d0 = privToBig(priv);
const Pt = affine(mulG(d0));
const d = (Pt.y & 1n) === 0n ? d0 : N - d0;
const px = bigToBytes(Pt.x);
const ta = taggedHash('BIP0340/aux', aux);
const t = bigToBytes(d).map((b, i) => b ^ ta[i]);
const k0 = mod(bytesToBig(taggedHash('BIP0340/nonce', t, px, msg)), N);
if (k0 === 0n) throw new Error('nonce is zero');
const R = affine(mulG(k0));
const k = (R.y & 1n) === 0n ? k0 : N - k0;
const rx = bigToBytes(R.x);
const e = mod(bytesToBig(taggedHash('BIP0340/challenge', rx, px, msg)), N);
const sig = concat(rx, bigToBytes(mod(k + e * d, N)));
if (!schnorrVerify(sig, msg, px)) throw new Error('signature did not verify');
return sig;
}
export function schnorrVerify(sig, msg, pub) {
try {
if (sig.length !== 64 || pub.length !== 32) return false;
const Pt = liftX(bytesToBig(pub));
if (!Pt) return false;
const r = bytesToBig(sig.subarray(0, 32));
const s = bytesToBig(sig.subarray(32, 64));
if (r >= P || s >= N) return false;
const e = mod(bytesToBig(taggedHash('BIP0340/challenge', sig.subarray(0, 32), pub, msg)), N);
const R = affine(add(mulG(s), neg(mul(Pt, e))));
if (!R || (R.y & 1n) !== 0n) return false;
return R.x === r;
} catch {
return false;
}
}
// ECDH as NIP-04 and NIP-44 use it: the unhashed x coordinate of priv·pub
export function sharedX(priv, pubX) {
const Pt = liftX(bytesToBig(pubX));
if (!Pt) throw new Error('bad public key');
return bigToBytes(affine(mul(Pt, privToBig(priv))).x);
}
// ---------------------------------------------------------------------------------------------------- ChaCha20
function rotl(v, c) { return (v << c) | (v >>> (32 - c)); }
function quarter(s, a, b, c, d) {
s[a] = (s[a] + s[b]) | 0; s[d] = rotl(s[d] ^ s[a], 16);
s[c] = (s[c] + s[d]) | 0; s[b] = rotl(s[b] ^ s[c], 12);
s[a] = (s[a] + s[b]) | 0; s[d] = rotl(s[d] ^ s[a], 8);
s[c] = (s[c] + s[d]) | 0; s[b] = rotl(s[b] ^ s[c], 7);
}
// RFC 8439 ChaCha20: 32-byte key, 12-byte nonce, 32-bit block counter
export function chacha20(key, nonce, data, counter = 0) {
if (key.length !== 32 || nonce.length !== 12) throw new Error('chacha20 key or nonce');
const kv = new DataView(key.buffer, key.byteOffset, 32);
const nv = new DataView(nonce.buffer, nonce.byteOffset, 12);
const init = new Int32Array(16);
init[0] = 0x61707865; init[1] = 0x3320646e; init[2] = 0x79622d32; init[3] = 0x6b206574;
for (let i = 0; i < 8; i++) init[4 + i] = kv.getUint32(i * 4, true);
for (let i = 0; i < 3; i++) init[13 + i] = nv.getUint32(i * 4, true);
const out = new Uint8Array(data.length);
const s = new Int32Array(16);
const block = new Uint8Array(64);
const bv = new DataView(block.buffer);
for (let off = 0; off < data.length; off += 64) {
init[12] = counter++;
s.set(init);
for (let i = 0; i < 10; i++) {
quarter(s, 0, 4, 8, 12); quarter(s, 1, 5, 9, 13); quarter(s, 2, 6, 10, 14); quarter(s, 3, 7, 11, 15);
quarter(s, 0, 5, 10, 15); quarter(s, 1, 6, 11, 12); quarter(s, 2, 7, 8, 13); quarter(s, 3, 4, 9, 14);
}
for (let i = 0; i < 16; i++) bv.setUint32(i * 4, (s[i] + init[i]) | 0, true);
const n = Math.min(64, data.length - off);
for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ block[i];
}
return out;
}
// ---------------------------------------------------------------------------------------------------- NIP-44 v2
export function nip44ConversationKey(priv, pubX) {
return hkdfExtract(utf8('nip44-v2'), sharedX(priv, pubX));
}
export function calcPaddedLen(len) {
if (len < 1) throw new Error('empty message');
if (len <= 32) return 32;
const nextPower = 2 ** (Math.floor(Math.log2(len - 1)) + 1);
const chunk = nextPower <= 256 ? 32 : nextPower / 8;
return chunk * (Math.floor((len - 1) / chunk) + 1);
}
function messageKeys(conversationKey, nonce) {
const k = hkdfExpand(conversationKey, nonce, 76);
return { chachaKey: k.subarray(0, 32), chachaNonce: k.subarray(32, 44), hmacKey: k.subarray(44, 76) };
}
function pad(text) {
const u = utf8(text);
if (u.length < 1 || u.length > 65535) throw new Error('message must be 1 to 65535 bytes');
const out = new Uint8Array(2 + calcPaddedLen(u.length));
out[0] = u.length >> 8;
out[1] = u.length & 255;
out.set(u, 2);
return out;
}
function unpad(padded) {
const len = (padded[0] << 8) | padded[1];
if (len < 1 || padded.length !== 2 + calcPaddedLen(len)) throw new Error('bad padding');
return fromUtf8(padded.subarray(2, 2 + len));
}
export function nip44Encrypt(text, conversationKey, nonce = randomBytes(32)) {
const { chachaKey, chachaNonce, hmacKey } = messageKeys(conversationKey, nonce);
const ct = chacha20(chachaKey, chachaNonce, pad(text));
const mac = hmacSha256(hmacKey, concat(nonce, ct));
return base64(concat(Uint8Array.of(2), nonce, ct, mac));
}
export function nip44Decrypt(payload, conversationKey) {
if (typeof payload !== 'string' || payload.startsWith('#')) throw new Error('unsupported encryption version');
if (payload.length < 132 || payload.length > 87472) throw new Error('bad payload size');
const data = unbase64(payload);
if (data.length < 99 || data.length > 65603) throw new Error('bad payload size');
if (data[0] !== 2) throw new Error('unsupported encryption version');
const nonce = data.subarray(1, 33);
const ct = data.subarray(33, data.length - 32);
const mac = data.subarray(data.length - 32);
const { chachaKey, chachaNonce, hmacKey } = messageKeys(conversationKey, nonce);
if (!equalBytes(hmacSha256(hmacKey, concat(nonce, ct)), mac)) throw new Error('bad mac');
return unpad(chacha20(chachaKey, chachaNonce, ct));
}
// ---------------------------------------------------------------------------------------------------- NIP-04 (for wallets)
// AES-256-CBC with the raw ECDH x as key. Only Nostr Wallet Connect still needs it, for wallets without NIP-44.
async function aesKey(priv, pubX, usage) {
return globalThis.crypto.subtle.importKey('raw', sharedX(priv, pubX), { name: 'AES-CBC' }, false, [usage]);
}
export async function nip04Encrypt(text, priv, pubX, iv = randomBytes(16)) {
const key = await aesKey(priv, pubX, 'encrypt');
const ct = new Uint8Array(await globalThis.crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, utf8(text)));
return base64(ct) + '?iv=' + base64(iv);
}
export async function nip04Decrypt(payload, priv, pubX) {
const [ct, ivPart] = String(payload).split('?iv=');
if (!ivPart) throw new Error('not a NIP-04 payload');
const key = await aesKey(priv, pubX, 'decrypt');
const pt = await globalThis.crypto.subtle.decrypt({ name: 'AES-CBC', iv: unbase64(ivPart) }, key, unbase64(ct));
return fromUtf8(new Uint8Array(pt));
}