| … | … | @@ -1,17 +1,57 @@ |
| 1 | 1 | import { decodeOp, OpTypes, validateOp } from './identity-operations.js' |
| 2 | import b4a from 'b4a' | |
| 2 | 3 | |
| 3 | 4 | /** |
| 4 | * Create the Autobase apply function for identity registry | |
| 5 | * Contract validation — checks that the writer who appended an operation | |
| 6 | * has permission to execute it. This prevents rogue Autobase writers from | |
| 7 | * injecting admin-level operations. | |
| 8 | * | |
| 9 | * Permission model: | |
| 10 | * - CREATE_USER: any writer (but first user gets admin via autoRegister) | |
| 11 | * - UPDATE_USER: writer must be the user themselves or an admin | |
| 12 | * - DELETE_USER: admin only | |
| 13 | * - ADD_IDENTITY / REMOVE_IDENTITY / LINK_IDENTITY: admin or self | |
| 14 | * - UPGRADE_PERMISSIONS: admin only | |
| 15 | * - MERGE_ACCOUNTS: admin only | |
| 16 | * - REVOKE_IDENTITY / RESTORE_IDENTITY: admin only | |
| 17 | * - SET_USERNAME: writer must be the user themselves or an admin | |
| 5 | 18 | * |
| 6 | * This is a deterministic function that processes identity operations | |
| 7 | * into a Hyperbee view. Each operation type modifies the view state. | |
| 19 | * Note: The first writer (bootstrapper) is always trusted for initial setup. | |
| 20 | * Contract validation is skipped if opts.contractValidation is false (default for | |
| 21 | * backwards compatibility during migration). | |
| 22 | */ | |
| 23 | ||
| 24 | // Operations that require admin permissions | |
| 25 | const ADMIN_OPS = new Set([ | |
| 26 | OpTypes.DELETE_USER, | |
| 27 | OpTypes.UPGRADE_PERMISSIONS, | |
| 28 | OpTypes.MERGE_ACCOUNTS, | |
| 29 | OpTypes.REVOKE_IDENTITY, | |
| 30 | OpTypes.RESTORE_IDENTITY, | |
| 31 | OpTypes.DEACTIVATE_USER, | |
| 32 | OpTypes.REACTIVATE_USER | |
| 33 | ]) | |
| 34 | ||
| 35 | // Operations that require admin OR self (userId in op matches writer's user) | |
| 36 | const SELF_OR_ADMIN_OPS = new Set([ | |
| 37 | OpTypes.UPDATE_USER, | |
| 38 | OpTypes.ADD_IDENTITY, | |
| 39 | OpTypes.REMOVE_IDENTITY, | |
| 40 | OpTypes.LINK_IDENTITY, | |
| 41 | OpTypes.SET_USERNAME | |
| 42 | ]) | |
| 43 | ||
| 44 | /** | |
| 45 | * Create the Autobase apply function for identity registry | |
| 8 | 46 | * |
| 9 | 47 | * @param {Object} opts - Options |
| 10 | 48 | * @param {string} opts.prefix - Key prefix for namespacing (default: '') |
| 49 | * @param {boolean} opts.contractValidation - Enable contract validation (default: false for migration compat) | |
| 11 | 50 | * @returns {Function} Autobase apply function |
| 12 | 51 | */ |
| 13 | 52 | export function createApplyFunction (opts = {}) { |
| 14 | 53 | const prefix = opts.prefix || '' |
| 54 | const enableContract = opts.contractValidation === true | |
| 15 | 55 | |
| 16 | 56 | return async function apply (batch, view, base) { |
| 17 | 57 | const b = view.batch({ update: false }) |
| … | … | @@ -26,20 +66,43 @@ export function createApplyFunction (opts = {}) { |
| 26 | 66 | continue |
| 27 | 67 | } |
| 28 | 68 | |
| 69 | // Contract validation: check writer permissions | |
| 70 | if (enableContract && node.from) { | |
| 71 | const writerKey = b4a.toString(node.from.key, 'hex') | |
| 72 | const violation = await checkContract(b, prefix, writerKey, op) | |
| 73 | if (violation) { | |
| 74 | console.error(`[IdentityApply] Contract violation: ${violation} (writer: ${writerKey.slice(0, 16)}..., op: ${op.type})`) | |
| 75 | continue // Drop the operation | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 29 | 79 | switch (op.type) { |
| 30 | 80 | case OpTypes.CREATE_USER: { |
| 31 | 81 | const key = `${prefix}user:${op.userId}` |
| 32 | 82 | const existing = await b.get(key) |
| 33 | 83 | if (existing) continue // Don't overwrite |
| 34 | 84 | |
| 85 | // Deterministic admin bootstrap: the FIRST user created gets | |
| 86 | // admin permissions. All subsequent users get read-only. | |
| 87 | // This decision happens in the apply function (deterministic, | |
| 88 | // linearized by Autobase) — NOT in the caller (which would be | |
| 89 | // racy in multi-writer scenarios). | |
| 90 | const isFirstUser = !(await hasAnyUser(b, prefix)) | |
| 91 | const permissions = isFirstUser | |
| 92 | ? ['read', 'write', 'admin'] | |
| 93 | : ['read'] | |
| 94 | const resources = isFirstUser | |
| 95 | ? [{ name: '*', permissions: ['read', 'write', 'admin'] }] | |
| 96 | : [{ name: '*', permissions: ['read'] }] | |
| 97 | ||
| 35 | 98 | const user = { |
| 36 | 99 | userId: op.userId, |
| 37 | 100 | username: op.username || null, |
| 38 | 101 | name: op.name || 'Anonymous', |
| 39 | 102 | email: op.email || null, |
| 40 | 103 | profilePicture: op.profilePicture || null, |
| 41 | globalPermissions: op.globalPermissions || ['read'], | |
| 42 | resources: op.resources || [{ name: '*', permissions: ['read'] }], | |
| 104 | globalPermissions: permissions, | |
| 105 | resources, | |
| 43 | 106 | identities: op.identities || [], |
| 44 | 107 | createdAt: op.createdAt || Date.now(), |
| 45 | 108 | updatedAt: op.updatedAt || Date.now() |
| … | … | @@ -211,6 +274,42 @@ export function createApplyFunction (opts = {}) { |
| 211 | 274 | break |
| 212 | 275 | } |
| 213 | 276 | |
| 277 | case OpTypes.DEACTIVATE_USER: { | |
| 278 | const key = `${prefix}user:${op.userId}` | |
| 279 | const existing = await b.get(key) | |
| 280 | if (!existing) continue | |
| 281 | ||
| 282 | const user = existing.value | |
| 283 | ||
| 284 | // Guard: can't deactivate the last admin | |
| 285 | if (user.globalPermissions?.includes('admin')) { | |
| 286 | let adminCount = 0 | |
| 287 | for await (const e of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff` })) { | |
| 288 | if (e.value.globalPermissions?.includes('admin') && !e.value.deactivatedAt && !e.value.mergedInto) adminCount++ | |
| 289 | } | |
| 290 | if (adminCount <= 1) continue // Silently skip — can't deactivate last admin | |
| 291 | } | |
| 292 | ||
| 293 | user.deactivatedAt = Date.now() | |
| 294 | user.deactivatedReason = op.reason || null | |
| 295 | user.updatedAt = Date.now() | |
| 296 | await b.put(key, user) | |
| 297 | break | |
| 298 | } | |
| 299 | ||
| 300 | case OpTypes.REACTIVATE_USER: { | |
| 301 | const key = `${prefix}user:${op.userId}` | |
| 302 | const existing = await b.get(key) | |
| 303 | if (!existing) continue | |
| 304 | ||
| 305 | const user = existing.value | |
| 306 | delete user.deactivatedAt | |
| 307 | delete user.deactivatedReason | |
| 308 | user.updatedAt = Date.now() | |
| 309 | await b.put(key, user) | |
| 310 | break | |
| 311 | } | |
| 312 | ||
| 214 | 313 | case OpTypes.SET_USERNAME: { |
| 215 | 314 | const key = `${prefix}user:${op.userId}` |
| 216 | 315 | const existing = await b.get(key) |
| … | … | @@ -245,3 +344,82 @@ export function createApplyFunction (opts = {}) { |
| 245 | 344 | } |
| 246 | 345 | } |
| 247 | 346 | } |
| 347 | ||
| 348 | /** | |
| 349 | * Check if a writer has permission to execute an operation. | |
| 350 | * | |
| 351 | * Resolves the writer's key to a user by scanning all users' identities, | |
| 352 | * then checks if that user has the required permission level. | |
| 353 | * | |
| 354 | * @param {Object} b - Hyperbee batch | |
| 355 | * @param {string} prefix - Key prefix | |
| 356 | * @param {string} writerKey - Writer's Hypercore public key (hex) | |
| 357 | * @param {Object} op - The operation being executed | |
| 358 | * @returns {string|null} Violation reason, or null if authorized | |
| 359 | */ | |
| 360 | async function checkContract (b, prefix, writerKey, op) { | |
| 361 | // CREATE_USER is always allowed (autoRegister handles first-user-admin) | |
| 362 | if (op.type === OpTypes.CREATE_USER) return null | |
| 363 | ||
| 364 | // Find the user associated with this writer key | |
| 365 | const writerUser = await resolveWriterUser(b, prefix, writerKey) | |
| 366 | ||
| 367 | // If no user found for this writer, check if ANY users exist yet. | |
| 368 | // During bootstrap (empty registry), allow all ops from the first writer. | |
| 369 | if (!writerUser) { | |
| 370 | const hasUsers = await hasAnyUser(b, prefix) | |
| 371 | if (!hasUsers) return null // Bootstrap: empty registry, first writer is trusted | |
| 372 | return `Writer ${writerKey.slice(0, 16)} has no associated user` | |
| 373 | } | |
| 374 | ||
| 375 | const isAdmin = writerUser.globalPermissions && writerUser.globalPermissions.includes('admin') | |
| 376 | ||
| 377 | // Admin-only operations | |
| 378 | if (ADMIN_OPS.has(op.type)) { | |
| 379 | if (!isAdmin) return `${op.type} requires admin permissions` | |
| 380 | return null | |
| 381 | } | |
| 382 | ||
| 383 | // Self-or-admin operations | |
| 384 | if (SELF_OR_ADMIN_OPS.has(op.type)) { | |
| 385 | if (isAdmin) return null | |
| 386 | if (op.userId === writerUser.userId) return null | |
| 387 | return `${op.type} requires admin or self (writer is ${writerUser.userId}, op targets ${op.userId})` | |
| 388 | } | |
| 389 | ||
| 390 | return null // Unknown op types pass through (validated by validateOp) | |
| 391 | } | |
| 392 | ||
| 393 | /** | |
| 394 | * Find the user associated with a writer's Hypercore key. | |
| 395 | * Looks up the writer key as a pubkey identity, then resolves to user. | |
| 396 | */ | |
| 397 | async function resolveWriterUser (b, prefix, writerKey) { | |
| 398 | // Check pubkey reverse lookup (writer key may be registered as an identity) | |
| 399 | const pubkeyEntry = await b.get(`${prefix}pubkey:${writerKey}`) | |
| 400 | if (pubkeyEntry) { | |
| 401 | const identityLink = await b.get(`${prefix}user-identity:${pubkeyEntry.value.identityId}`) | |
| 402 | if (identityLink) { | |
| 403 | const userEntry = await b.get(`${prefix}user:${identityLink.value.userId}`) | |
| 404 | if (userEntry) return userEntry.value | |
| 405 | } | |
| 406 | } | |
| 407 | ||
| 408 | // Scan all users to find one with this writer key in their identities | |
| 409 | // (fallback for when the key isn't indexed as a pubkey) | |
| 410 | for await (const entry of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff` })) { | |
| 411 | const user = entry.value | |
| 412 | if (user.identities && user.identities.includes(writerKey)) { | |
| 413 | return user | |
| 414 | } | |
| 415 | } | |
| 416 | ||
| 417 | return null | |
| 418 | } | |
| 419 | ||
| 420 | async function hasAnyUser (b, prefix) { | |
| 421 | for await (const _ of b.createReadStream({ gt: `${prefix}user:`, lt: `${prefix}user:\xff`, limit: 1 })) { | |
| 422 | return true | |
| 423 | } | |
| 424 | return false | |
| 425 | } |
| … | … | @@ -14,7 +14,9 @@ export const OpTypes = { |
| 14 | 14 | MERGE_ACCOUNTS: 'merge-accounts', |
| 15 | 15 | REVOKE_IDENTITY: 'revoke-identity', |
| 16 | 16 | RESTORE_IDENTITY: 'restore-identity', |
| 17 | SET_USERNAME: 'set-username' | |
| 17 | SET_USERNAME: 'set-username', | |
| 18 | DEACTIVATE_USER: 'deactivate-user', | |
| 19 | REACTIVATE_USER: 'reactivate-user' | |
| 18 | 20 | } |
| 19 | 21 | |
| 20 | 22 | /** |
| … | … | @@ -89,6 +91,14 @@ export function validateOp (op) { |
| 89 | 91 | if (!op.username) return { valid: false, error: 'Missing username' } |
| 90 | 92 | return { valid: true } |
| 91 | 93 | |
| 94 | case OpTypes.DEACTIVATE_USER: | |
| 95 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 96 | return { valid: true } | |
| 97 | ||
| 98 | case OpTypes.REACTIVATE_USER: | |
| 99 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 100 | return { valid: true } | |
| 101 | ||
| 92 | 102 | default: |
| 93 | 103 | return { valid: false, error: `Unknown op type: ${op.type}` } |
| 94 | 104 | } |
| … | … | @@ -89,7 +89,10 @@ export class IdentityRegistry { |
| 89 | 89 | } |
| 90 | 90 | await this._base.append(encodeOp(op)) |
| 91 | 91 | await this._base.update() |
| 92 | return op | |
| 92 | // Read back from view — the apply function may have modified permissions | |
| 93 | // (e.g., first user gets admin promotion) | |
| 94 | const user = await this.getUser(userId) | |
| 95 | return user || op // Fallback to op if view read fails | |
| 93 | 96 | } |
| 94 | 97 | |
| 95 | 98 | /** |
| … | … | @@ -163,6 +166,19 @@ export class IdentityRegistry { |
| 163 | 166 | async registerIdentity (identity) { |
| 164 | 167 | if (!this.db) throw new Error('No direct-write db configured') |
| 165 | 168 | await this.db.put(`identity:${identity.identityId}`, identity) |
| 169 | ||
| 170 | // Create reverse lookup indexes based on identity method | |
| 171 | if (identity.method === 'ssh-key') { | |
| 172 | const fingerprint = identity.credentials && identity.credentials.fingerprint | |
| 173 | if (fingerprint) { | |
| 174 | await this.db.put(`sshkey:${fingerprint}`, { identityId: identity.identityId }) | |
| 175 | } | |
| 176 | } else if (identity.method === 'noise-key') { | |
| 177 | const noiseKey = identity.credentials && identity.credentials.publicKey | |
| 178 | if (noiseKey) { | |
| 179 | await this.db.put(`noisekey:${noiseKey}`, { identityId: identity.identityId }) | |
| 180 | } | |
| 181 | } | |
| 166 | 182 | if (identity.publicKey) { |
| 167 | 183 | await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId }) |
| 168 | 184 | } |
| … | … | @@ -188,9 +204,53 @@ export class IdentityRegistry { |
| 188 | 204 | } |
| 189 | 205 | |
| 190 | 206 | /** |
| 191 | * Link identity to user via Autobase | |
| 207 | * Get identity by SSH key fingerprint | |
| 208 | */ | |
| 209 | async getIdentityBySSHKey (fingerprint) { | |
| 210 | if (!this.db) return null | |
| 211 | const entry = await this.db.get(`sshkey:${fingerprint}`) | |
| 212 | if (!entry) return null | |
| 213 | return this.getIdentity(entry.value.identityId) | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Get identity by Noise protocol public key | |
| 192 | 218 | */ |
| 193 | async linkIdentityToUser (identityId, userId) { | |
| 219 | async getIdentityByNoiseKey (noiseKey) { | |
| 220 | if (!this.db) return null | |
| 221 | const entry = await this.db.get(`noisekey:${noiseKey}`) | |
| 222 | if (!entry) return null | |
| 223 | return this.getIdentity(entry.value.identityId) | |
| 224 | } | |
| 225 | ||
| 226 | /** | |
| 227 | * Link identity to user via Autobase | |
| 228 | * | |
| 229 | * SECURITY: This method moves an identity to a user account. Callers MUST | |
| 230 | * verify ownership before calling (e.g., via challenge-response signature). | |
| 231 | * The registry itself cannot verify ownership because it doesn't have the | |
| 232 | * request context. Auth providers are responsible for proving key ownership | |
| 233 | * before calling this. | |
| 234 | * | |
| 235 | * @param {string} identityId - Identity to link | |
| 236 | * @param {string} userId - User to link to | |
| 237 | * @param {Object} [opts] - Options | |
| 238 | * @param {boolean} [opts.verified] - Set to true to confirm caller has verified ownership | |
| 239 | */ | |
| 240 | async linkIdentityToUser (identityId, userId, opts = {}) { | |
| 241 | // Guard: require explicit verification flag to prevent accidental misuse | |
| 242 | if (!opts.verified) { | |
| 243 | throw new Error('linkIdentityToUser requires opts.verified=true — caller must verify key ownership first') | |
| 244 | } | |
| 245 | ||
| 246 | // Check if identity is already linked to a different user | |
| 247 | if (this.db) { | |
| 248 | const identity = await this.getIdentity(identityId) | |
| 249 | if (identity && identity.userId && identity.userId !== userId) { | |
| 250 | throw new Error(`Identity already linked to user ${identity.userId}`) | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 194 | 254 | // Update direct-write db first (source of truth for identity records) |
| 195 | 255 | if (this.db) { |
| 196 | 256 | const identity = await this.getIdentity(identityId) |
| … | … | @@ -270,6 +330,10 @@ export class IdentityRegistry { |
| 270 | 330 | /** |
| 271 | 331 | * Check if any admin user exists |
| 272 | 332 | */ |
| 333 | async hasAnyAdmin () { | |
| 334 | return this._hasAnyAdmin() | |
| 335 | } | |
| 336 | ||
| 273 | 337 | async _hasAnyAdmin () { |
| 274 | 338 | for await (const entry of this._view.createReadStream({ |
| 275 | 339 | gte: `${this._prefix}user:`, |
| … | … | @@ -282,6 +346,44 @@ export class IdentityRegistry { |
| 282 | 346 | return false |
| 283 | 347 | } |
| 284 | 348 | |
| 349 | // ==================== DEACTIVATION ==================== | |
| 350 | ||
| 351 | /** | |
| 352 | * Deactivate user — blocks auth while preserving data | |
| 353 | * @param {string} userId | |
| 354 | * @param {{ reason?: string }} opts | |
| 355 | */ | |
| 356 | async deactivateUser (userId, opts = {}) { | |
| 357 | await this._base.append(encodeOp({ | |
| 358 | type: OpTypes.DEACTIVATE_USER, | |
| 359 | userId, | |
| 360 | reason: opts.reason || null | |
| 361 | })) | |
| 362 | await this._base.update() | |
| 363 | } | |
| 364 | ||
| 365 | /** | |
| 366 | * Reactivate user — re-enable auth | |
| 367 | * @param {string} userId | |
| 368 | */ | |
| 369 | async reactivateUser (userId) { | |
| 370 | await this._base.append(encodeOp({ | |
| 371 | type: OpTypes.REACTIVATE_USER, | |
| 372 | userId | |
| 373 | })) | |
| 374 | await this._base.update() | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Check if user is deactivated | |
| 379 | * @param {string} userId | |
| 380 | * @returns {Promise<boolean>} | |
| 381 | */ | |
| 382 | async isDeactivated (userId) { | |
| 383 | const user = await this.getUser(userId) | |
| 384 | return !!(user && user.deactivatedAt) | |
| 385 | } | |
| 386 | ||
| 285 | 387 | // ==================== ACCOUNT MERGE ==================== |
| 286 | 388 | |
| 287 | 389 | /** |
| … | … | @@ -366,17 +468,14 @@ export class IdentityRegistry { |
| 366 | 468 | * @returns {{ user: Object, identity: Object }} |
| 367 | 469 | */ |
| 368 | 470 | async autoRegister (identityData) { |
| 369 | const hasAdmin = await this._hasAnyAdmin() | |
| 370 | ||
| 371 | const permissions = hasAdmin ? ['read'] : ['read', 'write', 'admin'] | |
| 372 | const resources = hasAdmin | |
| 373 | ? [{ name: '*', permissions: ['read'] }] | |
| 374 | : [{ name: '*', permissions: ['read', 'write', 'admin'] }] | |
| 375 | ||
| 471 | // SECURITY: Always request read-only permissions. The apply function | |
| 472 | // deterministically promotes the FIRST CREATE_USER to admin based on | |
| 473 | // Autobase linearization order. This eliminates the TOCTOU race | |
| 474 | // condition where two writers both see "no admin" and both get admin. | |
| 376 | 475 | const user = await this.createUser({ |
| 377 | 476 | name: identityData.name || 'Anonymous', |
| 378 | globalPermissions: permissions, | |
| 379 | resources | |
| 477 | globalPermissions: ['read'], | |
| 478 | resources: [{ name: '*', permissions: ['read'] }] | |
| 380 | 479 | }) |
| 381 | 480 | |
| 382 | 481 | // Register identity in direct-write store |
| … | … | @@ -389,8 +488,8 @@ export class IdentityRegistry { |
| 389 | 488 | } |
| 390 | 489 | await this.registerIdentity(identity) |
| 391 | 490 | |
| 392 | // Link identity to user | |
| 393 | await this.linkIdentityToUser(identity.identityId, user.userId) | |
| 491 | // Link identity to user (safe: autoRegister is called after auth provider verification) | |
| 492 | await this.linkIdentityToUser(identity.identityId, user.userId, { verified: true }) | |
| 394 | 493 | |
| 395 | 494 | return { user, identity } |
| 396 | 495 | } |
| … | … | @@ -504,6 +603,21 @@ export class IdentityRegistry { |
| 504 | 603 | return !entry |
| 505 | 604 | } |
| 506 | 605 | |
| 606 | /** | |
| 607 | * List all identities from direct-write store (admin) | |
| 608 | * @returns {Promise<Object[]>} | |
| 609 | */ | |
| 610 | async listAllIdentities () { | |
| 611 | if (!this.db) return [] | |
| 612 | const identities = [] | |
| 613 | for await (const entry of this.db.createReadStream({ | |
| 614 | gte: 'identity:', lt: 'identity:\xff' | |
| 615 | })) { | |
| 616 | identities.push(entry.value) | |
| 617 | } | |
| 618 | return identities | |
| 619 | } | |
| 620 | ||
| 507 | 621 | // ==================== PROFILE UPDATES ==================== |
| 508 | 622 | |
| 509 | 623 | /** |
| … | … | @@ -643,7 +757,7 @@ export class IdentityRegistry { |
| 643 | 757 | createdAt: Date.now() |
| 644 | 758 | } |
| 645 | 759 | await this.registerIdentity(identity) |
| 646 | await this.linkIdentityToUser(publicKey, userId) | |
| 760 | await this.linkIdentityToUser(publicKey, userId, { verified: true }) | |
| 647 | 761 | |
| 648 | 762 | return identity |
| 649 | 763 | } |
| … | … | @@ -29,8 +29,8 @@ export class SSHKeyProvider extends BaseProvider { |
| 29 | 29 | async authenticate (credentials) { |
| 30 | 30 | const { sshPublicKey, sshSignature, challengeId } = credentials |
| 31 | 31 | |
| 32 | // Verify challenge exists and is valid | |
| 33 | const challenge = await this.challengeManager.verify(challengeId, challengeId) | |
| 32 | // Look up the challenge by ID, then verify the signature against the challenge value | |
| 33 | const challenge = await this.challengeManager.get(challengeId) | |
| 34 | 34 | if (!challenge) throw new Error('Invalid or expired challenge') |
| 35 | 35 | |
| 36 | 36 | // Parse SSH key |
| … | … | @@ -61,15 +61,30 @@ export class SSHKeyProvider extends BaseProvider { |
| 61 | 61 | |
| 62 | 62 | if (!valid) throw new Error('Invalid SSH signature') |
| 63 | 63 | |
| 64 | // Consume the challenge (single-use) | |
| 65 | await this.challengeManager.consume(challengeId) | |
| 66 | ||
| 64 | 67 | // Use SSH public key string as identity ID |
| 65 | 68 | const identityId = `ssh:${sshPublicKey.substring(0, 64)}` |
| 66 | 69 | let identity = await this.registry.getIdentity(identityId) |
| 67 | 70 | |
| 71 | // Check revocation before allowing auth | |
| 72 | if (identity) { | |
| 73 | const revoked = await this.registry.isRevoked(identityId) | |
| 74 | if (revoked) throw new Error('Identity has been revoked') | |
| 75 | } | |
| 76 | ||
| 68 | 77 | if (!identity) { |
| 78 | // Compute fingerprint for reverse lookup index | |
| 79 | const fingerprint = SSHKeyParser.fingerprint | |
| 80 | ? SSHKeyParser.fingerprint(sshPublicKey) | |
| 81 | : `ssh:${sshPublicKey.substring(0, 64)}` | |
| 82 | ||
| 69 | 83 | await this.registry.registerIdentity({ |
| 70 | 84 | identityId, |
| 71 | 85 | method: 'ssh-key', |
| 72 | 86 | publicKey: sshPublicKey, |
| 87 | credentials: { fingerprint, type: parsedKey.type }, | |
| 73 | 88 | keyType: parsedKey.type, |
| 74 | 89 | createdAt: Date.now() |
| 75 | 90 | }) |
| … | … | @@ -31,29 +31,39 @@ export class WebIdentityProvider extends BaseProvider { |
| 31 | 31 | async authenticate (credentials) { |
| 32 | 32 | const { publicKey, signature, challengeId } = credentials |
| 33 | 33 | |
| 34 | // Look up identity — existing users may have already proven key ownership | |
| 35 | let identity = await this.registry.getIdentityByPublicKey(publicKey) | |
| 36 | ||
| 37 | // Verify signature if provided (challenge-response proves key ownership) | |
| 38 | let signatureVerified = false | |
| 34 | 39 | if (signature && challengeId) { |
| 35 | // Look up the challenge by ID | |
| 36 | 40 | const challenge = await this.challengeManager.get(challengeId) |
| 37 | 41 | if (!challenge) throw new Error('Invalid or expired challenge') |
| 38 | 42 | |
| 39 | // Verify signature against the challenge value | |
| 40 | 43 | const valid = ANProto.verify(challenge.challenge, signature, publicKey) |
| 41 | 44 | if (!valid) throw new Error('Invalid signature') |
| 42 | 45 | |
| 43 | // Consume the challenge (single-use) | |
| 44 | 46 | await this.challengeManager.consume(challengeId) |
| 47 | signatureVerified = true | |
| 45 | 48 | } |
| 46 | 49 | |
| 47 | // Look up identity | |
| 48 | let identity = await this.registry.getIdentityByPublicKey(publicKey) | |
| 50 | // Check revocation | |
| 51 | if (identity) { | |
| 52 | const revoked = await this.registry.isRevoked(identity.identityId) | |
| 53 | if (revoked) throw new Error('Identity has been revoked') | |
| 54 | } | |
| 49 | 55 | |
| 50 | 56 | if (!identity) { |
| 57 | // SECURITY: New identities MUST prove key ownership via signature. | |
| 58 | // Without this check, anyone could register any public key they don't own. | |
| 59 | if (!signatureVerified) { | |
| 60 | throw new Error('Signature required for new identity registration') | |
| 61 | } | |
| 62 | ||
| 51 | 63 | if (!this.autoRegister) { |
| 52 | 64 | throw new Error('Unknown identity. Register first.') |
| 53 | 65 | } |
| 54 | 66 | |
| 55 | // Auto-register: creates user + identity atomically | |
| 56 | // First user gets admin, subsequent get read-only | |
| 57 | 67 | const result = await this.registry.autoRegister({ |
| 58 | 68 | identityId: publicKey, |
| 59 | 69 | method: 'web-identity', |
| … | … | @@ -1,40 +1,130 @@ |
| 1 | 1 | import crypto from 'hypercore-crypto' |
| 2 | 2 | import sodium from 'sodium-native' |
| 3 | 3 | import b4a from 'b4a' |
| 4 | import { join } from 'bare-path' | |
| 5 | import { SecureStorage } from './secure-storage.js' | |
| 4 | 6 | |
| 5 | 7 | /** |
| 6 | 8 | * IdentityManager — Persistent identity keypairs + mnemonic proof chains |
| 7 | 9 | * |
| 8 | * Manages: | |
| 9 | * - Instance keypair (persistent Ed25519 for this device) | |
| 10 | * - Mnemonic-based identity (keet-identity-key proof chains) | |
| 11 | * - Device attestation for multi-writer | |
| 10 | * SECURITY: Private keys and mnemonics are stored encrypted on the filesystem | |
| 11 | * using Argon2id + XSalsa20-Poly1305 (via SecureStorage). They are NEVER | |
| 12 | * written to Hyperbee or any replicable storage. | |
| 13 | * | |
| 14 | * Storage layout: | |
| 15 | * {storagePath}/keypair.enc — encrypted Ed25519 keypair (Argon2id vault) | |
| 16 | * {storagePath}/mnemonic.enc — encrypted BIP39 mnemonic (Argon2id vault) | |
| 17 | * | |
| 18 | * Passphrase sources (in priority order): | |
| 19 | * 1. opts.passphrase (Buffer or string) | |
| 20 | * 2. process.env.HYPER_IDENTITY_PASSPHRASE | |
| 21 | * 3. null → falls back to legacy Hyperbee storage (migration path) | |
| 12 | 22 | */ |
| 13 | 23 | export class IdentityManager { |
| 14 | 24 | constructor (store, opts = {}) { |
| 15 | this._store = store // Hyperbee or similar key-value store | |
| 25 | this._store = store // Hyperbee (for legacy migration reads only) | |
| 26 | this._storagePath = opts.storagePath || null | |
| 27 | this._passphrase = opts.passphrase | |
| 28 | ? (typeof opts.passphrase === 'string' ? Buffer.from(opts.passphrase) : opts.passphrase) | |
| 29 | : (process.env.HYPER_IDENTITY_PASSPHRASE | |
| 30 | ? Buffer.from(process.env.HYPER_IDENTITY_PASSPHRASE) | |
| 31 | : null) | |
| 16 | 32 | this._keypair = null |
| 17 | 33 | this._mnemonic = null |
| 18 | 34 | this._identityKey = null |
| 19 | this._IdentityKey = opts.IdentityKey || null // keet-identity-key class (optional) | |
| 35 | this._IdentityKey = opts.IdentityKey || null | |
| 36 | this._encrypted = false // Whether using encrypted storage | |
| 20 | 37 | } |
| 21 | 38 | |
| 22 | 39 | /** |
| 23 | 40 | * Initialize — load or generate persistent keypair |
| 24 | 41 | */ |
| 25 | 42 | async ready () { |
| 26 | // Try to load existing keypair | |
| 43 | // Try encrypted storage first (if storagePath and passphrase are available) | |
| 44 | if (this._storagePath && this._passphrase) { | |
| 45 | await this._loadEncrypted() | |
| 46 | return | |
| 47 | } | |
| 48 | ||
| 49 | // Fall back to legacy Hyperbee storage (migration path) | |
| 50 | await this._loadLegacy() | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Load keys from encrypted vaults | |
| 55 | */ | |
| 56 | async _loadEncrypted () { | |
| 57 | this._encrypted = true | |
| 58 | ||
| 59 | const keypairVault = new SecureStorage(join(this._storagePath, 'keypair.enc')) | |
| 60 | const mnemonicVault = new SecureStorage(join(this._storagePath, 'mnemonic.enc')) | |
| 61 | ||
| 62 | // Load or generate keypair | |
| 63 | if (keypairVault.exists()) { | |
| 64 | const plain = await keypairVault.decrypt(this._passphrase) | |
| 65 | const data = JSON.parse(plain.toString()) | |
| 66 | this._keypair = { | |
| 67 | publicKey: b4a.from(data.publicKey, 'base64'), | |
| 68 | secretKey: b4a.from(data.secretKey, 'base64') | |
| 69 | } | |
| 70 | sodium.sodium_memzero(plain) | |
| 71 | } else { | |
| 72 | this._keypair = crypto.keyPair() | |
| 73 | const plain = Buffer.from(JSON.stringify({ | |
| 74 | publicKey: b4a.toString(this._keypair.publicKey, 'base64'), | |
| 75 | secretKey: b4a.toString(this._keypair.secretKey, 'base64') | |
| 76 | })) | |
| 77 | await keypairVault.encrypt(plain, this._passphrase) | |
| 78 | sodium.sodium_memzero(plain) | |
| 79 | } | |
| 80 | ||
| 81 | // Load mnemonic if vault exists | |
| 82 | if (mnemonicVault.exists()) { | |
| 83 | const plain = await mnemonicVault.decrypt(this._passphrase) | |
| 84 | this._mnemonic = plain.toString() | |
| 85 | sodium.sodium_memzero(plain) | |
| 86 | await this._loadIdentityKey() | |
| 87 | } | |
| 88 | ||
| 89 | // Migrate from legacy Hyperbee if vaults didn't exist but Hyperbee has keys | |
| 90 | if (!keypairVault.exists()) { | |
| 91 | const existing = await this._store.get('instance-keypair') | |
| 92 | if (existing) { | |
| 93 | // Migrate: encrypt the Hyperbee keypair into vault, then delete from Hyperbee | |
| 94 | console.log('[IdentityManager] Migrating keypair from Hyperbee to encrypted vault') | |
| 95 | const val = existing.value || existing | |
| 96 | const encoding = (val.publicKey && val.publicKey.length === 44) ? 'base64' : 'hex' | |
| 97 | this._keypair = { | |
| 98 | publicKey: b4a.from(val.publicKey, encoding), | |
| 99 | secretKey: b4a.from(val.secretKey, encoding) | |
| 100 | } | |
| 101 | const plain = Buffer.from(JSON.stringify({ | |
| 102 | publicKey: b4a.toString(this._keypair.publicKey, 'base64'), | |
| 103 | secretKey: b4a.toString(this._keypair.secretKey, 'base64') | |
| 104 | })) | |
| 105 | await keypairVault.encrypt(plain, this._passphrase) | |
| 106 | sodium.sodium_memzero(plain) | |
| 107 | // Delete from Hyperbee (no longer needed) | |
| 108 | await this._store.del('instance-keypair') | |
| 109 | } | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Load keys from legacy Hyperbee storage (unencrypted) | |
| 115 | */ | |
| 116 | async _loadLegacy () { | |
| 117 | this._encrypted = false | |
| 118 | ||
| 27 | 119 | const existing = await this._store.get('instance-keypair') |
| 28 | 120 | if (existing) { |
| 29 | 121 | const val = existing.value || existing |
| 30 | // Support both base64 (new) and hex (legacy) storage formats | |
| 31 | 122 | const encoding = (val.publicKey && val.publicKey.length === 44) ? 'base64' : 'hex' |
| 32 | 123 | this._keypair = { |
| 33 | 124 | publicKey: b4a.from(val.publicKey, encoding), |
| 34 | 125 | secretKey: b4a.from(val.secretKey, encoding) |
| 35 | 126 | } |
| 36 | 127 | } else { |
| 37 | // Generate new persistent keypair, store as base64 | |
| 38 | 128 | this._keypair = crypto.keyPair() |
| 39 | 129 | await this._store.put('instance-keypair', { |
| 40 | 130 | publicKey: b4a.toString(this._keypair.publicKey, 'base64'), |
| … | … | @@ -42,7 +132,6 @@ export class IdentityManager { |
| 42 | 132 | }) |
| 43 | 133 | } |
| 44 | 134 | |
| 45 | // Load mnemonic if stored | |
| 46 | 135 | const mnemonicEntry = await this._store.get('identity-mnemonic') |
| 47 | 136 | if (mnemonicEntry) { |
| 48 | 137 | this._mnemonic = mnemonicEntry.value |
| … | … | @@ -50,9 +139,6 @@ export class IdentityManager { |
| 50 | 139 | } |
| 51 | 140 | } |
| 52 | 141 | |
| 53 | /** | |
| 54 | * Load keet-identity-key from stored mnemonic | |
| 55 | */ | |
| 56 | 142 | async _loadIdentityKey () { |
| 57 | 143 | if (!this._IdentityKey || !this._mnemonic) return |
| 58 | 144 | try { |
| … | … | @@ -62,24 +148,15 @@ export class IdentityManager { |
| 62 | 148 | } |
| 63 | 149 | } |
| 64 | 150 | |
| 65 | /** | |
| 66 | * Get instance public key as hex string | |
| 67 | */ | |
| 68 | 151 | getPublicKeyHex () { |
| 69 | 152 | if (!this._keypair) return null |
| 70 | 153 | return b4a.toString(this._keypair.publicKey, 'hex') |
| 71 | 154 | } |
| 72 | 155 | |
| 73 | /** | |
| 74 | * Get instance public key buffer | |
| 75 | */ | |
| 76 | 156 | getPublicKey () { |
| 77 | 157 | return this._keypair ? this._keypair.publicKey : null |
| 78 | 158 | } |
| 79 | 159 | |
| 80 | /** | |
| 81 | * Get instance keypair | |
| 82 | */ | |
| 83 | 160 | getKeypair () { |
| 84 | 161 | return this._keypair |
| 85 | 162 | } |
| … | … | @@ -90,88 +167,72 @@ export class IdentityManager { |
| 90 | 167 | */ |
| 91 | 168 | async setMnemonic (mnemonic) { |
| 92 | 169 | this._mnemonic = mnemonic |
| 93 | await this._store.put('identity-mnemonic', mnemonic) | |
| 170 | ||
| 171 | if (this._encrypted && this._storagePath && this._passphrase) { | |
| 172 | // Store encrypted | |
| 173 | const vault = new SecureStorage(join(this._storagePath, 'mnemonic.enc')) | |
| 174 | const plain = Buffer.from(mnemonic) | |
| 175 | await vault.encrypt(plain, this._passphrase) | |
| 176 | sodium.sodium_memzero(plain) | |
| 177 | } else { | |
| 178 | // Legacy: store in Hyperbee (unencrypted) | |
| 179 | await this._store.put('identity-mnemonic', mnemonic) | |
| 180 | } | |
| 181 | ||
| 94 | 182 | await this._loadIdentityKey() |
| 95 | 183 | } |
| 96 | 184 | |
| 97 | /** | |
| 98 | * Get mnemonic (if stored) | |
| 99 | */ | |
| 100 | 185 | getMnemonic () { |
| 101 | 186 | return this._mnemonic |
| 102 | 187 | } |
| 103 | 188 | |
| 104 | /** | |
| 105 | * Get keet-identity-key instance (if mnemonic loaded) | |
| 106 | */ | |
| 107 | 189 | getIdentityKey () { |
| 108 | 190 | return this._identityKey |
| 109 | 191 | } |
| 110 | 192 | |
| 111 | /** | |
| 112 | * Bootstrap device attestation proof chain | |
| 113 | * @param {Buffer} devicePublicKey - This device's public key | |
| 114 | * @returns {Object} Proof chain for device attestation | |
| 115 | */ | |
| 116 | 193 | async bootstrap (devicePublicKey) { |
| 117 | 194 | if (!this._identityKey) throw new Error('No identity key loaded') |
| 118 | 195 | return this._identityKey.bootstrap(devicePublicKey) |
| 119 | 196 | } |
| 120 | 197 | |
| 121 | /** | |
| 122 | * Sign data with instance keypair | |
| 123 | * @param {Buffer} data - Data to sign | |
| 124 | * @returns {Buffer} Signature | |
| 125 | */ | |
| 126 | 198 | sign (data) { |
| 127 | 199 | if (!this._keypair) throw new Error('Not initialized') |
| 128 | 200 | return crypto.sign(data, this._keypair.secretKey) |
| 129 | 201 | } |
| 130 | 202 | |
| 131 | /** | |
| 132 | * Verify signature with instance public key | |
| 133 | * @param {Buffer} data - Original data | |
| 134 | * @param {Buffer} signature - Signature to verify | |
| 135 | * @returns {boolean} | |
| 136 | */ | |
| 137 | 203 | verify (data, signature) { |
| 138 | 204 | if (!this._keypair) throw new Error('Not initialized') |
| 139 | 205 | return crypto.verify(data, signature, this._keypair.publicKey) |
| 140 | 206 | } |
| 141 | 207 | |
| 142 | /** | |
| 143 | * Get instance public key as 44-char base64 | |
| 144 | */ | |
| 145 | 208 | getPublicKeyBase64 () { |
| 146 | 209 | if (!this._keypair) return null |
| 147 | 210 | return b4a.toString(this._keypair.publicKey, 'base64') |
| 148 | 211 | } |
| 149 | 212 | |
| 150 | /** | |
| 151 | * Get instance keypair as 132-char base64 string (ANProto format) | |
| 152 | */ | |
| 153 | 213 | getKeypairString () { |
| 154 | 214 | if (!this._keypair) return null |
| 155 | 215 | return b4a.toString(this._keypair.publicKey, 'base64') + |
| 156 | 216 | b4a.toString(this._keypair.secretKey, 'base64') |
| 157 | 217 | } |
| 158 | 218 | |
| 159 | /** | |
| 160 | * Get identity public key (from mnemonic, distinct from instance key) | |
| 161 | */ | |
| 162 | 219 | getIdentityPublicKey () { |
| 163 | 220 | if (!this._identityKey) return null |
| 164 | 221 | return this._identityKey.publicKey |
| 165 | 222 | } |
| 166 | 223 | |
| 167 | /** | |
| 168 | * Get identity public key as hex | |
| 169 | */ | |
| 170 | 224 | getIdentityPublicKeyHex () { |
| 171 | 225 | const pk = this.getIdentityPublicKey() |
| 172 | 226 | return pk ? b4a.toString(pk, 'hex') : null |
| 173 | 227 | } |
| 174 | 228 | |
| 229 | /** | |
| 230 | * Whether using encrypted storage | |
| 231 | */ | |
| 232 | get isEncrypted () { | |
| 233 | return this._encrypted | |
| 234 | } | |
| 235 | ||
| 175 | 236 | /** |
| 176 | 237 | * Zero sensitive key material from memory |
| 177 | 238 | */ |
| … | … | @@ -182,9 +243,13 @@ export class IdentityManager { |
| 182 | 243 | if (this._identityKey && typeof this._identityKey.clear === 'function') { |
| 183 | 244 | this._identityKey.clear() |
| 184 | 245 | } |
| 246 | if (this._passphrase) { | |
| 247 | sodium.sodium_memzero(this._passphrase) | |
| 248 | } | |
| 185 | 249 | this._keypair = null |
| 186 | 250 | this._identityKey = null |
| 187 | 251 | this._mnemonic = null |
| 252 | this._passphrase = null | |
| 188 | 253 | } |
| 189 | 254 | } |
| 190 | 255 |
| … | … | @@ -12,10 +12,11 @@ async function createRegistry () { |
| 12 | 12 | test('createUser and getUser', async (t) => { |
| 13 | 13 | const { registry, cleanup } = await createRegistry() |
| 14 | 14 | |
| 15 | // First user gets admin promotion (deterministic in apply) | |
| 15 | 16 | const user = await registry.createUser({ name: 'Alice' }) |
| 16 | 17 | t.ok(user.userId, 'has userId') |
| 17 | 18 | t.is(user.name, 'Alice') |
| 18 | t.alike(user.globalPermissions, ['read']) | |
| 19 | t.ok(user.globalPermissions.includes('admin'), 'first user promoted to admin') | |
| 19 | 20 | |
| 20 | 21 | const fetched = await registry.getUser(user.userId) |
| 21 | 22 | t.is(fetched.name, 'Alice') |
| … | … | @@ -51,7 +52,7 @@ test('linkIdentityToUser and getUserByIdentity', async (t) => { |
| 51 | 52 | publicKey: 'bob-pubkey' |
| 52 | 53 | }) |
| 53 | 54 | |
| 54 | await registry.linkIdentityToUser('bob-key', user.userId) | |
| 55 | await registry.linkIdentityToUser('bob-key', user.userId, { verified: true }) | |
| 55 | 56 | |
| 56 | 57 | const found = await registry.getUserByIdentity('bob-key') |
| 57 | 58 | t.ok(found, 'found user by identity') |
| … | … | @@ -230,7 +231,7 @@ test('mergeAccounts', async (t) => { |
| 230 | 231 | const secondary = await registry.createUser({ name: 'Secondary', globalPermissions: ['read', 'write'] }) |
| 231 | 232 | |
| 232 | 233 | await registry.registerIdentity({ identityId: 'sec-id', method: 'web-identity', publicKey: 'sec-pub' }) |
| 233 | await registry.linkIdentityToUser('sec-id', secondary.userId) | |
| 234 | await registry.linkIdentityToUser('sec-id', secondary.userId, { verified: true }) | |
| 234 | 235 | |
| 235 | 236 | await registry.mergeAccounts(primary.userId, secondary.userId) |
| 236 | 237 | |
| … | … | @@ -375,3 +376,131 @@ test('importIdentity rejects key already linked to different user', async (t) => |
| 375 | 376 | |
| 376 | 377 | await cleanup() |
| 377 | 378 | }) |
| 379 | ||
| 380 | test('getIdentityBySSHKey — reverse lookup by fingerprint', async (t) => { | |
| 381 | const { registry, cleanup } = await createRegistry() | |
| 382 | ||
| 383 | await registry.registerIdentity({ | |
| 384 | identityId: 'ssh-id-1', | |
| 385 | method: 'ssh-key', | |
| 386 | credentials: { fingerprint: 'SHA256:abc123def456', type: 'ssh-ed25519' } | |
| 387 | }) | |
| 388 | ||
| 389 | const found = await registry.getIdentityBySSHKey('SHA256:abc123def456') | |
| 390 | t.ok(found, 'found by SSH fingerprint') | |
| 391 | t.is(found.identityId, 'ssh-id-1') | |
| 392 | t.is(found.method, 'ssh-key') | |
| 393 | ||
| 394 | const notFound = await registry.getIdentityBySSHKey('SHA256:nonexistent') | |
| 395 | t.is(notFound, null, 'returns null for unknown fingerprint') | |
| 396 | ||
| 397 | await cleanup() | |
| 398 | }) | |
| 399 | ||
| 400 | test('getIdentityByNoiseKey — reverse lookup by noise public key', async (t) => { | |
| 401 | const { registry, cleanup } = await createRegistry() | |
| 402 | ||
| 403 | await registry.registerIdentity({ | |
| 404 | identityId: 'noise-id-1', | |
| 405 | method: 'noise-key', | |
| 406 | credentials: { publicKey: 'deadbeef'.repeat(8) } | |
| 407 | }) | |
| 408 | ||
| 409 | const found = await registry.getIdentityByNoiseKey('deadbeef'.repeat(8)) | |
| 410 | t.ok(found, 'found by Noise key') | |
| 411 | t.is(found.identityId, 'noise-id-1') | |
| 412 | t.is(found.method, 'noise-key') | |
| 413 | ||
| 414 | const notFound = await registry.getIdentityByNoiseKey('00'.repeat(32)) | |
| 415 | t.is(notFound, null, 'returns null for unknown noise key') | |
| 416 | ||
| 417 | await cleanup() | |
| 418 | }) | |
| 419 | ||
| 420 | test('registerIdentity indexes all key types correctly', async (t) => { | |
| 421 | const { registry, cleanup } = await createRegistry() | |
| 422 | ||
| 423 | await registry.registerIdentity({ | |
| 424 | identityId: 'web-1', method: 'web-identity', publicKey: 'web-pubkey-123' | |
| 425 | }) | |
| 426 | await registry.registerIdentity({ | |
| 427 | identityId: 'ssh-1', method: 'ssh-key', | |
| 428 | credentials: { fingerprint: 'SHA256:sshfp123' } | |
| 429 | }) | |
| 430 | await registry.registerIdentity({ | |
| 431 | identityId: 'noise-1', method: 'noise-key', | |
| 432 | credentials: { publicKey: 'noisepk456' } | |
| 433 | }) | |
| 434 | ||
| 435 | t.ok(await registry.getIdentityByPublicKey('web-pubkey-123'), 'web identity found by pubkey') | |
| 436 | t.ok(await registry.getIdentityBySSHKey('SHA256:sshfp123'), 'SSH identity found by fingerprint') | |
| 437 | t.ok(await registry.getIdentityByNoiseKey('noisepk456'), 'Noise identity found by noise key') | |
| 438 | ||
| 439 | t.is(await registry.getIdentityBySSHKey('web-pubkey-123'), null, 'web key not found via SSH lookup') | |
| 440 | t.is(await registry.getIdentityByNoiseKey('SHA256:sshfp123'), null, 'SSH fp not found via Noise lookup') | |
| 441 | ||
| 442 | await cleanup() | |
| 443 | }) | |
| 444 | ||
| 445 | // ── Deactivation ───────────────────────────────────────────── | |
| 446 | ||
| 447 | test('deactivateUser blocks access, reactivateUser restores', async (t) => { | |
| 448 | const { registry, cleanup } = await createRegistry() | |
| 449 | ||
| 450 | const admin = await registry.createUser({ name: 'Admin' }) | |
| 451 | const user = await registry.createUser({ name: 'Bob' }) | |
| 452 | ||
| 453 | await registry.deactivateUser(user.userId, { reason: 'spam' }) | |
| 454 | ||
| 455 | const deactivated = await registry.getUser(user.userId) | |
| 456 | t.ok(deactivated.deactivatedAt, 'has deactivatedAt timestamp') | |
| 457 | t.is(deactivated.deactivatedReason, 'spam', 'has reason') | |
| 458 | t.ok(await registry.isDeactivated(user.userId), 'isDeactivated returns true') | |
| 459 | ||
| 460 | await registry.reactivateUser(user.userId) | |
| 461 | ||
| 462 | const reactivated = await registry.getUser(user.userId) | |
| 463 | t.absent(reactivated.deactivatedAt, 'deactivatedAt cleared') | |
| 464 | t.absent(reactivated.deactivatedReason, 'reason cleared') | |
| 465 | t.absent(await registry.isDeactivated(user.userId), 'isDeactivated returns false') | |
| 466 | ||
| 467 | await cleanup() | |
| 468 | }) | |
| 469 | ||
| 470 | test('deactivateUser — cannot deactivate last admin', async (t) => { | |
| 471 | const { registry, cleanup } = await createRegistry() | |
| 472 | ||
| 473 | const admin = await registry.createUser({ name: 'Solo Admin' }) | |
| 474 | // First user is admin. Try to deactivate — should be silently skipped. | |
| 475 | await registry.deactivateUser(admin.userId) | |
| 476 | ||
| 477 | const stillActive = await registry.getUser(admin.userId) | |
| 478 | t.absent(stillActive.deactivatedAt, 'last admin cannot be deactivated') | |
| 479 | ||
| 480 | await cleanup() | |
| 481 | }) | |
| 482 | ||
| 483 | test('hasAnyAdmin', async (t) => { | |
| 484 | const { registry, cleanup } = await createRegistry() | |
| 485 | ||
| 486 | t.absent(await registry.hasAnyAdmin(), 'no admin before any users') | |
| 487 | ||
| 488 | await registry.createUser({ name: 'First' }) | |
| 489 | t.ok(await registry.hasAnyAdmin(), 'first user is admin') | |
| 490 | ||
| 491 | await cleanup() | |
| 492 | }) | |
| 493 | ||
| 494 | test('listAllIdentities', async (t) => { | |
| 495 | const { registry, cleanup } = await createRegistry() | |
| 496 | ||
| 497 | await registry.registerIdentity({ identityId: 'id-1', method: 'web-identity', publicKey: 'pk1' }) | |
| 498 | await registry.registerIdentity({ identityId: 'id-2', method: 'ssh-key', credentials: { fingerprint: 'fp2' } }) | |
| 499 | ||
| 500 | const all = await registry.listAllIdentities() | |
| 501 | t.is(all.length, 2, 'lists all identities') | |
| 502 | t.ok(all.some(i => i.identityId === 'id-1')) | |
| 503 | t.ok(all.some(i => i.identityId === 'id-2')) | |
| 504 | ||
| 505 | await cleanup() | |
| 506 | }) |
| … | … | @@ -0,0 +1,326 @@ |
| 1 | import test from 'brittle' | |
| 2 | import b4a from 'b4a' | |
| 3 | import { createApplyFunction } from '../lib/auth/identity-apply.js' | |
| 4 | import { encodeOp, OpTypes } from '../lib/auth/identity-operations.js' | |
| 5 | ||
| 6 | /** | |
| 7 | * Security tests for hyper-identity contract validation. | |
| 8 | * | |
| 9 | * These tests verify that rogue Autobase writers cannot inject | |
| 10 | * unauthorized operations. Each test creates a mock Hyperbee view | |
| 11 | * and simulates operations from different writers. | |
| 12 | */ | |
| 13 | ||
| 14 | // Helper: mock Hyperbee batch with in-memory Map | |
| 15 | function mockView () { | |
| 16 | const data = new Map() | |
| 17 | return { | |
| 18 | data, | |
| 19 | batch: () => ({ | |
| 20 | put: async (k, v) => data.set(k, { value: v }), | |
| 21 | get: async (k) => data.get(k) || null, | |
| 22 | del: async (k) => data.delete(k), | |
| 23 | flush: async () => {}, | |
| 24 | close: async () => {}, | |
| 25 | createReadStream: (opts) => ({ | |
| 26 | [Symbol.asyncIterator]: async function * () { | |
| 27 | for (const [k, v] of data) { | |
| 28 | if (opts.gt && k <= opts.gt) continue | |
| 29 | if (opts.lt && k >= opts.lt) continue | |
| 30 | if (opts.limit && --opts.limit < 0) return | |
| 31 | yield { key: k, value: v.value } | |
| 32 | } | |
| 33 | } | |
| 34 | }) | |
| 35 | }) | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | // Helper: create a node as if appended by a specific writer | |
| 40 | function nodeFrom (writerKeyHex, op) { | |
| 41 | return { | |
| 42 | value: encodeOp(op), | |
| 43 | from: { key: b4a.from(writerKeyHex, 'hex') } | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | const ADMIN_KEY = 'aa'.repeat(32) | |
| 48 | const USER_KEY = 'bb'.repeat(32) | |
| 49 | const ROGUE_KEY = 'cc'.repeat(32) | |
| 50 | ||
| 51 | test('contract: bootstrap — first writer can create admin user', async (t) => { | |
| 52 | const apply = createApplyFunction({ contractValidation: true }) | |
| 53 | const view = mockView() | |
| 54 | ||
| 55 | // Empty registry: first writer is trusted | |
| 56 | const nodes = [nodeFrom(ADMIN_KEY, { | |
| 57 | type: OpTypes.CREATE_USER, | |
| 58 | userId: 'admin-user', | |
| 59 | username: 'admin', | |
| 60 | name: 'Admin', | |
| 61 | globalPermissions: ['read', 'write', 'admin'], | |
| 62 | identities: [ADMIN_KEY], | |
| 63 | createdAt: Date.now() | |
| 64 | })] | |
| 65 | ||
| 66 | await apply(nodes, view, {}) | |
| 67 | ||
| 68 | t.ok(view.data.has('user:admin-user'), 'admin user created') | |
| 69 | const user = view.data.get('user:admin-user').value | |
| 70 | t.ok(user.globalPermissions.includes('admin'), 'has admin permissions') | |
| 71 | }) | |
| 72 | ||
| 73 | test('contract: admin can upgrade permissions', async (t) => { | |
| 74 | const apply = createApplyFunction({ contractValidation: true }) | |
| 75 | const view = mockView() | |
| 76 | ||
| 77 | // Setup: create admin user with writer key linked | |
| 78 | view.data.set('user:admin-user', { | |
| 79 | value: { | |
| 80 | userId: 'admin-user', | |
| 81 | globalPermissions: ['read', 'write', 'admin'], | |
| 82 | identities: [ADMIN_KEY] | |
| 83 | } | |
| 84 | }) | |
| 85 | view.data.set('pubkey:' + ADMIN_KEY, { value: { identityId: ADMIN_KEY } }) | |
| 86 | view.data.set('user-identity:' + ADMIN_KEY, { value: { userId: 'admin-user' } }) | |
| 87 | ||
| 88 | // Setup: create regular user | |
| 89 | view.data.set('user:regular-user', { | |
| 90 | value: { | |
| 91 | userId: 'regular-user', | |
| 92 | globalPermissions: ['read'], | |
| 93 | identities: [USER_KEY] | |
| 94 | } | |
| 95 | }) | |
| 96 | ||
| 97 | // Admin upgrades regular user — should succeed | |
| 98 | const nodes = [nodeFrom(ADMIN_KEY, { | |
| 99 | type: OpTypes.UPGRADE_PERMISSIONS, | |
| 100 | userId: 'regular-user', | |
| 101 | resourceName: '*', | |
| 102 | permissions: ['read', 'write', 'admin'] | |
| 103 | })] | |
| 104 | ||
| 105 | await apply(nodes, view, {}) | |
| 106 | ||
| 107 | const user = view.data.get('user:regular-user').value | |
| 108 | t.ok(user.globalPermissions.includes('admin'), 'permissions upgraded by admin') | |
| 109 | }) | |
| 110 | ||
| 111 | test('contract: non-admin CANNOT upgrade permissions', async (t) => { | |
| 112 | const apply = createApplyFunction({ contractValidation: true }) | |
| 113 | const view = mockView() | |
| 114 | ||
| 115 | // Setup: admin exists | |
| 116 | view.data.set('user:admin-user', { | |
| 117 | value: { userId: 'admin-user', globalPermissions: ['read', 'write', 'admin'], identities: [ADMIN_KEY] } | |
| 118 | }) | |
| 119 | view.data.set('pubkey:' + ADMIN_KEY, { value: { identityId: ADMIN_KEY } }) | |
| 120 | view.data.set('user-identity:' + ADMIN_KEY, { value: { userId: 'admin-user' } }) | |
| 121 | ||
| 122 | // Setup: regular user | |
| 123 | view.data.set('user:regular-user', { | |
| 124 | value: { userId: 'regular-user', globalPermissions: ['read'], identities: [USER_KEY] } | |
| 125 | }) | |
| 126 | view.data.set('pubkey:' + USER_KEY, { value: { identityId: USER_KEY } }) | |
| 127 | view.data.set('user-identity:' + USER_KEY, { value: { userId: 'regular-user' } }) | |
| 128 | ||
| 129 | // Regular user tries to upgrade themselves — should be BLOCKED | |
| 130 | const nodes = [nodeFrom(USER_KEY, { | |
| 131 | type: OpTypes.UPGRADE_PERMISSIONS, | |
| 132 | userId: 'regular-user', | |
| 133 | resourceName: '*', | |
| 134 | permissions: ['read', 'write', 'admin'] | |
| 135 | })] | |
| 136 | ||
| 137 | await apply(nodes, view, {}) | |
| 138 | ||
| 139 | const user = view.data.get('user:regular-user').value | |
| 140 | t.alike(user.globalPermissions, ['read'], 'permissions NOT upgraded — contract blocked it') | |
| 141 | }) | |
| 142 | ||
| 143 | test('contract: rogue writer CANNOT inject admin ops', async (t) => { | |
| 144 | const apply = createApplyFunction({ contractValidation: true }) | |
| 145 | const view = mockView() | |
| 146 | ||
| 147 | // Setup: admin and regular user exist | |
| 148 | view.data.set('user:admin-user', { | |
| 149 | value: { userId: 'admin-user', globalPermissions: ['read', 'write', 'admin'], identities: [ADMIN_KEY] } | |
| 150 | }) | |
| 151 | view.data.set('pubkey:' + ADMIN_KEY, { value: { identityId: ADMIN_KEY } }) | |
| 152 | view.data.set('user-identity:' + ADMIN_KEY, { value: { userId: 'admin-user' } }) | |
| 153 | ||
| 154 | // Rogue writer (unknown key, no associated user) tries to: | |
| 155 | // 1. Create an admin user | |
| 156 | // 2. Delete an existing user | |
| 157 | // 3. Merge accounts | |
| 158 | const nodes = [ | |
| 159 | // CREATE_USER is allowed (but with read-only perms, autoRegister handles admin promotion) | |
| 160 | nodeFrom(ROGUE_KEY, { | |
| 161 | type: OpTypes.CREATE_USER, | |
| 162 | userId: 'rogue-user', | |
| 163 | name: 'Rogue', | |
| 164 | globalPermissions: ['read', 'write', 'admin'], | |
| 165 | identities: [ROGUE_KEY], | |
| 166 | createdAt: Date.now() | |
| 167 | }), | |
| 168 | // DELETE_USER requires admin — should be BLOCKED | |
| 169 | nodeFrom(ROGUE_KEY, { | |
| 170 | type: OpTypes.DELETE_USER, | |
| 171 | userId: 'admin-user' | |
| 172 | }), | |
| 173 | // MERGE_ACCOUNTS requires admin — should be BLOCKED | |
| 174 | nodeFrom(ROGUE_KEY, { | |
| 175 | type: OpTypes.MERGE_ACCOUNTS, | |
| 176 | primaryUserId: 'rogue-user', | |
| 177 | secondaryUserId: 'admin-user' | |
| 178 | }) | |
| 179 | ] | |
| 180 | ||
| 181 | await apply(nodes, view, {}) | |
| 182 | ||
| 183 | // CREATE_USER goes through (it's always allowed) | |
| 184 | t.ok(view.data.has('user:rogue-user'), 'rogue user created (CREATE_USER is open)') | |
| 185 | ||
| 186 | // But admin still exists (DELETE was blocked) | |
| 187 | t.ok(view.data.has('user:admin-user'), 'admin NOT deleted — contract blocked it') | |
| 188 | ||
| 189 | // Admin not merged (MERGE was blocked) | |
| 190 | const admin = view.data.get('user:admin-user').value | |
| 191 | t.not(admin.mergedInto, 'rogue-user', 'admin NOT merged — contract blocked it') | |
| 192 | }) | |
| 193 | ||
| 194 | test('contract: user can update their own profile', async (t) => { | |
| 195 | const apply = createApplyFunction({ contractValidation: true }) | |
| 196 | const view = mockView() | |
| 197 | ||
| 198 | view.data.set('user:alice', { | |
| 199 | value: { userId: 'alice', name: 'Alice', globalPermissions: ['read'], identities: [USER_KEY] } | |
| 200 | }) | |
| 201 | view.data.set('pubkey:' + USER_KEY, { value: { identityId: USER_KEY } }) | |
| 202 | view.data.set('user-identity:' + USER_KEY, { value: { userId: 'alice' } }) | |
| 203 | ||
| 204 | // Alice updates her own name — should succeed | |
| 205 | const nodes = [nodeFrom(USER_KEY, { | |
| 206 | type: OpTypes.UPDATE_USER, | |
| 207 | userId: 'alice', | |
| 208 | name: 'Alice Smith' | |
| 209 | })] | |
| 210 | ||
| 211 | await apply(nodes, view, {}) | |
| 212 | ||
| 213 | const user = view.data.get('user:alice').value | |
| 214 | t.is(user.name, 'Alice Smith', 'self-update succeeded') | |
| 215 | }) | |
| 216 | ||
| 217 | test('contract: user CANNOT update another user', async (t) => { | |
| 218 | const apply = createApplyFunction({ contractValidation: true }) | |
| 219 | const view = mockView() | |
| 220 | ||
| 221 | view.data.set('user:alice', { | |
| 222 | value: { userId: 'alice', name: 'Alice', globalPermissions: ['read'], identities: [USER_KEY] } | |
| 223 | }) | |
| 224 | view.data.set('pubkey:' + USER_KEY, { value: { identityId: USER_KEY } }) | |
| 225 | view.data.set('user-identity:' + USER_KEY, { value: { userId: 'alice' } }) | |
| 226 | ||
| 227 | view.data.set('user:bob', { | |
| 228 | value: { userId: 'bob', name: 'Bob', globalPermissions: ['read'], identities: ['dd'.repeat(32)] } | |
| 229 | }) | |
| 230 | ||
| 231 | // Alice tries to update Bob — should be BLOCKED | |
| 232 | const nodes = [nodeFrom(USER_KEY, { | |
| 233 | type: OpTypes.UPDATE_USER, | |
| 234 | userId: 'bob', | |
| 235 | name: 'Hacked' | |
| 236 | })] | |
| 237 | ||
| 238 | await apply(nodes, view, {}) | |
| 239 | ||
| 240 | const bob = view.data.get('user:bob').value | |
| 241 | t.is(bob.name, 'Bob', 'Bob NOT modified — contract blocked cross-user update') | |
| 242 | }) | |
| 243 | ||
| 244 | test('contract: revocation requires admin', async (t) => { | |
| 245 | const apply = createApplyFunction({ contractValidation: true }) | |
| 246 | const view = mockView() | |
| 247 | ||
| 248 | view.data.set('user:admin-user', { | |
| 249 | value: { userId: 'admin-user', globalPermissions: ['read', 'write', 'admin'], identities: [ADMIN_KEY] } | |
| 250 | }) | |
| 251 | view.data.set('pubkey:' + ADMIN_KEY, { value: { identityId: ADMIN_KEY } }) | |
| 252 | view.data.set('user-identity:' + ADMIN_KEY, { value: { userId: 'admin-user' } }) | |
| 253 | ||
| 254 | view.data.set('user:regular-user', { | |
| 255 | value: { userId: 'regular-user', globalPermissions: ['read'], identities: [USER_KEY] } | |
| 256 | }) | |
| 257 | view.data.set('pubkey:' + USER_KEY, { value: { identityId: USER_KEY } }) | |
| 258 | view.data.set('user-identity:' + USER_KEY, { value: { userId: 'regular-user' } }) | |
| 259 | ||
| 260 | // Regular user tries to revoke admin's identity — BLOCKED | |
| 261 | const rogueRevoke = [nodeFrom(USER_KEY, { | |
| 262 | type: OpTypes.REVOKE_IDENTITY, | |
| 263 | identityId: ADMIN_KEY, | |
| 264 | revokedAt: Date.now(), | |
| 265 | reason: 'hostile takeover' | |
| 266 | })] | |
| 267 | ||
| 268 | await apply(rogueRevoke, view, {}) | |
| 269 | t.not(view.data.has('revoked:' + ADMIN_KEY), 'admin identity NOT revoked by non-admin') | |
| 270 | ||
| 271 | // Admin revokes regular user's identity — succeeds | |
| 272 | const adminRevoke = [nodeFrom(ADMIN_KEY, { | |
| 273 | type: OpTypes.REVOKE_IDENTITY, | |
| 274 | identityId: USER_KEY, | |
| 275 | revokedAt: Date.now(), | |
| 276 | reason: 'compromised' | |
| 277 | })] | |
| 278 | ||
| 279 | await apply(adminRevoke, view, {}) | |
| 280 | t.ok(view.data.has('revoked:' + USER_KEY), 'identity revoked by admin') | |
| 281 | }) | |
| 282 | ||
| 283 | test('contract: disabled by default (backwards compat)', async (t) => { | |
| 284 | // Default: no contractValidation option | |
| 285 | const apply = createApplyFunction() | |
| 286 | const view = mockView() | |
| 287 | ||
| 288 | view.data.set('user:admin-user', { | |
| 289 | value: { userId: 'admin-user', globalPermissions: ['read', 'write', 'admin'], identities: [ADMIN_KEY] } | |
| 290 | }) | |
| 291 | ||
| 292 | // Rogue writer can do anything when contract is disabled | |
| 293 | const nodes = [nodeFrom(ROGUE_KEY, { | |
| 294 | type: OpTypes.DELETE_USER, | |
| 295 | userId: 'admin-user' | |
| 296 | })] | |
| 297 | ||
| 298 | await apply(nodes, view, {}) | |
| 299 | ||
| 300 | // Without contract validation, the delete goes through | |
| 301 | t.not(view.data.has('user:admin-user'), 'delete went through without contract validation') | |
| 302 | }) | |
| 303 | ||
| 304 | test('contract: unknown writer blocked after bootstrap', async (t) => { | |
| 305 | const apply = createApplyFunction({ contractValidation: true }) | |
| 306 | const view = mockView() | |
| 307 | ||
| 308 | // Registry has users (not empty bootstrap) | |
| 309 | view.data.set('user:existing', { | |
| 310 | value: { userId: 'existing', globalPermissions: ['read', 'write', 'admin'], identities: [ADMIN_KEY] } | |
| 311 | }) | |
| 312 | view.data.set('pubkey:' + ADMIN_KEY, { value: { identityId: ADMIN_KEY } }) | |
| 313 | view.data.set('user-identity:' + ADMIN_KEY, { value: { userId: 'existing' } }) | |
| 314 | ||
| 315 | // Unknown writer (not linked to any user) tries to update a user | |
| 316 | const nodes = [nodeFrom(ROGUE_KEY, { | |
| 317 | type: OpTypes.UPDATE_USER, | |
| 318 | userId: 'existing', | |
| 319 | name: 'Hacked' | |
| 320 | })] | |
| 321 | ||
| 322 | await apply(nodes, view, {}) | |
| 323 | ||
| 324 | const user = view.data.get('user:existing').value | |
| 325 | t.is(user.name, undefined, 'update blocked — unknown writer has no associated user') | |
| 326 | }) |