Files
nekonest-cloud/db/registration-replay.ts

63 lines
2.0 KiB
TypeScript

const REPLAY_DOMAIN = "nekonest-cloud/device-registration-replay/v1";
export const DEVICE_REGISTRATION_REPLAY_TTL_MS = 10 * 60_000;
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function registrationReplayKey(retryKey: string): Promise<CryptoKey> {
const material = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(`${REPLAY_DOMAIN}\0${retryKey}`),
);
return crypto.subtle.importKey("raw", material, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
export async function encryptRegistrationReplay<T>(input: {
retryKey: string;
pairingId: string;
requestHash: string;
response: T;
}): Promise<{ ciphertext: string; nonce: string }> {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: nonce,
additionalData: new TextEncoder().encode(`${input.pairingId}\0${input.requestHash}`),
},
await registrationReplayKey(input.retryKey),
new TextEncoder().encode(JSON.stringify(input.response)),
);
return {
ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
nonce: bytesToBase64(nonce),
};
}
export async function decryptRegistrationReplay<T>(input: {
retryKey: string;
pairingId: string;
requestHash: string;
ciphertext: string;
nonce: string;
}): Promise<T> {
const plaintext = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: Uint8Array.from(base64ToBytes(input.nonce)).buffer,
additionalData: new TextEncoder().encode(`${input.pairingId}\0${input.requestHash}`),
},
await registrationReplayKey(input.retryKey),
Uint8Array.from(base64ToBytes(input.ciphertext)).buffer,
);
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
}