ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

improve security; encrypt tokens at rest

byarielm.fyi edddd796 655349bc

verified
-1
.gitignore
··· 6 6 dist/ 7 7 private-key.pem 8 8 public-jwk.json 9 - keygen.js 10 9 test-data/
+11 -19
netlify.toml
··· 20 20 Cache-Control = "public, max-age=3600" 21 21 22 22 [[headers]] 23 - for = "/.well-known/*" 24 - [headers.values] 23 + for = "/.well-known/*" 24 + [headers.values] 25 25 Access-Control-Allow-Origin = "*" 26 26 27 - [[headers]] 28 - for = "/*" 29 - [headers.values] 30 - Content-Security-Policy = """ 31 - default-src 'self'; 32 - script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; 33 - style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; 34 - font-src 'self' https://fonts.gstatic.com; 35 - img-src 'self' data: https:; 36 - connect-src 'self' https://bsky.social https://*.bsky.network; 37 - frame-ancestors 'none'; 38 - base-uri 'self'; 39 - form-action 'self'; 40 - """ 41 - X-Frame-Options = "DENY" 42 - X-Content-Type-Options = "nosniff" 43 - Referrer-Policy = "strict-origin-when-cross-origin" 27 + [[headers]] 28 + for = "/*" 29 + [headers.values] 30 + X-Frame-Options = "DENY" 31 + X-Content-Type-Options = "nosniff" 32 + X-XSS-Protection = "1; mode=block" 33 + Referrer-Policy = "strict-origin-when-cross-origin" 34 + Permissions-Policy = "geolocation=(), microphone=(), camera=()" 35 + Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://*.bsky.app https://*.bsky.network https://public.api.bsky.app; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
+1 -1
netlify/functions/core/config/constants.ts
··· 4 4 STATE_EXPIRY: 10 * 60 * 1000, // 10 minutes 5 5 COOKIE_MAX_AGE: 2592000, // 30 days in seconds, 6 6 OAUTH_KEY_ID: "main-key", // jwks kid 7 - OAUTH_SCOPES: "atproto transition:generic", // future?: atproto repo:app.bsky.graph.follow?action=create repo:so.sprk.graph.follow?action=create repo:sh.tangled.graph.follow?action=create 7 + OAUTH_SCOPES: "atproto transition:generic", // future?: atproto rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.actor.getProfile?aud=* repo:app.bsky.graph.follow?action=create repo:so.sprk.graph.follow?action=create repo:sh.tangled.graph.follow?action=create 8 8 } as const;
+117
netlify/functions/core/middleware/session-security.middleware.ts
··· 1 + import { HandlerEvent } from "@netlify/functions"; 2 + 3 + interface SessionFingerprint { 4 + userAgent: string; 5 + ipAddress: string; 6 + createdAt: number; 7 + } 8 + 9 + /** 10 + * Session Security Service 11 + * Provides additional session replay protection and fingerprinting 12 + */ 13 + export class SessionSecurityService { 14 + /** 15 + * Generate a session fingerprint from request headers 16 + */ 17 + static generateFingerprint(event: HandlerEvent): SessionFingerprint { 18 + const userAgent = event.headers["user-agent"] || "unknown"; 19 + const ipAddress = 20 + event.headers["x-forwarded-for"]?.split(",")[0].trim() || 21 + event.headers["client-ip"] || 22 + "unknown"; 23 + 24 + return { 25 + userAgent, 26 + ipAddress, 27 + createdAt: Date.now(), 28 + }; 29 + } 30 + 31 + /** 32 + * Verify session fingerprint matches current request 33 + * Helps detect session hijacking 34 + */ 35 + static verifyFingerprint( 36 + stored: SessionFingerprint, 37 + current: SessionFingerprint, 38 + ): boolean { 39 + // User agent must match exactly 40 + if (stored.userAgent !== current.userAgent) { 41 + console.warn("Session fingerprint mismatch: User-Agent changed"); 42 + return false; 43 + } 44 + 45 + // IP can change (mobile networks, VPN) but log if it does 46 + if (stored.ipAddress !== current.ipAddress) { 47 + console.info( 48 + `Session IP changed: ${stored.ipAddress} -> ${current.ipAddress}`, 49 + ); 50 + // Don't fail - just log for monitoring 51 + } 52 + 53 + return true; 54 + } 55 + 56 + /** 57 + * Check if session is being used suspiciously fast 58 + * (potential replay attack) 59 + */ 60 + static detectSuspiciousActivity( 61 + lastUsed: number, 62 + minIntervalMs: number = 100, 63 + ): boolean { 64 + const timeSinceLastUse = Date.now() - lastUsed; 65 + 66 + // If requests are less than 100ms apart, suspicious 67 + if (timeSinceLastUse < minIntervalMs) { 68 + console.warn( 69 + `Suspicious activity: Request ${timeSinceLastUse}ms after last use`, 70 + ); 71 + return true; 72 + } 73 + 74 + return false; 75 + } 76 + } 77 + 78 + /** 79 + * Enhanced session validation middleware 80 + * Adds fingerprinting to detect session hijacking 81 + */ 82 + export async function validateSessionSecurity( 83 + event: HandlerEvent, 84 + sessionId: string, 85 + ): Promise<void> { 86 + const currentFingerprint = SessionSecurityService.generateFingerprint(event); 87 + 88 + // Get stored fingerprint (would need to extend UserSessionStore) 89 + // For now, just log current fingerprint for monitoring 90 + console.log("Session fingerprint:", { 91 + sessionId: sessionId.substring(0, 8) + "...", 92 + userAgent: currentFingerprint.userAgent.substring(0, 50), 93 + ip: currentFingerprint.ipAddress, 94 + }); 95 + 96 + // Future: Store and compare fingerprints 97 + // const session = await userSessions.get(sessionId); 98 + // if (session.fingerprint) { 99 + // if (!SessionSecurityService.verifyFingerprint(session.fingerprint, currentFingerprint)) { 100 + // throw new AuthenticationError("Session security check failed"); 101 + // } 102 + // } 103 + } 104 + 105 + /** 106 + * Add session fingerprint to new sessions 107 + * Call this in oauth-callback.ts when creating session 108 + */ 109 + export function createSecureSessionData( 110 + event: HandlerEvent, 111 + did: string, 112 + ): { did: string; fingerprint: SessionFingerprint } { 113 + return { 114 + did, 115 + fingerprint: SessionSecurityService.generateFingerprint(event), 116 + }; 117 + }
+3
netlify/functions/core/types/api.types.ts
··· 23 23 } 24 24 25 25 export interface SessionData { 26 + dpopJwk?: any; 26 27 dpopKey: any; 27 28 tokenSet: any; 29 + authMethod?: string; 28 30 } 29 31 30 32 export interface UserSessionData { 31 33 did: string; 34 + fingerprint?: any; 32 35 } 33 36 34 37 // OAuth configuration
+5 -1
netlify/functions/core/types/database.types.ts
··· 12 12 export interface OAuthSessionRow { 13 13 key: string; 14 14 data: { 15 - dpopKey: any; 15 + dpopJwk?: any; 16 + dpopKey?: any; 16 17 tokenSet: any; 18 + authMethod?: string; 19 + encrypted?: boolean; 17 20 }; 18 21 created_at: Date; 19 22 expires_at: Date; ··· 22 25 export interface UserSessionRow { 23 26 session_id: string; 24 27 did: string; 28 + fingerprint?: any; 25 29 created_at: Date; 26 30 expires_at: Date; 27 31 }
+1
netlify/functions/infrastructure/database/DatabaseService.ts
··· 52 52 CREATE TABLE IF NOT EXISTS user_sessions ( 53 53 session_id TEXT PRIMARY KEY, 54 54 did TEXT NOT NULL, 55 + fingerprint JSONB, 55 56 created_at TIMESTAMP DEFAULT NOW(), 56 57 expires_at TIMESTAMP NOT NULL 57 58 )
+55 -3
netlify/functions/infrastructure/oauth/stores/SessionStore.ts
··· 1 1 import { getDbClient } from "../../database"; 2 2 import { SessionData, OAuthSessionRow } from "../../../core/types"; 3 3 import { CONFIG } from "../../../core/config/constants"; 4 + import { 5 + encryptToken, 6 + decryptToken, 7 + isEncryptionConfigured, 8 + } from "../../../utils/encryption.utils"; 4 9 5 10 export class PostgresSessionStore { 6 11 private sql = getDbClient(); 12 + private encryptionEnabled = isEncryptionConfigured(); 7 13 8 14 async get(key: string): Promise<SessionData | undefined> { 9 15 const result = await this.sql` ··· 11 17 WHERE key = ${key} AND expires_at > NOW() 12 18 `; 13 19 const rows = result as OAuthSessionRow[]; 14 - return rows[0]?.data as SessionData | undefined; 20 + 21 + if (!rows[0]) return undefined; 22 + 23 + const stored = rows[0].data; 24 + 25 + // Handle encrypted format 26 + if ( 27 + this.encryptionEnabled && 28 + typeof stored === "object" && 29 + stored.encrypted 30 + ) { 31 + try { 32 + // Decrypt tokenSet and reconstruct with dpopJwk 33 + const decryptedTokenSet = decryptToken(stored.tokenSet); 34 + 35 + return { 36 + dpopJwk: stored.dpopJwk, // Use dpopJwk (not dpopKey!) 37 + tokenSet: decryptedTokenSet, 38 + authMethod: stored.authMethod, 39 + } as SessionData; 40 + } catch (error) { 41 + console.error( 42 + "[SessionStore] Failed to decrypt session token set:", 43 + error, 44 + ); 45 + return undefined; 46 + } 47 + } 48 + 49 + // Fallback for unencrypted format 50 + return stored as SessionData; 15 51 } 16 52 17 53 async set(key: string, value: SessionData): Promise<void> { 18 54 const expiresAt = new Date(Date.now() + CONFIG.SESSION_EXPIRY); 55 + 56 + let dataToStore: any; 57 + 58 + if (this.encryptionEnabled) { 59 + // Encrypt only tokenSet, keep dpopJwk and authMethod as-is 60 + dataToStore = { 61 + encrypted: true, 62 + dpopJwk: (value as any).dpopJwk, 63 + authMethod: (value as any).authMethod, 64 + tokenSet: encryptToken(value.tokenSet), 65 + }; 66 + } else { 67 + // Store as-is if encryption disabled 68 + dataToStore = value; 69 + } 70 + 19 71 await this.sql` 20 72 INSERT INTO oauth_sessions (key, data, expires_at) 21 - VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt}) 73 + VALUES (${key}, ${JSON.stringify(dataToStore)}, ${expiresAt}) 22 74 ON CONFLICT (key) DO UPDATE SET 23 - data = ${JSON.stringify(value)}, 75 + data = ${JSON.stringify(dataToStore)}, 24 76 expires_at = ${expiresAt} 25 77 `; 26 78 }
+13 -3
netlify/functions/infrastructure/oauth/stores/StateStore.ts
··· 11 11 WHERE key = ${key} AND expires_at > NOW() 12 12 `; 13 13 const rows = result as OAuthStateRow[]; 14 - return rows[0]?.data as StateData | undefined; 14 + 15 + if (!rows[0]) return undefined; 16 + 17 + // State data contains dpopKey which must remain as JWK object 18 + // We don't encrypt state data - it's ephemeral (10 min expiry) 19 + return rows[0].data as StateData; 15 20 } 16 21 17 22 async set(key: string, value: StateData): Promise<void> { 18 23 const expiresAt = new Date(Date.now() + CONFIG.STATE_EXPIRY); 24 + 25 + // Store as-is - no encryption for state data 26 + // State is ephemeral and dpopKey needs to be valid JWK 27 + const dataToStore = JSON.stringify(value); 28 + 19 29 await this.sql` 20 30 INSERT INTO oauth_states (key, data, expires_at) 21 - VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt.toISOString()}) 31 + VALUES (${key}, ${dataToStore}, ${expiresAt.toISOString()}) 22 32 ON CONFLICT (key) DO UPDATE SET 23 - data = ${JSON.stringify(value)}, 33 + data = ${dataToStore}, 24 34 expires_at = ${expiresAt.toISOString()} 25 35 `; 26 36 }
+7 -4
netlify/functions/infrastructure/oauth/stores/UserSessionStore.ts
··· 7 7 8 8 async get(sessionId: string): Promise<UserSessionData | undefined> { 9 9 const result = await this.sql` 10 - SELECT did FROM user_sessions 10 + SELECT did, fingerprint FROM user_sessions 11 11 WHERE session_id = ${sessionId} AND expires_at > NOW() 12 12 `; 13 13 const rows = result as UserSessionRow[]; 14 - return rows[0] ? { did: rows[0].did } : undefined; 14 + return rows[0] 15 + ? { did: rows[0].did, fingerprint: rows[0].fingerprint } 16 + : undefined; 15 17 } 16 18 17 19 async set(sessionId: string, data: UserSessionData): Promise<void> { 18 20 const expiresAt = new Date(Date.now() + CONFIG.SESSION_EXPIRY); 19 21 await this.sql` 20 - INSERT INTO user_sessions (session_id, did, expires_at) 21 - VALUES (${sessionId}, ${data.did}, ${expiresAt}) 22 + INSERT INTO user_sessions (session_id, did, fingerprint, expires_at) 23 + VALUES (${sessionId}, ${data.did}, ${JSON.stringify(data.fingerprint)}, ${expiresAt}) 22 24 ON CONFLICT (session_id) DO UPDATE SET 23 25 did = ${data.did}, 26 + fingerprint = ${JSON.stringify(data.fingerprint)}, 24 27 expires_at = ${expiresAt} 25 28 `; 26 29 }
+7 -2
netlify/functions/oauth-callback.ts
··· 1 1 import { SimpleHandler } from "./core/types/api.types"; 2 2 import { createOAuthClient, getOAuthConfig } from "./infrastructure/oauth"; 3 + import { createSecureSessionData } from "./core/middleware/session-security.middleware"; 3 4 import { userSessions } from "./infrastructure/oauth/stores"; 4 5 import { redirectResponse } from "./utils"; 5 6 import { withErrorHandling } from "./core/middleware"; ··· 38 39 ); 39 40 40 41 const sessionId = crypto.randomUUID(); 41 - const did = result.session.did; 42 - await userSessions.set(sessionId, { did }); 42 + const secureData = createSecureSessionData(event, result.session.did); 43 + 44 + await userSessions.set(sessionId, { 45 + did: secureData.did, 46 + fingerprint: secureData.fingerprint, 47 + }); 43 48 44 49 console.log("[oauth-callback] Created user session:", sessionId); 45 50
+24 -1
netlify/functions/services/SessionService.ts
··· 1 1 import { Agent } from "@atproto/api"; 2 2 import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 + import { SessionSecurityService } from "../core/middleware/session-security.middleware"; 3 4 import type { HandlerEvent } from "@netlify/functions"; 4 5 import { AuthenticationError, ERROR_MESSAGES } from "../core/errors"; 5 6 import { createOAuthClient } from "../infrastructure/oauth"; 6 7 import { userSessions } from "../infrastructure/oauth/stores"; 7 8 import { configCache } from "../infrastructure/cache/CacheService"; 9 + import { sessionStore } from "../infrastructure/oauth/stores"; 8 10 9 11 export class SessionService { 10 12 static async getAgentForSession( 11 13 sessionId: string, 12 - event?: HandlerEvent, 14 + event: HandlerEvent, 13 15 ): Promise<{ 14 16 agent: Agent; 15 17 did: string; ··· 22 24 throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 23 25 } 24 26 27 + const currentFingerprint = 28 + SessionSecurityService.generateFingerprint(event); 29 + if ( 30 + userSession.fingerprint && 31 + !SessionSecurityService.verifyFingerprint( 32 + userSession.fingerprint, 33 + currentFingerprint, 34 + ) 35 + ) { 36 + throw new AuthenticationError("Session hijacking detected"); 37 + } 38 + 25 39 const did = userSession.did; 26 40 console.log("[SessionService] Found user session for DID:", did); 27 41 ··· 39 53 40 54 const oauthSession = await client.restore(did); 41 55 console.log("[SessionService] Restored OAuth session for DID:", did); 56 + 57 + // Log token rotation for monitoring 58 + // The restore() call automatically refreshes if needed 59 + const sessionData = await sessionStore.get(did); 60 + if (sessionData) { 61 + // Token refresh happens transparently in restore() 62 + // Just log for monitoring purposes 63 + console.log("[SessionService] OAuth session restored/refreshed"); 64 + } 42 65 43 66 const agent = new Agent(oauthSession); 44 67
+139
netlify/functions/utils/encryption.utils.ts
··· 1 + import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; 2 + import { ApiError } from "../core/errors"; 3 + 4 + /** 5 + * Token Encryption Service 6 + * Encrypts sensitive OAuth tokens at rest using AES-256-GCM 7 + */ 8 + 9 + function getEncryptionKey(): Buffer { 10 + const key = process.env.TOKEN_ENCRYPTION_KEY; 11 + 12 + if (!key) { 13 + throw new ApiError( 14 + "Encryption key not configured", 15 + 500, 16 + "TOKEN_ENCRYPTION_KEY environment variable is required", 17 + ); 18 + } 19 + 20 + // Expect 64-char hex string (32 bytes) 21 + if (key.length !== 64) { 22 + throw new ApiError( 23 + "Invalid encryption key", 24 + 500, 25 + "TOKEN_ENCRYPTION_KEY must be 64 hex characters (32 bytes)", 26 + ); 27 + } 28 + 29 + return Buffer.from(key, "hex"); 30 + } 31 + 32 + interface EncryptedPayload { 33 + iv: string; 34 + data: string; 35 + tag: string; 36 + } 37 + 38 + /** 39 + * Encrypt sensitive data using AES-256-GCM 40 + * @param data - Data to encrypt (will be JSON stringified) 41 + * @returns Encrypted payload as JSON string 42 + */ 43 + export function encryptToken(data: any): string { 44 + try { 45 + const key = getEncryptionKey(); 46 + const iv = randomBytes(16); 47 + 48 + const cipher = createCipheriv("aes-256-gcm", key, iv); 49 + 50 + const jsonData = JSON.stringify(data); 51 + const encrypted = Buffer.concat([ 52 + cipher.update(jsonData, "utf8"), 53 + cipher.final(), 54 + ]); 55 + 56 + const authTag = cipher.getAuthTag(); 57 + 58 + const payload: EncryptedPayload = { 59 + iv: iv.toString("hex"), 60 + data: encrypted.toString("hex"), 61 + tag: authTag.toString("hex"), 62 + }; 63 + 64 + return JSON.stringify(payload); 65 + } catch (error) { 66 + console.error("Token encryption failed:", error); 67 + throw new ApiError( 68 + "Failed to encrypt token", 69 + 500, 70 + error instanceof Error ? error.message : "Unknown encryption error", 71 + ); 72 + } 73 + } 74 + 75 + /** 76 + * Decrypt sensitive data 77 + * @param encrypted - Encrypted payload as JSON string 78 + * @returns Decrypted data 79 + */ 80 + export function decryptToken(encrypted: string): any { 81 + try { 82 + const key = getEncryptionKey(); 83 + const payload: EncryptedPayload = JSON.parse(encrypted); 84 + 85 + const decipher = createDecipheriv( 86 + "aes-256-gcm", 87 + key, 88 + Buffer.from(payload.iv, "hex"), 89 + ); 90 + 91 + decipher.setAuthTag(Buffer.from(payload.tag, "hex")); 92 + 93 + const decrypted = Buffer.concat([ 94 + decipher.update(Buffer.from(payload.data, "hex")), 95 + decipher.final(), 96 + ]); 97 + 98 + return JSON.parse(decrypted.toString("utf8")); 99 + } catch (error) { 100 + console.error("Token decryption failed:", error); 101 + throw new ApiError( 102 + "Failed to decrypt token", 103 + 500, 104 + error instanceof Error ? error.message : "Unknown decryption error", 105 + ); 106 + } 107 + } 108 + 109 + /** 110 + * Generate a new encryption key (for initial setup) 111 + * Run this once and store in environment variables 112 + */ 113 + export function generateEncryptionKey(): string { 114 + return randomBytes(32).toString("hex"); 115 + } 116 + 117 + /** 118 + * Check if encryption is properly configured 119 + * Returns false in development if key is missing (with warning) 120 + */ 121 + export function isEncryptionConfigured(): boolean { 122 + const key = process.env.TOKEN_ENCRYPTION_KEY; 123 + 124 + if (!key) { 125 + if (process.env.NODE_ENV === "production") { 126 + throw new ApiError( 127 + "Encryption key not configured in production", 128 + 500, 129 + "TOKEN_ENCRYPTION_KEY is required in production", 130 + ); 131 + } 132 + console.warn( 133 + "⚠️ TOKEN_ENCRYPTION_KEY not set - tokens will NOT be encrypted", 134 + ); 135 + return false; 136 + } 137 + 138 + return true; 139 + }
+1
netlify/functions/utils/index.ts
··· 1 1 export * from "./response.utils"; 2 2 export * from "./string.utils"; 3 + export * from "./encryption.utils";
+2 -1
package.json
··· 9 9 "dev:mock": "vite --mode mock", 10 10 "dev:full": "netlify dev", 11 11 "build": "vite build", 12 - "init-db": "tsx scripts/init-local-db.ts" 12 + "init-db": "tsx scripts/init-local-db.ts", 13 + "generate-key": "tsx scripts/generate-encryption-key.ts" 13 14 }, 14 15 "dependencies": { 15 16 "@atcute/identity": "^1.1.0",
+21
scripts/generate-encryption-key.ts
··· 1 + import { randomBytes } from "crypto"; 2 + 3 + /** 4 + * Generate encryption key for token storage 5 + * Run once: npx tsx scripts/generate-encryption-key.ts 6 + */ 7 + 8 + const key = randomBytes(32).toString("hex"); 9 + 10 + console.log("\n🔐 TOKEN ENCRYPTION KEY GENERATED\n"); 11 + console.log("Add this to your .env file and Netlify environment variables:\n"); 12 + console.log(`TOKEN_ENCRYPTION_KEY=${key}\n`); 13 + console.log("⚠️ IMPORTANT:"); 14 + console.log("1. Keep this key secret and secure"); 15 + console.log("2. Never commit this to git"); 16 + console.log( 17 + "3. Use the same key across all environments to decrypt existing tokens", 18 + ); 19 + console.log( 20 + "4. If you lose this key, all encrypted tokens will be unrecoverable\n", 21 + );
+37
scripts/keygen.js
··· 1 + import { generateKeyPair, exportJWK, exportPKCS8 } from "jose"; 2 + import { writeFileSync } from "fs"; 3 + 4 + async function generateKeys() { 5 + // Generate ES256 key pair (recommended by atproto) 6 + const { publicKey, privateKey } = await generateKeyPair("ES256", { 7 + extractable: true, 8 + }); 9 + 10 + // Export public key as JWK (for client-metadata.json) 11 + const publicJWK = await exportJWK(publicKey); 12 + publicJWK.kid = "main-key"; // Key ID 13 + publicJWK.use = "sig"; // Signature use 14 + publicJWK.alg = "ES256"; 15 + 16 + // Export private key as PKCS8 (for environment variable) 17 + const privateKeyPem = await exportPKCS8(privateKey); 18 + 19 + console.log("\n=== PUBLIC KEY (JWK) ==="); 20 + console.log("Add this to your client-metadata.json jwks.keys array:"); 21 + console.log(JSON.stringify(publicJWK, null, 2)); 22 + 23 + console.log("\n=== PRIVATE KEY (PEM) ==="); 24 + console.log( 25 + "Add this to Netlify environment variables as OAUTH_PRIVATE_KEY:", 26 + ); 27 + console.log(privateKeyPem); 28 + 29 + // Save to files for reference 30 + writeFileSync("public-jwk.json", JSON.stringify(publicJWK, null, 2)); 31 + writeFileSync("private-key.pem", privateKeyPem); 32 + 33 + console.log("\n✅ Keys saved to public-jwk.json and private-key.pem"); 34 + console.log("⚠️ Keep private-key.pem SECRET! Add it to .gitignore"); 35 + } 36 + 37 + generateKeys().catch(console.error);