OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / identity.js

802 lines · javascript ref:
/**
 * Authentication Routes
 *
 * Handles:
 * - POST /api/identity/generate - Generate new web identity
 * - POST /api/user/link-ssh-key - Link SSH key to account
 * - GET /api/user/identities - List user identities
 * - PATCH /api/user/profile - Update user profile
 * - POST /api/auth/challenge - Generate auth challenge
 * - GET /api/user/attestation - Fetch signed permission attestation
 * - POST /api/auth/restore - Restore permissions from attestation
 * - POST /api/user/merge/initiate - Initiate account merge
 * - GET /api/user/merge/:mergeId - Get merge request details
 * - POST /api/user/merge/:mergeId/confirm - Confirm merge from secondary account
 * - GET /merge/:mergeId - Merge confirmation page
 */

import { json, html, unauthorized, badRequest, notFound, forbidden, readJson, text, rateLimited } from '../response.js'
import { requireAuth } from '../middleware/require-auth.js'
import { ANProto } from '../anproto.js'
import { PermissionAttestation } from '../auth/attestation.js'
import crypto from 'hypercore-crypto'

/**
 * Register auth routes
 */
export function registerIdentityRoutes(router) {
  // Generate new web identity (legacy — no username)
  router.post('/api/identity/generate', handleGenerateIdentity)

  // Create identity with username (new flow)
  router.post('/api/identity/create', handleCreateIdentity)

  // Check username availability
  router.get('/api/identity/username-available', handleUsernameAvailable)

  // Link SSH key to account
  router.post('/api/user/link-ssh-key', requireAuth, handleLinkSSHKey)

  // List user identities
  router.get('/api/user/identities', requireAuth, handleListIdentities)

  // Update user profile
  router.patch('/api/user/profile', requireAuth, handleUpdateProfile)

  // Generate challenge for signature-based auth
  router.post('/api/auth/challenge', handleGenerateChallenge)

  // Permission attestations (self-sovereign identity recovery)
  router.get('/api/user/attestation', requireAuth, handleGetAttestation)
  router.post('/api/auth/restore', handleRestoreFromAttestation)

  // Debug endpoints (admin only)
  router.get('/api/auth/debug', requireAuth, handleAuthDebug)
  router.get('/api/auth/debug/user/:pubkey', requireAuth, handleDebugUserLookup)

  // Localhost-only bootstrap (no auth required)
  router.post('/api/bootstrap/promote', handleBootstrapPromote)

  // Account merge routes
}

/**
 * Generate new web identity keypair
 */
async function handleGenerateIdentity(req, res, server) {
  const { authEnabled, authManager } = server.auth
  // Rate limit: 10 identities per IP per hour
  if (authEnabled && authManager.rateLimiter) {
    const clientIp = authManager.getClientIp(req)
    const rateCheck = await authManager.rateLimiter.check(clientIp, 'identity-generate')
    if (!rateCheck.allowed) {
      return rateLimited(res, rateCheck.retryAfter, rateCheck.resetAt)
    }
  }

  try {
    const identity = await authManager.generateWebIdentity()

    // Register in DB immediately so legacy (pre-ed25519) auth works
    const registry = authManager.identityRegistry
    await registry.autoRegister({
      identityId: identity.publicKey,
      method: 'web-identity',
      credentials: { publicKey: identity.publicKey }
    })

    json(res, {
      publicKey: identity.publicKey,
      keypair: identity.keypair,
      message: 'Identity created! Save your keypair to restore access.'
    })
  } catch (err) {
    console.error('[API] Generate identity error:', err)
    json(res, { error: 'Failed to generate identity' }, 500)
  }
}

/**
 * Create identity with username (new opt-in flow)
 */
async function handleCreateIdentity(req, res, server) {
  const { authEnabled, authManager } = server.auth
  // Rate limit
  if (authEnabled && authManager.rateLimiter) {
    const clientIp = authManager.getClientIp(req)
    const rateCheck = await authManager.rateLimiter.check(clientIp, 'identity-generate')
    if (!rateCheck.allowed) {
      return rateLimited(res, rateCheck.retryAfter, rateCheck.resetAt)
    }
  }

  try {
    const { username, displayName } = await readJson(req)

    if (!username) {
      return badRequest(res, 'username is required')
    }

    // Validate format: lowercase, alphanumeric + hyphens, 3-30 chars
    const normalized = username.toLowerCase()
    if (!/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/.test(normalized)) {
      return badRequest(res, 'Username must be 3-30 characters, lowercase letters, numbers, and hyphens only')
    }

    // Check availability
    const registry = authManager.identityRegistry
    const available = await registry.checkUsernameAvailable(normalized)
    if (!available) {
      return json(res, { error: 'Username is already taken' }, 409)
    }

    // Generate keypair
    const identity = await authManager.generateWebIdentity()

    // Register with username
    await registry.autoRegister({
      identityId: identity.publicKey,
      method: 'web-identity',
      credentials: { publicKey: identity.publicKey },
      username: normalized,
      name: displayName || normalized
    })

    json(res, {
      publicKey: identity.publicKey,
      keypair: identity.keypair,
      username: normalized,
      message: 'Identity created!'
    })
  } catch (err) {
    console.error('[API] Create identity error:', err)
    json(res, { error: 'Failed to create identity' }, 500)
  }
}

/**
 * Check username availability
 */
async function handleUsernameAvailable(req, res, server) {
  const { authManager } = server.auth
  try {
    const url = new URL(req.url, `http://${req.headers.host}`)
    const username = url.searchParams.get('username')

    if (!username) {
      return badRequest(res, 'username query parameter is required')
    }

    const normalized = username.toLowerCase()

    // Validate format
    if (!/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/.test(normalized)) {
      return json(res, { available: false, reason: 'Invalid format: 3-30 chars, lowercase letters, numbers, hyphens' })
    }

    const registry = authManager.identityRegistry
    const available = await registry.checkUsernameAvailable(normalized)
    json(res, { available, username: normalized })
  } catch (err) {
    console.error('[API] Username check error:', err)
    json(res, { error: 'Failed to check username' }, 500)
  }
}

/**
 * Link SSH key to current user
 */
async function handleLinkSSHKey(req, res, server) {
  const { authManager } = server.auth
  try {
    const { sshPublicKey } = await readJson(req)

    if (!sshPublicKey) {
      badRequest(res, 'sshPublicKey is required')
      return
    }

    // Check if SSH key is already linked to another user
    const existingIdentity = await authManager.identityRegistry.getIdentityBySSHKey(sshPublicKey)
    if (existingIdentity && existingIdentity.userId) {
      const currentUser = await authManager.identityRegistry.getUserByIdentity(req.auth.identityId)
      if (existingIdentity.userId !== currentUser?.userId) {
        badRequest(res, 'SSH key is already linked to another account')
        return
      }
    }

    // Link SSH key to current user
    const updatedUser = await authManager.linkSSHKey(
      req.auth.identityId,
      sshPublicKey
    )

    // Get all identities
    const identities = await authManager.identityRegistry.listUserIdentities(updatedUser.userId)

    json(res, {
      success: true,
      userId: updatedUser.userId,
      identities: identities.map(id => ({
        method: id.method,
        identityId: id.identityId,
        createdAt: id.createdAt
      }))
    })
  } catch (err) {
    console.error('[API] Link SSH key error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * List user identities and permissions
 */
async function handleListIdentities(req, res, server) {
  const { authManager } = server.auth
  try {
    const result = await authManager.getUserWithIdentities(req.auth.identityId)
    if (!result) {
      notFound(res, 'User not found')
      return
    }

    const { user, identities } = result

    json(res, {
      userId: user.userId,
      name: user.name,
      profilePicture: user.profilePicture,
      identities: identities.map(id => ({
        method: id.method,
        identityId: id.identityId,
        createdAt: id.createdAt
      })),
      permissions: {
        global: user.globalPermissions,
        repos: user.repos
      }
    })
  } catch (err) {
    console.error('[API] List identities error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Update user profile (name, profile picture)
 */
async function handleUpdateProfile(req, res, server) {
  const { authManager } = server.auth
  try {
    const result = await authManager.getUserWithIdentities(req.auth.identityId)
    if (!result) {
      notFound(res, 'User not found')
      return
    }

    const updates = await readJson(req)
    const updatedUser = await authManager.identityRegistry.updateUserProfile(result.user.userId, updates)

    json(res, {
      success: true,
      user: {
        userId: updatedUser.userId,
        name: updatedUser.name,
        profilePicture: updatedUser.profilePicture
      }
    })
  } catch (err) {
    console.error('[API] Update profile error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Generate challenge for signature-based authentication
 */
async function handleGenerateChallenge(req, res, server) {
  const { authManager } = server.auth
  try {
    const { context } = await readJson(req)
    const challenge = await authManager.generateChallenge({ context })

    json(res, {
      challengeId: challenge.challengeId,
      challenge: challenge.challenge,
      expiresAt: challenge.expiresAt,
      ttl: 300  // 5 minutes
    })
  } catch (err) {
    console.error('[API] Generate challenge error:', err)
    json(res, { error: err.message }, 500)
  }
}

// ============================================
// Permission Attestation Handlers
// ============================================

/**
 * Get a signed permission attestation for the current user.
 * Users store this alongside their keypair for disaster recovery.
 */
async function handleGetAttestation(req, res, server) {
  const { serverIdentity } = server.p2p
  const { authManager } = server.auth
  try {
    if (!serverIdentity) {
      json(res, { error: 'P2P not initialized — attestations require server identity' }, 503)
      return
    }

    const result = await authManager.getUserWithIdentities(req.auth.identityId)
    if (!result) {
      notFound(res, 'User not found')
      return
    }

    const { user, identities } = result

    // Find the identity record that matches the auth identity
    const identity = identities.find(i => i.identityId === req.auth.identityId) || identities[0]
    const userPublicKey = identity?.publicKey || identity?.credentials?.publicKey || req.auth.identityId

    const attestation = PermissionAttestation.create({
      user,
      userPublicKey,
      identityMethod: identity?.method || 'web-identity',
      identityId: identity?.identityId || req.auth.identityId,
      serverIdentity
    })

    json(res, { attestation })
  } catch (err) {
    console.error('[API] Get attestation error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Restore permissions from a previously-issued attestation.
 * No auth required (the user lost access — that's the whole point).
 *
 * Flow:
 *   1. Client presents attestation + proof of key ownership (challenge-response)
 *   2. Server verifies attestation signature (proves THIS server issued it)
 *   3. Server verifies key ownership (proves client holds the private key)
 *   4. Server re-creates user record with attested permissions
 */
async function handleRestoreFromAttestation(req, res, server) {
  const { serverIdentity } = server.p2p
  const { authManager } = server.auth
  try {
    if (!serverIdentity) {
      json(res, { error: 'P2P not initialized — restore requires server identity' }, 503)
      return
    }

    const body = await readJson(req)
    const { attestation, challengeId, publicKey, signature } = body

    if (!attestation || !challengeId || !publicKey || !signature) {
      badRequest(res, 'attestation, challengeId, publicKey, and signature are required')
      return
    }

    // Step 1: Verify the attestation was signed by THIS server
    const verification = PermissionAttestation.verify(
      attestation,
      serverIdentity.getPublicKeyHex()
    )

    if (!verification.valid) {
      forbidden(res, 'Invalid attestation — not signed by this server or tampered')
      return
    }

    // Step 2: Check publicKey matches attestation
    if (attestation.userPublicKey !== publicKey) {
      forbidden(res, 'Public key does not match attestation')
      return
    }

    // Step 3: Verify key ownership via challenge-response
    const challengeRecord = await authManager.challengeManager.consume(challengeId)
    if (!challengeRecord) {
      forbidden(res, 'Invalid or expired challenge')
      return
    }

    const isOwner = ANProto.verify(challengeRecord.challenge, signature, publicKey)
    if (!isOwner) {
      forbidden(res, 'Invalid signature — cannot prove key ownership')
      return
    }

    // Step 4: Re-create user record with attested permissions
    const grant = verification.grant
    const registry = authManager.identityRegistry

    // Check if user already exists (might have been partially recreated)
    let existingUser = null
    try {
      const existingIdentity = await registry.getIdentityByPublicKey(publicKey)
      if (existingIdentity && existingIdentity.userId) {
        existingUser = await registry.getUser(existingIdentity.userId)
      }
    } catch {}

    if (existingUser) {
      // Update existing user's permissions to match attestation via Autobase
      existingUser = await registry.updateUserProfile(existingUser.userId, {
        globalPermissions: grant.permissions,
        repos: grant.repos.map(name => ({
          name,
          permissions: grant.permissions
        })),
        restoredFromAttestation: true
      })

      console.log('[Restore] Updated existing user:', existingUser.userId, 'permissions:', grant.permissions)
    } else {
      // Create new user + identity records
      const result = await registry.autoRegister({
        identityId: grant.identityId,
        method: grant.identityMethod,
        credentials: { publicKey }
      })

      // Set permissions on the newly created user via Autobase
      existingUser = await registry.updateUserProfile(result.user.userId, {
        globalPermissions: grant.permissions,
        repos: grant.repos.map(name => ({
          name,
          permissions: grant.permissions
        })),
        restoredFromAttestation: true
      })

      console.log('[Restore] Created user from attestation:', existingUser.userId, 'permissions:', grant.permissions)
    }

    // Issue a fresh attestation for the restored user
    const freshAttestation = PermissionAttestation.create({
      user: existingUser,
      userPublicKey: publicKey,
      identityMethod: grant.identityMethod,
      identityId: grant.identityId,
      serverIdentity
    })

    json(res, {
      success: true,
      userId: existingUser.userId,
      permissions: grant.permissions,
      attestation: freshAttestation,
      message: 'Permissions restored from attestation'
    })
  } catch (err) {
    console.error('[API] Restore from attestation error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Helper: create an attestation for a user if server identity is available.
 * Returns the attestation object or null.
 */
async function createAttestationForUser (server, user, identityId) {
  const { serverIdentity } = server.p2p
  const { authManager } = server.auth
  if (!serverIdentity) return null

  try {
    const result = await authManager.getUserWithIdentities(identityId)
    if (!result) return null

    const identity = result.identities.find(i => i.identityId === identityId) || result.identities[0]
    const userPublicKey = identity?.publicKey || identity?.credentials?.publicKey || identityId

    return PermissionAttestation.create({
      user: result.user,
      userPublicKey,
      identityMethod: identity?.method || 'web-identity',
      identityId: identity?.identityId || identityId,
      serverIdentity
    })
  } catch {
    return null
  }
}

/**
 * Debug endpoint - shows auth system state (no auth required)
 */
async function handleAuthDebug(req, res, server) {
  const { authManager } = server.auth
  // Require admin permission
  const isAdmin = req.auth?.permissions?.admin ||
                  req.auth?.user?.globalPermissions?.includes('admin')
  if (!isAdmin) {
    forbidden(res, 'Admin permission required')
    return
  }

  try {
    // Get challenge stats
    const challengeStats = await authManager.challengeManager.stats()

    // Get recent challenges from cache
    const recentChallenges = []
    for (const [id, record] of authManager.challengeManager.cache.entries()) {
      recentChallenges.push({
        id: id.substring(0, 16) + '...',
        expiresAt: new Date(record.expiresAt).toISOString(),
        used: record.used,
        context: record.context,
        ageMs: Date.now() - record.createdAt
      })
    }

    // Count users and identities
    let userCount = 0
    let identityCount = 0
    const registry = authManager.identityRegistry

    for await (const entry of registry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
      userCount++
    }
    for await (const entry of registry.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
      identityCount++
    }

    // Check request headers
    const headers = {
      publicKey: req.headers['x-wirecore-publickey']?.substring(0, 20) + '...' || null,
      hasSignature: !!req.headers['x-wirecore-signature'],
      challengeId: req.headers['x-wirecore-challenge']?.substring(0, 16) + '...' || null
    }

    json(res, {
      status: 'ok',
      timestamp: new Date().toISOString(),
      headers,
      challenges: challengeStats,
      recentChallenges: recentChallenges.slice(0, 10),
      registry: {
        users: userCount,
        identities: identityCount
      }
    })
  } catch (err) {
    console.error('[API] Debug error:', err)
    json(res, { error: err.message, stack: err.stack }, 500)
  }
}

/**
 * Debug user lookup by public key
 */
async function handleDebugUserLookup(req, res, server) {
  const { authManager } = server.auth
  // Require admin permission
  const isAdmin = req.auth?.permissions?.admin ||
                  req.auth?.user?.globalPermissions?.includes('admin')
  if (!isAdmin) {
    forbidden(res, 'Admin permission required')
    return
  }

  try {
    const pubkey = decodeURIComponent(req.params.pubkey)
    console.log('[Debug] Looking up pubkey:', pubkey)

    const registry = authManager.identityRegistry

    // Get identity by public key
    const identity = await registry.getIdentityByPublicKey(pubkey)

    if (!identity) {
      json(res, { error: 'Identity not found', pubkey })
      return
    }

    // Get user
    const user = await registry.getUser(identity.userId)

    // Get all identities for this user
    let allIdentities = []
    try {
      allIdentities = await registry.listUserIdentities(identity.userId)
    } catch (err) {
      console.error('[Debug] listUserIdentities error:', err)
    }

    // Also scan for any identities pointing to this userId
    const foundIdentities = []
    for await (const entry of registry.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
      if (entry.value.userId === identity.userId) {
        foundIdentities.push({
          key: entry.key,
          ...entry.value
        })
      }
    }

    // Check pubkey index
    const pubkeyEntries = []
    for await (const entry of registry.db.createReadStream({ gte: 'pubkey:', lt: 'pubkey:\xff' })) {
      if (entry.value.userId === identity.userId) {
        pubkeyEntries.push({
          key: entry.key,
          ...entry.value
        })
      }
    }

    json(res, {
      pubkey,
      identity,
      user,
      userIdentitiesArray: user?.identities || [],
      listUserIdentitiesResult: allIdentities,
      foundIdentitiesInDb: foundIdentities,
      pubkeyEntries
    })
  } catch (err) {
    console.error('[API] Debug user lookup error:', err)
    json(res, { error: err.message, stack: err.stack }, 500)
  }
}

// ============================================
// Bootstrap Handlers (localhost-only, no auth)
// ============================================

/**
 * Promote a browser identity to admin — localhost only, no token required
 * Solves the chicken-and-egg problem on fresh servers
 */
async function handleBootstrapPromote(req, res, server) {
  const { bindAddresses } = server.config
  const { authManager } = server.auth
  // Localhost check: bare-http1 doesn't expose req.socket.remoteAddress,
  // so also check if the server is bound to localhost (only local connections possible)
  const socketIp = req.socket?.remoteAddress || ''
  const localhostIps = ['127.0.0.1', '::1', '::ffff:127.0.0.1', '']
  const serverIsLocalOnly = bindAddresses.every(a => a === 'localhost' || a === '127.0.0.1' || a === '::1')
  const isLocal = localhostIps.includes(socketIp) || serverIsLocalOnly

  if (!isLocal) {
    console.log('[Bootstrap] Rejected non-local promote from:', socketIp || 'unknown')
    forbidden(res, 'Bootstrap promote is localhost-only')
    return
  }

  try {
    const body = await readJson(req)
    const { publicKey, userId } = body

    if (!publicKey && !userId) {
      badRequest(res, 'publicKey or userId is required')
      return
    }

    const registry = authManager.identityRegistry
    let user

    if (userId) {
      // Direct user lookup by userId
      user = await registry.getUser(userId)
      if (!user) {
        notFound(res, 'User not found for userId: ' + userId.substring(0, 8) + '...')
        return
      }
      console.log('[Bootstrap] Promoting by userId:', userId.substring(0, 8) + '...')
    } else {
      // Lookup by publicKey (original flow)
      let identity = await registry.getIdentityByPublicKey(publicKey)

      if (!identity) {
        // Identity not in DB yet — register it now (handles pre-fix generate endpoints)
        const result = await registry.autoRegister({
          identityId: publicKey,
          method: 'web-identity',
          credentials: { publicKey }
        })
        identity = result.identity
        user = result.user
        console.log('[Bootstrap] Auto-registered identity:', publicKey.substring(0, 8) + '...')
      } else {
        if (!identity.userId) {
          badRequest(res, 'Identity has no associated user')
          return
        }
        user = await registry.getUser(identity.userId)
        if (!user) {
          notFound(res, 'User not found')
          return
        }
      }
    }

    // Promote to admin via Autobase
    const adminPerms = ['admin', 'read', 'write']
    const updatedRepos = [...(user.repos || [])]
    const wildcardRepo = updatedRepos.find(r => r.name === '*')
    if (wildcardRepo) {
      wildcardRepo.permissions = adminPerms
    } else {
      updatedRepos.push({ name: '*', permissions: adminPerms })
    }

    user = await registry.updateUserProfile(user.userId, {
      globalPermissions: adminPerms,
      repos: updatedRepos
    })

    console.log('[Bootstrap] Promoted user to admin:', user.userId)

    // Create bearer token for the new admin (localhost-only, so trusted)
    let token = null
    let tokenHash = null
    if (authManager.bearerProvider) {
      try {
        const tokenResult = await authManager.bearerProvider.createToken({
          username: user.name || 'admin',
          description: 'Bootstrap admin token',
          permissions: adminPerms,
          repos: ['*']
        })
        token = tokenResult.token
        tokenHash = tokenResult.tokenHash
        console.log('[Bootstrap] Created admin token:', tokenHash.slice(0, 8) + '...')
      } catch (err) {
        console.error('[Bootstrap] Token creation failed:', err.message)
      }
    }

    // Include attestation if server identity is available
    const identityId = publicKey || (user.identities && user.identities[0])
    const attestation = identityId
      ? await createAttestationForUser(server, user, identityId)
      : null

    json(res, {
      success: true,
      userId: user.userId,
      globalPermissions: user.globalPermissions,
      token,
      tokenHash,
      attestation
    })
  } catch (err) {
    console.error('[Bootstrap] Promote error:', err)
    json(res, { error: err.message }, 500)
  }
}

// ============================================
// Account Merge Handlers
// ============================================

/**
 * Initiate account merge - creates merge request
 * Called by primary account (the one to keep)
 */

export {
  handleGenerateIdentity,
  handleCreateIdentity,
  handleUsernameAvailable,
  handleLinkSSHKey,
  handleListIdentities,
  handleUpdateProfile,
  handleGenerateChallenge,
  handleGetAttestation,
  handleRestoreFromAttestation,
  createAttestationForUser,
}