import { AuthProvider } from '../base-provider.js'
import { ANProto } from '../../anproto.js'
import { UnauthorizedError } from '../../errors.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 UnauthorizedError('Invalid or expired challenge', 'web-identity')
}
// Verify signature
const isValid = ANProto.verify(challenge.challenge, signature, publicKey)
if (!isValid) {
throw new UnauthorizedError('Invalid signature', 'web-identity')
}
// 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 UnauthorizedError('Web identity has been revoked', 'web-identity')
}
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 UnauthorizedError('Signature required for new identities. Please refresh the page to enable signed authentication.', 'web-identity')
}
// Check if identity is revoked
const isRevoked = await this.registry.isRevoked(identity.identityId)
if (isRevoked) {
throw new UnauthorizedError('Web identity has been revoked', 'web-identity')
}
// 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 }
}
}
}