Repository navigation
feat(gkverify): add ghostkey-verify, verify-only Ghost Key chains for contracts - #212
Conversation
… contracts
A new crate, published as ghostkey-verify, that checks a Ghost Key
certificate chain (master -> notary -> ghost key), Ed25519 signatures by the
ghost key over raw payload bytes (the vault's ScopedPayload), and the notary
info JSON. No key generation, no blinding, and neither rand nor getrandom in
its dependency tree, so Freenet contracts can link it for wasm32 without a
custom getrandom backend. freenet/mail's v0.3.0 inbox contract is the first
user.
Parsing and the chain check mirror ghostkey_lib 0.2 type for type, including
the bytes the master key signs (num-bigint-dig's u64_digit BigUint encoding
quirk is reproduced), and differential tests hold the two together. The
deliberate differences each refuse something no genuine certificate
contains: weak ghost keys, notary keys outside 2048-4096 bits / e in {3,
65537}, and RSA signatures not below the modulus.
rsa is the 0.10 release candidate, pinned exactly: stable 0.9 pulls in rand
through num-bigint-dig's prime feature.
CI (ghostkey-verify.yml) runs the tests, checks the dependency tree for rand
and getrandom, builds a contract-like probe for wasm32, checks it imports
nothing, and runs proof vectors through it under node.
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
From the first review round on #212: - Refuse certificates over MAX_CERTIFICATE_BYTES (4 KiB, the inbox contract's bound) before parsing, and cap the reservation a CBOR length header can cause at 1024 elements: a 50-byte input could make serde reserve 1 MiB, which a contract's linear memory never gives back. A counting-allocator test fails without either cap. - Refuse a weak master key, under which the non-strict notary check accepts a forged signature; check the weak ghost key before the RSA operation. - Differential tests for PSS salt lengths other than 48 (the auto salt length was untested), for notary keys of 2049, 2050 and 3072 bits and e = 3, and for the refused keys (1024, 2047 bits, e = 5), oversized input and a weak master. Armour variants pinned one by one. - The wasm probe returns one code per error; more fresh keys, deep nesting in an unknown field and the new refusals run under wasm (16 vectors). - rust-version 1.88 (sha2 0.11.1 needs it); fix a broken intra-doc link. - CI: weekly run for upstream drift, clippy on the probe, a verifying cargo package, read-only token. publish-freenet.yml ignores this workflow's file, which is not in the site. - README: the exact rsa pin and its cost (tracked in #213), the rsa getrandom feature, why ed25519-dalek 2. AGENTS.md and gklib's AGENTS.md name the crate and the coupling. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…meter docs From the parity review on #212: run notary keys the issuing code never makes but the crate accepts (e = 3, a modulus that is not a whole number of bytes) through the wasm probe too, and describe the RSA parameter limits accurately (blind-rsa-signatures checks them only when parsing DER, by whole bytes; the modulus must also be odd). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #212 (round 1)
Summary
- PR: feat(gkverify): add ghostkey-verify, verify-only Ghost Key chains for contracts
- Review tier: Full (cryptographic verification; new CI workflow)
- HEAD SHA reviewed: 97eb5bf
- CI on 97eb5bf: green
- Reviewers run: freenet:code-first-reviewer, freenet:testing-reviewer, freenet:skeptical-reviewer, freenet:big-picture-reviewer, plus two change-specific Claude lenses: (a) byte-exact parity with ghostkey_lib, (b) dependencies, CI and packaging.
- External model:
codex review --base origin/mainwas tried twice and failed both times with "You've hit your usage limit". Per policy I substituted the two extra Claude lenses above and did not wait.
What the reviews established
- No Must Fix. No reviewer found an input this crate accepts that ghostkey_lib rejects.
- The parity lens tested this directly:
- RSA-PSS on about 1,100 hand-made vectors: key sizes 2040 to 4097 bits including 8k+1 moduli, e = 3 and 65537, every salt length, malformed encodings, s+n and leading-zero signatures. All of them also ran under wasm32.
- 20,000 CBOR transcodings: non-minimal integer headers, indefinite-length items, chunked strings and tags.
- Only gklib accepted the documented differences. Nothing was accepted by this crate alone.
- The skeptical lens checked every unwrap/expect reachable from input and found none that can fire.
Findings and how each was addressed (fixed in ca0f4b7 and a973b0e)
| # | Finding | Severity | Source | Resolution |
|---|---|---|---|---|
| 1 | rust-version = "1.85" is false (sha2 0.11.1 needs 1.88, crypto-bigint 0.7.10 needs 1.87) |
Should Fix | code-first, deps | Set to 1.88 |
| 2 | A 50-byte CBOR header claiming a 2^32-element array makes serde reserve 1 MiB. A contract's linear memory never shrinks. | Should Fix | skeptical | Reservation capped at 1024 elements (bounded_vec, which accepts the same inputs as Vec<T>). A counting-allocator test fails without the cap for both the modulus and the signature. |
| 3 | No input bound; the inbox enforces 4 KiB before parsing | Should Fix | code-first, big-picture, skeptical | MAX_CERTIFICATE_BYTES = 4 KiB, exported and enforced in from_armored/from_cbor; documented as a difference, with a test |
| 4 | Auto PSS salt length untested (a fixed-48 mutation survived) | Should Fix | testing | Differential test for salt 0, 1, 20, 32, 48, 64, 206; the mutation now fails |
| 5 | Notary keys other than 2048/e=65537 never tested end to end | Should Fix | testing, parity | Differential tests for 2049, 2050, 3072 bits and e = 3 (both accept), and for 1024, 2047 bits and e = 5 (gklib accepts, we refuse). e=3 and 2049-bit vectors also run under wasm. |
| 6 | Armour variants pinned only as a count range | Should Fix | testing | Each variant now has an exact expected verdict |
| 7 | cargo package --no-verify --list cannot go red |
Should Fix | deps, testing, code-first | The step now runs a verifying cargo package |
| 8 | Merging triggers publish-freenet.yml: the workflow file is outside its paths-ignore. The PR text said otherwise. |
Should Fix | big-picture | .github/workflows/ghostkey-verify.yml added to its paths-ignore. This merge still triggers one republish of the site from main, because it edits that workflow; the PR text is corrected. |
| 9 | Broken intra-doc link to ghostkey_lib | Should Fix | big-picture | Plain crates.io link |
| 10 | AGENTS.md files do not mention the crate or the coupling to gklib | Should Fix | big-picture | Both updated |
| 11 | A weak master key would let anyone forge notary certificates under the non-strict check | Consider | code-first | Refused (WeakMasterKey), with a test showing gklib accepts the forgery |
| 12 | Weak-ghost-key check after the RSA operation | Consider | code-first | Moved before it |
| 13 | Wasm vectors can't tell failing stages apart; only two RSA keys went through wasm | Consider | testing | One code per error; 18 vectors including 5 fresh keys, deep nesting in an unknown field (200 deep accepted, 300 refused), oversized input, weak master |
| 14 | Exact rsa rc pin blocks a downstream that uses another rsa 0.10; the getrandom feature can leak in through unification |
Consider | deps, big-picture | Documented in the README; move to 0.10.0 final tracked in #213 |
| 15 | No upstream-drift detection; clippy not run on the probe; no permissions block |
Consider | deps, code-first | Weekly schedule, probe clippy, contents: read |
| 16 | action not validated; date parser lax |
Consider | code-first, skeptical | Docs tell callers to check action; more refusal cases tested. A calendar check is not added because the string is master-signed. |
| 17 | Parameter wording ("what blind-rsa signs with") inaccurate; even modulus not listed | Consider | parity | Reworded |
Considered and not changed
- ed25519-dalek 3. It would remove the duplicate sha2/digest/signature stacks, but ghostkey-common, freenet-bitcoin and freenet/mail are on dalek 2, and
VerifyingKeyis re-exported. Staying on 2 keeps it interoperable with the callers; the README says why. - Committed lockfile. The repo ignores Cargo.lock. CI resolving fresh is what a new downstream contract gets, and the weekly run now catches drift.
- Master signature
verifyvsverify_strict(a surviving mutation).verifyis kept for parity with gklib; a weak master is now refused separately.
Verdict
State: Needs Changes, re-review required after fix. The fixes touch security-relevant parsing and grew the diff by more than 30 lines, so a full re-review runs on a973b0e.
[AI-assisted - Claude]
…esult type From the second review round on #212: - A certificate is refused if its canonical armour would exceed MAX_CERTIFICATE_BYTES. An RSA signature sent as a CBOR byte string re-encodes at twice the size, so before this a certificate could parse while its own to_armored() did not. Every accepted certificate's canonical forms now parse again, to themselves; the differential helper asserts it for every input both parse. The armour length is computed, not encoded. - VerifiedGhostkey's fields are private (ghost_key(), info()), so only verify() makes one and holding one means the chain was checked. - Tests: the cheap refusals come before the chain (weak keys with every signature broken); a 4096-bit notary key end to end, its certificate and the worst case inside the size limit; a 4097-bit modulus refused; RSA and master signatures as CBOR byte strings. - CI: an MSRV job checking the library at Cargo.toml's rust-version (native and wasm32); main runs are not cancelled by later pushes. - Dependabot watches rust/gkverify (rsa ignored: moved by hand, #213); the root README lists the crate; the README example checks `action`. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #212 (round 2, re-review)
- HEAD SHA reviewed: a973b0e (CI green there). Fixes are in eee220e.
- Tier: Full.
- Reviewers run: freenet:code-first-reviewer, freenet:testing-reviewer, freenet:skeptical-reviewer, freenet:big-picture-reviewer, plus a change-specific lens on contract safety and inbox parity.
- External model:
codex reviewwas retried and still reports "You've hit your usage limit". The extra Claude lens substitutes for it, per policy, without waiting.
This is a re-review of the whole diff after the round-1 fixes. Every reviewer confirmed the round-1 fixes are correct, and none found a Must Fix.
The lenses checked these directly:
- Determinism: 6,000 mutated certificates under 4 masters gave identical verdicts natively and under wasm32, and matched gklib.
- Hostile input under wasm: nesting 4,000 deep, 2^64 length claims and 2,000-chunk strings were all refused cleanly, with memory steady at 18 pages.
- Encodings: every encoding variant tried agreed with gklib, including byte strings, chunked strings, tags and bignum tags.
- Mutation testing: every guard added in round 1 is caught by a test.
Findings and resolution (eee220e)
| # | Finding | Severity | Source | Resolution |
|---|---|---|---|---|
| 1 | from_armored(to_armored(x)) is not total. A ~3.9 KB input with the RSA signature as a CBOR byte string re-encodes to 6.9 KB, which the crate then refuses, so a contract that canonicalizes before verifying gets a form it can't read back. |
Should Fix | contract lens | A certificate whose canonical armour would exceed the limit is refused at parse (computed from the CBOR length, with no extra encoding). The differential helper now asserts that canonical forms re-parse to themselves for every input, and a difference test covers the case. |
| 2 | VerifiedGhostkey has public fields, so anyone can construct one without verifying |
Should Fix | contract lens | Fields are private; ghost_key() and info() accessors. Only verify() makes one. |
| 3 | The "weak keys cost no RSA operation" ordering is untested | Should Fix | code-first, testing | cheap_refusals_come_before_the_chain covers weak keys with every signature broken. Moving the check after the chain fails it (checked). |
| 4 | No 4096-bit certificate is tested against the 4 KiB cap | Should Fix | code-first | End-to-end 4096-bit test that asserts the armour and the worst-case signature fit |
| 5 | The maximum key size isn't pinned (a 4100 mutant survived) | Should Fix | testing | Unit case for a 4097-bit modulus |
| 6 | rust/gkverify has no Dependabot entry |
Should Fix | big-picture | Added; rsa is ignored because it's moved by hand (#213) |
| 7 | The size comparison in the PR isn't reproducible | Should Fix | big-picture | Stated as a one-off measurement, with the full table |
| 8 | Byte-string encodings go through the new bounded_vec untested |
Consider | skeptical | Covered for the RSA and master signatures |
| 9 | Nothing in CI tests the MSRV | Consider | testing | New msrv job checks the library at Cargo.toml's rust-version, native and wasm32 |
| 10 | Main-branch runs can be cancelled by later pushes; toolchain drift goes unmentioned | Consider | code-first | cancel-in-progress only for PRs; the workflow comment now covers toolchain drift |
| 11 | README example skips the action check; the root README omits the crate |
Consider | contract lens, big-picture | Fixed |
| 12 | The probe's -99 catch-all could hide a new error |
Consider | testing | The vector test asserts no expectation is -99 |
| 13 | The explicit signature-length check is redundant with rsa's | Consider | testing | Kept as the mirrored rule, with a comment saying so |
Considered and not changed
ClaimedKeynewtype forclaimed_ghost_key()(contract lens). The docs and README show the required order, and finding 2 makes a verified result unforgeable. A second key type would add API without making the payload check meaningful on its own.- Exact rc pin. Documented in the README and tracked in #213.
Verdict
State: Needs Changes, re-review required after fix. Several Should Fix items touched parsing, so a full round 3 runs on eee220e.
[AI-assisted - Claude]
… does From the third review round on #212 (found independently by two lenses): the round-2 canonical-size bound made from_cbor fail inside armor::decode's label loop, which then moved on to the next label. ghostkey_lib has no such bound and stops at the first block that parses, so text holding an oversized decoy block followed by a genuine certificate under the unversioned label was accepted here and refused there: an accept-side difference, which the crate promises never to have. decode now returns TooLarge at that block. The difference test covers the two-block case, and fails if the early return is removed. Also from round 3: - canonical_bound_sits_exactly_at_the_limit sweeps byte-string signatures across the threshold, landing on 4096 itself, and requires the crate to parse exactly when ghostkey_lib's canonical armour fits; it kills the >= and CBOR-length mutants that survived before. - NotaryInfo is #[non_exhaustive] (made only by parse); GhostkeyCertificate is Debug; docs on TooLarge, from_cbor's many encodings, production_master. - The README example returns its own error for a non-donation action rather than misusing Error::Info. - Dependabot no longer ignores rsa, so its advisories are not silenced. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #212 (round 3, re-review)
- HEAD SHA reviewed: eee220e (CI green there, including the new
msrvjob). Fixes are in 8ccd956. - Tier: Full.
- Reviewers run: freenet:code-first-reviewer, freenet:testing-reviewer, freenet:skeptical-reviewer, freenet:big-picture-reviewer, plus the contract-safety and inbox-parity lens.
- External model:
codex reviewis still at its usage limit. The extra Claude lens substitutes, per policy.
Findings and resolution (8ccd956)
| # | Finding | Severity | Source | Resolution |
|---|---|---|---|---|
| 1 | Accept-side difference introduced in round 2. The canonical-size refusal inside armor::decode moved on to the next label, while gklib stops at the first block that parses. A 3.9-4.1 KB text with an oversized decoy _V1 block followed by a genuine certificate under the unversioned label was accepted here and refused by gklib and the inbox. Not a forgery (only a genuine certificate is accepted), but it breaks the "differences only refuse" promise. |
Should Fix | skeptical, contract lens (found independently) | decode returns TooLarge at that block. The two-block case is in the difference test, and removing the early return fails it. |
| 2 | The canonical bound was guarded only at gross scale (the >= and CBOR-length mutants survived), and assert_agree's re-parse check guarded nothing on its own |
Should Fix | testing | New canonical_bound_sits_exactly_at_the_limit sweeps byte-string signatures across the threshold, lands on exactly 4096, and runs every parsed case through assert_agree. Both mutants are now killed. |
| 3 | NotaryInfo not #[non_exhaustive], so a field added later would be a semver break |
Should Fix | big-picture | Done (it is only made by parse) |
| 4 | Deploy paragraph incomplete: .github/dependabot.yml also triggers publish-freenet.yml |
Should Fix | big-picture | PR text corrected (still one republish from main, no site change) |
| 5 | from_* docs and TooLarge docs didn't cover the canonical-size rule |
Should Fix | code-first | Documented. TooLarge now surfaces from the armour path too (it no longer collapses to Armor). |
| 6 | README example misused Error::Info for a wrong action |
Consider | big-picture | Example returns its own error |
| 7 | Dependabot ignore: rsa might also silence advisories |
Consider | code-first | Ignore removed; candidate bumps are decided by hand |
| 8 | GhostkeyCertificate has no Debug; production_master has no Panics note; from_cbor lacks the "store canonical bytes" advice; encoded_len not tested at 0 or near 4096; msrv drift unexplained |
Consider | big-picture, contract lens, skeptical, code-first | All done (encoded_len now tested over 0..=3100) |
Verified as holding (no change)
- Round trip. 200k seeded re-encodings natively and 14k under wasm32 (opt-z, LTO, panic=abort): every accepted certificate's canonical forms re-parse to themselves. No case was accepted here and refused by gklib except finding 1, now fixed. Re-encodings match gklib byte for byte.
- Arithmetic.
encoded_lenis exact. There is no reachable overflow or panic, canonicalization is idempotent, andVerifiedGhostkeycan't be forged. - MSRV job. On 1.87 it fails (sha2 0.11.1 requires 1.88); on 1.88 it passes, native and wasm32.
Not changed
YearMonthstays constructible, since callers build one to compare againstcreated_month.MIN/MAX_NOTARY_KEY_BITSstay public, as an intended part of the API.
Verdict
State: Needs Changes, re-review required after fix. Finding 1 touched parsing, so round 4 runs on 8ccd956.
[AI-assisted - Claude]
…r under wasm From the fourth review round on #212 (no Must Fix; no code change here): - The single-block and two-block oversized cases are separate tests, so removing armor::decode's TooLarge return fails each by name. - A wasm vector whose input fits and whose canonical form does not, so the computed armour length runs on 32-bit usize. - Comments: what refuses an oversized block on the inbox side; that the gklib agreement holds for the same resolved ciborium/serde/dalek versions; Dependabot's rsa note softened to what is known. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
So the vector's TooLarge comes from the canonical-form bound, not the input-length check. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #212 (round 4 re-review, plus a light re-check)
- HEAD SHA: 119aa16. CI is green on it: ghostkey-verify
testandmsrv, Rust API Tests, Freenet Deploybuild. - Tier: Full.
- Round 4 ran at 8ccd956: freenet:code-first-reviewer, freenet:testing-reviewer, freenet:skeptical-reviewer, freenet:big-picture-reviewer, and the contract-safety / inbox-parity lens.
- Light re-check: an independent freenet:skeptical-reviewer read the delta 8ccd956..119aa16. That delta is tests, comments and dependabot.yml only; no library code changed.
- External model:
codex reviewwas unavailable (usage limit) at every attempt this session. The extra Claude lens substituted for it in each round, per policy, without waiting.
Round 4 result: no Must Fix
- Parity evidence. The contract lens ran about 221k inputs, natively and under wasm32: multi-block texts mixing labels, decoys failing in 16 different ways, END and body variants, and the production certificate among decoys. The skeptical lens ran about 290k structural and text mutations. Neither found an input where this crate's verdict, chosen certificate or canonical form differs from gklib (run through the inbox's own steps), apart from the documented refusals. Every canonical form re-parses.
- Mutation testing. Every round-3 guard is caught. The only survivors are equivalent mutants: armour lengths that cannot occur, and checks duplicated by rsa.
cargo publish --dry-run. Clean. The 0.1.0 API and docs are judged right to publish.
Addressed after round 4 (119aa16, tests and comments only)
| Finding | Severity | Resolution |
|---|---|---|
| The two-block guard failed only through the single-block assertion | Should Fix (testing) | Split into two tests; removing the early return fails each by name |
| No wasm vector reached the canonical-size refusal | Consider | Added oversized-canonical-form (asserted to fit as sent); 19 vectors pass under wasm |
armor.rs comment overclaimed that gklib's chain then fails |
Consider | Reworded: the inbox's canonical-size check refuses it |
| Parity holds per resolved dependency set | Consider | Stated in the crate docs |
Dependabot comment overclaimed what ignoring rsa does |
Consider | Softened |
Not changed
- No Dependabot entry for
wasm-probe. It is CI-only and never published. - Generated
Cargo.lockin the package. This is cargo's default for a library and harmless.
Verdict
State: Ready to Merge, on review grounds.
HEAD SHA reviewed: 119aa16. Round 4 was a full re-review at 8ccd956; the later commits passed a light re-check.
[AI-assisted - Claude]
Problem
freenet/mail's v0.3.0 inbox contract needs to verify Ghost Key proofs inside contract WASM. The only verifier today is
ghostkey_lib, which carries key generation, blinding andrand; itsgetrandomrefuseswasm32-unknown-unknown, so every contract that links it (freenet-bitcoin's inbox does) has to register a never-called customgetrandombackend.Approach
A new crate,
ghostkey-verify0.1.0 inrust/gkverify, that does verification only:verify_strictof a payload signature by the ghost key, over raw bytes (the vault'sScopedPayload), so the crate does not depend on ghostkey-common or freenet-stdlib;infoJSON (action,amount,delegate-key-createdand its month).Exactness. The master key signs the CBOR re-encoding of the notary payload, so the crate mirrors
ghostkey_lib's serde types one for one, includingnum-bigint-dig'su64_digitBigUint encoding quirk (a top digit belowu32::MAXis written as one word, otherwise two). PEM parsing reproducesArmorable::from_armored_string. The deliberate differences each refuse something no genuine certificate contains, and each has a test assertingghostkey_libaccepts it and this crate refuses it:ghostkey_lib's);ghostkey_lib's rsa 0.8 reduces them; RFC 8017 refuses them).Bounds.
MAX_CERTIFICATE_BYTES(4 KiB, the inbox's bound) is refused before parsing.to_armored()/to_cbor()parse back to themselves.VerifiedGhostkeycan only be made byverify().Dependencies. ed25519-dalek 2 (no default features,
serde), rsa, sha2 0.11, ciborium, serde, serde_json (alloc), base64.rsais=0.10.0-rc.19because stable 0.9 pulls inrandthroughnum-bigint-dig'sprimefeature. The 0.10 line uses crypto-bigint and needs onlyrand_core, which this crate never calls. It is pinned exactly because the release candidate's API moves between candidates. This is the one dependency choice reviewers should weigh. The move to 0.10.0 is tracked in #213.Testing
tests/differential.rs, againstghostkey_libas the reference: valid chains (fresh RSA keys each time, re-encoding byte-identical), tampered info, wrong master, notary key swapped, notary signature by another master, wrong RSA signature (other notary, wrong message, bit flips, wrong lengths), ghost key swapped, 1,200 seeded random mutations (flips, truncation, insertion, deletion) with agreement plus identical re-encoding wherever both parse, equivalent re-encodings (unknown keys, reordered keys, zero high digits, byte-array keys, trailing bytes), 16 armour variants, gklib's legacy fixtures (cert plus signed message), and a genuine certificate under the production master key. Payload signatures are checked againstverify_strictfor valid, wrong signer, wrong or empty payload, every byte flipped, S+l malleation, wrong length and a weak key.tests/allocation.rs: a CBOR length header can't make parsing reserve memory (a counting allocator; fails without the cap).tests/wasm_vectors.rswrites 18 proof vectors. CI buildswasm-probe/(a contract-like cdylib with the freenet-bitcoin release profile) for wasm32, checks it has 0 imports, and runs every vector through it under node. That exercises the 32-bit-limb bignum path, which native tests don't reach.randorgetrandomappears incargo tree -e normal,build --target all.msrvjob checks the library at Cargo.toml'srust-version(1.88), native and wasm32.Size. CI measures this crate's probe; the comparison with
ghostkey_libis a one-off measurement and isn't committed. I built a probe doing the same work throughghostkey_lib(plus thegetrandomcustom hook) with the same profile.The ~1 MB figure only appears in an unoptimised-for-size build. Built for size, the two are about the same. The gain is the dependency tree, not bytes.
Deploy. Merging triggers two site workflows.
deploy.ymlrebuilds GitHub Pages on every push to main; that is an ordinary rebuild of unchanged content, the same as its daily scheduled run.publish-freenet.ymlrepublishes the site to Freenet from main. It ignoresrust/**, and this PR adds.github/workflows/ghostkey-verify.ymlto itspaths-ignoreso later CI edits don't trigger it. This merge still triggers it once, because it edits files outside itspaths-ignore(publish-freenet.ymlitself and.github/dependabot.yml). That run publishes from main with the same site content as the last merge. Nothing inhugo-site/changes.After merge:
cargo publishofghostkey-verify0.1.0 from main.[AI-assisted - Claude]
https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns