import { type NodeKeyMaterial, type NodeKeyPairMaterial, type WrappedKeyEnvelope } from './group-crypto.js'; import { type GroupStateDocument } from './group-state.js'; import { type GroupKeyRecord, type GroupSigningMaterial } from './group-store.js'; import { type ClusterEnvelope, type EnvelopeDraft } from './protocol-envelope.js'; /** Every message type this layer speaks. */ export declare const GROUP_MESSAGE_TYPES: { readonly beacon: "BEACON"; readonly roster: "ROSTER"; readonly rekey: "REKEY"; readonly join: "JOIN"; readonly joinAccept: "JOIN_ACCEPT"; readonly joinRefuse: "JOIN_REFUSE"; readonly rejoin: "REJOIN"; readonly rejoinAccept: "REJOIN_ACCEPT"; /** * An explicit "no" to a returning machine. * * Signed with the REFUSER'S identity key, not the group key. A machine that * was away across a removal cannot verify the current group key by * definition, but it can verify any member that was on the roster it stored * before it left, which is exactly who is answering. Without this message a * refused return is simply silence, and the returning machine waits out its * whole timeout only to be told, wrongly, that nobody answered. */ readonly rejoinRefuse: "REJOIN_REFUSE"; }; /** True when this type is authenticated by something other than the group key. */ export declare function isOutOfBandMessageType(type: string): boolean; /** * How stale an admission request may be. * * Bounds replay: a JOIN captured off the wire cannot be re-sent tomorrow. Wide * enough that two homelab machines whose clocks have drifted by a minute still * talk to each other, since neither is running NTP as a precondition. */ export declare const ADMISSION_FRESHNESS_MS: number; export type AdmissionRefusal = 'join-key-did-not-match' | 'identity-did-not-match' | 'not-on-the-roster' | 'removed-from-the-group' | 'request-is-stale' | 'group-is-full'; export type AdmissionDecision = { readonly admit: true; readonly path: 'join' | 'rejoin'; } | { readonly admit: false; readonly reason: AdmissionRefusal; }; export interface AdmissionRequest { readonly nodeId: string; readonly ts: number; readonly now: number; /** True when the request carried a valid proof of the CURRENT join key. */ readonly provedCurrentJoinKey: boolean; /** * True when the request carried a valid proof of a HISTORICAL key, the * node's own long-lived identity key, or a group key from an earlier * generation. Both mean the same thing: "this machine was in this group * before". */ readonly provedHistoricalKey: boolean; } /** * Decide whether a machine may come in. * * THE RULE, and the reasoning, which must survive anybody later deciding this * is more complicated than it needs to be: * * 1. A valid proof of the CURRENT join key admits anyone. That is what the * join key is for, and it is the only way a machine the group has never * seen can be added. * * 2. A valid proof of ANY HISTORICAL key admits a node IF AND ONLY IF its node * id is already on the roster. The machine is then re-keyed to the current * generation immediately. * * Why the roster condition is not optional: an old group key is a secret * that leaks with time. It sat on a disk that was retired, in a backup, on * a machine that was sold. If an old key ALONE were sufficient, then every * key the group has ever used would remain a permanent way in and rotating * would accomplish nothing. Requiring roster presence means an old key can * only ever re-admit a machine the operator ALREADY decided belongs, it * grants no new membership, so its leak grants no access. * * Why it is worth having at all: it is what makes a machine that has been * off for six months, through dozens of group-key rotations and a join-key * change, come back by itself, with the operator doing nothing. Without * it, every power cut on a homelab node ends in an SSH session. * * 3. A REMOVED node, one with a tombstone, is refused on the historical-key * path, always. That is the path a partitioned peer or an old disk takes, * and neither may bring back a machine the operator ejected. * * It is NOT refused on the current-join-key path. Presenting the current * join key is the operator deliberately putting the machine back, using a * secret only members hold, and refusing that would mean a removal made by * mistake permanently bans that node id with no way back short of deleting * its identity file. The re-admission writes an add ABOVE the tombstone and * clears it, so the partition property in rule 3's first paragraph is * untouched: a stale add still loses, because a stale add is not a join. * * Do not merge cases 1 and 2 into "any valid proof admits". That single change * would turn every retired disk in the house into a permanent group * credential. */ export declare function decideAdmission(state: GroupStateDocument, request: AdmissionRequest, maxMembers: number): AdmissionDecision; /** Plain-language refusal text, naming what the operator should do about it. */ export declare function describeRefusal(reason: AdmissionRefusal): string; /** What a joining machine tells the group about itself. */ export interface JoinRequestBody { readonly displayName: string; readonly identityKey: string; readonly agreementKey: string; } /** What a returning member tells the group. */ export interface RejoinRequestBody extends JoinRequestBody { /** Group-key generations this node still holds, newest first. Advisory only. */ readonly heldGenerations: readonly number[]; } /** The secret half of an acceptance, sealed to exactly one recipient. */ export interface AdmissionGrant { readonly joinKey: string; readonly joinSalt: string; readonly joinVerifier: string; readonly keys: readonly GroupKeyRecord[]; readonly currentGeneration: number; readonly state: GroupStateDocument; /** * The group's signing key pair. Handed over so this machine can answer a * returning member AS THE GROUP, and so it holds the public half to check * such an answer with if it is ever the one coming back. */ readonly groupSigning: GroupSigningMaterial; } /** Parse and validate a JOIN body. */ export declare function parseJoinRequestBody(body: Record): JoinRequestBody | null; /** Parse and validate a REJOIN body. */ export declare function parseRejoinRequestBody(body: Record): RejoinRequestBody | null; /** Validate a sealed grant that has already been opened and JSON-parsed. */ export declare function readAdmissionGrant(value: unknown): AdmissionGrant | null; /** Build a JOIN / JOIN_ACCEPT / JOIN_REFUSE datagram, signed with the join verifier. */ export declare function encodeJoinClassMessage(draft: EnvelopeDraft, groupId: string, joinVerifier: string): string; /** * Build a REJOIN / REJOIN_ACCEPT datagram, signed with an ed25519 key pair. * * A REJOIN is signed with the requesting node's own identity key, because the * roster is where the group looks it up. A REJOIN_ACCEPT is signed with the * GROUP's signing key, because the returning machine's roster is stale and the * only thing it can be sure of is what the group looked like when it left. */ export declare function encodeIdentityClassMessage(draft: EnvelopeDraft, groupId: string, signer: NodeKeyPairMaterial): string; export interface OutOfBandCheck { readonly envelope: ClusterEnvelope; /** True when the join-verifier signature matched. */ readonly joinKeyProved: boolean; /** True when the sender's identity signature matched the key supplied. */ readonly identityProved: boolean; } /** * Check a join-class datagram against the group's CURRENT join verifier. * * Returns null when the datagram is not a join-class message at all. A * join-class datagram whose signature does not match comes back with * `joinKeyProved: false`, and the caller refuses it out loud rather than * silently, a mistyped join key is the single most likely thing to go wrong * here, and silence would leave the operator with no idea why nothing happened. */ export declare function checkJoinClassMessage(raw: string, joinVerifier: string): OutOfBandCheck | null; /** * Check an identity-class datagram against a public key. * * The caller supplies the key, and where it got it IS the security property: * for a REJOIN it comes from the ROSTER, never from the datagram itself. A * datagram carrying its own identity key and being checked against that key * proves nothing whatsoever. */ export declare function checkIdentityClassMessage(raw: string, identityPublicKey: string): OutOfBandCheck | null; /** Read an identity-class datagram's envelope without checking anything. */ export declare function peekIdentityClassMessage(raw: string): ClusterEnvelope | null; /** Seal a grant so only the holder of `recipientAgreementKey`'s private half reads it. */ export declare function sealGrant(grant: AdmissionGrant, recipientAgreementKey: string, path: 'join' | 'rejoin'): WrappedKeyEnvelope; /** Open a grant sealed by {@link sealGrant}. Null on any failure at all. */ export declare function openGrant(node: NodeKeyMaterial, sealed: unknown, path: 'join' | 'rejoin'): AdmissionGrant | null; //# sourceMappingURL=group-membership.d.ts.map