Verify signed node registration claims
This commit is contained in:
parent
f45113b861
commit
2bfede2376
9 changed files with 965 additions and 54 deletions
|
|
@ -0,0 +1,269 @@
|
|||
import { createPublicKey, verify as verifySignature } from 'node:crypto';
|
||||
import type { DomainNodeType } from './domain-access.js';
|
||||
|
||||
const REGISTRY_SCHEMA = 'gh-aios.node-registration-endpoints/v1' as const;
|
||||
const CLAIM_SCHEMA = 'gh-aios.node-registration-claim/v1' as const;
|
||||
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
||||
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
||||
const COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/;
|
||||
const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const MAX_CLAIM_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
||||
const registeredEndpoints = new WeakSet<object>();
|
||||
|
||||
export interface NodeRegistrationEndpoint {
|
||||
algorithm: 'Ed25519';
|
||||
domainIds: readonly string[];
|
||||
endpointId: string;
|
||||
nodeTypes: readonly DomainNodeType[];
|
||||
publicKeyPem: string;
|
||||
signerId: string;
|
||||
status: 'ACTIVE' | 'REVOKED';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface NodeRegistrationEndpointRegistrySource {
|
||||
repositoryId: 'REPO-012';
|
||||
sourceCommit: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
export interface NodeRegistrationEndpointRegistry {
|
||||
endpoints: readonly NodeRegistrationEndpoint[];
|
||||
registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001';
|
||||
schema: typeof REGISTRY_SCHEMA;
|
||||
source: Readonly<NodeRegistrationEndpointRegistrySource>;
|
||||
state: 'CURRENT';
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface NodeRegistrationClaimPayload {
|
||||
accountId: string;
|
||||
claimId: string;
|
||||
domainId: string;
|
||||
endpointId: string;
|
||||
expiresAt: number;
|
||||
issuedAt: number;
|
||||
nodeId: string;
|
||||
nodeKeyFingerprint: string;
|
||||
nodeType: DomainNodeType;
|
||||
schema: typeof CLAIM_SCHEMA;
|
||||
signerId: string;
|
||||
}
|
||||
|
||||
export interface VerifiedNodeRegistration extends NodeRegistrationClaimPayload {
|
||||
signature: string;
|
||||
verifiedAt: number;
|
||||
}
|
||||
|
||||
export interface NodeRegistrationLookup {
|
||||
domainId: string;
|
||||
nodeType: DomainNodeType;
|
||||
}
|
||||
|
||||
function invalidRegistry(): never {
|
||||
throw new Error('node_registration_endpoint_registry_invalid');
|
||||
}
|
||||
|
||||
function invalidClaim(): never {
|
||||
throw new Error('node_registration_claim_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]);
|
||||
}
|
||||
|
||||
function isIdentifier(value: unknown): value is string {
|
||||
return typeof value === 'string' && IDENTIFIER_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function parsePublicKey(value: unknown): string {
|
||||
if (typeof value !== 'string'
|
||||
|| value.length > 4096
|
||||
|| value.includes('PRIVATE KEY')
|
||||
|| !value.startsWith('-----BEGIN PUBLIC KEY-----\n')
|
||||
|| !value.endsWith('-----END PUBLIC KEY-----\n')) invalidRegistry();
|
||||
try {
|
||||
const key = createPublicKey({ format: 'pem', key: value });
|
||||
if (key.asymmetricKeyType !== 'ed25519') invalidRegistry();
|
||||
} catch {
|
||||
invalidRegistry();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEndpointUrl(value: unknown): string {
|
||||
if (typeof value !== 'string') invalidRegistry();
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
invalidRegistry();
|
||||
}
|
||||
if (url.protocol !== 'https:'
|
||||
|| url.hostname !== 'guanghulab.com'
|
||||
|| url.port
|
||||
|| url.username
|
||||
|| url.password
|
||||
|| url.search
|
||||
|| url.hash
|
||||
|| !url.pathname.startsWith('/api/ai/v1/')) invalidRegistry();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseEndpoint(input: unknown): NodeRegistrationEndpoint {
|
||||
if (!isRecord(input)
|
||||
|| !hasExactKeys(input, ['algorithm', 'domainIds', 'endpointId', 'nodeTypes', 'publicKeyPem', 'signerId', 'status', 'url'])
|
||||
|| input.algorithm !== 'Ed25519'
|
||||
|| !isIdentifier(input.endpointId)
|
||||
|| !isIdentifier(input.signerId)
|
||||
|| (input.status !== 'ACTIVE' && input.status !== 'REVOKED')
|
||||
|| !Array.isArray(input.domainIds)
|
||||
|| input.domainIds.length === 0
|
||||
|| input.domainIds.some(domainId => !isIdentifier(domainId))
|
||||
|| new Set(input.domainIds).size !== input.domainIds.length
|
||||
|| !Array.isArray(input.nodeTypes)
|
||||
|| input.nodeTypes.length === 0
|
||||
|| input.nodeTypes.some(nodeType => nodeType !== 'local-terminal' && nodeType !== 'cloud-resident')
|
||||
|| new Set(input.nodeTypes).size !== input.nodeTypes.length) invalidRegistry();
|
||||
const endpoint = Object.freeze({
|
||||
algorithm: 'Ed25519' as const,
|
||||
domainIds: Object.freeze([...input.domainIds] as string[]),
|
||||
endpointId: input.endpointId,
|
||||
nodeTypes: Object.freeze([...input.nodeTypes] as DomainNodeType[]),
|
||||
publicKeyPem: parsePublicKey(input.publicKeyPem),
|
||||
signerId: input.signerId,
|
||||
status: input.status,
|
||||
url: parseEndpointUrl(input.url),
|
||||
});
|
||||
registeredEndpoints.add(endpoint);
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function parseSource(source: NodeRegistrationEndpointRegistrySource): Readonly<NodeRegistrationEndpointRegistrySource> {
|
||||
if (!isRecord(source)
|
||||
|| !hasExactKeys(source, ['repositoryId', 'sourceCommit', 'sourceUrl'])
|
||||
|| source.repositoryId !== 'REPO-012'
|
||||
|| typeof source.sourceCommit !== 'string'
|
||||
|| !COMMIT_PATTERN.test(source.sourceCommit)) invalidRegistry();
|
||||
const expected = `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${source.sourceCommit}/routing/node-registration-endpoints.json`;
|
||||
if (source.sourceUrl !== expected) invalidRegistry();
|
||||
return Object.freeze({ ...source });
|
||||
}
|
||||
|
||||
export function parseNodeRegistrationEndpointRegistry(
|
||||
input: unknown,
|
||||
source: NodeRegistrationEndpointRegistrySource,
|
||||
): NodeRegistrationEndpointRegistry {
|
||||
if (!isRecord(input)
|
||||
|| !hasExactKeys(input, ['endpoints', 'registryId', 'schema', 'state', 'version'])
|
||||
|| input.registryId !== 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001'
|
||||
|| input.schema !== REGISTRY_SCHEMA
|
||||
|| input.state !== 'CURRENT'
|
||||
|| typeof input.version !== 'string'
|
||||
|| !VERSION_PATTERN.test(input.version)
|
||||
|| !Array.isArray(input.endpoints)) invalidRegistry();
|
||||
const endpoints = input.endpoints.map(parseEndpoint);
|
||||
if (new Set(endpoints.map(endpoint => endpoint.endpointId)).size !== endpoints.length) invalidRegistry();
|
||||
const activeScopes = new Set<string>();
|
||||
for (const endpoint of endpoints.filter(candidate => candidate.status === 'ACTIVE')) {
|
||||
for (const domainId of endpoint.domainIds) {
|
||||
for (const nodeType of endpoint.nodeTypes) {
|
||||
const scope = `${domainId}\0${nodeType}`;
|
||||
if (activeScopes.has(scope)) invalidRegistry();
|
||||
activeScopes.add(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoints: Object.freeze(endpoints),
|
||||
registryId: input.registryId,
|
||||
schema: input.schema,
|
||||
source: parseSource(source),
|
||||
state: input.state,
|
||||
version: input.version,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNodeRegistrationEndpoint(
|
||||
registry: NodeRegistrationEndpointRegistry,
|
||||
lookup: NodeRegistrationLookup,
|
||||
): NodeRegistrationEndpoint | null {
|
||||
const endpoint = registry.endpoints.find(candidate => candidate.status === 'ACTIVE'
|
||||
&& candidate.domainIds.includes(lookup.domainId)
|
||||
&& candidate.nodeTypes.includes(lookup.nodeType));
|
||||
if (!endpoint) return null;
|
||||
if (!registeredEndpoints.has(endpoint)) throw new Error('node_registration_endpoint_unregistered');
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export function nodeRegistrationClaimSigningBytes(payload: NodeRegistrationClaimPayload): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
accountId: payload.accountId,
|
||||
claimId: payload.claimId,
|
||||
domainId: payload.domainId,
|
||||
endpointId: payload.endpointId,
|
||||
expiresAt: payload.expiresAt,
|
||||
issuedAt: payload.issuedAt,
|
||||
nodeId: payload.nodeId,
|
||||
nodeKeyFingerprint: payload.nodeKeyFingerprint,
|
||||
nodeType: payload.nodeType,
|
||||
schema: payload.schema,
|
||||
signerId: payload.signerId,
|
||||
}), 'utf8');
|
||||
}
|
||||
|
||||
export function verifyNodeRegistrationClaim(
|
||||
input: unknown,
|
||||
expected: Readonly<{ accountId: string; domainId: string; nodeId: string; nodeType: DomainNodeType }>,
|
||||
endpoint: NodeRegistrationEndpoint,
|
||||
now = Date.now(),
|
||||
): Readonly<VerifiedNodeRegistration> {
|
||||
if (!registeredEndpoints.has(endpoint) || endpoint.status !== 'ACTIVE') invalidClaim();
|
||||
if (!isRecord(input)
|
||||
|| !hasExactKeys(input, ['accountId', 'claimId', 'domainId', 'endpointId', 'expiresAt', 'issuedAt', 'nodeId', 'nodeKeyFingerprint', 'nodeType', 'schema', 'signature', 'signerId'])) invalidClaim();
|
||||
if (input.schema !== CLAIM_SCHEMA
|
||||
|| typeof input.accountId !== 'string'
|
||||
|| !ACCOUNT_ID_PATTERN.test(input.accountId)
|
||||
|| input.accountId !== expected.accountId
|
||||
|| !isIdentifier(input.claimId)
|
||||
|| input.domainId !== expected.domainId
|
||||
|| input.nodeId !== expected.nodeId
|
||||
|| input.nodeType !== expected.nodeType
|
||||
|| input.endpointId !== endpoint.endpointId
|
||||
|| input.signerId !== endpoint.signerId
|
||||
|| !endpoint.domainIds.includes(expected.domainId)
|
||||
|| !endpoint.nodeTypes.includes(expected.nodeType)
|
||||
|| typeof input.nodeKeyFingerprint !== 'string'
|
||||
|| !SHA256_PATTERN.test(input.nodeKeyFingerprint)
|
||||
|| typeof input.issuedAt !== 'number'
|
||||
|| !Number.isSafeInteger(input.issuedAt)
|
||||
|| typeof input.expiresAt !== 'number'
|
||||
|| !Number.isSafeInteger(input.expiresAt)
|
||||
|| input.issuedAt > now + MAX_CLOCK_SKEW_MS
|
||||
|| input.expiresAt <= now
|
||||
|| input.expiresAt <= input.issuedAt
|
||||
|| input.expiresAt - input.issuedAt > MAX_CLAIM_LIFETIME_MS
|
||||
|| typeof input.signature !== 'string') invalidClaim();
|
||||
const payload = input as unknown as NodeRegistrationClaimPayload;
|
||||
let signature: Buffer;
|
||||
try {
|
||||
signature = Buffer.from(input.signature, 'base64');
|
||||
if (signature.length !== 64 || signature.toString('base64') !== input.signature) invalidClaim();
|
||||
if (!verifySignature(null, nodeRegistrationClaimSigningBytes(payload), endpoint.publicKeyPem, signature)) invalidClaim();
|
||||
} catch {
|
||||
invalidClaim();
|
||||
}
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
signature: input.signature,
|
||||
verifiedAt: now,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue