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 .env | What ZSC does instead |
|---|---|
| Plaintext on disk | AES-256-GCM at rest, key from PBKDF2-200k over a 32-byte master |
Anyone with shell access can cat it | Decrypt requires the in-process master; the bytes never hit disk again |
| No record of who read what, when | Every read writes a secret_read row with proof_digest = SHA-256(name | zid | transitionId | purpose) |
| Bound to file lifetime | Bound to a ZID + permission list; revocable per caller |
| Stale forever | Rotation 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:
- 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 withreason="rate_limited"before the DB hit — prevents timing oracles. - Vault read via
vaultGetWithMeta(name)— selectsvalue_enc,value_iv,bound_zid,permissionsfromzsc_secrets. - Permission gate — caller's ZID must be
bound_zid, inpermissions[], or equal to the system-bypass constant"ZEQ-SYS". Failure →secret_deniedaudit row + denial counter bump. - Decrypt — AES-256-GCM, key derived once per process via
zeqField.ts::deriveKey(). Auth-tag failure →nullreturned, audit row markeddecrypt_failed. - Audit row —
secret_readwritten toaudit_logwithproof_digest = SHA-256(name | zid | transitionId | purpose).transitionIdis the entangled state's stable row identifier — using it (notzeqond_number) keeps the proof verifiable even when chain-write contention bumps the zeqond. - Counter bump —
last_read_zeqond+read_countupdated 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:
| Type | When | What it proves |
|---|---|---|
secret_set | vaultSet() upsert | A secret arrived in the vault under this name, owner ZID, and permission list. Forensic origin-of-value. |
secret_read | Successful ZeqContext.read() | This ZID accessed this secret at this Zeqond. The entangled state is the forensic record. |
secret_rotated | rotationDaemon re-encrypted | The IV changed at this Zeqond. Old ciphertext is dead, new one is live. |
secret_denied | Permission gate rejected, or rate-limiter tripped | Someone 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:
- 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.
- 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:
- Vault hit + permitted → plaintext from the encrypted row.
Audit row:
secret_read. - Vault hit + denied →
null. Audit row:secret_denied. Caller's code path: same asprocess.env.Xbeing unset. - Vault miss → fall through to
process.env[name]. No audit row. Caller's code path: identical to today's.envsemantics.
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:
- 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. - 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
vaultSetit under the same name, the leaked plaintext is dead even though the audit entangled state is intact. - 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_logfiltered bytransition_type = 'secret_read'andproof_digest.
Where to look next
- ZSC Audit Trail — the four transition types, how
proof_digestis computed, how to verify the entangled state - BYOK — the sister doc; BYOK uses the same
zeqFieldcipher 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