Skip to content

Harden schema verification across the reference SDKs - #50

Merged
jaschadub merged 15 commits into
mainfrom
security/audit-fixes
Oct 10, 2026
Merged

jaschadub merged 15 commits into
mainfrom
security/audit-fixes

Conversation

@jaschadub

@jaschadub jaschadub commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Schema verification now uses a defined cross-language canonicalization contract, P-256 validation, hardened discovery and revocation handling, authority-bound trust bundles, and domain-qualified pin stores. Skill signatures cover the complete signature document, and verifier-side lineage tracking binds schema versions. The reference server, CLI, documentation and shared conformance fixtures describe and exercise the security-contract changes.

Python requires 3.10+ and Rust requires 1.89+. Packaging and CI enforce the supported minimums, with patched Python/server dependencies, build tooling and JavaScript development dependencies. Python 3.10 uses the compatible UTC timezone API.

Validation: current-head CI passed Rust 1.89/stable with all features and Python 3.10/3.12 including reference-server routes and dependency audits. Local validation also passed Rust tests, all-target Clippy and formatting, 344 Python tests, 277 JavaScript tests and lint (36 existing warnings), Go tests/vet and eight server route tests. Dependency audits passed; Go reported no reachable vulnerabilities. Built Python wheel metadata and the 18 affected pin-store tests were checked locally. No release or tag is included.

SP-3, SP-4, SP-5: the four canonicalizers were not byte-compatible. Go
HTML-escaped <, > and & (and always escaped U+2028/U+2029), JavaScript
dropped __proto__ keys by assigning through the prototype setter and
sorted keys by UTF-16 code unit, Python emitted NaN/Infinity and 1e-07
style exponents, and integers above 2^53 silently collided in JS/Go.

Every SDK now implements the same hand-written serializer: keys sorted
by code point, minimal string escaping with literal <>& and U+2028/9,
ES6 Number::toString for non-integral values, integral values as plain
decimals, and rejection of NaN/Infinity, lone surrogates, duplicate
keys, invalid UTF-8 and any number whose magnitude exceeds 2^53-1.
Each SDK gains a strict parser (parse_schema_json / parseSchemaJson /
ParseSchemaJSON / parse_schema_json) that enforces the rejection rules
at parse time, and the Rust canonicalize functions now return Result.

tests/cross-language/canonicalization/ holds 33 vectors (19 canonical
with expected bytes and SHA-256, 14 reject cases) asserted by all four
suites. Also formats the Rust tree and silences the async-trait
double_must_use lint so clippy -D warnings passes on current toolchains.
SP-2: Go passed the schema digest straight to ecdsa.Sign while Python,
JavaScript and Rust all sign ECDSA(SHA-256(digest)), so no Go-signed
schema or skill verified anywhere else and vice versa. SignHash and
VerifySignature now apply SHA-256 to their input first, matching the
other three; the bundle path stops pre-hashing so its wire format is
unchanged (the shared signed_bundle fixture still verifies).

SP-10: Python accepted any EC curve, JavaScript accepted any OpenSSL
key including RSA, Go never checked the curve. Key loading, signing,
verification and fingerprinting now reject anything but secp256r1.

SP-19: Go ignored trailing bytes after the DER signature; it now
rejects them. go.mod pins go 1.22 with a toolchain directive.

Adds tests/cross-language/signatures/schema-signature.json, a fixed
ECDSA vector (signature, tampered schema, trailing-byte signature)
asserted by all four suites, test-only key material under
tests/cross-language/keys/ including P-384 and RSA public keys that
every SDK must reject, and a Go unit test pinning the hash convention.
SP-11: three SDKs fetched keys over http:// when the domain string
carried that prefix, every fetcher followed redirects (including scheme
downgrades), none capped the response body, Rust had no timeout, and no
SDK validated the domain before using it as a URL, pin key or file name.
Each SDK now has a shared RFC 1123 domain validator (domain module) and
a hardened transport helper: https only, redirects refused, 64 KiB body
cap, connect/read timeouts, and the JavaScript abort timer now covers
body consumption. construct_well_known_url validates and normalizes the
domain and refuses http://.

SP-16: LocalFileResolver built paths from the raw domain string, so
"../x" or an absolute path selected an arbitrary JSON file. Paths are
now derived from the validated domain and must stay under the
configured directory.

SP-9: revocation fetch failures were swallowed as "not revoked" in the
Python, JavaScript and Go resolver and legacy paths and in Rust's
ChainResolver. fetch_revocation_document now raises/returns an error,
ChainResolver propagates it, validate_key_not_revoked fails closed and
also consults revocation_endpoint, the Go workflow/pinning/CLI paths no
longer "proceed with caution", and Go's KeyPinning gains a
SetRevocationChecker hook so integrators can plug in offline sources.
The Go --auto-pin flag now pins without --interactive as documented.
SP-1: the skill signature covered only the 32-byte root hash, so
expires_at, schema_version, previous_hash, canonicalization, domain,
skill_name, signer_kid, signed_at and file_manifest were all unsigned
and freely rewritable (expiry stripping, lineage splicing). New
signatures are written as schemapin_version "1.4" and sign the
canonical (schemapin-v1) signature document with signature set to "";
verifiers recompute the root hash, require skill_hash to match it
(SP-14, now hex(root) in Rust too) and verify over the canonical
document. Pre-1.4 "1.3" documents still verify through the legacy
root-hash path with a legacy_signature_format warning, and
verify_chain refuses to anchor on them.

SP-12/SP-13: skill canonicalization v2 hashes every entry as
SHA-256("f" || be64(len(path)) || path || be64(len(content)) ||
content) and the root over length-prefixed path/digest pairs, so the
path/content boundary is no longer malleable; symlinks and special
files abort canonicalization instead of being silently skipped; file
count and total size are capped. The legacy algorithm is kept only for
verifying 1.3 documents.

SP-8: verify_skill_with_resolver binds the caller's domain to the
signature's domain before any I/O, and the offline verifiers accept an
expected domain (DOMAIN_MISMATCH / DOMAIN_INVALID); the signature's own
domain field is display-only and flagged with domain_unbound when used.

SP-17: expiration is fail-closed (SIGNATURE_EXPIRED) with a 5-minute
skew window and strict RFC 3339 parsing in every SDK; allow_expired
restores the degraded-not-failed behavior. Pins are written only after
a successful verification (KeyPinStore gains check/pin/seed), the DNS
TXT cross-check runs before pinning, and a DNS-aware schema verifier is
added. Go rejects an explicitly empty canonicalization field (SP-19).

tests/cross-language/skill/ adds a v1.4 signed fixture and a legacy 1.3
fixture with expected manifests and skill_hash, asserted by all four
suites including per-field tamper checks.
… key

SP-6: verify_trust_bundle TOFU-pinned the bundle authority by the kid
string inside the bundle, so any self-signed bundle under a fresh kid
passed on first use and could load attacker keys for any domain. The
authority must now be pre-seeded (seed_bundle_authority /
seed_bundle_authority_from_pem) or verification fails with
BUNDLE_AUTHORITY_UNTRUSTED; first-use trust is an explicit opt-in
(allow_first_use) and the structured result reports first_use so hosts
can gate on it. The authority is pinned only after the signature
verifies.

Design issue 4: merge_trust_bundles compared timestamp strings
lexicographically and let any authority win. Merges now take an
optional authority precedence list and compare parsed RFC 3339
instants; unparseable timestamps rank oldest.

SP-19: the bundle signing input is built from the known field set in
Python and JavaScript too (optional strings omitted, revoked_keys
always present), matching the Go/Rust struct serialization, so
unknown fields no longer change the bytes in two SDKs only; duplicate
domain entries are rejected at sign, parse and resolver load; JS
parses expires_at with the strict RFC 3339 parser.

SP-18: the fixture authority private key (go/schemapin_private.pem)
and the demo private keys are removed from the repository and ignored
going forward; tests/cross-language/signed_bundle.json is re-signed
under the clearly test-named kid schemapin-test-fixture-authority with
the key in tests/cross-language/keys/, and every SDK's fixture test
seeds that authority instead of relying on first use. Python keygen
and the demo scripts write private keys with mode 0600.
SP-15: the legacy KeyPinning stores (Python SQLite, JavaScript JSON,
Go Bolt) were keyed by tool_id alone, so whoever got pinned first
under a bare tool id owned it for every domain and the workflow's
domain argument only affected revocation lookups. Pins are now keyed
by tool_id@domain in all three stores, existing stores are migrated
in place on open, lookups take the domain and never return a pin made
for another domain (the domain-less lookups are kept as deprecated
shims that refuse to guess when a tool id is pinned for several
domains), and the SchemaVerificationWorkflow in all three languages
validates the domain and pins only after the signature verifies.

The JavaScript store additionally fails hard on an unreadable or
malformed file instead of silently resetting to "first use", writes
atomically (temp file + rename) with mode 0600, and its interactive
flows now run the fail-closed revocation check (with an injectable
revocationChecker for offline use); the Python store is created
0600. Design issue 9: the legacy store and workflow are marked
deprecated in favor of KeyPinStore + verify_schema_with_resolver.
SP-7: the server accepted unauthenticated key upload and revocation,
combined CORS allow_origins ["*"] with allow_credentials, wiped the
revocation list on every upload, ran with debug/reload on 0.0.0.0 and
never read its own rate-limit or admin_api settings. Every mutation
now requires a bearer token (security.admin_token or
SCHEMAPIN_ADMIN_TOKEN) and features.admin_api, which defaults to off;
uploads merge (the previous key is moved to revoked_keys, existing
revocations are preserved, a revoked key cannot be re-uploaded) and
only P-256 keys are accepted; credentials are never sent with a
wildcard origin; a per-client rate limit is enforced; defaults bind to
127.0.0.1 with debug and reload off.

Design issue 11: /.well-known/schemapin.json now selects the developer
from the request Host (RFC 8615) with no first-developer fallback, so
a client resolving one domain is never handed another developer's
key; the per-domain path is documented as a non-standard rewrite
target. The app is built by create_app(config) so it can be tested;
server/tests/test_server.py covers auth, merge-on-upload, host
routing, CORS, key policy and rate limiting.
Design issue 3: spec section 18.4 asks verifiers to keep a
latest_known_hash per tool, but no SDK shipped one, so verify_chain
could only compare two documents the caller already held and a
publisher (or anyone with the key) could fork from an old version or
reuse a schema_version. LineageStore (Python, JavaScript, Go, Rust)
records the latest accepted skill_hash and schema_version per
tool_id@domain, requires every new document to name that hash as its
previous_hash, requires schema_version to increase (numeric
major.minor.patch comparison), refuses legacy 1.3 documents whose
hashes are unsigned, and round-trips through JSON for persistence.
cargo audit flagged crossbeam-epoch 0.9.18 (RUSTSEC-2026-0204), h2
0.4.13 (RUSTSEC-2026-0258), rustls 0.23.36 (RUSTSEC-2026-0285) and the
yanked chacha20 0.10.0 in the optional fetch/dns dependency trees;
they are updated to patched releases and the audit is clean.
govulncheck reported go1.26.0 standard-library issues reachable from
the discovery code; go.mod now pins toolchain go1.26.9 so builds use
a patched runtime.
No fuzz or property tests existed for the parsers that face untrusted
input. Python gains hypothesis tests, JavaScript a seeded-generator
property suite, Go native fuzz targets (run as seeds in go test) for
the strict JSON parser, TXT parser, domain validator, PEM loader,
.schemapin.sig and trust-bundle parsing, and Rust gains proptest
properties plus libFuzzer targets under rust/fuzz/. Every property
checks that canonicalization is idempotent and that parsers fail only
with their documented error types.

The Rust property test found a real divergence: serde_json's default
float parsing is best-effort and rounds some 17-significant-digit
inputs to a different double than Python, JavaScript and Go, which
would change the canonical bytes. The float_roundtrip feature is now
enabled and a corpus vector (34-float-17-digits) pins the case for
all four SDKs.
SP-20 and the spec gaps from the review. The specification now defines
schemapin-v1 canonicalization normatively (section 4: code-point key
order, minimal escaping, ES6 numbers with the 2^53-1 limit, rejection
rules, the signing construction), adds section 4b for skill directory
canonicalization and the signed signature document, makes pinning
happen after verification (sections 7 and 15), states the transport
and fail-closed revocation requirements (section 9), rewrites
expiration as fail-closed with a risk statement (section 16), covers
the DNS cross-check for schemas (section 17), specifies the
LineageStore (section 18.4) and the bundle authority allow-list,
signing field set and merge precedence with a risk statement
(section 21).

SECURITY.md lists the supported versions and the fail-closed
properties; the "Rust only" banners are gone; docs/skill-signing.md
examples now match the real APIs and describe the v1.4 format;
auto-pin is documented as an opt-in rather than the standard call;
trust-bundle and version-binding docs describe the allow-list and
LineageStore; the integration demo no longer disables revocation
checks or uses plaintext discovery; keygen only produces ECDSA P-256
keys; CHANGELOG gains an Unreleased section listing every fix by ID.
@jaschadub
jaschadub merged commit 0b32060 into main Oct 10, 2026
4 checks passed
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