201 lines
6.5 KiB
TypeScript
201 lines
6.5 KiB
TypeScript
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<object>();
|
|
const consumedChallenges = new WeakSet<object>();
|
|
const verifiedPossessions = new WeakSet<object>();
|
|
|
|
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<NodePossessionChallenge>): Promise<unknown | null>;
|
|
}
|
|
|
|
export interface VerifiedNodePossession {
|
|
challenge: Readonly<NodePossessionChallenge>;
|
|
registration: Readonly<VerifiedNodeRegistration>;
|
|
response: Readonly<NodePossessionResponse>;
|
|
verifiedAt: number;
|
|
}
|
|
|
|
function invalidProof(): never {
|
|
throw new Error('node_possession_proof_invalid');
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function hasExactKeys(record: Record<string, unknown>, 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 assertActiveNodePossessionChallenge(
|
|
challenge: NodePossessionChallenge,
|
|
now = Date.now(),
|
|
): NodePossessionChallenge {
|
|
if (!issuedChallenges.has(challenge)
|
|
|| consumedChallenges.has(challenge)
|
|
|| !Number.isSafeInteger(now)
|
|
|| now < challenge.issuedAt
|
|
|| now >= challenge.expiresAt) invalidProof();
|
|
return challenge;
|
|
}
|
|
|
|
export function createNodePossessionChallenge(
|
|
registration: VerifiedNodeRegistration,
|
|
now = Date.now(),
|
|
challengeId = `NODE-CHALLENGE-${randomUUID().toUpperCase()}`,
|
|
nonce = randomBytes(32).toString('base64url'),
|
|
): Readonly<NodePossessionChallenge> {
|
|
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(),
|
|
): Readonly<VerifiedNodePossession> {
|
|
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();
|
|
|
|
let publicKeyPem: string;
|
|
let signatureText: string;
|
|
try {
|
|
publicKeyPem = input.publicKeyPem as string;
|
|
signatureText = input.signature as string;
|
|
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);
|
|
const verified = Object.freeze({
|
|
challenge,
|
|
registration,
|
|
response: Object.freeze({
|
|
challengeId: challenge.challengeId,
|
|
publicKeyPem: publicKeyPem!,
|
|
schema: RESPONSE_SCHEMA,
|
|
signature: signatureText!,
|
|
}),
|
|
verifiedAt: now,
|
|
});
|
|
verifiedPossessions.add(verified);
|
|
return verified;
|
|
}
|
|
|
|
export function assertVerifiedNodePossession(
|
|
possession: VerifiedNodePossession,
|
|
): VerifiedNodePossession {
|
|
if (!verifiedPossessions.has(possession)) invalidProof();
|
|
return possession;
|
|
}
|