| … | … | @@ -56,40 +56,82 @@ export const ANProtoClient = { |
| 56 | 56 | }, |
| 57 | 57 | |
| 58 | 58 | /** |
| 59 | * Sign a challenge (detached Ed25519 signature) | |
| 60 | * Compatible with server-side ANProto.verify() | |
| 59 | * SHA-256 hash (uses crypto.subtle, works on localhost + HTTPS) | |
| 60 | * @param {string} data | |
| 61 | * @returns {Promise<string>} 44-char base64 hash | |
| 62 | */ | |
| 63 | async hash (data) { | |
| 64 | const bytes = new TextEncoder().encode(data) | |
| 65 | const hashBuf = await crypto.subtle.digest('SHA-256', bytes) | |
| 66 | return bytesToBase64(new Uint8Array(hashBuf)) | |
| 67 | }, | |
| 68 | ||
| 69 | /** | |
| 70 | * Sign a challenge using canonical ANProto wire format. | |
| 71 | * | |
| 72 | * Produces: pubkey(44 base64) + base64(signature(64) || timestamp + hash) | |
| 73 | * Wire-compatible with server-side ANProto.verify() and an.open(). | |
| 61 | 74 | * |
| 62 | 75 | * @param {string} challenge — challenge string (from server) |
| 63 | 76 | * @param {string} keypair — 132-char base64 keypair |
| 64 | * @returns {Promise<string>} 88-char base64 signature | |
| 77 | * @returns {Promise<string>} canonical wire-format signature | |
| 65 | 78 | */ |
| 66 | 79 | async sign (challenge, keypair) { |
| 80 | const pubKeyB64 = keypair.substring(0, 44) | |
| 67 | 81 | const secretKeyB64 = keypair.substring(44, 132) |
| 68 | const secretKeyBytes = base64ToBytes(secretKeyB64) | |
| 82 | const seed = base64ToBytes(secretKeyB64).slice(0, 32) | |
| 83 | ||
| 84 | // Canonical: hash the challenge, prepend timestamp, sign the combined message | |
| 85 | const contentHash = await this.hash(challenge) | |
| 86 | const ts = Date.now().toString() | |
| 87 | const message = ts + contentHash | |
| 88 | const messageBytes = new TextEncoder().encode(message) | |
| 69 | 89 | |
| 70 | // noble/ed25519 sign() takes the 32-byte seed (first half of 64-byte secret) | |
| 71 | const seed = secretKeyBytes.slice(0, 32) | |
| 72 | const challengeBytes = new TextEncoder().encode(challenge) | |
| 90 | // Detached signature of the message | |
| 91 | const sig = await ed.signAsync(messageBytes, seed) | |
| 73 | 92 | |
| 74 | const signature = await ed.signAsync(challengeBytes, seed) | |
| 75 | return bytesToBase64(signature) | |
| 93 | // Combined-mode envelope: signature(64 bytes) || message | |
| 94 | const combined = new Uint8Array(sig.length + messageBytes.length) | |
| 95 | combined.set(sig, 0) | |
| 96 | combined.set(messageBytes, sig.length) | |
| 97 | ||
| 98 | return pubKeyB64 + bytesToBase64(combined) | |
| 76 | 99 | }, |
| 77 | 100 | |
| 78 | 101 | /** |
| 79 | * Verify a detached signature | |
| 80 | * @param {string} challenge | |
| 81 | * @param {string} signatureB64 — 88-char base64 | |
| 82 | * @param {string} publicKeyB64 — 44-char base64 | |
| 83 | * @returns {Promise<boolean>} | |
| 102 | * Verify a canonical ANProto signature and extract the signed data. | |
| 103 | * @param {string} message — wire-format signature | |
| 104 | * @returns {Promise<{ valid: boolean, publicKey: string, timestamp: number, hash: string }>} | |
| 84 | 105 | */ |
| 85 | async verify (challenge, signatureB64, publicKeyB64) { | |
| 106 | async open (message) { | |
| 86 | 107 | try { |
| 87 | const challengeBytes = new TextEncoder().encode(challenge) | |
| 88 | const signature = base64ToBytes(signatureB64) | |
| 108 | if (typeof message !== 'string' || message.length < 44) { | |
| 109 | return { valid: false, publicKey: null, timestamp: null, hash: null } | |
| 110 | } | |
| 111 | const publicKeyB64 = message.substring(0, 44) | |
| 89 | 112 | const publicKey = base64ToBytes(publicKeyB64) |
| 90 | return await ed.verifyAsync(signature, challengeBytes, publicKey) | |
| 113 | const combined = base64ToBytes(message.substring(44)) | |
| 114 | ||
| 115 | if (combined.length <= 64) { | |
| 116 | return { valid: false, publicKey: publicKeyB64, timestamp: null, hash: null } | |
| 117 | } | |
| 118 | ||
| 119 | const sig = combined.slice(0, 64) | |
| 120 | const body = combined.slice(64) | |
| 121 | ||
| 122 | const valid = await ed.verifyAsync(sig, body, publicKey) | |
| 123 | if (!valid) { | |
| 124 | return { valid: false, publicKey: publicKeyB64, timestamp: null, hash: null } | |
| 125 | } | |
| 126 | ||
| 127 | const bodyStr = new TextDecoder().decode(body) | |
| 128 | const tsMatch = bodyStr.match(/^(\d+)/) | |
| 129 | const timestamp = tsMatch ? parseInt(tsMatch[0], 10) : null | |
| 130 | const hash = tsMatch ? bodyStr.substring(tsMatch[0].length) : bodyStr | |
| 131 | ||
| 132 | return { valid: true, publicKey: publicKeyB64, timestamp, hash } | |
| 91 | 133 | } catch { |
| 92 | return false | |
| 134 | return { valid: false, publicKey: null, timestamp: null, hash: null } | |
| 93 | 135 | } |
| 94 | 136 | } |
| 95 | 137 | } |
| … | … | @@ -68,41 +68,59 @@ export class ANProto { |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | 70 | // --------------------------------------------------------------------------- |
| 71 | // Challenge-response mode (HTTP auth — pear-git/hypernote pattern) | |
| 72 | // Uses hypercore-crypto detached signatures | |
| 71 | // Challenge-response mode (HTTP auth) | |
| 72 | // Uses canonical ANProto wire format: hash → sign with timestamp → combined envelope | |
| 73 | 73 | // --------------------------------------------------------------------------- |
| 74 | 74 | |
| 75 | 75 | /** |
| 76 | * Sign a challenge (detached mode, for HTTP auth) | |
| 76 | * Sign a challenge for HTTP auth using canonical ANProto format. | |
| 77 | * | |
| 78 | * Produces: pubkey(44 base64) + base64(combined_sign(timestamp + SHA256(challenge))) | |
| 79 | * This is wire-compatible with evbogue's ANProto and an.sign(). | |
| 80 | * | |
| 77 | 81 | * @param {string|Buffer} challenge - Challenge to sign |
| 78 | 82 | * @param {{ publicKey: Buffer, secretKey: Buffer }} keypair |
| 79 | * @returns {string} 88-char base64 detached signature | |
| 83 | * @returns {string} canonical wire-format signature | |
| 80 | 84 | */ |
| 81 | 85 | static sign (challenge, keypair) { |
| 82 | const msg = typeof challenge === 'string' ? b4a.from(challenge, 'utf8') : challenge | |
| 83 | const sig = crypto.sign(msg, keypair.secretKey) | |
| 84 | return b4a.toString(sig, 'base64') | |
| 86 | const challengeStr = typeof challenge === 'string' ? challenge : b4a.toString(challenge, 'utf8') | |
| 87 | const contentHash = ANProto.hash(challengeStr) | |
| 88 | return ANProto.signContent(contentHash, keypair) | |
| 85 | 89 | } |
| 86 | 90 | |
| 87 | 91 | /** |
| 88 | * Verify a detached challenge-response signature | |
| 92 | * Verify a canonical ANProto challenge-response signature. | |
| 93 | * | |
| 94 | * Opens the combined-mode envelope, extracts timestamp + hash, | |
| 95 | * verifies hash matches SHA256(challenge), checks timestamp freshness. | |
| 96 | * | |
| 89 | 97 | * @param {string|Buffer} challenge - Original challenge |
| 90 | * @param {string} signature - 88-char base64 signature | |
| 91 | * @param {string|Buffer} publicKey - Base64 or Buffer public key | |
| 92 | * @returns {boolean} | |
| 98 | * @param {string} signature - canonical wire-format signature (pubkey + combined envelope) | |
| 99 | * @returns {{ valid: boolean, publicKey: string|null, timestamp: number|null, error: string|null }} | |
| 93 | 100 | */ |
| 94 | static verify (challenge, signature, publicKey) { | |
| 101 | static verify (challenge, signature) { | |
| 95 | 102 | try { |
| 96 | const msg = typeof challenge === 'string' ? b4a.from(challenge, 'utf8') : challenge | |
| 97 | const sig = b4a.from(signature, 'base64') | |
| 98 | const pk = typeof publicKey === 'string' ? b4a.from(publicKey, 'base64') : publicKey | |
| 99 | ||
| 100 | if (pk.length !== 32) return false | |
| 101 | if (sig.length !== 64) return false | |
| 102 | ||
| 103 | return crypto.verify(msg, sig, pk) | |
| 104 | } catch { | |
| 105 | return false | |
| 103 | if (typeof signature !== 'string' || signature.length < 44) { | |
| 104 | return { valid: false, publicKey: null, timestamp: null, error: 'invalid signature' } | |
| 105 | } | |
| 106 | ||
| 107 | const verification = ANProto.open(signature) | |
| 108 | const challengeStr = typeof challenge === 'string' ? challenge : b4a.toString(challenge, 'utf8') | |
| 109 | const expectedHash = ANProto.hash(challengeStr) | |
| 110 | ||
| 111 | if (verification.hash !== expectedHash) { | |
| 112 | return { valid: false, publicKey: verification.publicKey, timestamp: verification.timestamp, error: 'hash mismatch' } | |
| 113 | } | |
| 114 | ||
| 115 | // Check timestamp freshness (5 minute window) | |
| 116 | const age = Date.now() - verification.timestamp | |
| 117 | if (age < -60000 || age > 5 * 60 * 1000) { | |
| 118 | return { valid: false, publicKey: verification.publicKey, timestamp: verification.timestamp, error: 'timestamp expired' } | |
| 119 | } | |
| 120 | ||
| 121 | return { valid: true, publicKey: verification.publicKey, timestamp: verification.timestamp, error: null } | |
| 122 | } catch (err) { | |
| 123 | return { valid: false, publicKey: null, timestamp: null, error: err.message } | |
| 106 | 124 | } |
| 107 | 125 | } |
| 108 | 126 |
| … | … | @@ -388,10 +388,11 @@ export class AuthManager { |
| 388 | 388 | if (merge.status !== 'pending') throw new Error('Merge already completed or expired') |
| 389 | 389 | if (merge.expiresAt < Date.now()) throw new Error('Merge request expired') |
| 390 | 390 | |
| 391 | // Verify the signature is from the target | |
| 391 | // Verify the signature is from the target (canonical ANProto format) | |
| 392 | 392 | const { ANProto } = await import('./anproto.js') |
| 393 | const valid = ANProto.verify(merge.challenge, signature, merge.targetPublicKey) | |
| 394 | if (!valid) throw new Error('Invalid signature — must be signed by the target account') | |
| 393 | const result = ANProto.verify(merge.challenge, signature) | |
| 394 | if (!result.valid) throw new Error('Invalid signature — ' + (result.error || 'verification failed')) | |
| 395 | if (result.publicKey !== merge.targetPublicKey) throw new Error('Signature is not from the target account') | |
| 395 | 396 | |
| 396 | 397 | // Execute the merge |
| 397 | 398 | await this.identityRegistry.mergeAccounts(merge.primaryUserId, merge.targetUserId) |
| … | … | @@ -310,6 +310,71 @@ export function createApplyFunction (opts = {}) { |
| 310 | 310 | break |
| 311 | 311 | } |
| 312 | 312 | |
| 313 | case OpTypes.CREATE_LINK_TOKEN: { | |
| 314 | // Store the token row keyed by its hash. Idempotent — a | |
| 315 | // duplicate op for the same hash is silently dropped. | |
| 316 | const key = `${prefix}link-token:${op.tokenHash}` | |
| 317 | const existing = await b.get(key) | |
| 318 | if (existing) break | |
| 319 | await b.put(key, { | |
| 320 | tokenHash: op.tokenHash, | |
| 321 | userId: op.userId, | |
| 322 | expiresAt: op.expiresAt, | |
| 323 | createdAt: op.createdAt || Date.now(), | |
| 324 | consumed: false | |
| 325 | }) | |
| 326 | break | |
| 327 | } | |
| 328 | ||
| 329 | case OpTypes.CONSUME_LINK_TOKEN: { | |
| 330 | const tokenKey = `${prefix}link-token:${op.tokenHash}` | |
| 331 | const tokenEntry = await b.get(tokenKey) | |
| 332 | if (!tokenEntry) break // unknown token | |
| 333 | const token = tokenEntry.value | |
| 334 | if (token.consumed) break // double-consume | |
| 335 | if (token.expiresAt && token.expiresAt < Date.now()) break // expired | |
| 336 | ||
| 337 | // 1. Mark token consumed (kept for audit, never deleted) | |
| 338 | await b.put(tokenKey, { | |
| 339 | ...token, | |
| 340 | consumed: true, | |
| 341 | consumedAt: Date.now(), | |
| 342 | consumedBy: op.newIdentityId | |
| 343 | }) | |
| 344 | ||
| 345 | // 2. Attach the new identity to the token's target user | |
| 346 | const targetKey = `${prefix}user:${token.userId}` | |
| 347 | const targetEntry = await b.get(targetKey) | |
| 348 | if (targetEntry) { | |
| 349 | const target = targetEntry.value | |
| 350 | if (!target.identities.includes(op.newIdentityId)) { | |
| 351 | target.identities.push(op.newIdentityId) | |
| 352 | target.updatedAt = Date.now() | |
| 353 | await b.put(targetKey, target) | |
| 354 | } | |
| 355 | // Always rewrite the user-identity index — if the identity | |
| 356 | // was previously linked to a throwaway, this rebinds it. | |
| 357 | await b.put(`${prefix}user-identity:${op.newIdentityId}`, { userId: token.userId }) | |
| 358 | } | |
| 359 | ||
| 360 | // 3. Optionally deactivate the throwaway user the new | |
| 361 | // identity used to belong to. Strip the identity from | |
| 362 | // that user first so it isn't double-counted. | |
| 363 | if (op.deactivateUserId && op.deactivateUserId !== token.userId) { | |
| 364 | const dKey = `${prefix}user:${op.deactivateUserId}` | |
| 365 | const dEntry = await b.get(dKey) | |
| 366 | if (dEntry) { | |
| 367 | const u = dEntry.value | |
| 368 | u.identities = (u.identities || []).filter(id => id !== op.newIdentityId) | |
| 369 | u.deactivatedAt = Date.now() | |
| 370 | u.deactivatedReason = 'merged-via-link-token' | |
| 371 | u.updatedAt = Date.now() | |
| 372 | await b.put(dKey, u) | |
| 373 | } | |
| 374 | } | |
| 375 | break | |
| 376 | } | |
| 377 | ||
| 313 | 378 | case OpTypes.SET_USERNAME: { |
| 314 | 379 | const key = `${prefix}user:${op.userId}` |
| 315 | 380 | const existing = await b.get(key) |
| … | … | @@ -16,7 +16,12 @@ export const OpTypes = { |
| 16 | 16 | RESTORE_IDENTITY: 'restore-identity', |
| 17 | 17 | SET_USERNAME: 'set-username', |
| 18 | 18 | DEACTIVATE_USER: 'deactivate-user', |
| 19 | REACTIVATE_USER: 'reactivate-user' | |
| 19 | REACTIVATE_USER: 'reactivate-user', | |
| 20 | // F.7.2 — link-identity ceremony. CREATE stores a hashed token tied to a | |
| 21 | // user; CONSUME validates expiry + single-use, attaches a new identity | |
| 22 | // to the target user, and optionally deactivates an emptied throwaway. | |
| 23 | CREATE_LINK_TOKEN: 'create-link-token', | |
| 24 | CONSUME_LINK_TOKEN: 'consume-link-token' | |
| 20 | 25 | } |
| 21 | 26 | |
| 22 | 27 | /** |
| … | … | @@ -99,6 +104,17 @@ export function validateOp (op) { |
| 99 | 104 | if (!op.userId) return { valid: false, error: 'Missing userId' } |
| 100 | 105 | return { valid: true } |
| 101 | 106 | |
| 107 | case OpTypes.CREATE_LINK_TOKEN: | |
| 108 | if (!op.tokenHash) return { valid: false, error: 'Missing tokenHash' } | |
| 109 | if (!op.userId) return { valid: false, error: 'Missing userId' } | |
| 110 | if (typeof op.expiresAt !== 'number') return { valid: false, error: 'Missing expiresAt' } | |
| 111 | return { valid: true } | |
| 112 | ||
| 113 | case OpTypes.CONSUME_LINK_TOKEN: | |
| 114 | if (!op.tokenHash) return { valid: false, error: 'Missing tokenHash' } | |
| 115 | if (!op.newIdentityId) return { valid: false, error: 'Missing newIdentityId' } | |
| 116 | return { valid: true } | |
| 117 | ||
| 102 | 118 | default: |
| 103 | 119 | return { valid: false, error: `Unknown op type: ${op.type}` } |
| 104 | 120 | } |
| … | … | @@ -762,6 +762,97 @@ export class IdentityRegistry { |
| 762 | 762 | return identity |
| 763 | 763 | } |
| 764 | 764 | |
| 765 | // ==================== LINK TOKENS (F.7.2) ==================== | |
| 766 | ||
| 767 | /** | |
| 768 | * Generate a short-lived link token bound to a user. The token itself | |
| 769 | * is returned to the caller; only the SHA-256 hash is stored in the | |
| 770 | * Autobase view, so reading the registry can't replay tokens. | |
| 771 | * | |
| 772 | * @param {string} userId - The target user the token will link to | |
| 773 | * @param {object} [opts] | |
| 774 | * @param {number} [opts.ttlMs] - Token lifetime; default 5 min | |
| 775 | * @returns {Promise<{token, tokenHash, expiresAt}>} | |
| 776 | */ | |
| 777 | async createLinkToken (userId, opts = {}) { | |
| 778 | if (!userId) throw new Error('createLinkToken requires { userId }') | |
| 779 | const ttl = typeof opts.ttlMs === 'number' ? opts.ttlMs : 5 * 60 * 1000 | |
| 780 | const token = crypto.randomBytes(16).toString('hex') | |
| 781 | const tokenHash = crypto.hash(b4a.from(token)).toString('hex') | |
| 782 | const expiresAt = Date.now() + ttl | |
| 783 | ||
| 784 | await this._base.append(encodeOp({ | |
| 785 | type: OpTypes.CREATE_LINK_TOKEN, | |
| 786 | tokenHash, | |
| 787 | userId, | |
| 788 | expiresAt, | |
| 789 | createdAt: Date.now() | |
| 790 | })) | |
| 791 | await this._base.update() | |
| 792 | ||
| 793 | return { token, tokenHash, expiresAt } | |
| 794 | } | |
| 795 | ||
| 796 | /** | |
| 797 | * Look up a link token row by its raw token (re-hashes for lookup). | |
| 798 | * Returns the stored row or null. | |
| 799 | */ | |
| 800 | async getLinkToken (token) { | |
| 801 | if (!token) return null | |
| 802 | const tokenHash = crypto.hash(b4a.from(token)).toString('hex') | |
| 803 | const entry = await this._view.get(`${this._prefix}link-token:${tokenHash}`) | |
| 804 | return entry ? entry.value : null | |
| 805 | } | |
| 806 | ||
| 807 | /** | |
| 808 | * Consume a link token: validates expiry + single-use, attaches a new | |
| 809 | * identity to the token's target user, optionally deactivates a | |
| 810 | * throwaway user the new identity used to belong to. The whole sequence | |
| 811 | * runs in a single Autobase op (CONSUME_LINK_TOKEN) so apply-time | |
| 812 | * checks are atomic. | |
| 813 | * | |
| 814 | * @param {string} token - Raw token (re-hashed internally) | |
| 815 | * @param {string} newIdentityId - Identity to attach to target user | |
| 816 | * @param {object} [opts] | |
| 817 | * @param {string} [opts.deactivateUserId] - Throwaway user to deactivate | |
| 818 | * @returns {Promise<{userId, newIdentityId}>} | |
| 819 | */ | |
| 820 | async consumeLinkToken (token, newIdentityId, opts = {}) { | |
| 821 | if (!token) throw new Error('consumeLinkToken requires { token }') | |
| 822 | if (!newIdentityId) throw new Error('consumeLinkToken requires { newIdentityId }') | |
| 823 | ||
| 824 | // Pre-flight: classify the failure reason for the caller. The apply | |
| 825 | // function silently no-ops on the same conditions (defence in depth). | |
| 826 | const stored = await this.getLinkToken(token) | |
| 827 | if (!stored) throw new Error('link token not found') | |
| 828 | if (stored.consumed) throw new Error('link token already consumed') | |
| 829 | if (stored.expiresAt && stored.expiresAt < Date.now()) throw new Error('link token expired') | |
| 830 | ||
| 831 | const tokenHash = crypto.hash(b4a.from(token)).toString('hex') | |
| 832 | ||
| 833 | await this._base.append(encodeOp({ | |
| 834 | type: OpTypes.CONSUME_LINK_TOKEN, | |
| 835 | tokenHash, | |
| 836 | newIdentityId, | |
| 837 | deactivateUserId: opts.deactivateUserId || null | |
| 838 | })) | |
| 839 | await this._base.update() | |
| 840 | ||
| 841 | // Update direct-write identity record so reverse lookups stay coherent | |
| 842 | if (this.db) { | |
| 843 | try { | |
| 844 | const ident = await this.getIdentity(newIdentityId) | |
| 845 | if (ident) { | |
| 846 | ident.userId = stored.userId | |
| 847 | ident.updatedAt = Date.now() | |
| 848 | await this.db.put(`identity:${newIdentityId}`, ident) | |
| 849 | } | |
| 850 | } catch {} | |
| 851 | } | |
| 852 | ||
| 853 | return { userId: stored.userId, newIdentityId } | |
| 854 | } | |
| 855 | ||
| 765 | 856 | // ==================== FEDERATION & LIFECYCLE ==================== |
| 766 | 857 | |
| 767 | 858 | /** |
| … | … | @@ -40,12 +40,16 @@ export class SSHKeyProvider extends BaseProvider { |
| 40 | 40 | let valid = false |
| 41 | 41 | |
| 42 | 42 | if (parsedKey.type === 'ed25519') { |
| 43 | // Use hypercore-crypto for Ed25519 | |
| 44 | valid = ANProto.verify( | |
| 45 | challenge.challenge, | |
| 46 | b4a.toString(parsedSig.signature, 'base64'), | |
| 47 | parsedKey.publicKey | |
| 48 | ) | |
| 43 | // SSH Ed25519: raw detached verify (NOT ANProto wire format — SSH has its own format) | |
| 44 | try { | |
| 45 | const msg = typeof challenge.challenge === 'string' ? b4a.from(challenge.challenge, 'utf8') : challenge.challenge | |
| 46 | const sig = parsedSig.signature | |
| 47 | const pk = typeof parsedKey.publicKey === 'string' ? b4a.from(parsedKey.publicKey, 'base64') : parsedKey.publicKey | |
| 48 | const crypto = await import('hypercore-crypto') | |
| 49 | valid = crypto.default ? crypto.default.verify(msg, sig, pk) : crypto.verify(msg, sig, pk) | |
| 50 | } catch { | |
| 51 | valid = false | |
| 52 | } | |
| 49 | 53 | } else if (parsedKey.type === 'rsa') { |
| 50 | 54 | // RSA requires node:crypto |
| 51 | 55 | try { |
| … | … | @@ -40,8 +40,10 @@ export class WebIdentityProvider extends BaseProvider { |
| 40 | 40 | const challenge = await this.challengeManager.get(challengeId) |
| 41 | 41 | if (!challenge) throw new Error('Invalid or expired challenge') |
| 42 | 42 | |
| 43 | const valid = ANProto.verify(challenge.challenge, signature, publicKey) | |
| 44 | if (!valid) throw new Error('Invalid signature') | |
| 43 | const result = ANProto.verify(challenge.challenge, signature) | |
| 44 | if (!result.valid) throw new Error('Invalid signature — ' + (result.error || 'verification failed')) | |
| 45 | // Canonical format embeds the public key — verify it matches the claimed key | |
| 46 | if (result.publicKey !== publicKey) throw new Error('Signature public key does not match claimed key') | |
| 45 | 47 | |
| 46 | 48 | await this.challengeManager.consume(challengeId) |
| 47 | 49 | signatureVerified = true |
| … | … | @@ -0,0 +1,157 @@ |
| 1 | /** | |
| 2 | * Minimal ssh-agent client (SSH_AGENT_PROTOCOL_V2, RFC draft-miller-ssh-agent). | |
| 3 | * | |
| 4 | * Used by CLI clients (e.g. pgh) to sign server challenges with the | |
| 5 | * user's SSH key without prompting for a passphrase. The private key | |
| 6 | * stays in the agent; we only ever receive signatures back. | |
| 7 | * | |
| 8 | * Protocol: | |
| 9 | * Frame = <4-byte BE length> <1-byte message type> <payload> | |
| 10 | * | |
| 11 | * SSH_AGENTC_REQUEST_IDENTITIES = 11 → SSH_AGENT_IDENTITIES_ANSWER = 12 | |
| 12 | * SSH_AGENTC_SIGN_REQUEST = 13 → SSH_AGENT_SIGN_RESPONSE = 14 | |
| 13 | * SSH_AGENT_FAILURE = 5 | |
| 14 | * | |
| 15 | * Identities answer body: <uint32 count> [<string pubkey blob> <string comment>]* | |
| 16 | * Sign request body: <string pubkey blob> <string data> <uint32 flags> | |
| 17 | * Sign response body: <string signature blob> (blob is itself SSH-wire-format: | |
| 18 | * <string sig-type> <string raw-sig>) | |
| 19 | */ | |
| 20 | ||
| 21 | import net from 'net' | |
| 22 | import b4a from 'b4a' | |
| 23 | ||
| 24 | const SSH_AGENTC_REQUEST_IDENTITIES = 11 | |
| 25 | const SSH_AGENT_IDENTITIES_ANSWER = 12 | |
| 26 | const SSH_AGENTC_SIGN_REQUEST = 13 | |
| 27 | const SSH_AGENT_SIGN_RESPONSE = 14 | |
| 28 | const SSH_AGENT_FAILURE = 5 | |
| 29 | ||
| 30 | /** | |
| 31 | * Check whether an ssh-agent is reachable on $SSH_AUTH_SOCK. | |
| 32 | */ | |
| 33 | export function isAgentAvailable () { | |
| 34 | return !!process.env.SSH_AUTH_SOCK | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * List identities loaded into the agent. | |
| 39 | * | |
| 40 | * @param {string} [socketPath] - override $SSH_AUTH_SOCK | |
| 41 | * @returns {Promise<Array<{ publicKey: Buffer, comment: string }>>} | |
| 42 | */ | |
| 43 | export async function listIdentities (socketPath) { | |
| 44 | const path = socketPath || process.env.SSH_AUTH_SOCK | |
| 45 | if (!path) throw new Error('SSH_AUTH_SOCK is not set — no running ssh-agent') | |
| 46 | ||
| 47 | const req = b4a.from([SSH_AGENTC_REQUEST_IDENTITIES]) | |
| 48 | const reply = await sendFrame(path, req) | |
| 49 | ||
| 50 | if (reply[0] === SSH_AGENT_FAILURE) throw new Error('ssh-agent returned FAILURE') | |
| 51 | if (reply[0] !== SSH_AGENT_IDENTITIES_ANSWER) throw new Error('ssh-agent: unexpected reply ' + reply[0]) | |
| 52 | ||
| 53 | let off = 1 | |
| 54 | const count = reply.readUInt32BE(off); off += 4 | |
| 55 | const out = [] | |
| 56 | for (let i = 0; i < count; i++) { | |
| 57 | const keyLen = reply.readUInt32BE(off); off += 4 | |
| 58 | const publicKey = reply.subarray(off, off + keyLen); off += keyLen | |
| 59 | const commentLen = reply.readUInt32BE(off); off += 4 | |
| 60 | const comment = reply.subarray(off, off + commentLen).toString('utf8'); off += commentLen | |
| 61 | out.push({ publicKey: b4a.from(publicKey), comment }) | |
| 62 | } | |
| 63 | return out | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Sign `data` using a loaded key identified by its SSH-wire-format | |
| 68 | * public key blob. | |
| 69 | * | |
| 70 | * The returned signature is the agent's raw signature blob | |
| 71 | * (`<string sig-type> <string raw-sig>`) as delivered by the agent — | |
| 72 | * not parsed. Pass the full blob through to `SSHKeyParser.parseSignature()` | |
| 73 | * on the verify side. | |
| 74 | * | |
| 75 | * @param {Buffer} publicKey - SSH-wire-format key blob (NOT the base64 string) | |
| 76 | * @param {Buffer|string} data - message to sign | |
| 77 | * @param {Object} [opts] | |
| 78 | * @param {string} [opts.socketPath] | |
| 79 | * @param {number} [opts.flags=0] - 0=default, 2=SHA256, 4=SHA512 (RSA only) | |
| 80 | * @returns {Promise<Buffer>} signature blob | |
| 81 | */ | |
| 82 | export async function sign (publicKey, data, opts = {}) { | |
| 83 | const path = opts.socketPath || process.env.SSH_AUTH_SOCK | |
| 84 | if (!path) throw new Error('SSH_AUTH_SOCK is not set — no running ssh-agent') | |
| 85 | ||
| 86 | const msg = typeof data === 'string' ? b4a.from(data, 'utf8') : data | |
| 87 | const flags = opts.flags || 0 | |
| 88 | ||
| 89 | // body = string(pubkey) + string(data) + uint32(flags) | |
| 90 | const body = b4a.concat([ | |
| 91 | writeString(publicKey), | |
| 92 | writeString(msg), | |
| 93 | writeUint32(flags) | |
| 94 | ]) | |
| 95 | const req = b4a.concat([b4a.from([SSH_AGENTC_SIGN_REQUEST]), body]) | |
| 96 | ||
| 97 | const reply = await sendFrame(path, req) | |
| 98 | if (reply[0] === SSH_AGENT_FAILURE) throw new Error('ssh-agent: sign failed (key not in agent or refused)') | |
| 99 | if (reply[0] !== SSH_AGENT_SIGN_RESPONSE) throw new Error('ssh-agent: unexpected reply ' + reply[0]) | |
| 100 | ||
| 101 | let off = 1 | |
| 102 | const sigLen = reply.readUInt32BE(off); off += 4 | |
| 103 | const sig = reply.subarray(off, off + sigLen) | |
| 104 | return b4a.from(sig) | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * Send a single framed request to the agent and return the response | |
| 109 | * body (without the length prefix). | |
| 110 | * | |
| 111 | * ssh-agent keeps the Unix socket open after replying (expecting more | |
| 112 | * requests), so we can't wait for an 'end' event — we parse the length | |
| 113 | * prefix as soon as it arrives, read exactly 4+len bytes, then close | |
| 114 | * the socket ourselves. | |
| 115 | */ | |
| 116 | function sendFrame (socketPath, payload) { | |
| 117 | return new Promise((resolve, reject) => { | |
| 118 | const sock = net.createConnection(socketPath) | |
| 119 | let buf = b4a.alloc(0) | |
| 120 | let finished = false | |
| 121 | ||
| 122 | const done = (err, val) => { | |
| 123 | if (finished) return | |
| 124 | finished = true | |
| 125 | try { sock.destroy() } catch {} | |
| 126 | if (err) reject(err) | |
| 127 | else resolve(val) | |
| 128 | } | |
| 129 | ||
| 130 | sock.on('error', (err) => done(err)) | |
| 131 | sock.on('connect', () => { | |
| 132 | const frame = b4a.concat([writeUint32(payload.length), payload]) | |
| 133 | sock.write(frame) | |
| 134 | }) | |
| 135 | sock.on('data', (chunk) => { | |
| 136 | buf = b4a.concat([buf, chunk]) | |
| 137 | if (buf.length < 4) return | |
| 138 | const len = buf.readUInt32BE(0) | |
| 139 | if (buf.length >= 4 + len) { | |
| 140 | done(null, b4a.from(buf.subarray(4, 4 + len))) | |
| 141 | } | |
| 142 | }) | |
| 143 | sock.on('close', () => { | |
| 144 | if (!finished) done(new Error('ssh-agent: socket closed before reply')) | |
| 145 | }) | |
| 146 | }) | |
| 147 | } | |
| 148 | ||
| 149 | function writeUint32 (n) { | |
| 150 | const b = b4a.alloc(4) | |
| 151 | b.writeUInt32BE(n, 0) | |
| 152 | return b | |
| 153 | } | |
| 154 | ||
| 155 | function writeString (buf) { | |
| 156 | return b4a.concat([writeUint32(buf.length), buf]) | |
| 157 | } |
| … | … | @@ -0,0 +1,113 @@ |
| 1 | /** | |
| 2 | * Auto-import local SSH public keys into a hyper-identity registry, | |
| 3 | * linked to the daemon owner's user account. | |
| 4 | * | |
| 5 | * Rationale: when a user installs pear-git (or any hyper-identity | |
| 6 | * consumer) on their own Linux box, they typically already have | |
| 7 | * `~/.ssh/id_*.pub` files. Making them manually re-register those keys | |
| 8 | * is friction. This helper scans the running user's `~/.ssh/`, parses | |
| 9 | * candidate pubkeys, and registers each as a `method: 'ssh-key'` | |
| 10 | * identity linked to the owner's userId. | |
| 11 | * | |
| 12 | * Safety rules (all must hold before anything gets registered): | |
| 13 | * 1. Process not running as root (unless opts.allowRoot). | |
| 14 | * 2. `$HOME` is set and `.ssh/` is readable. | |
| 15 | * 3. Registry has an explicit ownerUserId (caller resolves "who is | |
| 16 | * the device owner"). We do not infer owner from the registry. | |
| 17 | * 4. Owner must have at least one non-ssh-key identity already (proves | |
| 18 | * the account exists and the caller knows what they're doing). | |
| 19 | * | |
| 20 | * The operation is idempotent: keys already registered by fingerprint | |
| 21 | * are skipped, so re-running on every daemon boot is safe. | |
| 22 | * | |
| 23 | * Returns a summary { scanned, imported, skipped } for logging. | |
| 24 | */ | |
| 25 | ||
| 26 | import { scanHomeSSHKeys } from './ssh-scan.js' | |
| 27 | ||
| 28 | /** | |
| 29 | * Auto-import local SSH pubkeys to a registry as ssh-key identities | |
| 30 | * linked to the given owner. | |
| 31 | * | |
| 32 | * @param {Object} registry - hyper-identity IdentityRegistry | |
| 33 | * @param {string} ownerUserId - userId to link imported identities to | |
| 34 | * @param {Object} [opts] | |
| 35 | * @param {boolean} [opts.allowRoot=false] - allow running as uid 0 | |
| 36 | * @param {boolean} [opts.requireExistingIdentity=true] - owner must | |
| 37 | * already have at least one non-ssh-key identity (safety check) | |
| 38 | * @returns {Promise<{scanned, imported, skipped, skippedReasons}>} | |
| 39 | */ | |
| 40 | export async function autoImportLocalSSHKeys (registry, ownerUserId, opts = {}) { | |
| 41 | const result = { scanned: 0, imported: 0, skipped: 0, skippedReasons: {} } | |
| 42 | const requireExistingIdentity = opts.requireExistingIdentity !== false | |
| 43 | ||
| 44 | if (!registry) throw new Error('autoImportLocalSSHKeys requires a registry') | |
| 45 | if (!ownerUserId) throw new Error('autoImportLocalSSHKeys requires ownerUserId') | |
| 46 | ||
| 47 | // Verify owner actually exists AND has at least one non-ssh identity | |
| 48 | // (so we don't auto-create perms for a userId that never consented). | |
| 49 | const user = await registry.getUser(ownerUserId) | |
| 50 | if (!user) { | |
| 51 | result.skippedReasons.noSuchUser = true | |
| 52 | return result | |
| 53 | } | |
| 54 | if (requireExistingIdentity) { | |
| 55 | const identities = user.identities || [] | |
| 56 | let hasNonSshIdentity = false | |
| 57 | for (const idRef of identities) { | |
| 58 | // identities[] may be either identityId strings or full records | |
| 59 | const idStr = typeof idRef === 'string' ? idRef : (idRef && idRef.identityId) | |
| 60 | if (!idStr) continue | |
| 61 | const rec = await registry.getIdentity(idStr).catch(() => null) | |
| 62 | if (rec && rec.method && rec.method !== 'ssh-key') { | |
| 63 | hasNonSshIdentity = true | |
| 64 | break | |
| 65 | } | |
| 66 | } | |
| 67 | if (!hasNonSshIdentity) { | |
| 68 | result.skippedReasons.noExistingIdentity = true | |
| 69 | return result | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | const keys = await scanHomeSSHKeys({ allowRoot: opts.allowRoot }) | |
| 74 | result.scanned = keys.length | |
| 75 | ||
| 76 | for (const k of keys) { | |
| 77 | const existing = await registry.getIdentityBySSHKey(k.fingerprint).catch(() => null) | |
| 78 | if (existing) { | |
| 79 | result.skipped++ | |
| 80 | continue | |
| 81 | } | |
| 82 | ||
| 83 | const identityId = `ssh:${k.fingerprint}` | |
| 84 | try { | |
| 85 | await registry.registerIdentity({ | |
| 86 | identityId, | |
| 87 | method: 'ssh-key', | |
| 88 | publicKey: k.publicKey, | |
| 89 | credentials: { fingerprint: k.fingerprint, type: k.type, comment: k.comment }, | |
| 90 | keyType: k.type, | |
| 91 | source: 'auto-import', | |
| 92 | autoImportedFrom: k.path, | |
| 93 | createdAt: Date.now() | |
| 94 | }) | |
| 95 | ||
| 96 | // Link to owner — `verified: true` is legitimate here because | |
| 97 | // the caller of auto-import has physical-filesystem access to | |
| 98 | // the user's $HOME, which is the strongest possible assertion | |
| 99 | // of ownership for a locally-present key. | |
| 100 | await registry.linkIdentityToUser(identityId, ownerUserId, { verified: true }) | |
| 101 | if (typeof registry.addIdentityToUser === 'function') { | |
| 102 | await registry.addIdentityToUser(ownerUserId, identityId) | |
| 103 | } | |
| 104 | ||
| 105 | result.imported++ | |
| 106 | } catch (err) { | |
| 107 | result.skipped++ | |
| 108 | result.skippedReasons[k.path] = err.message || String(err) | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | return result | |
| 113 | } |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import b4a from 'b4a' |
| 2 | import crypto from 'hypercore-crypto' | |
| 2 | 3 | |
| 3 | 4 | /** |
| 4 | 5 | * SSH Key Parser |
| … | … | @@ -7,6 +8,24 @@ import b4a from 'b4a' |
| 7 | 8 | * Supports: ssh-rsa, ssh-ed25519 |
| 8 | 9 | */ |
| 9 | 10 | export class SSHKeyParser { |
| 11 | /** | |
| 12 | * OpenSSH-style SHA256 fingerprint of an SSH public key. | |
| 13 | * Input is the `ssh-rsa AAAA... comment` format; we hash the decoded | |
| 14 | * key blob (the base64 middle part), base64 the digest, and prefix | |
| 15 | * with "SHA256:". Matches `ssh-keygen -lf key.pub`. | |
| 16 | * | |
| 17 | * @param {string} sshPublicKey | |
| 18 | * @returns {string} "SHA256:abc123..." | |
| 19 | */ | |
| 20 | static fingerprint (sshPublicKey) { | |
| 21 | const parts = sshPublicKey.trim().split(/\s+/) | |
| 22 | if (parts.length < 2) throw new Error('Invalid SSH key format') | |
| 23 | const keyBlob = b4a.from(parts[1], 'base64') | |
| 24 | const digest = crypto.hash(keyBlob) // SHA-256 | |
| 25 | // OpenSSH strips '=' padding from base64 | |
| 26 | return 'SHA256:' + b4a.toString(digest, 'base64').replace(/=+$/, '') | |
| 27 | } | |
| 28 | ||
| 10 | 29 | static parse (sshPublicKey) { |
| 11 | 30 | const parts = sshPublicKey.trim().split(/\s+/) |
| 12 | 31 | if (parts.length < 2) throw new Error('Invalid SSH key format') |
| … | … | @@ -0,0 +1,108 @@ |
| 1 | /** | |
| 2 | * Scan a local `~/.ssh/` directory for candidate SSH public keys. | |
| 3 | * | |
| 4 | * Returns an array of { publicKey, fingerprint, type, path } records, | |
| 5 | * one per .pub file we can read and parse. Silently skips files that | |
| 6 | * don't parse (unknown key type, bad format) — those aren't errors, | |
| 7 | * just non-candidates. | |
| 8 | * | |
| 9 | * This module never reads secret key files. It only looks at *.pub. | |
| 10 | * | |
| 11 | * Used by ssh-auto-import.js to seed a hyper-identity registry with | |
| 12 | * local SSH keys on the daemon's first boot. | |
| 13 | */ | |
| 14 | ||
| 15 | import { readdir, readFile, stat } from 'fs/promises' | |
| 16 | import { join } from 'path' | |
| 17 | ||
| 18 | import { SSHKeyParser } from './ssh-parser.js' | |
| 19 | ||
| 20 | /** | |
| 21 | * Scan a directory for readable `*.pub` SSH public keys. | |
| 22 | * | |
| 23 | * @param {string} dir - directory to scan (typically `$HOME/.ssh`) | |
| 24 | * @returns {Promise<Array<{ publicKey, fingerprint, type, path, comment }>>} | |
| 25 | */ | |
| 26 | export async function scanSSHKeys (dir) { | |
| 27 | let entries | |
| 28 | try { | |
| 29 | entries = await readdir(dir) | |
| 30 | } catch { | |
| 31 | // $HOME/.ssh doesn't exist, or we can't read it — return empty (not an error) | |
| 32 | return [] | |
| 33 | } | |
| 34 | ||
| 35 | const out = [] | |
| 36 | ||
| 37 | for (const name of entries) { | |
| 38 | if (!name.endsWith('.pub')) continue | |
| 39 | ||
| 40 | const path = join(dir, name) | |
| 41 | ||
| 42 | // Skip anything that isn't a regular file (symlink to revoked? | |
| 43 | // directory with .pub suffix?) | |
| 44 | let st | |
| 45 | try { | |
| 46 | st = await stat(path) | |
| 47 | } catch { | |
| 48 | continue | |
| 49 | } | |
| 50 | if (!st.isFile()) continue | |
| 51 | ||
| 52 | let text | |
| 53 | try { | |
| 54 | text = await readFile(path, 'utf8') | |
| 55 | } catch { | |
| 56 | continue // unreadable — likely permissions issue, skip | |
| 57 | } | |
| 58 | ||
| 59 | const first = text.split('\n').find(line => line.trim().length > 0) | |
| 60 | if (!first) continue | |
| 61 | ||
| 62 | let parsed | |
| 63 | try { | |
| 64 | parsed = SSHKeyParser.parse(first) | |
| 65 | } catch { | |
| 66 | continue // unsupported key type (dsa, ecdsa, sk-*) — not fatal | |
| 67 | } | |
| 68 | ||
| 69 | let fingerprint | |
| 70 | try { | |
| 71 | fingerprint = SSHKeyParser.fingerprint(first) | |
| 72 | } catch { | |
| 73 | continue | |
| 74 | } | |
| 75 | ||
| 76 | // Extract the trailing comment (OpenSSH convention: "ssh-ed25519 AAA... user@host") | |
| 77 | const parts = first.trim().split(/\s+/) | |
| 78 | const comment = parts.slice(2).join(' ') || null | |
| 79 | ||
| 80 | out.push({ | |
| 81 | publicKey: first.trim(), | |
| 82 | fingerprint, | |
| 83 | type: parsed.type, | |
| 84 | path, | |
| 85 | comment | |
| 86 | }) | |
| 87 | } | |
| 88 | ||
| 89 | return out | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Scan the current process owner's SSH directory. Skips if the process | |
| 94 | * has no $HOME (container without a passwd entry), or if we're running | |
| 95 | * as root (safety — don't auto-import root's keys, that's usually a | |
| 96 | * misconfig and the blast radius is large). | |
| 97 | * | |
| 98 | * @param {Object} [opts] | |
| 99 | * @param {boolean} [opts.allowRoot=false] - override the root safety check | |
| 100 | * @returns {Promise<Array<{ publicKey, fingerprint, type, path, comment }>>} | |
| 101 | */ | |
| 102 | export async function scanHomeSSHKeys (opts = {}) { | |
| 103 | if (!process.env.HOME) return [] | |
| 104 | if (!opts.allowRoot && typeof process.getuid === 'function' && process.getuid() === 0) { | |
| 105 | return [] | |
| 106 | } | |
| 107 | return scanSSHKeys(join(process.env.HOME, '.ssh')) | |
| 108 | } |
| … | … | @@ -18,6 +18,9 @@ |
| 18 | 18 | "./auth/rate-limiter": "./lib/auth/rate-limiter.js", |
| 19 | 19 | "./auth/base-provider": "./lib/auth/base-provider.js", |
| 20 | 20 | "./auth/ssh-parser": "./lib/auth/ssh-parser.js", |
| 21 | "./auth/ssh-scan": "./lib/auth/ssh-scan.js", | |
| 22 | "./auth/ssh-agent": "./lib/auth/ssh-agent.js", | |
| 23 | "./auth/ssh-auto-import": "./lib/auth/ssh-auto-import.js", | |
| 21 | 24 | "./auth/providers/web-identity": "./lib/auth/providers/web-identity.js", |
| 22 | 25 | "./auth/providers/keet-identity": "./lib/auth/providers/keet-identity.js", |
| 23 | 26 | "./auth/providers/bearer-token": "./lib/auth/providers/bearer-token.js", |
| … | … | @@ -31,6 +34,7 @@ |
| 31 | 34 | "blind-pairing": "^2.3.1", |
| 32 | 35 | "hypercore": "^11.24.0", |
| 33 | 36 | "hypercore-crypto": "^3.0.0", |
| 37 | "hyperbee": "^2.27.3", | |
| 34 | 38 | "hyperswarm": "^4.16.0", |
| 35 | 39 | "keet-identity-key": "github:holepunchto/keet-identity-key", |
| 36 | 40 | "sodium-native": "^4.3.1" |
| … | … | @@ -42,7 +46,6 @@ |
| 42 | 46 | "devDependencies": { |
| 43 | 47 | "brittle": "^3.19.1", |
| 44 | 48 | "corestore": "^7.9.2", |
| 45 | "hyperbee": "^2.27.3", | |
| 46 | 49 | "random-access-memory": "^6.2.1" |
| 47 | 50 | } |
| 48 | 51 | } |
| … | … | @@ -32,39 +32,47 @@ test('getPublicKey() extracts base64 pubkey', (t) => { |
| 32 | 32 | |
| 33 | 33 | // --- Challenge-Response Mode --- |
| 34 | 34 | |
| 35 | test('sign/verify challenge-response round-trip', (t) => { | |
| 35 | test('sign/verify challenge-response round-trip (canonical format)', (t) => { | |
| 36 | 36 | const kp = ANProto.gen() |
| 37 | 37 | const challenge = 'test-challenge-12345' |
| 38 | 38 | |
| 39 | 39 | const sig = ANProto.sign(challenge, kp) |
| 40 | 40 | t.is(typeof sig, 'string', 'signature is string') |
| 41 | t.is(b4a.from(sig, 'base64').length, 64, 'signature is 64 bytes (88 chars base64)') | |
| 41 | // Canonical format: 44-char pubkey + base64(combined envelope) | |
| 42 | t.ok(sig.length > 44, 'signature is longer than just pubkey') | |
| 43 | t.is(sig.substring(0, 44), b4a.toString(kp.publicKey, 'base64'), 'signature starts with pubkey') | |
| 42 | 44 | |
| 43 | const pubkeyB64 = b4a.toString(kp.publicKey, 'base64') | |
| 44 | t.ok(ANProto.verify(challenge, sig, pubkeyB64), 'valid signature verifies') | |
| 45 | const result = ANProto.verify(challenge, sig) | |
| 46 | t.ok(result.valid, 'valid signature verifies') | |
| 47 | t.is(result.publicKey, b4a.toString(kp.publicKey, 'base64'), 'extracted pubkey matches') | |
| 48 | t.ok(result.timestamp > 0, 'has timestamp') | |
| 45 | 49 | }) |
| 46 | 50 | |
| 47 | 51 | test('verify rejects wrong challenge', (t) => { |
| 48 | 52 | const kp = ANProto.gen() |
| 49 | 53 | const sig = ANProto.sign('correct-challenge', kp) |
| 50 | const pubkeyB64 = b4a.toString(kp.publicKey, 'base64') | |
| 51 | t.absent(ANProto.verify('wrong-challenge', sig, pubkeyB64), 'wrong challenge fails') | |
| 54 | const result = ANProto.verify('wrong-challenge', sig) | |
| 55 | t.absent(result.valid, 'wrong challenge fails') | |
| 56 | t.is(result.error, 'hash mismatch', 'error is hash mismatch') | |
| 52 | 57 | }) |
| 53 | 58 | |
| 54 | test('verify rejects wrong key', (t) => { | |
| 59 | test('verify rejects wrong key (tampered signature)', (t) => { | |
| 55 | 60 | const kp1 = ANProto.gen() |
| 56 | 61 | const kp2 = ANProto.gen() |
| 57 | 62 | const challenge = 'test' |
| 58 | 63 | const sig = ANProto.sign(challenge, kp1) |
| 59 | const wrongKey = b4a.toString(kp2.publicKey, 'base64') | |
| 60 | t.absent(ANProto.verify(challenge, sig, wrongKey), 'wrong key fails') | |
| 64 | // Replace pubkey prefix with kp2's key — signature won't match | |
| 65 | const tampered = b4a.toString(kp2.publicKey, 'base64') + sig.substring(44) | |
| 66 | const result = ANProto.verify(challenge, tampered) | |
| 67 | t.absent(result.valid, 'tampered signature fails') | |
| 61 | 68 | }) |
| 62 | 69 | |
| 63 | 70 | test('verify handles Buffer inputs', (t) => { |
| 64 | 71 | const kp = ANProto.gen() |
| 65 | 72 | const challenge = b4a.from('buffer-challenge') |
| 66 | 73 | const sig = ANProto.sign(challenge, kp) |
| 67 | t.ok(ANProto.verify(challenge, sig, kp.publicKey), 'Buffer inputs work') | |
| 74 | const result = ANProto.verify(challenge, sig) | |
| 75 | t.ok(result.valid, 'Buffer inputs work') | |
| 68 | 76 | }) |
| 69 | 77 | |
| 70 | 78 | // --- Content Signing Mode --- |
| … | … | @@ -0,0 +1,155 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { mkdtempSync, mkdirSync, writeFileSync } from 'fs' | |
| 3 | import { tmpdir } from 'os' | |
| 4 | import { join } from 'path' | |
| 5 | ||
| 6 | import { autoImportLocalSSHKeys } from '../lib/auth/ssh-auto-import.js' | |
| 7 | ||
| 8 | // Real Ed25519 pubkey (no secret exposed) | |
| 9 | const ED25519_A = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMbEhcffafHpuMVWZ0Q0iqQKg5vS7ALfRG0/XBT8fmP alice@host1' | |
| 10 | const ED25519_B = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINOyGaIPDcA7M6cFuLr/f4Wg4r7nnPwxbw2QNCvSqkJw alice@host2' | |
| 11 | ||
| 12 | /** | |
| 13 | * Mock IdentityRegistry with just the methods autoImportLocalSSHKeys | |
| 14 | * needs. We avoid booting Corestore/Autobase/Hyperbee — the pure logic | |
| 15 | * (safety check, idempotency, fingerprint-based skip) is fully covered | |
| 16 | * with this shim. | |
| 17 | */ | |
| 18 | function makeMockRegistry (users = {}, identities = {}) { | |
| 19 | const sshIndex = {} // fingerprint → identityId | |
| 20 | const ops = [] // audit log for tests | |
| 21 | ||
| 22 | return { | |
| 23 | _state: { users, identities, sshIndex, ops }, | |
| 24 | ||
| 25 | async getUser (userId) { | |
| 26 | return users[userId] || null | |
| 27 | }, | |
| 28 | async getIdentity (identityId) { | |
| 29 | return identities[identityId] || null | |
| 30 | }, | |
| 31 | async getIdentityBySSHKey (fingerprint) { | |
| 32 | const id = sshIndex[fingerprint] | |
| 33 | return id ? identities[id] : null | |
| 34 | }, | |
| 35 | async registerIdentity (identity) { | |
| 36 | identities[identity.identityId] = identity | |
| 37 | if (identity.credentials && identity.credentials.fingerprint) { | |
| 38 | sshIndex[identity.credentials.fingerprint] = identity.identityId | |
| 39 | } | |
| 40 | ops.push({ op: 'register', identityId: identity.identityId }) | |
| 41 | }, | |
| 42 | async linkIdentityToUser (identityId, userId, opts) { | |
| 43 | if (!opts || !opts.verified) throw new Error('verified required') | |
| 44 | if (identities[identityId]) identities[identityId].userId = userId | |
| 45 | ops.push({ op: 'link', identityId, userId }) | |
| 46 | }, | |
| 47 | async addIdentityToUser (userId, identityId) { | |
| 48 | const u = users[userId] | |
| 49 | if (!u) return | |
| 50 | u.identities = u.identities || [] | |
| 51 | if (!u.identities.includes(identityId)) u.identities.push(identityId) | |
| 52 | ops.push({ op: 'addToUser', userId, identityId }) | |
| 53 | } | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | function mkTmpHomeWithKeys (keys) { | |
| 58 | const dir = mkdtempSync(join(tmpdir(), 'hi-auto-import-')) | |
| 59 | mkdirSync(join(dir, '.ssh')) | |
| 60 | keys.forEach((k, i) => { | |
| 61 | writeFileSync(join(dir, '.ssh', `id_${i}.pub`), k + '\n') | |
| 62 | }) | |
| 63 | return dir | |
| 64 | } | |
| 65 | ||
| 66 | function scopedHome (t, home) { | |
| 67 | const orig = process.env.HOME | |
| 68 | process.env.HOME = home | |
| 69 | t.teardown(() => { process.env.HOME = orig }) | |
| 70 | } | |
| 71 | ||
| 72 | test('refuses when ownerUserId is missing', async (t) => { | |
| 73 | const reg = makeMockRegistry() | |
| 74 | await t.exception(() => autoImportLocalSSHKeys(reg, null), /ownerUserId/) | |
| 75 | }) | |
| 76 | ||
| 77 | test('skips when owner does not exist in registry', async (t) => { | |
| 78 | scopedHome(t, mkTmpHomeWithKeys([ED25519_A])) | |
| 79 | const reg = makeMockRegistry() | |
| 80 | const result = await autoImportLocalSSHKeys(reg, 'nobody') | |
| 81 | t.is(result.imported, 0) | |
| 82 | t.ok(result.skippedReasons.noSuchUser, 'flagged') | |
| 83 | t.is(reg._state.ops.length, 0, 'no writes') | |
| 84 | }) | |
| 85 | ||
| 86 | test('skips when owner has no existing non-ssh identity', async (t) => { | |
| 87 | scopedHome(t, mkTmpHomeWithKeys([ED25519_A])) | |
| 88 | // Owner exists but has only ssh-key identities — auto-import is | |
| 89 | // a bootstrap operation that needs to know a real user is there. | |
| 90 | const reg = makeMockRegistry( | |
| 91 | { alice: { userId: 'alice', identities: ['ssh:SHA256:x'] } }, | |
| 92 | { 'ssh:SHA256:x': { identityId: 'ssh:SHA256:x', method: 'ssh-key' } } | |
| 93 | ) | |
| 94 | const result = await autoImportLocalSSHKeys(reg, 'alice') | |
| 95 | t.is(result.imported, 0) | |
| 96 | t.ok(result.skippedReasons.noExistingIdentity) | |
| 97 | }) | |
| 98 | ||
| 99 | test('imports new keys for an owner with an existing web-identity', async (t) => { | |
| 100 | scopedHome(t, mkTmpHomeWithKeys([ED25519_A, ED25519_B])) | |
| 101 | const reg = makeMockRegistry( | |
| 102 | { alice: { userId: 'alice', identities: ['web:abc'] } }, | |
| 103 | { 'web:abc': { identityId: 'web:abc', method: 'web-identity' } } | |
| 104 | ) | |
| 105 | ||
| 106 | const result = await autoImportLocalSSHKeys(reg, 'alice') | |
| 107 | t.is(result.scanned, 2) | |
| 108 | t.is(result.imported, 2) | |
| 109 | t.is(result.skipped, 0) | |
| 110 | ||
| 111 | // Both new identities are linked to alice | |
| 112 | const linked = reg._state.ops.filter(o => o.op === 'link' && o.userId === 'alice') | |
| 113 | t.is(linked.length, 2) | |
| 114 | const added = reg._state.ops.filter(o => o.op === 'addToUser' && o.userId === 'alice') | |
| 115 | t.is(added.length, 2) | |
| 116 | }) | |
| 117 | ||
| 118 | test('idempotent: re-running skips existing fingerprints', async (t) => { | |
| 119 | scopedHome(t, mkTmpHomeWithKeys([ED25519_A])) | |
| 120 | const reg = makeMockRegistry( | |
| 121 | { alice: { userId: 'alice', identities: ['web:abc'] } }, | |
| 122 | { 'web:abc': { identityId: 'web:abc', method: 'web-identity' } } | |
| 123 | ) | |
| 124 | ||
| 125 | const a = await autoImportLocalSSHKeys(reg, 'alice') | |
| 126 | t.is(a.imported, 1, 'first run imports') | |
| 127 | ||
| 128 | const b = await autoImportLocalSSHKeys(reg, 'alice') | |
| 129 | t.is(b.imported, 0, 'second run imports nothing') | |
| 130 | t.is(b.skipped, 1, 'and skips the one existing key') | |
| 131 | }) | |
| 132 | ||
| 133 | test('no-op when no keys on disk', async (t) => { | |
| 134 | const dir = mkdtempSync(join(tmpdir(), 'hi-empty-')) | |
| 135 | mkdirSync(join(dir, '.ssh')) | |
| 136 | scopedHome(t, dir) | |
| 137 | ||
| 138 | const reg = makeMockRegistry( | |
| 139 | { alice: { userId: 'alice', identities: ['web:abc'] } }, | |
| 140 | { 'web:abc': { identityId: 'web:abc', method: 'web-identity' } } | |
| 141 | ) | |
| 142 | const result = await autoImportLocalSSHKeys(reg, 'alice') | |
| 143 | t.is(result.scanned, 0) | |
| 144 | t.is(result.imported, 0) | |
| 145 | }) | |
| 146 | ||
| 147 | test('requireExistingIdentity: false skips the safety check', async (t) => { | |
| 148 | scopedHome(t, mkTmpHomeWithKeys([ED25519_A])) | |
| 149 | const reg = makeMockRegistry( | |
| 150 | { alice: { userId: 'alice', identities: [] } }, // no identities | |
| 151 | {} | |
| 152 | ) | |
| 153 | const result = await autoImportLocalSSHKeys(reg, 'alice', { requireExistingIdentity: false }) | |
| 154 | t.is(result.imported, 1) | |
| 155 | }) |
| … | … | @@ -0,0 +1,103 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync, chmodSync } from 'fs' | |
| 3 | import { tmpdir } from 'os' | |
| 4 | import { join } from 'path' | |
| 5 | ||
| 6 | import { scanSSHKeys, scanHomeSSHKeys } from '../lib/auth/ssh-scan.js' | |
| 7 | import { SSHKeyParser } from '../lib/auth/ssh-parser.js' | |
| 8 | ||
| 9 | // Known good SSH public keys for fixture testing. These are real Ed25519 | |
| 10 | // pubkeys generated for the test suite only; no secret-key material is | |
| 11 | // checked in. Pubkeys are public, so this is safe. | |
| 12 | const ED25519_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMbEhcffafHpuMVWZ0Q0iqQKg5vS7ALfRG0/XBT8fmP test@example' | |
| 13 | const RSA_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNAYMZkFcAWkfWO7uKCg5V1hf9yV8cIIQ0MBPeRELvUxD9tyfxBDQhLNDlcmVGdO2ar8e2BsTBMQ7Smvp4ekXQj9Z+35DkQyAUlLYpGjJFYcB7cEiM7IwjGt9z7mGZkS0r1/UNu2MSSawYFHQ0uLyHYmcl0FlBYq8XAxA6mnPrZpcrPzQmGL8w2kFnL9Z1XclRBG7QMCw8ZCLr6a6yxa6drNbEFXmhZuXHH0RlQSoqbLs0Jc6Lk9ahQwCkYmNH3Cz2hWGLpGU2vvc8fF7bgWe+T4ZHG2J2CSSoO1fABQUM6qdh4jWi3j7XTvqEK1+QRY2fXq+NxCdoW2qiayw6ARXT test-rsa@example' | |
| 14 | ||
| 15 | test('scanSSHKeys on empty/missing dir returns []', async (t) => { | |
| 16 | const missing = join(tmpdir(), 'nonexistent-' + Date.now()) | |
| 17 | const result = await scanSSHKeys(missing) | |
| 18 | t.alike(result, []) | |
| 19 | }) | |
| 20 | ||
| 21 | test('scanSSHKeys picks up *.pub files and parses them', async (t) => { | |
| 22 | const dir = mkdtempSync(join(tmpdir(), 'ssh-scan-')) | |
| 23 | writeFileSync(join(dir, 'id_ed25519.pub'), ED25519_KEY + '\n') | |
| 24 | writeFileSync(join(dir, 'id_rsa.pub'), RSA_KEY + '\n') | |
| 25 | ||
| 26 | // Distractor files that must be ignored | |
| 27 | writeFileSync(join(dir, 'id_ed25519'), 'SECRET — must NOT be read') | |
| 28 | writeFileSync(join(dir, 'config'), 'Host foo\n') | |
| 29 | writeFileSync(join(dir, 'known_hosts'), 'github.com ssh-ed25519 AAA...\n') | |
| 30 | ||
| 31 | const result = await scanSSHKeys(dir) | |
| 32 | t.is(result.length, 2, 'found two .pub files') | |
| 33 | ||
| 34 | const types = result.map(r => r.type).sort() | |
| 35 | t.alike(types, ['ed25519', 'rsa']) | |
| 36 | ||
| 37 | const ed = result.find(r => r.type === 'ed25519') | |
| 38 | t.is(ed.comment, 'test@example') | |
| 39 | t.ok(ed.fingerprint.startsWith('SHA256:')) | |
| 40 | t.is(ed.publicKey, ED25519_KEY) | |
| 41 | }) | |
| 42 | ||
| 43 | test('scanSSHKeys silently skips malformed .pub files', async (t) => { | |
| 44 | const dir = mkdtempSync(join(tmpdir(), 'ssh-scan-bad-')) | |
| 45 | writeFileSync(join(dir, 'good.pub'), ED25519_KEY + '\n') | |
| 46 | writeFileSync(join(dir, 'broken.pub'), 'not an ssh key\n') | |
| 47 | writeFileSync(join(dir, 'empty.pub'), '') | |
| 48 | ||
| 49 | const result = await scanSSHKeys(dir) | |
| 50 | t.is(result.length, 1, 'only good.pub returned') | |
| 51 | t.is(result[0].type, 'ed25519') | |
| 52 | }) | |
| 53 | ||
| 54 | test('SSHKeyParser.fingerprint is deterministic and matches ssh-keygen format', async (t) => { | |
| 55 | const a = SSHKeyParser.fingerprint(ED25519_KEY) | |
| 56 | const b = SSHKeyParser.fingerprint(ED25519_KEY) | |
| 57 | t.is(a, b, 'deterministic') | |
| 58 | t.ok(a.startsWith('SHA256:')) | |
| 59 | t.absent(a.endsWith('='), 'no base64 padding (ssh-keygen convention)') | |
| 60 | ||
| 61 | // Two different keys produce different fingerprints | |
| 62 | const c = SSHKeyParser.fingerprint(RSA_KEY) | |
| 63 | t.not(a, c) | |
| 64 | }) | |
| 65 | ||
| 66 | test('scanHomeSSHKeys returns [] without $HOME', async (t) => { | |
| 67 | const origHome = process.env.HOME | |
| 68 | delete process.env.HOME | |
| 69 | t.teardown(() => { if (origHome) process.env.HOME = origHome }) | |
| 70 | ||
| 71 | const result = await scanHomeSSHKeys() | |
| 72 | t.alike(result, []) | |
| 73 | }) | |
| 74 | ||
| 75 | test('scanHomeSSHKeys returns [] when running as root (default)', async (t) => { | |
| 76 | // We can't become root in a test, so we fake it by monkey-patching getuid. | |
| 77 | const origGetuid = process.getuid | |
| 78 | process.getuid = () => 0 | |
| 79 | t.teardown(() => { process.getuid = origGetuid }) | |
| 80 | ||
| 81 | const result = await scanHomeSSHKeys() | |
| 82 | t.alike(result, [], 'root scan blocked') | |
| 83 | }) | |
| 84 | ||
| 85 | test('scanHomeSSHKeys respects allowRoot override', async (t) => { | |
| 86 | const origGetuid = process.getuid | |
| 87 | const origHome = process.env.HOME | |
| 88 | ||
| 89 | // Point HOME at a test dir so we can actually scan something | |
| 90 | const dir = mkdtempSync(join(tmpdir(), 'ssh-home-')) | |
| 91 | mkdirSync(join(dir, '.ssh')) | |
| 92 | writeFileSync(join(dir, '.ssh', 'id_ed25519.pub'), ED25519_KEY + '\n') | |
| 93 | ||
| 94 | process.getuid = () => 0 | |
| 95 | process.env.HOME = dir | |
| 96 | t.teardown(() => { | |
| 97 | process.getuid = origGetuid | |
| 98 | process.env.HOME = origHome | |
| 99 | }) | |
| 100 | ||
| 101 | const result = await scanHomeSSHKeys({ allowRoot: true }) | |
| 102 | t.is(result.length, 1, 'allowRoot lets root scan') | |
| 103 | }) |