rspace-online/shared/local-first/encryptid-bridge.ts

213 lines
5.4 KiB
TypeScript

/**
* EncryptID → DocCrypto Bridge
*
* Connects the EncryptID WebAuthn PRF output to the local-first
* DocCrypto layer for client-side document encryption.
*
* Data flow:
* WebAuthn PRF output
* → DocCrypto.initFromPRF()
* → deriveSpaceKey(spaceId)
* → deriveDocKey(spaceKey, docId)
* → AES-256-GCM encrypt/decrypt
*
* Usage:
* import { EncryptedDocBridge } from './encryptid-bridge';
*
* const bridge = new EncryptedDocBridge();
*
* // After EncryptID authentication (PRF output available):
* await bridge.initFromAuth(authResult.prfOutput);
*
* // Pass to EncryptedDocStore for a space:
* const store = new EncryptedDocStore(spaceSlug, bridge.getDocCrypto());
*
* // On sign-out:
* bridge.clear();
*/
import { DocCrypto } from './crypto';
// ============================================================================
// TYPES
// ============================================================================
export interface BridgeState {
initialized: boolean;
/** Cached space keys for quick doc key derivation */
spaceKeys: Map<string, CryptoKey>;
}
// ============================================================================
// EncryptedDocBridge
// ============================================================================
export class EncryptedDocBridge {
#crypto: DocCrypto;
#spaceKeys = new Map<string, CryptoKey>();
#initialized = false;
constructor() {
this.#crypto = new DocCrypto();
}
/**
* Initialize from WebAuthn PRF output.
* Call this after EncryptID authentication returns prfOutput.
*/
async initFromAuth(prfOutput: ArrayBuffer): Promise<void> {
await this.#crypto.initFromPRF(prfOutput);
this.#spaceKeys.clear();
this.#initialized = true;
}
/**
* Initialize from raw key material (e.g., from EncryptIDKeyManager's
* encryption key or any 256-bit key).
*/
async initFromKey(key: CryptoKey | Uint8Array | ArrayBuffer): Promise<void> {
await this.#crypto.init(key);
this.#spaceKeys.clear();
this.#initialized = true;
}
/**
* Get the DocCrypto instance for use with EncryptedDocStore.
* Returns null if not initialized.
*/
getDocCrypto(): DocCrypto | undefined {
return this.#initialized ? this.#crypto : undefined;
}
/**
* Pre-derive and cache a space key for faster doc key derivation.
*/
async warmSpaceKey(spaceId: string): Promise<void> {
if (!this.#initialized) return;
if (!this.#spaceKeys.has(spaceId)) {
const key = await this.#crypto.deriveSpaceKey(spaceId);
this.#spaceKeys.set(spaceId, key);
}
}
/**
* Get a cached space key (or derive it).
*/
async getSpaceKey(spaceId: string): Promise<CryptoKey> {
if (!this.#initialized) throw new Error('Bridge not initialized');
let key = this.#spaceKeys.get(spaceId);
if (!key) {
key = await this.#crypto.deriveSpaceKey(spaceId);
this.#spaceKeys.set(spaceId, key);
}
return key;
}
/**
* Derive a document key directly (convenience for one-off operations).
*/
async deriveDocKey(spaceId: string, docId: string): Promise<CryptoKey> {
const spaceKey = await this.getSpaceKey(spaceId);
return this.#crypto.deriveDocKey(spaceKey, docId);
}
/**
* Check if the bridge is ready for encryption operations.
*/
get isInitialized(): boolean {
return this.#initialized;
}
/**
* Clear all key material from memory. Call on sign-out.
*/
clear(): void {
this.#crypto.clear();
this.#spaceKeys.clear();
this.#initialized = false;
}
}
// ============================================================================
// Singleton for app-wide access
// ============================================================================
let _bridge: EncryptedDocBridge | null = null;
/**
* Get the global EncryptedDocBridge singleton.
*/
export function getDocBridge(): EncryptedDocBridge {
if (!_bridge) {
_bridge = new EncryptedDocBridge();
}
return _bridge;
}
/**
* Reset the global bridge (e.g., on sign-out).
*/
export function resetDocBridge(): void {
if (_bridge) {
_bridge.clear();
_bridge = null;
}
}
// ============================================================================
// Helper: check if a space supports client-side encryption
// ============================================================================
/**
* Check localStorage for whether the user has encryption enabled for a space.
* This is set by the space encryption toggle in the UI.
*/
export function isSpaceEncryptionEnabled(spaceSlug: string): boolean {
try {
return localStorage.getItem(`rspace:${spaceSlug}:encrypted`) === 'true';
} catch {
return false;
}
}
/**
* Set the client-side encryption flag for a space.
*/
export function setSpaceEncryptionEnabled(spaceSlug: string, enabled: boolean): void {
try {
if (enabled) {
localStorage.setItem(`rspace:${spaceSlug}:encrypted`, 'true');
} else {
localStorage.removeItem(`rspace:${spaceSlug}:encrypted`);
}
} catch {
// localStorage unavailable (SSR, etc.)
}
}
/**
* Check if encrypted backup is enabled globally.
*/
export function isEncryptedBackupEnabled(): boolean {
try {
return localStorage.getItem('encryptid_backup_enabled') === 'true';
} catch {
return false;
}
}
/**
* Toggle encrypted backup flag.
*/
export function setEncryptedBackupEnabled(enabled: boolean): void {
try {
if (enabled) {
localStorage.setItem('encryptid_backup_enabled', 'true');
} else {
localStorage.removeItem('encryptid_backup_enabled');
}
} catch {
// localStorage unavailable
}
}