/** * group-admissions.ts, the JOIN and REJOIN exchange, both ends of it. * * Split out of the runtime because it is the part with the security property in * it: everything here either decides whether a machine may enter the group, or * hands the group's secrets to one that may. Keeping it in one file means the * whole of that decision fits on a screen or two and can be read as a unit. * * The runtime owns the socket and the key material; this service borrows both * through {@link AdmissionHost} and owns nothing persistent of its own except * the single in-flight request this node is waiting on. */ import { type NodeKeyMaterial } from './group-crypto.js'; import { type AdmissionGrant } from './group-membership.js'; import { type GroupStateDocument } from './group-state.js'; import type { GroupKeyMaterial } from './group-store.js'; import type { ClusterClock, ClusterLogger } from './types.js'; /** What the admission service borrows from the runtime. */ export interface AdmissionHost { readonly nodeId: string; readonly version: string; readonly nodeDisplayName: string; readonly clock: ClusterClock; readonly logger: ClusterLogger; material(): GroupKeyMaterial | null; state(): GroupStateDocument | null; commitState(state: GroupStateDocument, gossip: boolean): Promise; send(raw: string): Promise; nextSeq(): number; } /** * Why an admission request failed, when the caller must act differently. * * - `unanswered`, nobody replied. The ordinary case for a machine that booted * before its peers, and it resolves itself when they come up. * * - `refused`, a member said no AND proved it was a member when it said so. * This is final: the machine is out, waiting changes nothing, and the * operator has to put it back by hand. Only an AUTHENTICATED refusal reaches * this, which is what stops anything on the network from talking a machine * out of its own group by shouting at it. * * - `unverifiable-replies`, replies arrived and none of them could be * authenticated. Deliberately NOT final, because this machine genuinely * cannot tell the two possible causes apart: it may have been away across a * removal and no longer recognise any current member, or a stranger may be * sending it noise. It is worth telling the operator about and it is not * worth asserting a removal over. * * - `not-sent`, the request never left this machine. */ export type AdmissionFailure = 'unanswered' | 'refused' | 'unverifiable-replies' | 'not-sent'; export type AdmissionOutcome = { readonly ok: true; readonly grant: AdmissionGrant; readonly node: NodeKeyMaterial; } | { readonly ok: false; readonly reason: string; readonly failure: AdmissionFailure; }; export declare class GroupAdmissionService { private readonly host; private pending; constructor(host: AdmissionHost); /** Fail an in-flight request, on shutdown, or when its deadline passes. */ expire(now: number, reason: string): void; abandon(reason: string): void; /** Route a datagram the group layer could not authenticate with a group key. */ handle(raw: string, type: string): Promise; private onJoinRequest; /** * A machine that was already a member is coming back. * * Its identity key comes from THE ROSTER, never from the datagram. That is * the whole difference between "prove you are node X" and "assert you are * node X", and rule 2 of `decideAdmission` depends on it entirely: a node id * that is not on the roster has no key to check against, so there is nothing * an unknown machine can present here that gets it in. */ private onRejoinRequest; private refuse; /** * Tell a returning machine, in a way it can actually verify, that it is out. * * Signed with THIS machine's identity key rather than the group key. The * recipient cannot check the group key, a removal rotated it and that is * precisely why it is being refused, but it can check this machine against * the roster it stored before it went away, and this machine was on it. * * It carries no secret and grants nothing. The worst a forged one can do is * make a machine give up early and tell its operator to run `cluster join`, * and a forgery cannot even do that: it is dropped unless it verifies. */ private refuseRejoin; /** * Hand the group over to a machine that has proved itself. * * Every secret in the grant is sealed to that one machine's agreement key, so * although the datagram goes to the whole network exactly one recipient can * read it. */ private grant; /** Broadcast a JOIN and wait for the group to answer. */ requestJoin(input: { readonly groupId: string; readonly joinKey: string; readonly joinSalt: string; readonly timeoutMs: number; }): Promise; /** * Broadcast a REJOIN, the zero-touch return. * * A machine that has been switched off long enough to have missed every group * key rotation, and a join-key change on top, sends this when it starts and * is back in the group without the operator doing anything. It proves only * that it is itself; the group decides whether that is enough, and it is * enough only because the node id is already on the roster. */ requestRejoin(timeoutMs: number): Promise; private onJoinReply; /** * The reply to a REJOIN. * * There is NO path here that accepts a reply on the strength of the seal * alone. The seal gives confidentiality, only this machine can read the * grant, and says nothing whatever about who sent it, so accepting on it * would let anything on the network hand a returning machine a group key * nobody accepts and keep it out of its own group indefinitely. * * Two authenticators are accepted, and both are things this machine held * BEFORE it went away: * * - the GROUP's signing key, which any current member can sign with. This * is the normal path and it works no matter who answers, including a * machine admitted while this one was switched off; * * - failing that, the identity key of a responder that is still on the * roster this machine stored. This covers the one case the group signing * key cannot: a REMOVAL happened while this machine was away, so the * group signing key rotated and the copy here is a generation behind. * * When neither matches, the reply is dropped and the request times out with a * message naming `cluster join`. That is the honest residual: a machine that * was away across a removal AND no longer recognises any current member has * nothing left to trust, and being told to re-join with the current key is * the correct answer rather than trusting an unauthenticated reply. */ private onRejoinReply; /** * A member said no, and proved it was a member when it said so. * * Settled immediately and marked terminal rather than waiting out the * timeout: the answer is not going to change, and the difference between * "wait, someone may still come up" and "you are out, run `cluster join`" is * the entire point of telling the operator anything at all. * * Authenticated through the same two-key check the acceptance path uses. An * unverifiable refusal is counted, not obeyed, so a stranger cannot talk a * machine out of its own group. */ private onRejoinRefused; /** Group signing key first, then a remembered member's identity key. Never neither. */ private rejoinReplyIsAuthentic; private await; private settle; } //# sourceMappingURL=group-admissions.d.ts.map