211 lines
6.1 KiB
TypeScript
211 lines
6.1 KiB
TypeScript
/**
|
|
* Layer 1: Crypto — Document-level encryption for local-first data.
|
|
*
|
|
* Key hierarchy:
|
|
* Master Key (PRF output / passphrase) → HKDF
|
|
* → Space Key (info: "rspace:{spaceId}") → HKDF
|
|
* → Doc Key (info: "rspace:{spaceId}:{docId}") → AES-256-GCM
|
|
*
|
|
* Server never sees plaintext — only encrypted Automerge binary blobs.
|
|
* Extends the existing EncryptIDKeyManager key derivation pattern.
|
|
*/
|
|
|
|
// ============================================================================
|
|
// TYPES
|
|
// ============================================================================
|
|
|
|
export interface EncryptedBlob {
|
|
/** AES-256-GCM ciphertext (includes auth tag) */
|
|
ciphertext: Uint8Array;
|
|
/** 12-byte random nonce */
|
|
nonce: Uint8Array;
|
|
}
|
|
|
|
// ============================================================================
|
|
// DocCrypto
|
|
// ============================================================================
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
/**
|
|
* DocCrypto — derives per-space and per-document encryption keys from a
|
|
* master key (typically the EncryptIDKeyManager's AES-256-GCM key material).
|
|
*
|
|
* Usage:
|
|
* const crypto = new DocCrypto();
|
|
* await crypto.init(masterKey); // from EncryptIDKeyManager PRF
|
|
* const spaceKey = await crypto.deriveSpaceKey('my-space');
|
|
* const docKey = await crypto.deriveDocKey(spaceKey, 'notes:items');
|
|
* const blob = await crypto.encrypt(docKey, automergeBytes);
|
|
* const plain = await crypto.decrypt(docKey, blob);
|
|
*/
|
|
export class DocCrypto {
|
|
#masterKeyMaterial: CryptoKey | null = null;
|
|
|
|
/**
|
|
* Initialize from a master key. Accepts either:
|
|
* - A CryptoKey with HKDF usage (from EncryptIDKeyManager.initFromPRF)
|
|
* - Raw key bytes (Uint8Array / ArrayBuffer) that will be imported as HKDF material
|
|
*/
|
|
async init(masterKey: CryptoKey | Uint8Array | ArrayBuffer): Promise<void> {
|
|
if (masterKey instanceof CryptoKey) {
|
|
// If the key already supports deriveBits/deriveKey, use it directly
|
|
if (masterKey.algorithm.name === 'HKDF') {
|
|
this.#masterKeyMaterial = masterKey;
|
|
} else {
|
|
// It's an AES-GCM key — export raw bits and re-import as HKDF
|
|
const raw = await crypto.subtle.exportKey('raw', masterKey);
|
|
this.#masterKeyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
raw as ArrayBuffer,
|
|
{ name: 'HKDF' },
|
|
false,
|
|
['deriveKey', 'deriveBits']
|
|
);
|
|
}
|
|
} else {
|
|
// Raw bytes → import as HKDF
|
|
const buf = masterKey instanceof Uint8Array ? masterKey.buffer as ArrayBuffer : masterKey;
|
|
this.#masterKeyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
buf,
|
|
{ name: 'HKDF' },
|
|
false,
|
|
['deriveKey', 'deriveBits']
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize directly from WebAuthn PRF output (convenience shortcut).
|
|
*/
|
|
async initFromPRF(prfOutput: ArrayBuffer): Promise<void> {
|
|
await this.init(new Uint8Array(prfOutput));
|
|
}
|
|
|
|
get isInitialized(): boolean {
|
|
return this.#masterKeyMaterial !== null;
|
|
}
|
|
|
|
/**
|
|
* Derive a space-level HKDF key.
|
|
* info = "rspace:{spaceId}"
|
|
*/
|
|
async deriveSpaceKey(spaceId: string): Promise<CryptoKey> {
|
|
this.#assertInit();
|
|
|
|
// Derive 256 bits of key material for the space
|
|
const bits = await crypto.subtle.deriveBits(
|
|
{
|
|
name: 'HKDF',
|
|
hash: 'SHA-256',
|
|
salt: encoder.encode('rspace-space-key-v1'),
|
|
info: encoder.encode(`rspace:${spaceId}`),
|
|
},
|
|
this.#masterKeyMaterial!,
|
|
256
|
|
);
|
|
|
|
// Re-import as HKDF for further derivation (space → doc)
|
|
return crypto.subtle.importKey(
|
|
'raw',
|
|
bits,
|
|
{ name: 'HKDF' },
|
|
false,
|
|
['deriveKey', 'deriveBits']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derive a document-level AES-256-GCM key from a space key.
|
|
* info = "rspace:{spaceId}:{docId}"
|
|
*/
|
|
async deriveDocKey(spaceKey: CryptoKey, docId: string): Promise<CryptoKey> {
|
|
return crypto.subtle.deriveKey(
|
|
{
|
|
name: 'HKDF',
|
|
hash: 'SHA-256',
|
|
salt: encoder.encode('rspace-doc-key-v1'),
|
|
info: encoder.encode(`doc:${docId}`),
|
|
},
|
|
spaceKey,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false, // non-extractable
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encrypt data with a document key.
|
|
* Returns ciphertext + 12-byte random nonce.
|
|
*/
|
|
async encrypt(docKey: CryptoKey, data: Uint8Array): Promise<EncryptedBlob> {
|
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
const ciphertext = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv: nonce },
|
|
docKey,
|
|
data.buffer as ArrayBuffer
|
|
);
|
|
return {
|
|
ciphertext: new Uint8Array(ciphertext),
|
|
nonce,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decrypt an encrypted blob with a document key.
|
|
*/
|
|
async decrypt(docKey: CryptoKey, blob: EncryptedBlob): Promise<Uint8Array> {
|
|
const iv = new Uint8Array(blob.nonce) as unknown as ArrayBuffer;
|
|
const ct = new Uint8Array(blob.ciphertext) as unknown as ArrayBuffer;
|
|
const plaintext = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
docKey,
|
|
ct
|
|
);
|
|
return new Uint8Array(plaintext);
|
|
}
|
|
|
|
/**
|
|
* Convenience: derive doc key directly from master key (space + doc in one call).
|
|
*/
|
|
async deriveDocKeyDirect(spaceId: string, docId: string): Promise<CryptoKey> {
|
|
const spaceKey = await this.deriveSpaceKey(spaceId);
|
|
return this.deriveDocKey(spaceKey, docId);
|
|
}
|
|
|
|
/**
|
|
* Serialize an EncryptedBlob for storage (nonce prepended to ciphertext).
|
|
* Format: [12-byte nonce][ciphertext...]
|
|
*/
|
|
static pack(blob: EncryptedBlob): Uint8Array {
|
|
const packed = new Uint8Array(12 + blob.ciphertext.length);
|
|
packed.set(blob.nonce, 0);
|
|
packed.set(blob.ciphertext, 12);
|
|
return packed;
|
|
}
|
|
|
|
/**
|
|
* Deserialize a packed blob.
|
|
*/
|
|
static unpack(packed: Uint8Array): EncryptedBlob {
|
|
return {
|
|
nonce: packed.slice(0, 12),
|
|
ciphertext: packed.slice(12),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear master key material from memory.
|
|
*/
|
|
clear(): void {
|
|
this.#masterKeyMaterial = null;
|
|
}
|
|
|
|
#assertInit(): void {
|
|
if (!this.#masterKeyMaterial) {
|
|
throw new Error('DocCrypto not initialized — call init() first');
|
|
}
|
|
}
|
|
}
|