move shim to new package

This commit is contained in:
Nystik
2026-05-20 20:49:28 +02:00
parent a0b44bde58
commit fe11f30c01
66 changed files with 46 additions and 23 deletions

View File

@@ -0,0 +1,88 @@
import { sha256, sha512 } from "@noble/hashes/sha2.js";
import { sha1, md5 } from "@noble/hashes/legacy.js";
const HASHERS = {
SHA1: sha1,
SHA256: sha256,
SHA512: sha512,
MD5: md5,
};
const SUBTLE_ALG = {
SHA1: "SHA-1",
SHA256: "SHA-256",
SHA512: "SHA-512",
};
function normalizeAlgorithm(algorithm) {
return algorithm.toUpperCase().replace(/-/g, "");
}
function encode(bytes, encoding) {
if (!encoding) {
return bytes;
}
if (encoding === "hex") {
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return hex;
}
if (encoding === "base64") {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
throw new Error(`Unsupported digest encoding: ${encoding}`);
}
export function createHash(algorithm) {
const alg = normalizeAlgorithm(algorithm);
const hasher = HASHERS[alg];
if (!hasher) {
throw new Error(`Unsupported hash algorithm: ${algorithm}`);
}
let inputData = new Uint8Array(0);
return {
update(data) {
const bytes =
typeof data === "string" ? new TextEncoder().encode(data) : data;
const merged = new Uint8Array(inputData.length + bytes.length);
merged.set(inputData);
merged.set(bytes, inputData.length);
inputData = merged;
return this;
},
digest(encoding) {
return encode(hasher(inputData), encoding);
},
async digestAsync(encoding) {
const subtleAlg = SUBTLE_ALG[alg];
if (!subtleAlg) {
// SubtleCrypto doesn't cover MD5; fall back to the sync hasher.
return encode(hasher(inputData), encoding);
}
const buf = await crypto.subtle.digest(subtleAlg, inputData);
return encode(new Uint8Array(buf), encoding);
},
};
}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { createHash } from "./create-hash.js";
// "abc" / empty SHA digests: NIST FIPS 180-4 worked examples (SHA_All.pdf).
// MD5: RFC 1321 §A.5 test suite.
const VECTORS = {
SHA1: {
empty: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
abc: "a9993e364706816aba3e25717850c26c9cd0d89d",
},
SHA256: {
empty: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
abc: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
},
SHA512: {
empty:
"cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e",
abc: "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
},
MD5: {
empty: "d41d8cd98f00b204e9800998ecf8427e",
abc: "900150983cd24fb0d6963f7d28e17f72",
},
};
describe("createHash", () => {
for (const [alg, vec] of Object.entries(VECTORS)) {
it(`${alg} digests "abc" correctly (hex)`, () => {
expect(createHash(alg).update("abc").digest("hex")).toBe(vec.abc);
});
}
it("handles empty input (no update calls)", () => {
expect(createHash("sha256").digest("hex")).toBe(VECTORS.SHA256.empty);
});
it("normalizes algorithm names (sha-256 -> SHA256)", () => {
expect(createHash("sha-256").update("abc").digest("hex")).toBe(
VECTORS.SHA256.abc,
);
});
it("digest() with no encoding returns raw bytes", () => {
const result = createHash("sha256").update("abc").digest();
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(32);
});
it("digest('base64') returns the base64 of the raw bytes", () => {
const result = createHash("sha256").update("abc").digest("base64");
expect(result).toBe("ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=");
});
it("supports multiple update() calls", () => {
const result = createHash("sha256")
.update("a")
.update("b")
.update("c")
.digest("hex");
expect(result).toBe(VECTORS.SHA256.abc);
});
it("throws on unsupported algorithm", () => {
expect(() => createHash("whirlpool")).toThrow(/Unsupported hash algorithm/);
});
it("throws on unsupported encoding", () => {
expect(() => createHash("sha256").update("abc").digest("utf8")).toThrow(
/Unsupported digest encoding/,
);
});
});
describe("digestAsync", () => {
it("SHA-256 async matches the sync result", async () => {
const h = createHash("sha256");
h.update("abc");
expect(await h.digestAsync("hex")).toBe(VECTORS.SHA256.abc);
});
it("MD5 async falls back to the sync hasher (SubtleCrypto doesn't support it)", async () => {
const h = createHash("md5");
h.update("abc");
expect(await h.digestAsync("hex")).toBe(VECTORS.MD5.abc);
});
});

View File

@@ -0,0 +1,11 @@
import { randomBytes } from "./random-bytes.js";
import { createHash } from "./create-hash.js";
import { scrypt } from "./scrypt.js";
import { randomUUID } from "./random-uuid.js";
export const cryptoShim = {
randomBytes,
createHash,
scrypt,
randomUUID,
};

View File

@@ -0,0 +1,20 @@
export function randomBytes(size) {
const buf = new Uint8Array(size);
crypto.getRandomValues(buf);
buf.toString = function (encoding) {
if (encoding === "hex") {
return Array.from(this)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
if (encoding === "base64") {
return btoa(String.fromCharCode(...this));
}
return new TextDecoder().decode(this);
};
return buf;
}

View File

@@ -0,0 +1,3 @@
export function randomUUID() {
return crypto.randomUUID();
}

View File

@@ -0,0 +1,26 @@
export function scrypt(password, salt, keylen, options, callback) {
if (typeof options === "function") {
callback = options;
options = {};
}
const N = options?.N || 32768;
const r = options?.r || 8;
const p = options?.p || 1;
if (window.scrypt && window.scrypt.scrypt) {
const pwBytes =
typeof password === "string"
? new TextEncoder().encode(password)
: password;
const saltBytes =
typeof salt === "string" ? new TextEncoder().encode(salt) : salt;
window.scrypt
.scrypt(pwBytes, saltBytes, N, r, p, keylen)
.then((result) => callback(null, new Uint8Array(result)))
.catch((err) => callback(err));
} else {
callback(new Error("scrypt not available"));
}
}