Browse Source
auto-logout on client after 24 hours of inactivity make splash-page full-screen move or repeat access-security server-side, to prevent client work-aroundsmain
13 changed files with 1107 additions and 32 deletions
@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
|
||||
/** |
||||
* Activity tracker for user sessions |
||||
* Tracks ONLY the last activity timestamp (no information about what the user did) |
||||
* Used for 24-hour auto-logout after inactivity |
||||
*
|
||||
* SECURITY: Only stores a single timestamp value. No activity details are stored. |
||||
*/ |
||||
|
||||
const LAST_ACTIVITY_KEY = 'gitrepublic_last_activity'; |
||||
const SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
|
||||
/** |
||||
* Update the last activity timestamp |
||||
*
|
||||
* SECURITY: Only stores the timestamp (Date.now()). No information about |
||||
* what the user did is stored. Previous timestamp is overwritten. |
||||
*/ |
||||
export function updateActivity(): void { |
||||
if (typeof window === 'undefined') return; |
||||
|
||||
// Only store the timestamp - no activity details
|
||||
const now = Date.now(); |
||||
localStorage.setItem(LAST_ACTIVITY_KEY, now.toString()); |
||||
} |
||||
|
||||
/** |
||||
* Get the last activity timestamp |
||||
*/ |
||||
export function getLastActivity(): number | null { |
||||
if (typeof window === 'undefined') return null; |
||||
|
||||
const timestamp = localStorage.getItem(LAST_ACTIVITY_KEY); |
||||
return timestamp ? parseInt(timestamp, 10) : null; |
||||
} |
||||
|
||||
/** |
||||
* Check if the session has expired (24 hours since last activity) |
||||
*/ |
||||
export function isSessionExpired(): boolean { |
||||
if (typeof window === 'undefined') return false; |
||||
|
||||
const lastActivity = getLastActivity(); |
||||
if (!lastActivity) return false; |
||||
|
||||
const now = Date.now(); |
||||
const timeSinceActivity = now - lastActivity; |
||||
|
||||
return timeSinceActivity >= SESSION_TIMEOUT_MS; |
||||
} |
||||
|
||||
/** |
||||
* Clear activity tracking (on logout) |
||||
*/ |
||||
export function clearActivity(): void { |
||||
if (typeof window === 'undefined') return; |
||||
|
||||
localStorage.removeItem(LAST_ACTIVITY_KEY); |
||||
} |
||||
|
||||
/** |
||||
* Get time until session expires (in milliseconds) |
||||
* Returns 0 if expired or no activity |
||||
*/ |
||||
export function getTimeUntilExpiry(): number { |
||||
if (typeof window === 'undefined') return 0; |
||||
|
||||
const lastActivity = getLastActivity(); |
||||
if (!lastActivity) return 0; |
||||
|
||||
const now = Date.now(); |
||||
const timeSinceActivity = now - lastActivity; |
||||
const timeUntilExpiry = SESSION_TIMEOUT_MS - timeSinceActivity; |
||||
|
||||
return Math.max(0, timeUntilExpiry); |
||||
} |
||||
@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
|
||||
/** |
||||
* Service for determining user access level based on relay write capability |
||||
*
|
||||
* SECURITY: User level verification is done server-side via API endpoint. |
||||
* Client-side checks are only for UI purposes and can be bypassed. |
||||
*
|
||||
* Three tiers: |
||||
* - unlimited: Users with write access to default relays |
||||
* - rate_limited: Logged-in users without write access |
||||
* - strictly_rate_limited: Not logged-in users |
||||
*/ |
||||
|
||||
import { signEventWithNIP07, isNIP07Available } from './nip07-signer.js'; |
||||
import { KIND } from '../../types/nostr.js'; |
||||
import { createProofEvent } from './relay-write-proof.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
|
||||
export type UserLevel = 'unlimited' | 'rate_limited' | 'strictly_rate_limited'; |
||||
|
||||
export interface UserLevelResult { |
||||
level: UserLevel; |
||||
userPubkey: string | null; |
||||
userPubkeyHex: string | null; |
||||
error?: string; |
||||
} |
||||
|
||||
/** |
||||
* Check if a user can write to default relays by creating and verifying a proof event |
||||
* SECURITY: This creates the proof event client-side, but verification is done server-side |
||||
*/ |
||||
export async function checkRelayWriteAccess( |
||||
userPubkeyHex: string |
||||
): Promise<{ hasAccess: boolean; error?: string }> { |
||||
if (!isNIP07Available()) { |
||||
return { hasAccess: false, error: 'NIP-07 extension not available' }; |
||||
} |
||||
|
||||
try { |
||||
// Create a proof event (kind 1 text note)
|
||||
const proofEventTemplate = createProofEvent( |
||||
userPubkeyHex, |
||||
`gitrepublic-write-proof-${Date.now()}` |
||||
); |
||||
|
||||
// Sign the event with NIP-07
|
||||
const signedEvent = await signEventWithNIP07(proofEventTemplate); |
||||
|
||||
// Verify server-side via API endpoint (secure)
|
||||
const response = await fetch('/api/user/level', { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
}, |
||||
body: JSON.stringify({ |
||||
proofEvent: signedEvent, |
||||
userPubkeyHex |
||||
}) |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); |
||||
return { |
||||
hasAccess: false, |
||||
error: errorData.error || `Server error: ${response.status}` |
||||
}; |
||||
} |
||||
|
||||
const result = await response.json(); |
||||
|
||||
return { |
||||
hasAccess: result.level === 'unlimited', |
||||
error: result.error |
||||
}; |
||||
} catch (error) { |
||||
return { |
||||
hasAccess: false, |
||||
error: error instanceof Error ? error.message : 'Unknown error checking relay write access' |
||||
}; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Determine user level based on authentication and relay write access |
||||
* This is the main function to call to get user level |
||||
*/ |
||||
export async function determineUserLevel( |
||||
userPubkey: string | null, |
||||
userPubkeyHex: string | null |
||||
): Promise<UserLevelResult> { |
||||
// Not logged in
|
||||
if (!userPubkey || !userPubkeyHex) { |
||||
return { |
||||
level: 'strictly_rate_limited', |
||||
userPubkey: null, |
||||
userPubkeyHex: null |
||||
}; |
||||
} |
||||
|
||||
// Check if user has write access to default relays
|
||||
const writeAccess = await checkRelayWriteAccess(userPubkeyHex); |
||||
|
||||
if (writeAccess.hasAccess) { |
||||
return { |
||||
level: 'unlimited', |
||||
userPubkey, |
||||
userPubkeyHex |
||||
}; |
||||
} |
||||
|
||||
// Logged in but no write access
|
||||
return { |
||||
level: 'rate_limited', |
||||
userPubkey, |
||||
userPubkeyHex, |
||||
error: writeAccess.error |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Helper to decode npub to hex if needed |
||||
*/ |
||||
export function decodePubkey(pubkey: string): string | null { |
||||
try { |
||||
const decoded = nip19.decode(pubkey); |
||||
if (decoded.type === 'npub') { |
||||
return decoded.data as string; |
||||
} |
||||
return pubkey; // Assume it's already hex
|
||||
} catch { |
||||
return pubkey; // Assume it's already hex
|
||||
} |
||||
} |
||||
@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
|
||||
/** |
||||
* Cache for user access levels |
||||
* Prevents users from losing access when relays are temporarily down |
||||
*
|
||||
* SECURITY: |
||||
* - Only caches successful verifications. Failed verifications are not cached. |
||||
* - Cache is keyed by pubkey, so only the user with that pubkey can use it |
||||
* - Users must still provide a valid proof event signed with their private key |
||||
* - Cache expires after 24 hours, requiring re-verification |
||||
* - If relays are down and no cache exists, access is denied (security-first) |
||||
*/ |
||||
|
||||
interface CachedUserLevel { |
||||
level: 'unlimited' | 'rate_limited'; |
||||
userPubkeyHex: string; |
||||
cachedAt: number; |
||||
expiresAt: number; |
||||
} |
||||
|
||||
// In-memory cache (in production, consider Redis for distributed systems)
|
||||
const userLevelCache = new Map<string, CachedUserLevel>(); |
||||
|
||||
// Cache duration: 24 hours (users keep access even if relays are down)
|
||||
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; |
||||
|
||||
// Cleanup interval: remove expired entries every hour
|
||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; |
||||
|
||||
let cleanupInterval: NodeJS.Timeout | null = null; |
||||
|
||||
/** |
||||
* Start cleanup interval if not already running |
||||
*/ |
||||
function startCleanup(): void { |
||||
if (cleanupInterval) return; |
||||
|
||||
cleanupInterval = setInterval(() => { |
||||
const now = Date.now(); |
||||
for (const [key, entry] of userLevelCache.entries()) { |
||||
if (entry.expiresAt < now) { |
||||
userLevelCache.delete(key); |
||||
} |
||||
} |
||||
}, CLEANUP_INTERVAL_MS); |
||||
} |
||||
|
||||
/** |
||||
* Get cached user level if it exists and is still valid |
||||
*/ |
||||
export function getCachedUserLevel(userPubkeyHex: string): CachedUserLevel | null { |
||||
startCleanup(); |
||||
|
||||
const cached = userLevelCache.get(userPubkeyHex); |
||||
if (!cached) { |
||||
return null; |
||||
} |
||||
|
||||
// Check if cache is still valid
|
||||
const now = Date.now(); |
||||
if (cached.expiresAt < now) { |
||||
userLevelCache.delete(userPubkeyHex); |
||||
return null; |
||||
} |
||||
|
||||
return cached; |
||||
} |
||||
|
||||
/** |
||||
* Cache a successful user level verification |
||||
* Only caches successful verifications (unlimited or rate_limited) |
||||
* Does not cache failures (strictly_rate_limited) |
||||
*/ |
||||
export function cacheUserLevel( |
||||
userPubkeyHex: string, |
||||
level: 'unlimited' | 'rate_limited' |
||||
): void { |
||||
startCleanup(); |
||||
|
||||
const now = Date.now(); |
||||
const cached: CachedUserLevel = { |
||||
level, |
||||
userPubkeyHex, |
||||
cachedAt: now, |
||||
expiresAt: now + CACHE_DURATION_MS |
||||
}; |
||||
|
||||
userLevelCache.set(userPubkeyHex, cached); |
||||
} |
||||
|
||||
/** |
||||
* Clear cached user level (e.g., on logout or when access is revoked) |
||||
*/ |
||||
export function clearCachedUserLevel(userPubkeyHex: string): void { |
||||
userLevelCache.delete(userPubkeyHex); |
||||
} |
||||
|
||||
/** |
||||
* Get cache statistics (for monitoring) |
||||
*/ |
||||
export function getCacheStats(): { |
||||
size: number; |
||||
entries: Array<{ userPubkeyHex: string; level: string; expiresAt: number }>; |
||||
} { |
||||
const entries = Array.from(userLevelCache.entries()).map(([key, value]) => ({ |
||||
userPubkeyHex: key, |
||||
level: value.level, |
||||
expiresAt: value.expiresAt |
||||
})); |
||||
|
||||
return { |
||||
size: userLevelCache.size, |
||||
entries |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Clear all cached entries (useful for testing or manual resets) |
||||
*/ |
||||
export function clearAllCache(): void { |
||||
userLevelCache.clear(); |
||||
} |
||||
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
/** |
||||
* User store for managing user state and access level across the application |
||||
*/ |
||||
|
||||
import { writable } from 'svelte/store'; |
||||
import type { UserLevel } from '../services/nostr/user-level-service.js'; |
||||
|
||||
export interface UserState { |
||||
userPubkey: string | null; |
||||
userPubkeyHex: string | null; |
||||
userLevel: UserLevel; |
||||
checkingLevel: boolean; |
||||
error: string | null; |
||||
} |
||||
|
||||
const initialState: UserState = { |
||||
userPubkey: null, |
||||
userPubkeyHex: null, |
||||
userLevel: 'strictly_rate_limited', |
||||
checkingLevel: false, |
||||
error: null |
||||
}; |
||||
|
||||
function createUserStore() { |
||||
const { subscribe, set, update } = writable<UserState>(initialState); |
||||
|
||||
return { |
||||
subscribe, |
||||
set, |
||||
update, |
||||
reset: () => set(initialState), |
||||
setChecking: (checking: boolean) => { |
||||
update(state => ({ ...state, checkingLevel: checking })); |
||||
}, |
||||
setUser: ( |
||||
userPubkey: string | null, |
||||
userPubkeyHex: string | null, |
||||
userLevel: UserLevel, |
||||
error: string | null = null |
||||
) => { |
||||
update(state => ({ |
||||
...state, |
||||
userPubkey, |
||||
userPubkeyHex, |
||||
userLevel, |
||||
checkingLevel: false, |
||||
error |
||||
})); |
||||
} |
||||
}; |
||||
} |
||||
|
||||
export const userStore = createUserStore(); |
||||
@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
|
||||
/** |
||||
* Input validation utilities for security |
||||
* Prevents injection attacks, path traversal, and other security issues |
||||
*/ |
||||
|
||||
/** |
||||
* Validate and sanitize repository name |
||||
* Repository names should be alphanumeric with hyphens and underscores |
||||
*/ |
||||
export function validateRepoName(name: string): { valid: boolean; error?: string; sanitized?: string } { |
||||
if (!name || typeof name !== 'string') { |
||||
return { valid: false, error: 'Repository name is required' }; |
||||
} |
||||
|
||||
// Remove leading/trailing whitespace
|
||||
const trimmed = name.trim(); |
||||
|
||||
if (trimmed.length === 0) { |
||||
return { valid: false, error: 'Repository name cannot be empty' }; |
||||
} |
||||
|
||||
if (trimmed.length > 100) { |
||||
return { valid: false, error: 'Repository name must be 100 characters or less' }; |
||||
} |
||||
|
||||
// Allow alphanumeric, hyphens, underscores, and dots
|
||||
// But not starting with dot or containing consecutive dots
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/.test(trimmed)) { |
||||
return { |
||||
valid: false, |
||||
error: 'Repository name must contain only alphanumeric characters, hyphens, underscores, and dots. Cannot start or end with a dot.' |
||||
}; |
||||
} |
||||
|
||||
// Prevent path traversal attempts
|
||||
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) { |
||||
return { valid: false, error: 'Repository name cannot contain path separators' }; |
||||
} |
||||
|
||||
return { valid: true, sanitized: trimmed }; |
||||
} |
||||
|
||||
/** |
||||
* Validate and sanitize file path |
||||
* Prevents path traversal attacks |
||||
*/ |
||||
export function validateFilePath(path: string): { valid: boolean; error?: string; sanitized?: string } { |
||||
if (!path || typeof path !== 'string') { |
||||
return { valid: false, error: 'File path is required' }; |
||||
} |
||||
|
||||
// Remove leading/trailing whitespace and slashes
|
||||
const trimmed = path.trim().replace(/^\/+|\/+$/g, ''); |
||||
|
||||
if (trimmed.length === 0) { |
||||
return { valid: false, error: 'File path cannot be empty' }; |
||||
} |
||||
|
||||
// Prevent path traversal
|
||||
if (trimmed.includes('..')) { |
||||
return { valid: false, error: 'Path traversal not allowed' }; |
||||
} |
||||
|
||||
// Prevent absolute paths
|
||||
if (trimmed.startsWith('/') || trimmed.match(/^[a-zA-Z]:/)) { |
||||
return { valid: false, error: 'Absolute paths not allowed' }; |
||||
} |
||||
|
||||
// Prevent null bytes
|
||||
if (trimmed.includes('\0')) { |
||||
return { valid: false, error: 'Null bytes not allowed in paths' }; |
||||
} |
||||
|
||||
// Normalize path separators
|
||||
const normalized = trimmed.replace(/\\/g, '/'); |
||||
|
||||
return { valid: true, sanitized: normalized }; |
||||
} |
||||
|
||||
/** |
||||
* Validate pubkey format (hex, 64 characters) |
||||
*/ |
||||
export function validatePubkey(pubkey: string): { valid: boolean; error?: string } { |
||||
if (!pubkey || typeof pubkey !== 'string') { |
||||
return { valid: false, error: 'Pubkey is required' }; |
||||
} |
||||
|
||||
// Hex format, 64 characters
|
||||
if (!/^[0-9a-f]{64}$/i.test(pubkey)) { |
||||
return { valid: false, error: 'Invalid pubkey format. Must be 64-character hex string.' }; |
||||
} |
||||
|
||||
return { valid: true }; |
||||
} |
||||
|
||||
/** |
||||
* Validate npub format (bech32 encoded) |
||||
*/ |
||||
export function validateNpub(npub: string): { valid: boolean; error?: string } { |
||||
if (!npub || typeof npub !== 'string') { |
||||
return { valid: false, error: 'Npub is required' }; |
||||
} |
||||
|
||||
// Basic npub format check (starts with npub, bech32 encoded)
|
||||
if (!/^npub1[ac-hj-np-z02-9]{58}$/.test(npub)) { |
||||
return { valid: false, error: 'Invalid npub format' }; |
||||
} |
||||
|
||||
return { valid: true }; |
||||
} |
||||
|
||||
/** |
||||
* Sanitize string input to prevent XSS |
||||
* Removes potentially dangerous characters |
||||
*/ |
||||
export function sanitizeString(input: string, maxLength: number = 10000): string { |
||||
if (typeof input !== 'string') { |
||||
return ''; |
||||
} |
||||
|
||||
// Truncate to max length
|
||||
let sanitized = input.slice(0, maxLength); |
||||
|
||||
// Remove null bytes
|
||||
sanitized = sanitized.replace(/\0/g, ''); |
||||
|
||||
// Remove control characters except newlines and tabs
|
||||
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); |
||||
|
||||
return sanitized; |
||||
} |
||||
|
||||
/** |
||||
* Validate commit message |
||||
*/ |
||||
export function validateCommitMessage(message: string): { valid: boolean; error?: string; sanitized?: string } { |
||||
if (!message || typeof message !== 'string') { |
||||
return { valid: false, error: 'Commit message is required' }; |
||||
} |
||||
|
||||
const trimmed = message.trim(); |
||||
|
||||
if (trimmed.length === 0) { |
||||
return { valid: false, error: 'Commit message cannot be empty' }; |
||||
} |
||||
|
||||
if (trimmed.length > 10000) { |
||||
return { valid: false, error: 'Commit message must be 10000 characters or less' }; |
||||
} |
||||
|
||||
// Sanitize but allow newlines
|
||||
const sanitized = sanitizeString(trimmed, 10000); |
||||
|
||||
return { valid: true, sanitized }; |
||||
} |
||||
|
||||
/** |
||||
* Validate branch name |
||||
*/ |
||||
export function validateBranchName(branch: string): { valid: boolean; error?: string; sanitized?: string } { |
||||
if (!branch || typeof branch !== 'string') { |
||||
return { valid: false, error: 'Branch name is required' }; |
||||
} |
||||
|
||||
const trimmed = branch.trim(); |
||||
|
||||
if (trimmed.length === 0) { |
||||
return { valid: false, error: 'Branch name cannot be empty' }; |
||||
} |
||||
|
||||
if (trimmed.length > 255) { |
||||
return { valid: false, error: 'Branch name must be 255 characters or less' }; |
||||
} |
||||
|
||||
// Git branch name rules
|
||||
// Cannot contain .., cannot end with ., cannot contain spaces
|
||||
if (trimmed.includes('..') || trimmed.endsWith('.') || /\s/.test(trimmed)) { |
||||
return { valid: false, error: 'Invalid branch name format' }; |
||||
} |
||||
|
||||
// Prevent path traversal
|
||||
if (trimmed.includes('/') && trimmed !== 'HEAD') { |
||||
// Allow forward slashes for branch paths, but validate
|
||||
const parts = trimmed.split('/'); |
||||
for (const part of parts) { |
||||
if (part === '' || part === '.' || part === '..') { |
||||
return { valid: false, error: 'Invalid branch name format' }; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return { valid: true, sanitized: trimmed }; |
||||
} |
||||
@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
|
||||
/** |
||||
* API endpoint for verifying user level (relay write access) |
||||
* This must be done server-side for security - client-side checks can be bypassed |
||||
*
|
||||
* User only needs to be able to write to ONE of the default relays, not all. |
||||
* This is a trust mechanism - users who can write to trusted default relays |
||||
* get unlimited access, limiting access to trusted npubs. |
||||
*/ |
||||
|
||||
import { json, error } from '@sveltejs/kit'; |
||||
import type { RequestHandler } from './$types'; |
||||
import { verifyRelayWriteProof } from '$lib/services/nostr/relay-write-proof.js'; |
||||
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||
import { auditLogger } from '$lib/services/security/audit-logger.js'; |
||||
import { getCachedUserLevel, cacheUserLevel } from '$lib/services/security/user-level-cache.js'; |
||||
import { extractRequestContext } from '$lib/utils/api-context.js'; |
||||
import { sanitizeError } from '$lib/utils/security.js'; |
||||
import { verifyEvent } from 'nostr-tools'; |
||||
import logger from '$lib/services/logger.js'; |
||||
|
||||
export const POST: RequestHandler = async (event) => { |
||||
const requestContext = extractRequestContext(event); |
||||
const clientIp = requestContext.clientIp || 'unknown'; |
||||
|
||||
try { |
||||
const body = await event.request.json(); |
||||
const { proofEvent, userPubkeyHex } = body; |
||||
|
||||
// Validate input
|
||||
if (!proofEvent || !userPubkeyHex) { |
||||
auditLogger.logAuth( |
||||
null, |
||||
clientIp, |
||||
'NIP-98', |
||||
'failure', |
||||
'Missing proof event or user pubkey' |
||||
); |
||||
return error(400, 'Missing required fields: proofEvent and userPubkeyHex'); |
||||
} |
||||
|
||||
// Validate pubkey format (should be hex, 64 characters)
|
||||
if (typeof userPubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(userPubkeyHex)) { |
||||
auditLogger.logAuth( |
||||
null, |
||||
clientIp, |
||||
'NIP-98', |
||||
'failure', |
||||
'Invalid pubkey format' |
||||
); |
||||
return error(400, 'Invalid pubkey format'); |
||||
} |
||||
|
||||
// Validate proof event structure
|
||||
if (!proofEvent.kind || !proofEvent.pubkey || !proofEvent.created_at || !proofEvent.id) { |
||||
auditLogger.logAuth( |
||||
userPubkeyHex, |
||||
clientIp, |
||||
'NIP-98', |
||||
'failure', |
||||
'Invalid proof event structure' |
||||
); |
||||
return error(400, 'Invalid proof event structure'); |
||||
} |
||||
|
||||
// Validate proof event signature first (even if using cache, user must prove they have private key)
|
||||
if (!verifyEvent(proofEvent)) { |
||||
auditLogger.logAuth( |
||||
userPubkeyHex, |
||||
clientIp, |
||||
'NIP-98', |
||||
'failure', |
||||
'Invalid proof event signature' |
||||
); |
||||
return error(400, 'Invalid proof event signature'); |
||||
} |
||||
|
||||
// Verify pubkey matches
|
||||
if (proofEvent.pubkey !== userPubkeyHex) { |
||||
auditLogger.logAuth( |
||||
userPubkeyHex, |
||||
clientIp, |
||||
'NIP-98', |
||||
'failure', |
||||
'Proof event pubkey does not match user pubkey' |
||||
); |
||||
return error(400, 'Proof event pubkey does not match user pubkey'); |
||||
} |
||||
|
||||
// Check cache (if relays are down, use cached value)
|
||||
// But user must still provide valid proof event signed with their private key
|
||||
const cached = getCachedUserLevel(userPubkeyHex); |
||||
if (cached) { |
||||
logger.info({ userPubkeyHex, level: cached.level }, '[API] Using cached user level (proof event signature validated)'); |
||||
return json({ |
||||
level: cached.level, |
||||
userPubkeyHex, |
||||
verified: true, |
||||
cached: true |
||||
}); |
||||
} |
||||
|
||||
// Verify relay write proof server-side
|
||||
const verification = await verifyRelayWriteProof( |
||||
proofEvent, |
||||
userPubkeyHex, |
||||
DEFAULT_NOSTR_RELAYS |
||||
); |
||||
|
||||
// If relays are down, check cache again (might have been cached from previous request)
|
||||
if (verification.relayDown) { |
||||
const cachedOnRelayDown = getCachedUserLevel(userPubkeyHex); |
||||
if (cachedOnRelayDown) { |
||||
logger.info({ userPubkeyHex, level: cachedOnRelayDown.level }, '[API] Relays down, using cached user level'); |
||||
auditLogger.logAuth( |
||||
userPubkeyHex, |
||||
clientIp, |
||||
'NIP-98', |
||||
'success', |
||||
'Relays down, using cached access level' |
||||
); |
||||
return json({ |
||||
level: cachedOnRelayDown.level, |
||||
userPubkeyHex, |
||||
verified: true, |
||||
cached: true, |
||||
relayDown: true |
||||
}); |
||||
} |
||||
|
||||
// No cache available and relays are down - return error
|
||||
auditLogger.logAuth( |
||||
userPubkeyHex, |
||||
clientIp, |
||||
'NIP-98', |
||||
'failure', |
||||
'Relays unavailable and no cached access level' |
||||
); |
||||
return error(503, 'Relays are temporarily unavailable. Please try again later.'); |
||||
} |
||||
|
||||
if (verification.valid) { |
||||
// User has write access - unlimited level
|
||||
// Cache the successful verification
|
||||
cacheUserLevel(userPubkeyHex, 'unlimited'); |
||||
|
||||
auditLogger.logAuth( |
||||
userPubkeyHex, |
||||
clientIp, |
||||
'NIP-98', |
||||
'success', |
||||
'Relay write access verified' |
||||
); |
||||
|
||||
return json({ |
||||
level: 'unlimited', |
||||
userPubkeyHex, |
||||
verified: true |
||||
}); |
||||
} else { |
||||
// User is logged in but no write access - rate limited
|
||||
// Cache this level too (so they don't lose access if relays go down)
|
||||
cacheUserLevel(userPubkeyHex, 'rate_limited'); |
||||
|
||||
auditLogger.logAuth( |
||||
userPubkeyHex, |
||||
clientIp, |
||||
'NIP-98', |
||||
'success', |
||||
'Authenticated but no relay write access' |
||||
); |
||||
|
||||
return json({ |
||||
level: 'rate_limited', |
||||
userPubkeyHex, |
||||
verified: true, |
||||
error: verification.error |
||||
}); |
||||
} |
||||
} catch (err) { |
||||
const errorMessage = err instanceof Error ? err.message : String(err); |
||||
logger.error({ error: err, clientIp }, '[API] Error verifying user level'); |
||||
|
||||
auditLogger.logAuth( |
||||
null, |
||||
clientIp, |
||||
'NIP-98', |
||||
'failure', |
||||
sanitizeError(errorMessage) |
||||
); |
||||
|
||||
return error(500, 'Failed to verify user level'); |
||||
} |
||||
}; |
||||
Loading…
Reference in new issue