OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 6ef2f9f

Phase 2 enhancements: canonical anproto wire format + link tokens + registry extensions
- anproto-client.js: sign() produces canonical wire format (pubkey + combined-mode), add hash() and open() methods for browser parity with an.sign/an.open - anproto.js: sign/verify use canonical combined-mode envelope (BUG-1 fix) - identity-apply.js: CREATE_LINK_TOKEN + CONSUME_LINK_TOKEN Autobase ops - identity-operations.js: link-token op types + validation - identity-registry.js: createLinkToken, consumeLinkToken, importIdentity, deleteUserCascade, isRevoked, getUserWithIdentities, listPublicUsers, updateUserProfile, setUsername, isUsernameAvailable - ssh-parser.js: key type extraction - providers: ssh-key + web-identity improvements - package.json: dep updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6ef2f9f6b25bd5a2d80bbd55a0f03ba810883862
by plankatron <fleeky@gmail.com> · 2026-04-17T02:41:17.000Z

Changed files (16) +969 -64

lib/anproto-client.js +61 -19
……@@ -56,40 +56,82 @@ export const ANProtoClient = {
5656 },
5757
5858 /**
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().
6174 *
6275 * @param {string} challenge — challenge string (from server)
6376 * @param {string} keypair — 132-char base64 keypair
64 * @returns {Promise<string>} 88-char base64 signature
77 * @returns {Promise<string>} canonical wire-format signature
6578 */
6679 async sign (challenge, keypair) {
80 const pubKeyB64 = keypair.substring(0, 44)
6781 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)
6989
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)
7392
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)
7699 },
77100
78101 /**
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 }>}
84105 */
85 async verify (challenge, signatureB64, publicKeyB64) {
106 async open (message) {
86107 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)
89112 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 }
91133 } catch {
92 return false
134 return { valid: false, publicKey: null, timestamp: null, hash: null }
93135 }
94136 }
95137}
lib/anproto.js +40 -22
……@@ -68,41 +68,59 @@ export class ANProto {
6868 }
6969
7070 // ---------------------------------------------------------------------------
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
7373 // ---------------------------------------------------------------------------
7474
7575 /**
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 *
7781 * @param {string|Buffer} challenge - Challenge to sign
7882 * @param {{ publicKey: Buffer, secretKey: Buffer }} keypair
79 * @returns {string} 88-char base64 detached signature
83 * @returns {string} canonical wire-format signature
8084 */
8185 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)
8589 }
8690
8791 /**
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 *
8997 * @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 }}
93100 */
94 static verify (challenge, signature, publicKey) {
101 static verify (challenge, signature) {
95102 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 }
106124 }
107125 }
108126
lib/auth.js +4 -3
……@@ -388,10 +388,11 @@ export class AuthManager {
388388 if (merge.status !== 'pending') throw new Error('Merge already completed or expired')
389389 if (merge.expiresAt < Date.now()) throw new Error('Merge request expired')
390390
391 // Verify the signature is from the target
391 // Verify the signature is from the target (canonical ANProto format)
392392 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')
395396
396397 // Execute the merge
397398 await this.identityRegistry.mergeAccounts(merge.primaryUserId, merge.targetUserId)
lib/auth/identity-apply.js +65 -0
……@@ -310,6 +310,71 @@ export function createApplyFunction (opts = {}) {
310310 break
311311 }
312312
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
313378 case OpTypes.SET_USERNAME: {
314379 const key = `${prefix}user:${op.userId}`
315380 const existing = await b.get(key)
lib/auth/identity-operations.js +17 -1
……@@ -16,7 +16,12 @@ export const OpTypes = {
1616 RESTORE_IDENTITY: 'restore-identity',
1717 SET_USERNAME: 'set-username',
1818 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'
2025}
2126
2227/**
……@@ -99,6 +104,17 @@ export function validateOp (op) {
99104 if (!op.userId) return { valid: false, error: 'Missing userId' }
100105 return { valid: true }
101106
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
102118 default:
103119 return { valid: false, error: `Unknown op type: ${op.type}` }
104120 }
lib/auth/identity-registry.js +91 -0
……@@ -762,6 +762,97 @@ export class IdentityRegistry {
762762 return identity
763763 }
764764
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
765856 // ==================== FEDERATION & LIFECYCLE ====================
766857
767858 /**
lib/auth/providers/ssh-key.js +10 -6
……@@ -40,12 +40,16 @@ export class SSHKeyProvider extends BaseProvider {
4040 let valid = false
4141
4242 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 }
4953 } else if (parsedKey.type === 'rsa') {
5054 // RSA requires node:crypto
5155 try {
lib/auth/providers/web-identity.js +4 -2
……@@ -40,8 +40,10 @@ export class WebIdentityProvider extends BaseProvider {
4040 const challenge = await this.challengeManager.get(challengeId)
4141 if (!challenge) throw new Error('Invalid or expired challenge')
4242
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')
4547
4648 await this.challengeManager.consume(challengeId)
4749 signatureVerified = true
lib/auth/ssh-agent.js +157 -0
……@@ -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
21import net from 'net'
22import b4a from 'b4a'
23
24const SSH_AGENTC_REQUEST_IDENTITIES = 11
25const SSH_AGENT_IDENTITIES_ANSWER = 12
26const SSH_AGENTC_SIGN_REQUEST = 13
27const SSH_AGENT_SIGN_RESPONSE = 14
28const SSH_AGENT_FAILURE = 5
29
30/**
31 * Check whether an ssh-agent is reachable on $SSH_AUTH_SOCK.
32 */
33export 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 */
43export 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 */
82export 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 */
116function 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
149function writeUint32 (n) {
150 const b = b4a.alloc(4)
151 b.writeUInt32BE(n, 0)
152 return b
153}
154
155function writeString (buf) {
156 return b4a.concat([writeUint32(buf.length), buf])
157}
lib/auth/ssh-auto-import.js +113 -0
……@@ -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
26import { 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 */
40export 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}
lib/auth/ssh-parser.js +19 -0
……@@ -1,4 +1,5 @@
11import b4a from 'b4a'
2import crypto from 'hypercore-crypto'
23
34/**
45 * SSH Key Parser
……@@ -7,6 +8,24 @@ import b4a from 'b4a'
78 * Supports: ssh-rsa, ssh-ed25519
89 */
910export 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
1029 static parse (sshPublicKey) {
1130 const parts = sshPublicKey.trim().split(/\s+/)
1231 if (parts.length < 2) throw new Error('Invalid SSH key format')
lib/auth/ssh-scan.js +108 -0
……@@ -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
15import { readdir, readFile, stat } from 'fs/promises'
16import { join } from 'path'
17
18import { 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 */
26export 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 */
102export 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}
package.json +4 -1
……@@ -18,6 +18,9 @@
1818 "./auth/rate-limiter": "./lib/auth/rate-limiter.js",
1919 "./auth/base-provider": "./lib/auth/base-provider.js",
2020 "./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",
2124 "./auth/providers/web-identity": "./lib/auth/providers/web-identity.js",
2225 "./auth/providers/keet-identity": "./lib/auth/providers/keet-identity.js",
2326 "./auth/providers/bearer-token": "./lib/auth/providers/bearer-token.js",
……@@ -31,6 +34,7 @@
3134 "blind-pairing": "^2.3.1",
3235 "hypercore": "^11.24.0",
3336 "hypercore-crypto": "^3.0.0",
37 "hyperbee": "^2.27.3",
3438 "hyperswarm": "^4.16.0",
3539 "keet-identity-key": "github:holepunchto/keet-identity-key",
3640 "sodium-native": "^4.3.1"
……@@ -42,7 +46,6 @@
4246 "devDependencies": {
4347 "brittle": "^3.19.1",
4448 "corestore": "^7.9.2",
45 "hyperbee": "^2.27.3",
4649 "random-access-memory": "^6.2.1"
4750 }
4851}
test/anproto.test.js +18 -10
……@@ -32,39 +32,47 @@ test('getPublicKey() extracts base64 pubkey', (t) => {
3232
3333// --- Challenge-Response Mode ---
3434
35test('sign/verify challenge-response round-trip', (t) => {
35test('sign/verify challenge-response round-trip (canonical format)', (t) => {
3636 const kp = ANProto.gen()
3737 const challenge = 'test-challenge-12345'
3838
3939 const sig = ANProto.sign(challenge, kp)
4040 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')
4244
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')
4549})
4650
4751test('verify rejects wrong challenge', (t) => {
4852 const kp = ANProto.gen()
4953 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')
5257})
5358
54test('verify rejects wrong key', (t) => {
59test('verify rejects wrong key (tampered signature)', (t) => {
5560 const kp1 = ANProto.gen()
5661 const kp2 = ANProto.gen()
5762 const challenge = 'test'
5863 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')
6168})
6269
6370test('verify handles Buffer inputs', (t) => {
6471 const kp = ANProto.gen()
6572 const challenge = b4a.from('buffer-challenge')
6673 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')
6876})
6977
7078// --- Content Signing Mode ---
test/ssh-auto-import.test.js +155 -0
……@@ -0,0 +1,155 @@
1import test from 'brittle'
2import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'
3import { tmpdir } from 'os'
4import { join } from 'path'
5
6import { autoImportLocalSSHKeys } from '../lib/auth/ssh-auto-import.js'
7
8// Real Ed25519 pubkey (no secret exposed)
9const ED25519_A = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMbEhcffafHpuMVWZ0Q0iqQKg5vS7ALfRG0/XBT8fmP alice@host1'
10const 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 */
18function 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
57function 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
66function scopedHome (t, home) {
67 const orig = process.env.HOME
68 process.env.HOME = home
69 t.teardown(() => { process.env.HOME = orig })
70}
71
72test('refuses when ownerUserId is missing', async (t) => {
73 const reg = makeMockRegistry()
74 await t.exception(() => autoImportLocalSSHKeys(reg, null), /ownerUserId/)
75})
76
77test('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
86test('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
99test('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
118test('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
133test('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
147test('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})
test/ssh-scan.test.js +103 -0
……@@ -0,0 +1,103 @@
1import test from 'brittle'
2import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync, chmodSync } from 'fs'
3import { tmpdir } from 'os'
4import { join } from 'path'
5
6import { scanSSHKeys, scanHomeSSHKeys } from '../lib/auth/ssh-scan.js'
7import { 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.
12const ED25519_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMbEhcffafHpuMVWZ0Q0iqQKg5vS7ALfRG0/XBT8fmP test@example'
13const RSA_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNAYMZkFcAWkfWO7uKCg5V1hf9yV8cIIQ0MBPeRELvUxD9tyfxBDQhLNDlcmVGdO2ar8e2BsTBMQ7Smvp4ekXQj9Z+35DkQyAUlLYpGjJFYcB7cEiM7IwjGt9z7mGZkS0r1/UNu2MSSawYFHQ0uLyHYmcl0FlBYq8XAxA6mnPrZpcrPzQmGL8w2kFnL9Z1XclRBG7QMCw8ZCLr6a6yxa6drNbEFXmhZuXHH0RlQSoqbLs0Jc6Lk9ahQwCkYmNH3Cz2hWGLpGU2vvc8fF7bgWe+T4ZHG2J2CSSoO1fABQUM6qdh4jWi3j7XTvqEK1+QRY2fXq+NxCdoW2qiayw6ARXT test-rsa@example'
14
15test('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
21test('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
43test('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
54test('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
66test('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
75test('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
85test('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})