hololake-system-architecture/product-source/guanghu-knowledge-base/server/node-possession-proof.ts

189 lines
6.1 KiB
TypeScript
Raw Normal View History

2026-08-10 08:00:53 +08:00
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>();
2026-08-10 08:25:34 +08:00
const verifiedPossessions = new WeakSet<object>();
2026-08-10 08:00:53 +08:00
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>;
}
2026-08-10 08:25:34 +08:00
export interface VerifiedNodePossession {
challenge: Readonly<NodePossessionChallenge>;
registration: Readonly<VerifiedNodeRegistration>;
response: Readonly<NodePossessionResponse>;
verifiedAt: number;
}
2026-08-10 08:00:53 +08:00
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(),
2026-08-10 08:25:34 +08:00
): Readonly<VerifiedNodePossession> {
2026-08-10 08:00:53 +08:00
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();
2026-08-10 08:25:34 +08:00
let publicKeyPem: string;
let signatureText: string;
2026-08-10 08:00:53 +08:00
try {
2026-08-10 08:25:34 +08:00
publicKeyPem = input.publicKeyPem as string;
signatureText = input.signature as string;
2026-08-10 08:00:53 +08:00
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);
2026-08-10 08:25:34 +08:00
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;
2026-08-10 08:00:53 +08:00
}