You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
349 lines
9.7 KiB
JavaScript
349 lines
9.7 KiB
JavaScript
import forge from "node-forge";
|
|
|
|
const textEncoder = new TextEncoder();
|
|
const textDecoder = new TextDecoder();
|
|
const subtlePublicKeyCache = new Map();
|
|
const forgePublicKeyCache = new Map();
|
|
const STORAGE_KEYS = {
|
|
subtlePrivatePkcs8B64: "oms_subtle_private_pkcs8_b64",
|
|
subtlePublicSpkiB64: "oms_subtle_public_spki_b64",
|
|
forgePrivatePem: "oms_forge_private_pem",
|
|
forgePublicSpkiB64: "oms_forge_public_spki_b64"
|
|
};
|
|
|
|
function hasWebCryptoSubtle() {
|
|
return Boolean(globalThis.crypto?.subtle);
|
|
}
|
|
|
|
function toBase64(bytes) {
|
|
let binary = "";
|
|
for (let i = 0; i < bytes.length; i += 1) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function fromBase64(base64) {
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function bytesToBinaryString(bytes) {
|
|
let result = "";
|
|
for (let i = 0; i < bytes.length; i += 1) {
|
|
result += String.fromCharCode(bytes[i]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function binaryStringToBytes(binary) {
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function concatChunks(chunks) {
|
|
const totalLength = chunks.reduce((sum, item) => sum + item.length, 0);
|
|
const output = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
chunks.forEach((item) => {
|
|
output.set(item, offset);
|
|
offset += item.length;
|
|
});
|
|
return output;
|
|
}
|
|
|
|
async function importRsaOaepPublicKeySubtle(publicKeyBase64) {
|
|
if (subtlePublicKeyCache.has(publicKeyBase64)) {
|
|
return subtlePublicKeyCache.get(publicKeyBase64);
|
|
}
|
|
|
|
const key = await globalThis.crypto.subtle.importKey(
|
|
"spki",
|
|
fromBase64(publicKeyBase64),
|
|
{
|
|
name: "RSA-OAEP",
|
|
hash: "SHA-256"
|
|
},
|
|
false,
|
|
["encrypt"]
|
|
);
|
|
subtlePublicKeyCache.set(publicKeyBase64, key);
|
|
return key;
|
|
}
|
|
|
|
function importForgePublicKey(publicKeyBase64) {
|
|
if (forgePublicKeyCache.has(publicKeyBase64)) {
|
|
return forgePublicKeyCache.get(publicKeyBase64);
|
|
}
|
|
|
|
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(atob(publicKeyBase64)));
|
|
const key = forge.pki.publicKeyFromAsn1(asn1);
|
|
forgePublicKeyCache.set(publicKeyBase64, key);
|
|
return key;
|
|
}
|
|
|
|
function generateForgeKeyPair() {
|
|
return Promise.resolve().then(() => forge.pki.rsa.generateKeyPair({ bits: 2048, workers: 0, e: 0x10001 }));
|
|
}
|
|
|
|
function clearSubtleIdentityFromStorage() {
|
|
try {
|
|
globalThis.localStorage?.removeItem(STORAGE_KEYS.subtlePrivatePkcs8B64);
|
|
globalThis.localStorage?.removeItem(STORAGE_KEYS.subtlePublicSpkiB64);
|
|
} catch {
|
|
// ignore storage failures in private mode
|
|
}
|
|
}
|
|
|
|
function saveSubtleIdentityToStorage(privateKeyRaw, publicKeyRaw) {
|
|
try {
|
|
globalThis.localStorage?.setItem(STORAGE_KEYS.subtlePrivatePkcs8B64, toBase64(new Uint8Array(privateKeyRaw)));
|
|
globalThis.localStorage?.setItem(STORAGE_KEYS.subtlePublicSpkiB64, toBase64(new Uint8Array(publicKeyRaw)));
|
|
} catch {
|
|
// ignore storage failures in private mode
|
|
}
|
|
}
|
|
|
|
async function loadSubtleIdentityFromStorage() {
|
|
try {
|
|
const privatePkcs8B64 = globalThis.localStorage?.getItem(STORAGE_KEYS.subtlePrivatePkcs8B64);
|
|
const publicSpkiB64 = globalThis.localStorage?.getItem(STORAGE_KEYS.subtlePublicSpkiB64);
|
|
if (!privatePkcs8B64 || !publicSpkiB64) {
|
|
return null;
|
|
}
|
|
|
|
const privateKeyRaw = fromBase64(privatePkcs8B64);
|
|
const signPrivateKey = await globalThis.crypto.subtle.importKey(
|
|
"pkcs8",
|
|
privateKeyRaw,
|
|
{
|
|
name: "RSASSA-PKCS1-v1_5",
|
|
hash: "SHA-256"
|
|
},
|
|
false,
|
|
["sign"]
|
|
);
|
|
|
|
const decryptPrivateKey = await globalThis.crypto.subtle.importKey(
|
|
"pkcs8",
|
|
privateKeyRaw,
|
|
{
|
|
name: "RSA-OAEP",
|
|
hash: "SHA-256"
|
|
},
|
|
false,
|
|
["decrypt"]
|
|
);
|
|
|
|
return {
|
|
provider: "subtle",
|
|
publicKeyBase64: publicSpkiB64,
|
|
signPrivateKey,
|
|
decryptPrivateKey
|
|
};
|
|
} catch {
|
|
clearSubtleIdentityFromStorage();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loadForgeIdentityFromStorage() {
|
|
try {
|
|
const privatePem = globalThis.localStorage?.getItem(STORAGE_KEYS.forgePrivatePem);
|
|
const publicSpkiB64 = globalThis.localStorage?.getItem(STORAGE_KEYS.forgePublicSpkiB64);
|
|
if (!privatePem || !publicSpkiB64) {
|
|
return null;
|
|
}
|
|
|
|
const privateKey = forge.pki.privateKeyFromPem(privatePem);
|
|
return {
|
|
provider: "forge",
|
|
publicKeyBase64: publicSpkiB64,
|
|
signPrivateKey: privateKey,
|
|
decryptPrivateKey: privateKey
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveForgeIdentityToStorage(privateKey, publicKeyBase64) {
|
|
try {
|
|
const privatePem = forge.pki.privateKeyToPem(privateKey);
|
|
globalThis.localStorage?.setItem(STORAGE_KEYS.forgePrivatePem, privatePem);
|
|
globalThis.localStorage?.setItem(STORAGE_KEYS.forgePublicSpkiB64, publicKeyBase64);
|
|
} catch {
|
|
// ignore storage failures in private mode
|
|
}
|
|
}
|
|
|
|
export function canInitializeCrypto() {
|
|
return hasWebCryptoSubtle() || Boolean(forge?.pki?.rsa);
|
|
}
|
|
|
|
export async function generateClientIdentity() {
|
|
if (hasWebCryptoSubtle()) {
|
|
const cached = await loadSubtleIdentityFromStorage();
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const signingKeyPair = await globalThis.crypto.subtle.generateKey(
|
|
{
|
|
name: "RSASSA-PKCS1-v1_5",
|
|
modulusLength: 2048,
|
|
publicExponent: new Uint8Array([1, 0, 1]),
|
|
hash: "SHA-256"
|
|
},
|
|
true,
|
|
["sign", "verify"]
|
|
);
|
|
|
|
const publicKeyRaw = await globalThis.crypto.subtle.exportKey("spki", signingKeyPair.publicKey);
|
|
const privateKeyRaw = await globalThis.crypto.subtle.exportKey("pkcs8", signingKeyPair.privateKey);
|
|
|
|
const decryptPrivateKey = await globalThis.crypto.subtle.importKey(
|
|
"pkcs8",
|
|
privateKeyRaw,
|
|
{
|
|
name: "RSA-OAEP",
|
|
hash: "SHA-256"
|
|
},
|
|
false,
|
|
["decrypt"]
|
|
);
|
|
saveSubtleIdentityToStorage(privateKeyRaw, publicKeyRaw);
|
|
|
|
return {
|
|
provider: "subtle",
|
|
publicKeyBase64: toBase64(new Uint8Array(publicKeyRaw)),
|
|
signPrivateKey: signingKeyPair.privateKey,
|
|
decryptPrivateKey
|
|
};
|
|
}
|
|
|
|
const cached = loadForgeIdentityFromStorage();
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const keyPair = await generateForgeKeyPair();
|
|
const publicPem = forge.pki.publicKeyToPem(keyPair.publicKey);
|
|
const publicKeyBase64 = publicPem
|
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
|
.replace("-----END PUBLIC KEY-----", "")
|
|
.replace(/\s+/g, "");
|
|
saveForgeIdentityToStorage(keyPair.privateKey, publicKeyBase64);
|
|
|
|
return {
|
|
provider: "forge",
|
|
publicKeyBase64,
|
|
signPrivateKey: keyPair.privateKey,
|
|
decryptPrivateKey: keyPair.privateKey
|
|
};
|
|
}
|
|
|
|
export async function signText(privateKey, text) {
|
|
if (hasWebCryptoSubtle() && privateKey?.type === "private") {
|
|
const signature = await globalThis.crypto.subtle.sign(
|
|
"RSASSA-PKCS1-v1_5",
|
|
privateKey,
|
|
textEncoder.encode(text)
|
|
);
|
|
return toBase64(new Uint8Array(signature));
|
|
}
|
|
|
|
const md = forge.md.sha256.create();
|
|
md.update(text, "utf8");
|
|
const signatureBinary = privateKey.sign(md);
|
|
return btoa(signatureBinary);
|
|
}
|
|
|
|
export async function rsaEncryptChunked(publicKeyBase64, plainText) {
|
|
if (!plainText) {
|
|
return "";
|
|
}
|
|
|
|
const srcBytes = textEncoder.encode(plainText);
|
|
const blockSize = 190;
|
|
|
|
if (hasWebCryptoSubtle()) {
|
|
const publicKey = await importRsaOaepPublicKeySubtle(publicKeyBase64);
|
|
const chunks = [];
|
|
for (let i = 0; i < srcBytes.length; i += blockSize) {
|
|
const block = srcBytes.slice(i, i + blockSize);
|
|
const encrypted = await globalThis.crypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, block);
|
|
chunks.push(new Uint8Array(encrypted));
|
|
}
|
|
return toBase64(concatChunks(chunks));
|
|
}
|
|
|
|
const forgePublicKey = importForgePublicKey(publicKeyBase64);
|
|
let encryptedBinary = "";
|
|
for (let i = 0; i < srcBytes.length; i += blockSize) {
|
|
const block = srcBytes.slice(i, i + blockSize);
|
|
encryptedBinary += forgePublicKey.encrypt(bytesToBinaryString(block), "RSA-OAEP", {
|
|
md: forge.md.sha256.create(),
|
|
mgf1: { md: forge.md.sha256.create() }
|
|
});
|
|
}
|
|
return btoa(encryptedBinary);
|
|
}
|
|
|
|
export async function rsaDecryptChunked(privateKey, cipherTextBase64) {
|
|
if (!cipherTextBase64) {
|
|
return "";
|
|
}
|
|
|
|
const secretBytes = fromBase64(cipherTextBase64);
|
|
const blockSize = 256;
|
|
if (secretBytes.length % blockSize !== 0) {
|
|
throw new Error("ciphertext length invalid");
|
|
}
|
|
|
|
if (hasWebCryptoSubtle() && privateKey?.type === "private") {
|
|
const chunks = [];
|
|
for (let i = 0; i < secretBytes.length; i += blockSize) {
|
|
const block = secretBytes.slice(i, i + blockSize);
|
|
const decrypted = await globalThis.crypto.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, block);
|
|
chunks.push(new Uint8Array(decrypted));
|
|
}
|
|
return textDecoder.decode(concatChunks(chunks));
|
|
}
|
|
|
|
const cipherBinary = atob(cipherTextBase64);
|
|
let plainBinary = "";
|
|
for (let i = 0; i < cipherBinary.length; i += blockSize) {
|
|
const block = cipherBinary.slice(i, i + blockSize);
|
|
plainBinary += privateKey.decrypt(block, "RSA-OAEP", {
|
|
md: forge.md.sha256.create(),
|
|
mgf1: { md: forge.md.sha256.create() }
|
|
});
|
|
}
|
|
return textDecoder.decode(binaryStringToBytes(plainBinary));
|
|
}
|
|
|
|
export function createNonce(size = 18) {
|
|
if (globalThis.crypto?.getRandomValues) {
|
|
const buf = new Uint8Array(size);
|
|
globalThis.crypto.getRandomValues(buf);
|
|
return toBase64(buf);
|
|
}
|
|
|
|
if (forge?.random?.getBytesSync) {
|
|
return btoa(forge.random.getBytesSync(size));
|
|
}
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
export function unixSecondsNow() {
|
|
return Math.floor(Date.now() / 1000);
|
|
}
|