Aller au contenu principal

R3 — Phase-Locked Cipher + HZC Archiver

R3 sits one layer above the entangled state. It gives devices a way to encrypt payloads with a deterministic nonce — no random IV stored alongside the ciphertext — and gives the framework a way to roll cold rows into hash- chained .hzc archives that can be replayed standalone, years later, without DB access.

The cipher and the archiver are independent primitives that compose: hot rows live in audit_log and can be encrypted in flight with the phase-locked variant; cold rows roll into HZC files that bundle the same phase-locked encryption with the canonical chain continuity proof.

Phase-locked AES-256-GCM

Standard zeqEncrypt(plaintext) uses a random 12-byte IV per call. That's the right primitive when you don't have a unique per-record identifier the server can recompute from. Audit rows do — every row has a unique (origin_id, zeqond_number) tuple, enforced by R1's UNIQUE INDEX. So the IV doesn't have to be stored.

Nonce derivation:

nonce = sha256("phase-lock|" + zeqond + "|" + originId)[0:12]

Domain-separated from the random-IV cipher via the HMAC label ZeqField/PhaseLockedNonce/v1, so a phase-locked ciphertext can never accidentally decrypt against the random-IV cipher (or vice-versa).

API:

import {
zeqEncryptPhaseLocked,
zeqDecryptPhaseLocked,
} from "@workspace/api-core/lib/zeqField";

// Encrypt — returns hex (no IV component, recoverable from zeqond+origin)
const ct = zeqEncryptPhaseLocked(plaintext, 2287513900n, "zeq-dev:my-slug");

// Decrypt — pass the same (zeqond, originId) tuple the encryptor used
const back = zeqDecryptPhaseLocked(ct, 2287513900n, "zeq-dev:my-slug");

Six properties verified live:

PropertyResult
Round-tripzeqDecryptPhaseLocked(zeqEncryptPhaseLocked(p, z, o), z, o) === p
DeterministicSame (plaintext, zeqond, origin) always produces the same ciphertext
No collisionDifferent zeqond → different ciphertext
Tamper-zWrong zeqond at decrypt → GCM auth tag fails
Tamper-originWrong origin at decrypt → GCM auth tag fails
IV-omittedCiphertext length = plaintext_len + 16 (just the GCM tag)

The two tamper properties are the load-bearing security claim: if anyone alters the row's zeqond_number or origin_id after the fact, the recomputed nonce diverges and decryption fails. Combined with R1's UNIQUE INDEX, no two rows can ever share the same phase-locked nonce on the same origin.

HZC cold-row archiver

archiveOrigin({ originId, slug, parentOrigin, options }) selects rows older than retentionDays (default 60), JSON-serialises each (with id stripped — recoverable from zeqond), encrypts with the phase-locked nonce, brotli-compresses via zeqCompress, and writes a <slug>-<period>-z<from>-z<to>.hzc file with magic HZC1\n + JSON manifest + base64 compressed body.

Continuity proof:

The manifest carries:

{
"version": "HZC1",
"slug": "zeq07792026349",
"parent_origin": "zeq-dev",
"origin_id": "zeq-dev:zeq07792026349",
"from_z": "2287489484",
"to_z": "2287489583",
"row_count": 100,
"last_archived_curr_hash": "70c9a73566a71f84...",
"first_hot_prev_hash": "70c9a73566a71f84...",
"continuity_holds": true,
"sealed_at_unix": 1745869800,
"domain": "audit-log/phase-locked"
}

last_archived_curr_hash is the canonical rowHash of the last row folded into this archive. first_hot_prev_hash is the prev_hash of the first row that REMAINS in audit_log after the rollover. They MUST be equal — that's the continuity proof. The archive refuses to write if they don't match.

Default non-destructive:

await archiveOrigin({ originId, slug, parentOrigin });
// → writes the .hzc file. rowsDeleted: false. Audit rows still in DB.

Pass { deleteAfterArchive: true } only after verifying the file out-of-band.

Read-back:

import { readArchive } from "@workspace/api-core/lib/archiver";

const { manifest, rows } = await readArchive("path/to/file.hzc");
// rows are decrypted and parsed; every emission's zeqond is recovered.

Verified end-to-end

A 100-row archive against a live chain (zeq07792026349, retentionDays=0, maxRows=100) produced a 37,969-byte .hzc file. Read-back decrypted all 100 rows in canonical chain order; first/last zeqonds (22874894842287489583) matched the manifest exactly.

File map

  • shared/api-core/src/lib/zeqField.tszeqEncryptPhaseLocked / zeqDecryptPhaseLocked
  • shared/api-core/src/lib/archiver.tsarchiveOrigin / readArchive
  • shared/api-core/src/lib/zeqCompress.ts — brotli compression wrapper

Deferred — R3.1

The destructive schema swap (replace state_hash, prev_hash columns with payload_ct BYTEA + per-row nonce) is gated behind a dual-write soak phase: the atomic writer must emit both plaintext and encrypted payload for ≥30 days before reader migration. Doing the swap in a single step would break R1 + chain-repair + the detail render simultaneously. The framework's ≤0.1% tolerance forbids that kind of coupled change without a verified rollback path.