Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions broker/access-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
11 changes: 11 additions & 0 deletions broker/access-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions dist/broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
}
Expand Down