Terms oracle: hash-aware sync, monotonic latest, bind content_sha256 in acceptance message #6

Closed
opened 2026-08-11 01:03:04 +00:00 by PlasticDigits · 10 comments
PlasticDigits commented 2026-08-11 01:03:04 +00:00 (Migrated from gitlab.com)

Summary

Harden the GitLab → API terms “oracle” so publication is content-hash aware, latest versions cannot silently roll back, and wallet/Telegram acceptance messages cryptographically bind content_sha256.

Tracked from internal audit audits/INTERNAL_COMPOSER_1786408744.md (H4 / related) and gaps/GAP_1786322222.md.

Bundle (single issue — one coherent publication + binding change):

  1. Sync compares content hash (and version label), not label alone.
  2. Reject or explicitly gate non-monotonic / downgrade “latest” transitions.
  3. Include content_sha256 in the canonical acceptance message (Rust + SDK + portal builders kept in lockstep).

Current codebase

Sync / publish

sync_terms_from_url (api/src/terms/sync.rs) fetches TERMS_GITLAB_RAW_URL, parses line-2 Version: …, and if that label equals the current is_latest row’s version_label, returns Unchanged without comparing content_sha256 or body bytes.

If the remote label differs from current latest, it publishes via publish_terms and marks the new row is_latest — with no monotonic ordering or “seen hash” check. A compromised/mis-edited oracle (or wrong label) can make an older/different draft become latest.

content_sha256 is computed and stored on terms_versions and returned by public terms JSON, but is not part of the signed acceptance text.

Acceptance message

build_acceptance_message / build_wallet_message / build_telegram_acceptance_message (api/src/message.rs) bind:

  • version label
  • effective date (formatted)
  • property, network, account
  • client timestamp

They do not bind document hash. TS mirrors live in packages/cl8y-clickwrap/src/message.ts and web re-exports.

Triggers

  • Startup sync + interval worker (api/src/lib.rs, api/src/terms_worker.rs)
  • Authenticated POST /update_terms (api/src/routes/update_terms.rs)

Out of scope: SSRF hardening of the fetch client (separate), admin UX console, property allowlist, Solana/Telegram crypto bugs.


Why the new implementation is needed

  1. Silent drift: Edit TERMS_AND_CONDITIONS.txt without bumping Version: → API keeps old DB text while GitLab shows new text; signers think they accepted what GitLab shows.
  2. Oracle rollback: Publish a never-latest or older label → users on a newer signed version suddenly signed_latest: false; bots/enforcers may demand re-sign of older/wrong text.
  3. Weak legal binding: Signatures attest to a label/date string, not the exact bytes of the terms. Auditors cannot prove which document body was accepted if labels are reused or content is swapped under a new label without hash binding.

DeFi analogue: oracle stale-price / rollback + signing an asset ticker without the underlying hash.


Constraints / guardrails

  1. Do not break existing stored signatures: message format change is a breaking change for in-flight clients — ship behind a coordinated release (API + SDK + web) and bump terms version when deploying so everyone re-signs the new format.
  2. Keep line-2 Version: as the human label; hash is additional integrity, not a replacement for labeling discipline.
  3. Publishing must remain gated by existing admin/unattended sync paths (no new unauthenticated publish).
  4. Golden tests: Rust message builders and @plasticdigits/cl8y-clickwrap must stay byte-identical.
  5. Prefer fail-closed sync: if remote content is empty/unparseable, do not clear is_latest.
  6. Document operator policy: always bump Version: when changing body; hash check is the safety net.
  7. Avoid deleting historical terms_versions rows; append + flip is_latest only under policy.
  8. Update skills/security-ops/SKILL.md (or add a short terms-oracle note) so agents do not regress.

Relevant files

Path Role
api/src/terms/sync.rs fetch + publish decision
api/src/terms.rs publish_terms, content_sha256, is_latest
api/src/terms_worker.rs interval sync
api/src/message.rs canonical acceptance text
api/src/signatures.rs submit paths using message builders
api/tests/integration_test.rs HTTP / DB integration
packages/cl8y-clickwrap/src/message.ts + message.test.ts SDK mirror + golden
web/src/message.ts re-export / portal
TERMS_AND_CONDITIONS.txt oracle source
skills/security-ops/SKILL.md ops invariants
audits/INTERNAL_COMPOSER_1786408744.md audit context

A. Hash-aware sync

After fetch + parse:

  1. Compute sha256 of remote body (same function as publish).
  2. If latest exists and latest.content_sha256 == remote_hash and labels match → Unchanged.
  3. If labels match but hashes differ → publish new row with a derived label policy or reject with clear error forcing operators to bump Version: (choose one; prefer reject + error log to avoid silent label reuse, unless product wants auto-suffix — document choice in PR).
  4. If labels differ → proceed to publish only if downgrade policy allows (B).

B. Monotonic / anti-downgrade latest

Pick a concrete policy (document in PR):

  • Preferred: maintain published_at / insert order; refuse to set is_latest if remote label was previously published and is not a configured “force” admin override; or require remote label to be new (never seen) unless FORCE_TERMS_DOWNGRADE=true (dev only).
  • Alternatively: semver/calver parse when labels match Draft X.Y — only allow increasing pairs.

At minimum: never mark as latest a row whose content_sha256 equals an older non-latest row while discarding a newer published_at without explicit force flag.

C. Bind hash in message

Extend canonical text with a stable line, e.g.:

Content-SHA256: <hex>

Update Rust + SDK + any e2e fixtures. Integration tests must use the new builder. Portal/SDK release in lockstep with API deploy.


Acceptance criteria

  • Same Version: + same body → Unchanged (no duplicate publish).
  • Same Version: + different body → does not silently keep stale DB as if nothing happened (either publish-with-policy or hard error requiring label bump — documented).
  • Attempted downgrade / rollback to an older published label (or older content as latest) is rejected without force flag.
  • Acceptance message includes content_sha256 hex; wallet verify still passes for honest clients.
  • SDK golden test updated; Rust unit test covers new line.
  • Telegram server-built message includes the same hash line.
  • Existing admin Bearer + rate-limit behavior for /update_terms unchanged.
  • Docs/skill mention oracle invariants.

Test plan — functional paths

Path Expectation
Sync identical remote Unchanged
Sync new version label + new body Published; new is_latest
Sync same label, mutated body reject or publish-per-policy; never leave operators believing sync applied when DB stale
Sync older label after newer latest rejected (no force)
Force flag (if implemented) only with insecure/dev opt-in; logged
Wallet submit after format change happy path with hash in message
Telegram submit message includes hash; status signed_latest true
SDK buildWalletMessage / poll matches API

Test plan — attack / abuse / oracle vectors

Vector Expectation
MITM / malicious TERMS_GITLAB_RAW_URL serving old label cannot become latest if downgrade blocked
GitLab edit without version bump detected via hash; no silent ignore
Label reuse with malicious body blocked or forces new version; signatures bind hash so old sigs don’t attest new body
Client omits/alters Content-SHA256 line message does not match canonical format
Client signs hash for version A against submit for version B fail verify / message mismatch
Replay signature across versions still fails (version + hash in message)
/update_terms without Bearer still 401
Extremely large remote body existing fetch limits / errors; no panic (add size cap if missing)

Verification criteria

  1. cd api && cargo test green (unit + integration covering sync policy + message hash).
  2. npm run test:sdk green (message golden).
  3. Manual: publish Draft N, sign; change file body without version bump; sync outcome matches chosen policy; DB/API content consistent.
  4. Manual: attempt sync of previously superseded label → rejected.
  5. Grep: acceptance builders in Rust and TS both emit Content-SHA256.
  6. Audit note or skill updated; PR description states breaking message-format deploy order (API+SDK+web together).
## Summary Harden the GitLab → API terms “oracle” so publication is content-hash aware, latest versions cannot silently roll back, and wallet/Telegram acceptance messages cryptographically bind `content_sha256`. Tracked from internal audit `audits/INTERNAL_COMPOSER_1786408744.md` (H4 / related) and `gaps/GAP_1786322222.md`. Bundle (single issue — one coherent publication + binding change): 1. Sync compares **content hash** (and version label), not label alone. 2. Reject or explicitly gate **non-monotonic / downgrade** “latest” transitions. 3. Include **`content_sha256`** in the canonical acceptance message (Rust + SDK + portal builders kept in lockstep). --- ## Current codebase ### Sync / publish `sync_terms_from_url` (`api/src/terms/sync.rs`) fetches `TERMS_GITLAB_RAW_URL`, parses line-2 `Version: …`, and if that label equals the current `is_latest` row’s `version_label`, returns `Unchanged` **without** comparing `content_sha256` or body bytes. If the remote label **differs** from current latest, it publishes via `publish_terms` and marks the new row `is_latest` — with **no monotonic ordering** or “seen hash” check. A compromised/mis-edited oracle (or wrong label) can make an older/different draft become latest. `content_sha256` is computed and stored on `terms_versions` and returned by public terms JSON, but is **not** part of the signed acceptance text. ### Acceptance message `build_acceptance_message` / `build_wallet_message` / `build_telegram_acceptance_message` (`api/src/message.rs`) bind: - version label - effective date (formatted) - property, network, account - client timestamp They do **not** bind document hash. TS mirrors live in `packages/cl8y-clickwrap/src/message.ts` and web re-exports. ### Triggers - Startup sync + interval worker (`api/src/lib.rs`, `api/src/terms_worker.rs`) - Authenticated `POST /update_terms` (`api/src/routes/update_terms.rs`) Out of scope: SSRF hardening of the fetch client (separate), admin UX console, property allowlist, Solana/Telegram crypto bugs. --- ## Why the new implementation is needed 1. **Silent drift:** Edit `TERMS_AND_CONDITIONS.txt` without bumping `Version:` → API keeps old DB text while GitLab shows new text; signers think they accepted what GitLab shows. 2. **Oracle rollback:** Publish a never-latest or older label → users on a newer signed version suddenly `signed_latest: false`; bots/enforcers may demand re-sign of older/wrong text. 3. **Weak legal binding:** Signatures attest to a label/date string, not the exact bytes of the terms. Auditors cannot prove which document body was accepted if labels are reused or content is swapped under a new label without hash binding. DeFi analogue: oracle stale-price / rollback + signing an asset ticker without the underlying hash. --- ## Constraints / guardrails 1. **Do not break** existing stored signatures: message format change is a **breaking change** for in-flight clients — ship behind a coordinated release (API + SDK + web) and bump terms version when deploying so everyone re-signs the new format. 2. Keep line-2 `Version:` as the human label; hash is additional integrity, not a replacement for labeling discipline. 3. Publishing must remain gated by existing admin/unattended sync paths (no new unauthenticated publish). 4. Golden tests: Rust message builders and `@plasticdigits/cl8y-clickwrap` must stay byte-identical. 5. Prefer fail-closed sync: if remote content is empty/unparseable, do not clear `is_latest`. 6. Document operator policy: always bump `Version:` when changing body; hash check is the safety net. 7. Avoid deleting historical `terms_versions` rows; append + flip `is_latest` only under policy. 8. Update `skills/security-ops/SKILL.md` (or add a short terms-oracle note) so agents do not regress. --- ## Relevant files | Path | Role | |------|------| | `api/src/terms/sync.rs` | fetch + publish decision | | `api/src/terms.rs` | `publish_terms`, `content_sha256`, `is_latest` | | `api/src/terms_worker.rs` | interval sync | | `api/src/message.rs` | canonical acceptance text | | `api/src/signatures.rs` | submit paths using message builders | | `api/tests/integration_test.rs` | HTTP / DB integration | | `packages/cl8y-clickwrap/src/message.ts` + `message.test.ts` | SDK mirror + golden | | `web/src/message.ts` | re-export / portal | | `TERMS_AND_CONDITIONS.txt` | oracle source | | `skills/security-ops/SKILL.md` | ops invariants | | `audits/INTERNAL_COMPOSER_1786408744.md` | audit context | --- ## Recommended direction ### A. Hash-aware sync After fetch + parse: 1. Compute `sha256` of remote body (same function as publish). 2. If latest exists and `latest.content_sha256 == remote_hash` **and** labels match → `Unchanged`. 3. If labels match but hashes differ → **publish new row** with a derived label policy **or** reject with clear error forcing operators to bump `Version:` (choose one; prefer **reject + error log** to avoid silent label reuse, unless product wants auto-suffix — document choice in PR). 4. If labels differ → proceed to publish only if downgrade policy allows (B). ### B. Monotonic / anti-downgrade latest Pick a concrete policy (document in PR): - **Preferred:** maintain `published_at` / insert order; refuse to set `is_latest` if remote label was previously published and is not a configured “force” admin override; or require remote label to be new (never seen) unless `FORCE_TERMS_DOWNGRADE=true` (dev only). - Alternatively: semver/calver parse when labels match `Draft X.Y` — only allow increasing pairs. At minimum: never mark as latest a row whose `content_sha256` equals an older non-latest row while discarding a newer `published_at` without explicit force flag. ### C. Bind hash in message Extend canonical text with a stable line, e.g.: ```text Content-SHA256: <hex> ``` Update Rust + SDK + any e2e fixtures. Integration tests must use the new builder. Portal/SDK release in lockstep with API deploy. --- ## Acceptance criteria - [ ] Same `Version:` + same body → `Unchanged` (no duplicate publish). - [ ] Same `Version:` + **different** body → does **not** silently keep stale DB as if nothing happened (either publish-with-policy or hard error requiring label bump — documented). - [ ] Attempted downgrade / rollback to an older published label (or older content as latest) is rejected without force flag. - [ ] Acceptance message includes `content_sha256` hex; wallet verify still passes for honest clients. - [ ] SDK golden test updated; Rust unit test covers new line. - [ ] Telegram server-built message includes the same hash line. - [ ] Existing admin Bearer + rate-limit behavior for `/update_terms` unchanged. - [ ] Docs/skill mention oracle invariants. --- ## Test plan — functional paths | Path | Expectation | |------|-------------| | Sync identical remote | `Unchanged` | | Sync new version label + new body | `Published`; new `is_latest` | | Sync same label, mutated body | reject or publish-per-policy; **never** leave operators believing sync applied when DB stale | | Sync older label after newer latest | rejected (no force) | | Force flag (if implemented) | only with insecure/dev opt-in; logged | | Wallet submit after format change | happy path with hash in message | | Telegram submit | message includes hash; status `signed_latest` true | | SDK `buildWalletMessage` / poll | matches API | --- ## Test plan — attack / abuse / oracle vectors | Vector | Expectation | |--------|-------------| | MITM / malicious `TERMS_GITLAB_RAW_URL` serving old label | cannot become latest if downgrade blocked | | GitLab edit without version bump | detected via hash; no silent ignore | | Label reuse with malicious body | blocked or forces new version; signatures bind hash so old sigs don’t attest new body | | Client omits/alters `Content-SHA256` line | `message does not match canonical format` | | Client signs hash for version A against submit for version B | fail verify / message mismatch | | Replay signature across versions | still fails (version + hash in message) | | `/update_terms` without Bearer | still 401 | | Extremely large remote body | existing fetch limits / errors; no panic (add size cap if missing) | --- ## Verification criteria 1. `cd api && cargo test` green (unit + integration covering sync policy + message hash). 2. `npm run test:sdk` green (message golden). 3. Manual: publish Draft N, sign; change file body without version bump; sync outcome matches chosen policy; DB/API content consistent. 4. Manual: attempt sync of previously superseded label → rejected. 5. Grep: acceptance builders in Rust and TS both emit `Content-SHA256`. 6. Audit note or skill updated; PR description states breaking message-format deploy order (API+SDK+web together).
PlasticDigits commented 2026-08-11 01:08:53 +00:00 (Migrated from gitlab.com)

mentioned in commit c83afafea2

mentioned in commit c83afafea280a7779ab7e131dce67d38966e896a
PlasticDigits commented 2026-08-11 01:09:09 +00:00 (Migrated from gitlab.com)

mentioned in merge request !25

mentioned in merge request !25
PlasticDigits commented 2026-08-11 01:09:25 +00:00 (Migrated from gitlab.com)

mentioned in merge request !26

mentioned in merge request !26
PlasticDigits commented 2026-08-11 01:09:32 +00:00 (Migrated from gitlab.com)

Implemented in !26 (fix/issue-6-terms-oracle). Hash-aware sync + anti-downgrade + Content-SHA256 message binding; see MR for deploy order and checklist.

Implemented in !26 (`fix/issue-6-terms-oracle`). Hash-aware sync + anti-downgrade + Content-SHA256 message binding; see MR for deploy order and checklist.
PlasticDigits commented 2026-08-11 01:25:13 +00:00 (Migrated from gitlab.com)

mentioned in commit 4afa9976cc

mentioned in commit 4afa9976ccc9dd62a5e7eefffb8a542d18c6ee0c
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-11 01:25:13 +00:00
PlasticDigits commented 2026-08-11 01:25:42 +00:00 (Migrated from gitlab.com)

mentioned in commit 6470432a8e

mentioned in commit 6470432a8e4cb2df985b7c67743cb5cb3dd03f4e
PlasticDigits commented 2026-08-11 01:26:12 +00:00 (Migrated from gitlab.com)

Merge verification (!26 → main)

Verdict: PASS_WITH_NOTES — all issue #6 acceptance criteria met; MR !26 merged without waiting for CI / without auto-merge.

Acceptance criteria

  • Same Version: + same body → Unchanged
  • Same Version: + different body → hard error requiring label bump (documented)
  • Downgrade/rollback rejected without FORCE_TERMS_DOWNGRADE
  • Acceptance message includes Content-SHA256
  • SDK golden + Rust unit tests cover new line
  • Telegram server-built message includes hash line
  • /update_terms Bearer + rate-limit unchanged
  • Docs/skill mention oracle invariants

Local checks

  • cd api && cargo test — PASS (incl. integration_terms_oracle_sync_policy)
  • npm run test:sdk — PASS (21/21)

Problems / residual risks (non-blocking)

  1. Breaking deploy order still required: ship API + @plasticdigits/cl8y-clickwrap + portal together, then bump TERMS_AND_CONDITIONS.txt Version: so clients re-sign. Pre-deploy clients will fail verify until updated.
  2. Manual verification not run here: mutate body without version bump / rollback label without force still need ops smoke after deploy.
  3. CI on !26 was failed with ci_quota_exceeded (not a code failure) — pipeline was not re-run before merge per instruction.
  4. Label-only bump with identical body returns Unchanged (hash treated as source of truth) — intentional; not in the eight criteria but operators should know.

No acceptance-criteria defects found; closing via !26.

## Merge verification (!26 → main) **Verdict:** PASS_WITH_NOTES — all issue #6 acceptance criteria met; MR !26 merged without waiting for CI / without auto-merge. ### Acceptance criteria - [x] Same `Version:` + same body → `Unchanged` - [x] Same `Version:` + different body → hard error requiring label bump (documented) - [x] Downgrade/rollback rejected without `FORCE_TERMS_DOWNGRADE` - [x] Acceptance message includes `Content-SHA256` - [x] SDK golden + Rust unit tests cover new line - [x] Telegram server-built message includes hash line - [x] `/update_terms` Bearer + rate-limit unchanged - [x] Docs/skill mention oracle invariants ### Local checks - `cd api && cargo test` — PASS (incl. `integration_terms_oracle_sync_policy`) - `npm run test:sdk` — PASS (21/21) ### Problems / residual risks (non-blocking) 1. **Breaking deploy order still required:** ship API + `@plasticdigits/cl8y-clickwrap` + portal together, then bump `TERMS_AND_CONDITIONS.txt` `Version:` so clients re-sign. Pre-deploy clients will fail verify until updated. 2. **Manual verification not run here:** mutate body without version bump / rollback label without force still need ops smoke after deploy. 3. **CI on !26 was failed** with `ci_quota_exceeded` (not a code failure) — pipeline was not re-run before merge per instruction. 4. **Label-only bump with identical body** returns `Unchanged` (hash treated as source of truth) — intentional; not in the eight criteria but operators should know. No acceptance-criteria defects found; closing via !26.
PlasticDigits commented 2026-08-11 01:26:14 +00:00 (Migrated from gitlab.com)

mentioned in issue #5

mentioned in issue #5
PlasticDigits commented 2026-08-31 04:12:25 +00:00 (Migrated from gitlab.com)

mentioned in issue #16

mentioned in issue #16
PlasticDigits commented 2026-08-31 04:31:51 +00:00 (Migrated from gitlab.com)

mentioned in issue #17

mentioned in issue #17
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-ecosystem-legal#6
No description provided.