rspace-online/server/local-first/encryption-utils.ts

126 lines
3.5 KiB
TypeScript

/**
* Shared server-side encryption utilities for rSpace at-rest encryption.
*
* Uses AES-256-GCM with keys derived from ENCRYPTION_SECRET via HMAC-SHA256.
* File format: [4-byte magic "rSEN"][4-byte keyId length][keyId bytes][12-byte IV][ciphertext+tag]
*/
// Magic bytes to identify encrypted files: "rSEN" (rSpace ENcrypted)
export const ENCRYPTED_MAGIC = new Uint8Array([0x72, 0x53, 0x45, 0x4e]);
/**
* Derive an AES-256-GCM key from a key identifier using HMAC-SHA256.
* Uses ENCRYPTION_SECRET env var as the HMAC key.
*/
export async function deriveSpaceKey(keyId: string): Promise<CryptoKey> {
const serverSecret = process.env.ENCRYPTION_SECRET;
if (!serverSecret) {
throw new Error("ENCRYPTION_SECRET environment variable is required");
}
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(serverSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const derived = await crypto.subtle.sign(
"HMAC",
keyMaterial,
encoder.encode(keyId),
);
return crypto.subtle.importKey(
"raw",
derived,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
/**
* Encrypt binary data using AES-256-GCM.
* Returns: 12-byte IV + ciphertext + 16-byte auth tag (concatenated).
*/
export async function encryptBinary(
data: Uint8Array,
key: CryptoKey,
): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const plainBuf = new ArrayBuffer(data.byteLength);
new Uint8Array(plainBuf).set(data);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plainBuf,
);
const result = new Uint8Array(12 + ciphertext.byteLength);
result.set(iv, 0);
result.set(new Uint8Array(ciphertext), 12);
return result;
}
/**
* Decrypt binary data encrypted with AES-256-GCM.
* Expects: 12-byte IV + ciphertext + 16-byte auth tag.
*/
export async function decryptBinary(
data: Uint8Array,
key: CryptoKey,
): Promise<Uint8Array> {
const iv = data.slice(0, 12);
const ciphertext = data.slice(12);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
ciphertext,
);
return new Uint8Array(plaintext);
}
/**
* Check if a byte array starts with the rSEN magic bytes.
*/
export function isEncryptedFile(bytes: Uint8Array): boolean {
return (
bytes.length >= ENCRYPTED_MAGIC.length &&
bytes[0] === ENCRYPTED_MAGIC[0] &&
bytes[1] === ENCRYPTED_MAGIC[1] &&
bytes[2] === ENCRYPTED_MAGIC[2] &&
bytes[3] === ENCRYPTED_MAGIC[3]
);
}
/**
* Pack an encrypted payload with the rSEN header.
* Format: [4-byte magic][4-byte keyId length][keyId UTF-8 bytes][ciphertext]
*/
export function packEncrypted(keyId: string, ciphertext: Uint8Array): Uint8Array {
const keyIdBytes = new TextEncoder().encode(keyId);
const packed = new Uint8Array(8 + keyIdBytes.length + ciphertext.length);
packed.set(ENCRYPTED_MAGIC, 0);
new DataView(packed.buffer).setUint32(4, keyIdBytes.length);
packed.set(keyIdBytes, 8);
packed.set(ciphertext, 8 + keyIdBytes.length);
return packed;
}
/**
* Unpack an rSEN-encrypted file into keyId and ciphertext components.
* Assumes caller already checked isEncryptedFile().
*/
export function unpackEncrypted(data: Uint8Array): {
keyId: string;
ciphertext: Uint8Array;
} {
const keyIdLen = new DataView(
data.buffer,
data.byteOffset + 4,
4,
).getUint32(0);
const keyId = new TextDecoder().decode(data.slice(8, 8 + keyIdLen));
const ciphertext = data.slice(8 + keyIdLen);
return { keyId, ciphertext };
}