fix(mem): reject off-curve pubkeys instead of aborting; correct mem patch safety contract - #6550
Open
holmes wants to merge 2 commits into
Open
fix(mem): reject off-curve pubkeys instead of aborting; correct mem patch safety contract#6550holmes wants to merge 2 commits into
holmes wants to merge 2 commits into
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independently reviewable commits
1.
fix(mem): reject off-curve pubkeys instead of aborting the processbuzz 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:PublicKey::from_hexonly hex-decodes 32 bytes, so such a key parses fine and survives into NIP-44 ECDH, whereengram::conversation_keyunwrapped the failure withexpect. A mistyped flag should not look like a crash.Fixed in two layers, because neither covers the other:
engram::conversation_keynow returnsResult<_, EngramError>(newEngramError::InvalidKey); its three callers (build_event,validate_and_decrypt, buzz-acp'sfetch_core_body) propagate. This removes the abort itself.--owner/--agentat the edge viaparse_pubkey_flag→Usage(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:
--owner,--agentUsage✅--ownerUsage✅--owner,--agentOk(())❌mem lsnever 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 asOther(exit 4) rather thanUsage, since a bad auth tag is an environment defect, not a typo. Shape follows existing precedentbuzz_core::private_managed_agent::parse_canonical_pubkey.2.
docs(mem): correct themem patchsafety contract to preimage-onlyThe docs claimed
mem patch"makes concurrent edits safe" and framed--no-base-hashas the only unsafe option. Both guards authenticate the value being replaced and say nothing about the replacement:verify_hunks_at_declared_positionfiltersLine::Insertout before comparing →+lines are never examined.--base-hashhashes 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 acoreengram was clobbered in practice.Corrected in the four places actually read:
cmd_patch+verify_hunks_at_declared_positionrustdoc, clap help forPatch/--base-hash/--no-base-hash, the buzz-cli README, and the agent-facing nestSKILL.md. Each now states the preimage-only scope and prescribes what does cover the postimage: compare the--dry-runsha256 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_VERSION5 → 6 so existing installs refresh.Test evidence
Every negative assertion carries a positive control (
x = 1is on-curve,x = 5is 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, assertingUsage/exit 1 for the off-curve key and notUsagefor a real one.strict_position_authenticates_preimage_only_not_postimagepins the documented limitation so the prose cannot drift from behaviour again: a foreign-postimage patch is accepted and applies,--base-hashcannot 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)
expect()ls --owner, caught only at the edgeauth_tagcurve checkresolve_owner_rejects_off_curve_auth_tag_ownerfailsGates at pushed SHA
3d6fc8f32a49b8af5ed9572db623d31b7f06b499cargo nextest run -p buzz-core -p buzz-cli -p buzz-acp→ 1421 passed, 0 failedcd desktop/src-tauri && cargo test --workspace→ 2684 passed, 0 failed, 18 ignoredcargo fmt --check(workspace + tauri) → cleancargo clippy --workspace --all-targets -- -D warnings(workspace + tauri) → cleanNote on the base commit
Branched from
da818eddcrather than currentorigin/main(a2d8be5ef). The available push credential lacks GitHub'sworkflowscope, and the rangefork/main..a2d8be5efcontains 3 commits touching.github/workflows/, so a push carrying them is rejected by GitHub.da818eddcis the newest commit before the first of those. My diff touches no workflow file. I verified a trial merge intoa2d8be5efis 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 currentmainif someone withworkflowscope pushes it.