| … | … | @@ -0,0 +1,449 @@ |
| 1 | # hyper-identity | |
| 2 | ||
| 3 | Generic P2P identity management with Autobase, ANProto, and multi-method authentication. | |
| 4 | ||
| 5 | Extracted from [pear-git](https://github.com/holepunchto/pear-git)'s battle-tested identity backend, genericized with an injectable permission model for any application. | |
| 6 | ||
| 7 | ## Features | |
| 8 | ||
| 9 | - **Multi-method authentication**: ANProto (Ed25519), BIP39 mnemonic, bearer tokens, SSH keys | |
| 10 | - **Autobase-backed registry**: Multi-writer identity consensus across federated peers | |
| 11 | - **Injectable permissions**: Generic `resources` model with custom `checkPermission` callback | |
| 12 | - **Device linking**: Blind-pairing over Hyperswarm for secure mnemonic exchange | |
| 13 | - **Encrypted storage**: Argon2id + XSalsa20-Poly1305 vault for sensitive data | |
| 14 | - **Challenge-response**: Replay-resistant authentication with server-side challenges | |
| 15 | - **Rate limiting**: Token bucket algorithm with persistent state | |
| 16 | ||
| 17 | ## Quick Start | |
| 18 | ||
| 19 | ```javascript | |
| 20 | import { AuthManager, IdentityRegistry } from 'hyper-identity' | |
| 21 | import Corestore from 'corestore' | |
| 22 | import Hyperbee from 'hyperbee' | |
| 23 | ||
| 24 | // Setup storage | |
| 25 | const store = new Corestore('./identity-storage') | |
| 26 | const core = store.get({ name: 'identity-db' }) | |
| 27 | const db = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' }) | |
| 28 | await db.ready() | |
| 29 | ||
| 30 | // Create identity registry (Autobase-backed) | |
| 31 | const registry = new IdentityRegistry(store, { db }) | |
| 32 | await registry.ready() | |
| 33 | ||
| 34 | // Create auth manager | |
| 35 | const auth = new AuthManager({ | |
| 36 | identityRegistry: registry, | |
| 37 | db | |
| 38 | }) | |
| 39 | ||
| 40 | // Authenticate HTTP requests | |
| 41 | const result = await auth.authenticate(req) | |
| 42 | if (result) { | |
| 43 | console.log(`User ${result.userId} authenticated via ${result.method}`) | |
| 44 | console.log(`Permissions:`, result.permissions) | |
| 45 | } | |
| 46 | ``` | |
| 47 | ||
| 48 | ## Authentication Methods | |
| 49 | ||
| 50 | ### Web Identity (ANProto) | |
| 51 | ||
| 52 | Browser-generated Ed25519 keypairs with challenge-response verification. | |
| 53 | ||
| 54 | **Headers:** | |
| 55 | ``` | |
| 56 | X-Identity-PublicKey: <base64-encoded-public-key> | |
| 57 | X-Identity-Signature: <base64-encoded-signature> | |
| 58 | X-Identity-Challenge: <challenge-id> | |
| 59 | ``` | |
| 60 | ||
| 61 | **Flow:** | |
| 62 | 1. Client requests challenge: `POST /api/auth/challenge` → `{ challengeId, challenge }` | |
| 63 | 2. Client signs challenge with Ed25519 secret key | |
| 64 | 3. Client sends signed request with headers above | |
| 65 | 4. Server verifies signature and looks up/creates user | |
| 66 | ||
| 67 | ```javascript | |
| 68 | import { ANProto } from 'hyper-identity' | |
| 69 | ||
| 70 | // Generate keypair (client-side) | |
| 71 | const keypair = ANProto.gen() | |
| 72 | const publicKey = ANProto.getPublicKey(keypair.publicKey) | |
| 73 | ||
| 74 | // Sign challenge | |
| 75 | const signature = ANProto.sign(challenge, keypair.secretKey) | |
| 76 | ||
| 77 | // Verify (server-side) | |
| 78 | const valid = ANProto.verify(challenge, signature, publicKey) | |
| 79 | ``` | |
| 80 | ||
| 81 | ### Keet Identity (BIP39 Mnemonic) | |
| 82 | ||
| 83 | BIP39 mnemonic → Ed25519 keypair with device attestation proof chains. | |
| 84 | ||
| 85 | **Headers:** | |
| 86 | ``` | |
| 87 | X-Identity-Proof: <json-encoded-proof-chain> | |
| 88 | ``` | |
| 89 | ||
| 90 | **Requires:** `keet-identity-key` package (pass as `IdentityKey` option). | |
| 91 | ||
| 92 | ```javascript | |
| 93 | import { AuthManager } from 'hyper-identity' | |
| 94 | import IdentityKey from 'keet-identity-key' | |
| 95 | ||
| 96 | const auth = new AuthManager({ | |
| 97 | identityRegistry: registry, | |
| 98 | db, | |
| 99 | IdentityKey // Enable keet identity support | |
| 100 | }) | |
| 101 | ``` | |
| 102 | ||
| 103 | ### Bearer Token | |
| 104 | ||
| 105 | API tokens with embedded permissions for CLI and service-to-service auth. | |
| 106 | ||
| 107 | **Header:** | |
| 108 | ``` | |
| 109 | Authorization: Bearer <token> | |
| 110 | ``` | |
| 111 | ||
| 112 | ```javascript | |
| 113 | // Create token | |
| 114 | const { token } = await auth.createToken({ | |
| 115 | username: 'admin', | |
| 116 | permissions: ['read', 'write', 'admin'], | |
| 117 | resources: ['*'] | |
| 118 | }) | |
| 119 | ||
| 120 | // First start: auto-create admin token | |
| 121 | const adminToken = await auth.createDefaultAdmin() | |
| 122 | ``` | |
| 123 | ||
| 124 | ### SSH Key | |
| 125 | ||
| 126 | Ed25519 and RSA SSH key signature verification. | |
| 127 | ||
| 128 | **Headers:** | |
| 129 | ``` | |
| 130 | X-SSH-PublicKey: <ssh-public-key-string> | |
| 131 | X-SSH-Signature: <base64-signature> | |
| 132 | X-SSH-Challenge: <challenge-id> | |
| 133 | ``` | |
| 134 | ||
| 135 | ## Permission Model | |
| 136 | ||
| 137 | hyper-identity uses a generic `resources` model instead of application-specific terms. Each user has: | |
| 138 | ||
| 139 | ```javascript | |
| 140 | { | |
| 141 | userId: "hex-string", | |
| 142 | username: "alice", | |
| 143 | globalPermissions: ["read", "write"], // Global default | |
| 144 | resources: [ | |
| 145 | { name: "project-a", permissions: ["read", "write", "admin"] }, | |
| 146 | { name: "*", permissions: ["read"] } // Wildcard | |
| 147 | ] | |
| 148 | } | |
| 149 | ``` | |
| 150 | ||
| 151 | ### Injectable Permission Check | |
| 152 | ||
| 153 | Override the default permission logic by passing a `checkPermission` callback: | |
| 154 | ||
| 155 | ```javascript | |
| 156 | const auth = new AuthManager({ | |
| 157 | identityRegistry: registry, | |
| 158 | db, | |
| 159 | // Custom permission check for your application | |
| 160 | checkPermission (auth, action, resourceName) { | |
| 161 | // Example: notebook-based permissions for hypernote | |
| 162 | if (auth.permissions.admin) return true | |
| 163 | if (!auth.permissions[action]) return false | |
| 164 | return auth.permissions.resources.includes('*') || | |
| 165 | auth.permissions.resources.includes(resourceName) | |
| 166 | } | |
| 167 | }) | |
| 168 | ||
| 169 | // Check access | |
| 170 | auth.hasResourceAccess(authResult, 'my-notebook', 'write') // true/false | |
| 171 | ``` | |
| 172 | ||
| 173 | ### Authorization Middleware | |
| 174 | ||
| 175 | ```javascript | |
| 176 | // One-liner for route protection | |
| 177 | const { authorized, auth, reason } = await authManager.authorizeRequest(req, 'write', 'my-resource') | |
| 178 | if (!authorized) { | |
| 179 | res.statusCode = auth ? 403 : 401 | |
| 180 | res.end(JSON.stringify({ error: reason })) | |
| 181 | return | |
| 182 | } | |
| 183 | ``` | |
| 184 | ||
| 185 | ## Identity Registry (Autobase) | |
| 186 | ||
| 187 | The `IdentityRegistry` provides multi-writer consensus via Autobase. Multiple servers can accept identity operations independently and sync automatically. | |
| 188 | ||
| 189 | ```javascript | |
| 190 | import { IdentityRegistry } from 'hyper-identity' | |
| 191 | ||
| 192 | const registry = new IdentityRegistry(store, { db }) | |
| 193 | await registry.ready() | |
| 194 | ||
| 195 | // Create user | |
| 196 | const user = await registry.createUser({ | |
| 197 | username: 'alice', | |
| 198 | name: 'Alice', | |
| 199 | globalPermissions: ['read', 'write'], | |
| 200 | resources: [{ name: '*', permissions: ['read'] }] | |
| 201 | }) | |
| 202 | ||
| 203 | // Register identity | |
| 204 | await registry.registerIdentity({ | |
| 205 | identityId: publicKey, | |
| 206 | method: 'web-identity', | |
| 207 | publicKey | |
| 208 | }) | |
| 209 | ||
| 210 | // Link identity to user | |
| 211 | await registry.linkIdentityToUser(publicKey, user.userId) | |
| 212 | ||
| 213 | // Look up user by identity | |
| 214 | const found = await registry.getUserByIdentity(publicKey) | |
| 215 | ||
| 216 | // Upgrade permissions | |
| 217 | await registry.upgradePermissions(user.userId, 'my-resource', ['read', 'write', 'admin']) | |
| 218 | ||
| 219 | // Merge duplicate accounts | |
| 220 | await registry.mergeAccounts(primaryUserId, duplicateUserId) | |
| 221 | ||
| 222 | // Add writer for federation | |
| 223 | await registry.addWriter(remoteWriterKey) | |
| 224 | ``` | |
| 225 | ||
| 226 | ### Storage Schema | |
| 227 | ||
| 228 | ``` | |
| 229 | user:{userId} → User object with permissions, resources, identities | |
| 230 | identity:{identityId} → Identity object with method, publicKey, metadata | |
| 231 | pubkey:{publicKey} → { identityId } (reverse lookup) | |
| 232 | user-identity:{id} → { userId } (Autobase view index) | |
| 233 | token:{hash} → Bearer token data | |
| 234 | challenge:{id} → Challenge state | |
| 235 | vault:{name} → Encrypted storage entry | |
| 236 | ``` | |
| 237 | ||
| 238 | ## Device Linking | |
| 239 | ||
| 240 | Secure mnemonic exchange between devices using blind-pairing over Hyperswarm. | |
| 241 | ||
| 242 | ```javascript | |
| 243 | import { DeviceLinker, IdentityManager } from 'hyper-identity' | |
| 244 | import Hyperswarm from 'hyperswarm' | |
| 245 | ||
| 246 | const swarm = new Hyperswarm() | |
| 247 | const identityManager = new IdentityManager(db) | |
| 248 | await identityManager.ready() | |
| 249 | ||
| 250 | // Set mnemonic on source device | |
| 251 | await identityManager.setMnemonic('abandon ability able ...') | |
| 252 | ||
| 253 | const linker = new DeviceLinker(swarm, { identityManager }) | |
| 254 | ||
| 255 | // Source device: create invite | |
| 256 | const { invite } = await linker.createInvite() | |
| 257 | console.log('Share this invite:', invite) | |
| 258 | ||
| 259 | // Target device: join with invite | |
| 260 | const result = await linker.joinWithInvite(invite) | |
| 261 | console.log('Received mnemonic:', result.mnemonic) | |
| 262 | ``` | |
| 263 | ||
| 264 | ## Encrypted Storage | |
| 265 | ||
| 266 | Argon2id key derivation + XSalsa20-Poly1305 encryption for sensitive data. | |
| 267 | ||
| 268 | ```javascript | |
| 269 | import { SecureStorage } from 'hyper-identity' | |
| 270 | ||
| 271 | const vault = new SecureStorage(db) | |
| 272 | ||
| 273 | // Store encrypted | |
| 274 | await vault.put('mnemonic', 'abandon ability able ...', 'my-password') | |
| 275 | ||
| 276 | // Retrieve and decrypt | |
| 277 | const mnemonic = await vault.get('mnemonic', 'my-password') | |
| 278 | ||
| 279 | // Check existence | |
| 280 | const exists = await vault.has('mnemonic') | |
| 281 | ||
| 282 | // Delete | |
| 283 | await vault.del('mnemonic') | |
| 284 | ``` | |
| 285 | ||
| 286 | ## Challenge Manager | |
| 287 | ||
| 288 | Server-side challenge generation with replay prevention. | |
| 289 | ||
| 290 | ```javascript | |
| 291 | import { ChallengeManager } from 'hyper-identity' | |
| 292 | ||
| 293 | const challenges = new ChallengeManager({ | |
| 294 | db, | |
| 295 | ttl: 5 * 60 * 1000 // 5 minutes | |
| 296 | }) | |
| 297 | ||
| 298 | // Generate challenge | |
| 299 | const { challengeId, challenge, expiresAt } = await challenges.generate() | |
| 300 | ||
| 301 | // Verify and consume (single-use) | |
| 302 | const result = await challenges.verify(challengeId, challenge) | |
| 303 | ``` | |
| 304 | ||
| 305 | ## Rate Limiter | |
| 306 | ||
| 307 | Token bucket rate limiting with configurable operations. | |
| 308 | ||
| 309 | ```javascript | |
| 310 | import { RateLimiter } from 'hyper-identity' | |
| 311 | ||
| 312 | const limiter = new RateLimiter({ | |
| 313 | limits: { | |
| 314 | auth: { tokens: 1000, refillRate: 100, refillInterval: 60000 }, | |
| 315 | api: { tokens: 10000, refillRate: 1000, refillInterval: 60000 } | |
| 316 | } | |
| 317 | }) | |
| 318 | ||
| 319 | const { allowed, remaining, retryAfter } = await limiter.check('192.168.1.1', 'auth') | |
| 320 | ``` | |
| 321 | ||
| 322 | ## Federation (Multi-Writer) | |
| 323 | ||
| 324 | Multiple servers can share an identity registry via Autobase: | |
| 325 | ||
| 326 | ```javascript | |
| 327 | // Server A: create registry | |
| 328 | const registryA = new IdentityRegistry(storeA, { db: dbA }) | |
| 329 | await registryA.ready() | |
| 330 | ||
| 331 | // Server B: join with A's bootstrap key | |
| 332 | const registryB = new IdentityRegistry(storeB, { | |
| 333 | db: dbB, | |
| 334 | bootstrapKey: registryA.base.key // Share this out-of-band | |
| 335 | }) | |
| 336 | await registryB.ready() | |
| 337 | ||
| 338 | // Add each other as writers | |
| 339 | await registryA.addWriter(registryB.base.local.key) | |
| 340 | await registryB.addWriter(registryA.base.local.key) | |
| 341 | ||
| 342 | // Both servers now see all identity operations | |
| 343 | ``` | |
| 344 | ||
| 345 | ## Custom Auth Providers | |
| 346 | ||
| 347 | Extend `BaseProvider` to add custom authentication methods: | |
| 348 | ||
| 349 | ```javascript | |
| 350 | import { BaseProvider } from 'hyper-identity' | |
| 351 | ||
| 352 | class MyCustomProvider extends BaseProvider { | |
| 353 | constructor (registry) { | |
| 354 | super('my-provider') | |
| 355 | this.registry = registry | |
| 356 | } | |
| 357 | ||
| 358 | async extractCredentials (req) { | |
| 359 | const token = req.headers['x-my-token'] | |
| 360 | return token ? { token } : null | |
| 361 | } | |
| 362 | ||
| 363 | async authenticate (credentials) { | |
| 364 | // Your verification logic | |
| 365 | return { | |
| 366 | identityId: 'my-identity-id', | |
| 367 | method: 'my-provider', | |
| 368 | credentials | |
| 369 | } | |
| 370 | } | |
| 371 | } | |
| 372 | ||
| 373 | // Register with AuthManager | |
| 374 | auth.addProvider(new MyCustomProvider(registry)) | |
| 375 | ``` | |
| 376 | ||
| 377 | ## API Reference | |
| 378 | ||
| 379 | ### AuthManager | |
| 380 | ||
| 381 | | Method | Description | | |
| 382 | |--------|-------------| | |
| 383 | | `authenticate(req, opts)` | Authenticate HTTP request via all providers | | |
| 384 | | `authorizeRequest(req, action, resource)` | Authenticate + check permissions | | |
| 385 | | `hasResourceAccess(auth, resource, action)` | Check permission for action | | |
| 386 | | `addProvider(provider)` | Register custom auth provider | | |
| 387 | | `createToken(opts)` | Create bearer token | | |
| 388 | | `validateToken(token)` | Validate bearer token | | |
| 389 | | `revokeToken(hash)` | Revoke bearer token | | |
| 390 | | `createDefaultAdmin()` | Auto-create admin token on first start | | |
| 391 | | `generateChallenge(opts)` | Generate auth challenge | | |
| 392 | | `verifyChallenge(id, challenge)` | Verify and consume challenge | | |
| 393 | | `getUserWithIdentities(id)` | Get user + all linked identities | | |
| 394 | | `upgradeUserPermissions(userId, resource, perms)` | Upgrade user permissions | | |
| 395 | | `getClientIp(req)` | Extract client IP (proxy-aware) | | |
| 396 | ||
| 397 | ### IdentityRegistry | |
| 398 | ||
| 399 | | Method | Description | | |
| 400 | |--------|-------------| | |
| 401 | | `ready()` | Initialize Autobase | | |
| 402 | | `createUser(opts)` | Create user with permissions | | |
| 403 | | `getUser(userId)` | Get user by ID | | |
| 404 | | `getUserByIdentity(identityId)` | Get user by identity | | |
| 405 | | `getUserByUsername(username)` | Get user by username | | |
| 406 | | `updateUser(userId, updates)` | Update user fields | | |
| 407 | | `deleteUser(userId)` | Delete user | | |
| 408 | | `listUsers()` | List all non-merged users | | |
| 409 | | `registerIdentity(identity)` | Register new identity | | |
| 410 | | `getIdentity(identityId)` | Get identity by ID | | |
| 411 | | `getIdentityByPublicKey(pk)` | Get identity by public key | | |
| 412 | | `linkIdentityToUser(identityId, userId)` | Link identity to user | | |
| 413 | | `upgradePermissions(userId, resource, perms)` | Upgrade permissions | | |
| 414 | | `mergeAccounts(primary, secondary)` | Merge duplicate accounts | | |
| 415 | | `revokeIdentity(identityId, opts)` | Revoke identity | | |
| 416 | | `restoreIdentity(identityId)` | Restore revoked identity | | |
| 417 | | `addWriter(key)` | Add Autobase writer for federation | | |
| 418 | ||
| 419 | ### ANProto | |
| 420 | ||
| 421 | | Method | Description | | |
| 422 | |--------|-------------| | |
| 423 | | `gen()` | Generate Ed25519 keypair | | |
| 424 | | `loadKeypair(keys)` | Load keypair from hex strings | | |
| 425 | | `sign(challenge, secretKey)` | Sign challenge → base64 signature | | |
| 426 | | `verify(challenge, signature, publicKey)` | Verify signature | | |
| 427 | | `getPublicKey(publicKey)` | Get hex-encoded public key | | |
| 428 | ||
| 429 | ## Dependencies | |
| 430 | ||
| 431 | - `autobase` — Multi-writer consensus | |
| 432 | - `hyperbee` — Persistent key-value storage | |
| 433 | - `hypercore` / `hypercore-crypto` — Core storage and Ed25519 cryptography | |
| 434 | - `hyperswarm` — P2P networking (for device linking) | |
| 435 | - `blind-pairing` — Secure device pairing protocol | |
| 436 | - `keet-identity-key` — BIP39 mnemonic identity (optional) | |
| 437 | - `sodium-native` — Argon2id + XSalsa20-Poly1305 encryption | |
| 438 | - `b4a` — Buffer utilities | |
| 439 | - `corestore` — Hypercore namespace management | |
| 440 | ||
| 441 | ## Used By | |
| 442 | ||
| 443 | - **[pear-git](https://github.com/holepunchto/pear-git)** — P2P Git forge (original source) | |
| 444 | - **[hypernote](https://github.com/holepunchto/hypernote)** — P2P notebook app (migration planned) | |
| 445 | - **[hyperident-gateway](https://github.com/holepunchto/hyperident-gateway)** — P2P VPN (planned) | |
| 446 | ||
| 447 | ## License | |
| 448 | ||
| 449 | MIT |
| … | … | @@ -0,0 +1,247 @@ |
| 1 | # API Endpoints Reference | |
| 2 | ||
| 3 | hyper-identity does not register routes itself — your application creates routes that call AuthManager methods. This document shows the standard endpoints used by pear-git and hypernote. | |
| 4 | ||
| 5 | ## Authentication Endpoints | |
| 6 | ||
| 7 | ### Generate Challenge | |
| 8 | ||
| 9 | ```http | |
| 10 | POST /api/auth/challenge | |
| 11 | Content-Type: application/json | |
| 12 | ||
| 13 | Response 200: | |
| 14 | { | |
| 15 | "challengeId": "a1b2c3d4e5f6...", | |
| 16 | "challenge": "random-hex-string", | |
| 17 | "expiresAt": 1709900300000 | |
| 18 | } | |
| 19 | ``` | |
| 20 | ||
| 21 | **Implementation:** | |
| 22 | ```javascript | |
| 23 | router.post('/api/auth/challenge', async (req, res) => { | |
| 24 | const { challengeId, challenge, expiresAt } = await authManager.generateChallenge() | |
| 25 | json(res, { challengeId, challenge, expiresAt }) | |
| 26 | }) | |
| 27 | ``` | |
| 28 | ||
| 29 | ### Who Am I | |
| 30 | ||
| 31 | ```http | |
| 32 | GET /api/whoami | |
| 33 | Headers: | |
| 34 | X-Identity-PublicKey: <base64-key> | |
| 35 | X-Identity-Signature: <base64-sig> | |
| 36 | X-Identity-Challenge: <challenge-id> | |
| 37 | ||
| 38 | Response 200: | |
| 39 | { | |
| 40 | "userId": "a1b2c3d4...", | |
| 41 | "username": "alice", | |
| 42 | "method": "web-identity", | |
| 43 | "permissions": { | |
| 44 | "read": true, | |
| 45 | "write": true, | |
| 46 | "admin": false, | |
| 47 | "resources": ["*"] | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | Response 401: | |
| 52 | { | |
| 53 | "error": "Unauthorized" | |
| 54 | } | |
| 55 | ``` | |
| 56 | ||
| 57 | **Implementation:** | |
| 58 | ```javascript | |
| 59 | router.get('/api/whoami', async (req, res) => { | |
| 60 | const auth = await authManager.authenticate(req) | |
| 61 | if (!auth) return sendAuthError(res) | |
| 62 | json(res, { | |
| 63 | userId: auth.userId, | |
| 64 | username: auth.user?.username, | |
| 65 | method: auth.method, | |
| 66 | permissions: auth.permissions | |
| 67 | }) | |
| 68 | }) | |
| 69 | ``` | |
| 70 | ||
| 71 | ## Identity Management Endpoints | |
| 72 | ||
| 73 | ### Generate Web Identity | |
| 74 | ||
| 75 | ```http | |
| 76 | POST /api/identity/generate | |
| 77 | ||
| 78 | Response 200: | |
| 79 | { | |
| 80 | "publicKey": "base64-public-key", | |
| 81 | "secretKey": "base64-secret-key" | |
| 82 | } | |
| 83 | ``` | |
| 84 | ||
| 85 | **Implementation:** | |
| 86 | ```javascript | |
| 87 | import { ANProto } from 'hyper-identity' | |
| 88 | ||
| 89 | router.post('/api/identity/generate', async (req, res) => { | |
| 90 | const keypair = ANProto.gen() | |
| 91 | json(res, { | |
| 92 | publicKey: b4a.toString(keypair.publicKey, 'base64'), | |
| 93 | secretKey: b4a.toString(keypair.secretKey, 'base64') | |
| 94 | }) | |
| 95 | }) | |
| 96 | ``` | |
| 97 | ||
| 98 | ### List User Identities | |
| 99 | ||
| 100 | ```http | |
| 101 | GET /api/user/identities | |
| 102 | Headers: [auth headers] | |
| 103 | ||
| 104 | Response 200: | |
| 105 | { | |
| 106 | "userId": "a1b2c3d4...", | |
| 107 | "identities": [ | |
| 108 | { | |
| 109 | "identityId": "GXsPilmS0Ek...", | |
| 110 | "method": "web-identity", | |
| 111 | "createdAt": 1709900000000 | |
| 112 | } | |
| 113 | ] | |
| 114 | } | |
| 115 | ``` | |
| 116 | ||
| 117 | ### Link Identity to User | |
| 118 | ||
| 119 | ```http | |
| 120 | POST /api/admin/identity/link | |
| 121 | Headers: Authorization: Bearer <admin-token> | |
| 122 | Body: | |
| 123 | { | |
| 124 | "identityId": "new-identity-id", | |
| 125 | "userId": "existing-user-id" | |
| 126 | } | |
| 127 | ``` | |
| 128 | ||
| 129 | ## Token Management Endpoints | |
| 130 | ||
| 131 | ### Create Token | |
| 132 | ||
| 133 | ```http | |
| 134 | POST /api/admin/tokens | |
| 135 | Headers: Authorization: Bearer <admin-token> | |
| 136 | Body: | |
| 137 | { | |
| 138 | "username": "api-user", | |
| 139 | "description": "CI/CD token", | |
| 140 | "permissions": ["read", "write"], | |
| 141 | "resources": ["my-project"] | |
| 142 | } | |
| 143 | ||
| 144 | Response 201: | |
| 145 | { | |
| 146 | "token": "raw-token-value-only-shown-once", | |
| 147 | "hash": "sha256-hash", | |
| 148 | "username": "api-user", | |
| 149 | "permissions": ["read", "write"], | |
| 150 | "resources": ["my-project"] | |
| 151 | } | |
| 152 | ``` | |
| 153 | ||
| 154 | ### List Tokens | |
| 155 | ||
| 156 | ```http | |
| 157 | GET /api/admin/tokens | |
| 158 | Headers: Authorization: Bearer <admin-token> | |
| 159 | ||
| 160 | Response 200: | |
| 161 | { | |
| 162 | "tokens": [ | |
| 163 | { | |
| 164 | "hash": "sha256-hash", | |
| 165 | "username": "admin", | |
| 166 | "description": "Default admin token", | |
| 167 | "permissions": ["read", "write", "admin"], | |
| 168 | "createdAt": 1709900000000 | |
| 169 | } | |
| 170 | ] | |
| 171 | } | |
| 172 | ``` | |
| 173 | ||
| 174 | ### Revoke Token | |
| 175 | ||
| 176 | ```http | |
| 177 | DELETE /api/admin/tokens/:hash | |
| 178 | Headers: Authorization: Bearer <admin-token> | |
| 179 | ||
| 180 | Response 200: | |
| 181 | { "revoked": true } | |
| 182 | ``` | |
| 183 | ||
| 184 | ## Permission Management Endpoints | |
| 185 | ||
| 186 | ### Upgrade User Permissions | |
| 187 | ||
| 188 | ```http | |
| 189 | POST /api/admin/users/:userId/permissions | |
| 190 | Headers: Authorization: Bearer <admin-token> | |
| 191 | Body: | |
| 192 | { | |
| 193 | "resourceName": "my-project", | |
| 194 | "permissions": ["read", "write", "admin"] | |
| 195 | } | |
| 196 | ``` | |
| 197 | ||
| 198 | ### Revoke Identity | |
| 199 | ||
| 200 | ```http | |
| 201 | POST /api/admin/identities/:identityId/revoke | |
| 202 | Headers: Authorization: Bearer <admin-token> | |
| 203 | Body: | |
| 204 | { | |
| 205 | "reason": "Compromised device" | |
| 206 | } | |
| 207 | ``` | |
| 208 | ||
| 209 | ### Restore Identity | |
| 210 | ||
| 211 | ```http | |
| 212 | POST /api/admin/identities/:identityId/restore | |
| 213 | Headers: Authorization: Bearer <admin-token> | |
| 214 | ``` | |
| 215 | ||
| 216 | ## Protected Route Pattern | |
| 217 | ||
| 218 | Standard pattern for protecting your application routes: | |
| 219 | ||
| 220 | ```javascript | |
| 221 | import { AuthManager, sendAuthError } from 'hyper-identity' | |
| 222 | ||
| 223 | // Read-only endpoint (auth optional for public data) | |
| 224 | router.get('/api/items', async (req, res) => { | |
| 225 | const items = await getItems() | |
| 226 | json(res, { items }) | |
| 227 | }) | |
| 228 | ||
| 229 | // Write endpoint (auth required) | |
| 230 | router.post('/api/items', async (req, res) => { | |
| 231 | const { authorized, auth, reason } = await authManager.authorizeRequest(req, 'write', 'items') | |
| 232 | if (!auth) return sendAuthError(res, 'Authentication required', 401) | |
| 233 | if (!authorized) return sendAuthError(res, reason, 403) | |
| 234 | ||
| 235 | const item = await createItem(await readJson(req)) | |
| 236 | json(res, item, 201) | |
| 237 | }) | |
| 238 | ||
| 239 | // Admin endpoint | |
| 240 | router.delete('/api/items/:id', async (req, res) => { | |
| 241 | const { authorized, auth, reason } = await authManager.authorizeRequest(req, 'admin') | |
| 242 | if (!authorized) return sendAuthError(res, reason || 'Unauthorized', auth ? 403 : 401) | |
| 243 | ||
| 244 | await deleteItem(req.params.id) | |
| 245 | json(res, { deleted: true }) | |
| 246 | }) | |
| 247 | ``` |
| … | … | @@ -0,0 +1,218 @@ |
| 1 | # Authentication System | |
| 2 | ||
| 3 | ## Overview | |
| 4 | ||
| 5 | hyper-identity provides a pluggable authentication system with four built-in providers and support for custom providers. All providers follow the same interface: extract credentials from HTTP request, verify them, return an auth result. | |
| 6 | ||
| 7 | ## Providers | |
| 8 | ||
| 9 | ### Web Identity (ANProto) | |
| 10 | ||
| 11 | **Protocol:** Ed25519 challenge-response using hypercore-crypto. | |
| 12 | ||
| 13 | **Headers:** | |
| 14 | | Header | Required | Description | | |
| 15 | |--------|----------|-------------| | |
| 16 | | `X-Identity-PublicKey` | Yes | Base64-encoded Ed25519 public key | | |
| 17 | | `X-Identity-Signature` | For signed auth | Base64-encoded signature of challenge | | |
| 18 | | `X-Identity-Challenge` | For signed auth | Challenge ID from server | | |
| 19 | ||
| 20 | Also accepts legacy headers: `X-Wirecore-PublicKey`, `X-Wirecore-Signature`, `X-Wirecore-Challenge`. | |
| 21 | ||
| 22 | **Signed Auth Flow:** | |
| 23 | 1. Client: `POST /api/auth/challenge` → `{ challengeId, challenge, expiresAt }` | |
| 24 | 2. Client: Sign `challenge` with Ed25519 secret key → base64 signature | |
| 25 | 3. Client: Send request with all three headers | |
| 26 | 4. Server: Look up challenge, verify signature, authenticate | |
| 27 | ||
| 28 | **Unsigned Auth (Legacy):** | |
| 29 | - Send only `X-Identity-PublicKey` header | |
| 30 | - Server looks up existing identity by public key | |
| 31 | - No new identities auto-registered without signature | |
| 32 | ||
| 33 | **Keypair Format:** | |
| 34 | - Public key: 32 bytes → 44 characters base64 | |
| 35 | - Secret key: 64 bytes → 88 characters base64 | |
| 36 | - Generated client-side using `ANProto.gen()` or `@noble/ed25519` | |
| 37 | ||
| 38 | ### Keet Identity | |
| 39 | ||
| 40 | **Protocol:** BIP39 mnemonic → Ed25519 keypair with proof chain attestation. | |
| 41 | ||
| 42 | **Header:** | |
| 43 | | Header | Required | Description | | |
| 44 | |--------|----------|-------------| | |
| 45 | | `X-Identity-Proof` | Yes | JSON-encoded proof chain | | |
| 46 | ||
| 47 | **Dependencies:** Requires `keet-identity-key` package. Pass as `IdentityKey` option to AuthManager. | |
| 48 | ||
| 49 | **Proof Chain Structure:** | |
| 50 | ```json | |
| 51 | { | |
| 52 | "publicKey": "hex-encoded-identity-public-key", | |
| 53 | "deviceKey": "hex-encoded-device-public-key", | |
| 54 | "proof": "hex-encoded-attestation-signature" | |
| 55 | } | |
| 56 | ``` | |
| 57 | ||
| 58 | ### Bearer Token | |
| 59 | ||
| 60 | **Protocol:** Random token with SHA256 hash storage. | |
| 61 | ||
| 62 | **Header:** | |
| 63 | | Header | Required | Description | | |
| 64 | |--------|----------|-------------| | |
| 65 | | `Authorization` | Yes | `Bearer <64-char-hex-token>` | | |
| 66 | ||
| 67 | **Token Structure (stored):** | |
| 68 | ```javascript | |
| 69 | { | |
| 70 | hash: "sha256-of-token", | |
| 71 | username: "admin", | |
| 72 | description: "API access token", | |
| 73 | permissions: ["read", "write", "admin"], | |
| 74 | resources: ["*"], | |
| 75 | createdAt: timestamp, | |
| 76 | expiresAt: null, // null = never expires | |
| 77 | revoked: false | |
| 78 | } | |
| 79 | ``` | |
| 80 | ||
| 81 | **Security:** Only the SHA256 hash is stored. The raw token is returned once at creation time and cannot be retrieved later. | |
| 82 | ||
| 83 | ### SSH Key | |
| 84 | ||
| 85 | **Protocol:** SSH key signature verification (Ed25519 + RSA). | |
| 86 | ||
| 87 | **Headers:** | |
| 88 | | Header | Required | Description | | |
| 89 | |--------|----------|-------------| | |
| 90 | | `X-SSH-PublicKey` | Yes | Full SSH public key string | | |
| 91 | | `X-SSH-Signature` | Yes | Base64-encoded SSH signature | | |
| 92 | | `X-SSH-Challenge` | Yes | Challenge ID | | |
| 93 | ||
| 94 | **Supported Key Types:** | |
| 95 | - `ssh-ed25519` — Verified using hypercore-crypto (works in Bare runtime) | |
| 96 | - `ssh-rsa` — Verified using node:crypto (Node.js only) | |
| 97 | ||
| 98 | ## Challenge System | |
| 99 | ||
| 100 | Server-side challenge generation prevents replay attacks. | |
| 101 | ||
| 102 | **Lifecycle:** | |
| 103 | 1. Generate: Random 32-byte challenge + unique ID | |
| 104 | 2. Store: In Hyperbee + in-memory cache | |
| 105 | 3. Verify: Check exists, not expired, not consumed | |
| 106 | 4. Consume: Mark as used (single-use) | |
| 107 | 5. Cleanup: Remove expired challenges periodically | |
| 108 | ||
| 109 | **Configuration:** | |
| 110 | ```javascript | |
| 111 | new ChallengeManager({ | |
| 112 | db: hyperbee, | |
| 113 | ttl: 5 * 60 * 1000, // Challenge expiry (5 min) | |
| 114 | cacheTimeout: 30 * 1000 // Memory cache lifetime (30s) | |
| 115 | }) | |
| 116 | ``` | |
| 117 | ||
| 118 | ## Rate Limiting | |
| 119 | ||
| 120 | Token bucket algorithm with per-client, per-operation limits. | |
| 121 | ||
| 122 | **Default Limits:** | |
| 123 | | Operation | Tokens | Refill Rate | Refill Interval | | |
| 124 | |-----------|--------|-------------|-----------------| | |
| 125 | | `auth` | 1,000 | 100/refill | 60 seconds | | |
| 126 | | `api` | 10,000 | 1,000/refill | 60 seconds | | |
| 127 | | `identity-generate` | 5 | 1/refill | 1 hour | | |
| 128 | ||
| 129 | **How It Works:** | |
| 130 | 1. Each client+operation pair gets a token bucket | |
| 131 | 2. Each request costs 1 token | |
| 132 | 3. Tokens refill at configured rate | |
| 133 | 4. When bucket is empty: `{ allowed: false, retryAfter: seconds }` | |
| 134 | ||
| 135 | **Configuration:** | |
| 136 | ```javascript | |
| 137 | new RateLimiter({ | |
| 138 | limits: { | |
| 139 | auth: { tokens: 1000, refillRate: 100, refillInterval: 60000 }, | |
| 140 | api: { tokens: 10000, refillRate: 1000, refillInterval: 60000 } | |
| 141 | } | |
| 142 | }) | |
| 143 | ``` | |
| 144 | ||
| 145 | ## Permission Model | |
| 146 | ||
| 147 | ### Built-in Levels | |
| 148 | ||
| 149 | | Permission | Description | | |
| 150 | |------------|-------------| | |
| 151 | | `read` | Read-only access to resources | | |
| 152 | | `write` | Create, update, delete resources | | |
| 153 | | `admin` | Full access + manage users/permissions | | |
| 154 | ||
| 155 | ### Resource Permissions | |
| 156 | ||
| 157 | Users have both global and per-resource permissions: | |
| 158 | ||
| 159 | ```javascript | |
| 160 | // Global: applies when no resource-specific match | |
| 161 | globalPermissions: ["read"] | |
| 162 | ||
| 163 | // Per-resource: checked first, overrides global | |
| 164 | resources: [ | |
| 165 | { name: "project-a", permissions: ["read", "write"] }, | |
| 166 | { name: "*", permissions: ["read"] } // Wildcard | |
| 167 | ] | |
| 168 | ``` | |
| 169 | ||
| 170 | ### Permission Resolution Order | |
| 171 | ||
| 172 | 1. Check `auth.permissions.admin` → true = allow everything | |
| 173 | 2. Check `auth.permissions[action]` → false = deny | |
| 174 | 3. Check resource match → `resources.includes('*')` or `resources.includes(name)` | |
| 175 | ||
| 176 | ### Custom Permission Check | |
| 177 | ||
| 178 | Override with injectable callback: | |
| 179 | ||
| 180 | ```javascript | |
| 181 | const auth = new AuthManager({ | |
| 182 | checkPermission (auth, action, resourceName) { | |
| 183 | // Your custom logic here | |
| 184 | // Return true to allow, false to deny | |
| 185 | } | |
| 186 | }) | |
| 187 | ``` | |
| 188 | ||
| 189 | ## Security | |
| 190 | ||
| 191 | ### Signature Verification | |
| 192 | - Ed25519: Full verification via hypercore-crypto (works in Bare + Node.js) | |
| 193 | - RSA: Verification via node:crypto (Node.js only) | |
| 194 | ||
| 195 | ### Replay Prevention | |
| 196 | - Challenges are single-use and time-limited (5 min default) | |
| 197 | - Challenge IDs are unique random values | |
| 198 | - Consumed challenges cannot be reused | |
| 199 | ||
| 200 | ### Token Security | |
| 201 | - Bearer tokens stored as SHA256 hashes only | |
| 202 | - Raw token returned once at creation, never retrievable | |
| 203 | - Tokens can be revoked instantly | |
| 204 | ||
| 205 | ### Rate Limiting | |
| 206 | - Per-IP token bucket prevents brute force | |
| 207 | - Persistent state survives restarts | |
| 208 | - Configurable per-operation limits | |
| 209 | ||
| 210 | ### Identity Revocation | |
| 211 | - Revoked identities immediately fail authentication | |
| 212 | - Stored in both Autobase view and direct-write store | |
| 213 | - Revocation reason tracked for audit | |
| 214 | ||
| 215 | ### Trusted Proxies | |
| 216 | - Configurable trusted proxy list for X-Forwarded-For parsing | |
| 217 | - Default: localhost only (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) | |
| 218 | - Prevents IP spoofing from untrusted sources |
| … | … | @@ -0,0 +1,198 @@ |
| 1 | # Identity System Architecture | |
| 2 | ||
| 3 | ## Overview | |
| 4 | ||
| 5 | hyper-identity provides a unified identity model where a single **User** can have multiple **Identities** (authentication methods). All identity data is stored in an Autobase-backed registry for multi-writer consensus. | |
| 6 | ||
| 7 | ## Identity Types | |
| 8 | ||
| 9 | | Type | Protocol | Key Material | Auto-Register | | |
| 10 | |------|----------|-------------|---------------| | |
| 11 | | Web Identity | ANProto (Ed25519) | Browser-generated keypair | Yes | | |
| 12 | | Keet Identity | BIP39 mnemonic | HD-derived Ed25519 keypair | Yes | | |
| 13 | | Bearer Token | SHA256 hash | Random 32-byte token | No (admin creates) | | |
| 14 | | SSH Key | RFC 4253 | RSA or Ed25519 SSH key | Yes | | |
| 15 | ||
| 16 | ## Data Model | |
| 17 | ||
| 18 | ### User | |
| 19 | ||
| 20 | ```javascript | |
| 21 | { | |
| 22 | userId: "a1b2c3d4...", // Random hex (16 bytes) | |
| 23 | username: "alice", // Optional, unique | |
| 24 | name: "Alice", // Display name | |
| 25 | email: null, // Optional | |
| 26 | profilePicture: null, // Optional URL | |
| 27 | globalPermissions: ["read"], // Default permissions | |
| 28 | resources: [ // Per-resource permissions | |
| 29 | { name: "*", permissions: ["read"] }, | |
| 30 | { name: "my-project", permissions: ["read", "write", "admin"] } | |
| 31 | ], | |
| 32 | identities: ["pubkey1", "pubkey2"], // Linked identity IDs | |
| 33 | createdAt: 1709900000000, | |
| 34 | updatedAt: 1709900000000 | |
| 35 | } | |
| 36 | ``` | |
| 37 | ||
| 38 | ### Identity | |
| 39 | ||
| 40 | ```javascript | |
| 41 | { | |
| 42 | identityId: "GXsPilmS0Ek/grGO...", // Usually the public key | |
| 43 | method: "web-identity", // Provider name | |
| 44 | publicKey: "GXsPilmS0Ek/grGO...", // For signature verification | |
| 45 | userId: "a1b2c3d4...", // Linked user (after linking) | |
| 46 | createdAt: 1709900000000, | |
| 47 | revoked: false // Revocation status | |
| 48 | } | |
| 49 | ``` | |
| 50 | ||
| 51 | ## Storage Schema | |
| 52 | ||
| 53 | ### Direct-Write Store (Hyperbee) | |
| 54 | ||
| 55 | Identity metadata and reverse lookups are stored in a direct-write Hyperbee for fast lookups: | |
| 56 | ||
| 57 | ``` | |
| 58 | identity:{identityId} → Identity object | |
| 59 | pubkey:{publicKey} → { identityId } # Reverse lookup | |
| 60 | token:{sha256hash} → Bearer token data | |
| 61 | challenge:{challengeId} → Challenge state | |
| 62 | vault:{name} → Encrypted data | |
| 63 | ``` | |
| 64 | ||
| 65 | ### Autobase View (Multi-Writer) | |
| 66 | ||
| 67 | User records and identity-to-user mappings are stored in the Autobase view for consensus: | |
| 68 | ||
| 69 | ``` | |
| 70 | user:{userId} → User object | |
| 71 | user-identity:{id} → { userId } # Identity→User mapping | |
| 72 | ``` | |
| 73 | ||
| 74 | ## Authentication Flow | |
| 75 | ||
| 76 | ``` | |
| 77 | HTTP Request | |
| 78 | │ | |
| 79 | ├─ Extract credentials (per provider) | |
| 80 | │ ├─ X-Identity-PublicKey header → Web Identity | |
| 81 | │ ├─ X-Identity-Proof header → Keet Identity | |
| 82 | │ ├─ Authorization: Bearer header → Bearer Token | |
| 83 | │ └─ X-SSH-PublicKey header → SSH Key | |
| 84 | │ | |
| 85 | ├─ Verify credentials | |
| 86 | │ ├─ Challenge-response signature check | |
| 87 | │ ├─ Proof chain verification | |
| 88 | │ ├─ Token hash lookup | |
| 89 | │ └─ SSH signature verification | |
| 90 | │ | |
| 91 | ├─ Look up Identity → User | |
| 92 | │ ├─ pubkey:{key} → identity:{id} → user-identity:{id} → user:{userId} | |
| 93 | │ └─ Auto-register if new (web/keet/ssh) | |
| 94 | │ | |
| 95 | └─ Return auth result | |
| 96 | ├─ userId, identityId, method | |
| 97 | ├─ permissions (formatted from user object) | |
| 98 | └─ verified: true | |
| 99 | ``` | |
| 100 | ||
| 101 | ## Auto-Registration | |
| 102 | ||
| 103 | When a new identity authenticates for the first time: | |
| 104 | ||
| 105 | 1. **Register identity** in direct-write store (pubkey index) | |
| 106 | 2. **Create user** via Autobase (new userId, default read permissions) | |
| 107 | 3. **Link identity to user** in both stores | |
| 108 | 4. **Admin bootstrap**: If no admin exists, first user gets admin | |
| 109 | ||
| 110 | ## Identity Linking | |
| 111 | ||
| 112 | A user can have multiple identities (e.g., web + keet + SSH): | |
| 113 | ||
| 114 | ```javascript | |
| 115 | // User authenticates with web identity | |
| 116 | // Later, authenticates with keet identity | |
| 117 | // Admin links them: | |
| 118 | await registry.linkIdentityToUser(keetIdentityId, existingUserId) | |
| 119 | ``` | |
| 120 | ||
| 121 | After linking, authenticating with either identity resolves to the same user. | |
| 122 | ||
| 123 | ## Account Merge | |
| 124 | ||
| 125 | When duplicate accounts are detected (e.g., same person registered twice): | |
| 126 | ||
| 127 | ```javascript | |
| 128 | await registry.mergeAccounts(primaryUserId, secondaryUserId) | |
| 129 | ``` | |
| 130 | ||
| 131 | This: | |
| 132 | 1. Transfers all identities from secondary to primary | |
| 133 | 2. Merges resource permissions (union) | |
| 134 | 3. Merges global permissions (union) | |
| 135 | 4. Marks secondary user with `mergedInto: primaryUserId` | |
| 136 | ||
| 137 | ## Revocation | |
| 138 | ||
| 139 | Identities can be revoked (e.g., compromised device): | |
| 140 | ||
| 141 | ```javascript | |
| 142 | await registry.revokeIdentity(identityId, { reason: 'Device stolen' }) | |
| 143 | // Later: | |
| 144 | await registry.restoreIdentity(identityId) | |
| 145 | ``` | |
| 146 | ||
| 147 | Revoked identities fail authentication immediately. | |
| 148 | ||
| 149 | ## Federation | |
| 150 | ||
| 151 | Multiple servers share the identity registry via Autobase: | |
| 152 | ||
| 153 | ``` | |
| 154 | Server A (writer) ──┐ | |
| 155 | ├──→ Autobase ──→ Shared Hyperbee View | |
| 156 | Server B (writer) ──┘ | |
| 157 | ||
| 158 | Both servers: | |
| 159 | - Accept identity operations independently | |
| 160 | - Autobase linearizes all writes deterministically | |
| 161 | - Both see identical state after sync | |
| 162 | ``` | |
| 163 | ||
| 164 | ### Adding a Writer | |
| 165 | ||
| 166 | ```javascript | |
| 167 | // Server A creates registry | |
| 168 | const registryA = new IdentityRegistry(storeA, { db: dbA }) | |
| 169 | await registryA.ready() | |
| 170 | ||
| 171 | // Share registryA.base.key with Server B (out-of-band) | |
| 172 | ||
| 173 | // Server B joins | |
| 174 | const registryB = new IdentityRegistry(storeB, { db: dbB }) | |
| 175 | await registryB.ready() | |
| 176 | ||
| 177 | // Add each other as writers | |
| 178 | await registryA.addWriter(registryB.base.local.key) | |
| 179 | await registryB.addWriter(registryA.base.local.key) | |
| 180 | ``` | |
| 181 | ||
| 182 | ### Replication | |
| 183 | ||
| 184 | Autobase cores replicate automatically when corestores are connected via Hyperswarm. No additional wiring needed beyond `store.replicate(conn)`. | |
| 185 | ||
| 186 | ## Proof Chains (Keet Identity) | |
| 187 | ||
| 188 | BIP39 mnemonic → identity keypair → device attestation: | |
| 189 | ||
| 190 | ``` | |
| 191 | Keet Identity (BIP39-derived Ed25519) | |
| 192 | │ | |
| 193 | └─ SIGNS → Device Key (server's Ed25519 keypair) | |
| 194 | │ | |
| 195 | └─ Proof chain stored with identity | |
| 196 | ``` | |
| 197 | ||
| 198 | The proof chain proves a device key belongs to a mnemonic holder without exposing the mnemonic itself. Verification uses `IdentityKey.verify(proof)` from `keet-identity-key`. |