Require current node possession proof
This commit is contained in:
parent
66eeaaf958
commit
64062fadac
10 changed files with 417 additions and 10 deletions
|
|
@ -4,6 +4,7 @@ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
|||
import { domainManifestSigningBytes } from './domain-access.js';
|
||||
import { DomainAccessOrchestrator } from './domain-access-orchestrator.js';
|
||||
import { nodeRegistrationClaimSigningBytes } from './node-registration.js';
|
||||
import { nodePossessionChallengeSigningBytes } from './node-possession-proof.js';
|
||||
import { NodeRegistrationSnapshotLoader } from './node-registration-snapshot.js';
|
||||
import { TrustedSignerSnapshotLoader } from './trusted-signer-snapshot.js';
|
||||
|
||||
|
|
@ -18,6 +19,11 @@ const SIGNER_REGISTRY_URL = `https://guanghulab.com/code/bingshuo/guanghu-ice-he
|
|||
const NODE_REGISTRY_URL = `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/node-registration-endpoints.json`;
|
||||
const manifestKeys = generateKeyPairSync('ed25519');
|
||||
const registrationKeys = generateKeyPairSync('ed25519');
|
||||
const nodeKeys = generateKeyPairSync('ed25519');
|
||||
const nodePublicKeyPem = nodeKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString();
|
||||
const nodeKeyFingerprint = createHash('sha256')
|
||||
.update(nodeKeys.publicKey.export({ format: 'der', type: 'spki' }))
|
||||
.digest('hex');
|
||||
|
||||
function anchor() {
|
||||
return {
|
||||
|
|
@ -118,7 +124,7 @@ function validRegistrationClaim() {
|
|||
expiresAt: NOW + 60_000,
|
||||
issuedAt: NOW - 1_000,
|
||||
nodeId: NODE_ID,
|
||||
nodeKeyFingerprint: 'c'.repeat(64),
|
||||
nodeKeyFingerprint,
|
||||
nodeType: NODE_TYPE,
|
||||
schema: 'gh-aios.node-registration-claim/v1' as const,
|
||||
signerId: 'GH-NODE-REG-SIGNER-001',
|
||||
|
|
@ -168,6 +174,16 @@ function validHandoff() {
|
|||
const loggedOutIdentity = { async read() { return { accountId: '', accountVerified: false, nodeId: '' }; } };
|
||||
const loggedInIdentity = { async read() { return { accountId: ACCOUNT_ID, accountVerified: true, nodeId: NODE_ID }; } };
|
||||
const validRegistrationSource = { async read() { return validRegistrationClaim(); } };
|
||||
const validPossessionSource = {
|
||||
async prove(challenge: Parameters<typeof nodePossessionChallengeSigningBytes>[0]) {
|
||||
return {
|
||||
challengeId: challenge.challengeId,
|
||||
publicKeyPem: nodePublicKeyPem,
|
||||
schema: 'gh-aios.node-possession-response/v1',
|
||||
signature: sign(null, nodePossessionChallengeSigningBytes(challenge), nodeKeys.privateKey).toString('base64'),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
test('projects both trust-source states without asking for claims or handoff before login', async () => {
|
||||
let claimReads = 0;
|
||||
|
|
@ -177,6 +193,7 @@ test('projects both trust-source states without asking for claims or handoff bef
|
|||
registrationLoader(),
|
||||
loggedOutIdentity,
|
||||
{ async read() { claimReads += 1; return null; } },
|
||||
validPossessionSource,
|
||||
{ async read() { handoffReads += 1; return null; } },
|
||||
);
|
||||
|
||||
|
|
@ -200,6 +217,7 @@ test('a signed node registration without a handoff remains identity-only', async
|
|||
observedEndpointId = request.endpoint.endpointId;
|
||||
return validRegistrationClaim();
|
||||
} },
|
||||
validPossessionSource,
|
||||
{ async read() { return null; } },
|
||||
);
|
||||
|
||||
|
|
@ -212,6 +230,24 @@ test('a signed node registration without a handoff remains identity-only', async
|
|||
assert.ok(status.blockers.includes('verified_domain_manifest_missing'));
|
||||
});
|
||||
|
||||
test('a signed registration without a fresh node-key proof cannot reach the handoff route', async () => {
|
||||
let handoffReads = 0;
|
||||
const orchestrator = new DomainAccessOrchestrator(
|
||||
signerLoader(),
|
||||
registrationLoader(),
|
||||
loggedInIdentity,
|
||||
validRegistrationSource,
|
||||
{ async prove() { return null; } },
|
||||
{ async read() { handoffReads += 1; return null; } },
|
||||
);
|
||||
|
||||
const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW);
|
||||
|
||||
assert.equal(status.stage, 'login-required');
|
||||
assert.ok(status.blockers.includes('verified_node_possession_missing'));
|
||||
assert.equal(handoffReads, 0);
|
||||
});
|
||||
|
||||
test('a code-channel account cannot supply registration and never contacts a handoff route', async () => {
|
||||
let handoffReads = 0;
|
||||
const orchestrator = new DomainAccessOrchestrator(
|
||||
|
|
@ -219,6 +255,7 @@ test('a code-channel account cannot supply registration and never contacts a han
|
|||
registrationLoader(),
|
||||
loggedInIdentity,
|
||||
{ async read() { return null; } },
|
||||
validPossessionSource,
|
||||
{ async read() { handoffReads += 1; return validHandoff() as never; } },
|
||||
);
|
||||
|
||||
|
|
@ -235,6 +272,7 @@ test('current registries, signed node claim and verified handoff project runtime
|
|||
registrationLoader(),
|
||||
loggedInIdentity,
|
||||
validRegistrationSource,
|
||||
validPossessionSource,
|
||||
{ async read() {
|
||||
return {
|
||||
handoff: validHandoff(),
|
||||
|
|
@ -259,6 +297,7 @@ test('source failures are reduced to safe status without raw details', async ()
|
|||
unavailableRegistration,
|
||||
loggedInIdentity,
|
||||
{ async read() { claimReads += 1; throw new Error('secret claim detail'); } },
|
||||
{ async prove() { throw new Error('secret possession detail'); } },
|
||||
{ async read() { throw new Error('secret handoff detail'); } },
|
||||
);
|
||||
|
||||
|
|
@ -282,6 +321,7 @@ test('a degraded signer may preserve handoff verification while node registratio
|
|||
registrationLoader(),
|
||||
loggedInIdentity,
|
||||
validRegistrationSource,
|
||||
validPossessionSource,
|
||||
{ async read() {
|
||||
return {
|
||||
handoff: validHandoff(),
|
||||
|
|
@ -308,6 +348,7 @@ test('a degraded node-registration registry cannot authorize a claim', async ()
|
|||
currentNodeLoader,
|
||||
loggedInIdentity,
|
||||
{ async read() { claimReads += 1; return validRegistrationClaim(); } },
|
||||
validPossessionSource,
|
||||
{ async read() { handoffReads += 1; return null; } },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ import {
|
|||
type NodeRegistrationSnapshotReceipt,
|
||||
} from './node-registration-snapshot.js';
|
||||
import type { NodeRegistrationClaimSource } from './node-registration-client.js';
|
||||
import {
|
||||
createNodePossessionChallenge,
|
||||
verifyNodePossessionResponse,
|
||||
type NodePossessionProofSource,
|
||||
} from './node-possession-proof.js';
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
||||
|
||||
|
|
@ -81,10 +86,13 @@ export class DomainAccessOrchestrator {
|
|||
private readonly nodeRegistrationSnapshots: NodeRegistrationSnapshotLoader,
|
||||
private readonly identities: DomainIdentitySource,
|
||||
private readonly nodeRegistrations: NodeRegistrationClaimSource,
|
||||
private readonly nodePossessions: NodePossessionProofSource,
|
||||
private readonly handoffs: DomainRuntimeHandoffSource,
|
||||
private readonly clock: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
async domainAccess(domainId: string, nodeType: DomainNodeType, now = Date.now()): Promise<Readonly<DomainAccessProjection>> {
|
||||
async domainAccess(domainId: string, nodeType: DomainNodeType, at?: number): Promise<Readonly<DomainAccessProjection>> {
|
||||
let now = at ?? this.clock();
|
||||
if (!IDENTIFIER_PATTERN.test(domainId)) throw new Error('domain_access_domain_invalid');
|
||||
if (nodeType !== 'local-terminal' && nodeType !== 'cloud-resident') throw new Error('domain_access_node_type_invalid');
|
||||
const [snapshot, nodeRegistrationSnapshot, identity] = await Promise.all([
|
||||
|
|
@ -93,6 +101,7 @@ export class DomainAccessOrchestrator {
|
|||
this.identities.read(domainId, nodeType).then(safeIdentity, () => safeIdentity(null)),
|
||||
]);
|
||||
let nodeRegistrationVerified = false;
|
||||
let nodePossessionVerified = false;
|
||||
if (identity.accountVerified && nodeRegistrationSnapshot.receipt.status === 'CURRENT') {
|
||||
try {
|
||||
const loaded = assertLoadedNodeRegistrationSnapshot(nodeRegistrationSnapshot);
|
||||
|
|
@ -106,13 +115,20 @@ export class DomainAccessOrchestrator {
|
|||
nodeType,
|
||||
}));
|
||||
if (claim) {
|
||||
verifyNodeRegistrationClaim(claim, {
|
||||
const registration = verifyNodeRegistrationClaim(claim, {
|
||||
accountId: identity.accountId,
|
||||
domainId,
|
||||
nodeId: identity.nodeId,
|
||||
nodeType,
|
||||
}, endpoint, now);
|
||||
nodeRegistrationVerified = true;
|
||||
const challenge = createNodePossessionChallenge(registration, now);
|
||||
const response = await this.nodePossessions.prove(challenge);
|
||||
if (response) {
|
||||
if (at === undefined) now = this.clock();
|
||||
verifyNodePossessionResponse(response, challenge, registration, now);
|
||||
nodePossessionVerified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -123,11 +139,12 @@ export class DomainAccessOrchestrator {
|
|||
accountVerified: identity.accountVerified,
|
||||
domainId,
|
||||
nodeId: identity.nodeId,
|
||||
nodePossessionVerified,
|
||||
nodeRegistrationVerified,
|
||||
nodeType,
|
||||
};
|
||||
|
||||
if (identity.accountVerified && nodeRegistrationVerified) {
|
||||
if (identity.accountVerified && nodeRegistrationVerified && nodePossessionVerified) {
|
||||
let candidate: DomainRuntimeHandoffCandidate | null = null;
|
||||
try {
|
||||
candidate = await this.handoffs.read(domainId, identity.nodeId, nodeType);
|
||||
|
|
|
|||
|
|
@ -147,21 +147,21 @@ function snapshotLoader(readRegistry: () => unknown) {
|
|||
}
|
||||
|
||||
test('a local workspace remains available without claiming domain runtime access', () => {
|
||||
const status = evaluateDomainAccess({ accountVerified: false, domainId: 'DOM-FIFTH-0001', nodeId: '', nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW);
|
||||
const status = evaluateDomainAccess({ accountVerified: false, domainId: 'DOM-FIFTH-0001', nodeId: '', nodePossessionVerified: false, nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW);
|
||||
assert.equal(status.localWorkspaceAllowed, true);
|
||||
assert.equal(status.runtimeReady, false);
|
||||
assert.equal(status.stage, 'login-required');
|
||||
});
|
||||
|
||||
test('a code-channel account cannot substitute for verified node registration', () => {
|
||||
const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW);
|
||||
const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodePossessionVerified: false, nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW);
|
||||
assert.equal(status.stage, 'login-required');
|
||||
assert.equal(status.blockers.includes('account_identity_missing'), false);
|
||||
assert.ok(status.blockers.includes('verified_node_registration_missing'));
|
||||
});
|
||||
|
||||
test('a separately verified node is still not a domain runtime session', () => {
|
||||
const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodeRegistrationVerified: true, nodeType: NODE_TYPE }, NOW);
|
||||
const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodePossessionVerified: true, nodeRegistrationVerified: true, nodeType: NODE_TYPE }, NOW);
|
||||
assert.equal(status.stage, 'identity-verified');
|
||||
assert.deepEqual(status.blockers, [
|
||||
'verified_domain_manifest_missing',
|
||||
|
|
@ -170,12 +170,26 @@ test('a separately verified node is still not a domain runtime session', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test('a signed registration without a current private-key proof remains login-required', () => {
|
||||
const status = evaluateDomainAccess({
|
||||
accountVerified: true,
|
||||
domainId: 'DOM-FIFTH-0001',
|
||||
nodeId: 'JD-FD-PRIMARY',
|
||||
nodePossessionVerified: false,
|
||||
nodeRegistrationVerified: true,
|
||||
nodeType: NODE_TYPE,
|
||||
}, NOW);
|
||||
assert.equal(status.stage, 'login-required');
|
||||
assert.ok(status.blockers.includes('verified_node_possession_missing'));
|
||||
});
|
||||
|
||||
test('runtime access requires matching manifest, scoped capability and online receipt', () => {
|
||||
const handoff = parseDomainAccessHandoff(validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW);
|
||||
const status = evaluateDomainAccess({
|
||||
accountVerified: true,
|
||||
domainId: 'DOM-FIFTH-0001',
|
||||
nodeId: 'LOCAL-001',
|
||||
nodePossessionVerified: true,
|
||||
nodeRegistrationVerified: true,
|
||||
nodeType: NODE_TYPE,
|
||||
...handoff,
|
||||
|
|
@ -203,6 +217,7 @@ test('the runtime handoff entry resolves its signer only from a loaded snapshot'
|
|||
accountVerified: true,
|
||||
domainId: 'DOM-FIFTH-0001',
|
||||
nodeId: 'LOCAL-001',
|
||||
nodePossessionVerified: true,
|
||||
nodeRegistrationVerified: true,
|
||||
nodeType: NODE_TYPE,
|
||||
...verified.handoff,
|
||||
|
|
@ -270,6 +285,7 @@ test('mismatched or expired evidence fails closed', () => {
|
|||
domainId: 'DOM-FIFTH-0001',
|
||||
manifest: verifiedHandoff.manifest,
|
||||
nodeId: 'LOCAL-001',
|
||||
nodePossessionVerified: true,
|
||||
nodeRegistrationVerified: true,
|
||||
nodeType: NODE_TYPE,
|
||||
sessionCapability: { capabilityId: 'CAP-001', domainId: 'DOM-FIFTH-0001', expiresAt: NOW, nodeId: 'LOCAL-001', nodeType: NODE_TYPE, scopes: ['domain:enter'] },
|
||||
|
|
@ -343,6 +359,7 @@ test('copying verified-looking fields cannot bypass the in-process verifier boun
|
|||
verifierReceipt: { ...verifiedHandoff.manifest.verifierReceipt },
|
||||
},
|
||||
nodeId: 'LOCAL-001',
|
||||
nodePossessionVerified: true,
|
||||
nodeRegistrationVerified: true,
|
||||
nodeType: NODE_TYPE,
|
||||
sessionCapability: verifiedHandoff.sessionCapability,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export interface DomainAccessEvidence {
|
|||
domainId: string;
|
||||
manifest?: DomainManifestEvidence;
|
||||
nodeId: string;
|
||||
nodePossessionVerified: boolean;
|
||||
nodeRegistrationVerified: boolean;
|
||||
nodeType: DomainNodeType;
|
||||
sessionCapability?: DomainSessionCapability;
|
||||
|
|
@ -244,6 +245,7 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date.
|
|||
const blockers: string[] = [];
|
||||
if (!evidence.accountVerified) blockers.push('account_identity_missing');
|
||||
if (!evidence.nodeRegistrationVerified || !evidence.nodeId) blockers.push('verified_node_registration_missing');
|
||||
if (!evidence.nodePossessionVerified) blockers.push('verified_node_possession_missing');
|
||||
const manifest = evidence.manifest;
|
||||
const verifier = manifest?.verifierReceipt;
|
||||
if (!manifest
|
||||
|
|
@ -285,6 +287,10 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date.
|
|||
nodeId: evidence.nodeId,
|
||||
nodeType: evidence.nodeType,
|
||||
runtimeReady,
|
||||
stage: runtimeReady ? 'runtime-ready' : evidence.accountVerified && evidence.nodeRegistrationVerified && Boolean(evidence.nodeId) ? 'identity-verified' : 'login-required',
|
||||
stage: runtimeReady
|
||||
? 'runtime-ready'
|
||||
: evidence.accountVerified && evidence.nodeRegistrationVerified && evidence.nodePossessionVerified && Boolean(evidence.nodeId)
|
||||
? 'identity-verified'
|
||||
: 'login-required',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||
import {
|
||||
createNodePossessionChallenge,
|
||||
nodePossessionChallengeSigningBytes,
|
||||
verifyNodePossessionResponse,
|
||||
} from './node-possession-proof.js';
|
||||
import {
|
||||
nodeRegistrationClaimSigningBytes,
|
||||
parseNodeRegistrationEndpointRegistry,
|
||||
resolveNodeRegistrationEndpoint,
|
||||
verifyNodeRegistrationClaim,
|
||||
} from './node-registration.js';
|
||||
|
||||
const NOW = 1_786_291_200_000;
|
||||
const DOMAIN_ID = 'DOM-FIFTH-0001';
|
||||
const NODE_ID = 'LOCAL-001';
|
||||
const registrationKeys = generateKeyPairSync('ed25519');
|
||||
const nodeKeys = generateKeyPairSync('ed25519');
|
||||
const nodePublicKeyPem = nodeKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString();
|
||||
const fingerprint = createHash('sha256')
|
||||
.update(nodeKeys.publicKey.export({ format: 'der', type: 'spki' }))
|
||||
.digest('hex');
|
||||
|
||||
function verifiedRegistration() {
|
||||
const registry = parseNodeRegistrationEndpointRegistry({
|
||||
endpoints: [{
|
||||
algorithm: 'Ed25519',
|
||||
domainIds: [DOMAIN_ID],
|
||||
endpointId: 'GH-NODE-REG-001',
|
||||
nodeTypes: ['local-terminal'],
|
||||
publicKeyPem: registrationKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(),
|
||||
signerId: 'GH-NODE-REG-SIGNER-001',
|
||||
status: 'ACTIVE',
|
||||
url: 'https://guanghulab.com/api/ai/v1/node-registrations/claims',
|
||||
}],
|
||||
registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001',
|
||||
schema: 'gh-aios.node-registration-endpoints/v1',
|
||||
state: 'CURRENT',
|
||||
version: '1.0.0',
|
||||
}, {
|
||||
repositoryId: 'REPO-012',
|
||||
sourceCommit: 'a'.repeat(40),
|
||||
sourceUrl: `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${'a'.repeat(40)}/routing/node-registration-endpoints.json`,
|
||||
});
|
||||
const endpoint = resolveNodeRegistrationEndpoint(registry, {
|
||||
domainId: DOMAIN_ID,
|
||||
nodeType: 'local-terminal',
|
||||
});
|
||||
assert.ok(endpoint);
|
||||
const payload = {
|
||||
accountId: 'bingshuo',
|
||||
claimId: 'NODE-CLAIM-001',
|
||||
domainId: DOMAIN_ID,
|
||||
endpointId: endpoint.endpointId,
|
||||
expiresAt: NOW + 120_000,
|
||||
issuedAt: NOW - 1_000,
|
||||
nodeId: NODE_ID,
|
||||
nodeKeyFingerprint: fingerprint,
|
||||
nodeType: 'local-terminal' as const,
|
||||
schema: 'gh-aios.node-registration-claim/v1' as const,
|
||||
signerId: endpoint.signerId,
|
||||
};
|
||||
return verifyNodeRegistrationClaim({
|
||||
...payload,
|
||||
signature: sign(null, nodeRegistrationClaimSigningBytes(payload), registrationKeys.privateKey).toString('base64'),
|
||||
}, {
|
||||
accountId: payload.accountId,
|
||||
domainId: DOMAIN_ID,
|
||||
nodeId: NODE_ID,
|
||||
nodeType: 'local-terminal',
|
||||
}, endpoint, NOW);
|
||||
}
|
||||
|
||||
function responseFor(challenge: ReturnType<typeof createNodePossessionChallenge>) {
|
||||
return {
|
||||
challengeId: challenge.challengeId,
|
||||
publicKeyPem: nodePublicKeyPem,
|
||||
schema: 'gh-aios.node-possession-response/v1',
|
||||
signature: sign(null, nodePossessionChallengeSigningBytes(challenge), nodeKeys.privateKey).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
test('verifies a fresh proof from the key fingerprint bound into the signed registration', () => {
|
||||
const registration = verifiedRegistration();
|
||||
const challenge = createNodePossessionChallenge(
|
||||
registration,
|
||||
NOW,
|
||||
'NODE-CHALLENGE-001',
|
||||
'A'.repeat(43),
|
||||
);
|
||||
const response = responseFor(challenge);
|
||||
assert.equal(verifyNodePossessionResponse(response, challenge, registration, NOW + 1), true);
|
||||
assert.throws(() => verifyNodePossessionResponse(response, challenge, registration, NOW + 2), /proof_invalid/);
|
||||
});
|
||||
|
||||
test('rejects a forged challenge that was not issued by the verifier', () => {
|
||||
const registration = verifiedRegistration();
|
||||
const challenge = {
|
||||
...createNodePossessionChallenge(registration, NOW, 'NODE-CHALLENGE-001', 'A'.repeat(43)),
|
||||
nonce: 'B'.repeat(43),
|
||||
};
|
||||
assert.throws(() => verifyNodePossessionResponse(responseFor(challenge), challenge, registration, NOW + 1), /proof_invalid/);
|
||||
});
|
||||
|
||||
test('rejects a response signed by a different node key', () => {
|
||||
const registration = verifiedRegistration();
|
||||
const challenge = createNodePossessionChallenge(registration, NOW, 'NODE-CHALLENGE-001', 'A'.repeat(43));
|
||||
const otherKeys = generateKeyPairSync('ed25519');
|
||||
const response = {
|
||||
challengeId: challenge.challengeId,
|
||||
publicKeyPem: otherKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(),
|
||||
schema: 'gh-aios.node-possession-response/v1',
|
||||
signature: sign(null, nodePossessionChallengeSigningBytes(challenge), otherKeys.privateKey).toString('base64'),
|
||||
};
|
||||
assert.throws(() => verifyNodePossessionResponse(response, challenge, registration, NOW + 1), /proof_invalid/);
|
||||
});
|
||||
|
||||
test('rejects expired, replayed-to-another-challenge and unknown-field responses', () => {
|
||||
const registration = verifiedRegistration();
|
||||
const first = createNodePossessionChallenge(registration, NOW, 'NODE-CHALLENGE-001', 'A'.repeat(43));
|
||||
const second = createNodePossessionChallenge(registration, NOW, 'NODE-CHALLENGE-002', 'B'.repeat(43));
|
||||
assert.throws(() => verifyNodePossessionResponse(responseFor(first), second, registration, NOW + 1), /proof_invalid/);
|
||||
assert.throws(() => verifyNodePossessionResponse({ ...responseFor(first), extra: true }, first, registration, NOW + 1), /proof_invalid/);
|
||||
assert.throws(() => verifyNodePossessionResponse(responseFor(first), first, registration, NOW + 60_000), /proof_invalid/);
|
||||
});
|
||||
|
||||
test('does not accept a structurally copied registration as verified provenance', () => {
|
||||
const copied = { ...verifiedRegistration() };
|
||||
assert.throws(() => createNodePossessionChallenge(copied, NOW, 'NODE-CHALLENGE-001', 'A'.repeat(43)), /proof_invalid/);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ 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>();
|
||||
const verifiedClaims = new WeakSet<object>();
|
||||
|
||||
export interface NodeRegistrationEndpoint {
|
||||
algorithm: 'Ed25519';
|
||||
|
|
@ -57,6 +58,13 @@ export interface VerifiedNodeRegistration extends NodeRegistrationClaimPayload {
|
|||
verifiedAt: number;
|
||||
}
|
||||
|
||||
export function assertVerifiedNodeRegistration(
|
||||
claim: VerifiedNodeRegistration,
|
||||
): VerifiedNodeRegistration {
|
||||
if (!verifiedClaims.has(claim)) invalidClaim();
|
||||
return claim;
|
||||
}
|
||||
|
||||
export interface NodeRegistrationLookup {
|
||||
domainId: string;
|
||||
nodeType: DomainNodeType;
|
||||
|
|
@ -270,9 +278,11 @@ export function verifyNodeRegistrationClaim(
|
|||
} catch {
|
||||
invalidClaim();
|
||||
}
|
||||
return Object.freeze({
|
||||
const verified = Object.freeze({
|
||||
...payload,
|
||||
signature: input.signature,
|
||||
verifiedAt: now,
|
||||
});
|
||||
verifiedClaims.add(verified);
|
||||
return verified;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue