diff --git a/packages/api/src/controllers/user/authorize.ts b/packages/api/src/controllers/user/authorize.ts index e701648..eed65ae 100644 --- a/packages/api/src/controllers/user/authorize.ts +++ b/packages/api/src/controllers/user/authorize.ts @@ -54,6 +54,16 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize( throw new InvalidRequestError("Invalid redirect_uri"); } + context.logger.info( + { + clientId: authRequest.client_id, + redirectUri: authRequest.redirect_uri, + requestedScopes: authRequest.scope, + hasZkParam: !!authRequest.zk_pub, + }, + "authorize request received" + ); + if (client.type === "public" || client.requirePkce) { if (!authRequest.code_challenge) { throw new InvalidRequestError("PKCE code_challenge is required"); @@ -127,6 +137,16 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize( response.statusCode = 302; response.setHeader("Location", redirectTo); response.end(); + + context.logger.info( + { + requestId, + clientId: authRequest.client_id, + zkPubKid: zkPubKid || null, + userSub, + }, + "authorize request stored" + ); }); export const schema = { diff --git a/packages/api/src/controllers/user/authorizeFinalize.ts b/packages/api/src/controllers/user/authorizeFinalize.ts index 46d5f85..f64d7dc 100644 --- a/packages/api/src/controllers/user/authorizeFinalize.ts +++ b/packages/api/src/controllers/user/authorizeFinalize.ts @@ -53,6 +53,16 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( throw new NotFoundError("Authorization request not found or expired"); } + context.logger.info( + { + requestId, + clientId: pendingRequest.clientId, + zkRequested: !!pendingRequest.zkPubKid, + hasDrkHash: !!parsed.drk_hash, + }, + "authorize finalize received" + ); + // Check if request has expired if (new Date() > pendingRequest.expiresAt) { await deletePendingAuth(context, requestId); @@ -77,6 +87,10 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( // ZK client has already created the JWE client-side and provided the hash hasZk = true; drkHash = drkHashFromClient; + context.logger.info( + { requestId, clientId: pendingRequest.clientId }, + "authorize finalize using client-provided drk hash" + ); } else if (pendingRequest.zkPubKid) { // Legacy support: server-side check for DRK existence // In the proper ZK flow, client-side handles DRK unwrapping and JWE creation @@ -85,6 +99,10 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( hasZk = true; // Note: In proper ZK flow, drkHash should come from client JWE creation // This is fallback for incomplete client implementation + context.logger.warn( + { requestId, clientId: pendingRequest.clientId }, + "authorize finalize missing drk hash, falling back to wrapped DRK presence" + ); } catch {} } @@ -112,6 +130,17 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( state: pendingRequest.state || undefined, redirect_uri: pendingRequest.redirectUri, }); + + context.logger.info( + { + requestId, + clientId: pendingRequest.clientId, + hasZk, + drkHashStored: !!drkHash, + redirectUri: pendingRequest.redirectUri, + }, + "authorize finalize completed" + ); } ) ); diff --git a/packages/demo-app/server/src/controllers/routes.ts b/packages/demo-app/server/src/controllers/routes.ts index 3d447f9..11de796 100644 --- a/packages/demo-app/server/src/controllers/routes.ts +++ b/packages/demo-app/server/src/controllers/routes.ts @@ -30,11 +30,18 @@ async function requireAuthentication(context: Context, request: http.IncomingMes if (!match) return null as null | { sub: string }; const token = match[1]; const jwksUri = new URL("/.well-known/jwks.json", context.config.issuer).toString(); - const jwkSet = createRemoteJWKSet(new URL(jwksUri)); - const { payload } = await jwtVerify(token, jwkSet, { issuer: context.config.issuer }); - const sub = payload.sub as string | undefined; - if (!sub) return null; - return { sub }; + try { + const jwkSet = createRemoteJWKSet(new URL(jwksUri)); + const { payload } = await jwtVerify(token, jwkSet, { issuer: context.config.issuer }); + const sub = payload.sub as string | undefined; + if (!sub) return null; + return { sub }; + } catch (error) { + try { + context.logger?.warn({ error: error instanceof Error ? error.message : String(error) }, "jwt verification failed"); + } catch {} + return null; + } } const BodyCreateNote = z.object({ collection_id: z.string().uuid().optional() }).strict().partial(); @@ -204,7 +211,7 @@ export function getRoutes(): Route[] { if (!user) return sendJson(response, 401, { error: "unauthorized" }); const noteId = match.pathname.groups.id; const dekResult = await getDekForRecipient(context.db, noteId, user.sub); - if (dekResult.rowCount === 0) return sendJson(response, 404, { error: "not_found" }); + if (dekResult.rows.length === 0) return sendJson(response, 404, { error: "not_found" }); return sendJson(response, 200, { dek_jwe: dekResult.rows[0].dek_jwe }); }, operation: { summary: "Get DEK" }, diff --git a/packages/demo-app/server/src/models/notes.ts b/packages/demo-app/server/src/models/notes.ts index 50de59b..a184475 100644 --- a/packages/demo-app/server/src/models/notes.ts +++ b/packages/demo-app/server/src/models/notes.ts @@ -43,7 +43,7 @@ export async function canWriteToNote(db: PGlite, noteId: string, sub: string) { `select 1 from demo_app.notes n where n.note_id=$1 and (n.owner_sub=$2 or exists(select 1 from demo_app.note_access a where a.note_id=n.note_id and a.recipient_sub=$2 and a.grants in ('write')))`, [noteId, sub] ); - return r.rowCount > 0; + return r.rows.length > 0; } export async function appendChange(db: PGlite, noteId: string, ciphertextBase64: string, additionalAuthenticatedData: unknown) { @@ -55,7 +55,7 @@ export async function appendChange(db: PGlite, noteId: string, ciphertextBase64: export async function deleteNoteCascade(db: PGlite, noteId: string, ownerSub: string) { const owner = await db.query("select 1 from demo_app.notes where note_id=$1 and owner_sub=$2", [noteId, ownerSub]); - if (owner.rowCount === 0) return false; + if (owner.rows.length === 0) return false; await db.query("delete from demo_app.note_changes where note_id=$1", [noteId]); await db.query("delete from demo_app.note_access where note_id=$1", [noteId]); await db.query("delete from demo_app.notes where note_id=$1", [noteId]); @@ -71,7 +71,7 @@ export async function getChangesSince(db: PGlite, noteId: string, since: number) export async function shareNote(db: PGlite, noteId: string, ownerSub: string, recipientSub: string, dekJwe: string, grants: string) { const owner = await db.query("select 1 from demo_app.notes where note_id=$1 and owner_sub=$2", [noteId, ownerSub]); - if (owner.rowCount === 0) return false; + if (owner.rows.length === 0) return false; await db.query( `insert into demo_app.note_access(note_id, recipient_sub, dek_jwe, grants) values($1,$2,$3,$4) on conflict(note_id, recipient_sub) do update set dek_jwe=excluded.dek_jwe, grants=excluded.grants`, @@ -82,7 +82,7 @@ export async function shareNote(db: PGlite, noteId: string, ownerSub: string, re export async function revokeShare(db: PGlite, noteId: string, ownerSub: string, recipientSub: string) { const owner = await db.query("select 1 from demo_app.notes where note_id=$1 and owner_sub=$2", [noteId, ownerSub]); - if (owner.rowCount === 0) return false; + if (owner.rows.length === 0) return false; await db.query("delete from demo_app.note_access where note_id=$1 and recipient_sub=$2", [noteId, recipientSub]); return true; } @@ -90,4 +90,3 @@ export async function revokeShare(db: PGlite, noteId: string, ownerSub: string, export async function getDekForRecipient(db: PGlite, noteId: string, recipientSub: string) { return db.query("select dek_jwe from demo_app.note_access where note_id=$1 and recipient_sub=$2", [noteId, recipientSub]); } - diff --git a/packages/demo-app/src/components/Dashboard/Dashboard.module.css b/packages/demo-app/src/components/Dashboard/Dashboard.module.css index 973e97f..b5ea330 100644 --- a/packages/demo-app/src/components/Dashboard/Dashboard.module.css +++ b/packages/demo-app/src/components/Dashboard/Dashboard.module.css @@ -47,10 +47,18 @@ color: #111827; margin-bottom: 0.5rem; } + +:global(.dark) .title { + color: #f3f4f6; +} .subtitle { color: #6b7280; } +:global(.dark) .subtitle { + color: #9ca3af; +} + .grid { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); @@ -86,6 +94,11 @@ background: transparent; } +:global(.dark) .newCard { + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.02); +} + .newIcon { width: 3rem; height: 3rem; @@ -96,11 +109,20 @@ justify-content: center; } +:global(.dark) .newIcon { + background: rgba(255, 255, 255, 0.1); + color: #e5e7eb; +} + .newText { color: #6b7280; font-weight: 500; } +:global(.dark) .newText { + color: #d1d5db; +} + .loading { display: flex; align-items: center; diff --git a/packages/demo-app/src/components/Dashboard/NoteCard.module.css b/packages/demo-app/src/components/Dashboard/NoteCard.module.css index 6425780..19d07d1 100644 --- a/packages/demo-app/src/components/Dashboard/NoteCard.module.css +++ b/packages/demo-app/src/components/Dashboard/NoteCard.module.css @@ -6,6 +6,12 @@ background: #fff; } +:global(.dark) .card { + background: #1f2937; + border-color: rgba(255, 255, 255, 0.08); + box-shadow: none; +} + .link { display: block; padding: 1.25rem; @@ -26,6 +32,10 @@ text-overflow: ellipsis; white-space: nowrap; } + +:global(.dark) .title { + color: #f3f4f6; +} .badges { display: flex; align-items: center; @@ -46,6 +56,10 @@ margin-bottom: 1rem; } +:global(.dark) .preview { + color: #d1d5db; +} + .footer { display: flex; align-items: center; @@ -53,6 +67,10 @@ font-size: 0.75rem; color: #6b7280; } + +:global(.dark) .footer { + color: #9ca3af; +} .footerGroup { display: flex; align-items: center; @@ -87,6 +105,12 @@ padding: 0.25rem 0; z-index: 10; } + +:global(.dark) .menuPanel { + background: #1f2937; + border-color: rgba(255, 255, 255, 0.08); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); +} .menuItem { display: flex; align-items: center; @@ -99,12 +123,20 @@ font-size: 0.875rem; cursor: pointer; } + +:global(.dark) .menuItem { + color: #e5e7eb; +} .separator { height: 1px; margin: 0.25rem 0; background: rgba(0, 0, 0, 0.08); border: 0; } + +:global(.dark) .separator { + background: rgba(255, 255, 255, 0.08); +} .danger { color: #dc2626; } diff --git a/packages/demo-app/src/components/Editor/EditorToolbar.tsx b/packages/demo-app/src/components/Editor/EditorToolbar.tsx index 20f8229..912b6dc 100644 --- a/packages/demo-app/src/components/Editor/EditorToolbar.tsx +++ b/packages/demo-app/src/components/Editor/EditorToolbar.tsx @@ -18,6 +18,7 @@ import { Undo, Unlink, } from "lucide-react"; +import styles from "./EditorToolbar.module.css"; interface EditorToolbarProps { editor: Editor; diff --git a/packages/demo-app/src/components/Editor/NoteEditor.module.css b/packages/demo-app/src/components/Editor/NoteEditor.module.css index bb881fa..d8f2ea5 100644 --- a/packages/demo-app/src/components/Editor/NoteEditor.module.css +++ b/packages/demo-app/src/components/Editor/NoteEditor.module.css @@ -72,7 +72,11 @@ background: transparent; border: none; outline: none; - color: #111827; + color: hsl(var(--foreground)); + caret-color: hsl(var(--foreground)); +} +.titleInput::placeholder { + color: hsl(var(--foreground) / 0.5); } .loading { display: flex; diff --git a/packages/demo-app/src/components/Editor/RichTextEditor.module.css b/packages/demo-app/src/components/Editor/RichTextEditor.module.css index 84ecb28..479a900 100644 --- a/packages/demo-app/src/components/Editor/RichTextEditor.module.css +++ b/packages/demo-app/src/components/Editor/RichTextEditor.module.css @@ -1,12 +1,14 @@ .container { - background: #fff; - border: 1px solid rgba(0, 0, 0, 0.08); + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--foreground) / 0.1); border-radius: 0.5rem; } .divider { - border-top: 1px solid rgba(0, 0, 0, 0.08); + border-top: 1px solid hsl(var(--foreground) / 0.08); } .content { min-height: 400px; padding: 1rem; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); } diff --git a/packages/demo-app/src/components/Layout/Header.module.css b/packages/demo-app/src/components/Layout/Header.module.css index 5f25b62..e7cc92b 100644 --- a/packages/demo-app/src/components/Layout/Header.module.css +++ b/packages/demo-app/src/components/Layout/Header.module.css @@ -64,9 +64,25 @@ position: relative; } +.searchIcon { + position: absolute; + left: 0.875rem; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; + pointer-events: none; +} + .searchInput { width: 100%; - padding-left: 2.5rem; +} + +:global(.input).searchInput { + padding-left: 3rem; +} + +:global(.dark) .searchIcon { + color: #6b7280; } .actions { diff --git a/packages/demo-app/src/components/Layout/Header.tsx b/packages/demo-app/src/components/Layout/Header.tsx index df0f1d9..7cef8d0 100644 --- a/packages/demo-app/src/components/Layout/Header.tsx +++ b/packages/demo-app/src/components/Layout/Header.tsx @@ -64,17 +64,7 @@ export function Header({ onMenuToggle }: HeaderProps) {
- + (null); const selfRegistrationEnabled = !!window.__APP_CONFIG__?.features?.selfRegistrationEnabled; + const normalizedSearch = authRequestSearch ?? location.search; + const pendingRequestId = useMemo(() => { + if (!normalizedSearch) return null; + try { + const params = new URLSearchParams(normalizedSearch); + return params.get("request_id"); + } catch { + return null; + } + }, [normalizedSearch]); + const hasPendingRequest = !!pendingRequestId; + + const appendSearch = useCallback( + (path: string) => + normalizedSearch && normalizedSearch.length > 0 ? `${path}${normalizedSearch}` : path, + [normalizedSearch] + ); + const initializeApp = useCallback(async () => { try { const session = await apiService.getSession(); @@ -121,8 +139,6 @@ function AppContent() { useEffect(() => { const handleSessionExpired = () => { setSessionData(null); - setAuthRequest(null); - setAuthRequestSearch(null); clearAllExportKeys(); }; @@ -131,8 +147,8 @@ function AppContent() { const handleLogin = (userData: SessionData) => { setSessionData(userData); - if (authRequest && authRequestSearch) { - navigate(`/authorize${authRequestSearch}`); + if (hasPendingRequest || authRequest) { + navigate(appendSearch("/authorize")); } else { navigate("/dashboard"); } @@ -140,8 +156,8 @@ function AppContent() { const handleRegister = (userData: SessionData) => { setSessionData(userData); - if (authRequest && authRequestSearch) { - navigate(`/authorize${authRequestSearch}`); + if (hasPendingRequest || authRequest) { + navigate(appendSearch("/authorize")); } else { navigate("/dashboard"); } @@ -174,13 +190,13 @@ function AppContent() {
) : sessionData ? ( - authRequest ? ( - + hasPendingRequest || authRequest ? ( + ) : ( ) ) : ( - + ) } /> @@ -189,11 +205,14 @@ function AppContent() { element={ sessionData ? ( ) : ( - navigate("/signup")} /> + navigate(appendSearch("/signup"))} + /> ) } /> @@ -202,13 +221,16 @@ function AppContent() { element={ sessionData ? ( ) : selfRegistrationEnabled ? ( - navigate("/login")} /> + navigate(appendSearch("/login"))} + /> ) : ( - + ) } /> @@ -247,7 +269,12 @@ function AppContent() { ) : !sessionData ? ( - + + ) : !authRequest && hasPendingRequest ? ( +
+
+

Loading...

+
) : !authRequest ? ( ) : sessionData.passwordResetRequired ? ( @@ -294,8 +321,8 @@ function AppContent() { ) : sessionData.passwordResetRequired ? ( - ) : authRequest ? ( - + ) : hasPendingRequest || authRequest ? ( + ) : ( diff --git a/packages/user-ui/src/services/secureStorage.ts b/packages/user-ui/src/services/secureStorage.ts index ecdbfd3..8d343cf 100644 --- a/packages/user-ui/src/services/secureStorage.ts +++ b/packages/user-ui/src/services/secureStorage.ts @@ -1,320 +1,156 @@ -// Secure storage service for export keys with enhanced XSS protection -// Uses WebCrypto PBKDF2, integrity checks, and key rotation - import { fromBase64Url, toBase64Url } from "./crypto"; import { logger } from "./logger"; -interface SecureStorageEntry { - encryptedData: string; - salt: string; +type SecureStorageEntry = { + ciphertext: string; iv: string; - hmac: string; timestamp: number; version: number; -} +}; -interface StorageMetadata { +type StorageMetadata = { sessionId: string; - keyRotationCount: number; + encryptionKey: string; lastAccess: number; - suspiciousActivity: boolean; -} +}; -// Storage keys and metadata const STORAGE_PREFIX = "DarkAuth_secure:"; const METADATA_KEY = "DarkAuth_meta"; -const MAX_KEY_AGE_MS = 30 * 60 * 1000; // 30 minutes -const PBKDF2_ITERATIONS = 100000; // Strong but reasonable for browser const STORAGE_VERSION = 1; -class SecureStorageService { - private sessionId: string; - private storageKey: CryptoKey | null = null; - private integrityKey: CryptoKey | null = null; - private keyRotationCount = 0; - - constructor() { - // Generate unique session ID - this.sessionId = this.generateSessionId(); - this.initializeSession(); +function toBuffer(view: Uint8Array): ArrayBuffer { + const { buffer, byteOffset, byteLength } = view; + if (buffer instanceof ArrayBuffer) { + return buffer.slice(byteOffset, byteOffset + byteLength); } + const copy = view.slice(); + return copy.buffer; +} - private generateSessionId(): string { - const array = new Uint8Array(16); - crypto.getRandomValues(array); - return toBase64Url(array); - } - - private async initializeSession(): Promise { - // Check if we need to rotate keys due to suspicious activity - const metadata = this.getMetadata(); - if (metadata?.suspiciousActivity) { - await this.rotateKeys(); - } - - // Initialize or load key rotation count - if (metadata) { - this.keyRotationCount = metadata.keyRotationCount; - } - - this.updateMetadata(); - } +class SecureStorageService { + private encryptionKey: CryptoKey | null = null; + private metadata: StorageMetadata | null = null; - private getMetadata(): StorageMetadata | null { + private loadMetadata(): StorageMetadata | null { try { - const metaStr = sessionStorage.getItem(METADATA_KEY); - if (!metaStr) return null; - return JSON.parse(metaStr); + const raw = sessionStorage.getItem(METADATA_KEY); + if (!raw) return null; + return JSON.parse(raw) as StorageMetadata; } catch { return null; } } - private updateMetadata(): void { - const metadata: StorageMetadata = { - sessionId: this.sessionId, - keyRotationCount: this.keyRotationCount, - lastAccess: Date.now(), - suspiciousActivity: false, - }; + private async persistMetadata(metadata: StorageMetadata): Promise { + this.metadata = metadata; sessionStorage.setItem(METADATA_KEY, JSON.stringify(metadata)); } - private async deriveStorageKeys( - password: string, - salt: Uint8Array - ): Promise<{ storageKey: CryptoKey; integrityKey: CryptoKey }> { - const passwordBuffer = new TextEncoder().encode(password); - - // Import password for PBKDF2 - const keyMaterial = await crypto.subtle.importKey( - "raw", - passwordBuffer, - { name: "PBKDF2" }, - false, - ["deriveBits", "deriveKey"] - ); - - // Derive 64 bytes (32 for encryption, 32 for HMAC) - const derivedBits = await crypto.subtle.deriveBits( - { - name: "PBKDF2", - salt: salt as BufferSource, - iterations: PBKDF2_ITERATIONS, - hash: "SHA-256", - }, - keyMaterial, - 512 // 64 bytes - ); - - const derivedArray = new Uint8Array(derivedBits); - const encryptionKeyMaterial = derivedArray.slice(0, 32); - const hmacKeyMaterial = derivedArray.slice(32, 64); - - // Clear the password buffer - passwordBuffer.fill(0); - - // Create encryption key - const storageKey = await crypto.subtle.importKey( - "raw", - encryptionKeyMaterial, - { name: "AES-GCM" }, - false, - ["encrypt", "decrypt"] - ); - - // Create HMAC key for integrity - const integrityKey = await crypto.subtle.importKey( - "raw", - hmacKeyMaterial, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign", "verify"] - ); - - // Clear key material - encryptionKeyMaterial.fill(0); - hmacKeyMaterial.fill(0); - - return { storageKey, integrityKey }; - } - - private async getOrCreateKeys(): Promise<{ storageKey: CryptoKey; integrityKey: CryptoKey }> { - if (this.storageKey && this.integrityKey) { - return { storageKey: this.storageKey, integrityKey: this.integrityKey }; + private async ensureEncryptionKey(): Promise { + if (this.encryptionKey) { + return this.encryptionKey; } - // Generate per-session derivation password from multiple entropy sources - const entropySourcesArray = [ - this.sessionId, - String(this.keyRotationCount), - navigator.userAgent.slice(-20), // Last 20 chars for some fingerprinting - String(performance.now()), - String(Date.now()), - ]; - - const derivationPassword = entropySourcesArray.join("|"); - const salt = crypto.getRandomValues(new Uint8Array(32)); + const existingMetadata = this.loadMetadata(); + if (existingMetadata?.encryptionKey) { + try { + const raw = fromBase64Url(existingMetadata.encryptionKey); + const key = await crypto.subtle.importKey( + "raw", + toBuffer(raw), + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"] + ); + this.encryptionKey = key; + this.metadata = existingMetadata; + return key; + } catch (error) { + logger.warn(error, "Failed to import stored secure storage key"); + } + } - const keys = await this.deriveStorageKeys(derivationPassword, salt); - this.storageKey = keys.storageKey; - this.integrityKey = keys.integrityKey; + const rawKey = crypto.getRandomValues(new Uint8Array(32)); + const key = await crypto.subtle.importKey("raw", toBuffer(rawKey), { name: "AES-GCM" }, false, [ + "encrypt", + "decrypt", + ]); + const sessionId = toBase64Url(crypto.getRandomValues(new Uint8Array(16))); + await this.persistMetadata({ + sessionId, + encryptionKey: toBase64Url(rawKey), + lastAccess: Date.now(), + }); + rawKey.fill(0); + this.encryptionKey = key; + return key; + } - return keys; + private updateLastAccess(): void { + if (!this.metadata) return; + this.metadata.lastAccess = Date.now(); + sessionStorage.setItem(METADATA_KEY, JSON.stringify(this.metadata)); } - private async encryptData( - data: Uint8Array, - storageKey: CryptoKey, - integrityKey: CryptoKey - ): Promise { - // Generate salt and IV - const salt = crypto.getRandomValues(new Uint8Array(32)); + async saveExportKey(sub: string, key: Uint8Array): Promise { + const storageKey = await this.ensureEncryptionKey(); const iv = crypto.getRandomValues(new Uint8Array(12)); - - // Encrypt the data - const encryptedBuffer = await crypto.subtle.encrypt( - { name: "AES-GCM", iv: iv as BufferSource }, + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: toBuffer(iv) }, storageKey, - data as BufferSource + toBuffer(key) ); - - const encryptedData = toBase64Url(encryptedBuffer); - const saltB64 = toBase64Url(salt); - const ivB64 = toBase64Url(iv); - - // Create HMAC for integrity check - const payload = `${encryptedData}|${saltB64}|${ivB64}|${Date.now()}|${STORAGE_VERSION}`; - const hmacBuffer = await crypto.subtle.sign( - "HMAC", - integrityKey, - new TextEncoder().encode(payload) - ); - const hmac = toBase64Url(hmacBuffer); - - return { - encryptedData, - salt: saltB64, - iv: ivB64, - hmac, + const entry: SecureStorageEntry = { + ciphertext: toBase64Url(ciphertext), + iv: toBase64Url(iv), timestamp: Date.now(), version: STORAGE_VERSION, }; - } - - private async decryptData( - entry: SecureStorageEntry, - storageKey: CryptoKey, - integrityKey: CryptoKey - ): Promise { - // Verify integrity first - const payload = `${entry.encryptedData}|${entry.salt}|${entry.iv}|${entry.timestamp}|${entry.version}`; - const hmacBuffer = await crypto.subtle.sign( - "HMAC", - integrityKey, - new TextEncoder().encode(payload) - ); - const expectedHmac = toBase64Url(hmacBuffer); - - if (expectedHmac !== entry.hmac) { - // Potential tampering detected - await this.markSuspiciousActivity(); - throw new Error("Storage integrity check failed"); - } - - // Check age - const age = Date.now() - entry.timestamp; - if (age > MAX_KEY_AGE_MS) { - throw new Error("Stored key has expired"); - } - - // Check version - if (entry.version !== STORAGE_VERSION) { - throw new Error("Unsupported storage version"); - } - - // Decrypt - const encryptedBuffer = fromBase64Url(entry.encryptedData); - const iv = fromBase64Url(entry.iv); - - const decryptedBuffer = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: iv as BufferSource }, - storageKey, - encryptedBuffer as BufferSource - ); - - return new Uint8Array(decryptedBuffer); - } - - private async markSuspiciousActivity(): Promise { - const metadata = this.getMetadata(); - if (metadata) { - metadata.suspiciousActivity = true; - sessionStorage.setItem(METADATA_KEY, JSON.stringify(metadata)); - } - await this.rotateKeys(); - } - - private async rotateKeys(): Promise { - this.keyRotationCount++; - this.storageKey = null; - this.integrityKey = null; - this.updateMetadata(); - } - - async saveExportKey(sub: string, key: Uint8Array): Promise { - const keys = await this.getOrCreateKeys(); - const entry = await this.encryptData(key, keys.storageKey, keys.integrityKey); - const storageKey = STORAGE_PREFIX + sub; - sessionStorage.setItem(storageKey, JSON.stringify(entry)); - this.updateMetadata(); + sessionStorage.setItem(`${STORAGE_PREFIX}${sub}`, JSON.stringify(entry)); + this.updateLastAccess(); } async loadExportKey(sub: string): Promise { try { - const storageKey = STORAGE_PREFIX + sub; - const entryStr = sessionStorage.getItem(storageKey); + const entryStr = sessionStorage.getItem(`${STORAGE_PREFIX}${sub}`); if (!entryStr) return null; - - const entry: SecureStorageEntry = JSON.parse(entryStr); - const keys = await this.getOrCreateKeys(); - const decryptedKey = await this.decryptData(entry, keys.storageKey, keys.integrityKey); - - this.updateMetadata(); - return decryptedKey; + const entry = JSON.parse(entryStr) as SecureStorageEntry; + if (entry.version !== STORAGE_VERSION) { + this.clearExportKey(sub); + return null; + } + const storageKey = await this.ensureEncryptionKey(); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: toBuffer(fromBase64Url(entry.iv)) }, + storageKey, + toBuffer(fromBase64Url(entry.ciphertext)) + ); + this.updateLastAccess(); + return new Uint8Array(plaintext); } catch (error) { logger.warn(error, "Failed to load export key"); - // Clear potentially corrupted data - this.clearExportKey(sub); return null; } } clearExportKey(sub: string): void { - const storageKey = STORAGE_PREFIX + sub; - sessionStorage.removeItem(storageKey); - this.updateMetadata(); + sessionStorage.removeItem(`${STORAGE_PREFIX}${sub}`); } - // Clear all export keys (useful for logout) clearAllExportKeys(): void { - const keysToRemove: string[] = []; + const keys: string[] = []; for (let i = 0; i < sessionStorage.length; i++) { const key = sessionStorage.key(i); - if (key?.startsWith(STORAGE_PREFIX)) { - keysToRemove.push(key); - } + if (key?.startsWith(STORAGE_PREFIX)) keys.push(key); } - - keysToRemove.forEach((key) => { + keys.forEach((key) => { sessionStorage.removeItem(key); }); sessionStorage.removeItem(METADATA_KEY); - this.storageKey = null; - this.integrityKey = null; + this.encryptionKey = null; + this.metadata = null; } - // Get current security status getSecurityStatus(): { sessionId: string; keyRotationCount: number; @@ -322,17 +158,16 @@ class SecureStorageService { lastAccess: number | null; suspiciousActivity: boolean; } { - const metadata = this.getMetadata(); + const metadata = this.metadata ?? this.loadMetadata(); return { - sessionId: this.sessionId, - keyRotationCount: this.keyRotationCount, - hasKeys: this.storageKey !== null && this.integrityKey !== null, - lastAccess: metadata?.lastAccess || null, - suspiciousActivity: metadata?.suspiciousActivity || false, + sessionId: metadata?.sessionId || "", + keyRotationCount: 0, + hasKeys: !!metadata?.encryptionKey, + lastAccess: metadata?.lastAccess ?? null, + suspiciousActivity: false, }; } } -// Export singleton instance export const secureStorageService = new SecureStorageService(); export default secureStorageService; diff --git a/packages/user-ui/src/services/sessionKey.ts b/packages/user-ui/src/services/sessionKey.ts index 02a1e36..8d686b9 100644 --- a/packages/user-ui/src/services/sessionKey.ts +++ b/packages/user-ui/src/services/sessionKey.ts @@ -30,6 +30,7 @@ async function migrateToSecureStorage(sub: string): Promise { // Enhanced export key storage with XSS protection export async function saveExportKey(sub: string, key: Uint8Array): Promise { + clearExportKey(sub); try { // Use secure storage await secureStorageService.saveExportKey(sub, key); @@ -112,9 +113,9 @@ export async function loadExportKey(sub: string): Promise { export function clearExportKey(sub: string): void { // Clear from both secure and legacy storage - secureStorageService.clearExportKey(sub); sessionStorage.removeItem(LEGACY_PREFIX + sub); sessionStorage.removeItem(SECURE_MIGRATION_FLAG + sub); + secureStorageService.clearExportKey(sub); } // Clear all export keys (useful for logout)