| … | … | @@ -0,0 +1,37 @@ |
| 1 | /** | |
| 2 | * hyper-identity — Generic P2P identity management | |
| 3 | * | |
| 4 | * Provides multi-method authentication (ANProto, BIP39 mnemonic, bearer tokens, SSH keys), | |
| 5 | * Autobase-backed identity registry, device linking, and encrypted storage. | |
| 6 | * | |
| 7 | * Extracted from pear-git's battle-tested identity backend, genericized | |
| 8 | * with injectable permission model (resources instead of repos/notebooks). | |
| 9 | */ | |
| 10 | ||
| 11 | // Core authentication | |
| 12 | export { AuthManager, Permissions, sendAuthError } from './lib/auth.js' | |
| 13 | ||
| 14 | // ANProto protocol | |
| 15 | export { ANProto } from './lib/anproto.js' | |
| 16 | ||
| 17 | // Identity management | |
| 18 | export { IdentityManager } from './lib/identity-manager.js' | |
| 19 | export { DeviceLinker } from './lib/device-linker.js' | |
| 20 | export { SecureStorage } from './lib/secure-storage.js' | |
| 21 | ||
| 22 | // Autobase identity registry | |
| 23 | export { IdentityRegistry } from './lib/auth/identity-registry.js' | |
| 24 | export { createApplyFunction } from './lib/auth/identity-apply.js' | |
| 25 | export { OpTypes, encodeOp, decodeOp, validateOp } from './lib/auth/identity-operations.js' | |
| 26 | ||
| 27 | // Auth providers | |
| 28 | export { BaseProvider } from './lib/auth/base-provider.js' | |
| 29 | export { WebIdentityProvider } from './lib/auth/providers/web-identity.js' | |
| 30 | export { KeetIdentityProvider } from './lib/auth/providers/keet-identity.js' | |
| 31 | export { BearerTokenProvider } from './lib/auth/providers/bearer-token.js' | |
| 32 | export { SSHKeyProvider } from './lib/auth/providers/ssh-key.js' | |
| 33 | ||
| 34 | // Auth helpers | |
| 35 | export { ChallengeManager } from './lib/auth/challenge-manager.js' | |
| 36 | export { RateLimiter } from './lib/auth/rate-limiter.js' | |
| 37 | export { SSHKeyParser } from './lib/auth/ssh-parser.js' |
| … | … | @@ -0,0 +1,67 @@ |
| 1 | import crypto from 'hypercore-crypto' | |
| 2 | import b4a from 'b4a' | |
| 3 | ||
| 4 | /** | |
| 5 | * ANProto — Ed25519 challenge-response authentication protocol | |
| 6 | * | |
| 7 | * Used for browser-based identity verification. | |
| 8 | * Generates keypairs, signs challenges, verifies signatures. | |
| 9 | */ | |
| 10 | export class ANProto { | |
| 11 | /** | |
| 12 | * Generate a new Ed25519 keypair | |
| 13 | * @returns {{ publicKey: Buffer, secretKey: Buffer }} | |
| 14 | */ | |
| 15 | static gen () { | |
| 16 | return crypto.keyPair() | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * Load keypair from hex-encoded keys | |
| 21 | * @param {{ publicKey: string, secretKey: string }} keys - Hex-encoded keys | |
| 22 | * @returns {{ publicKey: Buffer, secretKey: Buffer }} | |
| 23 | */ | |
| 24 | static loadKeypair (keys) { | |
| 25 | return { | |
| 26 | publicKey: b4a.from(keys.publicKey, 'hex'), | |
| 27 | secretKey: b4a.from(keys.secretKey, 'hex') | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * Sign a challenge with secret key | |
| 33 | * @param {Buffer|string} challenge - Challenge to sign | |
| 34 | * @param {Buffer} secretKey - Secret key | |
| 35 | * @returns {string} Base64-encoded signature | |
| 36 | */ | |
| 37 | static sign (challenge, secretKey) { | |
| 38 | const msg = typeof challenge === 'string' ? b4a.from(challenge) : challenge | |
| 39 | const sig = crypto.sign(msg, secretKey) | |
| 40 | return b4a.toString(sig, 'base64') | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * Verify a signature against a public key | |
| 45 | * @param {Buffer|string} challenge - Original challenge | |
| 46 | * @param {string} signature - Base64-encoded signature | |
| 47 | * @param {Buffer|string} publicKey - Public key (Buffer or base64) | |
| 48 | * @returns {boolean} | |
| 49 | */ | |
| 50 | static verify (challenge, signature, publicKey) { | |
| 51 | const msg = typeof challenge === 'string' ? b4a.from(challenge) : challenge | |
| 52 | const sig = b4a.from(signature, 'base64') | |
| 53 | const pk = typeof publicKey === 'string' ? b4a.from(publicKey, 'base64') : publicKey | |
| 54 | return crypto.verify(msg, sig, pk) | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Get hex-encoded public key | |
| 59 | * @param {Buffer} publicKey - Public key buffer | |
| 60 | * @returns {string} | |
| 61 | */ | |
| 62 | static getPublicKey (publicKey) { | |
| 63 | return b4a.toString(publicKey, 'hex') | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | export default ANProto |
| … | … | @@ -0,0 +1,383 @@ |
| 1 | import { IdentityRegistry } from './auth/identity-registry.js' | |
| 2 | import { WebIdentityProvider } from './auth/providers/web-identity.js' | |
| 3 | import { BearerTokenProvider } from './auth/providers/bearer-token.js' | |
| 4 | import { KeetIdentityProvider } from './auth/providers/keet-identity.js' | |
| 5 | import { SSHKeyProvider } from './auth/providers/ssh-key.js' | |
| 6 | import { ChallengeManager } from './auth/challenge-manager.js' | |
| 7 | import { RateLimiter } from './auth/rate-limiter.js' | |
| 8 | ||
| 9 | /** | |
| 10 | * AuthManager — Generic multi-method authentication and authorization | |
| 11 | * | |
| 12 | * Supports: | |
| 13 | * - Web Identity (ANProto Ed25519 challenge-response) | |
| 14 | * - Keet Identity (BIP39 mnemonic proof chains) | |
| 15 | * - Bearer Tokens (API tokens with embedded permissions) | |
| 16 | * - SSH Keys (Ed25519 + RSA signature verification) | |
| 17 | * | |
| 18 | * Permission model is injectable via checkPermission callback. | |
| 19 | * Uses 'resources' as the generic term (not repos, notebooks, etc). | |
| 20 | */ | |
| 21 | export class AuthManager { | |
| 22 | constructor (options = {}) { | |
| 23 | this.db = options.db || null // Direct-write Hyperbee for identity/token storage | |
| 24 | this.trustedProxies = options.trustedProxies || ['127.0.0.1', '::1', '::ffff:127.0.0.1'] | |
| 25 | ||
| 26 | // Injectable permission check: (auth, action, resourceName) => boolean | |
| 27 | // Default: check auth.permissions object | |
| 28 | this.checkPermission = options.checkPermission || null | |
| 29 | ||
| 30 | // Identity registry (Autobase-backed) | |
| 31 | this.identityRegistry = options.identityRegistry || null | |
| 32 | ||
| 33 | // Challenge manager | |
| 34 | this.challengeManager = new ChallengeManager({ | |
| 35 | db: this.db, | |
| 36 | ttl: options.challengeTTL || 5 * 60 * 1000, | |
| 37 | cacheTimeout: options.challengeCacheTimeout || 30 * 1000 | |
| 38 | }) | |
| 39 | ||
| 40 | // Rate limiter | |
| 41 | this.rateLimiter = new RateLimiter({ | |
| 42 | db: this.db, | |
| 43 | limits: options.rateLimits || { | |
| 44 | auth: { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 }, | |
| 45 | api: { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 }, | |
| 46 | 'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 } | |
| 47 | }, | |
| 48 | cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000 | |
| 49 | }) | |
| 50 | ||
| 51 | // Auth providers | |
| 52 | this.providers = [] | |
| 53 | ||
| 54 | // Register default providers based on what's available | |
| 55 | if (this.identityRegistry) { | |
| 56 | this.providers.push( | |
| 57 | new WebIdentityProvider(this.identityRegistry, this.challengeManager) | |
| 58 | ) | |
| 59 | ||
| 60 | if (options.IdentityKey) { | |
| 61 | this.providers.push( | |
| 62 | new KeetIdentityProvider(this.identityRegistry, { IdentityKey: options.IdentityKey }) | |
| 63 | ) | |
| 64 | } | |
| 65 | ||
| 66 | this.providers.push( | |
| 67 | new BearerTokenProvider(this.identityRegistry, { db: this.db }) | |
| 68 | ) | |
| 69 | ||
| 70 | if (options.enableSSH !== false) { | |
| 71 | this.providers.push( | |
| 72 | new SSHKeyProvider(this.identityRegistry, this.challengeManager) | |
| 73 | ) | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | // Get bearer token provider reference | |
| 78 | this.bearerProvider = this.providers.find(p => p.name === 'bearer-token') || null | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Add a custom auth provider | |
| 83 | * @param {BaseProvider} provider | |
| 84 | */ | |
| 85 | addProvider (provider) { | |
| 86 | this.providers.push(provider) | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Authenticate request using all available providers | |
| 91 | * @param {Object} req - HTTP request | |
| 92 | * @param {Object} options - { skipRateLimit, operation } | |
| 93 | * @returns {Promise<Object|null>} Auth result or null | |
| 94 | */ | |
| 95 | async authenticate (req, options = {}) { | |
| 96 | // Rate limiting | |
| 97 | if (!options.skipRateLimit) { | |
| 98 | const clientIp = this.getClientIp(req) | |
| 99 | const operation = options.operation || 'auth' | |
| 100 | const rateLimit = await this.rateLimiter.check(clientIp, operation) | |
| 101 | ||
| 102 | if (!rateLimit.allowed) { | |
| 103 | const error = new Error('Rate limit exceeded') | |
| 104 | error.code = 'RATE_LIMIT' | |
| 105 | error.retryAfter = rateLimit.retryAfter | |
| 106 | error.resetAt = rateLimit.resetAt | |
| 107 | throw error | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | // Try each provider | |
| 112 | for (const provider of this.providers) { | |
| 113 | const credentials = await provider.extractCredentials(req) | |
| 114 | if (!credentials) continue | |
| 115 | ||
| 116 | try { | |
| 117 | const authResult = await provider.authenticate(credentials) | |
| 118 | ||
| 119 | // Bearer tokens have permissions embedded | |
| 120 | if (authResult.method === 'bearer-token') { | |
| 121 | return { | |
| 122 | userId: null, | |
| 123 | identityId: authResult.identityId, | |
| 124 | method: authResult.method, | |
| 125 | username: authResult.credentials.username, | |
| 126 | permissions: this.formatTokenPermissions(authResult), | |
| 127 | verified: true | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | // For other methods, look up user via identity | |
| 132 | let user = await this.identityRegistry.getUserByIdentity(authResult.identityId) | |
| 133 | ||
| 134 | if (!user && authResult.method === 'web-identity') { | |
| 135 | // Retry — web identity auto-registers | |
| 136 | user = await this.identityRegistry.getUserByIdentity(authResult.identityId) | |
| 137 | } | |
| 138 | ||
| 139 | if (!user) continue | |
| 140 | ||
| 141 | // Auto-promote to admin if no admin exists (bootstrap) | |
| 142 | const hasAdmin = await this.identityRegistry._hasAnyAdmin() | |
| 143 | if (!hasAdmin && user.globalPermissions && !user.globalPermissions.includes('admin')) { | |
| 144 | await this.identityRegistry.upgradePermissions(user.userId, '*', ['read', 'write', 'admin']) | |
| 145 | const upgraded = await this.identityRegistry.getUser(user.userId) | |
| 146 | if (upgraded) user = upgraded | |
| 147 | } | |
| 148 | ||
| 149 | return { | |
| 150 | userId: user.userId, | |
| 151 | identityId: authResult.identityId, | |
| 152 | method: authResult.method, | |
| 153 | permissions: this.formatUserPermissions(user, null), | |
| 154 | user, | |
| 155 | verified: true | |
| 156 | } | |
| 157 | } catch { | |
| 158 | continue | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | return null | |
| 163 | } | |
| 164 | ||
| 165 | /** | |
| 166 | * Format user permissions | |
| 167 | * Uses user.resources (generic) instead of application-specific terms | |
| 168 | * @param {Object} user - User object | |
| 169 | * @param {string} resourceName - Resource name (or null for global) | |
| 170 | * @returns {Object} { read, write, admin, resources } | |
| 171 | */ | |
| 172 | formatUserPermissions (user, resourceName) { | |
| 173 | // Check resource-specific permissions | |
| 174 | const resourcePerms = (user.resources || []).find( | |
| 175 | r => r.name === resourceName || r.name === '*' | |
| 176 | ) | |
| 177 | ||
| 178 | if (resourcePerms) { | |
| 179 | return { | |
| 180 | read: resourcePerms.permissions.includes('read') || resourcePerms.permissions.includes('admin'), | |
| 181 | write: resourcePerms.permissions.includes('write') || resourcePerms.permissions.includes('admin'), | |
| 182 | admin: resourcePerms.permissions.includes('admin'), | |
| 183 | resources: [resourcePerms.name] | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | // Fall back to global permissions | |
| 188 | return { | |
| 189 | read: user.globalPermissions.includes('read') || user.globalPermissions.includes('admin'), | |
| 190 | write: user.globalPermissions.includes('write') || user.globalPermissions.includes('admin'), | |
| 191 | admin: user.globalPermissions.includes('admin'), | |
| 192 | resources: ['*'] | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Format bearer token permissions | |
| 198 | */ | |
| 199 | formatTokenPermissions (authResult) { | |
| 200 | return { | |
| 201 | read: authResult.permissions.includes('read') || authResult.permissions.includes('admin'), | |
| 202 | write: authResult.permissions.includes('write') || authResult.permissions.includes('admin'), | |
| 203 | admin: authResult.permissions.includes('admin'), | |
| 204 | resources: authResult.resources || ['*'] | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * Check if auth has permission for action on resource | |
| 210 | * Uses injectable checkPermission callback if provided. | |
| 211 | * @param {Object} auth - Auth result from authenticate() | |
| 212 | * @param {string} resourceName - Resource name | |
| 213 | * @param {string} action - 'read', 'write', or 'admin' | |
| 214 | * @returns {boolean} | |
| 215 | */ | |
| 216 | hasResourceAccess (auth, resourceName, action = 'read') { | |
| 217 | if (!auth || !auth.verified) return false | |
| 218 | ||
| 219 | // Use custom permission check if provided | |
| 220 | if (this.checkPermission) { | |
| 221 | return this.checkPermission(auth, action, resourceName) | |
| 222 | } | |
| 223 | ||
| 224 | // Default permission check | |
| 225 | const perms = auth.permissions | |
| 226 | if (perms.admin) return true | |
| 227 | if (!perms[action]) return false | |
| 228 | ||
| 229 | if (resourceName) { | |
| 230 | return perms.resources.includes('*') || perms.resources.includes(resourceName) | |
| 231 | } | |
| 232 | return true | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Middleware: authenticate and authorize request | |
| 237 | * @param {Object} req - HTTP request | |
| 238 | * @param {string} action - Required action | |
| 239 | * @param {string} resourceName - Resource name | |
| 240 | * @returns {Promise<Object>} { authorized, auth, reason } | |
| 241 | */ | |
| 242 | async authorizeRequest (req, action, resourceName = null) { | |
| 243 | const auth = await this.authenticate(req) | |
| 244 | if (!auth) { | |
| 245 | return { authorized: false, auth: null, reason: 'Not authenticated' } | |
| 246 | } | |
| 247 | if (!this.hasResourceAccess(auth, resourceName, action)) { | |
| 248 | return { authorized: false, auth, reason: 'Insufficient permissions' } | |
| 249 | } | |
| 250 | return { authorized: true, auth, reason: null } | |
| 251 | } | |
| 252 | ||
| 253 | // ==================== TOKEN MANAGEMENT ==================== | |
| 254 | ||
| 255 | async createToken (options) { | |
| 256 | if (!this.bearerProvider) throw new Error('Bearer token provider not configured') | |
| 257 | return this.bearerProvider.createToken(options) | |
| 258 | } | |
| 259 | ||
| 260 | async validateToken (token) { | |
| 261 | if (!this.bearerProvider) return null | |
| 262 | return this.bearerProvider.validateToken(token) | |
| 263 | } | |
| 264 | ||
| 265 | async revokeToken (tokenHash) { | |
| 266 | if (!this.bearerProvider) return | |
| 267 | return this.bearerProvider.revokeToken(tokenHash) | |
| 268 | } | |
| 269 | ||
| 270 | async listAllTokens () { | |
| 271 | if (!this.bearerProvider) return [] | |
| 272 | return this.bearerProvider.listAllTokens() | |
| 273 | } | |
| 274 | ||
| 275 | async getUserTokens (username) { | |
| 276 | if (!this.bearerProvider) return [] | |
| 277 | return this.bearerProvider.getUserTokens(username) | |
| 278 | } | |
| 279 | ||
| 280 | async createDefaultAdmin () { | |
| 281 | const tokens = await this.listAllTokens() | |
| 282 | if (tokens.length > 0) return null | |
| 283 | return this.createToken({ | |
| 284 | username: 'admin', | |
| 285 | description: 'Default admin token (generated on first start)', | |
| 286 | permissions: ['read', 'write', 'admin'], | |
| 287 | resources: ['*'] | |
| 288 | }) | |
| 289 | } | |
| 290 | ||
| 291 | async getStatus () { | |
| 292 | const tokens = await this.listAllTokens() | |
| 293 | const activeTokens = tokens.filter(t => !t.expiresAt || t.expiresAt > Date.now()) | |
| 294 | return { | |
| 295 | enabled: tokens.length > 0, | |
| 296 | totalTokens: tokens.length, | |
| 297 | activeTokens: activeTokens.length, | |
| 298 | users: [...new Set(tokens.map(t => t.username))].length | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | // ==================== IDENTITY METHODS ==================== | |
| 303 | ||
| 304 | async getUserWithIdentities (identityId) { | |
| 305 | const user = await this.identityRegistry.getUserByIdentity(identityId) | |
| 306 | if (!user) return null | |
| 307 | const identities = await this.identityRegistry.listUserIdentities(user.userId) | |
| 308 | return { user, identities } | |
| 309 | } | |
| 310 | ||
| 311 | async upgradeUserPermissions (userId, resourceName, permissions) { | |
| 312 | return this.identityRegistry.upgradePermissions(userId, resourceName, permissions) | |
| 313 | } | |
| 314 | ||
| 315 | async generateChallenge (options = {}) { | |
| 316 | return await this.challengeManager.generate(options) | |
| 317 | } | |
| 318 | ||
| 319 | async verifyChallenge (challengeId, challenge) { | |
| 320 | return await this.challengeManager.verify(challengeId, challenge) | |
| 321 | } | |
| 322 | ||
| 323 | async revokeIdentity (identityId, options = {}) { | |
| 324 | return this.identityRegistry.revokeIdentity(identityId, options) | |
| 325 | } | |
| 326 | ||
| 327 | async restoreIdentity (identityId) { | |
| 328 | return this.identityRegistry.restoreIdentity(identityId) | |
| 329 | } | |
| 330 | ||
| 331 | async listRevokedIdentities () { | |
| 332 | return this.identityRegistry.listRevokedIdentities() | |
| 333 | } | |
| 334 | ||
| 335 | getClientIp (req) { | |
| 336 | const socketIp = req.socket?.remoteAddress || 'unknown' | |
| 337 | const isTrustedProxy = this.trustedProxies.some( | |
| 338 | ip => socketIp === ip || socketIp === '::ffff:' + ip | |
| 339 | ) | |
| 340 | if (isTrustedProxy) { | |
| 341 | const forwarded = req.headers['x-forwarded-for'] | |
| 342 | if (forwarded) return forwarded.split(',')[0].trim() | |
| 343 | const realIp = req.headers['x-real-ip'] | |
| 344 | if (realIp) return realIp | |
| 345 | } | |
| 346 | return socketIp | |
| 347 | } | |
| 348 | ||
| 349 | async getRateLimitStats () { | |
| 350 | return await this.rateLimiter.stats() | |
| 351 | } | |
| 352 | ||
| 353 | async getChallengeStats () { | |
| 354 | return await this.challengeManager.stats() | |
| 355 | } | |
| 356 | ||
| 357 | destroy () { | |
| 358 | this.challengeManager.destroy() | |
| 359 | this.rateLimiter.destroy() | |
| 360 | } | |
| 361 | } | |
| 362 | ||
| 363 | /** | |
| 364 | * Permission constants | |
| 365 | */ | |
| 366 | export const Permissions = { | |
| 367 | READ: 'read', | |
| 368 | WRITE: 'write', | |
| 369 | ADMIN: 'admin' | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Send auth error response | |
| 374 | */ | |
| 375 | export function sendAuthError (res, message = 'Unauthorized', statusCode = 401) { | |
| 376 | res.statusCode = statusCode | |
| 377 | res.setHeader('WWW-Authenticate', 'Bearer realm="hyper-identity"') | |
| 378 | res.setHeader('Content-Type', 'application/json') | |
| 379 | res.end(JSON.stringify({ | |
| 380 | error: message, | |
| 381 | hint: 'Use Authorization: Bearer <token> or X-Identity-PublicKey header' | |
| 382 | })) | |
| 383 | } |
| … | … | @@ -0,0 +1,33 @@ |
| 1 | /** | |
| 2 | * BaseProvider — Abstract authentication provider | |
| 3 | * | |
| 4 | * All auth providers must implement: | |
| 5 | * - name: string identifier | |
| 6 | * - extractCredentials(req): Extract credentials from HTTP request | |
| 7 | * - authenticate(credentials): Verify credentials, return auth result | |
| 8 | */ | |
| 9 | export class BaseProvider { | |
| 10 | constructor (name) { | |
| 11 | this.name = name | |
| 12 | } | |
| 13 | ||
| 14 | /** | |
| 15 | * Extract credentials from HTTP request | |
| 16 | * @param {Object} req - HTTP request | |
| 17 | * @returns {Promise<Object|null>} Credentials or null if not applicable | |
| 18 | */ | |
| 19 | async extractCredentials (req) { | |
| 20 | throw new Error('Not implemented') | |
| 21 | } | |
| 22 | ||
| 23 | /** | |
| 24 | * Authenticate with extracted credentials | |
| 25 | * @param {Object} credentials - Extracted credentials | |
| 26 | * @returns {Promise<Object>} Auth result { identityId, method, credentials, permissions? } | |
| 27 | */ | |
| 28 | async authenticate (credentials) { | |
| 29 | throw new Error('Not implemented') | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | export default BaseProvider |
| … | … | @@ -0,0 +1,106 @@ |
| 1 | import crypto from 'hypercore-crypto' | |
| 2 | import b4a from 'b4a' | |
| 3 | ||
| 4 | /** | |
| 5 | * ChallengeManager — Challenge-response authentication | |
| 6 | * | |
| 7 | * Generates time-limited challenges for signature-based auth. | |
| 8 | * Supports persistent storage and in-memory caching. | |
| 9 | */ | |
| 10 | export class ChallengeManager { | |
| 11 | constructor (opts = {}) { | |
| 12 | this.db = opts.db || null | |
| 13 | this.ttl = opts.ttl || 5 * 60 * 1000 // 5 minutes default | |
| 14 | this.cacheTimeout = opts.cacheTimeout || 30 * 1000 // 30 seconds | |
| 15 | this._cache = new Map() | |
| 16 | this._cleanupInterval = setInterval(() => this._cleanup(), this.ttl) | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * Generate a new challenge | |
| 21 | * @param {Object} opts - { publicKey?, metadata? } | |
| 22 | * @returns {Promise<Object>} { challengeId, challenge, expiresAt } | |
| 23 | */ | |
| 24 | async generate (opts = {}) { | |
| 25 | const challengeId = b4a.toString(crypto.randomBytes(16), 'hex') | |
| 26 | const challenge = b4a.toString(crypto.randomBytes(32), 'hex') | |
| 27 | const expiresAt = Date.now() + this.ttl | |
| 28 | ||
| 29 | const entry = { | |
| 30 | challengeId, | |
| 31 | challenge, | |
| 32 | expiresAt, | |
| 33 | publicKey: opts.publicKey || null, | |
| 34 | metadata: opts.metadata || null, | |
| 35 | consumed: false | |
| 36 | } | |
| 37 | ||
| 38 | // Store persistently | |
| 39 | if (this.db) { | |
| 40 | await this.db.put(`challenge:${challengeId}`, entry) | |
| 41 | } | |
| 42 | ||
| 43 | // Cache in memory | |
| 44 | this._cache.set(challengeId, entry) | |
| 45 | ||
| 46 | return { challengeId, challenge, expiresAt } | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Verify and consume a challenge | |
| 51 | * @param {string} challengeId - Challenge ID | |
| 52 | * @param {string} challenge - Challenge value to verify | |
| 53 | * @returns {Promise<Object|null>} Challenge entry or null | |
| 54 | */ | |
| 55 | async verify (challengeId, challenge) { | |
| 56 | let entry = this._cache.get(challengeId) | |
| 57 | ||
| 58 | if (!entry && this.db) { | |
| 59 | const stored = await this.db.get(`challenge:${challengeId}`) | |
| 60 | if (stored) entry = stored.value | |
| 61 | } | |
| 62 | ||
| 63 | if (!entry) return null | |
| 64 | if (entry.consumed) return null | |
| 65 | if (entry.expiresAt < Date.now()) return null | |
| 66 | if (entry.challenge !== challenge) return null | |
| 67 | ||
| 68 | // Mark consumed | |
| 69 | entry.consumed = true | |
| 70 | this._cache.set(challengeId, entry) | |
| 71 | if (this.db) { | |
| 72 | await this.db.put(`challenge:${challengeId}`, entry) | |
| 73 | } | |
| 74 | ||
| 75 | return entry | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * Cleanup expired challenges | |
| 80 | */ | |
| 81 | async _cleanup () { | |
| 82 | const now = Date.now() | |
| 83 | for (const [id, entry] of this._cache) { | |
| 84 | if (entry.expiresAt < now || entry.consumed) { | |
| 85 | this._cache.delete(id) | |
| 86 | } | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Get stats | |
| 92 | */ | |
| 93 | async stats () { | |
| 94 | return { | |
| 95 | cached: this._cache.size, | |
| 96 | active: [...this._cache.values()].filter(e => !e.consumed && e.expiresAt > Date.now()).length | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | destroy () { | |
| 101 | clearInterval(this._cleanupInterval) | |
| 102 | this._cache.clear() | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | export default ChallengeManager |
| … | … | @@ -0,0 +1,236 @@ |
| 1 | import { decodeOp, OpTypes, validateOp } from './identity-operations.js' | |
| 2 | ||
| 3 | /** | |
| 4 | * Create the Autobase apply function for identity registry | |
| 5 | * | |
| 6 | * This is a deterministic function that processes identity operations | |
| 7 | * into a Hyperbee view. Each operation type modifies the view state. | |
| 8 | * | |
| 9 | * @param {Object} opts - Options | |
| 10 | * @param {string} opts.prefix - Key prefix for namespacing (default: '') | |
| 11 | * @returns {Function} Autobase apply function | |
| 12 | */ | |
| 13 | export function createApplyFunction (opts = {}) { | |
| 14 | const prefix = opts.prefix || '' | |
| 15 | ||
| 16 | return async function apply (batch, view, base) { | |
| 17 | const b = view.batch({ update: false }) | |
| 18 | ||
| 19 | try { | |
| 20 | for (const node of batch) { | |
| 21 | try { | |
| 22 | const op = decodeOp(node.value) | |
| 23 | const validation = validateOp(op) | |
| 24 | if (!validation.valid) { | |
| 25 | console.error(`[IdentityApply] Invalid op: ${validation.error}`) | |
| 26 | continue | |
| 27 | } | |
| 28 | ||
| 29 | switch (op.type) { | |
| 30 | case OpTypes.CREATE_USER: { | |
| 31 | const key = `${prefix}user:${op.userId}` | |
| 32 | const existing = await b.get(key) | |
| 33 | if (existing) continue // Don't overwrite | |
| 34 | ||
| 35 | const user = { | |
| 36 | userId: op.userId, | |
| 37 | username: op.username || null, | |
| 38 | name: op.name || 'Anonymous', | |
| 39 | email: op.email || null, | |
| 40 | profilePicture: op.profilePicture || null, | |
| 41 | globalPermissions: op.globalPermissions || ['read'], | |
| 42 | resources: op.resources || [{ name: '*', permissions: ['read'] }], | |
| 43 | identities: op.identities || [], | |
| 44 | createdAt: op.createdAt || Date.now(), | |
| 45 | updatedAt: op.updatedAt || Date.now() | |
| 46 | } | |
| 47 | await b.put(key, user) | |
| 48 | ||
| 49 | // Index identities | |
| 50 | for (const id of user.identities) { | |
| 51 | await b.put(`${prefix}user-identity:${id}`, { userId: op.userId }) | |
| 52 | } | |
| 53 | break | |
| 54 | } | |
| 55 | ||
| 56 | case OpTypes.UPDATE_USER: { | |
| 57 | const key = `${prefix}user:${op.userId}` | |
| 58 | const existing = await b.get(key) | |
| 59 | if (!existing) continue | |
| 60 | ||
| 61 | const user = existing.value | |
| 62 | if (op.username !== undefined) user.username = op.username | |
| 63 | if (op.name !== undefined) user.name = op.name | |
| 64 | if (op.email !== undefined) user.email = op.email | |
| 65 | if (op.profilePicture !== undefined) user.profilePicture = op.profilePicture | |
| 66 | if (op.globalPermissions !== undefined) user.globalPermissions = op.globalPermissions | |
| 67 | user.updatedAt = Date.now() | |
| 68 | await b.put(key, user) | |
| 69 | break | |
| 70 | } | |
| 71 | ||
| 72 | case OpTypes.DELETE_USER: { | |
| 73 | const key = `${prefix}user:${op.userId}` | |
| 74 | const existing = await b.get(key) | |
| 75 | if (!existing) continue | |
| 76 | ||
| 77 | // Remove identity index entries | |
| 78 | for (const id of (existing.value.identities || [])) { | |
| 79 | await b.del(`${prefix}user-identity:${id}`) | |
| 80 | } | |
| 81 | await b.del(key) | |
| 82 | break | |
| 83 | } | |
| 84 | ||
| 85 | case OpTypes.ADD_IDENTITY: { | |
| 86 | const key = `${prefix}user:${op.userId}` | |
| 87 | const existing = await b.get(key) | |
| 88 | if (!existing) continue | |
| 89 | ||
| 90 | const user = existing.value | |
| 91 | if (!user.identities.includes(op.identityId)) { | |
| 92 | user.identities.push(op.identityId) | |
| 93 | user.updatedAt = Date.now() | |
| 94 | await b.put(key, user) | |
| 95 | await b.put(`${prefix}user-identity:${op.identityId}`, { userId: op.userId }) | |
| 96 | } | |
| 97 | break | |
| 98 | } | |
| 99 | ||
| 100 | case OpTypes.REMOVE_IDENTITY: { | |
| 101 | const key = `${prefix}user:${op.userId}` | |
| 102 | const existing = await b.get(key) | |
| 103 | if (!existing) continue | |
| 104 | ||
| 105 | const user = existing.value | |
| 106 | user.identities = user.identities.filter(id => id !== op.identityId) | |
| 107 | user.updatedAt = Date.now() | |
| 108 | await b.put(key, user) | |
| 109 | await b.del(`${prefix}user-identity:${op.identityId}`) | |
| 110 | break | |
| 111 | } | |
| 112 | ||
| 113 | case OpTypes.LINK_IDENTITY: { | |
| 114 | await b.put(`${prefix}user-identity:${op.identityId}`, { userId: op.userId }) | |
| 115 | const key = `${prefix}user:${op.userId}` | |
| 116 | const existing = await b.get(key) | |
| 117 | if (existing) { | |
| 118 | const user = existing.value | |
| 119 | if (!user.identities.includes(op.identityId)) { | |
| 120 | user.identities.push(op.identityId) | |
| 121 | user.updatedAt = Date.now() | |
| 122 | await b.put(key, user) | |
| 123 | } | |
| 124 | } | |
| 125 | break | |
| 126 | } | |
| 127 | ||
| 128 | case OpTypes.UPGRADE_PERMISSIONS: { | |
| 129 | const key = `${prefix}user:${op.userId}` | |
| 130 | const existing = await b.get(key) | |
| 131 | if (!existing) continue | |
| 132 | ||
| 133 | const user = existing.value | |
| 134 | const resourceName = op.resourceName | |
| 135 | ||
| 136 | if (resourceName === '*') { | |
| 137 | // Global permissions | |
| 138 | user.globalPermissions = op.permissions | |
| 139 | } else { | |
| 140 | // Resource-specific permissions | |
| 141 | if (!user.resources) user.resources = [] | |
| 142 | const idx = user.resources.findIndex(r => r.name === resourceName) | |
| 143 | if (idx >= 0) { | |
| 144 | user.resources[idx].permissions = op.permissions | |
| 145 | } else { | |
| 146 | user.resources.push({ name: resourceName, permissions: op.permissions }) | |
| 147 | } | |
| 148 | } | |
| 149 | user.updatedAt = Date.now() | |
| 150 | await b.put(key, user) | |
| 151 | break | |
| 152 | } | |
| 153 | ||
| 154 | case OpTypes.MERGE_ACCOUNTS: { | |
| 155 | const primaryKey = `${prefix}user:${op.primaryUserId}` | |
| 156 | const secondaryKey = `${prefix}user:${op.secondaryUserId}` | |
| 157 | ||
| 158 | const primary = await b.get(primaryKey) | |
| 159 | const secondary = await b.get(secondaryKey) | |
| 160 | ||
| 161 | if (!primary || !secondary) continue | |
| 162 | ||
| 163 | const pUser = primary.value | |
| 164 | const sUser = secondary.value | |
| 165 | ||
| 166 | // Merge identities | |
| 167 | for (const id of sUser.identities) { | |
| 168 | if (!pUser.identities.includes(id)) { | |
| 169 | pUser.identities.push(id) | |
| 170 | } | |
| 171 | await b.put(`${prefix}user-identity:${id}`, { userId: op.primaryUserId }) | |
| 172 | } | |
| 173 | ||
| 174 | // Merge resources | |
| 175 | for (const res of (sUser.resources || [])) { | |
| 176 | const idx = (pUser.resources || []).findIndex(r => r.name === res.name) | |
| 177 | if (idx < 0) { | |
| 178 | if (!pUser.resources) pUser.resources = [] | |
| 179 | pUser.resources.push(res) | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | // Merge global permissions | |
| 184 | const allPerms = new Set([...pUser.globalPermissions, ...sUser.globalPermissions]) | |
| 185 | pUser.globalPermissions = [...allPerms] | |
| 186 | ||
| 187 | pUser.updatedAt = Date.now() | |
| 188 | await b.put(primaryKey, pUser) | |
| 189 | ||
| 190 | // Mark secondary as merged | |
| 191 | sUser.mergedInto = op.primaryUserId | |
| 192 | sUser.updatedAt = Date.now() | |
| 193 | await b.put(secondaryKey, sUser) | |
| 194 | break | |
| 195 | } | |
| 196 | ||
| 197 | case OpTypes.REVOKE_IDENTITY: { | |
| 198 | const idKey = `${prefix}identity:${op.identityId}` | |
| 199 | const existing = await b.get(idKey) | |
| 200 | if (existing) { | |
| 201 | const identity = existing.value | |
| 202 | identity.revoked = true | |
| 203 | identity.revokedAt = op.revokedAt || Date.now() | |
| 204 | identity.revokedReason = op.reason || null | |
| 205 | await b.put(idKey, identity) | |
| 206 | } | |
| 207 | break | |
| 208 | } | |
| 209 | ||
| 210 | case OpTypes.RESTORE_IDENTITY: { | |
| 211 | const idKey = `${prefix}identity:${op.identityId}` | |
| 212 | const existing = await b.get(idKey) | |
| 213 | if (existing) { | |
| 214 | const identity = existing.value | |
| 215 | delete identity.revoked | |
| 216 | delete identity.revokedAt | |
| 217 | delete identity.revokedReason | |
| 218 | await b.put(idKey, identity) | |
| 219 | } | |
| 220 | break | |
| 221 | } | |
| 222 | } | |
| 223 | } catch (err) { | |
| 224 | console.error('[IdentityApply] Skipping malformed op:', err.message) | |
| 225 | continue | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | await b.flush() | |
| 230 | } catch (err) { | |
| 231 | console.error('[IdentityApply] Batch error:', err.message) | |
| 232 | await b.close() | |
| 233 | throw err | |
| 234 | } | |
| 235 | } | |
| 236 | } |
| … | … | @@ -0,0 +1,89 @@ |
| 1 | import b4a from 'b4a' | |
| 2 | ||
| 3 | /** | |
| 4 | * Identity operation types for Autobase | |
| 5 | */ | |
| 6 | export const OpTypes = { | |
| 7 | CREATE_USER: 'create-user', | |
| 8 | UPDATE_USER: 'update-user', | |
| 9 | DELETE_USER: 'delete-user', | |
| 10 | ADD_IDENTITY: 'add-identity', | |
| 11 | REMOVE_IDENTITY: 'remove-identity', | |
| 12 | LINK_IDENTITY: 'link-identity', | |
| 13 | UPGRADE_PERMISSIONS: 'upgrade-permissions', | |
| 14 | MERGE_ACCOUNTS: 'merge-accounts', | |
| 15 | REVOKE_IDENTITY: 'revoke-identity', | |
| 16 | RESTORE_IDENTITY: 'restore-identity' | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * Encode operation for Autobase | |
| 21 | * @param {Object} op - Operation object | |
| 22 | * @returns {Buffer} | |
| 23 | */ | |
| 24 | export function encodeOp (op) { | |
| 25 | return b4a.from(JSON.stringify(op)) | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * Decode operation from Autobase | |
| 30 | * @param {Buffer} buf - Encoded operation | |
| 31 | * @returns {Object} | |
| 32 | */ | |
| 33 | export function decodeOp (buf) { | |
| 34 | return JSON.parse(b4a.toString(buf)) | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * Validate operation structure | |
| 39 | * @param {Object} op - Operation to validate | |
| 40 | * @returns {{ valid: boolean, error?: string }} | |
| 41 | */ | |
| 42 | export function validateOp (op) { | |
| 43 | if (!op || !op.type) return { valid: false, error: 'Missing op type' } | |
| 44 | ||
| 45 | switch (op.type) { | |
| 46 | case OpTypes.CREATE_USER: | |
| 47 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 48 | if (!op.name) return { valid: false, error: 'Missing name' } | |
| 49 | return { valid: true } | |
| 50 | ||
| 51 | case OpTypes.UPDATE_USER: | |
| 52 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 53 | return { valid: true } | |
| 54 | ||
| 55 | case OpTypes.DELETE_USER: | |
| 56 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 57 | return { valid: true } | |
| 58 | ||
| 59 | case OpTypes.ADD_IDENTITY: | |
| 60 | case OpTypes.REMOVE_IDENTITY: | |
| 61 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 62 | if (!op.identityId) return { valid: false, error: 'Missing identityId' } | |
| 63 | return { valid: true } | |
| 64 | ||
| 65 | case OpTypes.LINK_IDENTITY: | |
| 66 | if (!op.identityId) return { valid: false, error: 'Missing identityId' } | |
| 67 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 68 | return { valid: true } | |
| 69 | ||
| 70 | case OpTypes.UPGRADE_PERMISSIONS: | |
| 71 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 72 | if (!op.resourceName) return { valid: false, error: 'Missing resourceName' } | |
| 73 | if (!Array.isArray(op.permissions)) return { valid: false, error: 'permissions must be array' } | |
| 74 | return { valid: true } | |
| 75 | ||
| 76 | case OpTypes.MERGE_ACCOUNTS: | |
| 77 | if (!op.primaryUserId) return { valid: false, error: 'Missing primaryUserId' } | |
| 78 | if (!op.secondaryUserId) return { valid: false, error: 'Missing secondaryUserId' } | |
| 79 | return { valid: true } | |
| 80 | ||
| 81 | case OpTypes.REVOKE_IDENTITY: | |
| 82 | case OpTypes.RESTORE_IDENTITY: | |
| 83 | if (!op.identityId) return { valid: false, error: 'Missing identityId' } | |
| 84 | return { valid: true } | |
| 85 | ||
| 86 | default: | |
| 87 | return { valid: false, error: `Unknown op type: ${op.type}` } | |
| 88 | } | |
| 89 | } |
| … | … | @@ -0,0 +1,370 @@ |
| 1 | import Autobase from 'autobase' | |
| 2 | import Hyperbee from 'hyperbee' | |
| 3 | import crypto from 'hypercore-crypto' | |
| 4 | import b4a from 'b4a' | |
| 5 | import { createApplyFunction } from './identity-apply.js' | |
| 6 | import { encodeOp, OpTypes } from './identity-operations.js' | |
| 7 | ||
| 8 | /** | |
| 9 | * IdentityRegistry — Autobase-backed user and identity management | |
| 10 | * | |
| 11 | * Provides CRUD operations for users, identities, and permissions | |
| 12 | * using Autobase for multi-writer consensus. | |
| 13 | * | |
| 14 | * Generic: uses 'resources' instead of application-specific terms. | |
| 15 | */ | |
| 16 | export class IdentityRegistry { | |
| 17 | constructor (store, opts = {}) { | |
| 18 | this._store = store | |
| 19 | this._prefix = opts.prefix || '' | |
| 20 | this._base = null | |
| 21 | this._view = null | |
| 22 | this._ready = false | |
| 23 | ||
| 24 | // Separate Hyperbee for direct key lookups (identity records, pubkey index) | |
| 25 | // This is NOT the Autobase view — it's a direct-write store for identity metadata | |
| 26 | this.db = opts.db || null | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * Initialize Autobase with Hyperbee view | |
| 31 | */ | |
| 32 | async ready () { | |
| 33 | if (this._ready) return | |
| 34 | ||
| 35 | const apply = createApplyFunction({ prefix: this._prefix }) | |
| 36 | ||
| 37 | this._base = new Autobase(this._store, null, { | |
| 38 | apply, | |
| 39 | open (store) { | |
| 40 | return new Hyperbee(store.get('identity-view'), { | |
| 41 | extension: false, | |
| 42 | keyEncoding: 'utf-8', | |
| 43 | valueEncoding: 'json' | |
| 44 | }) | |
| 45 | }, | |
| 46 | valueEncoding: 'binary' | |
| 47 | }) | |
| 48 | ||
| 49 | await this._base.ready() | |
| 50 | this._view = this._base.view | |
| 51 | this._ready = true | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Get Autobase instance (for adding writers, etc.) | |
| 56 | */ | |
| 57 | get base () { return this._base } | |
| 58 | ||
| 59 | /** | |
| 60 | * Get Hyperbee view | |
| 61 | */ | |
| 62 | get view () { return this._view } | |
| 63 | ||
| 64 | // ==================== USER OPERATIONS ==================== | |
| 65 | ||
| 66 | /** | |
| 67 | * Create a new user | |
| 68 | */ | |
| 69 | async createUser (opts = {}) { | |
| 70 | const userId = opts.userId || crypto.randomBytes(16).toString('hex') | |
| 71 | const op = { | |
| 72 | type: OpTypes.CREATE_USER, | |
| 73 | userId, | |
| 74 | username: opts.username || null, | |
| 75 | name: opts.name || 'Anonymous', | |
| 76 | email: opts.email || null, | |
| 77 | profilePicture: opts.profilePicture || null, | |
| 78 | globalPermissions: opts.globalPermissions || ['read'], | |
| 79 | resources: opts.resources || [{ name: '*', permissions: ['read'] }], | |
| 80 | identities: opts.identities || [], | |
| 81 | createdAt: Date.now() | |
| 82 | } | |
| 83 | await this._base.append(encodeOp(op)) | |
| 84 | await this._base.update() | |
| 85 | return op | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Get user by userId | |
| 90 | */ | |
| 91 | async getUser (userId) { | |
| 92 | const entry = await this._view.get(`${this._prefix}user:${userId}`) | |
| 93 | return entry ? entry.value : null | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Get user by identity ID | |
| 98 | */ | |
| 99 | async getUserByIdentity (identityId) { | |
| 100 | const link = await this._view.get(`${this._prefix}user-identity:${identityId}`) | |
| 101 | if (!link) return null | |
| 102 | return this.getUser(link.value.userId) | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Get user by username | |
| 107 | */ | |
| 108 | async getUserByUsername (username) { | |
| 109 | if (!username) return null | |
| 110 | for await (const entry of this._view.createReadStream({ | |
| 111 | gte: `${this._prefix}user:`, | |
| 112 | lt: `${this._prefix}user:\xff` | |
| 113 | })) { | |
| 114 | if (entry.value.username === username) return entry.value | |
| 115 | } | |
| 116 | return null | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Update user | |
| 121 | */ | |
| 122 | async updateUser (userId, updates) { | |
| 123 | const op = { | |
| 124 | type: OpTypes.UPDATE_USER, | |
| 125 | userId, | |
| 126 | ...updates | |
| 127 | } | |
| 128 | await this._base.append(encodeOp(op)) | |
| 129 | await this._base.update() | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * Delete user | |
| 134 | */ | |
| 135 | async deleteUser (userId) { | |
| 136 | await this._base.append(encodeOp({ type: OpTypes.DELETE_USER, userId })) | |
| 137 | await this._base.update() | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * List all users | |
| 142 | */ | |
| 143 | async listUsers () { | |
| 144 | const users = [] | |
| 145 | for await (const entry of this._view.createReadStream({ | |
| 146 | gte: `${this._prefix}user:`, | |
| 147 | lt: `${this._prefix}user:\xff` | |
| 148 | })) { | |
| 149 | if (!entry.value.mergedInto) users.push(entry.value) | |
| 150 | } | |
| 151 | return users | |
| 152 | } | |
| 153 | ||
| 154 | // ==================== IDENTITY OPERATIONS ==================== | |
| 155 | ||
| 156 | /** | |
| 157 | * Register identity in direct-write store | |
| 158 | * @param {Object} identity - { identityId, method, publicKey, ... } | |
| 159 | */ | |
| 160 | async registerIdentity (identity) { | |
| 161 | if (!this.db) throw new Error('No direct-write db configured') | |
| 162 | await this.db.put(`identity:${identity.identityId}`, identity) | |
| 163 | if (identity.publicKey) { | |
| 164 | await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId }) | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * Get identity by ID | |
| 170 | */ | |
| 171 | async getIdentity (identityId) { | |
| 172 | if (!this.db) return null | |
| 173 | const entry = await this.db.get(`identity:${identityId}`) | |
| 174 | return entry ? entry.value : null | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Get identity by public key | |
| 179 | */ | |
| 180 | async getIdentityByPublicKey (publicKey) { | |
| 181 | if (!this.db) return null | |
| 182 | const entry = await this.db.get(`pubkey:${publicKey}`) | |
| 183 | if (!entry) return null | |
| 184 | return this.getIdentity(entry.value.identityId) | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Link identity to user via Autobase | |
| 189 | */ | |
| 190 | async linkIdentityToUser (identityId, userId) { | |
| 191 | await this._base.append(encodeOp({ | |
| 192 | type: OpTypes.LINK_IDENTITY, | |
| 193 | identityId, | |
| 194 | userId | |
| 195 | })) | |
| 196 | await this._base.update() | |
| 197 | ||
| 198 | // Also update direct-write identity record | |
| 199 | if (this.db) { | |
| 200 | const identity = await this.getIdentity(identityId) | |
| 201 | if (identity) { | |
| 202 | identity.userId = userId | |
| 203 | await this.db.put(`identity:${identityId}`, identity) | |
| 204 | } | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * Add identity to user | |
| 210 | */ | |
| 211 | async addIdentityToUser (userId, identityId) { | |
| 212 | await this._base.append(encodeOp({ | |
| 213 | type: OpTypes.ADD_IDENTITY, | |
| 214 | userId, | |
| 215 | identityId | |
| 216 | })) | |
| 217 | await this._base.update() | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Remove identity from user | |
| 222 | */ | |
| 223 | async removeIdentityFromUser (userId, identityId) { | |
| 224 | await this._base.append(encodeOp({ | |
| 225 | type: OpTypes.REMOVE_IDENTITY, | |
| 226 | userId, | |
| 227 | identityId | |
| 228 | })) | |
| 229 | await this._base.update() | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * List user's identities | |
| 234 | */ | |
| 235 | async listUserIdentities (userId) { | |
| 236 | const user = await this.getUser(userId) | |
| 237 | if (!user) return [] | |
| 238 | ||
| 239 | const identities = [] | |
| 240 | for (const id of user.identities) { | |
| 241 | const identity = await this.getIdentity(id) | |
| 242 | if (identity) identities.push(identity) | |
| 243 | } | |
| 244 | return identities | |
| 245 | } | |
| 246 | ||
| 247 | // ==================== PERMISSIONS ==================== | |
| 248 | ||
| 249 | /** | |
| 250 | * Upgrade permissions for user on resource | |
| 251 | * @param {string} userId | |
| 252 | * @param {string} resourceName - Resource name or '*' for global | |
| 253 | * @param {string[]} permissions - ['read', 'write', 'admin'] | |
| 254 | */ | |
| 255 | async upgradePermissions (userId, resourceName, permissions) { | |
| 256 | await this._base.append(encodeOp({ | |
| 257 | type: OpTypes.UPGRADE_PERMISSIONS, | |
| 258 | userId, | |
| 259 | resourceName, | |
| 260 | permissions | |
| 261 | })) | |
| 262 | await this._base.update() | |
| 263 | } | |
| 264 | ||
| 265 | /** | |
| 266 | * Check if any admin user exists | |
| 267 | */ | |
| 268 | async _hasAnyAdmin () { | |
| 269 | for await (const entry of this._view.createReadStream({ | |
| 270 | gte: `${this._prefix}user:`, | |
| 271 | lt: `${this._prefix}user:\xff` | |
| 272 | })) { | |
| 273 | const user = entry.value | |
| 274 | if (user.mergedInto) continue | |
| 275 | if (user.globalPermissions && user.globalPermissions.includes('admin')) return true | |
| 276 | } | |
| 277 | return false | |
| 278 | } | |
| 279 | ||
| 280 | // ==================== ACCOUNT MERGE ==================== | |
| 281 | ||
| 282 | /** | |
| 283 | * Merge two accounts | |
| 284 | */ | |
| 285 | async mergeAccounts (primaryUserId, secondaryUserId) { | |
| 286 | await this._base.append(encodeOp({ | |
| 287 | type: OpTypes.MERGE_ACCOUNTS, | |
| 288 | primaryUserId, | |
| 289 | secondaryUserId | |
| 290 | })) | |
| 291 | await this._base.update() | |
| 292 | } | |
| 293 | ||
| 294 | // ==================== REVOCATION ==================== | |
| 295 | ||
| 296 | /** | |
| 297 | * Revoke an identity | |
| 298 | */ | |
| 299 | async revokeIdentity (identityId, opts = {}) { | |
| 300 | await this._base.append(encodeOp({ | |
| 301 | type: OpTypes.REVOKE_IDENTITY, | |
| 302 | identityId, | |
| 303 | reason: opts.reason || null, | |
| 304 | revokedAt: Date.now() | |
| 305 | })) | |
| 306 | await this._base.update() | |
| 307 | ||
| 308 | // Also update direct-write store | |
| 309 | if (this.db) { | |
| 310 | const identity = await this.getIdentity(identityId) | |
| 311 | if (identity) { | |
| 312 | identity.revoked = true | |
| 313 | identity.revokedAt = Date.now() | |
| 314 | await this.db.put(`identity:${identityId}`, identity) | |
| 315 | } | |
| 316 | } | |
| 317 | } | |
| 318 | ||
| 319 | /** | |
| 320 | * Restore a revoked identity | |
| 321 | */ | |
| 322 | async restoreIdentity (identityId) { | |
| 323 | await this._base.append(encodeOp({ | |
| 324 | type: OpTypes.RESTORE_IDENTITY, | |
| 325 | identityId | |
| 326 | })) | |
| 327 | await this._base.update() | |
| 328 | ||
| 329 | if (this.db) { | |
| 330 | const identity = await this.getIdentity(identityId) | |
| 331 | if (identity) { | |
| 332 | delete identity.revoked | |
| 333 | delete identity.revokedAt | |
| 334 | await this.db.put(`identity:${identityId}`, identity) | |
| 335 | } | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | /** | |
| 340 | * List revoked identities | |
| 341 | */ | |
| 342 | async listRevokedIdentities () { | |
| 343 | if (!this.db) return [] | |
| 344 | const revoked = [] | |
| 345 | for await (const entry of this.db.createReadStream({ | |
| 346 | gte: 'identity:', | |
| 347 | lt: 'identity:\xff' | |
| 348 | })) { | |
| 349 | if (entry.value.revoked) revoked.push(entry.value) | |
| 350 | } | |
| 351 | return revoked | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * Add Autobase writer key | |
| 356 | */ | |
| 357 | async addWriter (key) { | |
| 358 | const keyBuf = typeof key === 'string' ? b4a.from(key, 'hex') : key | |
| 359 | await this._base.addWriter(keyBuf) | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * Close registry | |
| 364 | */ | |
| 365 | async close () { | |
| 366 | if (this._base) await this._base.close() | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | export default IdentityRegistry |
| … | … | @@ -0,0 +1,122 @@ |
| 1 | import { BaseProvider } from '../base-provider.js' | |
| 2 | import crypto from 'hypercore-crypto' | |
| 3 | import b4a from 'b4a' | |
| 4 | ||
| 5 | /** | |
| 6 | * BearerTokenProvider — API token authentication | |
| 7 | * | |
| 8 | * Manages bearer tokens with embedded permissions. | |
| 9 | * Tokens are stored as SHA256 hashes for security. | |
| 10 | */ | |
| 11 | export class BearerTokenProvider extends BaseProvider { | |
| 12 | constructor (identityRegistry, opts = {}) { | |
| 13 | super('bearer-token') | |
| 14 | this.registry = identityRegistry | |
| 15 | // Use separate db for tokens (not Autobase view) | |
| 16 | this.db = opts.db || (identityRegistry && identityRegistry.db) || null | |
| 17 | } | |
| 18 | ||
| 19 | async extractCredentials (req) { | |
| 20 | const authHeader = req.headers.authorization | |
| 21 | if (!authHeader || !authHeader.startsWith('Bearer ')) return null | |
| 22 | return { token: authHeader.substring(7) } | |
| 23 | } | |
| 24 | ||
| 25 | async authenticate (credentials) { | |
| 26 | const { token } = credentials | |
| 27 | const tokenData = await this.validateToken(token) | |
| 28 | if (!tokenData) throw new Error('Invalid token') | |
| 29 | ||
| 30 | return { | |
| 31 | identityId: `token:${tokenData.hash.substring(0, 16)}`, | |
| 32 | method: 'bearer-token', | |
| 33 | credentials: { username: tokenData.username, tokenHash: tokenData.hash }, | |
| 34 | permissions: tokenData.permissions || ['read'], | |
| 35 | resources: tokenData.resources || ['*'] | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Create a new bearer token | |
| 41 | */ | |
| 42 | async createToken (opts = {}) { | |
| 43 | const token = b4a.toString(crypto.randomBytes(32), 'hex') | |
| 44 | const hash = this._hashToken(token) | |
| 45 | ||
| 46 | const tokenData = { | |
| 47 | hash, | |
| 48 | username: opts.username || 'anonymous', | |
| 49 | description: opts.description || '', | |
| 50 | permissions: opts.permissions || ['read'], | |
| 51 | resources: opts.resources || ['*'], | |
| 52 | createdAt: Date.now(), | |
| 53 | expiresAt: opts.expiresAt || null | |
| 54 | } | |
| 55 | ||
| 56 | if (this.db) { | |
| 57 | await this.db.put(`token:${hash}`, tokenData) | |
| 58 | } | |
| 59 | ||
| 60 | return { token, ...tokenData } | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Validate a token | |
| 65 | */ | |
| 66 | async validateToken (token) { | |
| 67 | if (!this.db) return null | |
| 68 | const hash = this._hashToken(token) | |
| 69 | const entry = await this.db.get(`token:${hash}`) | |
| 70 | if (!entry) return null | |
| 71 | ||
| 72 | const data = entry.value | |
| 73 | if (data.expiresAt && data.expiresAt < Date.now()) return null | |
| 74 | if (data.revoked) return null | |
| 75 | ||
| 76 | return data | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Revoke a token | |
| 81 | */ | |
| 82 | async revokeToken (tokenHash) { | |
| 83 | if (!this.db) return | |
| 84 | const entry = await this.db.get(`token:${tokenHash}`) | |
| 85 | if (entry) { | |
| 86 | entry.value.revoked = true | |
| 87 | entry.value.revokedAt = Date.now() | |
| 88 | await this.db.put(`token:${tokenHash}`, entry.value) | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * List all tokens | |
| 94 | */ | |
| 95 | async listAllTokens () { | |
| 96 | if (!this.db) return [] | |
| 97 | const tokens = [] | |
| 98 | for await (const entry of this.db.createReadStream({ | |
| 99 | gte: 'token:', | |
| 100 | lt: 'token:\xff' | |
| 101 | })) { | |
| 102 | tokens.push(entry.value) | |
| 103 | } | |
| 104 | return tokens | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * Get tokens for specific user | |
| 109 | */ | |
| 110 | async getUserTokens (username) { | |
| 111 | const all = await this.listAllTokens() | |
| 112 | return all.filter(t => t.username === username) | |
| 113 | } | |
| 114 | ||
| 115 | _hashToken (token) { | |
| 116 | // Use hypercore-crypto for hashing (works in both Node and Bare) | |
| 117 | const data = b4a.from(token) | |
| 118 | return b4a.toString(crypto.data(data), 'hex') | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | export default BearerTokenProvider |
| … | … | @@ -0,0 +1,62 @@ |
| 1 | import { BaseProvider } from '../base-provider.js' | |
| 2 | ||
| 3 | /** | |
| 4 | * KeetIdentityProvider — BIP39 mnemonic-based authentication | |
| 5 | * | |
| 6 | * Uses keet-identity-key for mnemonic → identity keypair verification. | |
| 7 | */ | |
| 8 | export class KeetIdentityProvider extends BaseProvider { | |
| 9 | constructor (identityRegistry, opts = {}) { | |
| 10 | super('keet-identity') | |
| 11 | this.registry = identityRegistry | |
| 12 | this._IdentityKey = opts.IdentityKey || null | |
| 13 | } | |
| 14 | ||
| 15 | async extractCredentials (req) { | |
| 16 | const identityProof = req.headers['x-identity-proof'] | |
| 17 | if (!identityProof) return null | |
| 18 | ||
| 19 | try { | |
| 20 | const proof = JSON.parse(identityProof) | |
| 21 | return { proof } | |
| 22 | } catch { | |
| 23 | return null | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | async authenticate (credentials) { | |
| 28 | if (!this._IdentityKey) throw new Error('keet-identity-key not configured') | |
| 29 | ||
| 30 | const { proof } = credentials | |
| 31 | ||
| 32 | // Verify proof chain | |
| 33 | const verified = this._IdentityKey.verify(proof) | |
| 34 | if (!verified) throw new Error('Invalid identity proof') | |
| 35 | ||
| 36 | const publicKey = proof.publicKey || proof.identityPublicKey | |
| 37 | if (!publicKey) throw new Error('No public key in proof') | |
| 38 | ||
| 39 | // Look up identity | |
| 40 | let identity = await this.registry.getIdentityByPublicKey(publicKey) | |
| 41 | ||
| 42 | if (!identity) { | |
| 43 | // Auto-register | |
| 44 | const identityId = publicKey | |
| 45 | await this.registry.registerIdentity({ | |
| 46 | identityId, | |
| 47 | method: 'keet-identity', | |
| 48 | publicKey, | |
| 49 | createdAt: Date.now() | |
| 50 | }) | |
| 51 | identity = { identityId, method: 'keet-identity', publicKey } | |
| 52 | } | |
| 53 | ||
| 54 | return { | |
| 55 | identityId: identity.identityId, | |
| 56 | method: 'keet-identity', | |
| 57 | credentials: { publicKey, proof } | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | export default KeetIdentityProvider |
| … | … | @@ -0,0 +1,87 @@ |
| 1 | import { BaseProvider } from '../base-provider.js' | |
| 2 | import { SSHKeyParser } from '../ssh-parser.js' | |
| 3 | import { ANProto } from '../../anproto.js' | |
| 4 | import b4a from 'b4a' | |
| 5 | ||
| 6 | /** | |
| 7 | * SSHKeyProvider — SSH key authentication | |
| 8 | * | |
| 9 | * Verifies Ed25519 and RSA SSH key signatures. | |
| 10 | * Requires node:crypto for RSA verification (Ed25519 uses hypercore-crypto). | |
| 11 | */ | |
| 12 | export class SSHKeyProvider extends BaseProvider { | |
| 13 | constructor (identityRegistry, challengeManager) { | |
| 14 | super('ssh-key') | |
| 15 | this.registry = identityRegistry | |
| 16 | this.challengeManager = challengeManager | |
| 17 | } | |
| 18 | ||
| 19 | async extractCredentials (req) { | |
| 20 | const sshPublicKey = req.headers['x-ssh-publickey'] | |
| 21 | const sshSignature = req.headers['x-ssh-signature'] | |
| 22 | const challengeId = req.headers['x-ssh-challenge'] | |
| 23 | ||
| 24 | if (!sshPublicKey || !sshSignature || !challengeId) return null | |
| 25 | ||
| 26 | return { sshPublicKey, sshSignature, challengeId } | |
| 27 | } | |
| 28 | ||
| 29 | async authenticate (credentials) { | |
| 30 | const { sshPublicKey, sshSignature, challengeId } = credentials | |
| 31 | ||
| 32 | // Verify challenge exists and is valid | |
| 33 | const challenge = await this.challengeManager.verify(challengeId, challengeId) | |
| 34 | if (!challenge) throw new Error('Invalid or expired challenge') | |
| 35 | ||
| 36 | // Parse SSH key | |
| 37 | const parsedKey = SSHKeyParser.parse(sshPublicKey) | |
| 38 | const parsedSig = SSHKeyParser.parseSignature(sshSignature) | |
| 39 | ||
| 40 | let valid = false | |
| 41 | ||
| 42 | if (parsedKey.type === 'ed25519') { | |
| 43 | // Use hypercore-crypto for Ed25519 | |
| 44 | valid = ANProto.verify( | |
| 45 | challenge.challenge, | |
| 46 | b4a.toString(parsedSig.signature, 'base64'), | |
| 47 | parsedKey.publicKey | |
| 48 | ) | |
| 49 | } else if (parsedKey.type === 'rsa') { | |
| 50 | // RSA requires node:crypto | |
| 51 | try { | |
| 52 | const nodeCrypto = await import('node:crypto') | |
| 53 | const pem = SSHKeyParser.toPEM(parsedKey) | |
| 54 | const verifier = nodeCrypto.createVerify('SHA256') | |
| 55 | verifier.update(challenge.challenge) | |
| 56 | valid = verifier.verify(pem, parsedSig.signature) | |
| 57 | } catch { | |
| 58 | throw new Error('RSA verification requires node:crypto') | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | if (!valid) throw new Error('Invalid SSH signature') | |
| 63 | ||
| 64 | // Use SSH public key string as identity ID | |
| 65 | const identityId = `ssh:${sshPublicKey.substring(0, 64)}` | |
| 66 | let identity = await this.registry.getIdentity(identityId) | |
| 67 | ||
| 68 | if (!identity) { | |
| 69 | await this.registry.registerIdentity({ | |
| 70 | identityId, | |
| 71 | method: 'ssh-key', | |
| 72 | publicKey: sshPublicKey, | |
| 73 | keyType: parsedKey.type, | |
| 74 | createdAt: Date.now() | |
| 75 | }) | |
| 76 | identity = { identityId, method: 'ssh-key' } | |
| 77 | } | |
| 78 | ||
| 79 | return { | |
| 80 | identityId, | |
| 81 | method: 'ssh-key', | |
| 82 | credentials: { sshPublicKey, keyType: parsedKey.type } | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | export default SSHKeyProvider |
| … | … | @@ -0,0 +1,92 @@ |
| 1 | import { BaseProvider } from '../base-provider.js' | |
| 2 | import { ANProto } from '../../anproto.js' | |
| 3 | import b4a from 'b4a' | |
| 4 | ||
| 5 | /** | |
| 6 | * WebIdentityProvider — Browser ANProto authentication | |
| 7 | * | |
| 8 | * Handles Ed25519 challenge-response auth for web clients. | |
| 9 | * Supports both signed (challenge-based) and legacy (unsigned) auth. | |
| 10 | */ | |
| 11 | export class WebIdentityProvider extends BaseProvider { | |
| 12 | constructor (identityRegistry, challengeManager) { | |
| 13 | super('web-identity') | |
| 14 | this.registry = identityRegistry | |
| 15 | this.challengeManager = challengeManager | |
| 16 | } | |
| 17 | ||
| 18 | async extractCredentials (req) { | |
| 19 | const publicKey = req.headers['x-wirecore-publickey'] || req.headers['x-identity-publickey'] | |
| 20 | if (!publicKey) return null | |
| 21 | ||
| 22 | const signature = req.headers['x-wirecore-signature'] || req.headers['x-identity-signature'] | |
| 23 | const challengeId = req.headers['x-wirecore-challenge'] || req.headers['x-identity-challenge'] | |
| 24 | ||
| 25 | return { publicKey, signature, challengeId } | |
| 26 | } | |
| 27 | ||
| 28 | async authenticate (credentials) { | |
| 29 | const { publicKey, signature, challengeId } = credentials | |
| 30 | ||
| 31 | if (signature && challengeId) { | |
| 32 | // Signed auth — verify challenge signature | |
| 33 | const challenge = await this.challengeManager.verify(challengeId, challengeId) | |
| 34 | if (!challenge) { | |
| 35 | // Try direct challenge verification | |
| 36 | const stored = await this._getChallenge(challengeId) | |
| 37 | if (!stored) throw new Error('Invalid or expired challenge') | |
| 38 | ||
| 39 | const valid = ANProto.verify(stored.challenge, signature, publicKey) | |
| 40 | if (!valid) throw new Error('Invalid signature') | |
| 41 | } else { | |
| 42 | const valid = ANProto.verify(challenge.challenge, signature, publicKey) | |
| 43 | if (!valid) throw new Error('Invalid signature') | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // Look up or auto-register identity | |
| 48 | let identity = await this.registry.getIdentityByPublicKey(publicKey) | |
| 49 | ||
| 50 | if (!identity) { | |
| 51 | // Auto-register new web identity | |
| 52 | const identityId = publicKey // Use public key as identity ID | |
| 53 | await this.registry.registerIdentity({ | |
| 54 | identityId, | |
| 55 | method: 'web-identity', | |
| 56 | publicKey, | |
| 57 | createdAt: Date.now() | |
| 58 | }) | |
| 59 | ||
| 60 | // Auto-create user | |
| 61 | const user = await this.registry.createUser({ | |
| 62 | name: 'Anonymous', | |
| 63 | globalPermissions: ['read'], | |
| 64 | resources: [{ name: '*', permissions: ['read'] }], | |
| 65 | identities: [identityId] | |
| 66 | }) | |
| 67 | ||
| 68 | // Link identity to user | |
| 69 | if (user && user.userId) { | |
| 70 | await this.registry.linkIdentityToUser(identityId, user.userId) | |
| 71 | } | |
| 72 | ||
| 73 | identity = await this.registry.getIdentity(identityId) | |
| 74 | } | |
| 75 | ||
| 76 | return { | |
| 77 | identityId: identity.identityId || publicKey, | |
| 78 | method: 'web-identity', | |
| 79 | credentials: { publicKey } | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | async _getChallenge (challengeId) { | |
| 84 | try { | |
| 85 | return await this.challengeManager.verify(challengeId, challengeId) | |
| 86 | } catch { | |
| 87 | return null | |
| 88 | } | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | export default WebIdentityProvider |
| … | … | @@ -0,0 +1,94 @@ |
| 1 | /** | |
| 2 | * RateLimiter — Token bucket rate limiting | |
| 3 | * | |
| 4 | * Per-client, per-operation rate limiting with persistent storage. | |
| 5 | */ | |
| 6 | export class RateLimiter { | |
| 7 | constructor (opts = {}) { | |
| 8 | this.db = opts.db || null | |
| 9 | this.limits = opts.limits || { | |
| 10 | auth: { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 }, | |
| 11 | api: { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 } | |
| 12 | } | |
| 13 | this.cacheTimeout = opts.cacheTimeout || 10 * 1000 | |
| 14 | this._buckets = new Map() | |
| 15 | this._cleanupInterval = setInterval(() => this._cleanup(), 60 * 1000) | |
| 16 | } | |
| 17 | ||
| 18 | /** | |
| 19 | * Check rate limit for client/operation | |
| 20 | * @param {string} clientId - Client identifier (IP, user ID, etc.) | |
| 21 | * @param {string} operation - Operation type | |
| 22 | * @returns {Promise<Object>} { allowed, remaining, retryAfter?, resetAt? } | |
| 23 | */ | |
| 24 | async check (clientId, operation = 'api') { | |
| 25 | const key = `${clientId}:${operation}` | |
| 26 | const limit = this.limits[operation] || this.limits.api | |
| 27 | ||
| 28 | let bucket = this._buckets.get(key) | |
| 29 | if (!bucket) { | |
| 30 | bucket = { | |
| 31 | tokens: limit.tokens, | |
| 32 | lastRefill: Date.now(), | |
| 33 | operation | |
| 34 | } | |
| 35 | this._buckets.set(key, bucket) | |
| 36 | } | |
| 37 | ||
| 38 | // Refill tokens | |
| 39 | const now = Date.now() | |
| 40 | const elapsed = now - bucket.lastRefill | |
| 41 | const refills = Math.floor(elapsed / limit.refillInterval) | |
| 42 | if (refills > 0) { | |
| 43 | bucket.tokens = Math.min(limit.tokens, bucket.tokens + refills * limit.refillRate) | |
| 44 | bucket.lastRefill = now | |
| 45 | } | |
| 46 | ||
| 47 | // Check | |
| 48 | if (bucket.tokens <= 0) { | |
| 49 | const retryAfter = Math.ceil(limit.refillInterval / 1000) | |
| 50 | return { | |
| 51 | allowed: false, | |
| 52 | remaining: 0, | |
| 53 | retryAfter, | |
| 54 | resetAt: bucket.lastRefill + limit.refillInterval | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | // Consume token | |
| 59 | bucket.tokens-- | |
| 60 | ||
| 61 | return { | |
| 62 | allowed: true, | |
| 63 | remaining: bucket.tokens | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Cleanup old buckets | |
| 69 | */ | |
| 70 | _cleanup () { | |
| 71 | const cutoff = Date.now() - 10 * 60 * 1000 // 10 min | |
| 72 | for (const [key, bucket] of this._buckets) { | |
| 73 | if (bucket.lastRefill < cutoff) { | |
| 74 | this._buckets.delete(key) | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Get stats | |
| 81 | */ | |
| 82 | async stats () { | |
| 83 | return { | |
| 84 | activeBuckets: this._buckets.size | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | destroy () { | |
| 89 | clearInterval(this._cleanupInterval) | |
| 90 | this._buckets.clear() | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | export default RateLimiter |
| … | … | @@ -0,0 +1,107 @@ |
| 1 | import b4a from 'b4a' | |
| 2 | ||
| 3 | /** | |
| 4 | * SSH Key Parser | |
| 5 | * | |
| 6 | * Parses SSH public keys (RFC 4253 format) and extracts RSA components. | |
| 7 | * Supports: ssh-rsa, ssh-ed25519 | |
| 8 | */ | |
| 9 | export class SSHKeyParser { | |
| 10 | static parse (sshPublicKey) { | |
| 11 | const parts = sshPublicKey.trim().split(/\s+/) | |
| 12 | if (parts.length < 2) throw new Error('Invalid SSH key format') | |
| 13 | ||
| 14 | const keyType = parts[0] | |
| 15 | const keyData = parts[1] | |
| 16 | const buffer = b4a.from(keyData, 'base64') | |
| 17 | ||
| 18 | switch (keyType) { | |
| 19 | case 'ssh-rsa': return this.parseRSA(buffer) | |
| 20 | case 'ssh-ed25519': return this.parseEd25519(buffer) | |
| 21 | default: throw new Error(`Unsupported key type: ${keyType}`) | |
| 22 | } | |
| 23 | } | |
| 24 | ||
| 25 | static parseRSA (buffer) { | |
| 26 | let offset = 0 | |
| 27 | const algoLen = buffer.readUInt32BE(offset); offset += 4 | |
| 28 | const algorithm = buffer.toString('utf8', offset, offset + algoLen); offset += algoLen | |
| 29 | if (algorithm !== 'ssh-rsa') throw new Error(`Expected ssh-rsa, got ${algorithm}`) | |
| 30 | ||
| 31 | const eLen = buffer.readUInt32BE(offset); offset += 4 | |
| 32 | const e = buffer.subarray(offset, offset + eLen); offset += eLen | |
| 33 | const nLen = buffer.readUInt32BE(offset); offset += 4 | |
| 34 | const n = buffer.subarray(offset, offset + nLen) | |
| 35 | ||
| 36 | return { type: 'rsa', e: b4a.from(e), n: b4a.from(n) } | |
| 37 | } | |
| 38 | ||
| 39 | static parseEd25519 (buffer) { | |
| 40 | let offset = 0 | |
| 41 | const algoLen = buffer.readUInt32BE(offset); offset += 4 | |
| 42 | const algorithm = buffer.toString('utf8', offset, offset + algoLen); offset += algoLen | |
| 43 | if (algorithm !== 'ssh-ed25519') throw new Error(`Expected ssh-ed25519, got ${algorithm}`) | |
| 44 | ||
| 45 | const keyLen = buffer.readUInt32BE(offset); offset += 4 | |
| 46 | const publicKey = buffer.subarray(offset, offset + keyLen) | |
| 47 | return { type: 'ed25519', publicKey: b4a.from(publicKey) } | |
| 48 | } | |
| 49 | ||
| 50 | static parseSignature (signatureBase64) { | |
| 51 | const buffer = b4a.from(signatureBase64, 'base64') | |
| 52 | let offset = 0 | |
| 53 | const algoLen = buffer.readUInt32BE(offset); offset += 4 | |
| 54 | const algorithm = buffer.toString('utf8', offset, offset + algoLen); offset += algoLen | |
| 55 | const sigLen = buffer.readUInt32BE(offset); offset += 4 | |
| 56 | const signature = buffer.subarray(offset, offset + sigLen) | |
| 57 | ||
| 58 | let type | |
| 59 | if (algorithm === 'ssh-rsa') type = 'rsa' | |
| 60 | else if (algorithm === 'ssh-ed25519') type = 'ed25519' | |
| 61 | else throw new Error(`Unsupported signature algorithm: ${algorithm}`) | |
| 62 | ||
| 63 | return { type, signature: b4a.from(signature) } | |
| 64 | } | |
| 65 | ||
| 66 | static toPEM (key) { | |
| 67 | if (key.type !== 'rsa') throw new Error('Only RSA keys can be converted to PEM') | |
| 68 | const der = this.encodeRSAPublicKeyDER(key.n, key.e) | |
| 69 | const base64 = b4a.toString(der, 'base64') | |
| 70 | const lines = base64.match(/.{1,64}/g) || [] | |
| 71 | return ['-----BEGIN PUBLIC KEY-----', ...lines, '-----END PUBLIC KEY-----'].join('\n') | |
| 72 | } | |
| 73 | ||
| 74 | static encodeRSAPublicKeyDER (n, e) { | |
| 75 | const encodeInteger = (buf) => { | |
| 76 | const needsPadding = buf[0] & 0x80 | |
| 77 | const length = buf.length + (needsPadding ? 1 : 0) | |
| 78 | const result = b4a.alloc(2 + length) | |
| 79 | result[0] = 0x02 | |
| 80 | result[1] = length | |
| 81 | if (needsPadding) { result[2] = 0x00; buf.copy(result, 3) } | |
| 82 | else { buf.copy(result, 2) } | |
| 83 | return result | |
| 84 | } | |
| 85 | ||
| 86 | const nInt = encodeInteger(n) | |
| 87 | const eInt = encodeInteger(e) | |
| 88 | const innerSeq = b4a.concat([nInt, eInt]) | |
| 89 | const innerSeqEncoded = b4a.concat([b4a.from([0x30]), this.encodeLength(innerSeq.length), innerSeq]) | |
| 90 | const bitString = b4a.concat([b4a.from([0x03]), this.encodeLength(innerSeqEncoded.length + 1), b4a.from([0x00]), innerSeqEncoded]) | |
| 91 | const rsaOID = b4a.from([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]) | |
| 92 | const algNull = b4a.from([0x05, 0x00]) | |
| 93 | const algSeq = b4a.concat([b4a.from([0x30]), this.encodeLength(rsaOID.length + algNull.length), rsaOID, algNull]) | |
| 94 | const outerContent = b4a.concat([algSeq, bitString]) | |
| 95 | return b4a.concat([b4a.from([0x30]), this.encodeLength(outerContent.length), outerContent]) | |
| 96 | } | |
| 97 | ||
| 98 | static encodeLength (length) { | |
| 99 | if (length < 128) return b4a.from([length]) | |
| 100 | const bytes = [] | |
| 101 | let len = length | |
| 102 | while (len > 0) { bytes.unshift(len & 0xff); len >>= 8 } | |
| 103 | return b4a.from([0x80 | bytes.length, ...bytes]) | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | export default SSHKeyParser |
| … | … | @@ -0,0 +1,83 @@ |
| 1 | import BlindPairing from 'blind-pairing' | |
| 2 | import b4a from 'b4a' | |
| 3 | ||
| 4 | /** | |
| 5 | * DeviceLinker — Blind-pairing device linking for mnemonic exchange | |
| 6 | * | |
| 7 | * Uses blind-pairing over Hyperswarm for secure mnemonic transfer | |
| 8 | * between devices owned by the same user. | |
| 9 | */ | |
| 10 | export class DeviceLinker { | |
| 11 | constructor (swarm, opts = {}) { | |
| 12 | this._swarm = swarm | |
| 13 | this._identityManager = opts.identityManager || null | |
| 14 | this._onLinked = opts.onLinked || null // callback(mnemonic, remoteInfo) | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * Create a link invite (source device) | |
| 19 | * Generates invite hex that the target device uses to pair. | |
| 20 | * @returns {Promise<{ invite: string, promise: Promise<Object> }>} | |
| 21 | */ | |
| 22 | async createInvite () { | |
| 23 | return new Promise((resolve, reject) => { | |
| 24 | const mnemonic = this._identityManager ? this._identityManager.getMnemonic() : null | |
| 25 | if (!mnemonic) { | |
| 26 | reject(new Error('No mnemonic available to share')) | |
| 27 | return | |
| 28 | } | |
| 29 | ||
| 30 | const member = BlindPairing.member(this._swarm, { | |
| 31 | onadd (candidate) { | |
| 32 | // Send mnemonic to paired device | |
| 33 | candidate.open(b4a.from(JSON.stringify({ | |
| 34 | mnemonic, | |
| 35 | instanceKey: this._identityManager ? this._identityManager.getPublicKeyHex() : null | |
| 36 | }))) | |
| 37 | } | |
| 38 | }) | |
| 39 | ||
| 40 | member.invite().then(invite => { | |
| 41 | resolve({ | |
| 42 | invite: b4a.toString(invite, 'hex'), | |
| 43 | member | |
| 44 | }) | |
| 45 | }).catch(reject) | |
| 46 | }) | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Join with invite (target device) | |
| 51 | * Uses invite hex from source device to receive mnemonic. | |
| 52 | * @param {string} inviteHex - Hex-encoded invite from source | |
| 53 | * @returns {Promise<Object>} { mnemonic, instanceKey } | |
| 54 | */ | |
| 55 | async joinWithInvite (inviteHex) { | |
| 56 | return new Promise((resolve, reject) => { | |
| 57 | const invite = b4a.from(inviteHex, 'hex') | |
| 58 | ||
| 59 | const candidate = BlindPairing.candidate(this._swarm, invite, { | |
| 60 | onopen (data) { | |
| 61 | try { | |
| 62 | const payload = JSON.parse(b4a.toString(data)) | |
| 63 | if (this._identityManager && payload.mnemonic) { | |
| 64 | this._identityManager.setMnemonic(payload.mnemonic).catch(() => {}) | |
| 65 | } | |
| 66 | if (this._onLinked) this._onLinked(payload.mnemonic, payload) | |
| 67 | resolve(payload) | |
| 68 | } catch (err) { | |
| 69 | reject(err) | |
| 70 | } | |
| 71 | } | |
| 72 | }) | |
| 73 | ||
| 74 | candidate.on('error', reject) | |
| 75 | }) | |
| 76 | } | |
| 77 | ||
| 78 | destroy () { | |
| 79 | // Cleanup if needed | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | export default DeviceLinker |
| … | … | @@ -0,0 +1,155 @@ |
| 1 | import crypto from 'hypercore-crypto' | |
| 2 | import b4a from 'b4a' | |
| 3 | ||
| 4 | /** | |
| 5 | * IdentityManager — Persistent identity keypairs + mnemonic proof chains | |
| 6 | * | |
| 7 | * Manages: | |
| 8 | * - Instance keypair (persistent Ed25519 for this device) | |
| 9 | * - Mnemonic-based identity (keet-identity-key proof chains) | |
| 10 | * - Device attestation for multi-writer | |
| 11 | */ | |
| 12 | export class IdentityManager { | |
| 13 | constructor (store, opts = {}) { | |
| 14 | this._store = store // Hyperbee or similar key-value store | |
| 15 | this._keypair = null | |
| 16 | this._mnemonic = null | |
| 17 | this._identityKey = null | |
| 18 | this._IdentityKey = opts.IdentityKey || null // keet-identity-key class (optional) | |
| 19 | } | |
| 20 | ||
| 21 | /** | |
| 22 | * Initialize — load or generate persistent keypair | |
| 23 | */ | |
| 24 | async ready () { | |
| 25 | // Try to load existing keypair | |
| 26 | const existing = await this._store.get('instance-keypair') | |
| 27 | if (existing) { | |
| 28 | this._keypair = { | |
| 29 | publicKey: b4a.from(existing.value.publicKey, 'hex'), | |
| 30 | secretKey: b4a.from(existing.value.secretKey, 'hex') | |
| 31 | } | |
| 32 | } else { | |
| 33 | // Generate new persistent keypair | |
| 34 | this._keypair = crypto.keyPair() | |
| 35 | await this._store.put('instance-keypair', { | |
| 36 | publicKey: b4a.toString(this._keypair.publicKey, 'hex'), | |
| 37 | secretKey: b4a.toString(this._keypair.secretKey, 'hex') | |
| 38 | }) | |
| 39 | } | |
| 40 | ||
| 41 | // Load mnemonic if stored | |
| 42 | const mnemonicEntry = await this._store.get('identity-mnemonic') | |
| 43 | if (mnemonicEntry) { | |
| 44 | this._mnemonic = mnemonicEntry.value | |
| 45 | await this._loadIdentityKey() | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Load keet-identity-key from stored mnemonic | |
| 51 | */ | |
| 52 | async _loadIdentityKey () { | |
| 53 | if (!this._IdentityKey || !this._mnemonic) return | |
| 54 | try { | |
| 55 | this._identityKey = this._IdentityKey.from({ mnemonic: this._mnemonic }) | |
| 56 | } catch { | |
| 57 | this._identityKey = null | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Get instance public key as hex string | |
| 63 | */ | |
| 64 | getPublicKeyHex () { | |
| 65 | if (!this._keypair) return null | |
| 66 | return b4a.toString(this._keypair.publicKey, 'hex') | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Get instance public key buffer | |
| 71 | */ | |
| 72 | getPublicKey () { | |
| 73 | return this._keypair ? this._keypair.publicKey : null | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * Get instance keypair | |
| 78 | */ | |
| 79 | getKeypair () { | |
| 80 | return this._keypair | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Set mnemonic and derive identity key | |
| 85 | * @param {string} mnemonic - BIP39 mnemonic | |
| 86 | */ | |
| 87 | async setMnemonic (mnemonic) { | |
| 88 | this._mnemonic = mnemonic | |
| 89 | await this._store.put('identity-mnemonic', mnemonic) | |
| 90 | await this._loadIdentityKey() | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Get mnemonic (if stored) | |
| 95 | */ | |
| 96 | getMnemonic () { | |
| 97 | return this._mnemonic | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Get keet-identity-key instance (if mnemonic loaded) | |
| 102 | */ | |
| 103 | getIdentityKey () { | |
| 104 | return this._identityKey | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * Bootstrap device attestation proof chain | |
| 109 | * @param {Buffer} devicePublicKey - This device's public key | |
| 110 | * @returns {Object} Proof chain for device attestation | |
| 111 | */ | |
| 112 | async bootstrap (devicePublicKey) { | |
| 113 | if (!this._identityKey) throw new Error('No identity key loaded') | |
| 114 | return this._identityKey.bootstrap(devicePublicKey) | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * Sign data with instance keypair | |
| 119 | * @param {Buffer} data - Data to sign | |
| 120 | * @returns {Buffer} Signature | |
| 121 | */ | |
| 122 | sign (data) { | |
| 123 | if (!this._keypair) throw new Error('Not initialized') | |
| 124 | return crypto.sign(data, this._keypair.secretKey) | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * Verify signature with instance public key | |
| 129 | * @param {Buffer} data - Original data | |
| 130 | * @param {Buffer} signature - Signature to verify | |
| 131 | * @returns {boolean} | |
| 132 | */ | |
| 133 | verify (data, signature) { | |
| 134 | if (!this._keypair) throw new Error('Not initialized') | |
| 135 | return crypto.verify(data, signature, this._keypair.publicKey) | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Get identity public key (from mnemonic, distinct from instance key) | |
| 140 | */ | |
| 141 | getIdentityPublicKey () { | |
| 142 | if (!this._identityKey) return null | |
| 143 | return this._identityKey.publicKey | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Get identity public key as hex | |
| 148 | */ | |
| 149 | getIdentityPublicKeyHex () { | |
| 150 | const pk = this.getIdentityPublicKey() | |
| 151 | return pk ? b4a.toString(pk, 'hex') : null | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | export default IdentityManager |
| … | … | @@ -0,0 +1,126 @@ |
| 1 | import sodium from 'sodium-native' | |
| 2 | import b4a from 'b4a' | |
| 3 | ||
| 4 | /** | |
| 5 | * SecureStorage — Encrypted vault for sensitive data | |
| 6 | * | |
| 7 | * Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption. | |
| 8 | * Stores encrypted data in a Hyperbee or similar key-value store. | |
| 9 | */ | |
| 10 | export class SecureStorage { | |
| 11 | constructor (store) { | |
| 12 | this._store = store // Hyperbee or similar key-value store | |
| 13 | } | |
| 14 | ||
| 15 | /** | |
| 16 | * Derive encryption key from password using Argon2id | |
| 17 | * @param {string} password - User password | |
| 18 | * @param {Buffer} salt - 16-byte salt | |
| 19 | * @returns {Buffer} 32-byte key | |
| 20 | */ | |
| 21 | static deriveKey (password, salt) { | |
| 22 | const key = b4a.alloc(32) | |
| 23 | const pw = b4a.from(password) | |
| 24 | sodium.crypto_pwhash( | |
| 25 | key, | |
| 26 | pw, | |
| 27 | salt, | |
| 28 | sodium.crypto_pwhash_OPSLIMIT_MODERATE, | |
| 29 | sodium.crypto_pwhash_MEMLIMIT_MODERATE, | |
| 30 | sodium.crypto_pwhash_ALG_ARGON2ID13 | |
| 31 | ) | |
| 32 | return key | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * Encrypt data with key | |
| 37 | * @param {Buffer} data - Data to encrypt | |
| 38 | * @param {Buffer} key - 32-byte encryption key | |
| 39 | * @returns {{ nonce: Buffer, ciphertext: Buffer }} | |
| 40 | */ | |
| 41 | static encrypt (data, key) { | |
| 42 | const nonce = b4a.alloc(24) | |
| 43 | sodium.randombytes_buf(nonce) | |
| 44 | const ciphertext = b4a.alloc(data.length + sodium.crypto_secretbox_MACBYTES) | |
| 45 | sodium.crypto_secretbox_easy(ciphertext, data, nonce, key) | |
| 46 | return { nonce, ciphertext } | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Decrypt data with key | |
| 51 | * @param {Buffer} ciphertext - Encrypted data | |
| 52 | * @param {Buffer} nonce - 24-byte nonce | |
| 53 | * @param {Buffer} key - 32-byte decryption key | |
| 54 | * @returns {Buffer} Decrypted data | |
| 55 | */ | |
| 56 | static decrypt (ciphertext, nonce, key) { | |
| 57 | const plaintext = b4a.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES) | |
| 58 | const ok = sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, key) | |
| 59 | if (!ok) throw new Error('Decryption failed') | |
| 60 | return plaintext | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Store encrypted value | |
| 65 | * @param {string} name - Vault entry name | |
| 66 | * @param {string} value - Value to encrypt | |
| 67 | * @param {string} password - Encryption password | |
| 68 | */ | |
| 69 | async put (name, value, password) { | |
| 70 | const salt = b4a.alloc(16) | |
| 71 | sodium.randombytes_buf(salt) | |
| 72 | const key = SecureStorage.deriveKey(password, salt) | |
| 73 | const { nonce, ciphertext } = SecureStorage.encrypt(b4a.from(value), key) | |
| 74 | ||
| 75 | await this._store.put(`vault:${name}`, { | |
| 76 | salt: b4a.toString(salt, 'hex'), | |
| 77 | nonce: b4a.toString(nonce, 'hex'), | |
| 78 | ciphertext: b4a.toString(ciphertext, 'hex') | |
| 79 | }) | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Retrieve and decrypt value | |
| 84 | * @param {string} name - Vault entry name | |
| 85 | * @param {string} password - Decryption password | |
| 86 | * @returns {string|null} Decrypted value or null | |
| 87 | */ | |
| 88 | async get (name, password) { | |
| 89 | const entry = await this._store.get(`vault:${name}`) | |
| 90 | if (!entry) return null | |
| 91 | ||
| 92 | const { salt, nonce, ciphertext } = entry.value | |
| 93 | const key = SecureStorage.deriveKey(password, b4a.from(salt, 'hex')) | |
| 94 | ||
| 95 | try { | |
| 96 | const plaintext = SecureStorage.decrypt( | |
| 97 | b4a.from(ciphertext, 'hex'), | |
| 98 | b4a.from(nonce, 'hex'), | |
| 99 | key | |
| 100 | ) | |
| 101 | return b4a.toString(plaintext) | |
| 102 | } catch { | |
| 103 | return null | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * Check if vault entry exists | |
| 109 | * @param {string} name - Vault entry name | |
| 110 | * @returns {boolean} | |
| 111 | */ | |
| 112 | async has (name) { | |
| 113 | const entry = await this._store.get(`vault:${name}`) | |
| 114 | return entry !== null | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * Delete vault entry | |
| 119 | * @param {string} name - Vault entry name | |
| 120 | */ | |
| 121 | async del (name) { | |
| 122 | await this._store.del(`vault:${name}`) | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | export default SecureStorage |
| … | … | @@ -0,0 +1,40 @@ |
| 1 | { | |
| 2 | "name": "hyper-identity", | |
| 3 | "version": "1.0.0", | |
| 4 | "description": "Generic P2P identity management with Autobase, ANProto, and multi-method authentication", | |
| 5 | "type": "module", | |
| 6 | "main": "index.js", | |
| 7 | "exports": { | |
| 8 | ".": "./index.js", | |
| 9 | "./anproto": "./lib/anproto.js", | |
| 10 | "./auth": "./lib/auth.js", | |
| 11 | "./identity-manager": "./lib/identity-manager.js", | |
| 12 | "./device-linker": "./lib/device-linker.js", | |
| 13 | "./secure-storage": "./lib/secure-storage.js", | |
| 14 | "./auth/identity-registry": "./lib/auth/identity-registry.js", | |
| 15 | "./auth/identity-apply": "./lib/auth/identity-apply.js", | |
| 16 | "./auth/identity-operations": "./lib/auth/identity-operations.js", | |
| 17 | "./auth/challenge-manager": "./lib/auth/challenge-manager.js", | |
| 18 | "./auth/rate-limiter": "./lib/auth/rate-limiter.js", | |
| 19 | "./auth/base-provider": "./lib/auth/base-provider.js", | |
| 20 | "./auth/ssh-parser": "./lib/auth/ssh-parser.js", | |
| 21 | "./auth/providers/web-identity": "./lib/auth/providers/web-identity.js", | |
| 22 | "./auth/providers/keet-identity": "./lib/auth/providers/keet-identity.js", | |
| 23 | "./auth/providers/bearer-token": "./lib/auth/providers/bearer-token.js", | |
| 24 | "./auth/providers/ssh-key": "./lib/auth/providers/ssh-key.js" | |
| 25 | }, | |
| 26 | "dependencies": { | |
| 27 | "autobase": "^7.25.1", | |
| 28 | "b4a": "^1.6.4", | |
| 29 | "bip39-mnemonic": "^2.5.0", | |
| 30 | "blind-pairing": "^2.3.1", | |
| 31 | "corestore": "^7.8.0", | |
| 32 | "hyperbee": "^2.27.3", | |
| 33 | "hypercore": "^11.24.0", | |
| 34 | "hypercore-crypto": "^3.0.0", | |
| 35 | "hyperswarm": "^4.16.0", | |
| 36 | "keet-identity-key": "github:holepunchto/keet-identity-key", | |
| 37 | "sodium-native": "^4.3.1" | |
| 38 | }, | |
| 39 | "license": "MIT" | |
| 40 | } |