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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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