Require current node possession proof
This commit is contained in:
parent
66eeaaf958
commit
64062fadac
10 changed files with 417 additions and 10 deletions
|
|
@ -0,0 +1,158 @@
|
|||
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>();
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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 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(),
|
||||
): 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;
|
||||
}
|
||||
Loading…
Reference in a new issue