OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / e435c9f

Add user deactivation/reactivation, hasAnyAdmin, listAllIdentities
- deactivateUser(userId, {reason}) blocks auth, preserves data - reactivateUser(userId) re-enables auth - isDeactivated(userId) quick check - Guard: cannot deactivate the last admin (silently skipped in apply) - hasAnyAdmin() public method (was private _hasAnyAdmin) - listAllIdentities() for admin panel - 4 new tests (28 total, 85 assertions) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
e435c9f820f56e76ad948276a26ba5ceaca57d0c
by plankatron <fleeky@gmail.com> · 2026-03-30T17:30:06.000Z

Changed files (8) +937 -90

lib/auth/identity-apply.js +183 -5
……@@ -1,17 +1,57 @@
11import { decodeOp, OpTypes, validateOp } from './identity-operations.js'
2import b4a from 'b4a'
23
34/**
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
518 *
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
25const 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)
36const 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
846 *
947 * @param {Object} opts - Options
1048 * @param {string} opts.prefix - Key prefix for namespacing (default: '')
49 * @param {boolean} opts.contractValidation - Enable contract validation (default: false for migration compat)
1150 * @returns {Function} Autobase apply function
1251 */
1352export function createApplyFunction (opts = {}) {
1453 const prefix = opts.prefix || ''
54 const enableContract = opts.contractValidation === true
1555
1656 return async function apply (batch, view, base) {
1757 const b = view.batch({ update: false })
……@@ -26,20 +66,43 @@ export function createApplyFunction (opts = {}) {
2666 continue
2767 }
2868
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
2979 switch (op.type) {
3080 case OpTypes.CREATE_USER: {
3181 const key = `${prefix}user:${op.userId}`
3282 const existing = await b.get(key)
3383 if (existing) continue // Don't overwrite
3484
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
3598 const user = {
3699 userId: op.userId,
37100 username: op.username || null,
38101 name: op.name || 'Anonymous',
39102 email: op.email || null,
40103 profilePicture: op.profilePicture || null,
41 globalPermissions: op.globalPermissions || ['read'],
42 resources: op.resources || [{ name: '*', permissions: ['read'] }],
104 globalPermissions: permissions,
105 resources,
43106 identities: op.identities || [],
44107 createdAt: op.createdAt || Date.now(),
45108 updatedAt: op.updatedAt || Date.now()
……@@ -211,6 +274,42 @@ export function createApplyFunction (opts = {}) {
211274 break
212275 }
213276
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
214313 case OpTypes.SET_USERNAME: {
215314 const key = `${prefix}user:${op.userId}`
216315 const existing = await b.get(key)
……@@ -245,3 +344,82 @@ export function createApplyFunction (opts = {}) {
245344 }
246345 }
247346}
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 */
360async 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 */
397async 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
420async 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}
lib/auth/identity-operations.js +11 -1
……@@ -14,7 +14,9 @@ export const OpTypes = {
1414 MERGE_ACCOUNTS: 'merge-accounts',
1515 REVOKE_IDENTITY: 'revoke-identity',
1616 RESTORE_IDENTITY: 'restore-identity',
17 SET_USERNAME: 'set-username'
17 SET_USERNAME: 'set-username',
18 DEACTIVATE_USER: 'deactivate-user',
19 REACTIVATE_USER: 'reactivate-user'
1820}
1921
2022/**
……@@ -89,6 +91,14 @@ export function validateOp (op) {
8991 if (!op.username) return { valid: false, error: 'Missing username' }
9092 return { valid: true }
9193
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
92102 default:
93103 return { valid: false, error: `Unknown op type: ${op.type}` }
94104 }
lib/auth/identity-registry.js +129 -15
……@@ -89,7 +89,10 @@ export class IdentityRegistry {
8989 }
9090 await this._base.append(encodeOp(op))
9191 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
9396 }
9497
9598 /**
……@@ -163,6 +166,19 @@ export class IdentityRegistry {
163166 async registerIdentity (identity) {
164167 if (!this.db) throw new Error('No direct-write db configured')
165168 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 }
166182 if (identity.publicKey) {
167183 await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId })
168184 }
……@@ -188,9 +204,53 @@ export class IdentityRegistry {
188204 }
189205
190206 /**
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
192218 */
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
194254 // Update direct-write db first (source of truth for identity records)
195255 if (this.db) {
196256 const identity = await this.getIdentity(identityId)
……@@ -270,6 +330,10 @@ export class IdentityRegistry {
270330 /**
271331 * Check if any admin user exists
272332 */
333 async hasAnyAdmin () {
334 return this._hasAnyAdmin()
335 }
336
273337 async _hasAnyAdmin () {
274338 for await (const entry of this._view.createReadStream({
275339 gte: `${this._prefix}user:`,
……@@ -282,6 +346,44 @@ export class IdentityRegistry {
282346 return false
283347 }
284348
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
285387 // ==================== ACCOUNT MERGE ====================
286388
287389 /**
……@@ -366,17 +468,14 @@ export class IdentityRegistry {
366468 * @returns {{ user: Object, identity: Object }}
367469 */
368470 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.
376475 const user = await this.createUser({
377476 name: identityData.name || 'Anonymous',
378 globalPermissions: permissions,
379 resources
477 globalPermissions: ['read'],
478 resources: [{ name: '*', permissions: ['read'] }]
380479 })
381480
382481 // Register identity in direct-write store
……@@ -389,8 +488,8 @@ export class IdentityRegistry {
389488 }
390489 await this.registerIdentity(identity)
391490
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 })
394493
395494 return { user, identity }
396495 }
……@@ -504,6 +603,21 @@ export class IdentityRegistry {
504603 return !entry
505604 }
506605
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
507621 // ==================== PROFILE UPDATES ====================
508622
509623 /**
……@@ -643,7 +757,7 @@ export class IdentityRegistry {
643757 createdAt: Date.now()
644758 }
645759 await this.registerIdentity(identity)
646 await this.linkIdentityToUser(publicKey, userId)
760 await this.linkIdentityToUser(publicKey, userId, { verified: true })
647761
648762 return identity
649763 }
lib/auth/providers/ssh-key.js +17 -2
……@@ -29,8 +29,8 @@ export class SSHKeyProvider extends BaseProvider {
2929 async authenticate (credentials) {
3030 const { sshPublicKey, sshSignature, challengeId } = credentials
3131
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)
3434 if (!challenge) throw new Error('Invalid or expired challenge')
3535
3636 // Parse SSH key
……@@ -61,15 +61,30 @@ export class SSHKeyProvider extends BaseProvider {
6161
6262 if (!valid) throw new Error('Invalid SSH signature')
6363
64 // Consume the challenge (single-use)
65 await this.challengeManager.consume(challengeId)
66
6467 // Use SSH public key string as identity ID
6568 const identityId = `ssh:${sshPublicKey.substring(0, 64)}`
6669 let identity = await this.registry.getIdentity(identityId)
6770
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
6877 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
6983 await this.registry.registerIdentity({
7084 identityId,
7185 method: 'ssh-key',
7286 publicKey: sshPublicKey,
87 credentials: { fingerprint, type: parsedKey.type },
7388 keyType: parsedKey.type,
7489 createdAt: Date.now()
7590 })
lib/auth/providers/web-identity.js +17 -7
……@@ -31,29 +31,39 @@ export class WebIdentityProvider extends BaseProvider {
3131 async authenticate (credentials) {
3232 const { publicKey, signature, challengeId } = credentials
3333
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
3439 if (signature && challengeId) {
35 // Look up the challenge by ID
3640 const challenge = await this.challengeManager.get(challengeId)
3741 if (!challenge) throw new Error('Invalid or expired challenge')
3842
39 // Verify signature against the challenge value
4043 const valid = ANProto.verify(challenge.challenge, signature, publicKey)
4144 if (!valid) throw new Error('Invalid signature')
4245
43 // Consume the challenge (single-use)
4446 await this.challengeManager.consume(challengeId)
47 signatureVerified = true
4548 }
4649
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 }
4955
5056 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
5163 if (!this.autoRegister) {
5264 throw new Error('Unknown identity. Register first.')
5365 }
5466
55 // Auto-register: creates user + identity atomically
56 // First user gets admin, subsequent get read-only
5767 const result = await this.registry.autoRegister({
5868 identityId: publicKey,
5969 method: 'web-identity',
lib/identity-manager.js +122 -57
……@@ -1,40 +1,130 @@
11import crypto from 'hypercore-crypto'
22import sodium from 'sodium-native'
33import b4a from 'b4a'
4import { join } from 'bare-path'
5import { SecureStorage } from './secure-storage.js'
46
57/**
68 * IdentityManager — Persistent identity keypairs + mnemonic proof chains
79 *
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)
1222 */
1323export class IdentityManager {
1424 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)
1632 this._keypair = null
1733 this._mnemonic = null
1834 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
2037 }
2138
2239 /**
2340 * Initialize — load or generate persistent keypair
2441 */
2542 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
27119 const existing = await this._store.get('instance-keypair')
28120 if (existing) {
29121 const val = existing.value || existing
30 // Support both base64 (new) and hex (legacy) storage formats
31122 const encoding = (val.publicKey && val.publicKey.length === 44) ? 'base64' : 'hex'
32123 this._keypair = {
33124 publicKey: b4a.from(val.publicKey, encoding),
34125 secretKey: b4a.from(val.secretKey, encoding)
35126 }
36127 } else {
37 // Generate new persistent keypair, store as base64
38128 this._keypair = crypto.keyPair()
39129 await this._store.put('instance-keypair', {
40130 publicKey: b4a.toString(this._keypair.publicKey, 'base64'),
……@@ -42,7 +132,6 @@ export class IdentityManager {
42132 })
43133 }
44134
45 // Load mnemonic if stored
46135 const mnemonicEntry = await this._store.get('identity-mnemonic')
47136 if (mnemonicEntry) {
48137 this._mnemonic = mnemonicEntry.value
……@@ -50,9 +139,6 @@ export class IdentityManager {
50139 }
51140 }
52141
53 /**
54 * Load keet-identity-key from stored mnemonic
55 */
56142 async _loadIdentityKey () {
57143 if (!this._IdentityKey || !this._mnemonic) return
58144 try {
……@@ -62,24 +148,15 @@ export class IdentityManager {
62148 }
63149 }
64150
65 /**
66 * Get instance public key as hex string
67 */
68151 getPublicKeyHex () {
69152 if (!this._keypair) return null
70153 return b4a.toString(this._keypair.publicKey, 'hex')
71154 }
72155
73 /**
74 * Get instance public key buffer
75 */
76156 getPublicKey () {
77157 return this._keypair ? this._keypair.publicKey : null
78158 }
79159
80 /**
81 * Get instance keypair
82 */
83160 getKeypair () {
84161 return this._keypair
85162 }
……@@ -90,88 +167,72 @@ export class IdentityManager {
90167 */
91168 async setMnemonic (mnemonic) {
92169 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
94182 await this._loadIdentityKey()
95183 }
96184
97 /**
98 * Get mnemonic (if stored)
99 */
100185 getMnemonic () {
101186 return this._mnemonic
102187 }
103188
104 /**
105 * Get keet-identity-key instance (if mnemonic loaded)
106 */
107189 getIdentityKey () {
108190 return this._identityKey
109191 }
110192
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 */
116193 async bootstrap (devicePublicKey) {
117194 if (!this._identityKey) throw new Error('No identity key loaded')
118195 return this._identityKey.bootstrap(devicePublicKey)
119196 }
120197
121 /**
122 * Sign data with instance keypair
123 * @param {Buffer} data - Data to sign
124 * @returns {Buffer} Signature
125 */
126198 sign (data) {
127199 if (!this._keypair) throw new Error('Not initialized')
128200 return crypto.sign(data, this._keypair.secretKey)
129201 }
130202
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 */
137203 verify (data, signature) {
138204 if (!this._keypair) throw new Error('Not initialized')
139205 return crypto.verify(data, signature, this._keypair.publicKey)
140206 }
141207
142 /**
143 * Get instance public key as 44-char base64
144 */
145208 getPublicKeyBase64 () {
146209 if (!this._keypair) return null
147210 return b4a.toString(this._keypair.publicKey, 'base64')
148211 }
149212
150 /**
151 * Get instance keypair as 132-char base64 string (ANProto format)
152 */
153213 getKeypairString () {
154214 if (!this._keypair) return null
155215 return b4a.toString(this._keypair.publicKey, 'base64') +
156216 b4a.toString(this._keypair.secretKey, 'base64')
157217 }
158218
159 /**
160 * Get identity public key (from mnemonic, distinct from instance key)
161 */
162219 getIdentityPublicKey () {
163220 if (!this._identityKey) return null
164221 return this._identityKey.publicKey
165222 }
166223
167 /**
168 * Get identity public key as hex
169 */
170224 getIdentityPublicKeyHex () {
171225 const pk = this.getIdentityPublicKey()
172226 return pk ? b4a.toString(pk, 'hex') : null
173227 }
174228
229 /**
230 * Whether using encrypted storage
231 */
232 get isEncrypted () {
233 return this._encrypted
234 }
235
175236 /**
176237 * Zero sensitive key material from memory
177238 */
……@@ -182,9 +243,13 @@ export class IdentityManager {
182243 if (this._identityKey && typeof this._identityKey.clear === 'function') {
183244 this._identityKey.clear()
184245 }
246 if (this._passphrase) {
247 sodium.sodium_memzero(this._passphrase)
248 }
185249 this._keypair = null
186250 this._identityKey = null
187251 this._mnemonic = null
252 this._passphrase = null
188253 }
189254}
190255
test/identity-registry.test.js +132 -3
……@@ -12,10 +12,11 @@ async function createRegistry () {
1212test('createUser and getUser', async (t) => {
1313 const { registry, cleanup } = await createRegistry()
1414
15 // First user gets admin promotion (deterministic in apply)
1516 const user = await registry.createUser({ name: 'Alice' })
1617 t.ok(user.userId, 'has userId')
1718 t.is(user.name, 'Alice')
18 t.alike(user.globalPermissions, ['read'])
19 t.ok(user.globalPermissions.includes('admin'), 'first user promoted to admin')
1920
2021 const fetched = await registry.getUser(user.userId)
2122 t.is(fetched.name, 'Alice')
……@@ -51,7 +52,7 @@ test('linkIdentityToUser and getUserByIdentity', async (t) => {
5152 publicKey: 'bob-pubkey'
5253 })
5354
54 await registry.linkIdentityToUser('bob-key', user.userId)
55 await registry.linkIdentityToUser('bob-key', user.userId, { verified: true })
5556
5657 const found = await registry.getUserByIdentity('bob-key')
5758 t.ok(found, 'found user by identity')
……@@ -230,7 +231,7 @@ test('mergeAccounts', async (t) => {
230231 const secondary = await registry.createUser({ name: 'Secondary', globalPermissions: ['read', 'write'] })
231232
232233 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 })
234235
235236 await registry.mergeAccounts(primary.userId, secondary.userId)
236237
……@@ -375,3 +376,131 @@ test('importIdentity rejects key already linked to different user', async (t) =>
375376
376377 await cleanup()
377378})
379
380test('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
400test('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
420test('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
447test('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
470test('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
483test('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
494test('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})
test/security.test.js +326 -0
……@@ -0,0 +1,326 @@
1import test from 'brittle'
2import b4a from 'b4a'
3import { createApplyFunction } from '../lib/auth/identity-apply.js'
4import { 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
15function 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
40function nodeFrom (writerKeyHex, op) {
41 return {
42 value: encodeOp(op),
43 from: { key: b4a.from(writerKeyHex, 'hex') }
44 }
45}
46
47const ADMIN_KEY = 'aa'.repeat(32)
48const USER_KEY = 'bb'.repeat(32)
49const ROGUE_KEY = 'cc'.repeat(32)
50
51test('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
73test('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
111test('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
143test('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
194test('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
217test('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
244test('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
283test('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
304test('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})