OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth / providers / web-identity.js

86 lines · javascript ref:
import { BaseProvider } from '../base-provider.js'
import { ANProto } from '../../anproto.js'

/**
 * WebIdentityProvider — Browser ANProto authentication
 *
 * Handles Ed25519 challenge-response auth for web clients.
 *
 * Options:
 *   autoRegister: true  — auto-create user on first auth (default, pear-git/hypernote behavior)
 *   autoRegister: false — reject unknown keys, require explicit registration
 */
export class WebIdentityProvider extends BaseProvider {
  constructor (identityRegistry, challengeManager, opts = {}) {
    super('web-identity')
    this.registry = identityRegistry
    this.challengeManager = challengeManager
    this.autoRegister = opts.autoRegister !== false // default: true
  }

  async extractCredentials (req) {
    const publicKey = req.headers['x-wirecore-publickey'] || req.headers['x-identity-publickey']
    if (!publicKey) return null

    const signature = req.headers['x-wirecore-signature'] || req.headers['x-identity-signature']
    const challengeId = req.headers['x-wirecore-challenge'] || req.headers['x-identity-challenge']

    return { publicKey, signature, challengeId }
  }

  async authenticate (credentials) {
    const { publicKey, signature, challengeId } = credentials

    // Look up identity — existing users may have already proven key ownership
    let identity = await this.registry.getIdentityByPublicKey(publicKey)

    // Verify signature if provided (challenge-response proves key ownership)
    let signatureVerified = false
    if (signature && challengeId) {
      const challenge = await this.challengeManager.get(challengeId)
      if (!challenge) throw new Error('Invalid or expired challenge')

      const result = ANProto.verify(challenge.challenge, signature)
      if (!result.valid) throw new Error('Invalid signature — ' + (result.error || 'verification failed'))
      // Canonical format embeds the public key — verify it matches the claimed key
      if (result.publicKey !== publicKey) throw new Error('Signature public key does not match claimed key')

      await this.challengeManager.consume(challengeId)
      signatureVerified = true
    }

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

    if (!identity) {
      // SECURITY: New identities MUST prove key ownership via signature.
      // Without this check, anyone could register any public key they don't own.
      if (!signatureVerified) {
        throw new Error('Signature required for new identity registration')
      }

      if (!this.autoRegister) {
        throw new Error('Unknown identity. Register first.')
      }

      const result = await this.registry.autoRegister({
        identityId: publicKey,
        method: 'web-identity',
        credentials: { publicKey }
      })
      identity = result.identity
    }

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

export default WebIdentityProvider