Skip to content

fix(mem): reject off-curve pubkeys instead of aborting; correct mem patch safety contract - #6550

Open
holmes wants to merge 2 commits into
block:mainfrom
holmes:rust-worker/engram-offcurve-and-patch-contract
Open

fix(mem): reject off-curve pubkeys instead of aborting; correct mem patch safety contract#6550
holmes wants to merge 2 commits into
block:mainfrom
holmes:rust-worker/engram-offcurve-and-patch-contract

Conversation

@holmes

@holmes holmes commented Aug 22, 2026

Copy link
Copy Markdown

Two independently reviewable commits

1. fix(mem): reject off-curve pubkeys instead of aborting the process

buzz mem get/hash/set/rm --owner <hex> and --agent <hex> aborted with exit 101 when given a hex-valid x-coordinate that is not a point on secp256k1:

valid keys produce conversation key: Key(Secp256k1(InvalidPublicKey))

PublicKey::from_hex only hex-decodes 32 bytes, so such a key parses fine and survives into NIP-44 ECDH, where engram::conversation_key unwrapped the failure with expect. A mistyped flag should not look like a crash.

Fixed in two layers, because neither covers the other:

  • engram::conversation_key now returns Result<_, EngramError> (new EngramError::InvalidKey); its three callers (build_event, validate_and_decrypt, buzz-acp's fetch_core_body) propagate. This removes the abort itself.
  • The CLI curve-checks --owner/--agent at the edge via parse_pubkey_flagUsage (exit 1) naming the flag, key, and reason.

The edge check is not redundant, and this was measured rather than assumed. With the library fix only, per-subcommand outcome for an off-curve key:

subcommand flag outcome with library fix alone
get / hash --owner, --agent exit 1 Usage
set / rm / patch --owner exit 1 Usage
ls --owner, --agent exit 0, Ok(())

mem ls never derives a conversation key, so it silently prints an empty listing — reporting "no memories" for a key that cannot exist. That false-negative is the reason the edge guard stays.

The same guard covers the owner pubkey from BUZZ_AUTH_TAG, surfaced as Other (exit 4) rather than Usage, since a bad auth tag is an environment defect, not a typo. Shape follows existing precedent buzz_core::private_managed_agent::parse_canonical_pubkey.

2. docs(mem): correct the mem patch safety contract to preimage-only

The docs claimed mem patch "makes concurrent edits safe" and framed --no-base-hash as the only unsafe option. Both guards authenticate the value being replaced and say nothing about the replacement:

  • verify_hunks_at_declared_position filters Line::Insert out before comparing → + lines are never examined.
  • --base-hash hashes the pre-edit value, which a patch quoting the current value matches exactly.

So a patch whose context and - lines quote the real current value while its + lines carry content from a different file passes every check and is published. An agent following the documented workflow could overwrite its own memory with another agent's content and see nothing refuse it — which is how a core engram was clobbered in practice.

Corrected in the four places actually read: cmd_patch + verify_hunks_at_declared_position rustdoc, clap help for Patch/--base-hash/--no-base-hash, the buzz-cli README, and the agent-facing nest SKILL.md. Each now states the preimage-only scope and prescribes what does cover the postimage: compare the --dry-run sha256 to intended content, re-read and diff after writing, assert an identity string only you would write, and keep snapshots under a per-agent path instead of a shared filename. NEST_SKILL_VERSION 5 → 6 so existing installs refresh.

Test evidence

Every negative assertion carries a positive control (x = 1 is on-curve, x = 5 is not — the pair differs only in curve membership), so an always-refuse guard cannot pass. The end-to-end test drives all 9 subcommand/flag combinations against a stub relay, asserting Usage/exit 1 for the off-curve key and not Usage for a real one.

strict_position_authenticates_preimage_only_not_postimage pins the documented limitation so the prose cannot drift from behaviour again: a foreign-postimage patch is accepted and applies, --base-hash cannot distinguish it, and — as a control that the check isn't a no-op — corrupting the preimage side of the same patch is refused.

Mutation proofs (each reverted; zero markers remain)

mutation result
restore the library expect() 2 buzz-core tests fail; CLI tests still pass (proves the edge guard is a genuine outer layer)
remove the CLI-edge curve check 4 of 5 CLI tests fail, incl. ls --owner, caught only at the edge
remove the auth_tag curve check exactly resolve_owner_rejects_off_curve_auth_tag_owner fails

Gates at pushed SHA 3d6fc8f32a49b8af5ed9572db623d31b7f06b499

  • cargo nextest run -p buzz-core -p buzz-cli -p buzz-acp1421 passed, 0 failed
  • cd desktop/src-tauri && cargo test --workspace2684 passed, 0 failed, 18 ignored
  • cargo fmt --check (workspace + tauri) → clean
  • cargo clippy --workspace --all-targets -- -D warnings (workspace + tauri) → clean

Note on the base commit

Branched from da818eddc rather than current origin/main (a2d8be5ef). The available push credential lacks GitHub's workflow scope, and the range fork/main..a2d8be5ef contains 3 commits touching .github/workflows/, so a push carrying them is rejected by GitHub. da818eddc is the newest commit before the first of those. My diff touches no workflow file. I verified a trial merge into a2d8be5ef is conflict-free and leaves all 7 touched files byte-identical (sha256) to the reviewed content, so the base choice is purely a push-mechanics constraint and does not alter reviewed bytes. Happy to rebase onto current main if someone with workflow scope pushes it.

rusty and others added 2 commits August 21, 2026 23:35
`buzz mem get/hash/set/rm --owner <hex>` and `--agent <hex>` aborted with
exit 101 when handed a hex-valid x-coordinate that is not a point on
secp256k1 (e.g. `00…05`):

    valid keys produce conversation key: Key(Secp256k1(InvalidPublicKey))

`PublicKey::from_hex` only hex-decodes 32 bytes, so such a key parses
fine and survives all the way into NIP-44 ECDH, where
`engram::conversation_key` unwrapped the failure with `expect`. A
mistyped flag should not look like a crash.

Two layers, because neither covers the other:

- `engram::conversation_key` now returns `Result<_, EngramError>` with a
  new `EngramError::InvalidKey`, and its three callers (`build_event`,
  `validate_and_decrypt`, `buzz-acp`'s `fetch_core_body`) propagate it.
  This removes the abort itself, so no library caller can reintroduce
  it.
- The CLI validates `--owner`/`--agent` at the edge via
  `parse_pubkey_flag`, which curve-checks with `xonly()` and reports a
  `Usage` error (exit 1) naming the flag, the key, and the reason. This
  is not redundant: `mem ls` never derives a conversation key, so with
  the library fix alone `mem ls --owner <off-curve>` exits 0 and prints
  an empty listing — it silently reports "no memories" for a key that
  cannot exist. Measured, not assumed; the other eight subcommand/flag
  combinations do surface the library error.

The same guard is applied to the owner pubkey taken from `BUZZ_AUTH_TAG`,
surfaced as `Other` (exit 4) rather than `Usage`, because a bad auth tag
is an environment defect and not a mistyped argument.

Shape follows the existing precedent in
`buzz_core::private_managed_agent::parse_canonical_pubkey`, which
curve-checks the same way.

Tests pin the replacement behaviour, each with a positive control so an
always-refuse guard cannot pass: `x = 1` is a curve point and `x = 5` is
not, so the pair differs only in curve membership. The end-to-end test
drives all nine subcommand/flag combinations against a stub relay and
asserts `Usage` with exit 1 for the off-curve key and *not* `Usage` for
a real one.

Co-authored-by: Jason Holmes <holmes@squareup.com>
Signed-off-by: Jason Holmes <holmes@squareup.com>
The docs claimed `mem patch` "makes concurrent edits safe" and described
`--no-base-hash` as the only unsafe option. Both `--base-hash` and the
strict-position hunk check authenticate the value being *replaced* and
say nothing about the replacement:

- `verify_hunks_at_declared_position` filters `Line::Insert` out before
  comparing, so the `+` lines are never examined.
- `--base-hash` hashes the pre-edit value, which a patch quoting the
  current value matches exactly.

So a patch whose context and `-` lines quote the real current value while
its `+` lines carry content from a different file passes every check and
is published. An agent following the documented workflow could therefore
overwrite its own memory with another agent's content and see nothing
refuse it — which is how a `core` engram was clobbered in practice.

Corrected in the four places an agent or operator actually reads:
rustdoc on `cmd_patch` and `verify_hunks_at_declared_position`, clap help
for `Patch`/`--base-hash`/`--no-base-hash`, the buzz-cli README, and the
agent-facing nest SKILL.md. Each now states the preimage-only scope and
prescribes the checks that do cover the postimage: compare the
`--dry-run` sha256 against the intended content, re-read and diff the
slug after writing, assert an identity string only you would write is
present, and keep pre-edit snapshots under a per-agent path rather than
a shared generic filename.

`NEST_SKILL_VERSION` is bumped 5 → 6 so existing installs refresh the
skill text instead of keeping the incorrect version.

`strict_position_authenticates_preimage_only_not_postimage` pins the
behaviour so the prose cannot drift from it again: it asserts that a
foreign-postimage patch is accepted and applies, that `--base-hash`
cannot distinguish it, and — as a positive control that the check is not
simply a no-op — that corrupting the preimage side of the same patch is
refused.

Co-authored-by: Jason Holmes <holmes@squareup.com>
Signed-off-by: Jason Holmes <holmes@squareup.com>
@holmes
holmes requested a review from a team as a code owner August 22, 2026 06:45
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