import { createHash, createPublicKey, randomBytes, randomUUID, verify as verifySignature, } from 'node:crypto'; import { assertVerifiedNodeRegistration, type VerifiedNodeRegistration, } from './node-registration.js'; const CHALLENGE_SCHEMA = 'gh-aios.node-possession-challenge/v1' as const; const RESPONSE_SCHEMA = 'gh-aios.node-possession-response/v1' as const; const CHALLENGE_LIFETIME_MS = 60_000; const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; const NONCE_PATTERN = /^[A-Za-z0-9_-]{43}$/; const issuedChallenges = new WeakSet(); const consumedChallenges = new WeakSet(); export interface NodePossessionChallenge { accountId: string; challengeId: string; claimId: string; domainId: string; expiresAt: number; issuedAt: number; nodeId: string; nodeKeyFingerprint: string; nodeType: VerifiedNodeRegistration['nodeType']; nonce: string; schema: typeof CHALLENGE_SCHEMA; } export interface NodePossessionResponse { challengeId: string; publicKeyPem: string; schema: typeof RESPONSE_SCHEMA; signature: string; } export interface NodePossessionProofSource { prove(challenge: Readonly): Promise; } function invalidProof(): never { throw new Error('node_possession_proof_invalid'); } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function hasExactKeys(record: Record, keys: string[]): boolean { const expected = [...keys].sort(); const actual = Object.keys(record).sort(); return actual.length === expected.length && actual.every((key, index) => key === expected[index]); } export function nodePossessionChallengeSigningBytes(challenge: NodePossessionChallenge): Buffer { return Buffer.from(JSON.stringify({ accountId: challenge.accountId, challengeId: challenge.challengeId, claimId: challenge.claimId, domainId: challenge.domainId, expiresAt: challenge.expiresAt, issuedAt: challenge.issuedAt, nodeId: challenge.nodeId, nodeKeyFingerprint: challenge.nodeKeyFingerprint, nodeType: challenge.nodeType, nonce: challenge.nonce, schema: challenge.schema, }), 'utf8'); } export function createNodePossessionChallenge( registration: VerifiedNodeRegistration, now = Date.now(), challengeId = `NODE-CHALLENGE-${randomUUID().toUpperCase()}`, nonce = randomBytes(32).toString('base64url'), ): Readonly { try { assertVerifiedNodeRegistration(registration); } catch { invalidProof(); } if (!Number.isSafeInteger(now) || now < registration.issuedAt || now >= registration.expiresAt || !IDENTIFIER_PATTERN.test(challengeId) || !NONCE_PATTERN.test(nonce)) invalidProof(); const expiresAt = Math.min(now + CHALLENGE_LIFETIME_MS, registration.expiresAt); if (expiresAt <= now) invalidProof(); const challenge = Object.freeze({ accountId: registration.accountId, challengeId, claimId: registration.claimId, domainId: registration.domainId, expiresAt, issuedAt: now, nodeId: registration.nodeId, nodeKeyFingerprint: registration.nodeKeyFingerprint, nodeType: registration.nodeType, nonce, schema: CHALLENGE_SCHEMA, }); issuedChallenges.add(challenge); return challenge; } export function verifyNodePossessionResponse( input: unknown, challenge: NodePossessionChallenge, registration: VerifiedNodeRegistration, now = Date.now(), ): true { try { assertVerifiedNodeRegistration(registration); } catch { invalidProof(); } if (!issuedChallenges.has(challenge) || consumedChallenges.has(challenge) || !Number.isSafeInteger(now) || now < challenge.issuedAt || now >= challenge.expiresAt || registration.expiresAt <= now || challenge.accountId !== registration.accountId || challenge.claimId !== registration.claimId || challenge.domainId !== registration.domainId || challenge.nodeId !== registration.nodeId || challenge.nodeKeyFingerprint !== registration.nodeKeyFingerprint || challenge.nodeType !== registration.nodeType || !isRecord(input) || !hasExactKeys(input, ['challengeId', 'publicKeyPem', 'schema', 'signature']) || input.schema !== RESPONSE_SCHEMA || input.challengeId !== challenge.challengeId || typeof input.publicKeyPem !== 'string' || input.publicKeyPem.length > 4096 || input.publicKeyPem.includes('PRIVATE KEY') || typeof input.signature !== 'string') invalidProof(); try { const publicKey = createPublicKey({ key: input.publicKeyPem, format: 'pem' }); if (publicKey.asymmetricKeyType !== 'ed25519') invalidProof(); const fingerprint = createHash('sha256') .update(publicKey.export({ format: 'der', type: 'spki' })) .digest('hex'); if (fingerprint !== registration.nodeKeyFingerprint) invalidProof(); const signature = Buffer.from(input.signature, 'base64'); if (signature.length !== 64 || signature.toString('base64') !== input.signature) invalidProof(); if (!verifySignature(null, nodePossessionChallengeSigningBytes(challenge), publicKey, signature)) invalidProof(); } catch { invalidProof(); } consumedChallenges.add(challenge); return true; }