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 ? (
+
) : !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)