feat: version tag signing (muse tag add --sign / tag verify) + remote listing (muse#57 items 3-4)
Item 3: cryptographic signing of version tags.
Added signer_public_key/signature fields to VersionTagRecord (empty-string defaults, backward-compatible with every existing unsigned on-disk tag). version_tag_payload(repo_id, tag, commit_id) computes the canonical SHA-256 signing payload -- null-byte-separated, mirroring muse.core.provenance.provenance_payload's existing pattern for signed commits. author/message/created_at are deliberately excluded from the payload: they're metadata, not what "this tag was created by an authorized identity" needs to vouch for, so correcting a typo in the message doesn't invalidate an existing signature.
sign_version_tag()/verify_version_tag() reuse muse.core.provenance's existing sign_commit_ed25519/verify_commit_ed25519 primitives directly -- despite the "commit" in their names, they're payload-agnostic (sign/verify an arbitrary string), so no new crypto code was needed, just a new payload shape.
`muse tag add --sign` resolves a signing identity via get_signing_identity(root) (no --hub needed -- this is a purely local operation, and get_signing_identity already falls back to the repo-local [hub] url from .muse/config.toml internally) and signs (repo_id, tag, commit_id). Warns on stderr (not crash) when no identity is available, matching the muse#75 pattern. New `muse tag verify <tag>` subcommand recomputes the payload and checks it against the stored signature/public key -- exit 0 only when genuinely valid.
Item 4: `muse tag list --remote <name> [--pre]` queries a remote's version tags via the ALREADY-EXISTING `GET /{owner}/{slug}/version-tags` endpoint (confirmed live in musehub/api/routes/wire.py -- the ticket's "requires a new hub API endpoint" was already satisfied) and the already-existing transport.fetch_version_tags() client method (already used by `muse pull`, just never exposed as a read-only, no-pull-required CLI surface). Read-only: does not write to .muse/version-tags/. Reuses VersionTagRecord.from_dict() to convert the wire dicts (which already has a parse_semver(tag_name) fallback for the GET endpoint's flat semver_major/minor/etc shape, vs push's nested "semver" dict -- both shapes already existed independently in production; this uses the existing fallback, doesn't change it).
Also extended tag push's wire dict and pull's receiving reconstruction to carry signer_public_key/signature, so a signed tag survives a real push/pull round-trip, not just local storage.
New tests: - tests/test_version_tag_signing.py (13): payload determinism, sign/verify round-trip, tampered-commit-id detection (the entire point of binding commit_id into the payload), wrong-key detection, malformed-key handling (returns False, never raises), VersionTagRecord round-trip including backward-compat defaults for legacy on-disk tags with no signature keys at all. - tests/test_cmd_tag_sign.py (7): real keygen/register flow (network calls mocked, matching test_mist_cli.py's TestMistSignHubContext pattern) -- --sign produces signed:true, default is unsigned, no-identity warns without crashing, tag verify on valid/unsigned/tampered/nonexistent tags. - tests/test_cmd_tag_list_remote.py (7): lists from a mocked remote transport without touching local state, --pre filtering applies to remote results too, sorted by semver precedence, unconfigured remote errors cleanly, signed remote tags report signed:true.
Confirmed genuine RED on all three new test files (14 total failures: unknown --sign flag, missing verify subcommand, missing --remote flag, missing signed/signer_public_key keys) before GREEN with the fix restored.
Live-verified the full cross-repo round trip against the real local musehub instance (migration applied, both containers restarted, clean boot confirmed): signed a tag locally, pushed it, deleted the local copy, and `muse tag list --remote local` correctly retrieved it from the server with signed=true -- not just mocked unit tests on either side. Also confirmed (and left as a separate, pre-existing, out-of-scope finding) that `muse pull` only syncs version tags when it also has new commits to fetch -- an "up_to_date" pull returns before ever reaching the version-tag sync block. `tag list --remote` sidesteps this entirely by always actively fetching, which is exactly the use case this ticket item asked for.
Targeted regression suite -- test_version_tag_signing.py (13), test_cmd_tag_sign.py (7), test_cmd_tag_list_remote.py (7), test_core_version_tags.py, test_cmd_version_tags.py, test_cmd_version_tag_push.py, test_cmd_describe_version_tags.py -- all pass (215 passed), zero regressions.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Semantic Changes
109 symbols
Files Changed
+3
~4
1220 in snapshot
0 comments
muse hub commit comment sha256:28a95becb74c420d11509bbbda05cdf4afc9751f1dc6aabd77e9d8d9e4ae7306 --body "your comment"
No comments yet. Be the first to start the discussion.