Skip to main content

ZSC — Zeq Secure Context

ZSC is the framework's secret store. Where most apps drop credentials into .env and call it a day, ZSC treats every secret read as a state-machine transition: encrypted at rest with AES-256-GCM, gated by ZID permissions, recorded as a hash-linked audit entangled state row with a verifiable proof digest, and rotated automatically on the 1.287 Hz HulyaPulse cadence.

Concrete picture: STRIPE_SECRET_KEY stops being a file someone might cat and starts being a row in zsc_secrets that only the owner ZID can decrypt, only after the read produces a secret_read row in audit_log. Lose the master key → no plaintext. Leak the master → every prior read is still on the entangled state, forensics intact.


Why this replaces .env

.env is fine for "what region is this server in." It's not fine for the kind of secrets that, if leaked, let an attacker drain a wallet or impersonate the foundation. The framework already runs everything through audit_log with prev_hash linkage; ZSC just extends that contract to the secret-read step.

Problem with .envWhat ZSC does instead
Plaintext on diskAES-256-GCM at rest, key from PBKDF2-200k over a 32-byte master
Anyone with shell access can cat itDecrypt requires the in-process master; the bytes never hit disk again
No record of who read what, whenEvery read writes a secret_read row with proof_digest = SHA-256(name | zid | transitionId | purpose)
Bound to file lifetimeBound to a ZID + permission list; revocable per caller
Stale foreverRotation daemon re-encrypts under a fresh IV every expires_zeqond

The framework's .env doesn't go away — ZeqContext.read() falls through to process.env when a key isn't in the vault. That's the non-fatal migration ramp: turn entries on one at a time, each becomes an entangled state-audited secret without touching the call site.


The lifecycle of one secret read

A call like ZeqContext.read("STRIPE_SECRET_KEY", { zid: callerZid }) runs through this path:

  1. Rate-limit check (in-memory map, keyed by (name, zid)). If the caller has hit 5 denials in the last 60 Zeqonds (≈ 46.6 s), we short-circuit with reason="rate_limited" before the DB hit — prevents timing oracles.
  2. Vault read via vaultGetWithMeta(name) — selects value_enc, value_iv, bound_zid, permissions from zsc_secrets.
  3. Permission gate — caller's ZID must be bound_zid, in permissions[], or equal to the system-bypass constant "ZEQ-SYS". Failure → secret_denied audit row + denial counter bump.
  4. Decrypt — AES-256-GCM, key derived once per process via zeqField.ts::deriveKey(). Auth-tag failure → null returned, audit row marked decrypt_failed.
  5. Audit rowsecret_read written to audit_log with proof_digest = SHA-256(name | zid | transitionId | purpose). transitionId is the entangled state's stable row identifier — using it (not zeqond_number) keeps the proof verifiable even when chain-write contention bumps the zeqond.
  6. Counter bumplast_read_zeqond + read_count updated fire-and-forget on the row (we don't block the read for forensics).

Every step that can fail returns null and falls through to process.env. The vault is a non-fatal optimisation layer — DB down, decrypt failed, permission denied, all degrade to "fall through to env" so production never crashes because the vault is unhappy.


The four transition types

audit_log.transition_type carries a secret_* family ZSC writes:

TypeWhenWhat it proves
secret_setvaultSet() upsertA secret arrived in the vault under this name, owner ZID, and permission list. Forensic origin-of-value.
secret_readSuccessful ZeqContext.read()This ZID accessed this secret at this Zeqond. The entangled state is the forensic record.
secret_rotatedrotationDaemon re-encryptedThe IV changed at this Zeqond. Old ciphertext is dead, new one is live.
secret_deniedPermission gate rejected, or rate-limiter trippedSomeone tried to read this secret and the gate said no. Audit-visible attack signal.

All four rows carry proof_digest, all four hash-link backward via prev_hash. Run pulse > context audit STRIPE_SECRET_KEY and you get the full life-cycle for that name in chronological order.


Permissions — ZID-gated reads

zsc_secrets.bound_zid is the owning ZID; zsc_secrets.permissions is the list of additional ZIDs allowed to read. The gate works:

isPermitted(callerZid, boundZid, permissions) =
callerZid === "ZEQ-SYS" || // system bypass
callerZid === boundZid || // owner
permissions.includes(callerZid) // explicit grant

ZEQ-SYS is the system ZID — boot-time reads (the scheduler, the rotation daemon, the audit-log writer itself) use it to break the chicken-and-egg of "you need ZSC to start, but starting writes to the chain which needs ZSC." Operators see the bypass in the audit row's actor_zid field, so it's not invisible — just exempt from the gate.

Grant + revoke are admin operations:

POST /api/zsc/grant { name, zid } → adds zid to permissions[]
POST /api/zsc/revoke { name, zid } → removes zid from permissions[]

Both write secret_set (with a purpose_tag of granted / revoked) so the permission history is itself audit-chained.


Rate limiter — the 5-in-60-Z window

Two reasons we don't just bounce denied reads with a 403:

  1. Timing oracle protection. A denial that takes 8 ms (DB hit
    • permission gate) and a permission grant that takes 12 ms (DB hit + decrypt + audit write) leak the existence of a secret to anyone with a stopwatch. The rate-limiter trips before the DB hit so denied reads have flat-rate latency.
  2. Cheap attack surface. Without throttling, a leaked API key could enumerate every secret name via permission failures alone.

The window is DENIAL_WINDOW_ZEQONDS = 60 (≈ 46.6 s) with DENIAL_THRESHOLD = 5. Hit 5 denials in 60 Z and the 6th attempt returns reason="rate_limited" until the window clears. A successful grant in the middle of an active window does not bypass the limiter — the denied state is sticky until the window expires.


Rotation — the daemon that re-encrypts every secret

Every 100 Zeqonds (≈ 77.7 s) the rotation daemon ticks. It scans zsc_secrets WHERE expires_zeqond < currentZeqondNumber(), re-encrypts up to 64 rows per batch under a fresh IV, and bumps expires_zeqond by ROTATION_PERIOD_ZEQONDS (default 86,400 ≈ 18.6 h).

Each rotation writes a secret_rotated audit row carrying:

proof_digest = SHA-256(name | "ZEQ-SYS" | transitionId | "auto_rotated")

The proof is verifiable against the entangled state after the fact — you can re-derive it from the row's stable fields and confirm bit-for-bit. That's the framework's standard "every transition produces a forensically reproducible proof" contract.

The daemon is self-throttled — if a previous tick is still in flight at the next 100-Z mark, the new tick is skipped. No queue build-up, no thundering herd. Operators tune the batch size and period via ZSC_ROTATION_BATCH and ZSC_ROTATION_PERIOD_ZEQONDS.


Vault-first, env-fallback — the non-fatal contract

ZeqContext.read(name, { zid }) returns one of three things:

  1. Vault hit + permitted → plaintext from the encrypted row. Audit row: secret_read.
  2. Vault hit + deniednull. Audit row: secret_denied. Caller's code path: same as process.env.X being unset.
  3. Vault miss → fall through to process.env[name]. No audit row. Caller's code path: identical to today's .env semantics.

This is the migration ramp. Existing code that reads process.env.STRIPE_SECRET_KEY keeps working forever — the day someone calls POST /api/zsc/set with that name, the code path silently flips to vault-served. No call-site changes, no deploys.


The encryption substrate — reused, not reinvented

ZSC doesn't ship a new cipher. It calls zeqEncrypt / zeqDecrypt from shared/api-core/src/lib/zeqField.ts — the same AES-256-GCM primitive that already protects waitlist emails, contact form PII, and BYOK credentials. One key derivation, one salt material (HULYAS.ZeqField.f=1.287Hz.τ=0.777s.α=1.29e-3), one rotation contract via ZEQ_FIELD_KEY_PREV.

The master key sits at the root: a 32-byte secret loaded at boot from either process.env.ZEQ_FIELD_KEY (default), AWS KMS, or GCP KMS. That bootstrap layer is documented separately in Operate → ZSC Bootstrap.


What this gives you in practice

Three concrete wins:

  1. An attacker who pops a shell on an api-core node can read every secret today via cat .env. With ZSC live, they can decrypt nothing without also dumping the running process's memory (the master key never hits disk) — and every read they perform is an audit row.
  2. A leaked Stripe key is no longer a permanent secret. When the rotation daemon ticks, the IV rotates; if you also rotate the upstream Stripe credential and vaultSet it under the same name, the leaked plaintext is dead even though the audit entangled state is intact.
  3. You can prove who read what. A compliance request asks "did anyone ever read the prod API key for $tenant during this incident window?" — that's a single SQL query against audit_log filtered by transition_type = 'secret_read' and proof_digest.

Where to look next

  • ZSC Audit Trail — the four transition types, how proof_digest is computed, how to verify the entangled state
  • BYOK — the sister doc; BYOK uses the same zeqField cipher for LLM credentials but is account-scoped where ZSC is system-scoped
  • Operate → ZSC Bootstrap — KMS adapter setup, master-key rotation, recovery procedures
  • Build → Context CLI — the 8 pulse > context … commands operators use day-to-day
  • /portal/secrets/ — the admin UI (paired with the CLI)
  • API reference: /api/zsc/list, /info, /set, /rotate, /grant, /revoke, /audit, /delete, /probe-permission