diff --git a/broker/access-registry.test.ts b/broker/access-registry.test.ts index 2925d97..d12260a 100644 --- a/broker/access-registry.test.ts +++ b/broker/access-registry.test.ts @@ -264,3 +264,76 @@ test("recursive revoke increments generations and invalidates reconnect", () => f.close(); } }); + +test("revoke cancels pending enrollments throughout the subtree and persists the cancellation", () => { + const f = fixture(); + try { + const parent = f.registry.consumeEnrollment(f.registry.issueEnrollment({ + ...template, canDelegate: true, maxDepth: 3, maxChildren: 2, + }).enrollmentToken); + const child = f.registry.consumeEnrollment(f.registry.issueChildEnrollment(parent.principal.id, 1, { + name: "child", canDelegate: true, maxDepth: 3, maxChildren: 1, + }).enrollmentToken); + const pendingChild = f.registry.issueChildEnrollment(parent.principal.id, 1, { name: "pending-child" }); + const pendingGrandchild = f.registry.issueChildEnrollment(child.principal.id, 1, { name: "pending-grandchild" }); + const unrelated = f.registry.issueEnrollment({ ...template, name: "unrelated" }); + + f.registry.revoke(parent.principal.id); + assert.equal(Object.keys(f.registry.snapshot().enrollments).length, 1); + const reloaded = new RemoteAccessRegistry(join(f.root, "broker-access.json"), () => 1_800_000_000_000); + for (const registry of [f.registry, reloaded]) { + for (const pending of [pendingChild, pendingGrandchild]) { + assert.throws( + () => registry.consumeEnrollment(pending.enrollmentToken), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_ENROLLMENT", + ); + } + } + assert.equal(reloaded.consumeEnrollment(unrelated.enrollmentToken).principal.name, "unrelated"); + } finally { + f.close(); + } +}); + +test("expiry reconciliation cancels outstanding child enrollment tokens", () => { + const f = fixture(); + try { + const parent = f.registry.consumeEnrollment(f.registry.issueEnrollment({ + ...template, expiresAt: 1_800_000_001_000, canDelegate: true, maxDepth: 2, maxChildren: 1, + }).enrollmentToken); + const pending = f.registry.issueChildEnrollment(parent.principal.id, 1, { name: "pending-child" }); + f.advance(1001); + f.registry.expirePrincipals(); + assert.throws( + () => f.registry.consumeEnrollment(pending.enrollmentToken), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_ENROLLMENT", + ); + assert.equal(Object.keys(f.registry.snapshot().enrollments).length, 0); + } finally { + f.close(); + } +}); + +test("redemption rejects tokens retained by an older registry after parent revocation", () => { + const f = fixture(); + try { + const parent = f.registry.consumeEnrollment(f.registry.issueEnrollment({ + ...template, canDelegate: true, maxDepth: 2, maxChildren: 1, + }).enrollmentToken); + const pending = f.registry.issueChildEnrollment(parent.principal.id, 1, { name: "pending-child" }); + const legacyEnrollments = f.registry.snapshot().enrollments; + f.registry.revoke(parent.principal.id); + const state = f.registry.snapshot(); + state.enrollments = legacyEnrollments; + const path = join(f.root, "broker-access.json"); + writeFileSync(path, JSON.stringify(state)); + const reloaded = new RemoteAccessRegistry(path, () => 1_800_000_000_000); + assert.throws( + () => reloaded.consumeEnrollment(pending.enrollmentToken), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_ENROLLMENT", + ); + assert.equal(Object.keys(new RemoteAccessRegistry(path).snapshot().enrollments).length, 0); + } finally { + f.close(); + } +}); diff --git a/broker/access-registry.ts b/broker/access-registry.ts index 6093acd..bbf0bb3 100644 --- a/broker/access-registry.ts +++ b/broker/access-registry.ts @@ -299,6 +299,13 @@ export class RemoteAccessRegistry { this.persist(); throw new RemoteAccessError("INVALID_ENROLLMENT", "Enrollment credential has expired"); } + // Also reject tokens left behind by older registries before revocation + // cancelled pending enrollments. Local parents are not stored here. + const parent = this.state.principals[enrollment.template.parentSessionId]; + if (parent && (parent.state !== "active" || parent.expiresAt <= now)) { + this.persist(); + throw new RemoteAccessError("INVALID_ENROLLMENT", "Enrollment parent is no longer active"); + } const sessionCredential = newSecret(); const id = randomUUID(); const principal: RemotePrincipalRecord = { @@ -419,6 +426,10 @@ export class RemoteAccessRegistry { if (candidate.parentSessionId === id) queue.push(candidate.id); } } + const revokedIds = new Set(changed.map((principal) => principal.id)); + for (const [hash, enrollment] of Object.entries(this.state.enrollments)) { + if (revokedIds.has(enrollment.template.parentSessionId)) delete this.state.enrollments[hash]; + } if (changed.length) this.persist(); return changed; } diff --git a/dist/broker.mjs b/dist/broker.mjs index ebbc084..91599b9 100644 --- a/dist/broker.mjs +++ b/dist/broker.mjs @@ -475,6 +475,11 @@ var RemoteAccessRegistry = class { this.persist(); throw new RemoteAccessError("INVALID_ENROLLMENT", "Enrollment credential has expired"); } + const parent = this.state.principals[enrollment.template.parentSessionId]; + if (parent && (parent.state !== "active" || parent.expiresAt <= now)) { + this.persist(); + throw new RemoteAccessError("INVALID_ENROLLMENT", "Enrollment parent is no longer active"); + } const sessionCredential = newSecret(); const id = randomUUID2(); const principal = { @@ -587,6 +592,10 @@ var RemoteAccessRegistry = class { if (candidate.parentSessionId === id) queue.push(candidate.id); } } + const revokedIds = new Set(changed.map((principal) => principal.id)); + for (const [hash, enrollment] of Object.entries(this.state.enrollments)) { + if (revokedIds.has(enrollment.template.parentSessionId)) delete this.state.enrollments[hash]; + } if (changed.length) this.persist(); return changed; }