126 lines
3.5 KiB
TypeScript
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 };
|
|
}
|