프론트엔드 암호 모듈 리팩토링

This commit is contained in:
static
2025-01-03 12:21:53 +09:00
parent afe672228a
commit aad5617d25
15 changed files with 336 additions and 298 deletions

View File

@@ -1,4 +1,4 @@
import { signRequest } from "$lib/modules/crypto";
import { signRequestBody } from "$lib/modules/crypto";
export const refreshToken = async () => {
return await fetch("/api/auth/refreshToken", { method: "POST" });
@@ -36,6 +36,6 @@ export const callSignedPostApi = async <T>(input: RequestInfo, payload: T, signK
headers: {
"Content-Type": "application/json",
},
body: await signRequest(payload, signKey),
body: await signRequestBody(payload, signKey),
});
};

View File

@@ -10,7 +10,7 @@ interface KeyExportState {
signKeyBase64: string;
verifyKeyBase64: string;
masterKeyWrapped: ArrayBuffer;
masterKeyWrapped: string;
}
const useAutoNull = <T>(value: T | null) => {

View File

@@ -1,227 +0,0 @@
export type RSAKeyPurpose = "encryption" | "signature";
export type RSAKeyType = "public" | "private";
export const encodeToBase64 = (data: ArrayBuffer) => {
return btoa(String.fromCharCode(...new Uint8Array(data)));
};
export const decodeFromBase64 = (data: string) => {
return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer;
};
export const generateRSAKeyPair = async (purpose: RSAKeyPurpose) => {
return await window.crypto.subtle.generateKey(
{
name: purpose === "encryption" ? "RSA-OAEP" : "RSA-PSS",
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
} satisfies RsaHashedKeyGenParams,
true,
purpose === "encryption" ? ["encrypt", "decrypt", "wrapKey", "unwrapKey"] : ["sign", "verify"],
);
};
export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
const { format, key: exportedKey } = await exportRSAKey(key);
return await window.crypto.subtle.importKey(
format,
exportedKey,
key.algorithm,
false,
key.usages,
);
};
export const exportRSAKey = async (key: CryptoKey) => {
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
return {
format,
key: await window.crypto.subtle.exportKey(format, key),
};
};
export const exportRSAKeyToBase64 = async (key: CryptoKey) => {
return encodeToBase64((await exportRSAKey(key)).key);
};
export const encryptRSAPlaintext = async (plaintext: BufferSource, publicKey: CryptoKey) => {
return await window.crypto.subtle.encrypt(
{
name: "RSA-OAEP",
} satisfies RsaOaepParams,
publicKey,
plaintext,
);
};
export const decryptRSACiphertext = async (ciphertext: BufferSource, privateKey: CryptoKey) => {
return await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP",
} satisfies RsaOaepParams,
privateKey,
ciphertext,
);
};
export const signRSAMessage = async (message: BufferSource, privateKey: CryptoKey) => {
return await window.crypto.subtle.sign(
{
name: "RSA-PSS",
saltLength: 32,
} satisfies RsaPssParams,
privateKey,
message,
);
};
export const verifyRSASignature = async (
message: BufferSource,
signature: BufferSource,
publicKey: CryptoKey,
) => {
return await window.crypto.subtle.verify(
{
name: "RSA-PSS",
saltLength: 32,
} satisfies RsaPssParams,
publicKey,
signature,
message,
);
};
export const generateAESMasterKey = async () => {
return await window.crypto.subtle.generateKey(
{
name: "AES-KW",
length: 256,
} satisfies AesKeyGenParams,
true,
["wrapKey", "unwrapKey"],
);
};
export const generateAESDataKey = async () => {
return await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
} satisfies AesKeyGenParams,
true,
["encrypt", "decrypt"],
);
};
export const makeAESKeyNonextractable = async (key: CryptoKey) => {
return await window.crypto.subtle.importKey(
"raw",
await exportAESKey(key),
key.algorithm,
false,
key.usages,
);
};
export const exportAESKey = async (key: CryptoKey) => {
return await window.crypto.subtle.exportKey("raw", key);
};
export const encryptAESPlaintext = async (plaintext: BufferSource, aesKey: CryptoKey) => {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
} satisfies AesGcmParams,
aesKey,
plaintext,
);
return { ciphertext, iv };
};
export const decryptAESCiphertext = async (
ciphertext: BufferSource,
iv: BufferSource,
aesKey: CryptoKey,
) => {
return await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
} satisfies AesGcmParams,
aesKey,
ciphertext,
);
};
export const wrapAESMasterKey = async (aesKey: CryptoKey, rsaPublicKey: CryptoKey) => {
return await window.crypto.subtle.wrapKey("raw", aesKey, rsaPublicKey, {
name: "RSA-OAEP",
} satisfies RsaOaepParams);
};
export const wrapAESDataKey = async (aesKey: CryptoKey, aesWrapKey: CryptoKey) => {
return await window.crypto.subtle.wrapKey("raw", aesKey, aesWrapKey, "AES-KW");
};
export const unwrapAESMasterKey = async (wrappedKey: BufferSource, rsaPrivateKey: CryptoKey) => {
return await window.crypto.subtle.unwrapKey(
"raw",
wrappedKey,
rsaPrivateKey,
{
name: "RSA-OAEP",
} satisfies RsaOaepParams,
"AES-KW",
true,
["wrapKey", "unwrapKey"],
);
};
export const unwrapAESDataKey = async (wrappedKey: BufferSource, aesMasterKey: CryptoKey) => {
return await window.crypto.subtle.unwrapKey(
"raw",
wrappedKey,
aesMasterKey,
"AES-KW",
"AES-GCM",
false,
["encrypt", "decrypt"],
);
};
export const digestSHA256 = async (data: BufferSource) => {
return await window.crypto.subtle.digest("SHA-256", data);
};
export const signRequest = async <T>(data: T, privateKey: CryptoKey) => {
const dataBuffer = new TextEncoder().encode(JSON.stringify(data));
const signature = await signRSAMessage(dataBuffer, privateKey);
return JSON.stringify({
data,
signature: encodeToBase64(signature),
});
};
export const signMasterKeyWrapped = async (
version: number,
masterKeyWrapped: ArrayBuffer,
privateKey: CryptoKey,
) => {
const data = JSON.stringify({ version, key: encodeToBase64(masterKeyWrapped) });
const dataBuffer = new TextEncoder().encode(data);
return encodeToBase64(await signRSAMessage(dataBuffer, privateKey));
};
export const verifyMasterKeyWrappedSig = async (
version: number,
masterKeyWrappedBase64: string,
masterKeyWrappedSig: string,
publicKey: CryptoKey,
) => {
const data = JSON.stringify({ version, key: masterKeyWrappedBase64 });
const dataBuffer = new TextEncoder().encode(data);
return await verifyRSASignature(dataBuffer, decodeFromBase64(masterKeyWrappedSig), publicKey);
};

View File

@@ -0,0 +1,83 @@
import { encodeToBase64, decodeFromBase64 } from "./util";
export const generateMasterKey = async () => {
return {
masterKey: await window.crypto.subtle.generateKey(
{
name: "AES-KW",
length: 256,
} satisfies AesKeyGenParams,
true,
["wrapKey", "unwrapKey"],
),
};
};
export const generateDataKey = async () => {
return {
dataKey: await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
} satisfies AesKeyGenParams,
true,
["encrypt", "decrypt"],
),
};
};
const exportAESKey = async (key: CryptoKey) => {
return await window.crypto.subtle.exportKey("raw", key);
};
export const makeAESKeyNonextractable = async (key: CryptoKey) => {
return await window.crypto.subtle.importKey(
"raw",
await exportAESKey(key),
key.algorithm,
false,
key.usages,
);
};
export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await window.crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW"));
};
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
return {
dataKey: await window.crypto.subtle.unwrapKey(
"raw",
decodeFromBase64(dataKeyWrapped),
masterKey,
"AES-KW",
"AES-GCM",
false, // Non-extractable
["encrypt", "decrypt"],
),
};
};
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
} satisfies AesGcmParams,
dataKey,
data,
);
return { ciphertext, iv: encodeToBase64(iv.buffer) };
};
export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => {
return await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: decodeFromBase64(iv),
} satisfies AesGcmParams,
dataKey,
ciphertext,
);
};

View File

@@ -0,0 +1,4 @@
export * from "./aes";
export * from "./rsa";
export * from "./sha";
export * from "./util";

View File

@@ -0,0 +1,159 @@
import { encodeToBase64, decodeFromBase64 } from "./util";
export const generateEncryptionKeyPair = async () => {
const keyPair = await window.crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
} satisfies RsaHashedKeyGenParams,
true,
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
);
return {
encryptKey: keyPair.publicKey,
decryptKey: keyPair.privateKey,
};
};
export const generateSigningKeyPair = async () => {
const keyPair = await window.crypto.subtle.generateKey(
{
name: "RSA-PSS",
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
} satisfies RsaHashedKeyGenParams,
true,
["sign", "verify"],
);
return {
signKey: keyPair.privateKey,
verifyKey: keyPair.publicKey,
};
};
export const exportRSAKey = async (key: CryptoKey) => {
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
return {
key: await window.crypto.subtle.exportKey(format, key),
format,
};
};
export const exportRSAKeyToBase64 = async (key: CryptoKey) => {
return encodeToBase64((await exportRSAKey(key)).key);
};
export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
const { key: exportedKey, format } = await exportRSAKey(key);
return await window.crypto.subtle.importKey(
format,
exportedKey,
key.algorithm,
false,
key.usages,
);
};
export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => {
return await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP",
} satisfies RsaOaepParams,
decryptKey,
decodeFromBase64(challenge),
);
};
export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => {
return encodeToBase64(
await window.crypto.subtle.wrapKey("raw", masterKey, encryptKey, {
name: "RSA-OAEP",
} satisfies RsaOaepParams),
);
};
export const unwrapMasterKey = async (
masterKeyWrapped: string,
decryptKey: CryptoKey,
extractable = false,
) => {
return {
masterKey: await window.crypto.subtle.unwrapKey(
"raw",
decodeFromBase64(masterKeyWrapped),
decryptKey,
{
name: "RSA-OAEP",
} satisfies RsaOaepParams,
"AES-KW",
extractable,
["wrapKey", "unwrapKey"],
),
};
};
export const signMessage = async (message: BufferSource, signKey: CryptoKey) => {
return await window.crypto.subtle.sign(
{
name: "RSA-PSS",
saltLength: 32, // SHA-256
} satisfies RsaPssParams,
signKey,
message,
);
};
export const verifySignature = async (
message: BufferSource,
signature: BufferSource,
verifyKey: CryptoKey,
) => {
return await window.crypto.subtle.verify(
{
name: "RSA-PSS",
saltLength: 32, // SHA-256
} satisfies RsaPssParams,
verifyKey,
signature,
message,
);
};
export const signRequestBody = async <T>(requestBody: T, signKey: CryptoKey) => {
const dataBuffer = new TextEncoder().encode(JSON.stringify(requestBody));
const signature = await signMessage(dataBuffer, signKey);
return JSON.stringify({
data: requestBody,
signature: encodeToBase64(signature),
});
};
export const signMasterKeyWrapped = async (
masterKeyVersion: number,
masterKeyWrapped: string,
signKey: CryptoKey,
) => {
const serialized = JSON.stringify({
version: masterKeyVersion,
key: masterKeyWrapped,
});
const serializedBuffer = new TextEncoder().encode(serialized);
return encodeToBase64(await signMessage(serializedBuffer, signKey));
};
export const verifyMasterKeyWrapped = async (
masterKeyVersion: number,
masterKeyWrapped: string,
masterKeyWrappedSig: string,
verifyKey: CryptoKey,
) => {
const serialized = JSON.stringify({
version: masterKeyVersion,
key: masterKeyWrapped,
});
const serializedBuffer = new TextEncoder().encode(serialized);
return await verifySignature(serializedBuffer, decodeFromBase64(masterKeyWrappedSig), verifyKey);
};

View File

@@ -0,0 +1,3 @@
export const digestMessage = async (message: BufferSource) => {
return await window.crypto.subtle.digest("SHA-256", message);
};

View File

@@ -0,0 +1,19 @@
export const encodeToBase64 = (data: ArrayBuffer) => {
return btoa(String.fromCharCode(...new Uint8Array(data)));
};
export const decodeFromBase64 = (data: string) => {
return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer;
};
export const concatenateBuffers = (...buffers: ArrayBuffer[]) => {
const arrays = buffers.map((buffer) => new Uint8Array(buffer));
const totalLength = arrays.reduce((acc, array) => acc + array.length, 0);
const result = new Uint8Array(totalLength);
arrays.reduce((offset, array) => {
result.set(array, offset);
return offset + array.length;
}, 0);
return result;
};

View File

@@ -1,9 +1,4 @@
import {
encodeToBase64,
decodeFromBase64,
decryptRSACiphertext,
signRSAMessage,
} from "$lib/modules/crypto";
import { encodeToBase64, decryptChallenge, signMessage } from "$lib/modules/crypto";
import type {
TokenUpgradeRequest,
TokenUpgradeResponse,
@@ -29,8 +24,8 @@ export const requestTokenUpgrade = async (
if (!res.ok) return false;
const { challenge }: TokenUpgradeResponse = await res.json();
const answer = await decryptRSACiphertext(decodeFromBase64(challenge), decryptKey);
const sigAnswer = await signRSAMessage(answer, signKey);
const answer = await decryptChallenge(challenge, decryptKey);
const sigAnswer = await signMessage(answer, signKey);
res = await fetch("/api/auth/upgradeToken/verify", {
method: "POST",

View File

@@ -2,12 +2,10 @@ import { callGetApi, callPostApi } from "$lib/hooks";
import { storeMasterKeys } from "$lib/indexedDB";
import {
encodeToBase64,
decodeFromBase64,
decryptRSACiphertext,
signRSAMessage,
makeAESKeyNonextractable,
unwrapAESMasterKey,
verifyMasterKeyWrappedSig,
decryptChallenge,
signMessage,
unwrapMasterKey,
verifyMasterKeyWrapped,
} from "$lib/modules/crypto";
import type {
ClientRegisterRequest,
@@ -30,8 +28,8 @@ export const requestClientRegistration = async (
if (!res.ok) return false;
const { challenge }: ClientRegisterResponse = await res.json();
const answer = await decryptRSACiphertext(decodeFromBase64(challenge), decryptKey);
const sigAnswer = await signRSAMessage(answer, signKey);
const answer = await decryptChallenge(challenge, decryptKey);
const sigAnswer = await signMessage(answer, signKey);
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
answer: encodeToBase64(answer),
@@ -47,19 +45,20 @@ export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verfiyKey:
const { meks: masterKeysWrapped }: MasterKeyListResponse = await res.json();
const masterKeys = await Promise.all(
masterKeysWrapped.map(
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => ({
version,
state,
masterKey: await makeAESKeyNonextractable(
await unwrapAESMasterKey(decodeFromBase64(masterKeyWrapped), decryptKey),
),
isValid: await verifyMasterKeyWrappedSig(
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => {
const { masterKey } = await unwrapMasterKey(masterKeyWrapped, decryptKey);
return {
version,
masterKeyWrapped,
masterKeyWrappedSig,
verfiyKey,
),
}),
state,
masterKey,
isValid: await verifyMasterKeyWrapped(
version,
masterKeyWrapped,
masterKeyWrappedSig,
verfiyKey,
),
};
},
),
);
if (!masterKeys.every(({ isValid }) => isValid)) return false;

View File

@@ -9,7 +9,9 @@
let { data } = $props();
const fingerprint = $derived(
$clientKeyStore ? generateEncryptKeyFingerprint($clientKeyStore.encryptKey) : undefined,
$clientKeyStore
? generateEncryptKeyFingerprint($clientKeyStore.encryptKey, $clientKeyStore.verifyKey)
: undefined,
);
$effect(() => {

View File

@@ -1,10 +1,14 @@
import { exportRSAKey, digestSHA256 } from "$lib/modules/crypto";
import { concatenateBuffers, exportRSAKey, digestMessage } from "$lib/modules/crypto";
export { requestMasterKeyDownload } from "$lib/services/key";
export const generateEncryptKeyFingerprint = async (encryptKey: CryptoKey) => {
const { key } = await exportRSAKey(encryptKey);
const digest = await digestSHA256(key);
export const generateEncryptKeyFingerprint = async (
encryptKey: CryptoKey,
verifyKey: CryptoKey,
) => {
const { key: encryptKeyBuffer } = await exportRSAKey(encryptKey);
const { key: verifyKeyBuffer } = await exportRSAKey(verifyKey);
const digest = await digestMessage(concatenateBuffers(encryptKeyBuffer, verifyKeyBuffer));
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")

View File

@@ -1,6 +1,6 @@
import { callSignedPostApi } from "$lib/hooks";
import { storeClientKey } from "$lib/indexedDB";
import { encodeToBase64, signMasterKeyWrapped } from "$lib/modules/crypto";
import { signMasterKeyWrapped } from "$lib/modules/crypto";
import type { InitialMasterKeyRegisterRequest } from "$lib/server/schemas";
import type { ClientKeys } from "$lib/stores";
@@ -43,13 +43,13 @@ export const storeClientKeys = async (clientKeys: ClientKeys) => {
};
export const requestInitialMasterKeyRegistration = async (
masterKeyWrapped: ArrayBuffer,
masterKeyWrapped: string,
signKey: CryptoKey,
) => {
const res = await callSignedPostApi<InitialMasterKeyRegisterRequest>(
"/api/mek/register/initial",
{
mek: encodeToBase64(masterKeyWrapped),
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(1, masterKeyWrapped, signKey),
},
signKey,

View File

@@ -1,35 +1,36 @@
import {
generateRSAKeyPair,
makeRSAKeyNonextractable,
generateEncryptionKeyPair,
generateSigningKeyPair,
exportRSAKeyToBase64,
generateAESMasterKey,
wrapAESMasterKey,
makeRSAKeyNonextractable,
generateMasterKey,
wrapMasterKey,
} from "$lib/modules/crypto";
import { clientKeyStore } from "$lib/stores";
export const generateClientKeys = async () => {
const encKeyPair = await generateRSAKeyPair("encryption");
const sigKeyPair = await generateRSAKeyPair("signature");
const { encryptKey, decryptKey } = await generateEncryptionKeyPair();
const { signKey, verifyKey } = await generateSigningKeyPair();
clientKeyStore.set({
encryptKey: encKeyPair.publicKey,
decryptKey: await makeRSAKeyNonextractable(encKeyPair.privateKey),
signKey: await makeRSAKeyNonextractable(sigKeyPair.privateKey),
verifyKey: sigKeyPair.publicKey,
encryptKey,
decryptKey: await makeRSAKeyNonextractable(decryptKey),
signKey: await makeRSAKeyNonextractable(signKey),
verifyKey,
});
return {
encryptKey: encKeyPair.publicKey,
encryptKeyBase64: await exportRSAKeyToBase64(encKeyPair.publicKey),
decryptKeyBase64: await exportRSAKeyToBase64(encKeyPair.privateKey),
signKeyBase64: await exportRSAKeyToBase64(sigKeyPair.privateKey),
verifyKeyBase64: await exportRSAKeyToBase64(sigKeyPair.publicKey),
encryptKey,
encryptKeyBase64: await exportRSAKeyToBase64(encryptKey),
decryptKeyBase64: await exportRSAKeyToBase64(decryptKey),
signKeyBase64: await exportRSAKeyToBase64(signKey),
verifyKeyBase64: await exportRSAKeyToBase64(verifyKey),
};
};
export const generateInitialMasterKey = async (encryptKey: CryptoKey) => {
const masterKey = await generateAESMasterKey();
const { masterKey } = await generateMasterKey();
return {
masterKeyWrapped: await wrapAESMasterKey(masterKey, encryptKey),
masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey),
};
};

View File

@@ -2,11 +2,11 @@ import { callSignedPostApi } from "$lib/hooks";
import {
encodeToBase64,
decodeFromBase64,
generateAESDataKey,
encryptAESPlaintext,
decryptAESCiphertext,
wrapAESDataKey,
unwrapAESDataKey,
generateDataKey,
wrapDataKey,
unwrapDataKey,
encryptData,
decryptData,
} from "$lib/modules/crypto";
import type { DirectroyInfoResponse, DirectoryCreateRequest } from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
@@ -15,14 +15,10 @@ export const decryptDirectroyMetadata = async (
metadata: NonNullable<DirectroyInfoResponse["metadata"]>,
masterKey: CryptoKey,
) => {
const dataDecryptKey = await unwrapAESDataKey(decodeFromBase64(metadata.dek), masterKey);
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
return {
name: new TextDecoder().decode(
await decryptAESCiphertext(
decodeFromBase64(metadata.name),
decodeFromBase64(metadata.nameIv),
dataDecryptKey,
),
await decryptData(decodeFromBase64(metadata.name), metadata.nameIv, dataKey),
),
};
};
@@ -33,16 +29,16 @@ export const requestDirectroyCreation = async (
masterKey: MasterKey,
signKey: CryptoKey,
) => {
const dataKey = await generateAESDataKey();
const nameEncrypted = await encryptAESPlaintext(new TextEncoder().encode(name), dataKey);
const { dataKey } = await generateDataKey();
const nameEncrypted = await encryptData(new TextEncoder().encode(name), dataKey);
return await callSignedPostApi<DirectoryCreateRequest>(
"/api/directory/create",
{
parentId,
mekVersion: masterKey.version,
dek: encodeToBase64(await wrapAESDataKey(dataKey, masterKey.key)),
dek: await wrapDataKey(dataKey, masterKey.key),
name: encodeToBase64(nameEncrypted.ciphertext),
nameIv: encodeToBase64(nameEncrypted.iv.buffer),
nameIv: nameEncrypted.iv,
},
signKey,
);