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

Configure Feed

Select the types of activity you want to include in your feed.

improve security; encrypt tokens at rest

byarielm.fyi edddd796 655349bc

verified
+444 -36
-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);