OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / auth / providers / web-identity.js

198 lines · javascript ref:
import { AuthProvider } from '../base-provider.js'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { ANProto } from '../../anproto.js'

/**
 * WebIdentityProvider
 *
 * Implements wirecore-style browser identity with signature verification:
 * - Auto-generates ANProto ed25519 keypair on first visit
 * - Stores keypair in browser localStorage
 * - Uses challenge-response with ed25519 signatures
 * - New identities get read-only permissions by default
 */
const DEBUG = typeof process !== 'undefined' && process.env?.DEBUG

export class WebIdentityProvider extends AuthProvider {
  constructor(registry, challengeManager) {
    super(registry)
    this.name = 'web-identity'
    this.challengeManager = challengeManager
  }

  /**
   * Extract credentials from request headers or URL parameters
   * @param {Object} req - HTTP request
   * @returns {Promise<Object|null>} Credentials or null
   */
  async extractCredentials(req) {
    let publicKey = req.headers['x-wirecore-publickey']
    let signature = req.headers['x-wirecore-signature']
    let challengeId = req.headers['x-wirecore-challenge']

    // Also check URL parameters (for page navigations)
    if (!publicKey && req.url) {
      try {
        const url = new URL(req.url, 'http://localhost')
        publicKey = url.searchParams.get('publicKey')
        signature = url.searchParams.get('signature')
        challengeId = url.searchParams.get('challengeId')
      } catch (err) {
        if (DEBUG) console.error('[WebIdentity] URL parsing error:', err.message)
      }
    }

    // If no signature/challenge, fall back to public key only (for backwards compat)
    // SECURITY: Legacy mode only allows EXISTING identities, not auto-registration
    if (!signature && !challengeId) {
      if (!publicKey || publicKey.length !== 44) return null
      if (DEBUG) console.warn('[WebIdentity] Legacy auth (no signature) for:', publicKey.substring(0, 8) + '...')
      return { publicKey, legacy: true }
    }

    // New flow: require all three
    if (!publicKey || !signature || !challengeId) {
      return null
    }

    // Validate format
    if (publicKey.length !== 44) {
      if (DEBUG) console.warn('[WebIdentity] Invalid public key format')
      return null
    }

    if (signature.length !== 88) {
      if (DEBUG) console.warn('[WebIdentity] Invalid signature format')
      return null
    }

    return { publicKey, signature, challengeId }
  }

  /**
   * Authenticate using signed challenge
   * @param {Object} credentials - { publicKey, signature, challengeId, legacy }
   * @returns {Promise<Object>} { identityId, method, verified, credentials }
   */
  async authenticate(credentials) {
    const { publicKey, signature, challengeId, legacy } = credentials

    if (legacy) {
      return this.authenticateLegacy(publicKey)
    }

    // Verify challenge exists
    const challenge = await this.challengeManager.get(challengeId)
    if (!challenge) {
      throw new Error('Invalid or expired challenge')
    }

    // Verify signature
    const isValid = ANProto.verify(challenge.challenge, signature, publicKey)
    if (!isValid) {
      throw new Error('Invalid signature')
    }

    // Consume challenge (prevent replay)
    await this.challengeManager.consume(challengeId)

    // Check if identity exists
    let identity = await this.registry.getIdentityByPublicKey(publicKey)

    if (!identity) {
      // Auto-register new identity
      const { user, identity: newIdentity } = await this.registry.autoRegister({
        identityId: publicKey,
        method: 'web-identity',
        credentials: { publicKey }
      })

      if (DEBUG) console.log(`[WebIdentity] Auto-registered: ${publicKey.substring(0, 8)}...`)

      return {
        identityId: newIdentity.identityId,
        method: 'web-identity',
        verified: true,
        credentials: { publicKey }
      }
    }

    // Check if identity is revoked
    const isRevoked = await this.registry.isRevoked(identity.identityId)
    if (isRevoked) {
      throw new Error('Web identity has been revoked')
    }

    return {
      identityId: identity.identityId,
      method: 'web-identity',
      verified: true,
      credentials: { publicKey }
    }
  }

  /**
   * Legacy authentication (backwards compat - no signature)
   *
   * SECURITY FIX: Legacy mode only allows EXISTING identities.
   * New identities MUST use signed authentication to prevent
   * anyone from claiming arbitrary public keys.
   *
   * @param {string} publicKey - Public key
   * @returns {Promise<Object>}
   */
  async authenticateLegacy(publicKey) {
    // Check if identity exists
    let identity = null
    try {
      identity = await this.registry.getIdentityByPublicKey(publicKey)
    } catch (err) {
      if (DEBUG) console.error('[WebIdentity] getIdentityByPublicKey error:', err.message)
    }

    if (!identity) {
      throw new Error('Signature required for new identities. Please refresh the page to enable signed authentication.')
    }

    // Check if identity is revoked
    const isRevoked = await this.registry.isRevoked(identity.identityId)
    if (isRevoked) {
      throw new Error('Web identity has been revoked')
    }

    // Legacy auth allowed for existing identities only
    // Mark as unverified to distinguish from signed auth

    return {
      identityId: identity.identityId,
      method: 'web-identity',
      verified: false,  // Mark as unverified since no signature
      legacy: true,     // Flag legacy mode for audit/logging
      credentials: { publicKey }
    }
  }

  /**
   * Generate new ANProto ed25519 keypair
   * @param {Object} options - Generation options
   * @returns {Promise<Object>} { publicKey, secretKey, keypair }
   */
  async generateIdentity(options = {}) {
    // Generate ed25519 keypair using hypercore-crypto (use return form)
    const seed = crypto.randomBytes(32)
    const keypairObj = crypto.keyPair(seed)

    // Format as ANProto (base64)
    const publicKeyB64 = b4a.toString(keypairObj.publicKey, 'base64')
    const secretKeyB64 = b4a.toString(keypairObj.secretKey, 'base64')
    const keypair = publicKeyB64 + secretKeyB64  // 132 chars total

    return {
      publicKey: publicKeyB64,  // 44 chars
      secretKey: secretKeyB64,  // 88 chars
      keypair: keypair          // 132 chars
    }
  }
}