alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
192 lines (191 loc) • 7.29 kB
JavaScript
import { $module, AlephaError } from "alepha";
//#region ../../src/crypto/providers/BrowserCryptoProvider.ts
var BrowserCryptoProvider = class BrowserCryptoProvider {
static AES_ALGORITHM = "AES-GCM";
static AES_IV_LENGTH = 12;
hashPassword() {
throw new AlephaError("hashPassword is not supported in the browser");
}
verifyPassword() {
throw new AlephaError("verifyPassword is not supported in the browser");
}
async hash(data, algorithm = "SHA-256") {
const encoded = new TextEncoder().encode(data);
const digest = await crypto.subtle.digest(algorithm, encoded);
return this.toHex(digest);
}
async hmac(data, secret, algorithm = "SHA-256") {
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
name: "HMAC",
hash: algorithm
}, false, ["sign"]);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
return this.toHex(signature);
}
async verifyHmac(data, signature, secret, algorithm = "SHA-256") {
const expected = await this.hmac(data, secret, algorithm);
return this.equals(expected, signature);
}
async encrypt(plaintext, key) {
const iv = crypto.getRandomValues(new Uint8Array(BrowserCryptoProvider.AES_IV_LENGTH));
const cryptoKey = await this.deriveAesKey(key);
const encoded = new TextEncoder().encode(plaintext);
const encrypted = await crypto.subtle.encrypt({
name: BrowserCryptoProvider.AES_ALGORITHM,
iv: iv.buffer
}, cryptoKey, encoded);
const encryptedBytes = new Uint8Array(encrypted);
const ciphertext = encryptedBytes.slice(0, -16);
const tag = encryptedBytes.slice(-16);
return `${this.toHex(iv)}:${this.toHex(tag)}:${this.toHex(ciphertext)}`;
}
async decrypt(ciphertext, key) {
const parts = ciphertext.split(":");
if (parts.length !== 3) throw new AlephaError("Invalid ciphertext format");
const [ivHex, tagHex, encryptedHex] = parts;
const iv = this.fromHex(ivHex);
const tag = this.fromHex(tagHex);
const encrypted = this.fromHex(encryptedHex);
const combined = new Uint8Array(encrypted.length + tag.length);
combined.set(encrypted);
combined.set(tag, encrypted.length);
const cryptoKey = await this.deriveAesKey(key);
const decrypted = await crypto.subtle.decrypt({
name: BrowserCryptoProvider.AES_ALGORITHM,
iv: iv.buffer
}, cryptoKey, combined.buffer);
return new TextDecoder().decode(decrypted);
}
equals(a, b) {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
return result === 0;
}
randomUUID() {
return crypto.randomUUID();
}
randomText(length) {
const bytes = crypto.getRandomValues(new Uint8Array(length));
return this.toBase64Url(bytes.buffer).slice(0, length);
}
randomCode(length) {
const max = 10 ** length;
const array = /* @__PURE__ */ new Uint32Array(1);
const limit = Math.floor(4294967296 / max) * max;
let value;
do {
crypto.getRandomValues(array);
value = array[0];
} while (value >= limit);
const code = value % max;
return String(code).padStart(length, "0");
}
toHex(buffer) {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
}
fromHex(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16);
return bytes;
}
toBase64Url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async deriveAesKey(key) {
const encoded = new TextEncoder().encode(key);
const hash = await crypto.subtle.digest("SHA-256", encoded);
return crypto.subtle.importKey("raw", hash, { name: BrowserCryptoProvider.AES_ALGORITHM }, false, ["encrypt", "decrypt"]);
}
/**
* Derive an AES-GCM key from a low-entropy user passphrase using
* PBKDF2-SHA-256. Use this in place of {@link deriveAesKey} whenever the
* key material is a human-chosen string — a single SHA-256 over a
* passphrase is brute-forceable by anyone with the ciphertext.
*
* OWASP 2023 recommends 600k iterations of PBKDF2-SHA-256 for password
* storage; we use the same budget for client-side at-rest content keys.
* Each protected blob carries its own random salt so the same passphrase
* derives a different key per blob.
*/
async deriveKeyFromPassphrase(passphrase, saltHex, iterations = 6e5) {
const baseKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), { name: "PBKDF2" }, false, ["deriveKey"]);
return crypto.subtle.deriveKey({
name: "PBKDF2",
salt: this.fromHex(saltHex).buffer,
iterations,
hash: "SHA-256"
}, baseKey, {
name: BrowserCryptoProvider.AES_ALGORITHM,
length: 256
}, false, ["encrypt", "decrypt"]);
}
/**
* Encrypt `plaintext` with a pre-derived passphrase key, returning a
* self-contained envelope (versioned for forward compatibility):
*
* { v: 1, salt, iv, ciphertext, kdf }
*
* `salt` + `kdf` are echoed back so {@link decryptWithPassphrase} can
* reproduce the exact derivation. Each call generates a fresh IV — pass
* a stable salt across saves of the same protected blob so the
* passphrase only has to be derived once per session.
*/
async encryptWithPassphrase(plaintext, key, saltHex, iterations = 6e5) {
const iv = crypto.getRandomValues(new Uint8Array(BrowserCryptoProvider.AES_IV_LENGTH));
const encrypted = await crypto.subtle.encrypt({
name: BrowserCryptoProvider.AES_ALGORITHM,
iv: iv.buffer
}, key, new TextEncoder().encode(plaintext));
return JSON.stringify({
v: 1,
salt: saltHex,
iv: this.toHex(iv),
ciphertext: this.toHex(new Uint8Array(encrypted)),
kdf: {
name: "PBKDF2",
iterations,
hash: "SHA-256"
}
});
}
/**
* Reverse of {@link encryptWithPassphrase}. Throws when the passphrase
* is wrong or the envelope is corrupt — both surface as the same
* `OperationError` from Web Crypto, which the UI presents as a generic
* "wrong passphrase" without revealing which.
*/
async decryptWithPassphrase(envelope, passphrase) {
let parsed;
try {
parsed = JSON.parse(envelope);
} catch {
throw new AlephaError("Invalid protected folio envelope");
}
if (!parsed.salt || !parsed.iv || !parsed.ciphertext) throw new AlephaError("Invalid protected folio envelope");
const key = await this.deriveKeyFromPassphrase(passphrase, parsed.salt, parsed.kdf?.iterations ?? 6e5);
const decrypted = await crypto.subtle.decrypt({
name: BrowserCryptoProvider.AES_ALGORITHM,
iv: this.fromHex(parsed.iv).buffer
}, key, this.fromHex(parsed.ciphertext).buffer);
return new TextDecoder().decode(decrypted);
}
};
//#endregion
//#region ../../src/crypto/index.browser.ts
/**
* Cryptographic utilities: hashing, HMAC, AES-256-GCM encryption, password hashing, and secure random generation.
*
* @module alepha.crypto
*/
const AlephaCrypto = $module({
name: "alepha.crypto",
services: [BrowserCryptoProvider]
});
//#endregion
export { AlephaCrypto, BrowserCryptoProvider as CryptoProvider };
//# sourceMappingURL=index.browser.js.map