import { type NodeKeyMaterial } from './group-crypto.js'; import { type GroupStateDocument } from './group-state.js'; import type { ClusterKeyring } from './protocol-envelope.js'; import type { ClusterLogger } from './types.js'; /** The roster file, relative to the cluster state directory. */ export declare const GROUP_STATE_FILENAME = "group-state.json"; /** The replicated settings file, alongside the roster. Public to the group, like it. */ export declare const GROUP_REPLICA_FILENAME = "group-config.json"; /** * The single secrets-store key holding every piece of group key material. * * DERIVED from the daemon-owned config path rather than written out, because * the name is what decides where the value lives. `defaultScopeForKey` files a * secret in the daemon tier exactly when its name is one the daemon's * derivation produces; a hand-written `'cluster.groupMaterial'` matches * nothing it produces, so the group's key material was landing at PROJECT * scope, in whichever directory the daemon happened to start in, outside the * tier holding every other cluster secret. * * Deriving it means the name the daemon recognises and the name actually * written are the same by construction, and cannot drift apart again. */ export declare const GROUP_MATERIAL_SECRET_KEY: string; /** * Bounds on key history. * * 16 generations at the default 24-hour rotation is a fortnight of history, and * 30 days caps it for a node that rotates faster. Neither bound costs anything * a returning machine needs: a member that has been off for a year rejoins by * proving its long-lived IDENTITY key, which never rotates and never expires, * old group keys are a convenience path, not the mechanism. See * group-membership.ts. */ export declare const MAX_KEY_GENERATIONS = 16; export declare const MAX_KEY_AGE_MS: number; /** One generation of the group key. */ export interface GroupKeyRecord { readonly generation: number; readonly key: string; readonly createdAt: number; /** * The node that minted this generation. * * Only one member mints a given rotation, but a network that partitions * mid-rotation can produce two candidates for the same generation. When that * happens every node picks the one from the lexicographically SMALLER node * id, a rule with no dependence on arrival order or clock, so both sides of * a healed partition land on the same key without negotiating. */ readonly mintedBy: string; } /** * Which of two candidate keys for the same generation wins. * * Exported because it is the whole of the partition-rotation tiebreak and is * tested directly. */ export declare function preferredKeyRecord(a: GroupKeyRecord, b: GroupKeyRecord): GroupKeyRecord; /** Everything secret about this node's membership of one group. */ export interface GroupKeyMaterial { readonly version: 1; readonly groupId: string; /** * The root secret, on the node that CREATED the group. Null on a node that * joined: the root's only job is to have produced the group id, which is * already stored, so there is no reason to spread it. */ readonly groupRoot: string | null; readonly joinKey: string; readonly joinSalt: string; readonly joinVerifier: string; readonly keys: readonly GroupKeyRecord[]; readonly currentGeneration: number; /** * Wall-clock ms until which the PREVIOUS generation is still accepted. * * Set on a scheduled rotation, so members that have not yet cut over keep * being heard. Set to 0 on a rotation caused by a REMOVAL, so the machine * that was just ejected stops being heard immediately, which is the entire * point of rotating on removal. */ readonly previousAcceptedUntil: number; readonly node: NodeKeyMaterial; /** * The GROUP's signing key pair, and which generation of it this is. * * Every member holds the private half, so any member can answer a returning * machine as the group rather than as itself. It rotates only on REMOVAL, * not on a scheduled rotation, because its whole job is to be verifiable by * a machine holding a public key from months ago, and rotating it daily would * make that impossible for no gain. */ readonly groupSigning: GroupSigningMaterial; } /** The group's signing key pair at one generation. */ export interface GroupSigningMaterial { readonly publicKey: string; readonly privateKey: string; readonly generation: number; } /** Validate one key record. Exported so the wire path reuses exactly this check. */ export declare function readKeyRecord(value: unknown): GroupKeyRecord | null; /** Validate a stored group signing key pair. */ export declare function readGroupSigningMaterial(value: unknown): GroupSigningMaterial | null; /** * Parse stored key material. * * Returns null rather than a partly-filled object on anything unexpected. A * half-valid key blob is not something to work around: the node has no usable * membership, and saying so plainly (`cluster status` reports it, and the fix * is to join again) beats limping along signing with a key nobody accepts. */ export declare function readGroupKeyMaterial(value: unknown): GroupKeyMaterial | null; export interface KeyHistorySweepResult { readonly keys: readonly GroupKeyRecord[]; readonly dropped: number; } /** * Bound the key history. * * The current generation and the one before it are ALWAYS kept regardless of * age, dropping either would break the acceptance window and cause exactly the * spurious elections the window exists to prevent. */ export declare function sweepKeyHistory(keys: readonly GroupKeyRecord[], currentGeneration: number, now: number): KeyHistorySweepResult; /** * The keyring the envelope codec signs and verifies with. * * Reads through to whatever material the store currently holds, so a rotation * that replaces the material is picked up by the very next datagram without * anything having to be re-wired. */ export declare class GroupKeyring implements ClusterKeyring { private readonly readMaterial; private readonly now; constructor(readMaterial: () => GroupKeyMaterial, now: () => number); get groupId(): string; get currentGeneration(): number; keyForGeneration(generation: number): string | null; /** * The current generation, plus the previous one while the cutover window is * open. A removal closes the window immediately by setting * `previousAcceptedUntil` to 0, so the ejected machine's key stops verifying * on the same tick the tombstone is written. */ acceptedGenerations(): readonly number[]; /** Every generation still held, for checking an old-key proof from a returning node. */ heldGenerations(): readonly number[]; } /** * The slice of the encrypted secrets store this module needs. * * Narrow on purpose: `SecretsManager` satisfies it structurally, and a test can * satisfy it with a Map. Nothing here should know about secret scopes, policy * modes or file layout. */ export interface ClusterSecretStore { get(key: string): Promise; set(key: string, value: string): Promise; delete(key: string): Promise; } /** Read this node's group key material, or null when it is not in a group. */ export declare function loadGroupKeyMaterial(secrets: ClusterSecretStore, logger?: ClusterLogger): Promise; /** Persist key material. Every write goes through here so nothing else formats it. */ export declare function saveGroupKeyMaterial(secrets: ClusterSecretStore, material: GroupKeyMaterial): Promise; /** Forget this node's membership entirely. */ export declare function clearGroupKeyMaterial(secrets: ClusterSecretStore): Promise; /** Mint the key material for a brand-new group. */ export declare function createGroupKeyMaterial(input: { readonly groupId: string; readonly groupRoot: string; readonly joinKey: string; readonly joinSalt: string; readonly joinVerifier: string; readonly nodeId: string; readonly now: number; }): GroupKeyMaterial; /** Key material for a node that has proved itself and is being handed the group. */ export declare function joiningGroupKeyMaterial(input: { readonly groupId: string; readonly joinKey: string; readonly joinSalt: string; readonly joinVerifier: string; readonly keys: readonly GroupKeyRecord[]; readonly currentGeneration: number; readonly node: NodeKeyMaterial; readonly groupSigning: GroupSigningMaterial; readonly now: number; readonly graceMs: number; }): GroupKeyMaterial; /** Why a rotation happened, and therefore whether the old key stays acceptable. */ export type RotationCause = 'scheduled' | 'revocation'; /** * Advance to a new generation. * * `scheduled` opens the acceptance window for `graceMs`, so nobody's heartbeat * is dropped mid-cutover. `revocation` opens nothing: the previous key is * refused from this instant, which is what stops the machine that was just * removed from being heard. */ export declare function rotateGroupKeyMaterial(material: GroupKeyMaterial, cause: RotationCause, nodeId: string, now: number, graceMs: number): GroupKeyMaterial; /** * Adopt keys handed over by another member, on join, on a re-key, or on the * rotation announcement that follows a scheduled rotation. * * Two candidates for the SAME generation are resolved by * {@link preferredKeyRecord}, never by arrival order, so a partition that * produced two rotations converges on one key when it heals. * * The acceptance window is opened here too: a node that immediately started * refusing the generation its peers are still finishing a cutover on would drop * the very heartbeats it needs. */ export declare function adoptGroupKeys(material: GroupKeyMaterial, incoming: readonly GroupKeyRecord[], currentGeneration: number, now: number, graceMs: number): GroupKeyMaterial; /** * Read the roster, swept and validated. * * A missing file is the normal first-run case. An unreadable or malformed one * is NOT fatal: the roster is replicated, so an empty document re-converges * from the first gossip the node hears. Losing it costs a round trip; refusing * to start would cost inbound messaging. */ export declare function loadGroupState(stateDirectory: string, groupId: string, now: number, logger?: ClusterLogger): GroupStateDocument; /** * Read the replicated settings document. * * Content-validated through the caller's policy filter, so a file edited by * hand, or written by an older build with a wider policy, cannot smuggle a * node-local key into this machine's config on the next start. */ export declare function loadReplicaDocument(stateDirectory: string, parse: (value: unknown) => T | null, logger?: ClusterLogger): T | null; /** Persist the replicated settings document. Never throws: it re-converges by gossip. */ export declare function saveReplicaDocument(stateDirectory: string, document: unknown, logger?: ClusterLogger): void; /** Persist the roster. Failure is logged, never thrown: it re-converges by gossip. */ export declare function saveGroupState(stateDirectory: string, state: GroupStateDocument, logger?: ClusterLogger): void; //# sourceMappingURL=group-store.d.ts.map