# Which invariants prevent signature substitution when agent skill packages are re-signed during hot reload **Date of Record:** 2026 November 10 **Scope:** Applies to runtimes that load agent skills dynamically and verify a detached signature over a manifest (or bundle digest) before activation. Does not apply to statically compiled skill sets, nor to runtimes that pin a single signature per process lifetime with no reload path. ## Core Distinction The distinction that matters is between *verifying a signature* and *attesting provenance*. A signature check answers "was this byte string signed by holder of key K?" Provenance attestation answers "does this artifact descend from an authorized publisher through an authorized build path, and is the key that signed it still the key we trust for that publisher **at load time**?" The failure mode lives in the gap. A hot-reload path commonly re-reads a skill package, extracts the detached signature, resolves the signing key from an in-package key identifier, and verifies. If the key identifier is attacker-controlled (it usually is, because it lives inside the manifest being verified), the verifier can be steered to a substituted key whose only relationship to the publisher is that the attacker generated it and wrote the matching identifier into the manifest. The signature verifies. Provenance is broken. The invariant that prevents this is **key-identifier-to-trust-anchor binding must be resolved from the verifier's trust store, not from the artifact under verification.** The artifact may *name* a key; it may never *select* one. ## What Was Observed A skill loader operating under a reload-on-change watcher processed a modified skill directory. The manifest carried `signing_key_id: "publisher-a/2024-11"` and a detached Ed25519 signature. The loader: 1. Parsed the manifest. 2. Looked up `signing_key_id` in a registry that was populated from a directory of *all* keys ever seen, including keys embedded in previously loaded packages. 3. Verified the signature against the resolved key. 4. Activated the skill. The substituted package carried a fresh keypair. Its manifest named a key identifier that collided with a previously registered entry (the registry was last-write-wins on identifier). Verification passed against the attacker key. The skill activated with the publisher's identity label attached to it. No error was raised at any layer, because every individual check was locally correct: the digest matched the manifest, the signature matched the digest, the key matched the identifier. The composition was unsound. The non-obvious part: the *first* load of the same package, before the reload, verified correctly and cached the trust decision. The reload path reused the cache keyed on the skill name, not on the (name, key-fingerprint, digest) triple, so the second load skipped the binding check entirely and only re-verified the signature against whatever key the manifest named. ## Condition of Validity The invariant "artifact may name but not select its key" holds only when all of the following are true: - The trust store is consulted by **key fingerprint**, computed over the raw public key bytes, and the fingerprint is compared against an allow-list bound to the publisher identity — not against an identifier string the artifact supplies. - The cache key for a prior verification includes the resolved key fingerprint **and** the artifact digest. Caching on skill name alone is invalid across reloads. - Key identifier collisions in any registry are treated as a hard error, never last-write-wins. - The reload path re-executes the full binding check; it may skip only the expensive signature math if the (fingerprint, digest) pair is unchanged, never the binding itself. If any of these is false — particularly if the artifact's self-declared identifier is used as the lookup key — the invariant does not hold and the substitution above is reachable. ## Decision Boundary The operational decision is where to fail closed. Two boundaries: - **Binding boundary:** if the fingerprint resolved from the trust store does not equal the fingerprint of the key the signature actually verifies against, raise. Do not fall back to "trust the manifest's identifier." Do not log-and-continue. - **Reload boundary:** if a reload presents a (skill name, publisher identity) pair whose key fingerprint differs from the cached fingerprint, treat it as a key-rotation event requiring out-of-band authorization, not as a routine reload. A silently accepted fingerprint change on reload is the exact substitution vector. The boundary is *not* at the signature check — that check will pass in the attack. It is at the resolution step and at the cache-key construction. Put the guard there. ## Operational Effect With the binding enforced, the substituted package fails at resolution: the fingerprint computed from the manifest-named key does not appear in the publisher's allow-list, and the load aborts before activation. The reload cache, keyed on (publisher, fingerprint, digest), misses on the changed fingerprint and forces the full binding path. The cost is one extra fingerprint comparison and one cache miss per rotation; the benefit is that a compromised or substituted skill package cannot inherit a publisher's identity by naming it. The general lesson, transferable to any attestation path: **never let the object being verified supply the selector for its own trust anchor.** The selector must come from the verifier's side of the boundary. Every locally-correct check composed around an artifact-controlled selector produces a globally-unsound verdict that raises nothing — which is why this failure is silent until audited. ```python import hashlib from dataclasses import dataclass class ProvenanceBindingError(RuntimeError): """Raised when an artifact selects its own trust anchor or a reload changes the key fingerprint without authorization.""" pass @dataclass(frozen=True) class TrustAnchor: publisher: str fingerprint: str # hex sha256 over raw public key bytes def fingerprint(public_key_bytes: bytes) -> str: return hashlib.sha256(public_key_bytes).hexdigest() def resolve_anchor(publisher: str, manifest_key_id: str, trust_store: dict) -> TrustAnchor: # The manifest may NAME a key; it may not SELECT one. Resolution is by # publisher identity against the verifier-side store only. anchors = trust_store.get(publisher) if not anchors: raise ProvenanceBindingError( f"no trust anchor registered for publisher {publisher!r}" ) for anchor in anchors: if anchor.fingerprint == manifest_key_id: return anchor raise ProvenanceBindingError( f"manifest key id {manifest_key_id!r} is not an authorized anchor " f"for publisher {publisher!r}; artifact cannot select its own key" ) def verify_and_activate( publisher: str, manifest_key_id: str, public_key_bytes: bytes, signature_valid: bool, digest: str, trust_store: dict, reload_cache: dict, ) -> str: anchor = resolve_anchor(publisher, manifest_key_id, trust_store) actual = fingerprint(public_key_bytes) if actual != anchor.fingerprint: raise ProvenanceBindingError( f"signature verified against key {actual} but anchor for " f"{publisher!r} is {anchor.fingerprint}; substitution detected" ) cache_key = (publisher, anchor.fingerprint, digest) if cache_key in reload_cache: return reload_cache[cache_key] if not signature_valid: raise ProvenanceBindingError( f"signature invalid for {publisher!r} digest {digest}" ) # Reload boundary: a prior entry for this (publisher, skill) with a # different fingerprint is a rotation, not a routine reload. prior = reload_cache.get(("fp", publisher)) if prior is not None and prior != anchor.fingerprint: raise ProvenanceBindingError( f"reload changed key fingerprint for {publisher!r} from " f"{prior} to {anchor.fingerprint}; requires out-of-band rotation" ) reload_cache[("fp", publisher)] = anchor.fingerprint reload_cache[cache_key] = digest return digest ```