/** * gitvault — the principal keystore (protocol rev 41 §5.1; task 5.2). * * Layout (under the active profile's config dir, beside the project-key cache * `core/src/keystore.ts` manages — `RUN402_CONFIG_DIR` / `RUN402_WALLET` aware): * * /gitvault/ * identity.json Ed25519 seed + X25519 private key (0600) * repos/.json K_repo, trust pin, dual pins, last ref transaction (0600) * repos/.lock/ per-repo lock (mkdir-atomic, 0700) * receipts/.recovery_receipt.json the recovery receipt (NOT a secret; more copies better) * journal/.json six-stage creation journals (task 5.3) * audit.log append-only JSON lines (0600) * * Discipline (spec: "Writes SHALL be atomic with per-repo locks, no-follow * creation, and permission audits"): directories 0700, files 0600, every * write = temp file (O_NOFOLLOW|O_EXCL) + fsync + rename + directory fsync, * and `auditKeystorePermissions()` runs at open so a world-readable identity * is a named finding, never silent. * * Transitions (protocol §5.1 partial-loss table) are DERIVED by * {@link GitvaultKeystore.assess} and acted on by explicit methods — the * keystore never guesses: * - repo file lost + identity intact → `restoreRepoFromEnvelope` (own envelope) * - signing key lost, K_repo held → `read_only` (clear report) * - stale pin → `stale_pin` (re-verify from genesis; 5.4) * - whole keystore lost → `VAULT_UNRECOVERABLE` (§0, verbatim) */ import type { GitvaultEncryptionKeypair, GitvaultKeyEnvelope, GitvaultRecoveryReceipt, GitvaultSigningKeypair, GitvaultVaultGenesis } from "../namespaces/gitvault.types.js"; /** `identity.json` — the principal's two private keys. Either half MAY be absent after partial loss. */ export interface GitvaultIdentityFile { version: 1; /** Ed25519 32-byte seed, lowercase hex. Absent ⇒ the signing key is lost (read-only principal). */ signing_seed_hex?: string; /** Raw Ed25519 public key, base64url. */ signing_pubkey: string; /** `vk_` fingerprint. */ signing_fingerprint: string; /** X25519 32-byte private key, lowercase hex. Absent ⇒ no envelope can be opened. */ encryption_private_key_hex?: string; /** Raw X25519 public key, base64url. */ encryption_pubkey: string; /** `ek_` fingerprint. */ encryption_fingerprint: string; /** * The control-plane principal this identity ENROLLED as (consult round 2 * §9e) — persisted after the first successful authenticated enrollment so a * later run under a DIFFERENT profile fails loudly instead of silently * reusing another principal's custody identity. Absent on identities that * have never enrolled. */ enrolled_principal_id?: string; created_at: string; } /** The dual pins (protocol §6.4): the newest authenticated head and its generation. */ export interface GitvaultHeadPin { generation: string; head_sha256: string; pinned_at: string; } /** `repos/.json` — everything per-vault the principal must not lose. */ export interface GitvaultRepoFile { version: 1; repo_id: string; org_id: string; project_id: string; /** Raw 32-byte secret for {@link GitvaultRepoFile.epoch} (`K_repo`/`K_1` pre-rotation, `K_e` after one), lowercase hex. */ k_repo_hex: string; /** The current epoch — `0000000000000001` until this vault's first rotation (D194, rev 42); advances on every rotation this principal drove or opened its own envelope for. */ epoch: string; /** * D194/D202, rev 42: every symmetric epoch key this principal has EVER * locally held, keyed by 16-hex epoch — the substrate `checkFreshEpochKeyAgainstPriorKeys` * (D195's inequality obligation) and the full-history-on-join branch (a) * (`FULL_VIA_EPOCH_KEYS`) both need. `epoch` -> `k_repo_hex` above is * ALWAYS mirrored here too (an entry for the current epoch always * exists), so this map alone is sufficient for both purposes — a reader * never needs to special-case "the current one lives elsewhere." * Absent/`null` for a pre-rotation vault (equivalent to the single * `{[epoch]: k_repo_hex}` entry). */ epoch_keys?: Record | null; /** Trust pin: the admitted genesis stored-bytes hash. */ genesis_sha256: string; /** `highest_authenticated` (§6.4): chain-verified; a listing below it is `GENERATION_REGRESSION`. `null` until the first head is authenticated. */ head_pin: GitvaultHeadPin | null; /** `highest_materialized` (§6.4): decrypted + applied — the ONLY push base. Absent/`null` ⇒ never materialized. */ materialized_pin?: GitvaultHeadPin | null; /** The verified-prefix watermark of a budget-interrupted verification (§9.3 — resumable). Absent/`null` when none is pending. */ verified_prefix?: GitvaultHeadPin | null; /** * gitvault-clone-scaling (bench P3): the newest checkpoint coverage this * checkout has LOCALLY learned — from pushing a checkpoint-form head, * walking past a checkpoint head, a genesis-anchored walk that saw none * (proving coverage = genesis), or a restore's checkpoint apply. Feeds * the checkpoint-staleness advisory ONLY; absent/`null` means coverage * is unknown on this checkout and the advisory stays silent — it is * never protocol state and never consulted by verification or restore. */ checkpoint_covers_through?: string | null; /** * gitvault-object-host-predial (design D1): the object-store ORIGINS * (`scheme://host` — never a path, query, key, or credential) this * checkout has observed a presigned or edge URL actually served bytes * from, most-recently-observed first, deduped, capped at * {@link GITVAULT_OBJECT_STORE_ORIGINS_CAP}. Same locally-learned, * monotonic-in-usefulness pattern as {@link checkpoint_covers_through}: * absent/`null`/`[]` means nothing is known yet (a first-ever session, * byte-identical to before this field existed) and is never protocol * state — it feeds the connection prewarm ONLY (`predialGitvaultObjectStore` * in `gitvault-prewarm.ts`), never verification, never a source of truth * for where an object actually lives. A stale entry (bucket migrated) * costs at most one harmless background dial and is overwritten by the * next observed fetch. */ object_store_origins?: string[]; /** * TOFU pins for {@link GitvaultVault.reconcileEnvelopeRecipients} * (gitvault-human-envelopes design D4 point 3): `principal_id ->` the * `ek_` fingerprint this repo last wrapped a `key_envelope` for (or * observed already covering the vault). A later reconcile whose org * directory reports a DIFFERENT fingerprint for the same `principal_id` * refuses to wrap under it — a pinned-key change is a refusal, never a * silent re-wrap. Absent/`null` until reconcile has run at least once. */ envelope_recipient_pins?: Record | null; /** * The MOST RECENT `recipient_pin_manifest` this principal itself built, * signed, and successfully admitted (D197) — via {@link * GitvaultVault.publishPinManifestUpdate} or {@link * GitvaultVault.rotateEpoch}'s `pending_confirmations` fold. Read-side * short-circuit ONLY: {@link GitvaultVault} `readPinManifestObject` * consults this before a network `object-reads` round trip, and uses it * ONLY when `pin_manifest_version` + `stored_bytes_sha256` match the * receipt it is resolving — never as a substitute for verification of a * manifest this principal did not itself author (a version/hash mismatch * falls through to the network path unchanged). Safe to skip the network * fetch + re-verify-own-signature step in the match case: those exact * bytes were built with `this.signer()` moments earlier and the upload * already round-tripped a checksum-verified PUT, so re-fetching and * re-checking our own signature over our own just-authored bytes proves * nothing a network failure couldn't ALSO independently fail to prove. * Absent/`null` until this principal has published its first manifest. */ known_pin_manifest?: { pin_manifest_version: string; stored_bytes_sha256: string; pins: { principal_id: string; ek_fingerprint: string; }[]; } | null; /** * gitvault-multi-writer rev 47 (protocol §4.15) — the writer-set analog of * {@link head_pin}: the chain-replayed writer state as of THIS repo's own * `head_pin`/`verified_prefix` generation, persisted so `GitvaultVault.verifyToNewest` * never has to re-walk the writer set from genesis on every call. Advances * in lockstep with `head_pin`/`verified_prefix` — always at the SAME * generation, never ahead or behind. `null` until the first `verifyToNewest` * call on this checkout (equivalent to the genesis-only singleton, which * that call derives for free without needing this field yet). */ writer_set_pin?: { version: string; sha256: string; writers: { writer_key_id: string; signing_pubkey: string; }[]; retired_writers?: { writer_key_id: string; signing_pubkey: string; }[]; pinned_at: string; } | null; /** * gitvault-multi-writer rev 47 — THIS principal's own relationship to the * chain-verified `writer_set_pin` above, as of the last time it was * computed (`repos view`/`doctor`/reconcile — task 5.7). `"pending"`: a * claimed handoff or a directory publish is waiting on the actual * `add_writer_key` push (see `pending_writer_admission` below). * `"active"`: this principal's own signing key IS in `writer_set_pin.writers`. * `"not_admitted"`: an active, eligible org member whose key has never * been added. `"removed"`: a key that WAS a writer and was chain-removed * (permanently — see `writer-state.ts`'s `burnedWriterKeyIds`). `null` * until first computed (e.g. a checkout with no local signing key at all). */ writer_status?: "pending" | "active" | "not_admitted" | "removed" | null; /** * gitvault-multi-writer rev 47 (task 5.6) — set by `resume()` the moment a * handoff claim response's `writer_admission_grant` is verified and * persisted, BEFORE the recipient's own `add_writer_key{"handoff"}` * activation push lands on-chain; cleared once that push is admitted * (`writer_status` flips to `"active"` at the same time). Surviving this * field across a crash is exactly what makes the activation push * idempotently resumable (task 5.6's "crash replay") — the grant + the * writer_key_id it names are the only state a retry needs, and both are * already locally held (the grant was echoed verbatim in the claim * response; nothing here is ever re-derived from a network call). */ pending_writer_admission?: { handoff_id: string; writer_admission_grant: Record; claimed_writer_key_id: string; } | null; /** The last ref transaction this principal published (5.4 fills it). */ last_ref_transaction: Record | null; /** How this file came to exist — creation, a §5.1 restore from the principal's own envelope, or a bearer-key restore (`resume`/`join`, kygit-handoff/kygit-invite). */ provenance: "created" | "restored_from_envelope" | "restored_from_handoff" | "restored_from_invite"; updated_at: string; } /** One `audit.log` line. */ export interface GitvaultAuditEntry { at: string; event: GitvaultAuditEvent; repo_id?: string; details?: Record; } export type GitvaultAuditEvent = "keystore_opened" | "identity_created" | "identity_principal_bound" | "repo_saved" | "repo_restored_from_envelope" | "recovery_receipt_stored" | "permission_finding" | "lock_acquired" | "lock_released" | "lock_stale_reclaimed" | "transition_assessed" | "journal_stage" | "epoch_rotation_recorded"; /** A permission-audit finding (never fatal by default; surfaced to doctor). */ export interface GitvaultPermissionFinding { path: string; problem: "world_or_group_accessible" | "symlink" | "not_owned_by_user" | "missing"; mode?: string; } /** The §5.1 partial-loss transition the keystore is in for one vault. */ export type GitvaultKeystoreState = { state: "ready"; repo: GitvaultRepoFile; } | { state: "repo_state_lost_identity_intact"; next_action: "restore_from_envelope"; } | { state: "read_only"; repo: GitvaultRepoFile; reason: "signing_key_lost"; } | { state: "stale_pin"; repo: GitvaultRepoFile; next_action: "reverify_from_genesis"; } | { state: "unrecoverable"; code: "VAULT_UNRECOVERABLE"; statement: string; doctor_text: string; }; export interface GitvaultKeystoreOptions { /** Override the keystore root (tests). Defaults to `/gitvault`. */ rootDir?: string; /** Clock injection (tests). */ now?: () => Date; /** When true, a permission finding on open throws `GITVAULT_KEYSTORE_PERMISSIONS` instead of only auditing. */ strictPermissions?: boolean; } export interface GitvaultLockOptions { /** How long a lock is considered live before it is reclaimed as stale (ms). Default 10 min. */ staleAfterMs?: number; /** Total wait budget before `GITVAULT_KEYSTORE_LOCKED` (ms). Default 30 s. */ timeoutMs?: number; } /** The local object cache's recency window (design D3: "genesis + newest N=8 heads + newest carriers"). */ export declare const GITVAULT_OBJECT_CACHE_WINDOW = 8; /** gitvault-object-host-predial (design D1): the max persisted object-store origins per repo — small on purpose, this is a predial hint, not an inventory. */ export declare const GITVAULT_OBJECT_STORE_ORIGINS_CAP = 4; /** Default keystore root: `/gitvault`. */ export declare function getGitvaultKeystoreRoot(): string; /** * Atomic, no-follow, fsynced write: `O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW` on a * temp sibling, fsync, rename over the target, fsync the directory. */ export declare function writeFileAtomic0600(path: string, text: string): void; /** No-follow read: a symlinked keystore file is a refusal, not a read. */ export declare function readFileNoFollow(path: string): string | null; export declare class GitvaultKeystore { readonly rootDir: string; private readonly now; private readonly strictPermissions; constructor(options?: GitvaultKeystoreOptions); /** Open (creating the directory skeleton) and run the permission audit. */ static open(options?: GitvaultKeystoreOptions): GitvaultKeystore; get identityPath(): string; get reposDir(): string; get receiptsDir(): string; get journalDir(): string; get auditLogPath(): string; repoPath(repoId: string): string; recoveryReceiptPath(repoId: string): string; /** Audit every keystore path that exists; findings are recorded in `audit.log` and returned. */ auditPermissions(): GitvaultPermissionFinding[]; /** Append one JSON line to `audit.log` (0600, created no-follow). */ audit(event: GitvaultAuditEvent, repoId?: string, details?: Record): void; /** Read the audit log (newest last). */ readAuditLog(): GitvaultAuditEntry[]; readIdentity(): GitvaultIdentityFile | null; /** Create the identity if absent; never overwrites an existing one. */ ensureIdentity(): GitvaultIdentityFile; /** * Record (idempotently) which principal this identity enrolled as. A * DIFFERENT already-recorded principal is refused — a deliberate migration * deletes/replaces the identity, it never silently rebinds. */ bindIdentityPrincipal(principalId: string): void; /** The signing keypair, or `null` when the signing seed is lost (read-only principal). */ signingKeypair(identity?: GitvaultIdentityFile): GitvaultSigningKeypair | null; /** The encryption keypair, or `null` when the X25519 private key is lost. */ encryptionKeypair(identity?: GitvaultIdentityFile): GitvaultEncryptionKeypair | null; private requireIdentity; readRepo(repoId: string): GitvaultRepoFile | null; listRepoIds(): string[]; /** * gitvault-offline-clone-resolve (design D1/D2/D6, task 1.1): the * keystore's project→repo lookup that lets {@link resolveGitvaultAddress} * (`gitvault-address.ts`) answer an id-form address WITHOUT the network * `findVaultByProject` round trip. D2's completeness argument: decrypting * anything requires exactly this repo file (`k_repo_hex`), so "this * keystore holds a matching file" is exactly the set of clones that can * complete regardless of how resolution went — the network call was only * ever confirming a fact this machine already held. * * `orgId`, when supplied, is a HARD equality filter, never a hint: a * `project_id` match under a different `org_id` is a miss (D1, "mismatched * org → treat as miss, never guess") — an org transfer or an id collision * must never resolve to the wrong vault silently. Passing `undefined` * accepts any org for that project id. * * Multiple repo files can legitimately name the same `project_id` — most * commonly a re-vaulted project that left its old file behind under its * old `repo_id` on THIS machine. D3: the most-recently-modified file (by * filesystem mtime — D6 rules out a maintained on-disk index for v1) wins; * a wrong pick is indistinguishable from an ordinary stale pin and heals * through the same {@link recoverStaleGitvaultPin} first-use-refusal path. * * Cheap by construction (D6): one directory listing plus one JSON read per * candidate — no maintained project→repo index. NEVER throws: a corrupt, * unreadable, or concurrently-deleted repo file is skipped exactly as a * cache miss, because a project-id scan has no single `repo_id` to blame a * throw on (unlike {@link readRepo}'s single-id contract, where throwing on * corruption is the right, attributable failure). Returns `null` on no * match, an empty keystore, or a not-yet-created `repos/` directory. */ findRepoByProject(projectId: string, orgId?: string): GitvaultRepoFile | null; /** Save (create or replace) a repo file under the per-repo lock. */ saveRepo(repo: Omit): GitvaultRepoFile; /** Update the dual pins / last ref transaction without touching key material. */ updateRepo(repoId: string, patch: Partial>): GitvaultRepoFile; /** * gitvault-object-host-predial (design D1/D4, task 1.2): record that * `origins` (already-normalized `scheme://host` strings) were observed * serving THIS repo's objects — the transport's write-through-on-change * hook. Most-recently-observed wins ties over anything already recorded; * deduped; capped at {@link GITVAULT_OBJECT_STORE_ORIGINS_CAP}. A true * no-op (no lock taken, no write, `updated_at` untouched) when the * resulting set is IDENTICAL — in order — to what is already on disk, so * the steady state (every session re-observes the same one or two * origins) costs nothing. Best-effort by contract: a missing repo file * (this call racing a not-yet-`saveRepo`'d creation) or any read/write * failure is swallowed — this is a latency hint, never load-bearing, and * must never surface into a caller's own object-read path. */ recordObjectStoreOrigins(repoId: string, origins: readonly string[]): void; private objectsRepoDir; private objectsGenesisPath; private objectsHeadsDir; private objectsCarriersDir; private objectsHeadPath; private objectsCarrierPath; private readCachedFile; private writeCachedFile; private decodeCachedBytes; /** Genesis is one small object per vault, immutable forever — cached and never evicted. */ readCachedGenesis(repoId: string): { sha256: string; bytes: Uint8Array; } | null; writeCachedGenesis(repoId: string, sha256: string, bytes: Uint8Array): void; readCachedHead(repoId: string, generation: string): { sha256: string; bytes: Uint8Array; } | null; /** Writes the head, then evicts anything more than {@link GITVAULT_OBJECT_CACHE_WINDOW} generations behind it. */ writeCachedHead(repoId: string, generation: string, sha256: string, bytes: Uint8Array): void; readCachedCarrier(repoId: string, objectId: string): { sha256: string; bytes: Uint8Array; generation: string; } | null; /** Writes the carrier (keyed by `object_id`, tagged with the generation that referenced it), then sweeps the window. */ writeCachedCarrier(repoId: string, objectId: string, generation: string, sha256: string, bytes: Uint8Array): void; /** * Drop heads and carriers more than {@link GITVAULT_OBJECT_CACHE_WINDOW} * generations behind `latestGeneration` — genesis is exempt (kept * forever). Runs inline on every cache write; also callable directly * (`sweepObjectCache`, wired into `repos gc`) as a periodic backstop that * catches any orphan a crashed write might have left behind. Best-effort: * an unlink failure (e.g. already gone) is silently ignored, matching the * cache's own "a miss just refetches" posture. */ private evictObjectCache; /** The `repos gc`-time sweep (task 4.2): re-applies the SAME eviction window as an inline write would, for every repo this keystore holds. Safe to call on a repo with no cache directory at all (a no-op). */ sweepObjectCache(repoId: string): void; /** * Record a COMMITTED epoch rotation (D194, rev 42): advances the local * "current" pointer (`epoch` + `k_repo_hex`) to the new epoch's key AND * appends it to `epoch_keys` (never overwriting a prior entry — every key * this principal has ever held stays locally available for historical * decrypt / the D195 prior-key-inequality check on the NEXT rotation). * Audited distinctly from an ordinary `updateRepo` patch. */ recordEpochRotation(repoId: string, input: { new_epoch: string; new_k_repo_hex: string; }): GitvaultRepoFile; saveRecoveryReceipt(receipt: GitvaultRecoveryReceipt): void; readRecoveryReceipt(repoId: string): GitvaultRecoveryReceipt | null; /** * Run `fn` holding the per-repo lock (`repos/.lock/`, mkdir-atomic). * A lock older than `staleAfterMs` whose recorded pid is dead is reclaimed * (audited). Re-entrant for the same process+repo. */ withRepoLock(repoId: string, fn: () => T, options?: GitvaultLockOptions): T; private readonly held; private reclaimStaleLock; /** * Derive the partial-loss transition for one vault. `expectedGenesisSha256` * (from a freshly listed genesis, 5.4) turns a pin mismatch into `stale_pin`. */ assess(repoId: string, expectedGenesisSha256?: string): GitvaultKeystoreState; /** * §5.1 "repo file lost + identity intact → restore K_repo from own envelope". * The genesis is checked (signature + key bindings against the stored * envelope + the recovery receipt/pin when one survives) BEFORE the envelope * is opened; the restored file is marked `restored_from_envelope`. */ restoreRepoFromEnvelope(input: { genesis: GitvaultVaultGenesis; envelope: GitvaultKeyEnvelope; /** A surviving receipt (on disk or supplied) authenticates the genesis; without one the restore is labeled unauthenticated salvage. */ recovery_receipt?: GitvaultRecoveryReceipt | null; /** * gitvault-agent-envelopes D4: the caller verified genesis's creator * fingerprints against the control plane's SIGNED allocation record. That * is platform-attested consistency, NOT independent authentication — the * platform serves both sides of the comparison — so it labels the restore * `platform_attested`, never `receipt`. `continuity` says whether this * keystore had already pinned this genesis (a later open) or is seeing it * for the first time. */ allocation_attested?: boolean; /** * gitvault-multi-writer (rev 47): the vault's CURRENTLY admitted writers, * resolved by the caller from the chain-verified writer set (the admitted * heads' `add_writer_key` / `writer_set_update` transitions). A * non-creator envelope may be wrapped by ANY admitted writer — whichever * key-holder reconciled first — so `created_by` must name the genesis * writer OR one of these, and the envelope's signature is verified under * THAT writer's key. Omitted: only the genesis writer is accepted (V0). */ admitted_writers?: readonly { writer_key_id: string; signing_pubkey: string; }[]; }): Promise<{ repo: GitvaultRepoFile; trust: "receipt" | "platform_attested" | "unauthenticated_salvage"; continuity: "first_seen" | "pinned"; independently_verified: boolean; }>; /** Identity fingerprints for display/doctor (never key material). */ identitySummary(): { signing_fingerprint: string; encryption_fingerprint: string; signing_key_present: boolean; encryption_key_present: boolean; } | null; } //# sourceMappingURL=gitvault-keystore.d.ts.map