fix: export per-site signing key instead of legacy slot#2
Conversation
## Problem
Users reported that edits to imported sites don't persist after
refresh. Reproducible on the same node by: create site → export →
remove → re-import → edit → refresh. Only the original (pre-export)
copy accepted edits.
## Root cause
`DelegateRequest::GetSigningKey` had no `prefix` field, so the delegate
handler called `load_signing_key(ctx, None)`. With `None`,
`load_signing_key` skips the per-prefix slot entirely and only reads
the legacy `delta:signing_key` single-key slot. When a legacy key
happened to be present (left over from V1–V4 delegates or another
site), export silently returned that unrelated key paired with the
current site's `owner_pubkey`. The resulting token contained a
mismatched signing_key / owner_pubkey pair, so after import every
signed page failed contract validation on the network and UPDATEs were
silently rejected. Optimistic in-memory updates made the bug look
intermittent until the next refresh fetched the unchanged network
state.
## Fix
- Add `prefix: Option<String>` to `DelegateRequest::GetSigningKey`.
- Delegate handler passes it through to `load_signing_key`, which
already prefers the per-prefix slot over the legacy fallback.
- `request_export` passes the currently-selected site's prefix.
- Defence in depth: `handle_signing_key_response` verifies that the
returned key's public key matches the site's `owner_pubkey` before
producing a token, and refuses with an error if it doesn't. This
turns the silent corruption into a loud failure if the bug ever
regresses.
- Only treat the `SigningKey` response as an export result when the
export modal is open, so the legacy-migration code path (which also
issues `GetSigningKey { prefix: None }` against old delegates) can't
spuriously populate the export error.
- Legacy migration still uses `prefix: None` intentionally — it's
probing pre-V7 delegates where the legacy slot is the only place a
key can live.
## Testing
- Added `get_signing_key_request_roundtrips_with_prefix` CBOR
round-trip test guarding the wire format so a future refactor
can't silently drop the prefix again.
- `cargo test -p delta-core` — 18/18 passing.
- `cargo clippy --workspace --all-targets` clean.
## Migration
Adds legacy_delegates.toml entry V7 (captured before the delegate
code change via `./scripts/add-migration.sh`) so users whose signing
keys live in the pre-V7 delegate can still migrate them to the new
delegate on next load.
[AI-assisted - Claude]
- Add new `GetSigningKeyForPrefix { prefix: String }` variant instead of
modifying the existing `GetSigningKey` unit variant. Modifying the unit
variant would have broken CBOR wire compatibility with pre-V7 delegates,
silently killing the legacy-migration path that rescues signing keys
from older delegate versions. `GetSigningKey` stays as a unit variant
for legacy probes; `GetSigningKeyForPrefix` is the V7+ request for the
per-site signing key used during export.
- Correlate export responses to requests via a new `PENDING_EXPORT`
signal holding a snapshot of the target site's prefix/name/owner_pubkey.
Previously, a `SigningKey` response from a concurrent legacy-migration
probe or an arrival after the user switched sites could be paired with
the wrong site's `owner_pubkey`, producing a spurious "delegate returned
a signing key that does not match this site's owner" error even though
the user's real request hadn't returned yet.
- Reject `SiteRole::Visitor` up front in `request_export` so users get a
clear "only owned sites can be exported" message instead of the
defence-in-depth mismatch error.
- Eliminate the silent-hang path when `current_site()` returns None on
response: the response handler is now entirely driven by
`PENDING_EXPORT`, which is always set before the request fires.
- Refactor the delegate's `load_signing_key` to delegate its priority
logic to a new pure function `select_key_bytes<F>(prefix, read)`, which
can be unit-tested without a `DelegateCtx`. New tests in site-delegate
cover: per-prefix wins when both slots populated (the exact pre-V7
bug), legacy fallback when no prefix supplied, legacy fallback when
per-prefix slot empty (pre-V5 users migrating to V7), and not-found.
- Extract `validate_signing_key_matches` from `handle_signing_key_response`
into a pure helper and unit-test it in the UI crate: accept matching
keys, reject keys whose pubkey doesn't match the site owner, reject
malformed byte lengths. Locks in the defence-in-depth guard.
- Add a CBOR wire-format test asserting that `GetSigningKey` still
serializes as a bare text string (externally-tagged unit variant), so
a future refactor can't silently break wire compatibility with pre-V7
delegates.
- Update the `GetSigningKeyForPrefix` round-trip test to match the new
variant shape.
[AI-assisted - Claude]
|
Addressed review feedback in dae126d: Code-first + Skeptical (critical): wire-format break. Reverted to keeping Skeptical: response correlation race. Added a Skeptical: visitor sites. Code-first: silent hang on Testing (critical): extracted Big-picture: the site_contract.wasm hash does change (b92da83d → 53e3395f) because All tests passing locally: delta-core 19/19, site-delegate 4/4, delta-ui 3/3, clippy clean. [AI-assisted - Claude] |
Fresh bug report: export hangs on "Delta: routing signing request through legacy delegate" for sites created under the current release. Root cause: PR #2 added `DelegateRequest::GetSigningKeyForPrefix` but `request_prefix` in `ui/src/freenet_api/delegate.rs` only extracted prefixes from `SignPage`/`SignPageDeletion`/`SignConfig`. The new variant fell into the `_ => None` arm, so `send_signing_request` saw `prefix = None`, fell back to the `HAS_CURRENT_LEGACY_KEY` heuristic, and for any user who had ever migrated through a legacy delegate that flag was `false` — routing the export request to the legacy delegate. Pre-V7 delegates cannot deserialize `GetSigningKeyForPrefix` (unit-variant wire format ≠ struct-variant wire format) so the request hung forever, no response, no error, spinner indefinitely. Fix: 1. Add `GetSigningKeyForPrefix` to `request_prefix` so the router sees the correct prefix and checks `CURRENT_KEY_PREFIXES` directly against it. 2. Introduce `variant_is_v7_plus` and refuse to fall back to a legacy delegate for any such variant, even if somehow the current-delegate check fails. Send to the current delegate unconditionally so we get a clean error rather than silent hang. Every future `DelegateRequest` variant introduced in a delegate-WASM upgrade must be added to `variant_is_v7_plus` (or a similarly-named "post-that-version" gate) or risk the same silent-hang class of bug. [AI-assisted - Claude]
Problem
Users reported that edits to imported sites don't persist after refresh — including on the same node after export → remove → re-import. The imported site was readable but every edit silently reverted on refresh.
Root cause
DelegateRequest::GetSigningKeycarried noprefix, so the delegate handler calledload_signing_key(ctx, None). WithNone,load_signing_keyskips per-prefix storage and reads only the legacydelta:signing_keyslot. When any legacy key was present (leftover from V1–V4 or from another site migration), export silently returned that unrelated key paired with the current site'sowner_pubkey. The resulting token's signing_key and owner_pubkey were mismatched, so every signed page/config/deletion on the importing side failed the contract signature check on the network and UPDATEs were dropped. Optimistic local updates hid the failure until the next refresh fetched the unchanged network state.This explains all three Matrix bug reports:
Approach
prefix: Option<String>toDelegateRequest::GetSigningKeyand pass it through toload_signing_key, which already prefers the per-prefix slot.request_exportpasses the currently-selected site's prefix.handle_signing_key_responseverifies the returned key's public key matches the site'sowner_pubkeybefore emitting a token. If they don't match, we show an explicit error instead of silently producing a broken token. Turns any future regression of this bug into a loud failure.SHOW_EXPORT, so the legacy-migration code path (which still issuesGetSigningKey { prefix: None }to probe pre-V7 delegates) can't spuriously populate export state.prefix: None— that's the correct probe against pre-V7 delegates where the legacy slot is the only place a key can live.Testing
get_signing_key_request_roundtrips_with_prefix) guarding the wire format so a future refactor can't silently drop the prefix again.cargo test -p delta-core— 18/18 passing.cargo fmt+cargo clippy --workspace --all-targetsclean../scripts/check-migration.shconfirms the V7 migration entry was captured before the delegate code change.Migration
Adds
legacy_delegates.tomlentry V7 captured before the delegate code change via./scripts/add-migration.sh, so users whose signing keys live in the pre-V7 delegate still migrate to the new delegate on next load. Per AGENTS.md delegate upgrade workflow.Why CI didn't catch this
There are no integration tests exercising the real delegate through the UI's export/import round trip — the existing tests cover state signing and CBOR round-trips in
delta-core, but the delegate's storage behavior underprefix: NonevsSome(_)was untested. The new CBOR round-trip test narrows this gap forGetSigningKeyspecifically; a broader follow-up would exercise the delegate binary end-to-end.Closes the edit-after-import bug class reported on Matrix.
[AI-assisted - Claude]