141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
/**
|
|
* @wrnexus/encryption — authenticated symmetric encryption (AES-256-GCM) via
|
|
* WebCrypto, dependency-free. Use it to encrypt secrets, tokens, or database
|
|
* fields at rest.
|
|
*
|
|
* const key = await generateKey(); // store this safely
|
|
* const box = await encrypt("card #1234", key); // opaque base64 string
|
|
* const plain = await decrypt(box, key); // "card #1234"
|
|
*
|
|
* A key derived from a password (PBKDF2) is also supported via `deriveKey`.
|
|
*/
|
|
|
|
const enc = new TextEncoder();
|
|
const dec = new TextDecoder();
|
|
const IV_BYTES = 12;
|
|
|
|
function toB64(bytes: Uint8Array): string {
|
|
let bin = "";
|
|
for (const b of bytes) bin += String.fromCharCode(b);
|
|
return btoa(bin);
|
|
}
|
|
function fromB64(str: string): Uint8Array {
|
|
const bin = atob(str);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
function toHex(bytes: Uint8Array): string {
|
|
let out = "";
|
|
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
return out;
|
|
}
|
|
|
|
/** SHA-256 hex digest of a string (e.g. content hashing, dedup keys). */
|
|
export async function sha256(data: string): Promise<string> {
|
|
const digest = await crypto.subtle.digest("SHA-256", enc.encode(data) as BufferSource);
|
|
return toHex(new Uint8Array(digest));
|
|
}
|
|
|
|
/** HMAC-SHA256 hex signature of `data` with `secret` (e.g. signing webhooks). */
|
|
export async function hmacSign(data: string, secret: string): Promise<string> {
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
enc.encode(secret) as BufferSource,
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data) as BufferSource);
|
|
return toHex(new Uint8Array(sig));
|
|
}
|
|
|
|
/** Constant-time verify of an HMAC-SHA256 signature. */
|
|
export async function hmacVerify(
|
|
data: string,
|
|
secret: string,
|
|
signature: string,
|
|
): Promise<boolean> {
|
|
const expected = await hmacSign(data, secret);
|
|
if (expected.length !== signature.length) return false;
|
|
let diff = 0;
|
|
for (let i = 0; i < expected.length; i++)
|
|
diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
|
return diff === 0;
|
|
}
|
|
|
|
/** Generate a random 256-bit key, base64-encoded. Store it as a secret. */
|
|
export async function generateKey(): Promise<string> {
|
|
const bytes = new Uint8Array(32);
|
|
crypto.getRandomValues(bytes);
|
|
return toB64(bytes);
|
|
}
|
|
|
|
async function importAesKey(key: string): Promise<CryptoKey> {
|
|
const raw = fromB64(key);
|
|
if (raw.length !== 32) throw new Error("Encryption key must be a base64 256-bit key");
|
|
return crypto.subtle.importKey("raw", raw as BufferSource, "AES-GCM", false, [
|
|
"encrypt",
|
|
"decrypt",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Encrypt a string. Output is base64 of `iv(12) || ciphertext+tag`, safe to
|
|
* store or transmit. Each call uses a fresh random IV.
|
|
*/
|
|
export async function encrypt(plaintext: string, key: string): Promise<string> {
|
|
const cryptoKey = await importAesKey(key);
|
|
const iv = new Uint8Array(IV_BYTES);
|
|
crypto.getRandomValues(iv);
|
|
const ciphertext = new Uint8Array(
|
|
await crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv: iv as BufferSource },
|
|
cryptoKey,
|
|
enc.encode(plaintext) as BufferSource,
|
|
),
|
|
);
|
|
const packed = new Uint8Array(iv.length + ciphertext.length);
|
|
packed.set(iv, 0);
|
|
packed.set(ciphertext, iv.length);
|
|
return toB64(packed);
|
|
}
|
|
|
|
/** Decrypt a value produced by `encrypt`. Throws if the key is wrong or data tampered. */
|
|
export async function decrypt(payload: string, key: string): Promise<string> {
|
|
const cryptoKey = await importAesKey(key);
|
|
const packed = fromB64(payload);
|
|
if (packed.length <= IV_BYTES) throw new Error("Invalid ciphertext");
|
|
const iv = packed.slice(0, IV_BYTES);
|
|
const ciphertext = packed.slice(IV_BYTES);
|
|
const plain = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: iv as BufferSource },
|
|
cryptoKey,
|
|
ciphertext as BufferSource,
|
|
);
|
|
return dec.decode(plain);
|
|
}
|
|
|
|
/** Derive a base64 AES key from a password + salt (PBKDF2, 100k iterations). */
|
|
export async function deriveKey(password: string, salt: string): Promise<string> {
|
|
const baseKey = await crypto.subtle.importKey(
|
|
"raw",
|
|
enc.encode(password) as BufferSource,
|
|
"PBKDF2",
|
|
false,
|
|
["deriveBits"],
|
|
);
|
|
const bits = await crypto.subtle.deriveBits(
|
|
{
|
|
name: "PBKDF2",
|
|
salt: enc.encode(salt) as BufferSource,
|
|
iterations: 100_000,
|
|
hash: "SHA-256",
|
|
},
|
|
baseKey,
|
|
256,
|
|
);
|
|
return toB64(new Uint8Array(bits));
|
|
}
|