Skip to content

feat(attest)!: sign quipu-write-v2, bound to the receiving store (needs quipu-ai >= 0.12.0) - #198

Open
scbrown wants to merge 3 commits into
mainfrom
malcolm/sd-write-v2
Open

scbrown wants to merge 3 commits into
mainfrom
malcolm/sd-write-v2

Conversation

@scbrown

@scbrown scbrown commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Why

A quipu-write-v1 signature names no store, so a signed request is accepted by every store that trusts the key. quipu has accepted quipu-write-v2 (the signature binds the receiving store's id) since #388. sd still signed only v1.

What

sd signs quipu-write-v2 by default:

  1. Once, at connect and before anything is signed, sd reads the server's store_id from GET /stats. That endpoint is an unauthenticated read in quipu. sd pins the id for the connection.
  2. Every signed request (writes, /identity, and signed reads such as /health) puts the id in the signed message, as the audience= line LAST, and in the envelope, as the audience field LAST.
config ([quipu], user-level only) effect
(none) v2, with the audience read from /stats
signing_store_id = "urn:uuid:…" v2; refuses before signing anything if /stats reports another id
signing_version = "v1-legacy" the old v1, for a server older than write-v2; refused together with a pin
  • Never caller-asserted: there is no per-command audience.
  • No silent downgrade: a /stats that errors, names no store_id, or names one with a control character, or over 256 bytes, refuses the connection with nothing signed.
  • A project file setting either key is refused, like the other signing_* keys.

Breaking:

  • Against a quipu server older than write-v2, signed requests are refused until signing_version = "v1-legacy" is set.
  • Signer::{canonical_message, header, header_now} take the audience as a new last argument.
  • [quipu] has deny_unknown_fields, so an older sd rejects a config that sets either new key. The default needs neither key.

Evidence

Vectors:

  • quipu's published tests/vectors/write-attestation-v2.json is copied unchanged (sha256 identical to the source).
  • reproduces_quipus_published_v2_vector_byte_for_byte matches the canonical message, the signature, the envelope JSON and the header. The v1 vector test still passes.
  • Independently, a Python cryptography Ed25519 check rebuilt the v2 canonical message from the vector's rules, not by copying it. It reproduced all 9 derived fields and verified the signature; a mutated audience is refused.

Against a real quipu-server, built from quipu #824, which adds per-session require-v2. The session binding was set to REQUIRE v2, so a v1 write would be refused:

arm result subjects in graph
default config created (rc 0) 0 → 1
v1-legacy refused: invalid, "session binding requires quipu-write-v2" 1 → 1
allow-v1, then v1-legacy created 1 → 2
wrong signing_store_id refused locally (rc 5), naming both ids 2 → 2
correct signing_store_id created 2 → 3

Unit tests (new):

  • an_unusable_stats_refuses_before_anything_is_signed: 500, 404, no store_id, a control character, a pin mismatch. Only GET /stats is sent, and it carries no attestation.
  • the_signing_version_defaults_to_v2_and_v1_needs_the_explicit_legacy_word
  • a_store_id_that_could_break_the_message_is_refused

Adapted tests (intentional):

  • the gateway mock now answers /stats first, and asserts it is unsigned;
  • the actor_principal mock answers /stats, and now asserts every signed update is v2 with the fixture audience;
  • the PR demo fixture reproduce.py answers /stats (it still runs, rc 0).

Sabotage, each restored from a saved copy with its sha256 checked:

sabotage red
audience line dropped from the signed message v2 vector
silent fallback when /stats is unusable unusable-stats
pin not compared unusable-stats (pin arm)
project file may set the new keys config
default flipped to v1 config, actor_principal

Gates:

  • cargo test: 407 passed / 0 failed, each binary under a HOME-escape guard (clean);
  • --no-default-features: 160 / 0;
  • cargo fmt --check;
  • cargo clippy --all-targets -D warnings;
  • wasm32 clippy --no-default-features -D warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

scbrown and others added 2 commits October 24, 2026 21:04
A quipu-write-v1 signature names no store, so a signed request is accepted
by every store that trusts the key. sd now signs quipu-write-v2 by default:
it reads the server's store id from GET /stats once, before anything is
signed, pins it for the connection, and puts it in the signed message (the
audience line last) and the envelope. The store then refuses a request
signed for any other store.

- The audience is never a caller choice. A /stats that fails or names no
  usable store_id refuses the connection; sd never falls back to v1.
- `[quipu] signing_store_id` optionally pins the store: a server reporting
  another id is refused before anything is signed.
- `[quipu] signing_version = "v1-legacy"` keeps the old format for a server
  older than write-v2. It cannot be combined with a pin. Both keys are
  user-level only, like the other signing_* keys.

The bytes reproduce quipu's published tests/vectors/write-attestation-v2.json
(copied unchanged), and the v1 vector still reproduces.

BREAKING CHANGE: signed requests default to quipu-write-v2, so a quipu
server older than write-v2 refuses them until `signing_version =
"v1-legacy"` is set. `Signer::canonical_message`, `Signer::header` and
`Signer::header_now` take the audience as a new last argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
…cev)

quipu-ai-v0.12.0 is the first quipu release that verifies quipu-write-v2 and reports store_id in GET /stats (checked: v0.11.1 has neither). Older servers refuse every signed request until signing_version = "v1-legacy".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
@scbrown scbrown changed the title feat(attest)!: sign quipu-write-v2, bound to the receiving store feat(attest)!: sign quipu-write-v2, bound to the receiving store (needs quipu-ai >= 0.12.0) Oct 25, 2026
…s (aegis-2idcev)

The config.rs record claims storage routing resolves flags, environment, project then user config and refuses a conflict. The signing_version and signing_store_id keys leave that claim true; only the source hash moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
@scbrown

scbrown commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Repair at head 5d4109c9. pr-risk tier 2, record 5cd631149829….

The red: Repository pendant source controls failed, because docs/knowledge/repository-knowledge.json pins src/native/config.rs by sha256 and this PR changed that file. muldoon caught it.

  • Fix: re-witnessed the hash only, the same convention as fix(auth): observe shared-key actor mismatches by default #194. The record's claim still holds; the new keys don't touch storage routing.
  • Locally, the job's three scripts with rdflib 7.6.0: test-repository-knowledge.py was rc 1 on bb50ca2 (the control reproduces CI's error) and rc 0 now. share-query and share-publication are rc 0.

I also ran the Remote matrix locally, which I had missed before. My earlier "407 passed" had these server tests SKIPPED, because SEEDS_TEST_QUIPU_SERVER was unset. Against the pinned release binaries (sha256 checked as CI does), under a HOME-escape guard (clean):

quipu --test modes lib native::remote:: SKIPPED CI's 4 named tests
quipu-ai-v0.11.0 31 / 0 41 / 0 0 4/4 ok
quipu-ai-v0.14.0 31 / 0 41 / 0 0 4/4 ok

Limit: these suites use no signer (no Signer or attest:: in tests/modes.rs, remote_scoped_tests.rs, or remote.rs's tests). So v0.11.0 passing shows the unsigned and bearer paths are unchanged; it says nothing about signing. Signed writes against a real server are covered only by the manual e2e in the PR body, against a #824 build.

  • A CI arm doing a signed write against the matrix server would make that permanent. On v0.11.0 it should be refused under v2 and succeed under v1-legacy. I'm offering that as a follow-up rather than widening this PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant