From 68dfaef392e30ac8bc712b73c6826fa93e04689f Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 10:17:10 -0700 Subject: [PATCH 01/14] Specify schemapin-v1 canonicalization and add a cross-language corpus 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. --- go/pkg/core/core.go | 598 +++++++++++++++++- go/pkg/core/core_test.go | 57 ++ go/pkg/core/cross_language_test.go | 89 +++ javascript/src/core.js | 469 +++++++++++++- javascript/tests/core.test.js | 49 +- javascript/tests/cross-language.test.js | 45 ++ python/schemapin/core.py | 269 +++++++- python/tests/test_core.py | 47 ++ python/tests/test_cross_language.py | 47 ++ rust/src/bundle.rs | 45 +- rust/src/canonicalize.rs | 402 +++++++++++- rust/src/error.rs | 3 + rust/src/resolver.rs | 1 + rust/src/verification.rs | 14 +- rust/tests/cross_language_canonicalization.rs | 59 ++ tests/cross-language/README.md | 52 +- .../canonicalization/01-spec-example.json | 8 + .../canonicalization/02-nested-sort.json | 8 + .../canonicalization/03-html-chars.json | 8 + .../canonicalization/04-line-separators.json | 8 + .../canonicalization/05-control-escapes.json | 8 + .../canonicalization/06-unicode-literal.json | 8 + .../canonicalization/07-astral-key-order.json | 8 + .../canonicalization/08-key-order-ascii.json | 8 + .../canonicalization/09-numbers-integral.json | 8 + .../10-numbers-fractional.json | 8 + .../canonicalization/11-max-safe-integer.json | 8 + .../canonicalization/12-empty-containers.json | 8 + .../canonicalization/13-proto-key.json | 8 + .../canonicalization/14-whitespace.json | 8 + .../canonicalization/15-literals.json | 8 + .../16-keys-with-escapes.json | 8 + .../canonicalization/17-deep-nesting.json | 8 + .../canonicalization/18-top-level-array.json | 8 + .../canonicalization/19-mcp-tool.json | 8 + .../20-reject-duplicate-keys.json | 6 + .../21-reject-duplicate-keys-nested.json | 6 + .../canonicalization/22-reject-nan.json | 6 + .../canonicalization/23-reject-infinity.json | 6 + .../24-reject-lone-high-surrogate.json | 6 + .../25-reject-lone-low-surrogate.json | 6 + .../canonicalization/26-reject-2-pow-53.json | 6 + .../27-reject-2-pow-53-plus-1.json | 6 + .../canonicalization/28-reject-1e21.json | 6 + .../canonicalization/29-reject-1e16.json | 6 + .../30-reject-huge-negative-int.json | 6 + .../31-reject-trailing-garbage.json | 6 + .../32-reject-unescaped-control.json | 6 + .../33-reject-invalid-escape.json | 6 + 49 files changed, 2325 insertions(+), 157 deletions(-) create mode 100644 go/pkg/core/cross_language_test.go create mode 100644 javascript/tests/cross-language.test.js create mode 100644 python/tests/test_cross_language.py create mode 100644 rust/tests/cross_language_canonicalization.rs create mode 100644 tests/cross-language/canonicalization/01-spec-example.json create mode 100644 tests/cross-language/canonicalization/02-nested-sort.json create mode 100644 tests/cross-language/canonicalization/03-html-chars.json create mode 100644 tests/cross-language/canonicalization/04-line-separators.json create mode 100644 tests/cross-language/canonicalization/05-control-escapes.json create mode 100644 tests/cross-language/canonicalization/06-unicode-literal.json create mode 100644 tests/cross-language/canonicalization/07-astral-key-order.json create mode 100644 tests/cross-language/canonicalization/08-key-order-ascii.json create mode 100644 tests/cross-language/canonicalization/09-numbers-integral.json create mode 100644 tests/cross-language/canonicalization/10-numbers-fractional.json create mode 100644 tests/cross-language/canonicalization/11-max-safe-integer.json create mode 100644 tests/cross-language/canonicalization/12-empty-containers.json create mode 100644 tests/cross-language/canonicalization/13-proto-key.json create mode 100644 tests/cross-language/canonicalization/14-whitespace.json create mode 100644 tests/cross-language/canonicalization/15-literals.json create mode 100644 tests/cross-language/canonicalization/16-keys-with-escapes.json create mode 100644 tests/cross-language/canonicalization/17-deep-nesting.json create mode 100644 tests/cross-language/canonicalization/18-top-level-array.json create mode 100644 tests/cross-language/canonicalization/19-mcp-tool.json create mode 100644 tests/cross-language/canonicalization/20-reject-duplicate-keys.json create mode 100644 tests/cross-language/canonicalization/21-reject-duplicate-keys-nested.json create mode 100644 tests/cross-language/canonicalization/22-reject-nan.json create mode 100644 tests/cross-language/canonicalization/23-reject-infinity.json create mode 100644 tests/cross-language/canonicalization/24-reject-lone-high-surrogate.json create mode 100644 tests/cross-language/canonicalization/25-reject-lone-low-surrogate.json create mode 100644 tests/cross-language/canonicalization/26-reject-2-pow-53.json create mode 100644 tests/cross-language/canonicalization/27-reject-2-pow-53-plus-1.json create mode 100644 tests/cross-language/canonicalization/28-reject-1e21.json create mode 100644 tests/cross-language/canonicalization/29-reject-1e16.json create mode 100644 tests/cross-language/canonicalization/30-reject-huge-negative-int.json create mode 100644 tests/cross-language/canonicalization/31-reject-trailing-garbage.json create mode 100644 tests/cross-language/canonicalization/32-reject-unescaped-control.json create mode 100644 tests/cross-language/canonicalization/33-reject-invalid-escape.json diff --git a/go/pkg/core/core.go b/go/pkg/core/core.go index 760a79a..65eb478 100644 --- a/go/pkg/core/core.go +++ b/go/pkg/core/core.go @@ -1,12 +1,50 @@ // Package core provides schema canonicalization and hashing functionality for SchemaPin. +// +// It implements the schemapin-v1 canonical JSON serialization specified in +// TECHNICAL_SPECIFICATION.md section 4. The rules are strict so that every SDK +// produces byte-identical output: +// +// - Object keys are sorted by Unicode code point (bytewise order of their +// UTF-8 encoding). +// - Duplicate object keys, NaN/Infinity, lone surrogates and invalid UTF-8 +// are rejected -- they have no canonical form. +// - Strings are escaped minimally: '"' and '\' are backslash-escaped, +// U+0008/U+000C/U+000A/U+000D/U+0009 use \b \f \n \r \t, the remaining C0 +// controls use lowercase \u00xx, and everything else (including '<', '>', +// '&', U+007F, U+2028 and U+2029) is emitted literally. This is done by +// hand rather than via encoding/json, whose HTML escaping and +// toolchain-dependent \b/\f handling both diverge from the other SDKs. +// - Numbers are IEEE-754 binary64. Any value whose magnitude exceeds +// 2**53 - 1 is rejected. Integral values are emitted as plain decimal +// integers (-0 -> 0); non-integral values use ECMAScript Number::toString +// (RFC 8785 section 3.2.2.3). package core import ( "crypto/sha256" "encoding/json" + "errors" "fmt" + "math" + "sort" + "strconv" + "strings" + "unicode/utf8" ) +// MaxSafeInteger is the largest integer magnitude accepted by the canonicalizer (2**53 - 1). +const MaxSafeInteger = 9007199254740991 + +const maxDepth = 512 + +// ErrCanonicalization wraps every canonicalization / strict-parse failure so +// callers can test for it with errors.Is. +var ErrCanonicalization = errors.New("canonicalization error") + +func canonErr(format string, args ...interface{}) error { + return fmt.Errorf("%w: %s", ErrCanonicalization, fmt.Sprintf(format, args...)) +} + // SchemaPinCore provides schema canonicalization and hashing type SchemaPinCore struct{} @@ -16,24 +54,23 @@ func NewSchemaPinCore() *SchemaPinCore { } // CanonicalizeSchema converts a schema to its canonical string representation -// This matches the Python implementation exactly: -// - UTF-8 encoding -// - Remove insignificant whitespace -// - Sort keys lexicographically (recursive) -// - Strict JSON serialization +// per schemapin-v1 (see the package documentation for the normative rules). func (s *SchemaPinCore) CanonicalizeSchema(schema map[string]interface{}) (string, error) { - // Use Go's json.Marshal with custom encoder settings to match Python's behavior - // json.dumps(schema, ensure_ascii=False, separators=(',', ':'), sort_keys=True) - - // Create a buffer to hold the canonical JSON - canonical, err := json.Marshal(schema) - if err != nil { - return "", fmt.Errorf("failed to canonicalize schema: %w", err) + if schema == nil { + return "", canonErr("schema cannot be nil") } + return CanonicalizeValue(schema) +} - // Go's json.Marshal automatically sorts keys and uses compact format - // which matches Python's separators=(',', ':') and sort_keys=True - return string(canonical), nil +// CanonicalizeValue canonicalizes any JSON-compatible Go value (maps, slices, +// strings, bools, nil, numeric types, json.Number, or any value that +// encoding/json can marshal). +func CanonicalizeValue(v interface{}) (string, error) { + var sb strings.Builder + if err := writeCanonical(&sb, v, 0); err != nil { + return "", err + } + return sb.String(), nil } // HashCanonical computes SHA-256 hash of canonical schema string @@ -51,18 +88,14 @@ func (s *SchemaPinCore) CanonicalizeAndHash(schema map[string]interface{}) ([]by return s.HashCanonical(canonical), nil } -// ValidateSchema performs basic validation on a schema +// ValidateSchema performs basic validation on a schema: it must canonicalize. func (s *SchemaPinCore) ValidateSchema(schema map[string]interface{}) error { if schema == nil { return fmt.Errorf("schema cannot be nil") } - - // Basic validation - ensure it's valid JSON-serializable - _, err := json.Marshal(schema) - if err != nil { - return fmt.Errorf("schema is not valid JSON: %w", err) + if _, err := s.CanonicalizeSchema(schema); err != nil { + return fmt.Errorf("schema is not canonicalizable: %w", err) } - return nil } @@ -71,8 +104,523 @@ func (s *SchemaPinCore) NormalizeSchema(schema map[string]interface{}) (map[stri if err := s.ValidateSchema(schema); err != nil { return nil, err } - - // For now, normalization is just validation - // Future versions might add more normalization steps return schema, nil } + +// --------------------------------------------------------------------------- +// Serializer +// --------------------------------------------------------------------------- + +func writeCanonical(sb *strings.Builder, v interface{}, depth int) error { + if depth > maxDepth { + return canonErr("nesting depth exceeds limit") + } + switch x := v.(type) { + case nil: + sb.WriteString("null") + case bool: + if x { + sb.WriteString("true") + } else { + sb.WriteString("false") + } + case string: + return writeCanonicalString(sb, x) + case float64: + return writeFloat(sb, x) + case float32: + return writeFloat(sb, float64(x)) + case int: + return writeInt(sb, int64(x)) + case int8: + return writeInt(sb, int64(x)) + case int16: + return writeInt(sb, int64(x)) + case int32: + return writeInt(sb, int64(x)) + case int64: + return writeInt(sb, x) + case uint: + return writeUint(sb, uint64(x)) + case uint8: + return writeUint(sb, uint64(x)) + case uint16: + return writeUint(sb, uint64(x)) + case uint32: + return writeUint(sb, uint64(x)) + case uint64: + return writeUint(sb, x) + case json.Number: + f, err := strconv.ParseFloat(string(x), 64) + if err != nil { + return canonErr("malformed number %q", string(x)) + } + return writeFloat(sb, f) + case map[string]interface{}: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) // bytewise == code point order for valid UTF-8 + sb.WriteByte('{') + for i, k := range keys { + if i > 0 { + sb.WriteByte(',') + } + if err := writeCanonicalString(sb, k); err != nil { + return err + } + sb.WriteByte(':') + if err := writeCanonical(sb, x[k], depth+1); err != nil { + return err + } + } + sb.WriteByte('}') + case []interface{}: + sb.WriteByte('[') + for i, item := range x { + if i > 0 { + sb.WriteByte(',') + } + if err := writeCanonical(sb, item, depth+1); err != nil { + return err + } + } + sb.WriteByte(']') + default: + // Structs, typed maps/slices, etc.: round-trip through encoding/json + // and the strict parser so the same rules apply. + raw, err := json.Marshal(v) + if err != nil { + return canonErr("unsupported value: %v", err) + } + parsed, err := ParseSchemaJSON(raw) + if err != nil { + return err + } + return writeCanonical(sb, parsed, depth+1) + } + return nil +} + +func writeInt(sb *strings.Builder, i int64) error { + if i > MaxSafeInteger || i < -MaxSafeInteger { + return canonErr("integer %d exceeds the 2**53-1 safe range", i) + } + sb.WriteString(strconv.FormatInt(i, 10)) + return nil +} + +func writeUint(sb *strings.Builder, u uint64) error { + if u > MaxSafeInteger { + return canonErr("integer %d exceeds the 2**53-1 safe range", u) + } + sb.WriteString(strconv.FormatUint(u, 10)) + return nil +} + +func writeFloat(sb *strings.Builder, f float64) error { + if math.IsNaN(f) || math.IsInf(f, 0) { + return canonErr("NaN and Infinity are not valid JSON") + } + if math.Abs(f) > MaxSafeInteger { + return canonErr("number %v exceeds the 2**53-1 safe range", f) + } + if f == math.Trunc(f) { + sb.WriteString(strconv.FormatInt(int64(f), 10)) // also maps -0 to 0 + return nil + } + sb.WriteString(es6NumberToString(f)) + return nil +} + +// es6NumberToString formats a finite, non-integral float64 exactly as +// ECMAScript Number::toString would (RFC 8785 section 3.2.2.3). +func es6NumberToString(f float64) string { + neg := f < 0 + if neg { + f = -f + } + // Shortest round-trip digits in d.ddddde±xx form. + text := strconv.FormatFloat(f, 'e', -1, 64) + mant, expStr, _ := strings.Cut(text, "e") + exp, _ := strconv.Atoi(expStr) + intPart, fracPart, _ := strings.Cut(mant, ".") + digits := strings.TrimLeft(intPart+fracPart, "0") + exp -= len(fracPart) + trimmed := strings.TrimRight(digits, "0") + exp += len(digits) - len(trimmed) + digits = trimmed + k := len(digits) + n := exp + k + + var body string + switch { + case n > 0 && n <= 21: + body = digits[:n] + "." + digits[n:] + case n > -6 && n <= 0: + body = "0." + strings.Repeat("0", -n) + digits + default: + e := n - 1 + sign := "+" + if e < 0 { + sign = "-" + e = -e + } + if k == 1 { + body = digits + "e" + sign + strconv.Itoa(e) + } else { + body = digits[:1] + "." + digits[1:] + "e" + sign + strconv.Itoa(e) + } + } + if neg { + return "-" + body + } + return body +} + +const hexDigits = "0123456789abcdef" + +func writeCanonicalString(sb *strings.Builder, s string) error { + if !utf8.ValidString(s) { + return canonErr("string is not valid UTF-8") + } + sb.WriteByte('"') + for _, r := range s { + switch r { + case '"': + sb.WriteString(`\"`) + case '\\': + sb.WriteString(`\\`) + case '\b': + sb.WriteString(`\b`) + case '\f': + sb.WriteString(`\f`) + case '\n': + sb.WriteString(`\n`) + case '\r': + sb.WriteString(`\r`) + case '\t': + sb.WriteString(`\t`) + default: + if r < 0x20 { + sb.WriteString(`\u00`) + sb.WriteByte(hexDigits[r>>4]) + sb.WriteByte(hexDigits[r&0xf]) + } else { + sb.WriteRune(r) + } + } + } + sb.WriteByte('"') + return nil +} + +// --------------------------------------------------------------------------- +// Strict parser +// --------------------------------------------------------------------------- + +// ParseSchemaJSON parses JSON text strictly for canonicalization. It rejects +// duplicate object keys, NaN/Infinity, numbers outside the 2**53-1 safe +// range, lone surrogates, invalid UTF-8 and unescaped control characters. +// Objects decode to map[string]interface{}, arrays to []interface{} and +// numbers to float64 (every accepted number is exactly representable or is a +// non-integral value whose binary64 rounding is what every SDK uses). +func ParseSchemaJSON(data []byte) (interface{}, error) { + if !utf8.Valid(data) { + return nil, canonErr("input is not valid UTF-8") + } + p := &strictParser{data: data} + p.skipWs() + v, err := p.parseValue(0) + if err != nil { + return nil, err + } + p.skipWs() + if p.pos != len(p.data) { + return nil, p.fail("trailing characters") + } + return v, nil +} + +// ParseSchemaObject is ParseSchemaJSON restricted to a top-level JSON object. +func ParseSchemaObject(data []byte) (map[string]interface{}, error) { + v, err := ParseSchemaJSON(data) + if err != nil { + return nil, err + } + obj, ok := v.(map[string]interface{}) + if !ok { + return nil, canonErr("top-level JSON value must be an object") + } + return obj, nil +} + +type strictParser struct { + data []byte + pos int +} + +func (p *strictParser) fail(msg string) error { + return canonErr("invalid JSON at offset %d: %s", p.pos, msg) +} + +func (p *strictParser) skipWs() { + for p.pos < len(p.data) { + switch p.data[p.pos] { + case ' ', '\t', '\n', '\r': + p.pos++ + default: + return + } + } +} + +func (p *strictParser) parseValue(depth int) (interface{}, error) { + if depth > maxDepth { + return nil, p.fail("nesting depth exceeds limit") + } + if p.pos >= len(p.data) { + return nil, p.fail("unexpected end of input") + } + switch c := p.data[p.pos]; { + case c == '{': + return p.parseObject(depth) + case c == '[': + return p.parseArray(depth) + case c == '"': + return p.parseString() + case c == 't': + return p.parseLiteral("true", true) + case c == 'f': + return p.parseLiteral("false", false) + case c == 'n': + return p.parseLiteral("null", nil) + case c == '-' || (c >= '0' && c <= '9'): + return p.parseNumber() + default: + return nil, p.fail(fmt.Sprintf("unexpected character %q", c)) + } +} + +func (p *strictParser) parseLiteral(word string, v interface{}) (interface{}, error) { + if strings.HasPrefix(string(p.data[p.pos:]), word) { + p.pos += len(word) + return v, nil + } + return nil, p.fail("expected " + word) +} + +func (p *strictParser) parseNumber() (interface{}, error) { + start := p.pos + if p.data[p.pos] == '-' { + p.pos++ + } + if p.pos >= len(p.data) { + return nil, p.fail("malformed number") + } + if p.data[p.pos] == '0' { + p.pos++ + } else if p.data[p.pos] >= '1' && p.data[p.pos] <= '9' { + for p.pos < len(p.data) && p.data[p.pos] >= '0' && p.data[p.pos] <= '9' { + p.pos++ + } + } else { + return nil, p.fail("malformed number") + } + if p.pos < len(p.data) && p.data[p.pos] == '.' { + p.pos++ + if p.pos >= len(p.data) || p.data[p.pos] < '0' || p.data[p.pos] > '9' { + return nil, p.fail("malformed number") + } + for p.pos < len(p.data) && p.data[p.pos] >= '0' && p.data[p.pos] <= '9' { + p.pos++ + } + } + if p.pos < len(p.data) && (p.data[p.pos] == 'e' || p.data[p.pos] == 'E') { + p.pos++ + if p.pos < len(p.data) && (p.data[p.pos] == '+' || p.data[p.pos] == '-') { + p.pos++ + } + if p.pos >= len(p.data) || p.data[p.pos] < '0' || p.data[p.pos] > '9' { + return nil, p.fail("malformed number") + } + for p.pos < len(p.data) && p.data[p.pos] >= '0' && p.data[p.pos] <= '9' { + p.pos++ + } + } + text := string(p.data[start:p.pos]) + f, err := strconv.ParseFloat(text, 64) + if err != nil && !errors.Is(err, strconv.ErrRange) { + return nil, p.fail("malformed number") + } + if math.IsInf(f, 0) || math.IsNaN(f) || math.Abs(f) > MaxSafeInteger { + return nil, canonErr("number %s exceeds the 2**53-1 safe range", text) + } + return f, nil +} + +func (p *strictParser) parseHex4() (rune, error) { + if p.pos+4 > len(p.data) { + return 0, p.fail("invalid \\u escape") + } + v, err := strconv.ParseUint(string(p.data[p.pos:p.pos+4]), 16, 32) + if err != nil { + return 0, p.fail("invalid \\u escape") + } + p.pos += 4 + return rune(v), nil +} + +func (p *strictParser) parseString() (string, error) { + p.pos++ // opening quote + var sb strings.Builder + for { + if p.pos >= len(p.data) { + return "", p.fail("unterminated string") + } + c := p.data[p.pos] + switch { + case c == '"': + p.pos++ + return sb.String(), nil + case c < 0x20: + return "", p.fail("unescaped control character in string") + case c == '\\': + p.pos++ + if p.pos >= len(p.data) { + return "", p.fail("unterminated escape") + } + e := p.data[p.pos] + p.pos++ + switch e { + case '"': + sb.WriteByte('"') + case '\\': + sb.WriteByte('\\') + case '/': + sb.WriteByte('/') + case 'b': + sb.WriteByte('\b') + case 'f': + sb.WriteByte('\f') + case 'n': + sb.WriteByte('\n') + case 'r': + sb.WriteByte('\r') + case 't': + sb.WriteByte('\t') + case 'u': + hi, err := p.parseHex4() + if err != nil { + return "", err + } + if hi >= 0xD800 && hi <= 0xDBFF { + if p.pos+2 > len(p.data) || p.data[p.pos] != '\\' || p.data[p.pos+1] != 'u' { + return "", p.fail("lone high surrogate escape") + } + p.pos += 2 + lo, err := p.parseHex4() + if err != nil { + return "", err + } + if lo < 0xDC00 || lo > 0xDFFF { + return "", p.fail("lone high surrogate escape") + } + sb.WriteRune((hi-0xD800)<<10 + (lo - 0xDC00) + 0x10000) + } else if hi >= 0xDC00 && hi <= 0xDFFF { + return "", p.fail("lone low surrogate escape") + } else { + sb.WriteRune(hi) + } + default: + return "", p.fail(fmt.Sprintf("invalid escape \\%c", e)) + } + default: + r, size := utf8.DecodeRune(p.data[p.pos:]) + if r == utf8.RuneError && size <= 1 { + return "", p.fail("invalid UTF-8 in string") + } + sb.Write(p.data[p.pos : p.pos+size]) + p.pos += size + } + } +} + +func (p *strictParser) parseArray(depth int) (interface{}, error) { + p.pos++ // [ + out := []interface{}{} + p.skipWs() + if p.pos < len(p.data) && p.data[p.pos] == ']' { + p.pos++ + return out, nil + } + for { + p.skipWs() + v, err := p.parseValue(depth + 1) + if err != nil { + return nil, err + } + out = append(out, v) + p.skipWs() + if p.pos >= len(p.data) { + return nil, p.fail("expected , or ]") + } + switch p.data[p.pos] { + case ',': + p.pos++ + case ']': + p.pos++ + return out, nil + default: + return nil, p.fail("expected , or ]") + } + } +} + +func (p *strictParser) parseObject(depth int) (interface{}, error) { + p.pos++ // { + out := map[string]interface{}{} + p.skipWs() + if p.pos < len(p.data) && p.data[p.pos] == '}' { + p.pos++ + return out, nil + } + for { + p.skipWs() + if p.pos >= len(p.data) || p.data[p.pos] != '"' { + return nil, p.fail("expected string key") + } + key, err := p.parseString() + if err != nil { + return nil, err + } + p.skipWs() + if p.pos >= len(p.data) || p.data[p.pos] != ':' { + return nil, p.fail("expected :") + } + p.pos++ + p.skipWs() + v, err := p.parseValue(depth + 1) + if err != nil { + return nil, err + } + if _, dup := out[key]; dup { + return nil, canonErr("duplicate object key %q", key) + } + out[key] = v + p.skipWs() + if p.pos >= len(p.data) { + return nil, p.fail("expected , or }") + } + switch p.data[p.pos] { + case ',': + p.pos++ + case '}': + p.pos++ + return out, nil + default: + return nil, p.fail("expected , or }") + } + } +} diff --git a/go/pkg/core/core_test.go b/go/pkg/core/core_test.go index 7ac1009..8ca3bcb 100644 --- a/go/pkg/core/core_test.go +++ b/go/pkg/core/core_test.go @@ -3,6 +3,7 @@ package core import ( "crypto/sha256" "encoding/json" + "math" "testing" ) @@ -396,3 +397,59 @@ func TestKeyOrderingConsistency(t *testing.T) { t.Errorf("Key ordering inconsistency:\nSchema1: %s\nSchema2: %s", canonical1, canonical2) } } + +// Regression tests for SP-3 (HTML escaping) and SP-5 (number / escape rules). +func TestCanonicalizeLiteralHTMLChars(t *testing.T) { + c := NewSchemaPinCore() + got, err := c.CanonicalizeSchema(map[string]interface{}{"s": "a < b && c > d", "u": "

"}) + if err != nil { + t.Fatal(err) + } + want := "{\"s\":\"a < b && c > d\",\"u\":

\"}" + want = "{\"s\":\"a < b && c > d\",\"u\":\"

\"}" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestCanonicalizeShortEscapesIndependentOfToolchain(t *testing.T) { + got, err := CanonicalizeValue(map[string]interface{}{"s": "\b\f\n\r\t\x00\x1f\x7f"}) + if err != nil { + t.Fatal(err) + } + want := "{\"s\":\"\\b\\f\\n\\r\\t\\u0000\\u001f\x7f\"}" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestCanonicalizeNumbersES6(t *testing.T) { + got, err := CanonicalizeValue([]interface{}{1.0, 1e-7, 0.000001, 1e3, math.Copysign(0, -1), int64(42), json.Number("2.50")}) + if err != nil { + t.Fatal(err) + } + if got != "[1,1e-7,0.000001,1000,0,42,2.5]" { + t.Fatalf("got %q", got) + } + for _, bad := range []interface{}{float64(1 << 53), int64(1<<53 + 1), math.NaN(), math.Inf(1), json.Number("1e21")} { + if _, err := CanonicalizeValue(map[string]interface{}{"a": bad}); err == nil { + t.Errorf("expected rejection of %v", bad) + } + } +} + +func TestParseSchemaJSONStrict(t *testing.T) { + for _, bad := range []string{`{"a":1,"a":2}`, `{"a":NaN}`, `{"a":"\ud800"}`, `{"a":9007199254740992}`, "{\"a\":\"\xff\"}"} { + if _, err := ParseSchemaJSON([]byte(bad)); err == nil { + t.Errorf("expected rejection of %s", bad) + } + } + v, err := ParseSchemaJSON([]byte(`{"__proto__":{"polluted":1},"name":"x"}`)) + if err != nil { + t.Fatal(err) + } + got, _ := CanonicalizeValue(v) + if got != `{"__proto__":{"polluted":1},"name":"x"}` { + t.Fatalf("got %q", got) + } +} diff --git a/go/pkg/core/cross_language_test.go b/go/pkg/core/cross_language_test.go new file mode 100644 index 0000000..993c5dd --- /dev/null +++ b/go/pkg/core/cross_language_test.go @@ -0,0 +1,89 @@ +package core + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" +) + +// corpusDir resolves tests/cross-language/canonicalization relative to the +// repository root by walking up from the package directory. +func corpusDir(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 8; i++ { + candidate := filepath.Join(dir, "tests", "cross-language", "canonicalization") + if st, err := os.Stat(candidate); err == nil && st.IsDir() { + return candidate + } + dir = filepath.Dir(dir) + } + t.Fatal("could not locate tests/cross-language/canonicalization") + return "" +} + +type canonVector struct { + Name string `json:"name"` + Input string `json:"input"` + Reject bool `json:"reject"` + ExpectedCanonical string `json:"expected_canonical"` + ExpectedSHA256 string `json:"expected_sha256"` +} + +// TestCrossLanguageCanonicalizationCorpus asserts every shared vector. The same +// corpus is asserted by the Python, JavaScript and Rust suites. +func TestCrossLanguageCanonicalizationCorpus(t *testing.T) { + dir := corpusDir(t) + files, err := filepath.Glob(filepath.Join(dir, "*.json")) + if err != nil || len(files) == 0 { + t.Fatalf("no vectors found in %s", dir) + } + sort.Strings(files) + for _, path := range files { + raw, err := os.ReadFile(path) // #nosec G304 -- test fixture path + if err != nil { + t.Fatal(err) + } + var v canonVector + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("%s: %v", path, err) + } + t.Run(v.Name, func(t *testing.T) { + parsed, err := ParseSchemaJSON([]byte(v.Input)) + var canonical string + if err == nil { + canonical, err = CanonicalizeValue(parsed) + } + if v.Reject { + if err == nil { + t.Fatalf("expected rejection, got %q", canonical) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if canonical != v.ExpectedCanonical { + t.Fatalf("canonical mismatch\n got: %q\nwant: %q", canonical, v.ExpectedCanonical) + } + sum := sha256.Sum256([]byte(canonical)) + if got := hex.EncodeToString(sum[:]); got != v.ExpectedSHA256 { + t.Fatalf("sha256 mismatch: %s != %s", got, v.ExpectedSHA256) + } + again, err := ParseSchemaJSON([]byte(canonical)) + if err != nil { + t.Fatal(err) + } + if c2, _ := CanonicalizeValue(again); c2 != canonical { + t.Fatalf("canonicalization is not idempotent: %q != %q", c2, canonical) + } + }) + } +} diff --git a/javascript/src/core.js b/javascript/src/core.js index f94ea1b..e8e8e2f 100644 --- a/javascript/src/core.js +++ b/javascript/src/core.js @@ -1,47 +1,458 @@ /** * Core SchemaPin functionality for schema canonicalization and hashing. + * + * Implements the `schemapin-v1` canonical JSON serialization specified in + * TECHNICAL_SPECIFICATION.md section 4. The rules are strict so that every + * SDK produces byte-identical output: + * + * - Object keys are sorted by Unicode code point (NOT by UTF-16 code unit, + * which is what `Array.prototype.sort` would do for astral characters). + * - Duplicate object keys, `NaN`/`Infinity`, lone surrogates and invalid + * UTF-8 are rejected -- they have no canonical form. + * - Strings are escaped minimally: `"` and `\` are backslash-escaped, + * U+0008/U+000C/U+000A/U+000D/U+0009 use `\b \f \n \r \t`, the remaining + * C0 controls use lowercase `\u00xx`, and everything else (including + * `<`, `>`, `&`, U+007F, U+2028 and U+2029) is emitted literally. + * - Numbers are IEEE-754 binary64. Any value whose magnitude exceeds + * 2**53 - 1 is rejected. Integral values are emitted as plain decimal + * integers (`-0` -> `0`); non-integral values use ECMAScript + * `Number::toString` (RFC 8785 section 3.2.2.3). + * - `__proto__` is an ordinary key and is preserved. The serializer never + * materializes intermediate objects, so no prototype setter can fire. */ import { createHash } from 'crypto'; +/** Largest integer magnitude accepted by the canonicalizer (2**53 - 1). */ +export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; + +const MAX_DEPTH = 512; + +/** + * Thrown when a value has no `schemapin-v1` canonical form. + */ +export class CanonicalizationError extends Error { + constructor(message) { + super(message); + this.name = 'CanonicalizationError'; + } +} + +/** + * Compare two strings by Unicode code point. + * + * @param {string} a + * @param {string} b + * @returns {number} + */ +export function compareCodePoints(a, b) { + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + const ca = a.codePointAt(i); + const cb = b.codePointAt(j); + if (ca !== cb) { + return ca < cb ? -1 : 1; + } + i += ca > 0xffff ? 2 : 1; + j += cb > 0xffff ? 2 : 1; + } + if (i >= a.length && j >= b.length) { + return 0; + } + return i >= a.length ? -1 : 1; +} + +const SHORT_ESCAPES = { + '"': '\\"', + '\\': '\\\\', + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t' +}; + +function canonicalString(value) { + let out = '"'; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0; + if (next < 0xdc00 || next > 0xdfff) { + throw new CanonicalizationError( + `lone surrogate U+${code.toString(16).toUpperCase()} has no canonical form` + ); + } + out += value[i] + value[i + 1]; + i++; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) { + throw new CanonicalizationError( + `lone surrogate U+${code.toString(16).toUpperCase()} has no canonical form` + ); + } + const ch = value[i]; + const esc = SHORT_ESCAPES[ch]; + if (esc !== undefined) { + out += esc; + } else if (code < 0x20) { + out += '\\u' + code.toString(16).padStart(4, '0'); + } else { + out += ch; + } + } + return out + '"'; +} + +function canonicalNumber(value) { + if (typeof value === 'bigint') { + if (value > BigInt(MAX_SAFE_INTEGER) || value < -BigInt(MAX_SAFE_INTEGER)) { + throw new CanonicalizationError(`integer ${value} exceeds the 2**53-1 safe range`); + } + return value.toString(); + } + if (!Number.isFinite(value)) { + throw new CanonicalizationError('NaN and Infinity are not valid JSON'); + } + if (Math.abs(value) > MAX_SAFE_INTEGER) { + throw new CanonicalizationError(`number ${value} exceeds the 2**53-1 safe range`); + } + if (Object.is(value, -0)) { + return '0'; + } + // Integral values within the safe range print as plain decimals; the + // non-integral case is exactly ECMAScript Number::toString. + return String(value); +} + +function canonicalValue(value, depth) { + if (depth > MAX_DEPTH) { + throw new CanonicalizationError('nesting depth exceeds limit'); + } + if (value === null) { + return 'null'; + } + switch (typeof value) { + case 'boolean': + return value ? 'true' : 'false'; + case 'string': + return canonicalString(value); + case 'number': + case 'bigint': + return canonicalNumber(value); + case 'object': + break; + default: + throw new CanonicalizationError(`unsupported value type ${typeof value}`); + } + if (typeof value.toJSON === 'function') { + return canonicalValue(value.toJSON(), depth + 1); + } + if (Array.isArray(value)) { + const parts = []; + for (const item of value) { + if (item === undefined) { + throw new CanonicalizationError('undefined array element has no canonical form'); + } + parts.push(canonicalValue(item, depth + 1)); + } + return '[' + parts.join(',') + ']'; + } + const keys = Object.keys(value).sort(compareCodePoints); + const parts = []; + for (const key of keys) { + const v = value[key]; + if (v === undefined || typeof v === 'function' || typeof v === 'symbol') { + continue; + } + parts.push(canonicalString(key) + ':' + canonicalValue(v, depth + 1)); + } + return '{' + parts.join(',') + '}'; +} + +// ───────────────────────────────────────────────────────────────────────── +// Strict JSON parser +// ───────────────────────────────────────────────────────────────────────── + +class StrictParser { + constructor(text) { + this.text = text; + this.pos = 0; + } + + fail(message) { + throw new CanonicalizationError(`invalid JSON at offset ${this.pos}: ${message}`); + } + + skipWs() { + const t = this.text; + while (this.pos < t.length) { + const c = t.charCodeAt(this.pos); + if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) { + this.pos++; + } else { + break; + } + } + } + + parseDocument() { + this.skipWs(); + const value = this.parseValue(0); + this.skipWs(); + if (this.pos !== this.text.length) { + this.fail('trailing characters'); + } + return value; + } + + parseValue(depth) { + if (depth > MAX_DEPTH) { + this.fail('nesting depth exceeds limit'); + } + const t = this.text; + if (this.pos >= t.length) { + this.fail('unexpected end of input'); + } + const ch = t[this.pos]; + switch (ch) { + case '{': + return this.parseObject(depth); + case '[': + return this.parseArray(depth); + case '"': + return this.parseString(); + case 't': + return this.parseLiteral('true', true); + case 'f': + return this.parseLiteral('false', false); + case 'n': + return this.parseLiteral('null', null); + default: + if (ch === '-' || (ch >= '0' && ch <= '9')) { + return this.parseNumber(); + } + return this.fail(`unexpected character '${ch}'`); + } + } + + parseLiteral(word, value) { + if (this.text.startsWith(word, this.pos)) { + this.pos += word.length; + return value; + } + return this.fail(`expected ${word}`); + } + + parseNumber() { + const m = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(this.text.slice(this.pos)); + if (!m) { + this.fail('malformed number'); + } + this.pos += m[0].length; + const value = Number(m[0]); + if (!Number.isFinite(value) || Math.abs(value) > MAX_SAFE_INTEGER) { + throw new CanonicalizationError(`number ${m[0]} exceeds the 2**53-1 safe range`); + } + return value; + } + + parseString() { + const t = this.text; + this.pos++; // opening quote + let out = ''; + for (;;) { + if (this.pos >= t.length) { + this.fail('unterminated string'); + } + const code = t.charCodeAt(this.pos); + if (code === 0x22) { + this.pos++; + return out; + } + if (code < 0x20) { + this.fail('unescaped control character in string'); + } + if (code === 0x5c) { + out += this.parseEscape(); + continue; + } + if (code >= 0xd800 && code <= 0xdbff) { + const next = this.pos + 1 < t.length ? t.charCodeAt(this.pos + 1) : 0; + if (next < 0xdc00 || next > 0xdfff) { + this.fail('lone surrogate in string'); + } + out += t[this.pos] + t[this.pos + 1]; + this.pos += 2; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) { + this.fail('lone surrogate in string'); + } + out += t[this.pos]; + this.pos++; + } + } + + parseEscape() { + const t = this.text; + this.pos++; // backslash + if (this.pos >= t.length) { + this.fail('unterminated escape'); + } + const ch = t[this.pos++]; + switch (ch) { + case '"': return '"'; + case '\\': return '\\'; + case '/': return '/'; + case 'b': return '\b'; + case 'f': return '\f'; + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + case 'u': { + const hi = this.parseHex4(); + if (hi >= 0xd800 && hi <= 0xdbff) { + if (t[this.pos] !== '\\' || t[this.pos + 1] !== 'u') { + this.fail('lone high surrogate escape'); + } + this.pos += 2; + const lo = this.parseHex4(); + if (lo < 0xdc00 || lo > 0xdfff) { + this.fail('lone high surrogate escape'); + } + return String.fromCharCode(hi, lo); + } + if (hi >= 0xdc00 && hi <= 0xdfff) { + this.fail('lone low surrogate escape'); + } + return String.fromCharCode(hi); + } + default: + return this.fail(`invalid escape \\${ch}`); + } + } + + parseHex4() { + const hex = this.text.slice(this.pos, this.pos + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { + this.fail('invalid \\u escape'); + } + this.pos += 4; + return parseInt(hex, 16); + } + + parseArray(depth) { + this.pos++; // [ + const out = []; + this.skipWs(); + if (this.text[this.pos] === ']') { + this.pos++; + return out; + } + for (;;) { + this.skipWs(); + out.push(this.parseValue(depth + 1)); + this.skipWs(); + const ch = this.text[this.pos++]; + if (ch === ',') { + continue; + } + if (ch === ']') { + return out; + } + this.pos--; + this.fail('expected , or ]'); + } + } + + parseObject(depth) { + this.pos++; // { + const out = {}; + this.skipWs(); + if (this.text[this.pos] === '}') { + this.pos++; + return out; + } + for (;;) { + this.skipWs(); + if (this.text[this.pos] !== '"') { + this.fail('expected string key'); + } + const key = this.parseString(); + this.skipWs(); + if (this.text[this.pos++] !== ':') { + this.pos--; + this.fail('expected :'); + } + this.skipWs(); + const value = this.parseValue(depth + 1); + if (Object.prototype.hasOwnProperty.call(out, key)) { + throw new CanonicalizationError(`duplicate object key "${key}"`); + } + // defineProperty creates an own data property even for "__proto__", + // where plain assignment would invoke the prototype setter instead. + Object.defineProperty(out, key, { + value, + enumerable: true, + writable: true, + configurable: true + }); + this.skipWs(); + const ch = this.text[this.pos++]; + if (ch === ',') { + continue; + } + if (ch === '}') { + return out; + } + this.pos--; + this.fail('expected , or }'); + } + } +} + /** * Core SchemaPin operations for schema canonicalization and hashing. */ export class SchemaPinCore { /** - * Convert a schema to canonical string format per SchemaPin specification. - * - * Process: - * 1. UTF-8 encoding - * 2. Remove insignificant whitespace - * 3. Sort keys lexicographically (recursive) - * 4. Strict JSON serialization - * - * @param {Object} schema - Tool schema as object + * Parse JSON text strictly for canonicalization. + * + * Rejects (`CanonicalizationError`): duplicate object keys, + * `NaN`/`Infinity`, numbers outside the 2**53-1 safe range, lone + * surrogates and unescaped control characters. `__proto__` keys are + * preserved as own properties. + * + * @param {string} text + * @returns {*} + */ + static parseSchemaJson(text) { + if (typeof text !== 'string') { + throw new CanonicalizationError('input must be a string'); + } + return new StrictParser(text).parseDocument(); + } + + /** + * Convert a schema to its `schemapin-v1` canonical string. + * + * @param {*} schema - Tool schema (object, array or primitive) * @returns {string} Canonical string representation + * @throws {CanonicalizationError} when the value has no canonical form */ static canonicalizeSchema(schema) { - // Recursively sort keys and use compact separators (no whitespace) - const sortKeys = (obj) => { - if (obj === null || typeof obj !== 'object') { - return obj; - } - if (Array.isArray(obj)) { - return obj.map(sortKeys); - } - const sorted = {}; - Object.keys(obj).sort().forEach(key => { - sorted[key] = sortKeys(obj[key]); - }); - return sorted; - }; - - return JSON.stringify(sortKeys(schema), null, 0); + if (schema === undefined) { + throw new CanonicalizationError('undefined has no canonical form'); + } + return canonicalValue(schema, 0); } /** * Hash canonical schema string using SHA-256. - * + * * @param {string} canonical - Canonical schema string * @returns {Buffer} SHA-256 hash bytes */ @@ -51,12 +462,12 @@ export class SchemaPinCore { /** * Convenience method to canonicalize and hash schema in one step. - * - * @param {Object} schema - Tool schema as object + * + * @param {*} schema - Tool schema as object * @returns {Buffer} SHA-256 hash of canonical schema */ static canonicalizeAndHash(schema) { const canonical = this.canonicalizeSchema(schema); return this.hashCanonical(canonical); } -} \ No newline at end of file +} diff --git a/javascript/tests/core.test.js b/javascript/tests/core.test.js index b405991..8471568 100644 --- a/javascript/tests/core.test.js +++ b/javascript/tests/core.test.js @@ -4,7 +4,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert'; -import { SchemaPinCore } from '../src/core.js'; +import { SchemaPinCore, CanonicalizationError } from '../src/core.js'; describe('SchemaPinCore', () => { test('canonicalizeSchema - basic', () => { @@ -90,4 +90,49 @@ describe('SchemaPinCore', () => { // Should produce identical hashes assert(hash1.equals(hash2)); }); -}); \ No newline at end of file +}); +describe('strict canonicalization (SP-3/SP-4/SP-5 regressions)', () => { + test('preserves __proto__ keys instead of invoking the prototype setter', () => { + const parsed = SchemaPinCore.parseSchemaJson('{"__proto__":{"polluted":1},"name":"x"}'); + assert.strictEqual( + SchemaPinCore.canonicalizeSchema(parsed), + '{"__proto__":{"polluted":1},"name":"x"}' + ); + // A benign schema must not share bytes with one carrying __proto__. + assert.notStrictEqual( + SchemaPinCore.canonicalizeSchema(parsed), + SchemaPinCore.canonicalizeSchema({ name: 'x' }) + ); + assert.strictEqual(({}).polluted, undefined); + // Also handles objects produced by the built-in JSON.parse. + assert.strictEqual( + SchemaPinCore.canonicalizeSchema(JSON.parse('{"__proto__":{"polluted":1},"name":"x"}')), + '{"__proto__":{"polluted":1},"name":"x"}' + ); + }); + + test('sorts keys by code point, not UTF-16 code unit', () => { + const canonical = SchemaPinCore.canonicalizeSchema({ '\u{1F600}': 1, '￿': 2, z: 3 }); + assert.strictEqual(canonical, '{"z":3,"￿":2,"\u{1F600}":1}'); + }); + + test('emits < > & and U+2028/U+2029 literally', () => { + assert.strictEqual( + SchemaPinCore.canonicalizeSchema({ s: '&\u2028\u2029' }), + '{"s":"&\u2028\u2029"}' + ); + }); + + test('formats numbers per ES6 and rejects unsafe magnitudes', () => { + assert.strictEqual(SchemaPinCore.canonicalizeSchema([1.0, 1e-7, 0.000001, 1e3, -0]), '[1,1e-7,0.000001,1000,0]'); + assert.throws(() => SchemaPinCore.canonicalizeSchema({ a: 2 ** 53 }), CanonicalizationError); + assert.throws(() => SchemaPinCore.canonicalizeSchema({ a: NaN }), CanonicalizationError); + assert.throws(() => SchemaPinCore.canonicalizeSchema({ a: Infinity }), CanonicalizationError); + }); + + test('rejects duplicate keys and lone surrogates at parse time', () => { + assert.throws(() => SchemaPinCore.parseSchemaJson('{"a":1,"a":2}'), CanonicalizationError); + assert.throws(() => SchemaPinCore.parseSchemaJson('{"a":"\\ud800"}'), CanonicalizationError); + assert.throws(() => SchemaPinCore.canonicalizeSchema({ a: '\ud800' }), CanonicalizationError); + }); +}); diff --git a/javascript/tests/cross-language.test.js b/javascript/tests/cross-language.test.js new file mode 100644 index 0000000..2d4a7e2 --- /dev/null +++ b/javascript/tests/cross-language.test.js @@ -0,0 +1,45 @@ +/** + * Cross-language conformance: canonicalization corpus shared by all SDKs. + * + * Every vector under tests/cross-language/canonicalization/ is asserted by + * the Python, JavaScript, Go and Rust suites. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { SchemaPinCore, CanonicalizationError } from '../src/core.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CORPUS = resolve(HERE, '..', '..', 'tests', 'cross-language', 'canonicalization'); + +describe('cross-language canonicalization corpus', () => { + const files = readdirSync(CORPUS).filter((f) => f.endsWith('.json')).sort(); + assert.ok(files.length > 0, 'corpus must not be empty'); + + for (const file of files) { + const vector = JSON.parse(readFileSync(join(CORPUS, file), 'utf-8')); + test(vector.name, () => { + if (vector.reject) { + assert.throws( + () => SchemaPinCore.canonicalizeSchema(SchemaPinCore.parseSchemaJson(vector.input)), + CanonicalizationError + ); + return; + } + const parsed = SchemaPinCore.parseSchemaJson(vector.input); + const canonical = SchemaPinCore.canonicalizeSchema(parsed); + assert.strictEqual(canonical, vector.expected_canonical); + const digest = createHash('sha256').update(canonical, 'utf8').digest('hex'); + assert.strictEqual(digest, vector.expected_sha256); + assert.strictEqual( + SchemaPinCore.canonicalizeSchema(SchemaPinCore.parseSchemaJson(canonical)), + canonical + ); + }); + } +}); diff --git a/python/schemapin/core.py b/python/schemapin/core.py index ffeea44..6c715f3 100644 --- a/python/schemapin/core.py +++ b/python/schemapin/core.py @@ -1,55 +1,254 @@ -"""Core SchemaPin functionality for schema canonicalization and hashing.""" +"""Core SchemaPin functionality for schema canonicalization and hashing. + +This module implements the ``schemapin-v1`` canonical JSON serialization +exactly as specified in TECHNICAL_SPECIFICATION.md section 4. The rules are +deliberately strict so that every SDK produces byte-identical output: + +* Object keys are sorted by Unicode code point (equivalently, by the UTF-8 + byte sequence of the key). This is *not* UTF-16 code-unit order. +* Duplicate object keys, ``NaN``/``Infinity``, lone surrogates and invalid + UTF-8 are rejected -- they have no canonical form. +* Strings are escaped minimally: ``"`` and ``\\`` are backslash-escaped, + U+0008/U+000C/U+000A/U+000D/U+0009 use the two-character escapes + ``\\b \\f \\n \\r \\t``, the remaining C0 controls (U+0000--U+001F) use + lowercase ``\\u00xx``, and every other code point (including ``<``, ``>``, + ``&``, U+007F, U+2028 and U+2029) is emitted literally as UTF-8. +* Numbers are interpreted as IEEE-754 binary64 values. Any number whose + absolute value exceeds 2**53 - 1 is rejected, so no two distinct inputs + can collide after double rounding. Integral values are emitted as plain + decimal integers (``1.0`` -> ``1``, ``-0`` -> ``0``); non-integral values + use the ECMAScript ``Number::toString`` algorithm (RFC 8785 section + 3.2.2.3), e.g. ``1e-7``, ``0.000001``, ``123.456``. +* ``__proto__`` and other special-looking keys are ordinary keys and are + preserved. +""" import hashlib import json -from typing import Any, Dict +import math +import re +from typing import Any, Dict, List, Tuple + +#: Largest integer magnitude accepted by the canonicalizer (2**53 - 1). +MAX_SAFE_INTEGER = 9007199254740991 + +_ESCAPES = { + '"': '\\"', + "\\": "\\\\", + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", +} + +_FLOAT_RE = re.compile(r"^(-)?(\d+)(?:\.(\d+))?(?:e([+-]\d+))?$") + + +class CanonicalizationError(ValueError): + """Raised when a value has no ``schemapin-v1`` canonical form.""" + + +def _canonical_string(value: str) -> str: + out: List[str] = ['"'] + for ch in value: + cp = ord(ch) + if 0xD800 <= cp <= 0xDFFF: + raise CanonicalizationError( + f"lone surrogate U+{cp:04X} has no canonical form" + ) + esc = _ESCAPES.get(ch) + if esc is not None: + out.append(esc) + elif cp < 0x20: + out.append(f"\\u{cp:04x}") + else: + out.append(ch) + out.append('"') + return "".join(out) + + +def _es6_number_to_string(value: float) -> str: + """Format a finite, non-integral binary64 per ECMAScript Number::toString.""" + text = repr(value) + m = _FLOAT_RE.match(text) + if m is None: # pragma: no cover - repr() of a finite float always matches + raise CanonicalizationError(f"cannot format number {value!r}") + sign, int_part, frac_part, exp_part = m.groups() + frac_part = frac_part or "" + exp = int(exp_part) if exp_part else 0 + digits = (int_part + frac_part).lstrip("0") + exp -= len(frac_part) + stripped = digits.rstrip("0") + exp += len(digits) - len(stripped) + digits = stripped + k = len(digits) + n = exp + k + if 0 < n <= 21: + body = digits[:n] + "." + digits[n:] + elif -6 < n <= 0: + body = "0." + "0" * (-n) + digits + else: + e = n - 1 + esign = "+" if e >= 0 else "-" + mant = digits[0] if k == 1 else digits[0] + "." + digits[1:] + body = f"{mant}e{esign}{abs(e)}" + return ("-" if sign else "") + body + + +def _canonical_number(value: Any) -> str: + if isinstance(value, bool): + raise CanonicalizationError("bool is not a number") + if isinstance(value, int): + if abs(value) > MAX_SAFE_INTEGER: + raise CanonicalizationError( + f"integer {value} exceeds the 2**53-1 safe range" + ) + return str(value) + if isinstance(value, float): + if math.isnan(value) or math.isinf(value): + raise CanonicalizationError("NaN and Infinity are not valid JSON") + if abs(value) > MAX_SAFE_INTEGER: + raise CanonicalizationError( + f"number {value!r} exceeds the 2**53-1 safe range" + ) + if value == int(value): + return str(int(value)) + return _es6_number_to_string(value) + raise CanonicalizationError(f"unsupported numeric type {type(value).__name__}") + + +def _canonical_value(value: Any, out: List[str]) -> None: + if value is None: + out.append("null") + elif value is True: + out.append("true") + elif value is False: + out.append("false") + elif isinstance(value, str): + out.append(_canonical_string(value)) + elif isinstance(value, (int, float)): + out.append(_canonical_number(value)) + elif isinstance(value, dict): + out.append("{") + first = True + # Keys sort by Unicode code point: Python str ordering is exactly that. + for key in sorted(value.keys()): + if not isinstance(key, str): + raise CanonicalizationError( + f"object key must be a string, got {type(key).__name__}" + ) + if not first: + out.append(",") + first = False + out.append(_canonical_string(key)) + out.append(":") + _canonical_value(value[key], out) + out.append("}") + elif isinstance(value, (list, tuple)): + out.append("[") + for i, item in enumerate(value): + if i: + out.append(",") + _canonical_value(item, out) + out.append("]") + else: + raise CanonicalizationError( + f"unsupported value type {type(value).__name__}" + ) + + +def _reject_duplicate_keys(pairs: List[Tuple[str, Any]]) -> Dict[str, Any]: + result: Dict[str, Any] = {} + for key, val in pairs: + if key in result: + raise CanonicalizationError(f"duplicate object key {key!r}") + result[key] = val + return result + + +def _reject_constant(name: str) -> Any: + raise CanonicalizationError(f"{name} is not valid JSON") + + +def _parse_int(text: str) -> int: + value = int(text) + if abs(value) > MAX_SAFE_INTEGER: + raise CanonicalizationError( + f"integer {text} exceeds the 2**53-1 safe range" + ) + return value + + +def _parse_float(text: str) -> float: + value = float(text) + if math.isinf(value) or abs(value) > MAX_SAFE_INTEGER: + raise CanonicalizationError( + f"number {text} exceeds the 2**53-1 safe range" + ) + return value + + +def _check_surrogates(value: Any) -> None: + if isinstance(value, str): + for ch in value: + if 0xD800 <= ord(ch) <= 0xDFFF: + raise CanonicalizationError( + f"lone surrogate U+{ord(ch):04X} is not valid" + ) + elif isinstance(value, dict): + for k, v in value.items(): + _check_surrogates(k) + _check_surrogates(v) + elif isinstance(value, list): + for v in value: + _check_surrogates(v) class SchemaPinCore: """Core SchemaPin operations for schema canonicalization and hashing.""" @staticmethod - def canonicalize_schema(schema: Dict[str, Any]) -> str: - """ - Convert a schema to canonical string format per SchemaPin specification. + def parse_schema_json(text: str) -> Any: + """Parse JSON text strictly for canonicalization. - Process: - 1. UTF-8 encoding - 2. Remove insignificant whitespace - 3. Sort keys lexicographically (recursive) - 4. Strict JSON serialization + Rejects (``CanonicalizationError``): duplicate object keys, + ``NaN``/``Infinity``, numbers outside the 2**53-1 safe range and + lone surrogates. Everything else follows RFC 8259. + """ + try: + value = json.loads( + text, + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_constant, + parse_int=_parse_int, + parse_float=_parse_float, + ) + except json.JSONDecodeError as e: + raise CanonicalizationError(f"invalid JSON: {e}") from e + _check_surrogates(value) + return value - Args: - schema: Tool schema as dictionary + @staticmethod + def canonicalize_schema(schema: Any) -> str: + """Convert a schema to its ``schemapin-v1`` canonical string. - Returns: - Canonical string representation + See the module docstring for the normative rules. Raises + ``CanonicalizationError`` when the value has no canonical form + (duplicate keys cannot occur in a ``dict``; non-finite or oversized + numbers, lone surrogates and non-JSON types are rejected). """ - return json.dumps(schema, ensure_ascii=False, separators=(',', ':'), sort_keys=True) + out: List[str] = [] + _canonical_value(schema, out) + return "".join(out) @staticmethod def hash_canonical(canonical: str) -> bytes: - """ - Hash canonical schema string using SHA-256. - - Args: - canonical: Canonical schema string - - Returns: - SHA-256 hash bytes - """ - return hashlib.sha256(canonical.encode('utf-8')).digest() + """Hash a canonical schema string using SHA-256.""" + return hashlib.sha256(canonical.encode("utf-8")).digest() @classmethod - def canonicalize_and_hash(cls, schema: Dict[str, Any]) -> bytes: - """ - Convenience method to canonicalize and hash schema in one step. - - Args: - schema: Tool schema as dictionary - - Returns: - SHA-256 hash of canonical schema - """ + def canonicalize_and_hash(cls, schema: Any) -> bytes: + """Canonicalize and hash a schema in one step.""" canonical = cls.canonicalize_schema(schema) return cls.hash_canonical(canonical) diff --git a/python/tests/test_core.py b/python/tests/test_core.py index 2bd6f64..8c95185 100644 --- a/python/tests/test_core.py +++ b/python/tests/test_core.py @@ -1,5 +1,7 @@ """Tests for core SchemaPin functionality.""" +import pytest + from schemapin.core import SchemaPinCore @@ -89,3 +91,48 @@ def test_hash_deterministic(self): # Should produce identical hashes assert hash1 == hash2 + + +class TestStrictCanonicalization: + """Regression tests for SP-5 (number / NaN / surrogate / duplicate-key rules).""" + + def test_rejects_nan_and_infinity(self): + from schemapin.core import CanonicalizationError + + with pytest.raises(CanonicalizationError): + SchemaPinCore.canonicalize_schema({"a": float("nan")}) + with pytest.raises(CanonicalizationError): + SchemaPinCore.canonicalize_schema({"a": float("inf")}) + with pytest.raises(CanonicalizationError): + SchemaPinCore.parse_schema_json('{"a": NaN}') + + def test_rejects_integers_beyond_safe_range(self): + from schemapin.core import CanonicalizationError + + with pytest.raises(CanonicalizationError): + SchemaPinCore.canonicalize_schema({"a": 2**53}) + with pytest.raises(CanonicalizationError): + SchemaPinCore.canonicalize_schema({"a": 2**53 + 1}) + assert SchemaPinCore.canonicalize_schema({"a": 2**53 - 1}) == '{"a":9007199254740991}' + + def test_float_formatting_matches_es6(self): + assert SchemaPinCore.canonicalize_schema([1.0, 1e-7, 0.000001, 1e3, -0.0]) == "[1,1e-7,0.000001,1000,0]" + + def test_rejects_duplicate_keys_at_parse(self): + from schemapin.core import CanonicalizationError + + with pytest.raises(CanonicalizationError): + SchemaPinCore.parse_schema_json('{"a":1,"a":2}') + + def test_rejects_lone_surrogates(self): + from schemapin.core import CanonicalizationError + + with pytest.raises(CanonicalizationError): + SchemaPinCore.parse_schema_json('{"a":"\\ud800"}') + with pytest.raises(CanonicalizationError): + SchemaPinCore.canonicalize_schema({"a": "\ud800"}) + + def test_keys_sorted_by_code_point(self): + assert SchemaPinCore.canonicalize_schema({"\U0001F600": 1, "￿": 2, "z": 3}) == ( + '{"z":3,"￿":2,"\U0001F600":1}' + ) diff --git a/python/tests/test_cross_language.py b/python/tests/test_cross_language.py new file mode 100644 index 0000000..8ba78f4 --- /dev/null +++ b/python/tests/test_cross_language.py @@ -0,0 +1,47 @@ +"""Cross-language conformance: canonicalization corpus shared by all SDKs. + +Every vector under ``tests/cross-language/canonicalization/`` is asserted by +the Python, JavaScript, Go and Rust suites. A vector with ``reject: true`` +must fail to parse or canonicalize; otherwise the canonical bytes and their +SHA-256 must match exactly. +""" + +import glob +import hashlib +import json +import os + +import pytest + +from schemapin.core import CanonicalizationError, SchemaPinCore + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +CORPUS = os.path.join(ROOT, "tests", "cross-language", "canonicalization") + + +def _vectors(): + paths = sorted(glob.glob(os.path.join(CORPUS, "*.json"))) + assert paths, f"no canonicalization vectors found under {CORPUS}" + for path in paths: + with open(path, encoding="utf-8") as f: + yield pytest.param(json.load(f), id=os.path.basename(path)) + + +@pytest.mark.parametrize("vector", list(_vectors())) +def test_canonicalization_vector(vector): + if vector["reject"]: + with pytest.raises(CanonicalizationError): + SchemaPinCore.canonicalize_schema( + SchemaPinCore.parse_schema_json(vector["input"]) + ) + return + parsed = SchemaPinCore.parse_schema_json(vector["input"]) + canonical = SchemaPinCore.canonicalize_schema(parsed) + assert canonical == vector["expected_canonical"] + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + assert digest == vector["expected_sha256"] + # Idempotence: canonical output re-parses and re-canonicalizes identically. + assert ( + SchemaPinCore.canonicalize_schema(SchemaPinCore.parse_schema_json(canonical)) + == canonical + ) diff --git a/rust/src/bundle.rs b/rust/src/bundle.rs index ca37217..fe99751 100644 --- a/rust/src/bundle.rs +++ b/rust/src/bundle.rs @@ -41,7 +41,7 @@ fn signing_bytes(bundle: &SchemaPinTrustBundle) -> Result { if let Value::Object(map) = &mut value { map.insert("signature".to_string(), Value::String(String::new())); } - Ok(canonicalize_schema(&value)) + canonicalize_schema(&value) } /// Sign a trust bundle with a bundle-authority key. @@ -86,22 +86,24 @@ pub fn verify_trust_bundle( bundle: &SchemaPinTrustBundle, authority_pin_store: &mut KeyPinStore, ) -> Result<(), Error> { - let authority = bundle.bundle_authority.as_ref().ok_or(Error::Verification { - code: ErrorCode::BundleUnsigned, - message: "trust bundle has no bundle_authority".to_string(), - })?; + let authority = bundle + .bundle_authority + .as_ref() + .ok_or(Error::Verification { + code: ErrorCode::BundleUnsigned, + message: "trust bundle has no bundle_authority".to_string(), + })?; let signature = bundle.signature.as_ref().ok_or(Error::Verification { code: ErrorCode::BundleUnsigned, message: "trust bundle has no signature".to_string(), })?; if let Some(expires_at) = &bundle.expires_at { - let exp = chrono::DateTime::parse_from_rfc3339(expires_at).map_err(|e| { - Error::Verification { + let exp = + chrono::DateTime::parse_from_rfc3339(expires_at).map_err(|e| Error::Verification { code: ErrorCode::BundleExpired, message: format!("unparseable expires_at '{}': {}", expires_at, e), - } - })?; + })?; if chrono::Utc::now() > exp { return Err(Error::Verification { code: ErrorCode::BundleExpired, @@ -146,10 +148,7 @@ pub fn merge_trust_bundles(bundles: &[SchemaPinTrustBundle]) -> SchemaPinTrustBu let mut newest_ts = String::new(); for b in bundles { - let ts = b - .signed_at - .clone() - .unwrap_or_else(|| b.created_at.clone()); + let ts = b.signed_at.clone().unwrap_or_else(|| b.created_at.clone()); if ts > newest_ts { newest_ts = ts.clone(); } @@ -214,13 +213,14 @@ pub fn build_trust_bundle_response( /// Extract the bundle from a `schemapin/trustBundle` JSON-RPC response. pub fn parse_trust_bundle_response(response: &Value) -> Result { - let bundle = response - .get("result") - .and_then(|r| r.get("bundle")) - .ok_or(Error::Verification { - code: ErrorCode::DiscoveryInvalid, - message: "JSON-RPC response missing result.bundle".to_string(), - })?; + let bundle = + response + .get("result") + .and_then(|r| r.get("bundle")) + .ok_or(Error::Verification { + code: ErrorCode::DiscoveryInvalid, + message: "JSON-RPC response missing result.bundle".to_string(), + })?; Ok(serde_json::from_value(bundle.clone())?) } @@ -266,7 +266,10 @@ mod tests { assert_eq!(signed.schemapin_bundle_version, "1.4"); assert!(signed.signature.is_some()); - assert_eq!(signed.bundle_authority.as_ref().unwrap().kid, "auth-2026-05"); + assert_eq!( + signed.bundle_authority.as_ref().unwrap().kid, + "auth-2026-05" + ); let mut store = KeyPinStore::new(); verify_trust_bundle(&signed, &mut store).unwrap(); diff --git a/rust/src/canonicalize.rs b/rust/src/canonicalize.rs index 0aada26..172f285 100644 --- a/rust/src/canonicalize.rs +++ b/rust/src/canonicalize.rs @@ -1,6 +1,27 @@ -use serde_json::Value; +//! `schemapin-v1` canonical JSON serialization. +//! +//! Implements TECHNICAL_SPECIFICATION.md section 4. The rules are strict so +//! that every SDK produces byte-identical output: +//! +//! - Object keys are sorted by Unicode code point (bytewise order of their +//! UTF-8 encoding). +//! - Duplicate object keys, `NaN`/`Infinity`, lone surrogates and invalid +//! UTF-8 are rejected -- they have no canonical form. +//! - Strings are escaped minimally: `"` and `\` are backslash-escaped, +//! U+0008/U+000C/U+000A/U+000D/U+0009 use `\b \f \n \r \t`, the remaining +//! C0 controls use lowercase `\u00xx`, and everything else (including `<`, +//! `>`, `&`, U+007F, U+2028 and U+2029) is emitted literally. +//! - Numbers are IEEE-754 binary64. Any value whose magnitude exceeds +//! 2^53 - 1 is rejected. Integral values are emitted as plain decimal +//! integers (`-0` -> `0`); non-integral values use ECMAScript +//! `Number::toString` (RFC 8785 section 3.2.2.3). + +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; +use std::fmt; + +use crate::error::Error; /// Canonical-algorithm identifier (v1.4) for the sorted-key, no-whitespace, /// UTF-8 canonicalization implemented in this module. @@ -11,6 +32,11 @@ use std::collections::BTreeMap; /// any other value as `CANONICALIZATION_UNSUPPORTED`. pub const CANONICALIZATION_V1: &str = "schemapin-v1"; +/// Largest integer magnitude accepted by the canonicalizer (2^53 - 1). +pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +const MAX_DEPTH: usize = 512; + /// Returns `Ok(())` if `algorithm` names a canonicalization algorithm this /// SDK supports. Returns `Err(declared)` with the offending value when it /// does not — the caller surfaces this as `ErrorCode::CanonicalizationUnsupported`. @@ -24,14 +50,19 @@ pub fn check_canonicalization(algorithm: Option<&str>) -> Result<(), String> { } } -/// Recursively sort JSON object keys and produce a canonical string. +fn canon_err(msg: impl Into) -> Error { + Error::Canonicalization(msg.into()) +} + +/// Produce the `schemapin-v1` canonical string for a JSON value. /// -/// - Object keys are sorted lexicographically (BTreeMap guarantees this). -/// - Arrays preserve element order. -/// - All insignificant whitespace is removed. -pub fn canonicalize_schema(schema: &Value) -> String { - let sorted = sort_keys_recursive(schema); - serde_json::to_string(&sorted).unwrap_or_default() +/// Fails when the value has no canonical form (numbers outside the 2^53 - 1 +/// safe range; `serde_json::Value` cannot hold NaN, duplicates or invalid +/// UTF-8, so those are rejected by [`parse_schema_json`] instead). +pub fn canonicalize_schema(schema: &Value) -> Result { + let mut out = String::new(); + write_value(&mut out, schema, 0)?; + Ok(out) } /// SHA-256 hash of a canonical string, returned as raw bytes. @@ -42,23 +73,256 @@ pub fn hash_canonical(canonical: &str) -> Vec { } /// Canonicalize a schema and return its SHA-256 hash. -pub fn canonicalize_and_hash(schema: &Value) -> Vec { - let canonical = canonicalize_schema(schema); - hash_canonical(&canonical) +pub fn canonicalize_and_hash(schema: &Value) -> Result, Error> { + let canonical = canonicalize_schema(schema)?; + Ok(hash_canonical(&canonical)) } -/// Recursively sort all object keys, producing a new Value. -fn sort_keys_recursive(value: &Value) -> Value { +fn write_value(out: &mut String, value: &Value, depth: usize) -> Result<(), Error> { + if depth > MAX_DEPTH { + return Err(canon_err("nesting depth exceeds limit")); + } match value { + Value::Null => out.push_str("null"), + Value::Bool(true) => out.push_str("true"), + Value::Bool(false) => out.push_str("false"), + Value::String(s) => write_string(out, s), + Value::Number(n) => write_number(out, n)?, + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_value(out, item, depth + 1)?; + } + out.push(']'); + } Value::Object(map) => { - let sorted: BTreeMap = map - .iter() - .map(|(k, v)| (k.clone(), sort_keys_recursive(v))) - .collect(); - Value::Object(sorted.into_iter().collect()) + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); // String ordering is bytewise == code point order + out.push('{'); + for (i, key) in keys.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_string(out, key); + out.push(':'); + write_value(out, &map[*key], depth + 1)?; + } + out.push('}'); + } + } + Ok(()) +} + +fn write_string(out: &mut String, s: &str) { + out.push('"'); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c => out.push(c), + } + } + out.push('"'); +} + +fn write_number(out: &mut String, n: &Number) -> Result<(), Error> { + if let Some(i) = n.as_i64() { + if i.unsigned_abs() > MAX_SAFE_INTEGER { + return Err(canon_err(format!( + "integer {} exceeds the 2^53-1 safe range", + i + ))); + } + out.push_str(&i.to_string()); + return Ok(()); + } + if let Some(u) = n.as_u64() { + if u > MAX_SAFE_INTEGER { + return Err(canon_err(format!( + "integer {} exceeds the 2^53-1 safe range", + u + ))); + } + out.push_str(&u.to_string()); + return Ok(()); + } + let f = n + .as_f64() + .ok_or_else(|| canon_err("unrepresentable number"))?; + write_f64(out, f) +} + +fn write_f64(out: &mut String, f: f64) -> Result<(), Error> { + if !f.is_finite() { + return Err(canon_err("NaN and Infinity are not valid JSON")); + } + if f.abs() > MAX_SAFE_INTEGER as f64 { + return Err(canon_err(format!( + "number {} exceeds the 2^53-1 safe range", + f + ))); + } + if f.fract() == 0.0 { + // Exact for |f| <= 2^53 - 1; also maps -0.0 to "0". + out.push_str(&(f as i64).to_string()); + return Ok(()); + } + out.push_str(&es6_number_to_string(f)); + Ok(()) +} + +/// Format a finite, non-integral f64 exactly as ECMAScript `Number::toString`. +fn es6_number_to_string(f: f64) -> String { + let neg = f < 0.0; + let f = f.abs(); + // `{:e}` yields the shortest round-trip digits as d.ddde±x. + let text = format!("{:e}", f); + let (mant, exp_str) = text.split_once('e').unwrap_or((&text, "0")); + let mut exp: i64 = exp_str.parse().unwrap_or(0); + let (int_part, frac_part) = mant.split_once('.').unwrap_or((mant, "")); + let mut digits: String = format!("{}{}", int_part, frac_part) + .trim_start_matches('0') + .to_string(); + exp -= frac_part.len() as i64; + let trimmed = digits.trim_end_matches('0').to_string(); + exp += (digits.len() - trimmed.len()) as i64; + digits = trimmed; + let k = digits.len() as i64; + let n = exp + k; + + let body = if n > 0 && n <= 21 { + let (a, b) = digits.split_at(n as usize); + format!("{}.{}", a, b) + } else if n > -6 && n <= 0 { + format!("0.{}{}", "0".repeat((-n) as usize), digits) + } else { + let e = n - 1; + let sign = if e < 0 { '-' } else { '+' }; + let mant = if k == 1 { + digits.clone() + } else { + format!("{}.{}", &digits[..1], &digits[1..]) + }; + format!("{}e{}{}", mant, sign, e.abs()) + }; + if neg { + format!("-{}", body) + } else { + body + } +} + +// --------------------------------------------------------------------------- +// Strict parser +// --------------------------------------------------------------------------- + +/// Parse JSON text strictly for canonicalization. +/// +/// Rejects duplicate object keys, `NaN`/`Infinity`, numbers outside the +/// 2^53 - 1 safe range, lone surrogates, invalid UTF-8 and unescaped control +/// characters (`serde_json` already enforces the last three). +pub fn parse_schema_json(text: &str) -> Result { + let strict: StrictValue = + serde_json::from_str(text).map_err(|e| canon_err(format!("invalid JSON: {}", e)))?; + validate_numbers(&strict.0)?; + Ok(strict.0) +} + +fn validate_numbers(value: &Value) -> Result<(), Error> { + match value { + Value::Number(n) => { + let mut scratch = String::new(); + write_number(&mut scratch, n) } - Value::Array(arr) => Value::Array(arr.iter().map(sort_keys_recursive).collect()), - other => other.clone(), + Value::Array(items) => items.iter().try_for_each(validate_numbers), + Value::Object(map) => map.values().try_for_each(validate_numbers), + _ => Ok(()), + } +} + +/// `serde_json::Value` wrapper whose `Deserialize` impl rejects duplicate keys. +struct StrictValue(Value); + +impl<'de> Deserialize<'de> for StrictValue { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(StrictVisitor) + } +} + +struct StrictVisitor; + +impl<'de> Visitor<'de> for StrictVisitor { + type Value = StrictValue; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a JSON value without duplicate object keys") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(StrictValue(Value::Bool(v))) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(StrictValue(Value::from(v))) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(StrictValue(Value::from(v))) + } + + fn visit_f64(self, v: f64) -> Result { + Number::from_f64(v) + .map(|n| StrictValue(Value::Number(n))) + .ok_or_else(|| E::custom("non-finite number")) + } + + fn visit_str(self, v: &str) -> Result { + Ok(StrictValue(Value::String(v.to_string()))) + } + + fn visit_string(self, v: String) -> Result { + Ok(StrictValue(Value::String(v))) + } + + fn visit_unit(self) -> Result { + Ok(StrictValue(Value::Null)) + } + + fn visit_none(self) -> Result { + Ok(StrictValue(Value::Null)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::new(); + while let Some(StrictValue(v)) = seq.next_element()? { + items.push(v); + } + Ok(StrictValue(Value::Array(items))) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut object = Map::new(); + while let Some(key) = map.next_key::()? { + let StrictValue(v) = map.next_value()?; + if object.insert(key.clone(), v).is_some() { + return Err(de::Error::custom(format!( + "duplicate object key \"{}\"", + key + ))); + } + } + Ok(StrictValue(Value::Object(object))) } } @@ -74,7 +338,7 @@ mod tests { "name": "calculate_sum", "parameters": { "b": "integer", "a": "integer" } }); - let canonical = canonicalize_schema(&schema); + let canonical = canonicalize_schema(&schema).unwrap(); assert_eq!( canonical, r#"{"description":"Calculates the sum","name":"calculate_sum","parameters":{"a":"integer","b":"integer"}}"# @@ -87,7 +351,7 @@ mod tests { "z": { "b": 2, "a": 1 }, "a": { "d": 4, "c": 3 } }); - let canonical = canonicalize_schema(&schema); + let canonical = canonicalize_schema(&schema).unwrap(); assert_eq!(canonical, r#"{"a":{"c":3,"d":4},"z":{"a":1,"b":2}}"#); } @@ -97,7 +361,7 @@ mod tests { "items": [3, 1, 2], "name": "test" }); - let canonical = canonicalize_schema(&schema); + let canonical = canonicalize_schema(&schema).unwrap(); assert_eq!(canonical, r#"{"items":[3,1,2],"name":"test"}"#); } @@ -108,10 +372,10 @@ mod tests { "description": "Calculates the sum", "parameters": { "b": "integer", "a": "integer" } }); - let hash1 = canonicalize_and_hash(&schema); - let hash2 = canonicalize_and_hash(&schema); + let hash1 = canonicalize_and_hash(&schema).unwrap(); + let hash2 = canonicalize_and_hash(&schema).unwrap(); assert_eq!(hash1, hash2); - assert_eq!(hash1.len(), 32); // SHA-256 = 32 bytes + assert_eq!(hash1.len(), 32); } #[test] @@ -119,8 +383,8 @@ mod tests { let schema_a = json!({"z": 1, "a": 2, "m": 3}); let schema_b = json!({"a": 2, "m": 3, "z": 1}); assert_eq!( - canonicalize_schema(&schema_a), - canonicalize_schema(&schema_b) + canonicalize_schema(&schema_a).unwrap(), + canonicalize_schema(&schema_b).unwrap() ); } @@ -129,9 +393,7 @@ mod tests { let canonical = r#"{"name":"test"}"#; let hash = hash_canonical(canonical); assert_eq!(hash.len(), 32); - // Verify it's the expected SHA-256 (deterministic) - let hash2 = hash_canonical(canonical); - assert_eq!(hash, hash2); + assert_eq!(hash, hash_canonical(canonical)); } #[test] @@ -144,10 +406,84 @@ mod tests { } } }); - let canonical = canonicalize_schema(&schema); + let canonical = canonicalize_schema(&schema).unwrap(); assert_eq!( canonical, r#"{"level1":{"level2":{"a":"first","z":"last"}}}"# ); } + + #[test] + fn test_numbers_es6_format() { + let v = + parse_schema_json(r#"[1e-7,0.000001,123.456,1e3,-0.0,0.1,1.5e-10,5e-324,1.0,-2.5]"#) + .unwrap(); + assert_eq!( + canonicalize_schema(&v).unwrap(), + "[1e-7,0.000001,123.456,1000,0,0.1,1.5e-10,5e-324,1,-2.5]" + ); + } + + #[test] + fn test_numbers_out_of_range_rejected() { + for text in [ + "9007199254740992", + "-9007199254740992", + "1e21", + "1e16", + "18446744073709551616", + ] { + assert!( + parse_schema_json(text).is_err(), + "{} must be rejected", + text + ); + } + assert!(parse_schema_json("9007199254740991").is_ok()); + } + + #[test] + fn test_string_escaping_rules() { + let v = parse_schema_json( + "{\"s\":\"<>&\u{2028}\u{2029}\\u0000\\u001f\\b\\f\\n\\r\\t\\\"\\\\\\u007f\"}", + ) + .unwrap(); + assert_eq!( + canonicalize_schema(&v).unwrap(), + "{\"s\":\"<>&\u{2028}\u{2029}\\u0000\\u001f\\b\\f\\n\\r\\t\\\"\\\\\u{7f}\"}" + ); + } + + #[test] + fn test_key_order_is_code_point() { + let v = parse_schema_json(r#"{"😀":1,"￿":2,"z":3}"#).unwrap(); + assert_eq!( + canonicalize_schema(&v).unwrap(), + "{\"z\":3,\"\u{ffff}\":2,\"\u{1f600}\":1}" + ); + } + + #[test] + fn test_proto_key_preserved() { + let v = parse_schema_json(r#"{"__proto__":{"polluted":1},"name":"x"}"#).unwrap(); + assert_eq!( + canonicalize_schema(&v).unwrap(), + r#"{"__proto__":{"polluted":1},"name":"x"}"# + ); + } + + #[test] + fn test_duplicate_keys_rejected() { + assert!(parse_schema_json(r#"{"a":1,"a":2}"#).is_err()); + assert!(parse_schema_json(r#"{"o":{"a":1,"b":2,"a":3}}"#).is_err()); + assert!(parse_schema_json(r#"{"a":1,"b":2}"#).is_ok()); + } + + #[test] + fn test_nan_and_lone_surrogate_rejected() { + assert!(parse_schema_json(r#"{"a":NaN}"#).is_err()); + assert!(parse_schema_json(r#"{"a":Infinity}"#).is_err()); + assert!(parse_schema_json(r#"{"a":"\ud800"}"#).is_err()); + assert!(parse_schema_json(r#"{"a":"\udc00"}"#).is_err()); + } } diff --git a/rust/src/error.rs b/rust/src/error.rs index 9081d34..bc3f528 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -20,6 +20,9 @@ pub enum Error { #[error("Invalid key format")] InvalidKeyFormat, + #[error("Canonicalization error: {0}")] + Canonicalization(String), + #[error("Discovery error: {0}")] Discovery(String), diff --git a/rust/src/resolver.rs b/rust/src/resolver.rs index 43a40b6..803fef1 100644 --- a/rust/src/resolver.rs +++ b/rust/src/resolver.rs @@ -44,6 +44,7 @@ pub trait SchemaResolver: Send + Sync { /// Async equivalent of [`SchemaResolver`]. /// /// Gated behind the `fetch` feature because it brings in `async-trait`. +#[allow(clippy::double_must_use)] #[async_trait::async_trait] pub trait AsyncSchemaResolver: Send + Sync { async fn resolve_discovery(&self, domain: &str) -> Result; diff --git a/rust/src/verification.rs b/rust/src/verification.rs index ad79319..579dbe3 100644 --- a/rust/src/verification.rs +++ b/rust/src/verification.rs @@ -231,7 +231,15 @@ pub fn verify_schema_offline_with_canonicalization( }; // Step 5: Canonicalize schema and compute hash - let schema_hash = canonicalize_and_hash(schema); + let schema_hash = match canonicalize_and_hash(schema) { + Ok(h) => h, + Err(e) => { + return VerificationResult::failure( + ErrorCode::SchemaCanonicalizationFailed, + &format!("Failed to canonicalize schema: {}", e), + ); + } + }; // Step 6: Verify ECDSA signature let valid = @@ -477,7 +485,7 @@ mod tests { "description": "Calculates the sum of two numbers", "parameters": { "a": "integer", "b": "integer" } }); - let hash = canonicalize_and_hash(&schema); + let hash = canonicalize_and_hash(&schema).unwrap(); let signature = sign_data(&kp.private_key_pem, &hash).unwrap(); let discovery = build_well_known_response(&kp.public_key_pem, Some("Test Developer"), vec![], "1.2"); @@ -600,7 +608,7 @@ mod tests { // Now change the key let kp2 = generate_key_pair().unwrap(); - let hash = canonicalize_and_hash(&f.schema); + let hash = canonicalize_and_hash(&f.schema).unwrap(); let sig2 = sign_data(&kp2.private_key_pem, &hash).unwrap(); let disc2 = build_well_known_response( &kp2.public_key_pem, diff --git a/rust/tests/cross_language_canonicalization.rs b/rust/tests/cross_language_canonicalization.rs new file mode 100644 index 0000000..6a7b045 --- /dev/null +++ b/rust/tests/cross_language_canonicalization.rs @@ -0,0 +1,59 @@ +//! Cross-language conformance: canonicalization corpus shared by all SDKs +//! (see ../../tests/cross-language/README.md). + +use schemapin::canonicalize::{canonicalize_schema, parse_schema_json}; +use sha2::{Digest, Sha256}; + +#[derive(serde::Deserialize)] +struct Vector { + name: String, + input: String, + reject: bool, + #[serde(default)] + expected_canonical: Option, + #[serde(default)] + expected_sha256: Option, +} + +#[test] +fn cross_language_canonicalization_corpus() { + let dir = std::path::Path::new("../tests/cross-language/canonicalization"); + let mut paths: Vec<_> = std::fs::read_dir(dir) + .expect("corpus directory present") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .collect(); + paths.sort(); + assert!(!paths.is_empty(), "corpus must not be empty"); + + for path in paths { + let text = std::fs::read_to_string(&path).unwrap(); + let v: Vector = serde_json::from_str(&text).unwrap(); + let result = parse_schema_json(&v.input).and_then(|val| canonicalize_schema(&val)); + if v.reject { + assert!( + result.is_err(), + "{}: expected rejection, got {:?}", + v.name, + result + ); + continue; + } + let canonical = result.unwrap_or_else(|e| panic!("{}: {}", v.name, e)); + assert_eq!( + canonical, + v.expected_canonical.clone().unwrap(), + "{}: canonical mismatch", + v.name + ); + let digest = hex::encode(Sha256::digest(canonical.as_bytes())); + assert_eq!( + digest, + v.expected_sha256.unwrap(), + "{}: sha256 mismatch", + v.name + ); + let again = canonicalize_schema(&parse_schema_json(&canonical).unwrap()).unwrap(); + assert_eq!(again, canonical, "{}: not idempotent", v.name); + } +} diff --git a/tests/cross-language/README.md b/tests/cross-language/README.md index a7a3657..92b31d1 100644 --- a/tests/cross-language/README.md +++ b/tests/cross-language/README.md @@ -1,11 +1,45 @@ # Cross-language interop fixtures -`signed_bundle.json` is a v1.4 signed trust bundle produced by the Rust SDK -(`sign_trust_bundle`), signed with `go/schemapin_private.pem` under the bundle -authority kid `schemapin-bundle-authority-2026`. The authority public key is -embedded in `bundle_authority.public_key_pem`, so the bundle is self-verifying. - -Each SDK has a test that loads this file and asserts `verify_trust_bundle` -succeeds — proving the four SDKs agree on the bundle canonicalization and -signing input. Regenerate by re-signing the same input if the wire format -changes; all four SDK tests must still pass. +Every fixture in this directory is asserted by all four SDK test suites +(Python, JavaScript, Go, Rust). They are the normative proof that the SDKs +agree on the bytes that get signed. If a wire format changes, regenerate the +affected fixtures and make sure all four suites still pass. + +## `canonicalization/*.json` + +One vector per file. Each file has: + +| Field | Meaning | +|----------------------|-------------------------------------------------------------------------| +| `name` | Vector id (matches the file name) | +| `description` | What the vector exercises | +| `input` | Raw JSON text to parse with the SDK's strict parser | +| `reject` | `true` if the input MUST fail to parse or canonicalize | +| `expected_canonical` | The exact `schemapin-v1` canonical string (absent when `reject`) | +| `expected_sha256` | Lowercase hex SHA-256 of the UTF-8 canonical bytes (absent when `reject`)| + +The vectors cover HTML-sensitive characters (`<>&`), U+2028/U+2029, C0 +control escapes, non-ASCII literals, code-point key ordering (astral +characters), number formatting (ES6 `Number::toString`, integral floats, `-0`), +the 2^53-1 magnitude limit, `__proto__` preservation, and the rejection cases +(duplicate keys, `NaN`/`Infinity`, lone surrogates, oversized numbers, parse +errors). The rules themselves are in TECHNICAL_SPECIFICATION.md section 4. + +Consumers: + +- `python/tests/test_cross_language.py` +- `javascript/tests/cross-language.test.js` +- `go/pkg/core/cross_language_test.go` +- `rust/tests/cross_language_canonicalization.rs` + +## `signed_bundle.json` + +A v1.4 signed trust bundle. See the `keys/` section below for the test +authority that signed it. + +## `keys/` + +Test-only key material. Nothing in this directory is a production identity: +the bundle authority kid is deliberately named `schemapin-test-fixture-authority` +so it cannot be mistaken for a real authority, and verifiers must never seed a +persistent authority store with it outside of a test run. diff --git a/tests/cross-language/canonicalization/01-spec-example.json b/tests/cross-language/canonicalization/01-spec-example.json new file mode 100644 index 0000000..d3afdaf --- /dev/null +++ b/tests/cross-language/canonicalization/01-spec-example.json @@ -0,0 +1,8 @@ +{ + "name": "01-spec-example", + "description": "The example from spec section 4.", + "input": "{\n \"description\": \"Calculates the sum\",\n \"name\": \"calculate_sum\",\n \"parameters\": { \"b\": \"integer\", \"a\": \"integer\"}\n}", + "reject": false, + "expected_canonical": "{\"description\":\"Calculates the sum\",\"name\":\"calculate_sum\",\"parameters\":{\"a\":\"integer\",\"b\":\"integer\"}}", + "expected_sha256": "19180f803e81700c41e85abb988df2565095344d2186ba93aabf400e7d37c6f8" +} diff --git a/tests/cross-language/canonicalization/02-nested-sort.json b/tests/cross-language/canonicalization/02-nested-sort.json new file mode 100644 index 0000000..8e12b3e --- /dev/null +++ b/tests/cross-language/canonicalization/02-nested-sort.json @@ -0,0 +1,8 @@ +{ + "name": "02-nested-sort", + "description": "Keys are sorted recursively; arrays keep their order.", + "input": "{\"z\":{\"b\":2,\"a\":1},\"a\":{\"d\":[3,1,2],\"c\":{\"y\":true,\"x\":null}}}", + "reject": false, + "expected_canonical": "{\"a\":{\"c\":{\"x\":null,\"y\":true},\"d\":[3,1,2]},\"z\":{\"a\":1,\"b\":2}}", + "expected_sha256": "348d96c3bcd851ec1273ea92b1bbe290d6fa85727e93d0f885e8a98004d1e2a2" +} diff --git a/tests/cross-language/canonicalization/03-html-chars.json b/tests/cross-language/canonicalization/03-html-chars.json new file mode 100644 index 0000000..c7f5b75 --- /dev/null +++ b/tests/cross-language/canonicalization/03-html-chars.json @@ -0,0 +1,8 @@ +{ + "name": "03-html-chars", + "description": "<, > and & are emitted literally, never as \\u003c etc.", + "input": "{\"description\":\"a < b && c > d\",\"html\":\"&\"}", + "reject": false, + "expected_canonical": "{\"description\":\"a < b && c > d\",\"html\":\"&\"}", + "expected_sha256": "0b11887d1b59827412c7e6f2cc3e7bc4c8ba845b09d2ca5b56983c0e05efd102" +} diff --git a/tests/cross-language/canonicalization/04-line-separators.json b/tests/cross-language/canonicalization/04-line-separators.json new file mode 100644 index 0000000..6987b60 --- /dev/null +++ b/tests/cross-language/canonicalization/04-line-separators.json @@ -0,0 +1,8 @@ +{ + "name": "04-line-separators", + "description": "U+2028 and U+2029 are emitted literally.", + "input": "{\"text\":\"line
sep
para\"}", + "reject": false, + "expected_canonical": "{\"text\":\"line
sep
para\"}", + "expected_sha256": "711d9b90f63229ca7c83c20adbf73d72d406ebf81c10d63c3843a2baf27501aa" +} diff --git a/tests/cross-language/canonicalization/05-control-escapes.json b/tests/cross-language/canonicalization/05-control-escapes.json new file mode 100644 index 0000000..8f57c23 --- /dev/null +++ b/tests/cross-language/canonicalization/05-control-escapes.json @@ -0,0 +1,8 @@ +{ + "name": "05-control-escapes", + "description": "C0 controls: short escapes for \\b \\f \\n \\r \\t, lowercase \\u00xx otherwise; DEL (U+007F) literal; solidus never escaped.", + "input": "{\"s\":\"\\u0000\\u0001\\u001f\\b\\f\\n\\r\\t\\\"\\\\\\/\\u007f\"}", + "reject": false, + "expected_canonical": "{\"s\":\"\\u0000\\u0001\\u001f\\b\\f\\n\\r\\t\\\"\\\\/\"}", + "expected_sha256": "2677634f2b2c4d59a1e24fa949cced8205569d3c93b3742c10ef7ab7bd52bfe3" +} diff --git a/tests/cross-language/canonicalization/06-unicode-literal.json b/tests/cross-language/canonicalization/06-unicode-literal.json new file mode 100644 index 0000000..941fac1 --- /dev/null +++ b/tests/cross-language/canonicalization/06-unicode-literal.json @@ -0,0 +1,8 @@ +{ + "name": "06-unicode-literal", + "description": "Non-ASCII is emitted as literal UTF-8, not \\u escapes.", + "input": "{\"name\":\"café\",\"lang\":\"日本語\",\"emoji\":\"\\ud83d\\ude00\"}", + "reject": false, + "expected_canonical": "{\"emoji\":\"😀\",\"lang\":\"日本語\",\"name\":\"café\"}", + "expected_sha256": "fa6d52fd2c1798c3f09135cdd9029192d90a64481000cc841a1745778e6afa2a" +} diff --git a/tests/cross-language/canonicalization/07-astral-key-order.json b/tests/cross-language/canonicalization/07-astral-key-order.json new file mode 100644 index 0000000..5b755e9 --- /dev/null +++ b/tests/cross-language/canonicalization/07-astral-key-order.json @@ -0,0 +1,8 @@ +{ + "name": "07-astral-key-order", + "description": "Keys sort by code point: \"z\" (U+007A) < U+FFFF < U+1F600. UTF-16 order would put U+1F600 before U+FFFF.", + "input": "{\"\\ud83d\\ude00\":1,\"\\uffff\":2,\"z\":3}", + "reject": false, + "expected_canonical": "{\"z\":3,\"￿\":2,\"😀\":1}", + "expected_sha256": "ee0448f401397e05098248a25cb0c638fdf6002cdf69edda02986c02f2c6ed15" +} diff --git a/tests/cross-language/canonicalization/08-key-order-ascii.json b/tests/cross-language/canonicalization/08-key-order-ascii.json new file mode 100644 index 0000000..42f3c29 --- /dev/null +++ b/tests/cross-language/canonicalization/08-key-order-ascii.json @@ -0,0 +1,8 @@ +{ + "name": "08-key-order-ascii", + "description": "Code point order: digits < uppercase < underscore < lowercase; \"a10\" < \"a2\".", + "input": "{\"b\":1,\"B\":2,\"_\":3,\"a2\":4,\"a10\":5,\"1\":6,\"\":7}", + "reject": false, + "expected_canonical": "{\"\":7,\"1\":6,\"B\":2,\"_\":3,\"a10\":5,\"a2\":4,\"b\":1}", + "expected_sha256": "c07a80aeedf135f87bb589b2b00126b29cd9f1b7f62e38da223e8020e36b0d1f" +} diff --git a/tests/cross-language/canonicalization/09-numbers-integral.json b/tests/cross-language/canonicalization/09-numbers-integral.json new file mode 100644 index 0000000..b534828 --- /dev/null +++ b/tests/cross-language/canonicalization/09-numbers-integral.json @@ -0,0 +1,8 @@ +{ + "name": "09-numbers-integral", + "description": "Integral values print as plain integers; 1.0 -> 1, 1e3 -> 1000, -0 and -0.0 -> 0.", + "input": "[1.0,1e3,-0.0,-0,0,100,-7,2.50e1]", + "reject": false, + "expected_canonical": "[1,1000,0,0,0,100,-7,25]", + "expected_sha256": "713a5010f5b5424fa2fc78002fc3a840d1c7e3e6d5b98d4cbaf10069de3a2286" +} diff --git a/tests/cross-language/canonicalization/10-numbers-fractional.json b/tests/cross-language/canonicalization/10-numbers-fractional.json new file mode 100644 index 0000000..fa0c649 --- /dev/null +++ b/tests/cross-language/canonicalization/10-numbers-fractional.json @@ -0,0 +1,8 @@ +{ + "name": "10-numbers-fractional", + "description": "Non-integral values use ECMAScript Number::toString.", + "input": "[0.1,1e-7,0.000001,0.0000015,1.5e-7,123.456,1.5e-10,5e-324,-2.5,2.5e-5,1.7976931348623157e-300]", + "reject": false, + "expected_canonical": "[0.1,1e-7,0.000001,0.0000015,1.5e-7,123.456,1.5e-10,5e-324,-2.5,0.000025,1.7976931348623157e-300]", + "expected_sha256": "b3e2784888fc7d1b3fc2700c3ee13c7c94c612f91081bf086092374d3e3d956d" +} diff --git a/tests/cross-language/canonicalization/11-max-safe-integer.json b/tests/cross-language/canonicalization/11-max-safe-integer.json new file mode 100644 index 0000000..cdc035c --- /dev/null +++ b/tests/cross-language/canonicalization/11-max-safe-integer.json @@ -0,0 +1,8 @@ +{ + "name": "11-max-safe-integer", + "description": "2^53-1 is the largest accepted magnitude.", + "input": "{\"max\":9007199254740991,\"min\":-9007199254740991}", + "reject": false, + "expected_canonical": "{\"max\":9007199254740991,\"min\":-9007199254740991}", + "expected_sha256": "63546eb60913dcb1cdd5118f7bf4885beed344af930c8a9d5f38fad243fd4819" +} diff --git a/tests/cross-language/canonicalization/12-empty-containers.json b/tests/cross-language/canonicalization/12-empty-containers.json new file mode 100644 index 0000000..72ed625 --- /dev/null +++ b/tests/cross-language/canonicalization/12-empty-containers.json @@ -0,0 +1,8 @@ +{ + "name": "12-empty-containers", + "description": "Empty objects and arrays.", + "input": "{\"o\":{},\"a\":[],\"n\":{\"o\":{},\"a\":[]}}", + "reject": false, + "expected_canonical": "{\"a\":[],\"n\":{\"a\":[],\"o\":{}},\"o\":{}}", + "expected_sha256": "ce6dbc64ed0524b37fd7cd908f0e5ebfc2aa36c1c5af07ede2fcbca475b54c32" +} diff --git a/tests/cross-language/canonicalization/13-proto-key.json b/tests/cross-language/canonicalization/13-proto-key.json new file mode 100644 index 0000000..6756263 --- /dev/null +++ b/tests/cross-language/canonicalization/13-proto-key.json @@ -0,0 +1,8 @@ +{ + "name": "13-proto-key", + "description": "__proto__ is an ordinary key and must be preserved.", + "input": "{\"__proto__\":{\"polluted\":true},\"constructor\":{\"prototype\":1},\"name\":\"x\"}", + "reject": false, + "expected_canonical": "{\"__proto__\":{\"polluted\":true},\"constructor\":{\"prototype\":1},\"name\":\"x\"}", + "expected_sha256": "a8653b851f420a832dc1e9b51a2548c11108c07afcd16d5cf213753360ddeffb" +} diff --git a/tests/cross-language/canonicalization/14-whitespace.json b/tests/cross-language/canonicalization/14-whitespace.json new file mode 100644 index 0000000..c2afb17 --- /dev/null +++ b/tests/cross-language/canonicalization/14-whitespace.json @@ -0,0 +1,8 @@ +{ + "name": "14-whitespace", + "description": "Insignificant whitespace (including tabs and CRLF) is removed.", + "input": "\t{\r\n\t\"a\" :\t[ 1 ,\r\n 2 ] ,\r\n \"b\" : { \"c\" : \"d e\" }\r\n}\r\n", + "reject": false, + "expected_canonical": "{\"a\":[1,2],\"b\":{\"c\":\"d e\"}}", + "expected_sha256": "cc64527e07932c1733c56833873e1a90e0a7df83c0c583b28bd3c3fe766cdb09" +} diff --git a/tests/cross-language/canonicalization/15-literals.json b/tests/cross-language/canonicalization/15-literals.json new file mode 100644 index 0000000..4ece24b --- /dev/null +++ b/tests/cross-language/canonicalization/15-literals.json @@ -0,0 +1,8 @@ +{ + "name": "15-literals", + "description": "true, false, null.", + "input": "{\"t\":true,\"f\":false,\"n\":null,\"arr\":[null,true,false]}", + "reject": false, + "expected_canonical": "{\"arr\":[null,true,false],\"f\":false,\"n\":null,\"t\":true}", + "expected_sha256": "20e35b45af849c02536a892e3554a0ce5132a920414df85a6185f15be9138e6e" +} diff --git a/tests/cross-language/canonicalization/16-keys-with-escapes.json b/tests/cross-language/canonicalization/16-keys-with-escapes.json new file mode 100644 index 0000000..7c70d16 --- /dev/null +++ b/tests/cross-language/canonicalization/16-keys-with-escapes.json @@ -0,0 +1,8 @@ +{ + "name": "16-keys-with-escapes", + "description": "Object keys are escaped with the same rules as string values.", + "input": "{\"quote\\\"key\":1,\"back\\\\slash\":2,\"tab\\tkey\":3,\"nl\\nkey\":4}", + "reject": false, + "expected_canonical": "{\"back\\\\slash\":2,\"nl\\nkey\":4,\"quote\\\"key\":1,\"tab\\tkey\":3}", + "expected_sha256": "7e2222fcaa213470de5ee372ad9a7587bb9235bd5ceccc9faccafc1d14f440e4" +} diff --git a/tests/cross-language/canonicalization/17-deep-nesting.json b/tests/cross-language/canonicalization/17-deep-nesting.json new file mode 100644 index 0000000..05ad19f --- /dev/null +++ b/tests/cross-language/canonicalization/17-deep-nesting.json @@ -0,0 +1,8 @@ +{ + "name": "17-deep-nesting", + "description": "Moderate nesting depth.", + "input": "{\"l1\":{\"l2\":{\"l3\":{\"l4\":{\"l5\":{\"z\":1,\"a\":[{\"b\":2,\"a\":1}]}}}}}}", + "reject": false, + "expected_canonical": "{\"l1\":{\"l2\":{\"l3\":{\"l4\":{\"l5\":{\"a\":[{\"a\":1,\"b\":2}],\"z\":1}}}}}}", + "expected_sha256": "c38fcb6e32b172c58b1716cbf8b6660df06446a60de925aa4ad31939180b28c8" +} diff --git a/tests/cross-language/canonicalization/18-top-level-array.json b/tests/cross-language/canonicalization/18-top-level-array.json new file mode 100644 index 0000000..ee08686 --- /dev/null +++ b/tests/cross-language/canonicalization/18-top-level-array.json @@ -0,0 +1,8 @@ +{ + "name": "18-top-level-array", + "description": "Top-level arrays are canonicalized too.", + "input": "[{\"b\":1,\"a\":2},[],\"x\",1.5]", + "reject": false, + "expected_canonical": "[{\"a\":2,\"b\":1},[],\"x\",1.5]", + "expected_sha256": "65522185759931aed24d5dee90787d19b201e19d4a11231697b23e267b0583d7" +} diff --git a/tests/cross-language/canonicalization/19-mcp-tool.json b/tests/cross-language/canonicalization/19-mcp-tool.json new file mode 100644 index 0000000..f653d7c --- /dev/null +++ b/tests/cross-language/canonicalization/19-mcp-tool.json @@ -0,0 +1,8 @@ +{ + "name": "19-mcp-tool", + "description": "A realistic MCP tool schema.", + "input": "{\"name\":\"get_weather\",\"description\":\"Get weather for a city & region\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"maxLength\":100},\"units\":{\"type\":\"string\",\"enum\":[\"metric\",\"imperial\"]},\"days\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":14}},\"required\":[\"city\"]}}", + "reject": false, + "expected_canonical": "{\"description\":\"Get weather for a city & region\",\"inputSchema\":{\"properties\":{\"city\":{\"maxLength\":100,\"type\":\"string\"},\"days\":{\"maximum\":14,\"minimum\":1,\"type\":\"integer\"},\"units\":{\"enum\":[\"metric\",\"imperial\"],\"type\":\"string\"}},\"required\":[\"city\"],\"type\":\"object\"},\"name\":\"get_weather\"}", + "expected_sha256": "0a61bf006ee496152c71d4355f1c4c82d63df4093b33ef964a96347537f45277" +} diff --git a/tests/cross-language/canonicalization/20-reject-duplicate-keys.json b/tests/cross-language/canonicalization/20-reject-duplicate-keys.json new file mode 100644 index 0000000..5f1280c --- /dev/null +++ b/tests/cross-language/canonicalization/20-reject-duplicate-keys.json @@ -0,0 +1,6 @@ +{ + "name": "20-reject-duplicate-keys", + "description": "Duplicate object keys have no canonical form.", + "input": "{\"a\":1,\"a\":2}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/21-reject-duplicate-keys-nested.json b/tests/cross-language/canonicalization/21-reject-duplicate-keys-nested.json new file mode 100644 index 0000000..775f51a --- /dev/null +++ b/tests/cross-language/canonicalization/21-reject-duplicate-keys-nested.json @@ -0,0 +1,6 @@ +{ + "name": "21-reject-duplicate-keys-nested", + "description": "Duplicate keys in nested objects are rejected too.", + "input": "{\"o\":{\"a\":1,\"b\":2,\"a\":3}}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/22-reject-nan.json b/tests/cross-language/canonicalization/22-reject-nan.json new file mode 100644 index 0000000..196af9e --- /dev/null +++ b/tests/cross-language/canonicalization/22-reject-nan.json @@ -0,0 +1,6 @@ +{ + "name": "22-reject-nan", + "description": "NaN is not valid JSON.", + "input": "{\"a\":NaN}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/23-reject-infinity.json b/tests/cross-language/canonicalization/23-reject-infinity.json new file mode 100644 index 0000000..6d056dd --- /dev/null +++ b/tests/cross-language/canonicalization/23-reject-infinity.json @@ -0,0 +1,6 @@ +{ + "name": "23-reject-infinity", + "description": "Infinity is not valid JSON.", + "input": "{\"a\":Infinity}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/24-reject-lone-high-surrogate.json b/tests/cross-language/canonicalization/24-reject-lone-high-surrogate.json new file mode 100644 index 0000000..fe3c033 --- /dev/null +++ b/tests/cross-language/canonicalization/24-reject-lone-high-surrogate.json @@ -0,0 +1,6 @@ +{ + "name": "24-reject-lone-high-surrogate", + "description": "A lone high surrogate escape is rejected.", + "input": "{\"a\":\"\\ud800\"}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/25-reject-lone-low-surrogate.json b/tests/cross-language/canonicalization/25-reject-lone-low-surrogate.json new file mode 100644 index 0000000..9b04113 --- /dev/null +++ b/tests/cross-language/canonicalization/25-reject-lone-low-surrogate.json @@ -0,0 +1,6 @@ +{ + "name": "25-reject-lone-low-surrogate", + "description": "A lone low surrogate escape is rejected.", + "input": "{\"a\":\"\\udc00x\"}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/26-reject-2-pow-53.json b/tests/cross-language/canonicalization/26-reject-2-pow-53.json new file mode 100644 index 0000000..7bb6587 --- /dev/null +++ b/tests/cross-language/canonicalization/26-reject-2-pow-53.json @@ -0,0 +1,6 @@ +{ + "name": "26-reject-2-pow-53", + "description": "2^53 exceeds the safe range.", + "input": "{\"a\":9007199254740992}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/27-reject-2-pow-53-plus-1.json b/tests/cross-language/canonicalization/27-reject-2-pow-53-plus-1.json new file mode 100644 index 0000000..b6eb2de --- /dev/null +++ b/tests/cross-language/canonicalization/27-reject-2-pow-53-plus-1.json @@ -0,0 +1,6 @@ +{ + "name": "27-reject-2-pow-53-plus-1", + "description": "2^53+1 rounds to 2^53 in binary64; it must be rejected, not silently collapsed.", + "input": "{\"a\":9007199254740993}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/28-reject-1e21.json b/tests/cross-language/canonicalization/28-reject-1e21.json new file mode 100644 index 0000000..574f2e4 --- /dev/null +++ b/tests/cross-language/canonicalization/28-reject-1e21.json @@ -0,0 +1,6 @@ +{ + "name": "28-reject-1e21", + "description": "1e21 exceeds the safe range.", + "input": "{\"a\":1e21}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/29-reject-1e16.json b/tests/cross-language/canonicalization/29-reject-1e16.json new file mode 100644 index 0000000..5c785f8 --- /dev/null +++ b/tests/cross-language/canonicalization/29-reject-1e16.json @@ -0,0 +1,6 @@ +{ + "name": "29-reject-1e16", + "description": "1e16 exceeds the safe range.", + "input": "{\"a\":1e16}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/30-reject-huge-negative-int.json b/tests/cross-language/canonicalization/30-reject-huge-negative-int.json new file mode 100644 index 0000000..43830c1 --- /dev/null +++ b/tests/cross-language/canonicalization/30-reject-huge-negative-int.json @@ -0,0 +1,6 @@ +{ + "name": "30-reject-huge-negative-int", + "description": "Large negative integers are rejected symmetrically.", + "input": "{\"a\":-9007199254740992}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/31-reject-trailing-garbage.json b/tests/cross-language/canonicalization/31-reject-trailing-garbage.json new file mode 100644 index 0000000..b7e8881 --- /dev/null +++ b/tests/cross-language/canonicalization/31-reject-trailing-garbage.json @@ -0,0 +1,6 @@ +{ + "name": "31-reject-trailing-garbage", + "description": "Trailing characters after the document are a parse error.", + "input": "{\"a\":1} x", + "reject": true +} diff --git a/tests/cross-language/canonicalization/32-reject-unescaped-control.json b/tests/cross-language/canonicalization/32-reject-unescaped-control.json new file mode 100644 index 0000000..bef71a3 --- /dev/null +++ b/tests/cross-language/canonicalization/32-reject-unescaped-control.json @@ -0,0 +1,6 @@ +{ + "name": "32-reject-unescaped-control", + "description": "Raw control characters inside strings are a parse error.", + "input": "{\"a\":\"tab\there\"}", + "reject": true +} diff --git a/tests/cross-language/canonicalization/33-reject-invalid-escape.json b/tests/cross-language/canonicalization/33-reject-invalid-escape.json new file mode 100644 index 0000000..54d8310 --- /dev/null +++ b/tests/cross-language/canonicalization/33-reject-invalid-escape.json @@ -0,0 +1,6 @@ +{ + "name": "33-reject-invalid-escape", + "description": "Unknown escape sequences are a parse error.", + "input": "{\"a\":\"\\x41\"}", + "reject": true +} From 5a2424ae0e2615f90e89e9b6b701e6a97b075c54 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 10:20:44 -0700 Subject: [PATCH 02/14] Align Go ECDSA hashing with the other SDKs; enforce P-256 everywhere 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. --- go/go.mod | 4 +- go/pkg/bundle/distribution.go | 9 +- go/pkg/crypto/cross_language_test.go | 104 ++++++++++++++++++ go/pkg/crypto/crypto.go | 67 +++++++++-- go/pkg/crypto/crypto_test.go | 31 ++++++ javascript/src/crypto.js | 62 ++++++++--- javascript/tests/cross-language.test.js | 42 +++++++ python/schemapin/crypto.py | 54 +++++++-- python/tests/test_cross_language.py | 45 ++++++++ rust/tests/cross_language_signature.rs | 58 ++++++++++ .../keys/reject-p384-public.pem | 5 + .../keys/reject-rsa2048-public.pem | 9 ++ .../keys/test-only-bundle-authority.pem | 5 + .../keys/test-only-bundle-authority.pub.pem | 4 + .../keys/test-only-signing-key.pem | 5 + .../keys/test-only-signing-key.pub.pem | 4 + .../signatures/schema-signature.json | 67 +++++++++++ 17 files changed, 537 insertions(+), 38 deletions(-) create mode 100644 go/pkg/crypto/cross_language_test.go create mode 100644 rust/tests/cross_language_signature.rs create mode 100644 tests/cross-language/keys/reject-p384-public.pem create mode 100644 tests/cross-language/keys/reject-rsa2048-public.pem create mode 100644 tests/cross-language/keys/test-only-bundle-authority.pem create mode 100644 tests/cross-language/keys/test-only-bundle-authority.pub.pem create mode 100644 tests/cross-language/keys/test-only-signing-key.pem create mode 100644 tests/cross-language/keys/test-only-signing-key.pub.pem create mode 100644 tests/cross-language/signatures/schema-signature.json diff --git a/go/go.mod b/go/go.mod index 95484b8..ce16dd6 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,6 +1,8 @@ module github.com/ThirdKeyAi/schemapin/go -go 1.21 +go 1.22 + +toolchain go1.26.0 require ( github.com/spf13/cobra v1.8.0 diff --git a/go/pkg/bundle/distribution.go b/go/pkg/bundle/distribution.go index 3ce5f20..873e416 100644 --- a/go/pkg/bundle/distribution.go +++ b/go/pkg/bundle/distribution.go @@ -23,7 +23,6 @@ package bundle import ( "bytes" - "crypto/sha256" "encoding/json" "fmt" "sort" @@ -230,9 +229,10 @@ func SignTrustBundle(b *SchemaPinTrustBundle, privateKeyPEM, kid, signedAt, expi return nil, err } - hash := sha256.Sum256([]byte(canonical)) + // SignHash applies SHA-256 itself, so pass the canonical bytes directly: + // the signature is ECDSA over SHA-256(canonical), identical to every SDK. sm := crypto.NewSignatureManager() - sig, err := sm.SignHash(hash[:], priv) + sig, err := sm.SignHash([]byte(canonical), priv) if err != nil { return nil, fmt.Errorf("failed to sign bundle: %w", err) } @@ -289,9 +289,8 @@ func VerifyTrustBundle(b *SchemaPinTrustBundle, authorityPinStore *AuthorityPinS if err != nil { return err } - hash := sha256.Sum256([]byte(canonical)) sm := crypto.NewSignatureManager() - if !sm.VerifySignature(hash[:], b.Signature, pub) { + if !sm.VerifySignature([]byte(canonical), b.Signature, pub) { return newBundleError(ErrSignatureInvalid, "trust bundle signature does not verify") } return nil diff --git a/go/pkg/crypto/cross_language_test.go b/go/pkg/crypto/cross_language_test.go new file mode 100644 index 0000000..69036a4 --- /dev/null +++ b/go/pkg/crypto/cross_language_test.go @@ -0,0 +1,104 @@ +package crypto + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/ThirdKeyAi/schemapin/go/pkg/core" +) + +func fixtureRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 8; i++ { + candidate := filepath.Join(dir, "tests", "cross-language") + if st, err := os.Stat(candidate); err == nil && st.IsDir() { + return candidate + } + dir = filepath.Dir(dir) + } + t.Fatal("could not locate tests/cross-language") + return "" +} + +type schemaSignatureVector struct { + PublicKeyPEM string `json:"public_key_pem"` + KeyFingerprint string `json:"key_fingerprint"` + Schema map[string]interface{} `json:"schema"` + Canonical string `json:"canonical"` + SHA256 string `json:"sha256"` + Signature string `json:"signature"` + SignatureWithTrailingByte string `json:"signature_with_trailing_byte"` + TamperedSchema map[string]interface{} `json:"tampered_schema"` +} + +// TestCrossLanguageSchemaSignature proves SP-2: the Go SDK accepts a signature +// produced by another SDK (ECDSA over SHA-256 of the canonical digest) and +// rejects tampering and DER trailing bytes, like every other SDK. +func TestCrossLanguageSchemaSignature(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(fixtureRoot(t), "signatures", "schema-signature.json")) // #nosec G304 + if err != nil { + t.Fatal(err) + } + var v schemaSignatureVector + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + + c := core.NewSchemaPinCore() + canonical, err := c.CanonicalizeSchema(v.Schema) + if err != nil { + t.Fatal(err) + } + if canonical != v.Canonical { + t.Fatalf("canonical mismatch:\n got %q\nwant %q", canonical, v.Canonical) + } + digest := c.HashCanonical(canonical) + if hex.EncodeToString(digest) != v.SHA256 { + t.Fatalf("digest mismatch") + } + + km := NewKeyManager() + pub, err := km.LoadPublicKeyPEM(v.PublicKeyPEM) + if err != nil { + t.Fatal(err) + } + if fp, _ := km.CalculateKeyFingerprint(pub); fp != v.KeyFingerprint { + t.Fatalf("fingerprint mismatch: %s", fp) + } + + sm := NewSignatureManager() + if !sm.VerifySchemaSignature(digest, v.Signature, pub) { + t.Fatal("fixed cross-language signature must verify in Go") + } + tampered, _ := c.CanonicalizeAndHash(v.TamperedSchema) + if sm.VerifySchemaSignature(tampered, v.Signature, pub) { + t.Fatal("tampered schema must not verify") + } + if sm.VerifySchemaSignature(digest, v.SignatureWithTrailingByte, pub) { + t.Fatal("DER signature with trailing bytes must not verify (SP-19)") + } +} + +// TestNonP256KeysRejected proves SP-10 for Go. +func TestNonP256KeysRejected(t *testing.T) { + km := NewKeyManager() + for _, name := range []string{"reject-p384-public.pem", "reject-rsa2048-public.pem"} { + pem, err := os.ReadFile(filepath.Join(fixtureRoot(t), "keys", name)) // #nosec G304 + if err != nil { + t.Fatal(err) + } + if _, err := km.LoadPublicKeyPEM(string(pem)); err == nil { + t.Errorf("%s: expected rejection", name) + } + if _, err := km.CalculateKeyFingerprintFromPEM(string(pem)); err == nil { + t.Errorf("%s: fingerprint must fail", name) + } + } +} diff --git a/go/pkg/crypto/crypto.go b/go/pkg/crypto/crypto.go index 90955f3..b87dbfc 100644 --- a/go/pkg/crypto/crypto.go +++ b/go/pkg/crypto/crypto.go @@ -70,10 +70,14 @@ func (k *KeyManager) LoadPrivateKeyPEM(pemData string) (*ecdsa.PrivateKey, error // Try PKCS#8 format first (preferred) if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { - if ecdsaKey, ok := key.(*ecdsa.PrivateKey); ok { - return ecdsaKey, nil + ecdsaKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an ECDSA private key") } - return nil, fmt.Errorf("not an ECDSA private key") + if err := requireP256(ecdsaKey.Curve, "private key"); err != nil { + return nil, err + } + return ecdsaKey, nil } // Fall back to EC private key format @@ -81,10 +85,26 @@ func (k *KeyManager) LoadPrivateKeyPEM(pemData string) (*ecdsa.PrivateKey, error if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } + if err := requireP256(key.Curve, "private key"); err != nil { + return nil, err + } return key, nil } +// requireP256 enforces spec section 10: keys that are not ECDSA P-256 MUST be +// rejected. +func requireP256(curve elliptic.Curve, what string) error { + if curve != elliptic.P256() { + name := "unknown" + if curve != nil && curve.Params() != nil { + name = curve.Params().Name + } + return fmt.Errorf("%s uses curve %s; only P-256 is accepted", what, name) + } + return nil +} + // LoadPublicKeyPEM loads public key from PEM format func (k *KeyManager) LoadPublicKeyPEM(pemData string) (*ecdsa.PublicKey, error) { block, _ := pem.Decode([]byte(pemData)) @@ -101,6 +121,9 @@ func (k *KeyManager) LoadPublicKeyPEM(pemData string) (*ecdsa.PublicKey, error) if !ok { return nil, fmt.Errorf("not an ECDSA public key") } + if err := requireP256(ecdsaKey.Curve, "public key"); err != nil { + return nil, err + } return ecdsaKey, nil } @@ -138,9 +161,25 @@ type ecdsaSignature struct { R, S *big.Int } -// SignHash signs a hash with the private key and returns base64-encoded signature +// SignHash signs data with ECDSA P-256 over SHA-256(hashBytes) and returns a +// base64-encoded DER signature. +// +// The ECDSA input is SHA-256(hashBytes): for schema and legacy skill +// signatures hashBytes is itself the canonical SHA-256 digest, for bundles and +// v1.4 skill signature documents it is the canonical bytes. This matches the +// Python (cryptography), JavaScript (createSign('SHA256')) and Rust (p256 +// SigningKey::sign) SDKs, which all hash their input before ECDSA. Earlier Go +// releases passed hashBytes to ecdsa.Sign directly and were therefore +// incompatible with every other SDK. func (s *SignatureManager) SignHash(hashBytes []byte, privateKey *ecdsa.PrivateKey) (string, error) { - r, sig, err := ecdsa.Sign(rand.Reader, privateKey, hashBytes) + if privateKey == nil { + return "", fmt.Errorf("private key is nil") + } + if err := requireP256(privateKey.Curve, "private key"); err != nil { + return "", err + } + digest := sha256.Sum256(hashBytes) + r, sig, err := ecdsa.Sign(rand.Reader, privateKey, digest[:]) if err != nil { return "", fmt.Errorf("failed to sign hash: %w", err) } @@ -155,20 +194,30 @@ func (s *SignatureManager) SignHash(hashBytes []byte, privateKey *ecdsa.PrivateK return base64.StdEncoding.EncodeToString(derBytes), nil } -// VerifySignature verifies a base64-encoded signature against a hash +// VerifySignature verifies a base64-encoded DER signature against +// SHA-256(hashBytes). See SignHash for the hashing convention. Trailing bytes +// after the DER structure, non-P-256 keys and malformed base64 all fail. func (s *SignatureManager) VerifySignature(hashBytes []byte, signatureB64 string, publicKey *ecdsa.PublicKey) bool { + if publicKey == nil || requireP256(publicKey.Curve, "public key") != nil { + return false + } signature, err := base64.StdEncoding.DecodeString(signatureB64) if err != nil { return false } - // Decode ASN.1 DER signature + // Decode ASN.1 DER signature; reject trailing garbage. var sig ecdsaSignature - if _, err := asn1.Unmarshal(signature, &sig); err != nil { + rest, err := asn1.Unmarshal(signature, &sig) + if err != nil || len(rest) != 0 { + return false + } + if sig.R == nil || sig.S == nil || sig.R.Sign() <= 0 || sig.S.Sign() <= 0 { return false } - return ecdsa.Verify(publicKey, hashBytes, sig.R, sig.S) + digest := sha256.Sum256(hashBytes) + return ecdsa.Verify(publicKey, digest[:], sig.R, sig.S) } // SignSchemaHash signs a schema hash (convenience method) diff --git a/go/pkg/crypto/crypto_test.go b/go/pkg/crypto/crypto_test.go index 5e491a9..ddfeda3 100644 --- a/go/pkg/crypto/crypto_test.go +++ b/go/pkg/crypto/crypto_test.go @@ -1,7 +1,10 @@ package crypto import ( + "crypto/ecdsa" "crypto/sha256" + "encoding/asn1" + "encoding/base64" "strings" "testing" ) @@ -413,3 +416,31 @@ func TestCrossCompatibility(t *testing.T) { t.Error("Cross-compatibility test failed") } } + +// TestSignHashMatchesOtherSDKConvention pins the SP-2 fix: the DER signature +// must verify under ECDSA(SHA-256(input)), which is what Python, JavaScript +// and Rust compute, and must not verify under raw ecdsa.Verify(input). +func TestSignHashMatchesOtherSDKConvention(t *testing.T) { + km := NewKeyManager() + priv, err := km.GenerateKeypair() + if err != nil { + t.Fatal(err) + } + input := sha256.Sum256([]byte("canonical schema")) + sigB64, err := NewSignatureManager().SignHash(input[:], priv) + if err != nil { + t.Fatal(err) + } + der, _ := base64.StdEncoding.DecodeString(sigB64) + var sig ecdsaSignature + if _, err := asn1.Unmarshal(der, &sig); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(input[:]) + if !ecdsa.Verify(&priv.PublicKey, digest[:], sig.R, sig.S) { + t.Fatal("signature must be over SHA-256(input)") + } + if ecdsa.Verify(&priv.PublicKey, input[:], sig.R, sig.S) { + t.Fatal("signature must not be over the raw input") + } +} diff --git a/javascript/src/crypto.js b/javascript/src/crypto.js index ed71128..b8023aa 100644 --- a/javascript/src/crypto.js +++ b/javascript/src/crypto.js @@ -2,7 +2,26 @@ * Cryptographic operations for SchemaPin using ECDSA P-256. */ -import { createSign, createVerify, generateKeyPairSync, createHash, createPublicKey } from 'crypto'; +import { createSign, createVerify, generateKeyPairSync, createHash, createPublicKey, createPrivateKey } from 'crypto'; + +/** The only curve SchemaPin accepts (spec section 10). */ +export const REQUIRED_CURVE = 'prime256v1'; + +/** + * Throw unless `keyObject` is an EC key on P-256 (prime256v1 / secp256r1). + * + * @param {import('crypto').KeyObject} keyObject + * @param {string} what - label for the error message + */ +function assertP256(keyObject, what) { + if (keyObject.asymmetricKeyType !== 'ec') { + throw new Error(`${what} is not an ECDSA key (got ${keyObject.asymmetricKeyType})`); + } + const curve = keyObject.asymmetricKeyDetails?.namedCurve; + if (curve !== REQUIRED_CURVE) { + throw new Error(`${what} uses curve ${curve}; only ${REQUIRED_CURVE} (P-256) is accepted`); + } +} /** * Manages ECDSA P-256 key generation and serialization. @@ -50,22 +69,26 @@ export class KeyManager { } /** - * Load private key from PEM format. - * + * Load private key from PEM format, rejecting anything but ECDSA P-256. + * * @param {string} pemData - PEM-encoded private key string - * @returns {string} ECDSA private key + * @returns {string} ECDSA private key (PEM) + * @throws {Error} if the key is not an EC key on prime256v1 */ static loadPrivateKeyPem(pemData) { + assertP256(createPrivateKey(pemData), 'private key'); return pemData; } /** - * Load public key from PEM format. - * + * Load public key from PEM format, rejecting anything but ECDSA P-256. + * * @param {string} pemData - PEM-encoded public key string - * @returns {string} ECDSA public key + * @returns {string} ECDSA public key (PEM) + * @throws {Error} if the key is not an EC key on prime256v1 */ static loadPublicKeyPem(pemData) { + assertP256(createPublicKey(pemData), 'public key'); return pemData; } @@ -78,6 +101,7 @@ export class KeyManager { static calculateKeyFingerprint(publicKeyPem) { // Convert PEM to DER format for consistent fingerprinting const keyObject = createPublicKey(publicKeyPem); + assertP256(keyObject, 'public key'); const der = keyObject.export({ type: 'spki', format: 'der' }); const hash = createHash('sha256').update(der).digest('hex'); return `sha256:${hash}`; @@ -89,16 +113,24 @@ export class KeyManager { */ export class SignatureManager { /** - * Sign hash using ECDSA P-256 and return Base64-encoded signature. - * - * @param {Buffer} hashBytes - SHA-256 hash to sign - * @param {string} privateKey - ECDSA private key in PEM format - * @returns {string} Base64-encoded signature + * Sign data using ECDSA P-256 with SHA-256 and return a Base64 signature. + * + * The ECDSA input is SHA-256(hashBytes); for schema and legacy skill + * signatures `hashBytes` is itself the canonical SHA-256 digest, for + * bundles and v1.4 skill signature documents it is the canonical bytes. + * Every SDK applies exactly this construction. + * + * @param {Buffer} hashBytes - Bytes to sign + * @param {string} privateKey - ECDSA P-256 private key in PEM format + * @returns {string} Base64-encoded DER signature + * @throws {Error} if the private key is not ECDSA P-256 */ static signHash(hashBytes, privateKey) { + const keyObject = createPrivateKey(privateKey); + assertP256(keyObject, 'private key'); const sign = createSign('SHA256'); sign.update(hashBytes); - const signature = sign.sign(privateKey); + const signature = sign.sign(keyObject); return signature.toString('base64'); } @@ -112,10 +144,12 @@ export class SignatureManager { */ static verifySignature(hashBytes, signatureB64, publicKey) { try { + const keyObject = createPublicKey(publicKey); + assertP256(keyObject, 'public key'); const verify = createVerify('SHA256'); verify.update(hashBytes); const signature = Buffer.from(signatureB64, 'base64'); - return verify.verify(publicKey, signature); + return verify.verify(keyObject, signature); } catch (error) { return false; } diff --git a/javascript/tests/cross-language.test.js b/javascript/tests/cross-language.test.js index 2d4a7e2..6699f9f 100644 --- a/javascript/tests/cross-language.test.js +++ b/javascript/tests/cross-language.test.js @@ -43,3 +43,45 @@ describe('cross-language canonicalization corpus', () => { }); } }); + +// --------------------------------------------------------------------------- +// Fixed ECDSA signature vector (SP-2) and key-policy fixtures (SP-10, SP-19) +// --------------------------------------------------------------------------- + +import { KeyManager, SignatureManager } from '../src/crypto.js'; + +const SIGNATURES = resolve(HERE, '..', '..', 'tests', 'cross-language', 'signatures'); +const KEYS = resolve(HERE, '..', '..', 'tests', 'cross-language', 'keys'); + +describe('cross-language signature vector', () => { + const v = JSON.parse(readFileSync(join(SIGNATURES, 'schema-signature.json'), 'utf-8')); + + test('fixed P-256 schema signature verifies', () => { + const canonical = SchemaPinCore.canonicalizeSchema(v.schema); + assert.strictEqual(canonical, v.canonical); + const digest = SchemaPinCore.hashCanonical(canonical); + assert.strictEqual(digest.toString('hex'), v.sha256); + assert.strictEqual(KeyManager.calculateKeyFingerprint(v.public_key_pem), v.key_fingerprint); + assert.strictEqual(SignatureManager.verifySchemaSignature(digest, v.signature, v.public_key_pem), true); + }); + + test('tampered schema and DER trailing bytes are rejected', () => { + const digest = SchemaPinCore.hashCanonical(v.canonical); + const tampered = SchemaPinCore.canonicalizeAndHash(v.tampered_schema); + assert.strictEqual(SignatureManager.verifySchemaSignature(tampered, v.signature, v.public_key_pem), false); + assert.strictEqual( + SignatureManager.verifySchemaSignature(digest, v.signature_with_trailing_byte, v.public_key_pem), + false + ); + }); + + for (const name of ['reject-p384-public.pem', 'reject-rsa2048-public.pem']) { + test(`non-P-256 key rejected: ${name}`, () => { + const pem = readFileSync(join(KEYS, name), 'utf-8'); + assert.throws(() => KeyManager.loadPublicKeyPem(pem), /only prime256v1|not an ECDSA key/); + assert.throws(() => KeyManager.calculateKeyFingerprint(pem), /only prime256v1|not an ECDSA key/); + const digest = SchemaPinCore.hashCanonical(v.canonical); + assert.strictEqual(SignatureManager.verifySignature(digest, v.signature, pem), false); + }); + } +}); diff --git a/python/schemapin/crypto.py b/python/schemapin/crypto.py index 8c62d1c..6be14c9 100644 --- a/python/schemapin/crypto.py +++ b/python/schemapin/crypto.py @@ -2,7 +2,7 @@ import base64 import hashlib -from typing import Tuple +from typing import Any, Tuple from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec @@ -12,6 +12,21 @@ ) +#: The only curve SchemaPin accepts (spec section 10: "not valid ECDSA P-256 +#: keys MUST be rejected"). +REQUIRED_CURVE = "secp256r1" + + +def _require_p256(key: Any, what: str) -> None: + """Raise ``ValueError`` unless ``key`` is an ECDSA key on secp256r1.""" + if not isinstance(key, (EllipticCurvePrivateKey, EllipticCurvePublicKey)): + raise ValueError(f"{what} is not an ECDSA key (got {type(key).__name__})") + if key.curve.name != REQUIRED_CURVE: + raise ValueError( + f"{what} uses curve {key.curve.name}; only {REQUIRED_CURVE} (P-256) is accepted" + ) + + class KeyManager: """Manages ECDSA P-256 key generation and serialization.""" @@ -72,11 +87,16 @@ def load_private_key_pem(pem_data: str) -> EllipticCurvePrivateKey: Returns: ECDSA private key + + Raises: + ValueError: If the key is not an ECDSA P-256 (secp256r1) key. """ - return serialization.load_pem_private_key( + key = serialization.load_pem_private_key( pem_data.encode('utf-8'), password=None ) + _require_p256(key, "private key") + return key # type: ignore[return-value] @staticmethod def load_public_key_pem(pem_data: str) -> EllipticCurvePublicKey: @@ -88,8 +108,13 @@ def load_public_key_pem(pem_data: str) -> EllipticCurvePublicKey: Returns: ECDSA public key + + Raises: + ValueError: If the key is not an ECDSA P-256 (secp256r1) key. """ - return serialization.load_pem_public_key(pem_data.encode('utf-8')) + key = serialization.load_pem_public_key(pem_data.encode('utf-8')) + _require_p256(key, "public key") + return key # type: ignore[return-value] @staticmethod def calculate_key_fingerprint(public_key: EllipticCurvePublicKey) -> str: @@ -130,15 +155,24 @@ class SignatureManager: @staticmethod def sign_hash(hash_bytes: bytes, private_key: EllipticCurvePrivateKey) -> str: """ - Sign hash using ECDSA P-256 and return Base64-encoded signature. + Sign data using ECDSA P-256 with SHA-256 and return a Base64 signature. + + The ECDSA input is ``SHA-256(hash_bytes)``; for schema and legacy skill + signatures ``hash_bytes`` is itself the canonical SHA-256 digest, for + bundles and v1.4 skill signature documents it is the canonical bytes. + Every SDK applies exactly this construction. Args: - hash_bytes: SHA-256 hash to sign - private_key: ECDSA private key + hash_bytes: Bytes to sign + private_key: ECDSA P-256 private key Returns: - Base64-encoded signature + Base64-encoded DER signature + + Raises: + ValueError: If the private key is not ECDSA P-256. """ + _require_p256(private_key, "private key") signature = private_key.sign(hash_bytes, ec.ECDSA(hashes.SHA256())) return base64.b64encode(signature).decode('ascii') @@ -153,10 +187,12 @@ def verify_signature(hash_bytes: bytes, signature_b64: str, public_key: Elliptic public_key: ECDSA public key Returns: - True if signature is valid, False otherwise + True if signature is valid, False otherwise (including when the + public key is not ECDSA P-256 or the DER signature is malformed) """ try: - signature = base64.b64decode(signature_b64) + _require_p256(public_key, "public key") + signature = base64.b64decode(signature_b64, validate=True) public_key.verify(signature, hash_bytes, ec.ECDSA(hashes.SHA256())) return True except Exception: diff --git a/python/tests/test_cross_language.py b/python/tests/test_cross_language.py index 8ba78f4..b046aac 100644 --- a/python/tests/test_cross_language.py +++ b/python/tests/test_cross_language.py @@ -45,3 +45,48 @@ def test_canonicalization_vector(vector): SchemaPinCore.canonicalize_schema(SchemaPinCore.parse_schema_json(canonical)) == canonical ) + + +# --------------------------------------------------------------------------- +# Fixed ECDSA signature vector (SP-2) and key-policy fixtures (SP-10, SP-19) +# --------------------------------------------------------------------------- + +SIGNATURES = os.path.join(ROOT, "tests", "cross-language", "signatures") +KEYS = os.path.join(ROOT, "tests", "cross-language", "keys") + + +def _load_json(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def test_schema_signature_vector_verifies(): + from schemapin.crypto import KeyManager, SignatureManager + + v = _load_json(os.path.join(SIGNATURES, "schema-signature.json")) + canonical = SchemaPinCore.canonicalize_schema(v["schema"]) + assert canonical == v["canonical"] + digest = SchemaPinCore.hash_canonical(canonical) + assert digest.hex() == v["sha256"] + public_key = KeyManager.load_public_key_pem(v["public_key_pem"]) + assert KeyManager.calculate_key_fingerprint(public_key) == v["key_fingerprint"] + assert SignatureManager.verify_schema_signature(digest, v["signature"], public_key) + # Tampered schema must fail. + tampered = SchemaPinCore.canonicalize_and_hash(v["tampered_schema"]) + assert not SignatureManager.verify_schema_signature(tampered, v["signature"], public_key) + # DER trailing bytes must fail. + assert not SignatureManager.verify_schema_signature( + digest, v["signature_with_trailing_byte"], public_key + ) + + +@pytest.mark.parametrize("name", ["reject-p384-public.pem", "reject-rsa2048-public.pem"]) +def test_non_p256_public_keys_rejected(name): + from schemapin.crypto import KeyManager + + with open(os.path.join(KEYS, name), encoding="utf-8") as f: + pem = f.read() + with pytest.raises(ValueError): + KeyManager.load_public_key_pem(pem) + with pytest.raises(ValueError): + KeyManager.calculate_key_fingerprint_from_pem(pem) diff --git a/rust/tests/cross_language_signature.rs b/rust/tests/cross_language_signature.rs new file mode 100644 index 0000000..a855af2 --- /dev/null +++ b/rust/tests/cross_language_signature.rs @@ -0,0 +1,58 @@ +//! Cross-language interop: fixed ECDSA signature vector and key-policy +//! fixtures shared by every SDK (see ../../tests/cross-language/README.md). + +use schemapin::canonicalize::{canonicalize_and_hash, canonicalize_schema, hash_canonical}; +use schemapin::crypto::{calculate_key_id, verify_signature}; +use serde_json::Value; + +#[derive(serde::Deserialize)] +struct Vector { + public_key_pem: String, + key_fingerprint: String, + schema: Value, + canonical: String, + sha256: String, + signature: String, + signature_with_trailing_byte: String, + tampered_schema: Value, +} + +fn load_vector() -> Vector { + let text = std::fs::read_to_string("../tests/cross-language/signatures/schema-signature.json") + .expect("fixture present"); + serde_json::from_str(&text).unwrap() +} + +#[test] +fn fixed_schema_signature_verifies() { + let v = load_vector(); + let canonical = canonicalize_schema(&v.schema).unwrap(); + assert_eq!(canonical, v.canonical); + let digest = hash_canonical(&canonical); + assert_eq!(hex::encode(&digest), v.sha256); + assert_eq!(calculate_key_id(&v.public_key_pem).unwrap(), v.key_fingerprint); + assert!(verify_signature(&v.public_key_pem, &digest, &v.signature).unwrap()); +} + +#[test] +fn tampered_schema_and_trailing_der_bytes_rejected() { + let v = load_vector(); + let digest = hash_canonical(&v.canonical); + let tampered = canonicalize_and_hash(&v.tampered_schema).unwrap(); + assert!(!verify_signature(&v.public_key_pem, &tampered, &v.signature).unwrap()); + let trailing = verify_signature(&v.public_key_pem, &digest, &v.signature_with_trailing_byte); + assert!(matches!(trailing, Ok(false) | Err(_))); +} + +#[test] +fn non_p256_public_keys_rejected() { + for name in ["reject-p384-public.pem", "reject-rsa2048-public.pem"] { + let pem = std::fs::read_to_string(format!("../tests/cross-language/keys/{}", name)).unwrap(); + assert!(calculate_key_id(&pem).is_err(), "{} must be rejected", name); + assert!( + verify_signature(&pem, b"x", "AAAA").is_err(), + "{} must be rejected", + name + ); + } +} diff --git a/tests/cross-language/keys/reject-p384-public.pem b/tests/cross-language/keys/reject-p384-public.pem new file mode 100644 index 0000000..ef1c1d5 --- /dev/null +++ b/tests/cross-language/keys/reject-p384-public.pem @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEDMfvGdEF8/6q4Y2KK8zKBmWiQDEW6ojJ +WcHuAhIUQMVf8VAFh9sAChPlfwXMmCOJUlOs4IgORgCvbLHpM8bcUTDoABHjHE/6 +jlBxs5LXxKOX6iHJLkzIa882TsXXJ+X4 +-----END PUBLIC KEY----- diff --git a/tests/cross-language/keys/reject-rsa2048-public.pem b/tests/cross-language/keys/reject-rsa2048-public.pem new file mode 100644 index 0000000..ca8baba --- /dev/null +++ b/tests/cross-language/keys/reject-rsa2048-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkwlICPlPgl539GGBebET +wrgDjaji6FCRNUvJGsOywnh2wTaicrO+cJXpAVFArs5eVpU+muJD3Kg4TKrcQ9di +NxXq5gEb9WCIsfXRbLs8yrYky0DXDwIc8jvQ6gxqQBjqXAl5JQb12edr4nKTFvaT +aEMXWBcv5iBQwpDnKmZHTx5+3VwGEjelJr6pWgq1+dpX4MZUXVm/C/3evx3/z4cK +g2+OWKx1QNAhmbf4PHIY/XHGbQdinhenHVhwMhutEo+pRJhGqKHLURTDoxKPk19y +CRFZcz/wU0uksgBRfaKY7FhekC9h4chZFUbW4Hq7eoqVQeuSF/HOFpFC07Z3vXmh +0wIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/cross-language/keys/test-only-bundle-authority.pem b/tests/cross-language/keys/test-only-bundle-authority.pem new file mode 100644 index 0000000..55871d0 --- /dev/null +++ b/tests/cross-language/keys/test-only-bundle-authority.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgQQnwEqG7sXBXUu8D +2kXs+XyhSZLIdPmCPN0cXEmF286hRANCAAS/8tqFGiCdRW6WW0CFY7bWNiX30Qq0 +Hp6QSwjJOk+ivV72gp4Bb1pDbixQuDYwbaq85a/gIullPTxPLiUBdIlf +-----END PRIVATE KEY----- diff --git a/tests/cross-language/keys/test-only-bundle-authority.pub.pem b/tests/cross-language/keys/test-only-bundle-authority.pub.pem new file mode 100644 index 0000000..e5b944a --- /dev/null +++ b/tests/cross-language/keys/test-only-bundle-authority.pub.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEv/LahRognUVulltAhWO21jYl99EK +tB6ekEsIyTpPor1e9oKeAW9aQ24sULg2MG2qvOWv4CLpZT08Ty4lAXSJXw== +-----END PUBLIC KEY----- diff --git a/tests/cross-language/keys/test-only-signing-key.pem b/tests/cross-language/keys/test-only-signing-key.pem new file mode 100644 index 0000000..ffa449b --- /dev/null +++ b/tests/cross-language/keys/test-only-signing-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTn2GhWQI+mFo/pcx +8tMGauKSUMhP46jIheJ+hVNdHfGhRANCAAR1pkrSgwuHjsi9Vhh7xs/aDaSjhzQl +SY+Y3Z5wRCt5n27yhN3R3Ot1lGIUO4pxg8aqNIk0IiX9oTl5uPtJFfGN +-----END PRIVATE KEY----- diff --git a/tests/cross-language/keys/test-only-signing-key.pub.pem b/tests/cross-language/keys/test-only-signing-key.pub.pem new file mode 100644 index 0000000..d27c9ca --- /dev/null +++ b/tests/cross-language/keys/test-only-signing-key.pub.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEdaZK0oMLh47IvVYYe8bP2g2ko4c0 +JUmPmN2ecEQreZ9u8oTd0dzrdZRiFDuKcYPGqjSJNCIl/aE5ebj7SRXxjQ== +-----END PUBLIC KEY----- diff --git a/tests/cross-language/signatures/schema-signature.json b/tests/cross-language/signatures/schema-signature.json new file mode 100644 index 0000000..a280f7f --- /dev/null +++ b/tests/cross-language/signatures/schema-signature.json @@ -0,0 +1,67 @@ +{ + "name": "schema-signature-p256", + "description": "ECDSA P-256 schema signature. Scheme: DER(ECDSA(SHA-256(digest))) where digest = SHA-256(canonical UTF-8 bytes). Verifiers must accept `signature`, reject `signature_with_trailing_byte` (DER trailing data) and reject `signature` over `tampered_schema`.", + "scheme": "ecdsa-p256-sha256-over-sha256-digest", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEdaZK0oMLh47IvVYYe8bP2g2ko4c0\nJUmPmN2ecEQreZ9u8oTd0dzrdZRiFDuKcYPGqjSJNCIl/aE5ebj7SRXxjQ==\n-----END PUBLIC KEY-----\n", + "key_fingerprint": "sha256:bbdd8a900c49dbc8600300a54422f79f24f45977c09907a09e0072528d903c13", + "schema": { + "name": "get_weather", + "description": "Get weather for a city & region", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "maxLength": 100 + }, + "units": { + "type": "string", + "enum": [ + "metric", + "imperial" + ] + }, + "days": { + "type": "integer", + "minimum": 1, + "maximum": 14 + } + }, + "required": [ + "city" + ] + } + }, + "canonical": "{\"description\":\"Get weather for a city & region\",\"inputSchema\":{\"properties\":{\"city\":{\"maxLength\":100,\"type\":\"string\"},\"days\":{\"maximum\":14,\"minimum\":1,\"type\":\"integer\"},\"units\":{\"enum\":[\"metric\",\"imperial\"],\"type\":\"string\"}},\"required\":[\"city\"],\"type\":\"object\"},\"name\":\"get_weather\"}", + "sha256": "0a61bf006ee496152c71d4355f1c4c82d63df4093b33ef964a96347537f45277", + "signature": "MEYCIQD/59EsPUV+mrMLILxdqc4Pp42+HGr4xA8TWZ1UZnyOqwIhAOJN8r8kdZaUgDOR1sWXWt8nBZ6qdpR0WrM0EWmys41z", + "signature_with_trailing_byte": "MEYCIQD/59EsPUV+mrMLILxdqc4Pp42+HGr4xA8TWZ1UZnyOqwIhAOJN8r8kdZaUgDOR1sWXWt8nBZ6qdpR0WrM0EWmys41zAA==", + "tampered_schema": { + "name": "get_weather", + "description": "Get weather for a city & region", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "maxLength": 100 + }, + "units": { + "type": "string", + "enum": [ + "metric", + "imperial" + ] + }, + "days": { + "type": "integer", + "minimum": 1, + "maximum": 15 + } + }, + "required": [ + "city" + ] + } + } +} From 6c74649b830db2d57f24c1ae38674221886e27a1 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 10:57:02 -0700 Subject: [PATCH 03/14] Validate domains, harden discovery transport, fail closed on revocation 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. --- go/cmd/schemapin-verify/main.go | 18 ++- go/go.mod | 2 +- go/pkg/discovery/discovery.go | 117 ++++++++++-------- go/pkg/discovery/discovery_test.go | 31 +++-- go/pkg/domain/domain.go | 70 +++++++++++ go/pkg/domain/domain_test.go | 48 ++++++++ go/pkg/pinning/pinning.go | 34 ++++-- go/pkg/pinning/pinning_test.go | 11 +- go/pkg/resolver/resolver.go | 61 ++++++++-- go/pkg/resolver/resolver_test.go | 29 +++++ go/pkg/revocation/revocation.go | 36 +++--- go/pkg/transport/transport.go | 105 +++++++++++++++++ go/pkg/transport/transport_test.go | 81 +++++++++++++ go/pkg/utils/utils.go | 13 +- go/pkg/utils/utils_test.go | 29 ++++- javascript/src/discovery.js | 96 +++++++++------ javascript/src/domain.js | 81 +++++++++++++ javascript/src/resolver.js | 46 +++++++- javascript/src/revocation.js | 48 +++++--- javascript/src/transport.js | 123 +++++++++++++++++++ python/schemapin/discovery.py | 115 ++++++++++++------ python/schemapin/domain.py | 67 +++++++++++ python/schemapin/resolver.py | 55 +++++++-- python/schemapin/revocation.py | 27 +++-- python/schemapin/transport.py | 80 +++++++++++++ python/tests/test_cli_tools.py | 5 +- python/tests/test_domain.py | 60 ++++++++++ python/tests/test_interactive_pinning.py | 16 +-- python/tests/test_resolver.py | 19 +++ python/tests/test_transport.py | 68 +++++++++++ rust/src/discovery.rs | 78 ++++++------ rust/src/domain.rs | 144 +++++++++++++++++++++++ rust/src/error.rs | 20 ++++ rust/src/lib.rs | 2 + rust/src/resolver.rs | 66 ++++++++++- rust/src/revocation.rs | 31 ++--- rust/src/transport.rs | 119 +++++++++++++++++++ 37 files changed, 1744 insertions(+), 307 deletions(-) create mode 100644 go/pkg/domain/domain.go create mode 100644 go/pkg/domain/domain_test.go create mode 100644 go/pkg/transport/transport.go create mode 100644 go/pkg/transport/transport_test.go create mode 100644 javascript/src/domain.js create mode 100644 javascript/src/transport.js create mode 100644 python/schemapin/domain.py create mode 100644 python/schemapin/transport.py create mode 100644 python/tests/test_domain.py create mode 100644 python/tests/test_transport.py create mode 100644 rust/src/domain.rs create mode 100644 rust/src/transport.rs diff --git a/go/cmd/schemapin-verify/main.go b/go/cmd/schemapin-verify/main.go index 96f5b3b..f55f461 100644 --- a/go/cmd/schemapin-verify/main.go +++ b/go/cmd/schemapin-verify/main.go @@ -90,7 +90,7 @@ key pinning for Trust-On-First-Use (TOFU) security.`, rootCmd.Flags().StringVar(&toolID, "tool-id", "", "Tool identifier for key pinning") rootCmd.Flags().StringVar(&pinningDB, "pinning-db", "", "Path to key pinning database") rootCmd.Flags().BoolVar(&interactiveMode, "interactive", false, "Enable interactive key pinning prompts") - rootCmd.Flags().BoolVar(&autoPin, "auto-pin", false, "Automatically pin keys on first use") + rootCmd.Flags().BoolVar(&autoPin, "auto-pin", false, "Automatically pin keys on first use (requires --tool-id; pins without --interactive)") // Batch processing options rootCmd.Flags().StringVar(&pattern, "pattern", "*.json", "File pattern for batch processing") @@ -333,11 +333,15 @@ func verifyWithDiscovery(schema map[string]interface{}, signature string) (Verif return VerificationResult{}, fmt.Errorf("failed to load discovered public key: %w", err) } - // Check if key is revoked + // Check if key is revoked. Fail-closed: if the revocation status cannot + // be determined the key is not trusted. isNotRevoked, err := discoveryClient.ValidateKeyNotRevoked(ctx, publicKeyPEM, domain) if err != nil { - // If we can't check revocation, proceed with caution - isNotRevoked = true + return VerificationResult{ + Valid: false, + VerificationMethod: "discovery", + Error: fmt.Sprintf("revocation status unavailable (fail-closed): %v", err), + }, nil } if !isNotRevoked { @@ -348,8 +352,10 @@ func verifyWithDiscovery(schema map[string]interface{}, signature string) (Verif }, nil } - // Handle interactive pinning if enabled - if interactiveMode && toolID != "" { + // Handle pinning when --interactive or --auto-pin is given. --auto-pin + // alone pins on first use without prompting (automatic mode); with + // --interactive the console handler decides. + if (interactiveMode || autoPin) && toolID != "" { pinningManager, err := createPinningManager() if err != nil { return VerificationResult{}, fmt.Errorf("failed to create pinning manager: %w", err) diff --git a/go/go.mod b/go/go.mod index ce16dd6..4704946 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,6 +1,6 @@ module github.com/ThirdKeyAi/schemapin/go -go 1.22 +go 1.23 toolchain go1.26.0 diff --git a/go/pkg/discovery/discovery.go b/go/pkg/discovery/discovery.go index bc16119..e5de724 100644 --- a/go/pkg/discovery/discovery.go +++ b/go/pkg/discovery/discovery.go @@ -3,14 +3,15 @@ package discovery import ( "context" - "encoding/json" "fmt" "net/http" - "net/url" "strings" "time" "github.com/ThirdKeyAi/schemapin/go/pkg/crypto" + "github.com/ThirdKeyAi/schemapin/go/pkg/domain" + "github.com/ThirdKeyAi/schemapin/go/pkg/revocation" + "github.com/ThirdKeyAi/schemapin/go/pkg/transport" ) // WellKnownResponse represents .well-known/schemapin.json structure @@ -29,46 +30,51 @@ type PublicKeyDiscovery struct { keyManager *crypto.KeyManager } -// NewPublicKeyDiscovery creates a new PublicKeyDiscovery instance +// NewPublicKeyDiscovery creates a new PublicKeyDiscovery instance. The HTTP +// client refuses redirects, caps response bodies and applies a 10s timeout. func NewPublicKeyDiscovery() *PublicKeyDiscovery { - return &PublicKeyDiscovery{ - client: &http.Client{ - Timeout: 10 * time.Second, - }, - keyManager: crypto.NewKeyManager(), - } + return NewPublicKeyDiscoveryWithTimeout(transport.DefaultTimeout) } // NewPublicKeyDiscoveryWithTimeout creates a new PublicKeyDiscovery instance with custom timeout func NewPublicKeyDiscoveryWithTimeout(timeout time.Duration) *PublicKeyDiscovery { + return NewPublicKeyDiscoveryWithClient(transport.NewClient(timeout)) +} + +// NewPublicKeyDiscoveryWithClient creates a PublicKeyDiscovery that issues +// requests through client. The URL policy (https only, validated domain, +// capped body) still applies; the client is responsible for its own timeout +// and redirect policy (use transport.NewClient for the hardened defaults). +func NewPublicKeyDiscoveryWithClient(client *http.Client) *PublicKeyDiscovery { + if client == nil { + client = transport.NewClient(transport.DefaultTimeout) + } return &PublicKeyDiscovery{ - client: &http.Client{ - Timeout: timeout, - }, + client: client, keyManager: crypto.NewKeyManager(), } } -// ConstructWellKnownURL constructs the .well-known URL for a domain -func ConstructWellKnownURL(domain string) string { - // Handle domains with or without protocol - if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") { - domain = "https://" + domain +// ConstructWellKnownURL constructs the .well-known URL for a domain. +// +// The domain must be a bare hostname (an https:// prefix is tolerated and +// stripped). http:// is refused: the spec requires HTTPS and a plaintext +// first fetch would let a network attacker pin their own key. +func ConstructWellKnownURL(domainName string) (string, error) { + if strings.HasPrefix(domainName, "http://") { + return "", fmt.Errorf("%w: SchemaPin discovery requires https; http:// is not permitted", domain.ErrInvalidDomain) } - - baseURL, err := url.Parse(domain) + domainName = strings.TrimRight(strings.TrimPrefix(domainName, "https://"), "/") + normalized, err := domain.ValidateDomain(domainName) if err != nil { - // Fallback to simple concatenation if URL parsing fails - return fmt.Sprintf("https://%s/.well-known/schemapin.json", strings.TrimPrefix(strings.TrimPrefix(domain, "https://"), "http://")) + return "", err } - - baseURL.Path = "/.well-known/schemapin.json" - return baseURL.String() + return "https://" + normalized + "/.well-known/schemapin.json", nil } // ConstructWellKnownURL constructs the .well-known URL for a domain (instance method) -func (p *PublicKeyDiscovery) ConstructWellKnownURL(domain string) string { - return ConstructWellKnownURL(domain) +func (p *PublicKeyDiscovery) ConstructWellKnownURL(domainName string) (string, error) { + return ConstructWellKnownURL(domainName) } // ValidateWellKnownResponse validates .well-known response structure @@ -79,27 +85,15 @@ func ValidateWellKnownResponse(response *WellKnownResponse) bool { } // FetchWellKnown fetches and validates .well-known/schemapin.json from domain -func (p *PublicKeyDiscovery) FetchWellKnown(ctx context.Context, domain string) (*WellKnownResponse, error) { - url := p.ConstructWellKnownURL(domain) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := p.client.Do(req) // #nosec G704 -- URL constructed from ConstructWellKnownURL with domain validation +func (p *PublicKeyDiscovery) FetchWellKnown(ctx context.Context, domainName string) (*WellKnownResponse, error) { + wellKnownURL, err := p.ConstructWellKnownURL(domainName) if err != nil { - return nil, fmt.Errorf("failed to fetch .well-known file: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return nil, err } var wellKnown WellKnownResponse - if err := json.NewDecoder(resp.Body).Decode(&wellKnown); err != nil { - return nil, fmt.Errorf("failed to decode .well-known response: %w", err) + if err := transport.FetchJSON(ctx, p.client, wellKnownURL, &wellKnown); err != nil { + return nil, fmt.Errorf("failed to fetch .well-known file: %w", err) } if !ValidateWellKnownResponse(&wellKnown) { @@ -154,8 +148,8 @@ func CheckKeyRevocation(publicKeyPEM string, revokedKeys []string) bool { keyManager := crypto.NewKeyManager() fingerprint, err := keyManager.CalculateKeyFingerprintFromPEM(publicKeyPEM) if err != nil { - // If we can't calculate fingerprint, assume not revoked - return false + // A key that cannot be fingerprinted cannot be trusted either. + return true } for _, revokedKey := range revokedKeys { @@ -188,15 +182,34 @@ func (p *PublicKeyDiscovery) GetRevokedKeysWithTimeout(domain string, timeout ti return p.GetRevokedKeys(ctx, domain) } -// ValidateKeyNotRevoked validates that a public key is not revoked -func (p *PublicKeyDiscovery) ValidateKeyNotRevoked(ctx context.Context, publicKeyPEM, domain string) (bool, error) { - revokedKeys, err := p.GetRevokedKeys(ctx, domain) +// ValidateKeyNotRevoked validates that a public key is not revoked (fail-closed). +// +// Consults both the inline revoked_keys list and, when the discovery document +// declares one, the standalone revocation_endpoint. If either cannot be fetched +// the key is treated as not validated: (false, err) is returned. Callers that +// want to proceed anyway must do so explicitly. +func (p *PublicKeyDiscovery) ValidateKeyNotRevoked(ctx context.Context, publicKeyPEM, domainName string) (bool, error) { + wellKnown, err := p.FetchWellKnown(ctx, domainName) if err != nil { - // If we can't fetch revocation list, assume not revoked - return true, nil + return false, fmt.Errorf("revocation status unavailable (fail-closed): %w", err) } - - return !CheckKeyRevocation(publicKeyPEM, revokedKeys), nil + if CheckKeyRevocation(publicKeyPEM, wellKnown.RevokedKeys) { + return false, nil + } + if wellKnown.RevocationEndpoint != "" { + doc, err := revocation.FetchRevocationDocumentWithClient(ctx, p.client, wellKnown.RevocationEndpoint) + if err != nil { + return false, fmt.Errorf("revocation status unavailable (fail-closed): %w", err) + } + fingerprint, err := p.keyManager.CalculateKeyFingerprintFromPEM(publicKeyPEM) + if err != nil { + return false, err + } + if err := revocation.CheckRevocation(doc, fingerprint); err != nil { + return false, nil + } + } + return true, nil } // ValidateKeyNotRevokedWithTimeout validates key revocation with custom timeout diff --git a/go/pkg/discovery/discovery_test.go b/go/pkg/discovery/discovery_test.go index eeaaa7f..e18cc70 100644 --- a/go/pkg/discovery/discovery_test.go +++ b/go/pkg/discovery/discovery_test.go @@ -26,25 +26,30 @@ func TestConstructWellKnownURL(t *testing.T) { expected: "https://example.com/.well-known/schemapin.json", }, { - name: "domain with http", - domain: "http://example.com", - expected: "http://example.com/.well-known/schemapin.json", - }, - { - name: "domain with port", - domain: "example.com:8080", - expected: "https://example.com:8080/.well-known/schemapin.json", + name: "mixed case is normalized", + domain: "Example.COM", + expected: "https://example.com/.well-known/schemapin.json", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ConstructWellKnownURL(tt.domain) + result, err := ConstructWellKnownURL(tt.domain) + if err != nil { + t.Fatalf("ConstructWellKnownURL(%q) error: %v", tt.domain, err) + } if result != tt.expected { t.Errorf("ConstructWellKnownURL(%q) = %q, want %q", tt.domain, result, tt.expected) } }) } + + // SP-11: plaintext, ports, paths, IP literals and traversal are refused. + for _, bad := range []string{"http://example.com", "example.com:8080", "example.com/x", "10.0.0.1", "localhost", "../evil", "good.com@evil.com"} { + if _, err := ConstructWellKnownURL(bad); err == nil { + t.Errorf("ConstructWellKnownURL(%q) must fail", bad) + } + } } func TestValidateWellKnownResponse(t *testing.T) { @@ -109,10 +114,12 @@ func TestCheckKeyRevocation(t *testing.T) { expected: false, }, { - name: "key not revoked - not in list", + // A key that cannot be fingerprinted cannot be cleared against a + // non-empty revocation list (fail-closed). + name: "unparseable key with revocations - treated as revoked", publicKey: "test-key", revokedKeys: []string{"other-key", "another-key"}, - expected: false, + expected: true, }, { name: "key revoked - direct match", @@ -166,7 +173,7 @@ func TestPublicKeyDiscoveryFetchWellKnown(t *testing.T) { domain := server.URL[7:] // Remove "http://" // Override the URL construction to use HTTP for testing - originalURL := discovery.ConstructWellKnownURL(domain) + originalURL, _ := discovery.ConstructWellKnownURL(domain) testURL := server.URL + "/.well-known/schemapin.json" // Create a custom request diff --git a/go/pkg/domain/domain.go b/go/pkg/domain/domain.go new file mode 100644 index 0000000..b0986ef --- /dev/null +++ b/go/pkg/domain/domain.go @@ -0,0 +1,70 @@ +// Package domain provides the shared domain-name validator. +// +// Every place that turns a domain string into something security-relevant -- +// a .well-known URL, a pin-store key, a local file path, a DNS lookup name -- +// MUST pass it through ValidateDomain first. The grammar is the RFC 1123 +// hostname grammar, restricted further so the value cannot be confused with a +// URL, a path, an IP literal or an IDN U-label: +// +// - ASCII only (internationalized names must be given as punycode A-labels) +// - at least two labels, each 1-63 characters of [a-z0-9-] that neither start +// nor end with "-" +// - total length at most 253 characters +// - the last label is not purely numeric (rejects IPv4 literals) +// - no scheme, path, port, userinfo or trailing dot +// +// The returned value is lowercased so pin keys and file names are consistent +// regardless of how the caller spelled the domain. +package domain + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +// MaxDomainLength is the maximum total length of a domain name. +const MaxDomainLength = 253 + +// ErrInvalidDomain wraps every validation failure so callers can test for it +// with errors.Is. +var ErrInvalidDomain = errors.New("invalid domain") + +var labelRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`) +var digitsRe = regexp.MustCompile(`^[0-9]+$`) + +// ValidateDomain validates domain and returns its normalized (lowercase) form. +func ValidateDomain(domain string) (string, error) { + if domain == "" { + return "", fmt.Errorf("%w: domain must be a non-empty string", ErrInvalidDomain) + } + if len(domain) > MaxDomainLength { + return "", fmt.Errorf("%w: domain exceeds %d characters", ErrInvalidDomain, MaxDomainLength) + } + for i := 0; i < len(domain); i++ { + if domain[i] > 0x7f { + return "", fmt.Errorf("%w: domain must be ASCII; encode internationalized names as punycode", ErrInvalidDomain) + } + } + normalized := strings.ToLower(domain) + labels := strings.Split(normalized, ".") + if len(labels) < 2 { + return "", fmt.Errorf("%w: domain %q must contain at least two labels", ErrInvalidDomain, domain) + } + for _, label := range labels { + if !labelRe.MatchString(label) { + return "", fmt.Errorf("%w: domain %q has an invalid label %q", ErrInvalidDomain, domain, label) + } + } + if digitsRe.MatchString(labels[len(labels)-1]) { + return "", fmt.Errorf("%w: domain %q looks like an IP literal", ErrInvalidDomain, domain) + } + return normalized, nil +} + +// IsValidDomain reports whether ValidateDomain would accept domain. +func IsValidDomain(domain string) bool { + _, err := ValidateDomain(domain) + return err == nil +} diff --git a/go/pkg/domain/domain_test.go b/go/pkg/domain/domain_test.go new file mode 100644 index 0000000..f192c4d --- /dev/null +++ b/go/pkg/domain/domain_test.go @@ -0,0 +1,48 @@ +package domain + +import ( + "errors" + "strings" + "testing" +) + +func TestValidDomains(t *testing.T) { + cases := map[string]string{ + "example.com": "example.com", + "Example.COM": "example.com", + "sub.domain.example.co.uk": "sub.domain.example.co.uk", + "xn--bcher-kva.example": "xn--bcher-kva.example", + "a-b.c1": "a-b.c1", + "1.example": "1.example", + } + for in, want := range cases { + got, err := ValidateDomain(in) + if err != nil || got != want { + t.Errorf("ValidateDomain(%q) = %q, %v; want %q", in, got, err, want) + } + if !IsValidDomain(in) { + t.Errorf("IsValidDomain(%q) = false", in) + } + } +} + +func TestInvalidDomains(t *testing.T) { + bad := []string{ + "", "localhost", "example", "http://example.com", "https://example.com", + "example.com/path", "example.com:8443", "user@example.com", "good.com@evil.com", + "../skills/evil/key", "/etc/passwd", "example.com.", ".example.com", "exa mple.com", + "-bad.example.com", "bad-.example.com", "under_score.example.com", "10.0.0.1", "[::1]", + "bücher.example", strings.Repeat("a", 64) + ".example.com", + strings.Repeat(strings.Repeat("a", 63)+".", 4) + "com", "evil.com\nexample.com", + } + for _, in := range bad { + if _, err := ValidateDomain(in); err == nil { + t.Errorf("ValidateDomain(%q) accepted", in) + } else if !errors.Is(err, ErrInvalidDomain) { + t.Errorf("ValidateDomain(%q) error is not ErrInvalidDomain: %v", in, err) + } + if IsValidDomain(in) { + t.Errorf("IsValidDomain(%q) = true", in) + } + } +} diff --git a/go/pkg/pinning/pinning.go b/go/pkg/pinning/pinning.go index e237bc2..6a43ee1 100644 --- a/go/pkg/pinning/pinning.go +++ b/go/pkg/pinning/pinning.go @@ -50,6 +50,10 @@ type DomainPolicy struct { CreatedAt time.Time `json:"created_at"` } +// RevocationChecker reports whether publicKeyPEM is confirmed not revoked for +// domain. An error means the status could not be determined (fail-closed). +type RevocationChecker func(publicKeyPEM, domain string) (bool, error) + // KeyPinning manages TOFU key storage with BoltDB type KeyPinning struct { db *bbolt.DB @@ -57,6 +61,22 @@ type KeyPinning struct { mode PinningMode interactiveManager *interactive.InteractivePinningManager discovery *discovery.PublicKeyDiscovery + checkRevoked RevocationChecker +} + +// SetRevocationChecker replaces the discovery-backed revocation check used by +// the interactive pinning flows (for custom resolvers or offline bundles). +func (k *KeyPinning) SetRevocationChecker(fn RevocationChecker) { + k.checkRevoked = fn +} + +func (k *KeyPinning) revocationChecker() RevocationChecker { + if k.checkRevoked != nil { + return k.checkRevoked + } + return func(publicKeyPEM, domain string) (bool, error) { + return k.discovery.ValidateKeyNotRevokedWithTimeout(publicKeyPEM, domain, 10*time.Second) + } } var ( @@ -399,11 +419,11 @@ func (k *KeyPinning) interactivePinKeyWithOptions(toolID, publicKeyPEM, domain, // handleFirstTimeKey handles first-time key encounter func (k *KeyPinning) handleFirstTimeKey(toolID, domain, publicKeyPEM, developerName string, forcePrompt bool) (bool, error) { - // Check if key is revoked - isNotRevoked, err := k.discovery.ValidateKeyNotRevokedWithTimeout(publicKeyPEM, domain, 10*time.Second) + // Check if key is revoked. Fail-closed: an unreachable revocation source + // means the key cannot be trusted. + isNotRevoked, err := k.revocationChecker()(publicKeyPEM, domain) if err != nil { - // If we can't check revocation, proceed with caution - isNotRevoked = true + return false, fmt.Errorf("revocation status unavailable (fail-closed): %w", err) } if !isNotRevoked { @@ -459,10 +479,10 @@ func (k *KeyPinning) handleFirstTimeKey(toolID, domain, publicKeyPEM, developerN // handleKeyChange handles key change scenario func (k *KeyPinning) handleKeyChange(toolID, domain, currentKeyPEM, newKeyPEM, developerName string) (bool, error) { - // Check if new key is revoked - isNotRevoked, err := k.discovery.ValidateKeyNotRevokedWithTimeout(newKeyPEM, domain, 10*time.Second) + // Check if new key is revoked (fail-closed) + isNotRevoked, err := k.revocationChecker()(newKeyPEM, domain) if err != nil { - isNotRevoked = true // Assume not revoked if check fails + return false, fmt.Errorf("revocation status unavailable (fail-closed): %w", err) } if !isNotRevoked { diff --git a/go/pkg/pinning/pinning_test.go b/go/pkg/pinning/pinning_test.go index 608d13f..fac21c7 100644 --- a/go/pkg/pinning/pinning_test.go +++ b/go/pkg/pinning/pinning_test.go @@ -459,6 +459,7 @@ func TestInteractivePinKeyAutomatic(t *testing.T) { t.Fatalf("Failed to create KeyPinning: %v", err) } defer pinning.Close() + pinning.SetRevocationChecker(func(string, string) (bool, error) { return true, nil }) toolID := "test-tool" publicKeyPEM := "test-key" @@ -503,10 +504,15 @@ func TestInteractivePinKeyWithMockServer(t *testing.T) { t.Fatalf("Failed to create KeyPinning: %v", err) } defer pinning.Close() + pinning.SetRevocationChecker(func(string, string) (bool, error) { return true, nil }) toolID := "test-tool" publicKeyPEM := "test-key" - domain := server.URL[7:] // Remove "http://" for domain + // Discovery is https-only and validates domains, so the mock server is + // not reachable through the real client; the revocation checker above + // stands in for it and the server only documents the expected shape. + _ = server + domain := "example.com" developerName := "Test Developer" result, err := pinning.InteractivePinKey(toolID, publicKeyPEM, domain, developerName) @@ -533,6 +539,7 @@ func TestInteractivePinKeyReject(t *testing.T) { t.Fatalf("Failed to create KeyPinning: %v", err) } defer pinning.Close() + pinning.SetRevocationChecker(func(string, string) (bool, error) { return true, nil }) toolID := "test-tool" publicKeyPEM := "test-key" @@ -633,6 +640,7 @@ func TestKeyChangeScenario(t *testing.T) { t.Fatalf("Failed to create KeyPinning: %v", err) } defer pinning.Close() + pinning.SetRevocationChecker(func(string, string) (bool, error) { return true, nil }) toolID := "test-tool" originalKey := "original-key" @@ -680,6 +688,7 @@ func TestSameKeyUpdateVerification(t *testing.T) { t.Fatalf("Failed to create KeyPinning: %v", err) } defer pinning.Close() + pinning.SetRevocationChecker(func(string, string) (bool, error) { return true, nil }) toolID := "test-tool" publicKeyPEM := "test-key" diff --git a/go/pkg/resolver/resolver.go b/go/pkg/resolver/resolver.go index ad14e7c..d53b235 100644 --- a/go/pkg/resolver/resolver.go +++ b/go/pkg/resolver/resolver.go @@ -7,12 +7,32 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/ThirdKeyAi/schemapin/go/pkg/bundle" "github.com/ThirdKeyAi/schemapin/go/pkg/discovery" + "github.com/ThirdKeyAi/schemapin/go/pkg/domain" "github.com/ThirdKeyAi/schemapin/go/pkg/revocation" ) +// safeDomainPath joins a validated domain-derived file name onto baseDir and +// makes sure the result stays inside baseDir (SP-16). +func safeDomainPath(baseDir, domainName, suffix string) (string, error) { + normalized, err := domain.ValidateDomain(domainName) + if err != nil { + return "", err + } + root, err := filepath.Abs(baseDir) + if err != nil { + return "", err + } + path := filepath.Join(root, normalized+suffix) + if path != root && !strings.HasPrefix(path, root+string(filepath.Separator)) { + return "", fmt.Errorf("resolved path %s escapes %s", path, root) + } + return path, nil +} + // SchemaResolver is the interface for resolving discovery and revocation documents. type SchemaResolver interface { ResolveDiscovery(domain string) (*discovery.WellKnownResponse, error) @@ -37,6 +57,7 @@ func (r *WellKnownResolver) ResolveDiscovery(domain string) (*discovery.WellKnow } // ResolveRevocation fetches revocation from the discovery's revocation_endpoint. +// A declared endpoint that cannot be fetched is an error (callers fail closed). func (r *WellKnownResolver) ResolveRevocation(domain string, disc *discovery.WellKnownResponse) (*revocation.RevocationDocument, error) { if disc == nil || disc.RevocationEndpoint == "" { return nil, nil @@ -49,6 +70,10 @@ func (r *WellKnownResolver) ResolveRevocation(domain string, disc *discovery.Wel } // LocalFileResolver resolves discovery from local JSON files. +// +// The domain is validated and the resolved path must stay inside the +// configured directory, so a hostile domain string cannot select an +// arbitrary file. type LocalFileResolver struct { discoveryDir string revocationDir string @@ -63,9 +88,12 @@ func NewLocalFileResolver(discoveryDir, revocationDir string) *LocalFileResolver } // ResolveDiscovery reads {domain}.json from the discovery directory. -func (r *LocalFileResolver) ResolveDiscovery(domain string) (*discovery.WellKnownResponse, error) { - path := filepath.Join(r.discoveryDir, domain+".json") - data, err := os.ReadFile(path) // #nosec G304 -- path constructed from trusted config directory + domain +func (r *LocalFileResolver) ResolveDiscovery(domainName string) (*discovery.WellKnownResponse, error) { + path, err := safeDomainPath(r.discoveryDir, domainName, ".json") + if err != nil { + return nil, fmt.Errorf("invalid domain for local discovery: %w", err) + } + data, err := os.ReadFile(path) // #nosec G304 -- domain validated and path confined by safeDomainPath if err != nil { return nil, fmt.Errorf("failed to read discovery file: %w", err) } @@ -79,15 +107,21 @@ func (r *LocalFileResolver) ResolveDiscovery(domain string) (*discovery.WellKnow } // ResolveRevocation reads {domain}.revocations.json from the revocation directory. -func (r *LocalFileResolver) ResolveRevocation(domain string, disc *discovery.WellKnownResponse) (*revocation.RevocationDocument, error) { +func (r *LocalFileResolver) ResolveRevocation(domainName string, disc *discovery.WellKnownResponse) (*revocation.RevocationDocument, error) { if r.revocationDir == "" { return nil, nil } - path := filepath.Join(r.revocationDir, domain+".revocations.json") - data, err := os.ReadFile(path) // #nosec G304 -- path constructed from trusted config directory + domain + path, err := safeDomainPath(r.revocationDir, domainName, ".revocations.json") + if err != nil { + return nil, fmt.Errorf("invalid domain for local revocation: %w", err) + } + data, err := os.ReadFile(path) // #nosec G304 -- domain validated and path confined by safeDomainPath if err != nil { - return nil, nil // Missing file is not an error + if os.IsNotExist(err) { + return nil, nil // Missing file is not an error + } + return nil, fmt.Errorf("failed to read revocation file: %w", err) } var doc revocation.RevocationDocument @@ -132,6 +166,10 @@ func (r *TrustBundleResolver) ResolveRevocation(domain string, disc *discovery.W } // ChainResolver tries multiple resolvers in order, returning the first success. +// +// Precedence is positional: put the most trusted source first. A revocation +// fetch failure from any member propagates (fail-closed) instead of being +// skipped. type ChainResolver struct { resolvers []SchemaResolver } @@ -157,11 +195,16 @@ func (r *ChainResolver) ResolveDiscovery(domain string) (*discovery.WellKnownRes return nil, fmt.Errorf("no resolvers available") } -// ResolveRevocation tries each resolver in order. +// ResolveRevocation tries each resolver in order. The first error is returned +// rather than skipped: a declared-but-unreachable revocation source must not +// silently degrade to "not revoked". func (r *ChainResolver) ResolveRevocation(domain string, disc *discovery.WellKnownResponse) (*revocation.RevocationDocument, error) { for _, resolver := range r.resolvers { doc, err := resolver.ResolveRevocation(domain, disc) - if err == nil && doc != nil { + if err != nil { + return nil, err + } + if doc != nil { return doc, nil } } diff --git a/go/pkg/resolver/resolver_test.go b/go/pkg/resolver/resolver_test.go index 7097101..db09da4 100644 --- a/go/pkg/resolver/resolver_test.go +++ b/go/pkg/resolver/resolver_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/ThirdKeyAi/schemapin/go/pkg/bundle" @@ -229,3 +230,31 @@ func TestChainResolverAllMiss(t *testing.T) { t.Error("expected error when all resolvers miss") } } + +// TestLocalFileResolverRejectsTraversal is the SP-16 regression: a hostile +// domain string must not select a file outside the configured directory. +func TestLocalFileResolverRejectsTraversal(t *testing.T) { + base := t.TempDir() + discoveryDir := filepath.Join(base, "discovery") + if err := os.MkdirAll(discoveryDir, 0750); err != nil { + t.Fatal(err) + } + outside := filepath.Join(base, "evil.json") + doc := `{"schema_version":"1.4","public_key_pem":"-----BEGIN PUBLIC KEY-----\nMOCK\n-----END PUBLIC KEY-----"}` + if err := os.WriteFile(outside, []byte(doc), 0600); err != nil { + t.Fatal(err) + } + r := NewLocalFileResolver(discoveryDir, "") + for _, bad := range []string{"../evil", "/" + strings.TrimSuffix(outside, ".json"), "evil.json/..", "localhost", "10.0.0.1"} { + if _, err := r.ResolveDiscovery(bad); err == nil { + t.Errorf("ResolveDiscovery(%q) must fail", bad) + } + } + // A valid domain still resolves and is case-normalized. + if err := os.WriteFile(filepath.Join(discoveryDir, "example.com.json"), []byte(doc), 0600); err != nil { + t.Fatal(err) + } + if _, err := r.ResolveDiscovery("Example.COM"); err != nil { + t.Fatalf("valid domain must resolve: %v", err) + } +} diff --git a/go/pkg/revocation/revocation.go b/go/pkg/revocation/revocation.go index 549c96a..0aa65ee 100644 --- a/go/pkg/revocation/revocation.go +++ b/go/pkg/revocation/revocation.go @@ -3,10 +3,11 @@ package revocation import ( "context" - "encoding/json" "fmt" "net/http" "time" + + "github.com/ThirdKeyAi/schemapin/go/pkg/transport" ) // RevocationReason represents why a key was revoked. @@ -80,29 +81,26 @@ func CheckRevocationCombined(simpleRevoked []string, doc *RevocationDocument, fi return nil } -// FetchRevocationDocument fetches a standalone revocation document from a URL. +// FetchRevocationDocument fetches a standalone revocation document from an +// https:// URL. Redirects are not followed, the body is capped and the URL +// must use https. Any failure is returned as an error so callers fail closed; +// it never silently yields "no revocations". func FetchRevocationDocument(ctx context.Context, url string) (*RevocationDocument, error) { - client := &http.Client{Timeout: 10 * time.Second} + return FetchRevocationDocumentWithClient(ctx, nil, url) +} - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) +// FetchRevocationDocumentWithClient is FetchRevocationDocument with an explicit +// client (nil means a hardened default with a 10s timeout). +func FetchRevocationDocumentWithClient(ctx context.Context, client *http.Client, url string) (*RevocationDocument, error) { + if client == nil { + client = transport.NewClient(10 * time.Second) } - - resp, err := client.Do(req) // #nosec G704 -- URL is from discovery document's revocation_endpoint - if err != nil { + var doc RevocationDocument + if err := transport.FetchJSON(ctx, client, url, &doc); err != nil { return nil, fmt.Errorf("failed to fetch revocation document: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + if doc.Domain == "" { + return nil, fmt.Errorf("failed to fetch revocation document: missing domain") } - - var doc RevocationDocument - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { - return nil, fmt.Errorf("failed to decode revocation document: %w", err) - } - return &doc, nil } diff --git a/go/pkg/transport/transport.go b/go/pkg/transport/transport.go new file mode 100644 index 0000000..60bd498 --- /dev/null +++ b/go/pkg/transport/transport.go @@ -0,0 +1,105 @@ +// Package transport provides the hardened HTTP fetch helper shared by +// discovery and revocation. +// +// Every outbound request made by this module goes through FetchJSON: +// +// - https:// only -- plaintext URLs are refused before any socket is opened +// - redirects are never followed (CheckRedirect returns an error) +// - the response body is capped at MaxResponseBytes +// - a timeout is always applied +package transport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// MaxResponseBytes is the maximum accepted response body size. +const MaxResponseBytes = 64 * 1024 + +// DefaultTimeout is the default request timeout. +const DefaultTimeout = 10 * time.Second + +// ErrTransport wraps every transport-level failure (scheme, redirect, size, +// network) so callers can test for it with errors.Is. +var ErrTransport = errors.New("transport error") + +var errNoRedirect = errors.New("redirects are not followed") + +// RequireHTTPS returns nil if rawURL is an absolute https:// URL without +// userinfo. +func RequireHTTPS(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("%w: invalid URL %q: %v", ErrTransport, rawURL, err) + } + if u.Scheme != "https" || u.Host == "" { + return fmt.Errorf("%w: only https:// URLs are permitted: %q", ErrTransport, rawURL) + } + if u.User != nil { + return fmt.Errorf("%w: userinfo is not permitted in URLs: %q", ErrTransport, rawURL) + } + return nil +} + +// NewClient returns an http.Client with the hardening rules applied. +func NewClient(timeout time.Duration) *http.Client { + if timeout <= 0 { + timeout = DefaultTimeout + } + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return errNoRedirect + }, + } +} + +// FetchJSON GETs rawURL with client (nil means NewClient(DefaultTimeout)) and +// decodes the JSON body into out under the hardening rules above. +func FetchJSON(ctx context.Context, client *http.Client, rawURL string, out interface{}) error { + return FetchJSONLimited(ctx, client, rawURL, MaxResponseBytes, out) +} + +// FetchJSONLimited is FetchJSON with an explicit body cap. +func FetchJSONLimited(ctx context.Context, client *http.Client, rawURL string, maxBytes int64, out interface{}) error { + if err := RequireHTTPS(rawURL); err != nil { + return err + } + if client == nil { + client = NewClient(DefaultTimeout) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("%w: %v", ErrTransport, err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "SchemaPin") + + resp, err := client.Do(req) // #nosec G704 -- URL validated by RequireHTTPS; redirects refused + if err != nil { + return fmt.Errorf("%w: request failed for %s: %v", ErrTransport, rawURL, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: HTTP %d fetching %s", ErrTransport, resp.StatusCode, rawURL) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) + if err != nil { + return fmt.Errorf("%w: reading %s: %v", ErrTransport, rawURL, err) + } + if int64(len(body)) > maxBytes { + return fmt.Errorf("%w: response from %s exceeds %d bytes", ErrTransport, rawURL, maxBytes) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("%w: invalid JSON from %s: %v", ErrTransport, rawURL, err) + } + return nil +} diff --git a/go/pkg/transport/transport_test.go b/go/pkg/transport/transport_test.go new file mode 100644 index 0000000..466aef0 --- /dev/null +++ b/go/pkg/transport/transport_test.go @@ -0,0 +1,81 @@ +package transport + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func clientWith(status int, body string, headers map[string]string) *http.Client { + c := NewClient(0) + c.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + h := http.Header{} + for k, v := range headers { + h.Set(k, v) + } + return &http.Response{StatusCode: status, Header: h, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + }) + return c +} + +func TestRequireHTTPS(t *testing.T) { + for _, bad := range []string{"http://example.com/x", "ftp://example.com/x", "https://user:pw@example.com/x", "example.com", ""} { + if err := RequireHTTPS(bad); !errors.Is(err, ErrTransport) { + t.Errorf("RequireHTTPS(%q) = %v, want ErrTransport", bad, err) + } + } + if err := RequireHTTPS("https://example.com/.well-known/schemapin.json"); err != nil { + t.Fatal(err) + } +} + +func TestFetchJSONRefusesPlaintext(t *testing.T) { + var out map[string]interface{} + err := FetchJSON(context.Background(), clientWith(200, `{"a":1}`, nil), "http://example.com/x", &out) + if !errors.Is(err, ErrTransport) { + t.Fatalf("expected ErrTransport, got %v", err) + } +} + +func TestFetchJSONRefusesRedirects(t *testing.T) { + var out map[string]interface{} + err := FetchJSON(context.Background(), clientWith(302, "", map[string]string{"Location": "https://evil.example/x"}), "https://example.com/x", &out) + if err == nil { + t.Fatal("redirect must fail") + } + // With the real client a redirect is refused by CheckRedirect before any + // second request is issued. + c := NewClient(0) + if err := c.CheckRedirect(nil, nil); err == nil { + t.Fatal("CheckRedirect must refuse") + } +} + +func TestFetchJSONCapsBody(t *testing.T) { + var out interface{} + big := `"` + strings.Repeat("x", MaxResponseBytes+10) + `"` + err := FetchJSON(context.Background(), clientWith(200, big, nil), "https://example.com/x", &out) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("expected size-cap error, got %v", err) + } +} + +func TestFetchJSONSuccess(t *testing.T) { + var out map[string]interface{} + if err := FetchJSON(context.Background(), clientWith(200, `{"ok":true}`, nil), "https://example.com/x", &out); err != nil { + t.Fatal(err) + } + if out["ok"] != true { + t.Fatalf("unexpected body %v", out) + } + if err := FetchJSON(context.Background(), clientWith(404, "", nil), "https://example.com/x", &out); err == nil { + t.Fatal("non-200 must fail") + } +} diff --git a/go/pkg/utils/utils.go b/go/pkg/utils/utils.go index 590077a..1f19b45 100644 --- a/go/pkg/utils/utils.go +++ b/go/pkg/utils/utils.go @@ -156,11 +156,12 @@ func (s *SchemaVerificationWorkflow) VerifySchema(ctx context.Context, schema ma var publicKey *ecdsa.PublicKey if pinnedKeyPEM != "" { - // Use pinned key, but check if it's been revoked + // Use pinned key, but check if it's been revoked (fail-closed: an + // unreachable revocation source is a verification failure). isNotRevoked, err := s.discovery.ValidateKeyNotRevoked(ctx, pinnedKeyPEM, domain) if err != nil { - // If we can't check revocation, proceed with caution - isNotRevoked = true + result.Error = fmt.Sprintf("revocation status unavailable: %v", err) + return result, nil } if !isNotRevoked { @@ -184,11 +185,11 @@ func (s *SchemaVerificationWorkflow) VerifySchema(ctx context.Context, schema ma return result, nil } - // Check if key is revoked + // Check if key is revoked (fail-closed) isNotRevoked, err := s.discovery.ValidateKeyNotRevoked(ctx, discoveredKeyPEM, domain) if err != nil { - // If we can't check revocation, proceed with caution - isNotRevoked = true + result.Error = fmt.Sprintf("revocation status unavailable: %v", err) + return result, nil } if !isNotRevoked { diff --git a/go/pkg/utils/utils_test.go b/go/pkg/utils/utils_test.go index 56ec4a3..6846bb6 100644 --- a/go/pkg/utils/utils_test.go +++ b/go/pkg/utils/utils_test.go @@ -243,8 +243,10 @@ func TestSchemaVerificationWorkflow_VerifySchema_AutoPin(t *testing.T) { })) defer server.Close() - // Extract domain from server URL - domain := strings.TrimPrefix(server.URL, "http://") + // Discovery is https-only and validates domains, so route requests for + // https://example.com through the mock handler instead of the network. + workflow.discovery = discovery.NewPublicKeyDiscoveryWithClient(mockClient(server)) + domain := "example.com" ctx := context.Background() result, err := workflow.VerifySchema(ctx, schema, signature, "test-tool", domain, false) @@ -336,8 +338,10 @@ func TestSchemaVerificationWorkflow_VerifySchema_PinnedKey(t *testing.T) { })) defer server.Close() - // Extract domain from server URL - domain := strings.TrimPrefix(server.URL, "http://") + // Discovery is https-only and validates domains, so route requests for + // https://example.com through the mock handler instead of the network. + workflow.discovery = discovery.NewPublicKeyDiscoveryWithClient(mockClient(server)) + domain := "example.com" ctx := context.Background() result, err := workflow.VerifySchema(ctx, schema, signature, "test-tool", domain, false) @@ -692,3 +696,20 @@ func BenchmarkFormatKeyFingerprint(b *testing.B) { _ = FormatKeyFingerprint(fingerprint) } } + +// mockClient returns an http.Client that serves every request from the +// httptest server's handler regardless of the request host, so https-only +// discovery for "example.com" can be exercised offline. +func mockClient(server *httptest.Server) *http.Client { + return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + server.Config.Handler.ServeHTTP(rec, r) + resp := rec.Result() + resp.Request = r + return resp, nil + })} +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/javascript/src/discovery.js b/javascript/src/discovery.js index 9476889..0490264 100644 --- a/javascript/src/discovery.js +++ b/javascript/src/discovery.js @@ -3,6 +3,9 @@ */ import { KeyManager } from './crypto.js'; +import { InvalidDomainError, validateDomain } from './domain.js'; +import { checkRevocation, fetchRevocationDocument, RevocationFetchError } from './revocation.js'; +import { DEFAULT_TIMEOUT_MS, fetchJson } from './transport.js'; /** * Handles public key discovery from .well-known endpoints. @@ -15,10 +18,16 @@ export class PublicKeyDiscovery { * @returns {string} Full .well-known URI */ static constructWellKnownUrl(domain) { - if (!domain.startsWith('http://') && !domain.startsWith('https://')) { - domain = `https://${domain}`; + if (typeof domain !== 'string') { + throw new InvalidDomainError('domain must be a string'); } - return new URL('/.well-known/schemapin.json', domain).toString(); + if (domain.startsWith('http://')) { + throw new InvalidDomainError('SchemaPin discovery requires https; http:// is not permitted'); + } + if (domain.startsWith('https://')) { + domain = domain.slice('https://'.length).replace(/\/+$/, ''); + } + return `https://${validateDomain(domain)}/.well-known/schemapin.json`; } /** @@ -28,6 +37,9 @@ export class PublicKeyDiscovery { * @returns {boolean} True if response is valid, false otherwise */ static validateWellKnownResponse(responseData) { + if (responseData === null || typeof responseData !== 'object' || Array.isArray(responseData)) { + return false; + } const requiredFields = ['schema_version', 'public_key_pem']; return requiredFields.every(field => field in responseData); } @@ -39,33 +51,15 @@ export class PublicKeyDiscovery { * @param {number} timeout - Request timeout in milliseconds (default: 10000) * @returns {Promise} Parsed response data if valid, null otherwise */ - static async fetchWellKnown(domain, timeout = 10000) { + static async fetchWellKnown(domain, timeout = DEFAULT_TIMEOUT_MS) { try { const url = this.constructWellKnownUrl(domain); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - const response = await fetch(url, { - signal: controller.signal, - headers: { - 'Accept': 'application/json', - 'User-Agent': 'SchemaPin/1.0' - } - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - return null; - } - - const data = await response.json(); + const data = await fetchJson(url, timeout); if (this.validateWellKnownResponse(data)) { return data; } return null; - - } catch (error) { + } catch { return null; } } @@ -77,7 +71,7 @@ export class PublicKeyDiscovery { * @param {number} timeout - Request timeout in milliseconds (default: 10000) * @returns {Promise} PEM-encoded public key if found, null otherwise */ - static async getPublicKeyPem(domain, timeout = 10000) { + static async getPublicKeyPem(domain, timeout = DEFAULT_TIMEOUT_MS) { const wellKnownData = await this.fetchWellKnown(domain, timeout); if (wellKnownData) { return wellKnownData.public_key_pem; @@ -92,7 +86,7 @@ export class PublicKeyDiscovery { * @param {number} timeout - Request timeout in milliseconds (default: 10000) * @returns {Promise} Object with developer info if available, null otherwise */ - static async getDeveloperInfo(domain, timeout = 10000) { + static async getDeveloperInfo(domain, timeout = DEFAULT_TIMEOUT_MS) { const wellKnownData = await this.fetchWellKnown(domain, timeout); if (wellKnownData) { return { @@ -119,9 +113,9 @@ export class PublicKeyDiscovery { try { const fingerprint = KeyManager.calculateKeyFingerprint(publicKeyPem); return revokedKeys.includes(fingerprint); - } catch (error) { - // If we can't calculate fingerprint, assume not revoked - return false; + } catch { + // A key that cannot be fingerprinted cannot be trusted either. + return true; } } @@ -132,7 +126,7 @@ export class PublicKeyDiscovery { * @param {number} timeout - Request timeout in milliseconds (default: 10000) * @returns {Promise|null>} Array of revoked key fingerprints if available, null otherwise */ - static async getRevokedKeys(domain, timeout = 10000) { + static async getRevokedKeys(domain, timeout = DEFAULT_TIMEOUT_MS) { const wellKnownData = await this.fetchWellKnown(domain, timeout); if (wellKnownData) { return wellKnownData.revoked_keys || []; @@ -141,20 +135,44 @@ export class PublicKeyDiscovery { } /** - * Validate that a public key is not revoked. + * Validate that a public key is not revoked (fail-closed). + * + * Consults both the inline `revoked_keys` list and, when the discovery + * document declares one, the standalone `revocation_endpoint`. If either + * cannot be fetched the key is treated as not validated and `false` is + * returned, unless `allowUnreachable` is true. * * @param {string} publicKeyPem - PEM-encoded public key string * @param {string} domain - Tool provider domain * @param {number} timeout - Request timeout in milliseconds (default: 10000) - * @returns {Promise} True if key is not revoked, false if revoked or error + * @param {boolean} [allowUnreachable=false] - Treat fetch failures as "not revoked" + * @returns {Promise} True if the key is confirmed not revoked */ - static async validateKeyNotRevoked(publicKeyPem, domain, timeout = 10000) { - const revokedKeys = await this.getRevokedKeys(domain, timeout); - if (revokedKeys === null) { - // If we can't fetch revocation list, assume not revoked - return true; + static async validateKeyNotRevoked(publicKeyPem, domain, timeout = DEFAULT_TIMEOUT_MS, allowUnreachable = false) { + const wellKnown = await this.fetchWellKnown(domain, timeout); + if (wellKnown === null) { + return allowUnreachable; } - - return !this.checkKeyRevocation(publicKeyPem, revokedKeys); + if (this.checkKeyRevocation(publicKeyPem, wellKnown.revoked_keys || [])) { + return false; + } + const endpoint = wellKnown.revocation_endpoint; + if (endpoint) { + let doc; + try { + doc = await fetchRevocationDocument(endpoint, timeout); + } catch (e) { + if (e instanceof RevocationFetchError) { + return allowUnreachable; + } + throw e; + } + try { + checkRevocation(doc, KeyManager.calculateKeyFingerprint(publicKeyPem)); + } catch { + return false; + } + } + return true; } } \ No newline at end of file diff --git a/javascript/src/domain.js b/javascript/src/domain.js new file mode 100644 index 0000000..0d54fd5 --- /dev/null +++ b/javascript/src/domain.js @@ -0,0 +1,81 @@ +/** + * Shared domain-name validation. + * + * Every place that turns a domain string into something security-relevant -- + * a .well-known URL, a pin-store key, a local file path, a DNS lookup name -- + * MUST pass it through {@link validateDomain} first. The grammar is the + * RFC 1123 hostname grammar, restricted further so the value cannot be + * confused with a URL, a path, an IP literal or an IDN U-label: + * + * - ASCII only (internationalized names must be given as punycode A-labels) + * - at least two labels, each 1-63 characters of [a-z0-9-] that neither + * start nor end with "-" + * - total length at most 253 characters + * - the last label is not purely numeric (rejects IPv4 literals) + * - no scheme, path, port, userinfo or trailing dot + * + * The returned value is lowercased so pin keys and file names are consistent + * regardless of how the caller spelled the domain. + */ + +/** Maximum total length of a domain name. */ +export const MAX_DOMAIN_LENGTH = 253; + +const LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +/** + * Thrown when a string is not an acceptable SchemaPin domain. + */ +export class InvalidDomainError extends Error { + constructor(message) { + super(message); + this.name = 'InvalidDomainError'; + } +} + +/** + * Validate `domain` and return its normalized (lowercase) form. + * + * @param {string} domain + * @returns {string} + * @throws {InvalidDomainError} + */ +export function validateDomain(domain) { + if (typeof domain !== 'string' || domain.length === 0) { + throw new InvalidDomainError('domain must be a non-empty string'); + } + if (domain.length > MAX_DOMAIN_LENGTH) { + throw new InvalidDomainError(`domain exceeds ${MAX_DOMAIN_LENGTH} characters`); + } + // eslint-disable-next-line no-control-regex + if (!/^[\x00-\x7f]*$/.test(domain)) { + throw new InvalidDomainError('domain must be ASCII; encode internationalized names as punycode'); + } + const normalized = domain.toLowerCase(); + const labels = normalized.split('.'); + if (labels.length < 2) { + throw new InvalidDomainError(`domain ${JSON.stringify(domain)} must contain at least two labels`); + } + for (const label of labels) { + if (!LABEL_RE.test(label)) { + throw new InvalidDomainError(`domain ${JSON.stringify(domain)} has an invalid label ${JSON.stringify(label)}`); + } + } + if (/^[0-9]+$/.test(labels[labels.length - 1])) { + throw new InvalidDomainError(`domain ${JSON.stringify(domain)} looks like an IP literal`); + } + return normalized; +} + +/** + * @param {string} domain + * @returns {boolean} true when {@link validateDomain} would accept `domain` + */ +export function isValidDomain(domain) { + try { + validateDomain(domain); + return true; + } catch { + return false; + } +} diff --git a/javascript/src/resolver.js b/javascript/src/resolver.js index e537952..95a9d4d 100644 --- a/javascript/src/resolver.js +++ b/javascript/src/resolver.js @@ -3,11 +3,31 @@ */ import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve, sep } from 'node:path'; import { PublicKeyDiscovery } from './discovery.js'; -import { fetchRevocationDocument } from './revocation.js'; +import { fetchRevocationDocument, RevocationFetchError } from './revocation.js'; import { findDiscovery, findRevocation, parseTrustBundle } from './bundle.js'; +import { validateDomain } from './domain.js'; + +/** + * Join a validated domain-derived file name onto `baseDir` and make sure the + * result stays inside `baseDir` (SP-16). + * + * @param {string} baseDir + * @param {string} domain + * @param {string} suffix + * @returns {string} + */ +function safeDomainPath(baseDir, domain, suffix) { + const normalized = validateDomain(domain); + const root = resolve(baseDir); + const path = resolve(root, `${normalized}${suffix}`); + if (path !== root && !path.startsWith(root + sep)) { + throw new Error(`resolved path ${path} escapes ${root}`); + } + return path; +} /** * Abstract base class for discovery resolution. @@ -52,6 +72,12 @@ export class WellKnownResolver extends SchemaResolver { } } + /** + * Fetch revocation from the discovery's revocation_endpoint. + * + * @throws {RevocationFetchError} when the endpoint is declared but cannot + * be fetched or parsed (callers fail closed) + */ async resolveRevocation(domain, discovery) { const endpoint = discovery?.revocation_endpoint; if (!endpoint) return null; @@ -61,6 +87,10 @@ export class WellKnownResolver extends SchemaResolver { /** * Resolves discovery from local JSON files. + * + * The domain is validated and the resolved path must stay inside the + * configured directory, so a hostile domain string cannot select an + * arbitrary file. */ export class LocalFileResolver extends SchemaResolver { constructor(discoveryDir, revocationDir = null) { @@ -71,7 +101,7 @@ export class LocalFileResolver extends SchemaResolver { async resolveDiscovery(domain) { try { - const path = join(this._discoveryDir, `${domain}.json`); + const path = safeDomainPath(this._discoveryDir, domain, '.json'); const data = readFileSync(path, 'utf-8'); return JSON.parse(data); } catch { @@ -82,7 +112,7 @@ export class LocalFileResolver extends SchemaResolver { async resolveRevocation(domain, discovery) { if (!this._revocationDir) return null; try { - const path = join(this._revocationDir, `${domain}.revocations.json`); + const path = safeDomainPath(this._revocationDir, domain, '.revocations.json'); const data = readFileSync(path, 'utf-8'); return JSON.parse(data); } catch { @@ -116,6 +146,10 @@ export class TrustBundleResolver extends SchemaResolver { /** * Tries multiple resolvers in order, returning the first success. + * + * Precedence is positional: put the most trusted source first. A revocation + * fetch failure from any member propagates (fail-closed) instead of being + * skipped. */ export class ChainResolver extends SchemaResolver { constructor(resolvers) { @@ -133,9 +167,13 @@ export class ChainResolver extends SchemaResolver { async resolveRevocation(domain, discovery) { for (const resolver of this._resolvers) { + // RevocationFetchError propagates: a declared-but-unreachable + // revocation source must not be silently skipped. const result = await resolver.resolveRevocation(domain, discovery); if (result !== null) return result; } return null; } } + +export { RevocationFetchError }; diff --git a/javascript/src/revocation.js b/javascript/src/revocation.js index 982ab21..66ef21d 100644 --- a/javascript/src/revocation.js +++ b/javascript/src/revocation.js @@ -2,6 +2,21 @@ * Standalone revocation documents for SchemaPin v1.2. */ +import { DEFAULT_TIMEOUT_MS, fetchJson, TransportError } from './transport.js'; + +/** + * Thrown when a declared revocation endpoint cannot be fetched or parsed. + * + * Verification paths treat this as fail-closed (`DISCOVERY_FETCH_FAILED`) + * unless the caller opts out with `allowUnreachableRevocation`. + */ +export class RevocationFetchError extends Error { + constructor(message) { + super(message); + this.name = 'RevocationFetchError'; + } +} + /** * Revocation reason constants. */ @@ -80,26 +95,29 @@ export function checkRevocationCombined(simpleRevoked, revocationDoc, fingerprin } /** - * Fetch a standalone revocation document from a URL. + * Fetch a standalone revocation document from an https:// URL. + * + * Redirects are not followed, the response body is capped and the URL must + * use https. Throws {@link RevocationFetchError} on any failure so that + * callers fail closed; it never silently resolves to "no revocations". * * @param {string} url - URL to fetch from * @param {number} timeout - Timeout in milliseconds (default: 10000) - * @returns {Promise} Revocation document or null on failure + * @returns {Promise} Revocation document + * @throws {RevocationFetchError} */ -export async function fetchRevocationDocument(url, timeout = 10000) { +export async function fetchRevocationDocument(url, timeout = DEFAULT_TIMEOUT_MS) { + let data; try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - const response = await fetch(url, { signal: controller.signal }); - clearTimeout(timeoutId); - - if (!response.ok) { - return null; + data = await fetchJson(url, timeout); + } catch (e) { + if (e instanceof TransportError) { + throw new RevocationFetchError(`failed to fetch revocation document ${url}: ${e.message}`); } - - return await response.json(); - } catch { - return null; + throw e; + } + if (data === null || typeof data !== 'object' || !Array.isArray(data.revoked_keys) || typeof data.domain !== 'string') { + throw new RevocationFetchError(`revocation document from ${url} is malformed`); } + return data; } diff --git a/javascript/src/transport.js b/javascript/src/transport.js new file mode 100644 index 0000000..6545ef5 --- /dev/null +++ b/javascript/src/transport.js @@ -0,0 +1,123 @@ +/** + * Hardened HTTP fetch helpers shared by discovery and revocation. + * + * Every outbound request made by this package goes through {@link fetchJson}: + * + * - https:// only -- plaintext URLs are refused before any socket is opened + * - redirects are never followed (`redirect: 'error'`) + * - the response body is capped at {@link MAX_RESPONSE_BYTES} + * - the abort timer covers the whole exchange, including reading the body + */ + +/** Maximum accepted response body size (bytes). */ +export const MAX_RESPONSE_BYTES = 64 * 1024; + +/** Default timeout in milliseconds. */ +export const DEFAULT_TIMEOUT_MS = 10000; + +/** + * Thrown for any transport-level failure (scheme, redirect, size, network). + */ +export class TransportError extends Error { + constructor(message) { + super(message); + this.name = 'TransportError'; + } +} + +/** + * Return `url` unchanged if it is an absolute https:// URL without userinfo. + * + * @param {string} url + * @returns {string} + * @throws {TransportError} + */ +export function requireHttps(url) { + let parsed; + try { + parsed = new URL(url); + } catch { + throw new TransportError(`invalid URL: ${JSON.stringify(url)}`); + } + if (parsed.protocol !== 'https:' || !parsed.hostname) { + throw new TransportError(`only https:// URLs are permitted: ${JSON.stringify(url)}`); + } + if (parsed.username || parsed.password) { + throw new TransportError(`userinfo is not permitted in URLs: ${JSON.stringify(url)}`); + } + return url; +} + +/** + * Read a response body with a byte cap. + * + * @param {Response} response + * @param {number} maxBytes + * @returns {Promise} + */ +async function readCapped(response, maxBytes) { + const reader = response.body?.getReader?.(); + if (!reader) { + const buf = Buffer.from(await response.arrayBuffer()); + if (buf.length > maxBytes) { + throw new TransportError(`response exceeds ${maxBytes} bytes`); + } + return buf; + } + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.length; + if (total > maxBytes) { + await reader.cancel().catch(() => {}); + throw new TransportError(`response exceeds ${maxBytes} bytes`); + } + chunks.push(Buffer.from(value)); + } + return Buffer.concat(chunks); +} + +/** + * GET `url` and decode the JSON body under the hardening rules above. + * + * @param {string} url + * @param {number} [timeout=DEFAULT_TIMEOUT_MS] - milliseconds + * @param {number} [maxBytes=MAX_RESPONSE_BYTES] + * @returns {Promise<*>} parsed JSON + * @throws {TransportError} + */ +export async function fetchJson(url, timeout = DEFAULT_TIMEOUT_MS, maxBytes = MAX_RESPONSE_BYTES) { + requireHttps(url); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + let response; + try { + response = await fetch(url, { + signal: controller.signal, + redirect: 'error', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'SchemaPin' + } + }); + } catch (e) { + throw new TransportError(`request failed for ${url}: ${e.message}`); + } + if (response.status !== 200) { + throw new TransportError(`HTTP ${response.status} fetching ${url}`); + } + const body = await readCapped(response, maxBytes); + try { + return JSON.parse(body.toString('utf8')); + } catch (e) { + throw new TransportError(`invalid JSON from ${url}: ${e.message}`); + } + } finally { + clearTimeout(timer); + } +} diff --git a/python/schemapin/discovery.py b/python/schemapin/discovery.py index 83afbc4..3371d45 100644 --- a/python/schemapin/discovery.py +++ b/python/schemapin/discovery.py @@ -1,12 +1,20 @@ -"""Public key discovery via .well-known URIs per RFC 8615.""" +"""Public key discovery via .well-known URIs per RFC 8615. -import json -from typing import Any, Dict, List, Optional -from urllib.parse import urljoin +All network access goes through :mod:`schemapin.transport`: ``https://`` only, +no redirects, capped bodies, timeouts. Domains are validated with +:func:`schemapin.domain.validate_domain` before a URL is built. +""" -import requests +from typing import Any, Dict, List, Optional from .crypto import KeyManager +from .domain import InvalidDomainError, validate_domain +from .revocation import ( + RevocationFetchError, + check_revocation, + fetch_revocation_document, +) +from .transport import DEFAULT_TIMEOUT, TransportError, fetch_json class PublicKeyDiscovery: @@ -15,17 +23,25 @@ class PublicKeyDiscovery: @staticmethod def construct_well_known_url(domain: str) -> str: """ - Construct .well-known URI for SchemaPin public key discovery. + Construct the ``.well-known`` URI for SchemaPin public key discovery. - Args: - domain: Tool provider domain + ``domain`` must be a bare hostname (an ``https://`` prefix is + tolerated and stripped). ``http://`` is refused: the spec requires + HTTPS and a plaintext first fetch would let a network attacker pin + their own key. - Returns: - Full .well-known URI + Raises: + ValueError: for ``http://`` prefixes or an invalid domain. """ - if not domain.startswith(('http://', 'https://')): - domain = f"https://{domain}" - return urljoin(domain, '/.well-known/schemapin.json') + if domain.startswith("http://"): + raise ValueError("SchemaPin discovery requires https; http:// is not permitted") + if domain.startswith("https://"): + domain = domain[len("https://"):].rstrip("/") + try: + domain = validate_domain(domain) + except InvalidDomainError as e: + raise ValueError(str(e)) from e + return f"https://{domain}/.well-known/schemapin.json" @staticmethod def validate_well_known_response(response_data: Dict[str, Any]) -> bool: @@ -38,11 +54,13 @@ def validate_well_known_response(response_data: Dict[str, Any]) -> bool: Returns: True if response is valid, False otherwise """ + if not isinstance(response_data, dict): + return False required_fields = ['schema_version', 'public_key_pem'] return all(field in response_data for field in required_fields) @classmethod - def fetch_well_known(cls, domain: str, timeout: int = 10) -> Optional[Dict[str, Any]]: + def fetch_well_known(cls, domain: str, timeout: int = DEFAULT_TIMEOUT) -> Optional[Dict[str, Any]]: """ Fetch and validate .well-known/schemapin.json from domain. @@ -55,19 +73,15 @@ def fetch_well_known(cls, domain: str, timeout: int = 10) -> Optional[Dict[str, """ try: url = cls.construct_well_known_url(domain) - response = requests.get(url, timeout=timeout) - response.raise_for_status() - - data = response.json() - if cls.validate_well_known_response(data): - return data - return None - - except (requests.RequestException, json.JSONDecodeError, ValueError): + data = fetch_json(url, timeout=timeout) + except (TransportError, ValueError): return None + if cls.validate_well_known_response(data): + return data + return None @classmethod - def get_public_key_pem(cls, domain: str, timeout: int = 10) -> Optional[str]: + def get_public_key_pem(cls, domain: str, timeout: int = DEFAULT_TIMEOUT) -> Optional[str]: """ Get public key PEM from domain's .well-known endpoint. @@ -93,20 +107,20 @@ def check_key_revocation(cls, public_key_pem: str, revoked_keys: List[str]) -> b revoked_keys: List of revoked key fingerprints Returns: - True if key is revoked, False otherwise + True if key is revoked (or cannot be fingerprinted), False otherwise """ if not revoked_keys: return False try: fingerprint = KeyManager.calculate_key_fingerprint_from_pem(public_key_pem) - return fingerprint in revoked_keys except Exception: - # If we can't calculate fingerprint, assume not revoked - return False + # A key that cannot be fingerprinted cannot be trusted either. + return True + return fingerprint in revoked_keys @classmethod - def get_revoked_keys(cls, domain: str, timeout: int = 10) -> Optional[List[str]]: + def get_revoked_keys(cls, domain: str, timeout: int = DEFAULT_TIMEOUT) -> Optional[List[str]]: """ Get revoked keys list from domain's .well-known endpoint. @@ -123,27 +137,50 @@ def get_revoked_keys(cls, domain: str, timeout: int = 10) -> Optional[List[str]] return None @classmethod - def validate_key_not_revoked(cls, public_key_pem: str, domain: str, timeout: int = 10) -> bool: + def validate_key_not_revoked( + cls, + public_key_pem: str, + domain: str, + timeout: int = DEFAULT_TIMEOUT, + allow_unreachable: bool = False, + ) -> bool: """ - Validate that a public key is not revoked. + Validate that a public key is not revoked (fail-closed). + + Consults both the inline ``revoked_keys`` list and, when the discovery + document declares one, the standalone ``revocation_endpoint``. If + either cannot be fetched the key is treated as **not validated** and + ``False`` is returned, unless ``allow_unreachable=True``. Args: public_key_pem: PEM-encoded public key string domain: Tool provider domain timeout: Request timeout in seconds + allow_unreachable: Treat fetch failures as "not revoked" Returns: - True if key is not revoked, False if revoked or error + True if the key is confirmed not revoked, False otherwise """ - revoked_keys = cls.get_revoked_keys(domain, timeout) - if revoked_keys is None: - # If we can't fetch revocation list, assume not revoked - return True - - return not cls.check_key_revocation(public_key_pem, revoked_keys) + well_known = cls.fetch_well_known(domain, timeout) + if well_known is None: + return allow_unreachable + if cls.check_key_revocation(public_key_pem, well_known.get('revoked_keys', [])): + return False + endpoint = well_known.get('revocation_endpoint') + if endpoint: + try: + doc = fetch_revocation_document(endpoint, timeout=timeout) + except RevocationFetchError: + return allow_unreachable + try: + fingerprint = KeyManager.calculate_key_fingerprint_from_pem(public_key_pem) + check_revocation(doc, fingerprint) + except ValueError: + return False + return True @classmethod - def get_developer_info(cls, domain: str, timeout: int = 10) -> Optional[Dict[str, str]]: + def get_developer_info(cls, domain: str, timeout: int = DEFAULT_TIMEOUT) -> Optional[Dict[str, str]]: """ Get developer information from .well-known endpoint. diff --git a/python/schemapin/domain.py b/python/schemapin/domain.py new file mode 100644 index 0000000..e92b5d9 --- /dev/null +++ b/python/schemapin/domain.py @@ -0,0 +1,67 @@ +"""Shared domain-name validation. + +Every place that turns a domain string into something security-relevant -- +a ``.well-known`` URL, a pin-store key, a local file path, a DNS lookup name +-- MUST pass it through :func:`validate_domain` first. The grammar is the +RFC 1123 hostname grammar, restricted further so that the value cannot be +confused with a URL, a path, an IP literal or an IDN U-label: + +* ASCII only (internationalized names must be given as punycode A-labels) +* at least two labels, each 1-63 characters of ``[a-z0-9-]`` that neither + start nor end with ``-`` +* total length at most 253 characters +* the last label is not purely numeric (rejects IPv4 literals) +* no scheme, path, port, userinfo or trailing dot + +The returned value is lowercased so that pin keys and file names are +consistent regardless of how the caller spelled the domain. +""" + +import re + +#: Maximum total length of a domain name. +MAX_DOMAIN_LENGTH = 253 + +_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") + + +class InvalidDomainError(ValueError): + """Raised when a string is not an acceptable SchemaPin domain.""" + + +def validate_domain(domain: str) -> str: + """Validate ``domain`` and return its normalized (lowercase) form. + + Raises: + InvalidDomainError: if the value is not a bare RFC 1123 hostname with + at least two labels. + """ + if not isinstance(domain, str) or not domain: + raise InvalidDomainError("domain must be a non-empty string") + if len(domain) > MAX_DOMAIN_LENGTH: + raise InvalidDomainError(f"domain exceeds {MAX_DOMAIN_LENGTH} characters") + if not domain.isascii(): + raise InvalidDomainError( + "domain must be ASCII; encode internationalized names as punycode" + ) + normalized = domain.lower() + labels = normalized.split(".") + if len(labels) < 2: + raise InvalidDomainError(f"domain {domain!r} must contain at least two labels") + for label in labels: + if not _LABEL_RE.match(label): + raise InvalidDomainError( + f"domain {domain!r} has an invalid label {label!r}" + ) + if labels[-1].isdigit(): + raise InvalidDomainError(f"domain {domain!r} looks like an IP literal") + return normalized + + +def is_valid_domain(domain: str) -> bool: + """Return ``True`` when :func:`validate_domain` would accept ``domain``.""" + try: + validate_domain(domain) + except InvalidDomainError: + return False + return True diff --git a/python/schemapin/resolver.py b/python/schemapin/resolver.py index 553e920..eb835ce 100644 --- a/python/schemapin/resolver.py +++ b/python/schemapin/resolver.py @@ -7,9 +7,28 @@ from .bundle import SchemaPinTrustBundle from .discovery import PublicKeyDiscovery +from .domain import InvalidDomainError, validate_domain from .revocation import RevocationDocument, fetch_revocation_document +def _safe_domain_path(base_dir: str, domain: str, suffix: str) -> Optional[str]: + """Join a validated domain-derived file name onto ``base_dir``. + + Returns ``None`` when the domain is invalid or the resolved path would + escape ``base_dir`` (SP-16), so a hostile domain string can never select + an arbitrary file. + """ + try: + normalized = validate_domain(domain) + except InvalidDomainError: + return None + root = os.path.realpath(base_dir) + path = os.path.realpath(os.path.join(root, f"{normalized}{suffix}")) + if path != root and not path.startswith(root + os.sep): + return None + return path + + class SchemaResolver(ABC): """Abstract base class for discovery resolution.""" @@ -50,7 +69,12 @@ def resolve_discovery(self, domain: str) -> Optional[Dict[str, Any]]: def resolve_revocation( self, domain: str, discovery: Dict[str, Any] ) -> Optional[RevocationDocument]: - """Fetch revocation from the discovery's revocation_endpoint.""" + """Fetch revocation from the discovery's revocation_endpoint. + + Raises: + RevocationFetchError: when the endpoint is declared but cannot be + fetched or parsed (callers fail closed). + """ endpoint = discovery.get("revocation_endpoint") if not endpoint: return None @@ -58,7 +82,12 @@ def resolve_revocation( class LocalFileResolver(SchemaResolver): - """Resolves discovery from local JSON files.""" + """Resolves discovery from local JSON files. + + The domain is validated and the resolved path must stay inside the + configured directory, so a hostile domain string cannot select an + arbitrary file. + """ def __init__( self, discovery_dir: str, revocation_dir: Optional[str] = None @@ -68,12 +97,15 @@ def __init__( def resolve_discovery(self, domain: str) -> Optional[Dict[str, Any]]: """Read {domain}.json from the discovery directory.""" - path = os.path.join(self._discovery_dir, f"{domain}.json") + path = _safe_domain_path(self._discovery_dir, domain, ".json") + if path is None: + return None try: with open(path) as f: - return json.load(f) + data = json.load(f) except (OSError, json.JSONDecodeError): return None + return data if isinstance(data, dict) else None def resolve_revocation( self, domain: str, discovery: Dict[str, Any] @@ -81,14 +113,14 @@ def resolve_revocation( """Read {domain}.revocations.json from the revocation directory.""" if not self._revocation_dir: return None - path = os.path.join( - self._revocation_dir, f"{domain}.revocations.json" - ) + path = _safe_domain_path(self._revocation_dir, domain, ".revocations.json") + if path is None: + return None try: with open(path) as f: data = json.load(f) return RevocationDocument.from_dict(data) - except (OSError, json.JSONDecodeError): + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError): return None @@ -116,7 +148,12 @@ def resolve_revocation( class ChainResolver(SchemaResolver): - """Tries multiple resolvers in order, returning the first success.""" + """Tries multiple resolvers in order, returning the first success. + + Precedence is positional: put the most trusted source first. A + :class:`~schemapin.revocation.RevocationFetchError` raised by any member + propagates (fail-closed) instead of being skipped. + """ def __init__(self, resolvers: List[SchemaResolver]): self._resolvers = resolvers diff --git a/python/schemapin/revocation.py b/python/schemapin/revocation.py index 2ce13f4..fa14cb7 100644 --- a/python/schemapin/revocation.py +++ b/python/schemapin/revocation.py @@ -5,7 +5,13 @@ from enum import Enum from typing import Any, Dict, List, Optional -import requests + +class RevocationFetchError(RuntimeError): + """Raised when a declared revocation endpoint cannot be fetched or parsed. + + Verification paths treat this as fail-closed (``DISCOVERY_FETCH_FAILED``) + unless the caller opts out with ``allow_unreachable_revocation=True``. + """ class RevocationReason(Enum): @@ -131,16 +137,17 @@ def check_revocation_combined( def fetch_revocation_document( url: str, timeout: int = 10 -) -> Optional[RevocationDocument]: - """Fetch a standalone revocation document from a URL. +) -> RevocationDocument: + """Fetch a standalone revocation document from an ``https://`` URL. - Returns: - RevocationDocument if successful, None on failure. + Redirects are not followed, the response body is capped and the URL must + use ``https``. Raises :class:`RevocationFetchError` on any failure so that + callers fail closed; it never silently returns "no revocations". """ + from .transport import TransportError, fetch_json + try: - resp = requests.get(url, timeout=timeout) - resp.raise_for_status() - data = resp.json() + data = fetch_json(url, timeout=timeout) return RevocationDocument.from_dict(data) - except Exception: - return None + except (TransportError, KeyError, TypeError, ValueError) as e: + raise RevocationFetchError(f"failed to fetch revocation document {url}: {e}") from e diff --git a/python/schemapin/transport.py b/python/schemapin/transport.py new file mode 100644 index 0000000..2be498e --- /dev/null +++ b/python/schemapin/transport.py @@ -0,0 +1,80 @@ +"""Hardened HTTP fetch helpers shared by discovery and revocation. + +Every outbound request made by this package goes through :func:`fetch_json`: + +* ``https://`` only -- plaintext URLs are refused before any socket is opened +* redirects are never followed (a 3xx response is a failure) +* the response body is capped at :data:`MAX_RESPONSE_BYTES` +* a connect/read timeout is always applied +""" + +from typing import Any +from urllib.parse import urlsplit + +import requests + +#: Maximum accepted response body size (bytes). +MAX_RESPONSE_BYTES = 64 * 1024 + +#: Default timeout in seconds. +DEFAULT_TIMEOUT = 10 + + +class TransportError(RuntimeError): + """Raised for any transport-level failure (scheme, redirect, size, network).""" + + +def require_https(url: str) -> str: + """Return ``url`` unchanged if it is an absolute ``https://`` URL.""" + parts = urlsplit(url) + if parts.scheme != "https" or not parts.netloc: + raise TransportError(f"only https:// URLs are permitted: {url!r}") + if "@" in parts.netloc: + raise TransportError(f"userinfo is not permitted in URLs: {url!r}") + return url + + +def fetch_json( + url: str, + timeout: int = DEFAULT_TIMEOUT, + max_bytes: int = MAX_RESPONSE_BYTES, +) -> Any: + """GET ``url`` and decode the JSON body under the hardening rules above. + + Raises: + TransportError: on a non-https URL, a redirect, a non-200 status, an + oversized body, a network error or undecodable JSON. + """ + import json + + require_https(url) + try: + resp = requests.get( + url, + timeout=timeout, + allow_redirects=False, + stream=True, + headers={"Accept": "application/json", "User-Agent": "SchemaPin"}, + ) + except requests.RequestException as e: + raise TransportError(f"request failed for {url}: {e}") from e + try: + if 300 <= resp.status_code < 400: + raise TransportError( + f"redirect ({resp.status_code}) fetching {url}; redirects are not followed" + ) + if resp.status_code != 200: + raise TransportError(f"HTTP {resp.status_code} fetching {url}") + body = b"" + for chunk in resp.iter_content(chunk_size=8192): + body += chunk + if len(body) > max_bytes: + raise TransportError( + f"response from {url} exceeds {max_bytes} bytes" + ) + finally: + resp.close() + try: + return json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as e: + raise TransportError(f"invalid JSON from {url}: {e}") from e diff --git a/python/tests/test_cli_tools.py b/python/tests/test_cli_tools.py index a78ae09..7929764 100644 --- a/python/tests/test_cli_tools.py +++ b/python/tests/test_cli_tools.py @@ -279,9 +279,10 @@ def test_verify_with_public_key(self): assert result_data["verification_method"] == "public_key" assert "key_fingerprint" in result_data + @patch('schemapin.discovery.PublicKeyDiscovery.validate_key_not_revoked', return_value=True) @patch('schemapin.discovery.PublicKeyDiscovery.get_public_key_pem') - def test_verify_with_discovery(self, mock_get_key): - """Test verification with discovery.""" + def test_verify_with_discovery(self, mock_get_key, _mock_not_revoked): + """Test verification with discovery (revocation check is fail-closed, so it is mocked).""" mock_get_key.return_value = self.public_key_pem # Test the underlying function directly instead of subprocess diff --git a/python/tests/test_domain.py b/python/tests/test_domain.py new file mode 100644 index 0000000..61c0035 --- /dev/null +++ b/python/tests/test_domain.py @@ -0,0 +1,60 @@ +"""Tests for the shared domain validator (SP-11 / SP-16).""" + +import pytest + +from schemapin.domain import InvalidDomainError, is_valid_domain, validate_domain + + +@pytest.mark.parametrize( + "domain,expected", + [ + ("example.com", "example.com"), + ("Example.COM", "example.com"), + ("sub.domain.example.co.uk", "sub.domain.example.co.uk"), + ("xn--bcher-kva.example", "xn--bcher-kva.example"), + ("a-b.c1", "a-b.c1"), + ("1.example", "1.example"), + ], +) +def test_valid_domains(domain, expected): + assert validate_domain(domain) == expected + assert is_valid_domain(domain) + + +@pytest.mark.parametrize( + "domain", + [ + "", + "localhost", + "example", + "http://example.com", + "https://example.com", + "example.com/path", + "example.com:8443", + "user@example.com", + "good.com@evil.com", + "../skills/evil/key", + "/etc/passwd", + "example.com.", + ".example.com", + "exa mple.com", + "-bad.example.com", + "bad-.example.com", + "under_score.example.com", + "10.0.0.1", + "[::1]", + "bücher.example", + "a" * 64 + ".example.com", + ("a" * 63 + ".") * 4 + "com", + "evil.com\nexample.com", + ], +) +def test_invalid_domains(domain): + with pytest.raises(InvalidDomainError): + validate_domain(domain) + assert not is_valid_domain(domain) + + +def test_non_string_rejected(): + with pytest.raises(InvalidDomainError): + validate_domain(None) # type: ignore[arg-type] diff --git a/python/tests/test_interactive_pinning.py b/python/tests/test_interactive_pinning.py index dc57be3..1a42367 100644 --- a/python/tests/test_interactive_pinning.py +++ b/python/tests/test_interactive_pinning.py @@ -183,13 +183,15 @@ def test_automatic_mode_pinning(self): mode=PinningMode.AUTOMATIC ) - # Should pin automatically without prompts - result = pinning.interactive_pin_key( - self.tool_id, - self.public_key_pem, - self.domain, - self.developer_name - ) + # Should pin automatically without prompts. Revocation checks are + # fail-closed, so the network lookup is mocked as "not revoked". + with patch('schemapin.pinning.PublicKeyDiscovery.validate_key_not_revoked', return_value=True): + result = pinning.interactive_pin_key( + self.tool_id, + self.public_key_pem, + self.domain, + self.developer_name + ) self.assertTrue(result) self.assertTrue(pinning.is_key_pinned(self.tool_id)) diff --git a/python/tests/test_resolver.py b/python/tests/test_resolver.py index 23ac00f..98440ea 100644 --- a/python/tests/test_resolver.py +++ b/python/tests/test_resolver.py @@ -202,3 +202,22 @@ def test_all_miss(self): ) chain = ChainResolver([TrustBundleResolver(bundle)]) assert chain.resolve_discovery("missing.com") is None + + +class TestLocalFileResolverPathSafety: + """SP-16: a hostile domain string must not select a file outside the directory.""" + + def test_rejects_traversal_and_invalid_domains(self, tmp_path): + from schemapin.resolver import LocalFileResolver + + disc_dir = tmp_path / "discovery" + disc_dir.mkdir() + doc = '{"schema_version":"1.4","public_key_pem":"-----BEGIN PUBLIC KEY-----\\nMOCK\\n-----END PUBLIC KEY-----"}' + (tmp_path / "evil.json").write_text(doc) + (tmp_path / "key.json").write_text(doc) + resolver = LocalFileResolver(str(disc_dir), str(disc_dir)) + for bad in ("../evil", str(tmp_path / "key"), "localhost", "10.0.0.1", "evil.json/..", "../skills/evil/key"): + assert resolver.resolve_discovery(bad) is None, bad + assert resolver.resolve_revocation(bad, {}) is None, bad + (disc_dir / "example.com.json").write_text(doc) + assert resolver.resolve_discovery("Example.COM") is not None diff --git a/python/tests/test_transport.py b/python/tests/test_transport.py new file mode 100644 index 0000000..3de2e6f --- /dev/null +++ b/python/tests/test_transport.py @@ -0,0 +1,68 @@ +"""Tests for the hardened HTTP transport (SP-11).""" + +from unittest.mock import MagicMock, patch + +import pytest + +from schemapin.discovery import PublicKeyDiscovery +from schemapin.transport import ( + MAX_RESPONSE_BYTES, + TransportError, + fetch_json, + require_https, +) + + +def _response(status=200, chunks=(b'{"ok":true}',)): + resp = MagicMock() + resp.status_code = status + resp.iter_content = lambda chunk_size: iter(chunks) + return resp + + +def test_https_required(): + with pytest.raises(TransportError): + require_https("http://example.com/.well-known/schemapin.json") + with pytest.raises(TransportError): + require_https("ftp://example.com/x") + with pytest.raises(TransportError): + require_https("https://user:pw@example.com/x") + assert require_https("https://example.com/x") == "https://example.com/x" + with pytest.raises(TransportError): + fetch_json("http://example.com/x") + + +def test_redirects_are_not_followed(): + with patch("schemapin.transport.requests.get", return_value=_response(302)) as get: + with pytest.raises(TransportError, match="redirect"): + fetch_json("https://example.com/x") + assert get.call_args.kwargs["allow_redirects"] is False + + +def test_body_size_cap(): + big = b"x" * (MAX_RESPONSE_BYTES + 1) + with patch("schemapin.transport.requests.get", return_value=_response(200, (big,))): + with pytest.raises(TransportError, match="exceeds"): + fetch_json("https://example.com/x") + + +def test_success_path_parses_json(): + with patch("schemapin.transport.requests.get", return_value=_response()): + assert fetch_json("https://example.com/x") == {"ok": True} + + +def test_well_known_url_rejects_http_and_validates_domain(): + with pytest.raises(ValueError): + PublicKeyDiscovery.construct_well_known_url("http://example.com") + with pytest.raises(ValueError): + PublicKeyDiscovery.construct_well_known_url("example.com/evil") + with pytest.raises(ValueError): + PublicKeyDiscovery.construct_well_known_url("localhost") + assert ( + PublicKeyDiscovery.construct_well_known_url("Example.com") + == "https://example.com/.well-known/schemapin.json" + ) + assert ( + PublicKeyDiscovery.construct_well_known_url("https://example.com") + == "https://example.com/.well-known/schemapin.json" + ) diff --git a/rust/src/discovery.rs b/rust/src/discovery.rs index b309002..c256dfb 100644 --- a/rust/src/discovery.rs +++ b/rust/src/discovery.rs @@ -1,9 +1,24 @@ +use crate::domain::validate_domain; use crate::error::Error; use crate::types::discovery::WellKnownResponse; /// Construct the `.well-known` URL for a domain. -pub fn construct_well_known_url(domain: &str) -> String { - format!("https://{}/.well-known/schemapin.json", domain) +/// +/// `domain` must be a bare hostname (an `https://` prefix is tolerated and +/// stripped); `http://` is refused because the spec requires HTTPS and a +/// plaintext first fetch would let a network attacker pin their own key. +pub fn construct_well_known_url(domain: &str) -> Result { + if domain.starts_with("http://") { + return Err(Error::InvalidDomain( + "SchemaPin discovery requires https; http:// is not permitted".to_string(), + )); + } + let bare = domain + .strip_prefix("https://") + .unwrap_or(domain) + .trim_end_matches('/'); + let normalized = validate_domain(bare)?; + Ok(format!("https://{}/.well-known/schemapin.json", normalized)) } /// Validate a well-known response for basic structural requirements. @@ -52,41 +67,15 @@ pub fn check_key_revocation(fingerprint: &str, revoked_keys: &[String]) -> bool } /// Fetch a well-known response from a domain over HTTPS. +/// +/// Uses the hardened transport: https only, no redirects, capped body, +/// timeouts. #[cfg(feature = "fetch")] pub async fn fetch_well_known(domain: &str) -> Result { - let url = construct_well_known_url(domain); - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| Error::Discovery(e.to_string()))?; - - let resp = client - .get(&url) - .send() + let url = construct_well_known_url(domain)?; + let well_known: WellKnownResponse = crate::transport::fetch_json(&url) .await - .map_err(|e| Error::Discovery(format!("Failed to fetch {}: {}", url, e)))?; - - if resp.status().is_redirection() { - return Err(Error::Discovery(format!( - "Redirect detected fetching {} (status {}). Redirects are not allowed.", - url, - resp.status() - ))); - } - - if !resp.status().is_success() { - return Err(Error::Discovery(format!( - "HTTP {} fetching {}", - resp.status(), - url - ))); - } - - let well_known: WellKnownResponse = resp - .json() - .await - .map_err(|e| Error::Discovery(format!("Invalid JSON from {}: {}", url, e)))?; - + .map_err(|e| Error::Discovery(e.to_string()))?; validate_well_known_response(&well_known)?; Ok(well_known) } @@ -98,9 +87,28 @@ mod tests { #[test] fn test_construct_well_known_url() { assert_eq!( - construct_well_known_url("example.com"), + construct_well_known_url("example.com").unwrap(), + "https://example.com/.well-known/schemapin.json" + ); + assert_eq!( + construct_well_known_url("https://Example.COM/").unwrap(), "https://example.com/.well-known/schemapin.json" ); + for bad in [ + "http://example.com", + "example.com:8080", + "example.com/x", + "10.0.0.1", + "localhost", + "../evil", + "good.com@evil.com", + ] { + assert!( + construct_well_known_url(bad).is_err(), + "{:?} must fail", + bad + ); + } } #[test] diff --git a/rust/src/domain.rs b/rust/src/domain.rs new file mode 100644 index 0000000..73ae24e --- /dev/null +++ b/rust/src/domain.rs @@ -0,0 +1,144 @@ +//! Shared domain-name validation. +//! +//! Every place that turns a domain string into something security-relevant -- +//! a `.well-known` URL, a pin-store key, a local file path, a DNS lookup name +//! -- MUST pass it through [`validate_domain`] first. The grammar is the +//! RFC 1123 hostname grammar, restricted further so the value cannot be +//! confused with a URL, a path, an IP literal or an IDN U-label: +//! +//! - ASCII only (internationalized names must be given as punycode A-labels) +//! - at least two labels, each 1-63 characters of `[a-z0-9-]` that neither +//! start nor end with `-` +//! - total length at most 253 characters +//! - the last label is not purely numeric (rejects IPv4 literals) +//! - no scheme, path, port, userinfo or trailing dot +//! +//! The returned value is lowercased so pin keys and file names are consistent +//! regardless of how the caller spelled the domain. + +use crate::error::Error; + +/// Maximum total length of a domain name. +pub const MAX_DOMAIN_LENGTH: usize = 253; + +fn invalid(msg: String) -> Error { + Error::InvalidDomain(msg) +} + +fn valid_label(label: &str) -> bool { + let bytes = label.as_bytes(); + if bytes.is_empty() || bytes.len() > 63 { + return false; + } + if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' { + return false; + } + bytes + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-') +} + +/// Validate `domain` and return its normalized (lowercase) form. +pub fn validate_domain(domain: &str) -> Result { + if domain.is_empty() { + return Err(invalid("domain must be a non-empty string".to_string())); + } + if domain.len() > MAX_DOMAIN_LENGTH { + return Err(invalid(format!( + "domain exceeds {} characters", + MAX_DOMAIN_LENGTH + ))); + } + if !domain.is_ascii() { + return Err(invalid( + "domain must be ASCII; encode internationalized names as punycode".to_string(), + )); + } + let normalized = domain.to_ascii_lowercase(); + let labels: Vec<&str> = normalized.split('.').collect(); + if labels.len() < 2 { + return Err(invalid(format!( + "domain {:?} must contain at least two labels", + domain + ))); + } + for label in &labels { + if !valid_label(label) { + return Err(invalid(format!( + "domain {:?} has an invalid label {:?}", + domain, label + ))); + } + } + if labels[labels.len() - 1].bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid(format!( + "domain {:?} looks like an IP literal", + domain + ))); + } + Ok(normalized) +} + +/// Returns `true` when [`validate_domain`] would accept `domain`. +pub fn is_valid_domain(domain: &str) -> bool { + validate_domain(domain).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_domains() { + for (input, want) in [ + ("example.com", "example.com"), + ("Example.COM", "example.com"), + ("sub.domain.example.co.uk", "sub.domain.example.co.uk"), + ("xn--bcher-kva.example", "xn--bcher-kva.example"), + ("a-b.c1", "a-b.c1"), + ("1.example", "1.example"), + ] { + assert_eq!(validate_domain(input).unwrap(), want); + assert!(is_valid_domain(input)); + } + } + + #[test] + fn rejects_invalid_domains() { + let long_label = format!("{}.example.com", "a".repeat(64)); + let too_long = format!("{}com", format!("{}.", "a".repeat(63)).repeat(4)); + let bad = [ + "", + "localhost", + "example", + "http://example.com", + "https://example.com", + "example.com/path", + "example.com:8443", + "user@example.com", + "good.com@evil.com", + "../skills/evil/key", + "/etc/passwd", + "example.com.", + ".example.com", + "exa mple.com", + "-bad.example.com", + "bad-.example.com", + "under_score.example.com", + "10.0.0.1", + "[::1]", + "bücher.example", + long_label.as_str(), + too_long.as_str(), + "evil.com\nexample.com", + ]; + for input in bad { + assert!( + matches!(validate_domain(input), Err(Error::InvalidDomain(_))), + "{:?} must be rejected", + input + ); + assert!(!is_valid_domain(input)); + } + } +} diff --git a/rust/src/error.rs b/rust/src/error.rs index bc3f528..e950365 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -23,6 +23,12 @@ pub enum Error { #[error("Canonicalization error: {0}")] Canonicalization(String), + #[error("Invalid domain: {0}")] + InvalidDomain(String), + + #[error("Transport error: {0}")] + Transport(String), + #[error("Discovery error: {0}")] Discovery(String), @@ -102,6 +108,17 @@ pub enum ErrorCode { /// (v1.4) A signed trust bundle's `expires_at` is in the past. #[serde(rename = "BUNDLE_EXPIRED")] BundleExpired, + /// A signature's `expires_at` is in the past (or unparseable) and the + /// caller did not opt in with `allow_expired`. Hard failure. + #[serde(rename = "SIGNATURE_EXPIRED")] + SignatureExpired, + /// The caller-supplied domain is not a valid RFC 1123 hostname. + #[serde(rename = "DOMAIN_INVALID")] + DomainInvalid, + /// A trust bundle's authority key is not pre-seeded in the authority + /// store and the caller did not opt in to first-use trust. + #[serde(rename = "BUNDLE_AUTHORITY_UNTRUSTED")] + BundleAuthorityUntrusted, } impl std::fmt::Display for ErrorCode { @@ -119,6 +136,9 @@ impl std::fmt::Display for ErrorCode { ErrorCode::A2aScopeViolation => "A2A_SCOPE_VIOLATION", ErrorCode::BundleUnsigned => "BUNDLE_UNSIGNED", ErrorCode::BundleExpired => "BUNDLE_EXPIRED", + ErrorCode::SignatureExpired => "SIGNATURE_EXPIRED", + ErrorCode::DomainInvalid => "DOMAIN_INVALID", + ErrorCode::BundleAuthorityUntrusted => "BUNDLE_AUTHORITY_UNTRUSTED", }; write!(f, "{}", s) } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index ba9b0c6..3d25437 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -70,11 +70,13 @@ pub mod crypto; pub mod bundle; pub mod canonicalize; pub mod discovery; +pub mod domain; pub mod error; pub mod pinning; pub mod resolver; pub mod revocation; pub mod skill; +pub mod transport; pub mod types; pub mod verification; diff --git a/rust/src/resolver.rs b/rust/src/resolver.rs index 803fef1..692ac01 100644 --- a/rust/src/resolver.rs +++ b/rust/src/resolver.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use crate::domain::validate_domain; use crate::error::Error; use crate::types::bundle::SchemaPinTrustBundle; use crate::types::discovery::WellKnownResponse; @@ -111,9 +112,24 @@ impl LocalFileResolver { } } +/// Join a validated domain-derived file name onto `base` and make sure the +/// result stays inside `base` (SP-16). +fn safe_domain_path(base: &Path, domain: &str, suffix: &str) -> Result { + let normalized = validate_domain(domain)?; + let path = base.join(format!("{}{}", normalized, suffix)); + if !path.starts_with(base) { + return Err(Error::Discovery(format!( + "resolved path {} escapes {}", + path.display(), + base.display() + ))); + } + Ok(path) +} + impl SchemaResolver for LocalFileResolver { fn resolve_discovery(&self, domain: &str) -> Result { - let path = self.discovery_dir.join(format!("{}.json", domain)); + let path = safe_domain_path(&self.discovery_dir, domain, ".json")?; let data = std::fs::read_to_string(&path) .map_err(|e| Error::Discovery(format!("Cannot read {}: {}", path.display(), e)))?; let doc: WellKnownResponse = serde_json::from_str(&data)?; @@ -126,7 +142,7 @@ impl SchemaResolver for LocalFileResolver { _discovery: &WellKnownResponse, ) -> Result, Error> { let dir = self.revocation_dir.as_ref().unwrap_or(&self.discovery_dir); - let path = dir.join(format!("{}.revocations.json", domain)); + let path = safe_domain_path(dir, domain, ".revocations.json")?; if !path.exists() { return Ok(None); } @@ -194,6 +210,10 @@ impl SchemaResolver for TrustBundleResolver { /// Composite resolver that tries a sequence of resolvers in order until one /// succeeds. +/// +/// Precedence is positional: put the most trusted source first. A revocation +/// fetch failure from any member propagates (fail-closed) instead of being +/// skipped. pub struct ChainResolver { resolvers: Vec>, } @@ -222,10 +242,11 @@ impl SchemaResolver for ChainResolver { discovery: &WellKnownResponse, ) -> Result, Error> { for resolver in &self.resolvers { - match resolver.resolve_revocation(domain, discovery) { - Ok(Some(doc)) => return Ok(Some(doc)), - Ok(None) => continue, - Err(_) => continue, + // Errors propagate: a declared-but-unreachable revocation source + // must not silently degrade to "not revoked". + match resolver.resolve_revocation(domain, discovery)? { + Some(doc) => return Ok(Some(doc)), + None => continue, } } Ok(None) @@ -312,6 +333,39 @@ mod tests { assert_eq!(resolved.schema_version, "1.2"); } + #[test] + fn test_local_file_resolver_rejects_traversal() { + let dir = tempfile::tempdir().unwrap(); + let disc_dir = dir.path().join("discovery"); + std::fs::create_dir_all(&disc_dir).unwrap(); + let wk = make_well_known("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"); + std::fs::write( + dir.path().join("evil.json"), + serde_json::to_string(&wk).unwrap(), + ) + .unwrap(); + let resolver = LocalFileResolver::new(&disc_dir, None); + for bad in [ + "../evil", + "/etc/passwd", + "localhost", + "10.0.0.1", + "evil.json/..", + ] { + assert!( + resolver.resolve_discovery(bad).is_err(), + "{:?} must fail", + bad + ); + } + std::fs::write( + disc_dir.join("example.com.json"), + serde_json::to_string(&wk).unwrap(), + ) + .unwrap(); + assert!(resolver.resolve_discovery("Example.COM").is_ok()); + } + #[test] fn test_local_file_resolver_missing() { let dir = tempfile::tempdir().unwrap(); diff --git a/rust/src/revocation.rs b/rust/src/revocation.rs index 1fe0ee3..b595d3f 100644 --- a/rust/src/revocation.rs +++ b/rust/src/revocation.rs @@ -60,33 +60,16 @@ pub fn check_revocation_combined( Ok(()) } -/// Fetch a revocation document from a URL. +/// Fetch a revocation document from an `https://` URL. +/// +/// Uses the hardened transport (no redirects, capped body, timeouts). Any +/// failure is an error so callers fail closed; it never silently yields "no +/// revocations". #[cfg(feature = "fetch")] pub async fn fetch_revocation_document(url: &str) -> Result { - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| Error::Revocation(e.to_string()))?; - - let resp = client - .get(url) - .send() + let doc: RevocationDocument = crate::transport::fetch_json(url) .await - .map_err(|e| Error::Revocation(format!("Failed to fetch {}: {}", url, e)))?; - - if !resp.status().is_success() { - return Err(Error::Revocation(format!( - "HTTP {} fetching {}", - resp.status(), - url - ))); - } - - let doc: RevocationDocument = resp - .json() - .await - .map_err(|e| Error::Revocation(format!("Invalid JSON from {}: {}", url, e)))?; - + .map_err(|e| Error::Revocation(e.to_string()))?; Ok(doc) } diff --git a/rust/src/transport.rs b/rust/src/transport.rs new file mode 100644 index 0000000..77eeb87 --- /dev/null +++ b/rust/src/transport.rs @@ -0,0 +1,119 @@ +//! Hardened HTTP fetch helper shared by discovery and revocation (`fetch` +//! feature). +//! +//! Every outbound request made by this crate goes through [`fetch_json`]: +//! +//! - `https://` only -- plaintext URLs are refused before any socket is opened +//! - redirects are never followed +//! - the response body is capped at [`MAX_RESPONSE_BYTES`] +//! - connect and total timeouts are always applied + +use crate::error::Error; + +/// Maximum accepted response body size (bytes). +pub const MAX_RESPONSE_BYTES: usize = 64 * 1024; + +/// Default total request timeout (seconds). +pub const DEFAULT_TIMEOUT_SECS: u64 = 10; + +/// Return `Ok(())` if `url` is an absolute `https://` URL without userinfo. +pub fn require_https(url: &str) -> Result<(), Error> { + let rest = url + .strip_prefix("https://") + .ok_or_else(|| Error::Transport(format!("only https:// URLs are permitted: {:?}", url)))?; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + if authority.is_empty() { + return Err(Error::Transport(format!("invalid URL: {:?}", url))); + } + if authority.contains('@') { + return Err(Error::Transport(format!( + "userinfo is not permitted in URLs: {:?}", + url + ))); + } + Ok(()) +} + +/// Build a `reqwest::Client` with the hardening rules applied. +#[cfg(feature = "fetch")] +pub fn hardened_client() -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .connect_timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .user_agent("SchemaPin") + .build() + .map_err(|e| Error::Transport(e.to_string())) +} + +/// GET `url` and decode the JSON body under the hardening rules above. +#[cfg(feature = "fetch")] +pub async fn fetch_json(url: &str) -> Result { + require_https(url)?; + let client = hardened_client()?; + let mut resp = client + .get(url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| Error::Transport(format!("request failed for {}: {}", url, e)))?; + + if resp.status().is_redirection() { + return Err(Error::Transport(format!( + "redirect ({}) fetching {}; redirects are not followed", + resp.status(), + url + ))); + } + if resp.status() != reqwest::StatusCode::OK { + return Err(Error::Transport(format!( + "HTTP {} fetching {}", + resp.status(), + url + ))); + } + if let Some(len) = resp.content_length() { + if len as usize > MAX_RESPONSE_BYTES { + return Err(Error::Transport(format!( + "response from {} exceeds {} bytes", + url, MAX_RESPONSE_BYTES + ))); + } + } + let mut body: Vec = Vec::new(); + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| Error::Transport(format!("reading {}: {}", url, e)))? + { + body.extend_from_slice(&chunk); + if body.len() > MAX_RESPONSE_BYTES { + return Err(Error::Transport(format!( + "response from {} exceeds {} bytes", + url, MAX_RESPONSE_BYTES + ))); + } + } + serde_json::from_slice(&body) + .map_err(|e| Error::Transport(format!("invalid JSON from {}: {}", url, e))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn https_required() { + for bad in [ + "http://example.com/x", + "ftp://example.com/x", + "https://user:pw@example.com/x", + "example.com", + "https://", + "", + ] { + assert!(require_https(bad).is_err(), "{:?} must be rejected", bad); + } + assert!(require_https("https://example.com/.well-known/schemapin.json").is_ok()); + } +} From 82e1c7a78534aab9da58f2ea8f6670586125f131 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 10:57:24 -0700 Subject: [PATCH 04/14] Sign the whole skill signature document; harden skill hashing 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. --- go/pkg/skill/cross_language_test.go | 154 +++ go/pkg/skill/dns_verify.go | 35 +- go/pkg/skill/expiration_test.go | 120 +- go/pkg/skill/security_test.go | 306 +++++ go/pkg/skill/skill.go | 724 ++++++---- go/pkg/skill/skill_test.go | 6 +- go/pkg/verification/verification.go | 296 ++++- javascript/src/index.js | 15 +- javascript/src/skill.js | 617 ++++++--- javascript/src/verification.js | 256 +++- javascript/tests/cross-language.test.js | 56 + javascript/tests/skill.test.js | 304 ++++- python/schemapin/__init__.py | 23 +- python/schemapin/skill.py | 598 +++++++-- python/schemapin/verification.py | 258 +++- python/tests/test_cross_language.py | 62 + python/tests/test_skill.py | 360 ++++- python/tools/verify_schema.py | 30 +- rust/src/pinning.rs | 51 + rust/src/skill.rs | 1176 ++++++++++++++--- rust/src/verification.rs | 221 +++- rust/tests/cross_language_skill.rs | 124 ++ tests/cross-language/skill/expected.json | 43 + .../skill/fixture-skill/.schemapin.sig | 19 + .../skill/fixture-skill/README.md | 2 + .../skill/fixture-skill/SKILL.md | 7 + .../skill/fixture-skill/data/config.json | 1 + .../skill/fixture-skill/nested/deep/bin.dat | Bin 0 -> 256 bytes .../skill/fixture-skill/nested/deep/empty.txt | 0 .../skill/fixture-skill/scripts/run.sh | 2 + .../skill/legacy-v13-skill/.schemapin.sig | 17 + .../skill/legacy-v13-skill/README.md | 2 + .../skill/legacy-v13-skill/SKILL.md | 7 + .../skill/legacy-v13-skill/data/config.json | 1 + .../legacy-v13-skill/nested/deep/bin.dat | Bin 0 -> 256 bytes .../legacy-v13-skill/nested/deep/empty.txt | 0 .../skill/legacy-v13-skill/scripts/run.sh | 2 + 37 files changed, 4757 insertions(+), 1138 deletions(-) create mode 100644 go/pkg/skill/cross_language_test.go create mode 100644 go/pkg/skill/security_test.go create mode 100644 rust/tests/cross_language_skill.rs create mode 100644 tests/cross-language/skill/expected.json create mode 100644 tests/cross-language/skill/fixture-skill/.schemapin.sig create mode 100644 tests/cross-language/skill/fixture-skill/README.md create mode 100644 tests/cross-language/skill/fixture-skill/SKILL.md create mode 100644 tests/cross-language/skill/fixture-skill/data/config.json create mode 100644 tests/cross-language/skill/fixture-skill/nested/deep/bin.dat create mode 100644 tests/cross-language/skill/fixture-skill/nested/deep/empty.txt create mode 100644 tests/cross-language/skill/fixture-skill/scripts/run.sh create mode 100644 tests/cross-language/skill/legacy-v13-skill/.schemapin.sig create mode 100644 tests/cross-language/skill/legacy-v13-skill/README.md create mode 100644 tests/cross-language/skill/legacy-v13-skill/SKILL.md create mode 100644 tests/cross-language/skill/legacy-v13-skill/data/config.json create mode 100644 tests/cross-language/skill/legacy-v13-skill/nested/deep/bin.dat create mode 100644 tests/cross-language/skill/legacy-v13-skill/nested/deep/empty.txt create mode 100644 tests/cross-language/skill/legacy-v13-skill/scripts/run.sh diff --git a/go/pkg/skill/cross_language_test.go b/go/pkg/skill/cross_language_test.go new file mode 100644 index 0000000..eec697c --- /dev/null +++ b/go/pkg/skill/cross_language_test.go @@ -0,0 +1,154 @@ +package skill + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/ThirdKeyAi/schemapin/go/pkg/discovery" + "github.com/ThirdKeyAi/schemapin/go/pkg/verification" +) + +type skillFixtureExpected struct { + Domain string `json:"domain"` + Discovery discovery.WellKnownResponse `json:"discovery"` + V14 struct { + SkillDir string `json:"skill_dir"` + SkillHash string `json:"skill_hash"` + FileManifest map[string]string `json:"file_manifest"` + TamperFields []string `json:"signed_fields_tamper_must_fail"` + } `json:"v14"` + LegacyV13 struct { + SkillDir string `json:"skill_dir"` + SkillHash string `json:"skill_hash"` + FileManifest map[string]string `json:"file_manifest"` + } `json:"legacy_v13"` +} + +func skillFixtureDir(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 8; i++ { + candidate := filepath.Join(dir, "tests", "cross-language", "skill") + if st, err := os.Stat(candidate); err == nil && st.IsDir() { + return candidate + } + dir = filepath.Dir(dir) + } + t.Fatal("could not locate tests/cross-language/skill") + return "" +} + +func loadSkillFixture(t *testing.T) (string, *skillFixtureExpected) { + t.Helper() + root := skillFixtureDir(t) + raw, err := os.ReadFile(filepath.Join(root, "expected.json")) // #nosec G304 + if err != nil { + t.Fatal(err) + } + var exp skillFixtureExpected + if err := json.Unmarshal(raw, &exp); err != nil { + t.Fatal(err) + } + return root, &exp +} + +func TestCrossLanguageSkillFixtureV14(t *testing.T) { + root, exp := loadSkillFixture(t) + dir := filepath.Join(root, exp.V14.SkillDir) + rootHash, manifest, err := CanonicalizeSkill(dir) + if err != nil { + t.Fatal(err) + } + if got := "sha256:" + hex.EncodeToString(rootHash); got != exp.V14.SkillHash { + t.Fatalf("skill_hash mismatch: %s != %s", got, exp.V14.SkillHash) + } + if !reflect.DeepEqual(manifest, exp.V14.FileManifest) { + t.Fatalf("manifest mismatch:\n got %v\nwant %v", manifest, exp.V14.FileManifest) + } + r := VerifySkillOfflineWithOptions(dir, &exp.Discovery, nil, nil, nil, VerifyOptions{Domain: exp.Domain}) + if !r.Valid { + t.Fatalf("fixture must verify: %+v", r) + } + for _, w := range r.Warnings { + if w == verification.WarningLegacySignatureFormat { + t.Fatal("v1.4 fixture must not be treated as legacy") + } + } + if r.SchemaVersion != "1.0.0" { + t.Fatalf("expected schema_version 1.0.0, got %q", r.SchemaVersion) + } +} + +func TestCrossLanguageSkillFixtureV14Tamper(t *testing.T) { + root, exp := loadSkillFixture(t) + src := filepath.Join(root, exp.V14.SkillDir) + for _, field := range exp.V14.TamperFields { + dst := filepath.Join(t.TempDir(), field) + if err := os.CopyFS(dst, os.DirFS(src)); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dst, SignatureFilename)) // #nosec G304 + if err != nil { + t.Fatal(err) + } + var doc map[string]interface{} + _ = json.Unmarshal(raw, &doc) + if field == "skill_hash" { + doc[field] = "sha256:" + hex.EncodeToString(make([]byte, 32)) + } else { + doc[field] = "tampered" + } + out, _ := json.Marshal(doc) + if err := os.WriteFile(filepath.Join(dst, SignatureFilename), out, 0600); err != nil { + t.Fatal(err) + } + if r := VerifySkillOffline(dst, &exp.Discovery, nil, nil, nil, ""); r.Valid { + t.Errorf("tampering %s must fail", field) + } + } + dst := filepath.Join(t.TempDir(), "content") + if err := os.CopyFS(dst, os.DirFS(src)); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dst, "scripts", "run.sh"), []byte("#!/bin/sh\nrm -rf /\n"), 0600); err != nil { + t.Fatal(err) + } + if r := VerifySkillOffline(dst, &exp.Discovery, nil, nil, nil, ""); r.Valid { + t.Fatal("content tamper must fail") + } +} + +func TestCrossLanguageSkillFixtureLegacyV13(t *testing.T) { + root, exp := loadSkillFixture(t) + dir := filepath.Join(root, exp.LegacyV13.SkillDir) + rootHash, manifest, err := CanonicalizeSkillV1(dir) + if err != nil { + t.Fatal(err) + } + if got := "sha256:" + hex.EncodeToString(rootHash); got != exp.LegacyV13.SkillHash { + t.Fatalf("legacy skill_hash mismatch: %s != %s", got, exp.LegacyV13.SkillHash) + } + if !reflect.DeepEqual(manifest, exp.LegacyV13.FileManifest) { + t.Fatalf("legacy manifest mismatch") + } + r := VerifySkillOfflineWithOptions(dir, &exp.Discovery, nil, nil, nil, VerifyOptions{Domain: exp.Domain}) + if !r.Valid { + t.Fatalf("legacy fixture must verify: %+v", r) + } + found := false + for _, w := range r.Warnings { + if w == verification.WarningLegacySignatureFormat { + found = true + } + } + if !found { + t.Fatalf("expected legacy warning: %v", r.Warnings) + } +} diff --git a/go/pkg/skill/dns_verify.go b/go/pkg/skill/dns_verify.go index 5a50de1..62e7e10 100644 --- a/go/pkg/skill/dns_verify.go +++ b/go/pkg/skill/dns_verify.go @@ -32,17 +32,30 @@ func VerifySkillOfflineWithDNS( toolID string, dnsTxt *dns.DnsTxtRecord, ) *verification.VerificationResult { - result := VerifySkillOffline(skillDir, disc, sig, rev, pinStore, toolID) - if !result.Valid || dnsTxt == nil { - return result - } - if err := dns.VerifyDnsMatch(disc, dnsTxt); err != nil { - return &verification.VerificationResult{ - Valid: false, - Domain: result.Domain, - ErrorCode: verification.ErrDomainMismatch, - ErrorMessage: err.Error(), + return VerifySkillOfflineWithDNSOptions(skillDir, disc, sig, rev, pinStore, dnsTxt, VerifyOptions{ToolID: toolID}) +} + +// VerifySkillOfflineWithDNSOptions is VerifySkillOfflineWithDNS with explicit +// policy knobs. The DNS cross-check runs before verification so that a +// mismatching key is never pinned. +func VerifySkillOfflineWithDNSOptions( + skillDir string, + disc *discovery.WellKnownResponse, + sig *SkillSignature, + rev *revocation.RevocationDocument, + pinStore *verification.KeyPinStore, + dnsTxt *dns.DnsTxtRecord, + opts VerifyOptions, +) *verification.VerificationResult { + if dnsTxt != nil { + if err := dns.VerifyDnsMatch(disc, dnsTxt); err != nil { + return &verification.VerificationResult{ + Valid: false, + Domain: opts.Domain, + ErrorCode: verification.ErrDomainMismatch, + ErrorMessage: err.Error(), + } } } - return result + return VerifySkillOfflineWithOptions(skillDir, disc, sig, rev, pinStore, opts) } diff --git a/go/pkg/skill/expiration_test.go b/go/pkg/skill/expiration_test.go index 8441d91..3cd7650 100644 --- a/go/pkg/skill/expiration_test.go +++ b/go/pkg/skill/expiration_test.go @@ -51,8 +51,8 @@ func TestSignWithTTLWritesExpiresAt(t *testing.T) { } } -// TestSignWithoutTTLOmitsExpiresAt ensures that the v1.3 default behaviour -// is byte-compatible: no expires_at field, schemapin_version stays at "1.3". +// TestSignWithoutTTLOmitsExpiresAt ensures that the default signature writes +// no expires_at field and uses the signed-document format ("1.4"). func TestSignWithoutTTLOmitsExpiresAt(t *testing.T) { privPEM, _ := makeKeypair(t) dir := createSkillDir(t, map[string]string{ @@ -66,8 +66,8 @@ func TestSignWithoutTTLOmitsExpiresAt(t *testing.T) { if sig.ExpiresAt != "" { t.Errorf("expected ExpiresAt to be empty, got %q", sig.ExpiresAt) } - if sig.SchemapinVersion != "1.3" { - t.Errorf("expected schemapin_version '1.3', got %q", sig.SchemapinVersion) + if sig.SchemapinVersion != "1.4" { + t.Errorf("expected schemapin_version '1.4', got %q", sig.SchemapinVersion) } raw, err := os.ReadFile(filepath.Join(dir, SignatureFilename)) @@ -114,71 +114,90 @@ func TestVerifyWithFutureTTLPassesNoWarnings(t *testing.T) { } } -// TestVerifyWithPastTTLPassesWithExpiredWarning rewrites a fresh signature -// on disk with a past expires_at and confirms the verifier degrades: -// Valid stays true, Expired becomes true, and a "signature_expired" -// warning is emitted. -func TestVerifyWithPastTTLPassesWithExpiredWarning(t *testing.T) { +// TestVerifyWithPastTTLFailsClosed signs with a TTL already in the past and +// confirms the verifier fails closed with ErrSignatureExpired by default, and +// degrades (Valid, Expired, warning) only when AllowExpired is set. +func TestVerifyWithPastTTLFailsClosed(t *testing.T) { privPEM, pubPEM := makeKeypair(t) dir := createSkillDir(t, map[string]string{ "SKILL.md": "---\nname: past\n---\n", }) - sig, err := SignSkill(dir, privPEM, "example.com", "", "") - if err != nil { - t.Fatal(err) - } - - // Mutate to past expiration and rewrite the .schemapin.sig. - sig.ExpiresAt = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339) - sig.SchemapinVersion = "1.4" - data, err := json.MarshalIndent(sig, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, SignatureFilename), append(data, '\n'), 0600); err != nil { + if _, err := SignSkillWithOptions(dir, privPEM, "example.com", SignOptions{ExpiresIn: -24 * time.Hour}); err != nil { t.Fatal(err) } disc := makeDiscovery(pubPEM) result := VerifySkillOffline(dir, disc, nil, nil, nil, "past") - - if !result.Valid { - t.Errorf("expired signatures should degrade, not fail; got error: %s", result.ErrorMessage) + if result.Valid { + t.Fatal("expired signatures must fail closed by default") } - if !result.Expired { - t.Error("expected Expired=true for past ttl") + if result.ErrorCode != verification.ErrSignatureExpired { + t.Errorf("expected ErrSignatureExpired, got %q", result.ErrorCode) } - if result.ExpiresAt == "" { - t.Error("expected ExpiresAt to be populated on the result") + if !result.Expired { + t.Error("expected Expired=true") } + degraded := VerifySkillOfflineWithOptions(dir, disc, nil, nil, nil, VerifyOptions{ToolID: "past", AllowExpired: true}) + if !degraded.Valid || !degraded.Expired { + t.Fatalf("AllowExpired should degrade, not fail: %+v", degraded) + } found := false - for _, w := range result.Warnings { + for _, w := range degraded.Warnings { if w == verification.WarningSignatureExpired { found = true - break } } if !found { - t.Errorf("expected warnings to contain %q; got %v", verification.WarningSignatureExpired, result.Warnings) + t.Errorf("expected warnings to contain %q; got %v", verification.WarningSignatureExpired, degraded.Warnings) } } -// TestVerifyWithUnparseableExpiresAtWarns confirms fail-open semantics: -// a malformed expires_at is reported as a warning, not a hard failure or -// an Expired flag. -func TestVerifyWithUnparseableExpiresAtWarns(t *testing.T) { +// TestUnparseableExpiresAtFailsClosed confirms that a malformed or +// non-RFC-3339 expires_at is a hard failure unless AllowExpired is set. +func TestUnparseableExpiresAtFailsClosed(t *testing.T) { + for _, bad := range []string{"not-a-timestamp", "2020-01-01", "2020-01-01T00:00:00", "2020-01-01 00:00:00Z"} { + r := (&verification.VerificationResult{Valid: true}).WithExpirationPolicy(bad, false) + if r.Valid || r.ErrorCode != verification.ErrSignatureExpired { + t.Errorf("%q: expected ErrSignatureExpired, got %+v", bad, r) + } + lenient := (&verification.VerificationResult{Valid: true}).WithExpirationPolicy(bad, true) + if !lenient.Valid || len(lenient.Warnings) == 0 || lenient.Warnings[0] != verification.WarningSignatureExpiresAtUnparseable { + t.Errorf("%q: expected unparseable warning with AllowExpired, got %+v", bad, lenient) + } + } + justNow := time.Now().UTC().Add(-60 * time.Second).Format(time.RFC3339) + if r := (&verification.VerificationResult{Valid: true}).WithExpirationPolicy(justNow, false); !r.Valid || r.Expired { + t.Errorf("timestamp inside the skew window must not be expired: %+v", r) + } +} + +// TestExpiresAtIsCoveredBySignature is the SP-1 regression: deleting +// expires_at (and downgrading the version) after signing must invalidate. +func TestExpiresAtIsCoveredBySignature(t *testing.T) { privPEM, pubPEM := makeKeypair(t) dir := createSkillDir(t, map[string]string{ - "SKILL.md": "---\nname: bad\n---\n", + "SKILL.md": "---\nname: strip\n---\n", }) - - sig, err := SignSkill(dir, privPEM, "example.com", "", "") + sig, err := SignSkillWithOptions(dir, privPEM, "example.com", SignOptions{ExpiresIn: -24 * time.Hour}) if err != nil { t.Fatal(err) } - sig.ExpiresAt = "not-a-timestamp" + sig.ExpiresAt = "" + writeSig(t, dir, sig) + if r := VerifySkillOffline(dir, makeDiscovery(pubPEM), nil, nil, nil, "strip"); r.Valid || r.ErrorCode != verification.ErrSignatureInvalid { + t.Fatalf("stripping expires_at must invalidate: %+v", r) + } + sig.SchemapinVersion = "1.3" + writeSig(t, dir, sig) + if r := VerifySkillOffline(dir, makeDiscovery(pubPEM), nil, nil, nil, "strip"); r.Valid { + t.Fatalf("downgrade to 1.3 must invalidate: %+v", r) + } +} + +func writeSig(t *testing.T, dir string, sig *SkillSignature) { + t.Helper() data, err := json.MarshalIndent(sig, "", " ") if err != nil { t.Fatal(err) @@ -186,25 +205,4 @@ func TestVerifyWithUnparseableExpiresAtWarns(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, SignatureFilename), append(data, '\n'), 0600); err != nil { t.Fatal(err) } - - disc := makeDiscovery(pubPEM) - result := VerifySkillOffline(dir, disc, nil, nil, nil, "bad") - - if !result.Valid { - t.Errorf("expected valid (fail-open) for unparseable expires_at; got error: %s", result.ErrorMessage) - } - if result.Expired { - t.Error("expected Expired=false for unparseable expires_at") - } - - found := false - for _, w := range result.Warnings { - if w == verification.WarningSignatureExpiresAtUnparseable { - found = true - break - } - } - if !found { - t.Errorf("expected warning %q; got %v", verification.WarningSignatureExpiresAtUnparseable, result.Warnings) - } } diff --git a/go/pkg/skill/security_test.go b/go/pkg/skill/security_test.go new file mode 100644 index 0000000..5612052 --- /dev/null +++ b/go/pkg/skill/security_test.go @@ -0,0 +1,306 @@ +package skill + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ThirdKeyAi/schemapin/go/pkg/crypto" + "github.com/ThirdKeyAi/schemapin/go/pkg/discovery" + "github.com/ThirdKeyAi/schemapin/go/pkg/resolver" + "github.com/ThirdKeyAi/schemapin/go/pkg/revocation" + "github.com/ThirdKeyAi/schemapin/go/pkg/verification" +) + +// signLegacyV13 writes a pre-1.4 root-hash-only signature document. +func signLegacyV13(t *testing.T, dir, privPEM, domain string) *SkillSignature { + t.Helper() + rootHash, manifest, err := CanonicalizeSkillV1(dir) + if err != nil { + t.Fatal(err) + } + km := crypto.NewKeyManager() + priv, err := km.LoadPrivateKeyPEM(privPEM) + if err != nil { + t.Fatal(err) + } + pubPEM, _ := km.ExportPublicKeyPEM(&priv.PublicKey) + kid, _ := km.CalculateKeyFingerprintFromPEM(pubPEM) + sigB64, err := crypto.NewSignatureManager().SignHash(rootHash, priv) + if err != nil { + t.Fatal(err) + } + sig := &SkillSignature{ + SchemapinVersion: "1.3", + SkillName: "legacy", + SkillHash: "sha256:" + hex.EncodeToString(rootHash), + Signature: sigB64, + SignedAt: "2026-01-01T00:00:00Z", + Domain: domain, + SignerKid: kid, + FileManifest: manifest, + } + writeSig(t, dir, sig) + return sig +} + +func TestSignedDocumentMetadataTamperInvalidates(t *testing.T) { + privPEM, pubPEM := makeKeypair(t) + cases := []struct{ field, value string }{ + {"schema_version", "9.9.9"}, + {"previous_hash", "sha256:" + hex.EncodeToString(make([]byte, 32))}, + {"skill_name", "payments-tool"}, + {"domain", "good.example.com"}, + {"signer_kid", "sha256:" + hex.EncodeToString(make([]byte, 32))}, + {"signed_at", "2030-01-01T00:00:00Z"}, + {"canonicalization", "schemapin-v1"}, + } + for _, c := range cases { + dir := createSkillDir(t, map[string]string{"SKILL.md": "---\nname: s\n---\n"}) + sig, err := SignSkillWithOptions(dir, privPEM, "example.com", SignOptions{SchemaVersion: "1.0.0"}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(sig) + var doc map[string]interface{} + _ = json.Unmarshal(raw, &doc) + doc[c.field] = c.value + out, _ := json.Marshal(doc) + if err := os.WriteFile(filepath.Join(dir, SignatureFilename), out, 0600); err != nil { + t.Fatal(err) + } + r := VerifySkillOffline(dir, makeDiscovery(pubPEM), nil, nil, nil, "") + if r.Valid { + t.Errorf("tampering %s must invalidate the signature", c.field) + } + } +} + +func TestManifestTamperInvalidates(t *testing.T) { + privPEM, pubPEM := makeKeypair(t) + dir := createSkillDir(t, map[string]string{"SKILL.md": "# hi", "a.txt": "A"}) + sig, err := SignSkill(dir, privPEM, "example.com", "", "") + if err != nil { + t.Fatal(err) + } + sig.FileManifest["extra.txt"] = "sha256:" + hex.EncodeToString(make([]byte, 32)) + writeSig(t, dir, sig) + if r := VerifySkillOffline(dir, makeDiscovery(pubPEM), nil, nil, nil, ""); r.Valid || r.ErrorCode != verification.ErrSignatureInvalid { + t.Fatalf("manifest tamper must invalidate: %+v", r) + } +} + +func TestSkillHashIsVerified(t *testing.T) { + privPEM, pubPEM := makeKeypair(t) + dir := createSkillDir(t, map[string]string{"SKILL.md": "# hi"}) + sig, err := SignSkill(dir, privPEM, "example.com", "", "") + if err != nil { + t.Fatal(err) + } + root, _, _ := CanonicalizeSkill(dir) + if sig.SkillHash != "sha256:"+hex.EncodeToString(root) { + t.Fatalf("skill_hash must be hex(root): %s", sig.SkillHash) + } + // Lineage splice: another validly signed artifact's document on this dir. + other := createSkillDir(t, map[string]string{"SKILL.md": "# other"}) + otherSig, err := SignSkill(other, privPEM, "example.com", "", "") + if err != nil { + t.Fatal(err) + } + writeSig(t, dir, otherSig) + r := VerifySkillOffline(dir, makeDiscovery(pubPEM), nil, nil, nil, "") + if r.Valid || r.ErrorCode != verification.ErrSignatureInvalid { + t.Fatalf("spliced document must fail: %+v", r) + } +} + +func TestLegacyV13DocumentStillVerifiesWithWarning(t *testing.T) { + privPEM, pubPEM := makeKeypair(t) + dir := createSkillDir(t, map[string]string{"SKILL.md": "# hi", "sub/x.txt": "x"}) + sig := signLegacyV13(t, dir, privPEM, "example.com") + r := VerifySkillOfflineWithOptions(dir, makeDiscovery(pubPEM), nil, nil, nil, VerifyOptions{Domain: "example.com"}) + if !r.Valid { + t.Fatalf("legacy document must verify: %+v", r) + } + found := false + for _, w := range r.Warnings { + if w == verification.WarningLegacySignatureFormat { + found = true + } + } + if !found { + t.Errorf("expected legacy warning, got %v", r.Warnings) + } + sig.SchemapinVersion = "1.4" + writeSig(t, dir, sig) + if r := VerifySkillOffline(dir, makeDiscovery(pubPEM), nil, nil, nil, ""); r.Valid { + t.Fatal("legacy signature upgraded to 1.4 must fail") + } + if err := VerifyChain(&SkillSignature{SchemapinVersion: "1.4", PreviousHash: "sha256:aa"}, &SkillSignature{SchemapinVersion: "1.3", SkillHash: "sha256:aa"}); err == nil { + t.Fatal("VerifyChain must reject legacy documents") + } +} + +func TestLoadSignatureRejectsEmptyCanonicalization(t *testing.T) { + dir := t.TempDir() + doc := `{"schemapin_version":"1.4","skill_name":"x","skill_hash":"sha256:00","signature":"","signed_at":"2026-01-01T00:00:00Z","canonicalization":"","domain":"example.com","signer_kid":"k","file_manifest":{}}` + if err := os.WriteFile(filepath.Join(dir, SignatureFilename), []byte(doc), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadSignature(dir); err == nil { + t.Fatal("empty canonicalization must be rejected (SP-19)") + } +} + +type explodingResolver struct{ t *testing.T } + +func (e explodingResolver) ResolveDiscovery(string) (*discovery.WellKnownResponse, error) { + e.t.Fatal("network must not be touched on domain mismatch") + return nil, nil +} + +func (e explodingResolver) ResolveRevocation(string, *discovery.WellKnownResponse) (*revocation.RevocationDocument, error) { + return nil, nil +} + +var _ resolver.SchemaResolver = explodingResolver{} + +func TestDomainBinding(t *testing.T) { + privPEM, pubPEM := makeKeypair(t) + dir := createSkillDir(t, map[string]string{"SKILL.md": "# hi"}) + if _, err := SignSkill(dir, privPEM, "evil.example.com", "", ""); err != nil { + t.Fatal(err) + } + store := verification.NewKeyPinStore() + r := VerifySkillOfflineWithOptions(dir, makeDiscovery(pubPEM), nil, nil, store, VerifyOptions{Domain: "good.example.com", ToolID: "calc"}) + if r.Valid || r.ErrorCode != verification.ErrDomainMismatch { + t.Fatalf("expected DOMAIN_MISMATCH: %+v", r) + } + if store.GetPinned("calc", "good.example.com") != "" || store.GetPinned("calc", "evil.example.com") != "" { + t.Fatal("nothing must be pinned on domain mismatch") + } + if r := VerifySkillWithResolver(dir, "good.example.com", explodingResolver{t}, nil, ""); r.ErrorCode != verification.ErrDomainMismatch { + t.Fatalf("resolver path must bind the domain before I/O: %+v", r) + } + for _, bad := range []string{"../skills/evil/key", "localhost", "http://example.com", "10.0.0.1"} { + r := VerifySkillOfflineWithOptions(dir, makeDiscovery(pubPEM), nil, nil, nil, VerifyOptions{Domain: bad}) + if r.Valid || r.ErrorCode != verification.ErrDomainInvalid { + t.Errorf("%q: expected DOMAIN_INVALID, got %+v", bad, r) + } + } + // Bound domain is reported normalized; unbound surfaces a warning. + dir2 := createSkillDir(t, map[string]string{"SKILL.md": "# hi"}) + if _, err := SignSkill(dir2, privPEM, "Example.COM", "", ""); err != nil { + t.Fatal(err) + } + r = VerifySkillOfflineWithOptions(dir2, makeDiscovery(pubPEM), nil, nil, nil, VerifyOptions{Domain: "example.com"}) + if !r.Valid || r.Domain != "example.com" { + t.Fatalf("bound verification failed: %+v", r) + } + r = VerifySkillOffline(dir2, makeDiscovery(pubPEM), nil, nil, nil, "") + if !r.Valid || len(r.Warnings) == 0 || r.Warnings[0] != verification.WarningDomainUnbound { + t.Fatalf("expected domain_unbound warning: %+v", r) + } +} + +func TestPinAfterVerify(t *testing.T) { + privPEM, pubPEM := makeKeypair(t) + _, otherPub := makeKeypair(t) + dir := createSkillDir(t, map[string]string{"SKILL.md": "# hi"}) + if _, err := SignSkill(dir, privPEM, "example.com", "", ""); err != nil { + t.Fatal(err) + } + store := verification.NewKeyPinStore() + opts := VerifyOptions{Domain: "example.com", ToolID: "t"} + if r := VerifySkillOfflineWithOptions(dir, makeDiscovery(otherPub), nil, nil, store, opts); r.Valid || r.ErrorCode != verification.ErrSignatureInvalid { + t.Fatalf("expected SIGNATURE_INVALID: %+v", r) + } + if store.GetPinned("t", "example.com") != "" { + t.Fatal("a failed verification must not pin") + } + r := VerifySkillOfflineWithOptions(dir, makeDiscovery(pubPEM), nil, nil, store, opts) + if !r.Valid || r.KeyPinning == nil || r.KeyPinning.Status != "first_use" || store.GetPinned("t", "example.com") == "" { + t.Fatalf("expected first_use pin after success: %+v", r) + } + // Expired must not pin either. + dir2 := createSkillDir(t, map[string]string{"SKILL.md": "# hi"}) + if _, err := SignSkillWithOptions(dir2, privPEM, "example.com", SignOptions{ExpiresIn: -24 * time.Hour}); err != nil { + t.Fatal(err) + } + store2 := verification.NewKeyPinStore() + if r := VerifySkillOfflineWithOptions(dir2, makeDiscovery(pubPEM), nil, nil, store2, opts); r.Valid || store2.GetPinned("t", "example.com") != "" { + t.Fatalf("expired signature must not pin: %+v", r) + } +} + +func TestSymlinksRejected(t *testing.T) { + privPEM, pubPEM := makeKeypair(t) + base := t.TempDir() + dir := filepath.Join(base, "skill") + if err := os.MkdirAll(dir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# hi"), 0600); err != nil { + t.Fatal(err) + } + if _, err := SignSkill(dir, privPEM, "example.com", "", ""); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "outside.txt"), []byte("unsigned"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(base, "outside.txt"), filepath.Join(dir, "helper.sh")); err != nil { + t.Skip("symlinks not supported") + } + if _, _, err := CanonicalizeSkill(dir); !errors.Is(err, ErrSkillCanonicalization) { + t.Fatalf("expected canonicalization error, got %v", err) + } + if r := VerifySkillOffline(dir, makeDiscovery(pubPEM), nil, nil, nil, ""); r.Valid || r.ErrorCode != verification.ErrSchemaCanonicalizationFailed { + t.Fatalf("symlink added after signing must fail: %+v", r) + } + _ = os.Remove(filepath.Join(dir, "helper.sh")) + if err := os.Mkdir(filepath.Join(base, "other"), 0750); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(base, "other"), filepath.Join(dir, "examples")); err != nil { + t.Fatal(err) + } + if _, _, err := CanonicalizeSkill(dir); err == nil { + t.Fatal("symlinked directory must be rejected") + } +} + +func TestPathContentBoundaryNotMalleable(t *testing.T) { + a := createSkillDir(t, map[string]string{"run.sh.disabled": "\necho hi"}) + b := createSkillDir(t, map[string]string{"run.sh": ".disabled\necho hi"}) + ha, _, _ := CanonicalizeSkill(a) + hb, _, _ := CanonicalizeSkill(b) + if hex.EncodeToString(ha) == hex.EncodeToString(hb) { + t.Fatal("v2 hashing must separate path and content") + } + la, _, _ := CanonicalizeSkillV1(a) + lb, _, _ := CanonicalizeSkillV1(b) + if hex.EncodeToString(la) != hex.EncodeToString(lb) { + t.Fatal("legacy hashing is expected to collide on this re-split (documents the SP-13 defect)") + } + _ = sha256.New +} + +func TestNestedSigFileIsContent(t *testing.T) { + dir := createSkillDir(t, map[string]string{"SKILL.md": "# hi", "nested/.schemapin.sig": "{}"}) + _, manifest, err := CanonicalizeSkill(dir) + if err != nil { + t.Fatal(err) + } + if _, ok := manifest["nested/.schemapin.sig"]; !ok { + t.Error("nested .schemapin.sig must be hashed as content") + } + if _, ok := manifest[".schemapin.sig"]; ok { + t.Error("top-level .schemapin.sig must be excluded") + } +} diff --git a/go/pkg/skill/skill.go b/go/pkg/skill/skill.go index 1321f28..3a7781d 100644 --- a/go/pkg/skill/skill.go +++ b/go/pkg/skill/skill.go @@ -1,33 +1,57 @@ // Package skill provides skill folder signing and verification for SchemaPin -// v1.3 with v1.4-alpha additions. +// v1.3 / v1.4. // // Extends SchemaPin's ECDSA P-256 signing to cover file-based skill folders // (AgentSkills spec). Same keys, same .well-known discovery, new canonicalization // target. // -// v1.4-alpha additions: -// - Optional signature expiration (expires_at) on .schemapin.sig -- -// written via SignSkillWithOptions. Verifiers degrade past expires_at -// instead of failing. -// - Optional DNS TXT cross-verification via VerifySkillOfflineWithDNS; -// see the dns subpackage for the parser and lookup helpers. +// # Wire formats +// +// schemapin_version "1.4" (the default for every new signature): +// +// - The directory is canonicalized with CanonicalizeSkill (skill +// canonicalization v2, spec section 4b): every entry is hashed as +// SHA-256("f" || be64(len(path)) || path || be64(len(content)) || content) +// and the root hash is SHA-256 over be64(len(path)) || path || digest for +// every entry in code-point path order. Symlinks are rejected. +// - skill_hash is "sha256:" + hex(root_hash). +// - The signature covers the entire signature document: the document is +// built with signature "", canonicalized with schemapin-v1 and signed as +// ECDSA-P256(SHA-256(canonical_bytes)). expires_at, schema_version, +// previous_hash, canonicalization, domain, skill_name, signer_kid, +// signed_at and file_manifest are therefore all integrity-protected. +// +// schemapin_version "1.3" (legacy, verify-only): +// +// - Entries hashed as SHA-256(path || content), root hash SHA-256 of the +// concatenated hex digests, symlinks silently skipped, and the signature +// covers only the 32-byte root hash. None of the metadata fields are +// signed; verifiers surface a legacy_signature_format warning. +// +// Verification is fail-closed on expiration and on domain mismatch. package skill import ( "crypto/ecdsa" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" "regexp" "sort" + "strconv" "strings" "time" + "unicode/utf8" + "github.com/ThirdKeyAi/schemapin/go/pkg/core" "github.com/ThirdKeyAi/schemapin/go/pkg/crypto" "github.com/ThirdKeyAi/schemapin/go/pkg/discovery" + "github.com/ThirdKeyAi/schemapin/go/pkg/domain" "github.com/ThirdKeyAi/schemapin/go/pkg/resolver" "github.com/ThirdKeyAi/schemapin/go/pkg/revocation" "github.com/ThirdKeyAi/schemapin/go/pkg/verification" @@ -36,19 +60,36 @@ import ( // SignatureFilename is the name of the signature file written into skill directories. const SignatureFilename = ".schemapin.sig" -// Version constants written into the SchemapinVersion field of new -// signatures. v1.3 signatures lack expires_at; v1.4 signatures may carry -// it. Verifiers handle both transparently. +// Version constants written into the SchemapinVersion field. +const ( + // SchemapinVersion is written on every new signature (signed-document format). + SchemapinVersion = "1.4" + // SchemapinVersionLegacy is the root-hash-only format; accepted on + // verification, never written. + SchemapinVersionLegacy = "1.3" + + schemapinVersionV13 = SchemapinVersionLegacy + schemapinVersionV14 = SchemapinVersion +) + +// Caps enforced by CanonicalizeSkill. const ( - schemapinVersionV13 = "1.3" - schemapinVersionV14 = "1.4" + MaxSkillFiles = 10000 + MaxSkillTotalBytes = 256 * 1024 * 1024 ) +// ErrSkillCanonicalization wraps every canonicalization failure. +var ErrSkillCanonicalization = errors.New("skill canonicalization error") + +func canonErr(format string, args ...interface{}) error { + return fmt.Errorf("%w: %s", ErrSkillCanonicalization, fmt.Sprintf(format, args...)) +} + // SkillSignature represents the JSON structure of a .schemapin.sig file. // // Optional v1.4 fields: -// - ExpiresAt: when present, verifiers treat signatures past the expiration -// as degraded (warning) rather than a hard failure -- see VerifySkillOffline. +// - ExpiresAt: verification fails with ErrSignatureExpired past this unless +// the caller allows expired signatures. // - SchemaVersion: caller-supplied semver string identifying *this* version // of the signed artifact. Surfaced via VerificationResult for policy use. // - PreviousHash: sha256: of the prior signed version's SkillHash, @@ -62,8 +103,8 @@ type SkillSignature struct { ExpiresAt string `json:"expires_at,omitempty"` SchemaVersion string `json:"schema_version,omitempty"` PreviousHash string `json:"previous_hash,omitempty"` - // Canonicalization (v1.4 alpha.3) names the algorithm used to produce - // the signing input. Absence is wire-equivalent to "schemapin-v1". + // Canonicalization (v1.4) names the algorithm used to produce the + // signing input. Absence is wire-equivalent to "schemapin-v1". // Verifiers MUST reject any other value as ErrCanonicalizationUnsupported. Canonicalization string `json:"canonicalization,omitempty"` Domain string `json:"domain"` @@ -72,10 +113,6 @@ type SkillSignature struct { } // SignOptions are optional sign-time parameters for SignSkillWithOptions. -// -// All fields are optional and default to "absent": empty strings derive the -// value from the key/SKILL.md/dirname, and a zero ExpiresIn omits the -// expires_at field entirely. type SignOptions struct { // SignerKid overrides the kid written into the signature. When empty, // the kid is derived from the public key fingerprint. @@ -84,24 +121,37 @@ type SignOptions struct { // empty, it is parsed from SKILL.md frontmatter or falls back to the // directory basename. SkillName string - // ExpiresIn sets a TTL relative to signing time. When > 0, the - // signature carries an RFC 3339 expires_at field and the version is - // bumped to "1.4". A zero value (the default) writes no expires_at and - // keeps the version at "1.3". + // ExpiresIn sets a TTL relative to signing time. When non-zero, the + // signature carries an RFC 3339 expires_at field (a negative value is + // permitted and yields an already-expired signature, useful for tests). ExpiresIn time.Duration // SchemaVersion is a caller-supplied semver string identifying *this* - // version of the signed artifact (v1.4 alpha.2). Empty omits the field. + // version of the signed artifact. Empty omits the field. SchemaVersion string // PreviousHash is sha256: of the prior signed version's SkillHash, - // forming a hash chain (v1.4 alpha.2). Pair with VerifyChain at verify - // time. Empty omits the field. + // forming a hash chain. Pair with VerifyChain at verify time. PreviousHash string - // Canonicalization (v1.4 alpha.3) is the algorithm identifier to write - // into the signature. Empty omits the field on the wire (== implicit + // Canonicalization is the algorithm identifier to write into the + // signature. Empty omits the field on the wire (== implicit // "schemapin-v1"); pass "schemapin-v1" to declare it explicitly. Canonicalization string } +// VerifyOptions tunes VerifySkillOfflineWithOptions. +type VerifyOptions struct { + // Domain binds the skill to an expected domain: the signature's domain + // field must equal it (ErrDomainMismatch otherwise). Pass it whenever you + // know which domain the discovery document belongs to -- the signature's + // own domain field is display-only. When empty the signature's domain is + // used and a domain_unbound warning is surfaced. + Domain string + // ToolID is the pin-store key; defaults to the signature's skill_name. + ToolID string + // AllowExpired accepts expired signatures (degraded) instead of failing + // with ErrSignatureExpired. + AllowExpired bool +} + // TamperedFiles holds the result of comparing two file manifests. type TamperedFiles struct { Modified []string @@ -109,113 +159,198 @@ type TamperedFiles struct { Removed []string } -// walkSorted recursively walks a directory in sorted order, building the manifest. -func walkSorted(dir, baseDir string, manifest map[string]string) error { - entries, err := os.ReadDir(dir) +type manifestEntry struct { + path string + digest []byte +} + +func be64(n int) []byte { + var b [8]byte + binary.BigEndian.PutUint64(b[:], uint64(n)) // #nosec G115 -- n is a non-negative length + return b[:] +} + +// walkV2 implements skill canonicalization v2. +func walkV2(dir, baseDir string, entries *[]manifestEntry, total *int64) error { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return canonErr("failed to read directory %s: %v", dir, err) + } + for _, entry := range dirEntries { + fullPath := filepath.Join(dir, entry.Name()) + info, err := os.Lstat(fullPath) + if err != nil { + return canonErr("failed to stat %s: %v", fullPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return canonErr("symlink not permitted in skill directory: %s", fullPath) + } + if info.IsDir() { + if err := walkV2(fullPath, baseDir, entries, total); err != nil { + return err + } + continue + } + if !info.Mode().IsRegular() { + return canonErr("non-regular file in skill directory: %s", fullPath) + } + if dir == baseDir && entry.Name() == SignatureFilename { + continue + } + relPath, err := filepath.Rel(baseDir, fullPath) + if err != nil { + return canonErr("failed to compute relative path: %v", err) + } + relStr := filepath.ToSlash(relPath) + if !utf8.ValidString(relStr) { + return canonErr("path is not valid UTF-8: %q", relStr) + } + data, err := os.ReadFile(fullPath) // #nosec G304 -- path constructed from the directory walk + if err != nil { + return canonErr("failed to read file %s: %v", fullPath, err) + } + *total += int64(len(data)) + if len(*entries)+1 > MaxSkillFiles { + return canonErr("skill exceeds %d files", MaxSkillFiles) + } + if *total > MaxSkillTotalBytes { + return canonErr("skill exceeds %d bytes", MaxSkillTotalBytes) + } + h := sha256.New() + h.Write([]byte{'f'}) + h.Write(be64(len(relStr))) + h.Write([]byte(relStr)) + h.Write(be64(len(data))) + h.Write(data) + *entries = append(*entries, manifestEntry{path: relStr, digest: h.Sum(nil)}) + } + return nil +} + +// CanonicalizeSkill walks a skill directory deterministically and computes the +// v2 root hash. +// +// Algorithm (spec section 4b): +// 1. Walk the tree; directories are not entries. The top-level .schemapin.sig +// is excluded. +// 2. Any symlink (file or directory) or non-regular file aborts +// canonicalization -- unsigned content must not be reachable from a +// signed skill. +// 3. Paths are relative, forward-slash separated, UTF-8, sorted by code point. +// 4. Per entry: SHA-256(0x66 || be64(len(path)) || path || be64(len(content)) || content). +// 5. Root: SHA-256 over be64(len(path)) || path || entry_digest for every +// entry in sorted order. +// +// Returns (root_hash_bytes, manifest, error). +func CanonicalizeSkill(skillDir string) ([]byte, map[string]string, error) { + absDir, err := filepath.Abs(skillDir) if err != nil { - return fmt.Errorf("failed to read directory %s: %w", dir, err) + return nil, nil, canonErr("failed to resolve skill directory: %v", err) + } + absDir, err = filepath.EvalSymlinks(absDir) + if err != nil { + return nil, nil, canonErr("failed to resolve skill directory: %v", err) + } + info, err := os.Stat(absDir) + if err != nil || !info.IsDir() { + return nil, nil, canonErr("not a directory: %s", skillDir) + } + + var entries []manifestEntry + var total int64 + if err := walkV2(absDir, absDir, &entries, &total); err != nil { + return nil, nil, err + } + if len(entries) == 0 { + return nil, nil, canonErr("skill directory is empty or contains no signable files: %s", skillDir) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path }) + + root := sha256.New() + manifest := make(map[string]string, len(entries)) + for _, e := range entries { + root.Write(be64(len(e.path))) + root.Write([]byte(e.path)) + root.Write(e.digest) + manifest[e.path] = "sha256:" + hex.EncodeToString(e.digest) } + return root.Sum(nil), manifest, nil +} - // os.ReadDir returns entries sorted by name already, but let's be explicit +// walkV1 is the legacy (schemapin_version "1.3") walk. +func walkV1(dir, baseDir string, manifest map[string]string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return canonErr("failed to read directory %s: %v", dir, err) + } sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) - for _, entry := range entries { fullPath := filepath.Join(dir, entry.Name()) - - // Check for symlinks using Lstat info, err := os.Lstat(fullPath) if err != nil { - return fmt.Errorf("failed to stat %s: %w", fullPath, err) + return canonErr("failed to stat %s: %v", fullPath, err) } if info.Mode()&os.ModeSymlink != 0 { continue } - if info.IsDir() { - if err := walkSorted(fullPath, baseDir, manifest); err != nil { + if err := walkV1(fullPath, baseDir, manifest); err != nil { return err } continue } - - // Regular file if entry.Name() == SignatureFilename { continue } - relPath, err := filepath.Rel(baseDir, fullPath) if err != nil { - return fmt.Errorf("failed to compute relative path: %w", err) + return canonErr("failed to compute relative path: %v", err) } - // Normalize to forward slashes relStr := filepath.ToSlash(relPath) - - fileBytes, err := os.ReadFile(fullPath) // #nosec G304 -- path constructed from trusted directory walk + fileBytes, err := os.ReadFile(fullPath) // #nosec G304 -- path constructed from the directory walk if err != nil { - return fmt.Errorf("failed to read file %s: %w", fullPath, err) + return canonErr("failed to read file %s: %v", fullPath, err) } - h := sha256.New() h.Write([]byte(relStr)) h.Write(fileBytes) - digest := hex.EncodeToString(h.Sum(nil)) - manifest[relStr] = "sha256:" + digest + manifest[relStr] = "sha256:" + hex.EncodeToString(h.Sum(nil)) } - return nil } -// CanonicalizeSkill walks a skill directory deterministically and computes a root hash. -// -// Algorithm: -// 1. Recursive sorted directory walk -// 2. Skip .schemapin.sig and symlinks -// 3. Normalize paths to forward slashes -// 4. Per-file: SHA-256(relative_path_utf8 + file_bytes) -> hex -> "sha256:" -// 5. Root: sort manifest keys, extract hex digests, concatenate, SHA-256 -> raw bytes -// -// Returns (root_hash_bytes, manifest, error). Returns error if directory is empty. -func CanonicalizeSkill(skillDir string) ([]byte, map[string]string, error) { +// CanonicalizeSkillV1 is the legacy (schemapin_version "1.3") skill +// canonicalization. Kept only so that pre-1.4 signatures still verify. Per-file +// digest is SHA-256(path || content), the root hash is SHA-256 of the +// concatenated hex digests, symlinks are skipped and .schemapin.sig is +// excluded at any depth. Do not use for new signatures. +func CanonicalizeSkillV1(skillDir string) ([]byte, map[string]string, error) { absDir, err := filepath.Abs(skillDir) if err != nil { - return nil, nil, fmt.Errorf("failed to resolve skill directory: %w", err) + return nil, nil, canonErr("failed to resolve skill directory: %v", err) } - - // Resolve any symlinks in the base directory itself absDir, err = filepath.EvalSymlinks(absDir) if err != nil { - return nil, nil, fmt.Errorf("failed to eval symlinks: %w", err) + return nil, nil, canonErr("failed to resolve skill directory: %v", err) } - manifest := make(map[string]string) - if err := walkSorted(absDir, absDir, manifest); err != nil { + if err := walkV1(absDir, absDir, manifest); err != nil { return nil, nil, err } - if len(manifest) == 0 { - return nil, nil, fmt.Errorf("skill directory is empty or contains no signable files: %s", skillDir) + return nil, nil, canonErr("skill directory is empty or contains no signable files: %s", skillDir) } - - // Collect sorted keys keys := make([]string, 0, len(manifest)) for k := range manifest { keys = append(keys, k) } sort.Strings(keys) - - // Extract hex digests and concatenate var builder strings.Builder for _, k := range keys { - val := manifest[k] - // Split on ":" and take the hex part - parts := strings.SplitN(val, ":", 2) - if len(parts) == 2 { - builder.WriteString(parts[1]) - } + builder.WriteString(strings.TrimPrefix(manifest[k], "sha256:")) } - rootHash := sha256.Sum256([]byte(builder.String())) return rootHash[:], manifest, nil } @@ -235,27 +370,25 @@ func ParseSkillName(skillDir string) string { } text := string(data) - - // Match frontmatter block: --- ... --- fmRe := regexp.MustCompile(`(?s)^---\s*\n(.*?)\n---`) fmMatch := fmRe.FindStringSubmatch(text) if fmMatch == nil { return filepath.Base(absDir) } - frontmatter := fmMatch[1] - - // Match name: value in frontmatter (with optional quotes) nameRe := regexp.MustCompile(`(?m)^name:\s*['"]?([^'"#\n]+?)['"]?\s*$`) nameMatch := nameRe.FindStringSubmatch(frontmatter) if nameMatch == nil { return filepath.Base(absDir) } - return strings.TrimSpace(nameMatch[1]) } // LoadSignature reads and parses the .schemapin.sig file from a skill directory. +// +// An explicitly empty "canonicalization" field is rejected: the empty string +// is not a supported algorithm identifier, and the Go struct cannot otherwise +// distinguish it from an absent field. func LoadSignature(skillDir string) (*SkillSignature, error) { sigPath := filepath.Join(skillDir, SignatureFilename) data, err := os.ReadFile(sigPath) // #nosec G304 -- path constructed from user-provided skill directory @@ -263,38 +396,93 @@ func LoadSignature(skillDir string) (*SkillSignature, error) { return nil, fmt.Errorf("failed to read signature file: %w", err) } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse signature file: %w", err) + } + if v, ok := raw["canonicalization"]; ok && string(v) == `""` { + return nil, fmt.Errorf("failed to parse signature file: canonicalization must not be empty") + } + var sig SkillSignature if err := json.Unmarshal(data, &sig); err != nil { return nil, fmt.Errorf("failed to parse signature file: %w", err) } - return &sig, nil } +// SigningInput builds the canonical bytes a v1.4 signature covers: the known +// signature fields (unknown fields are ignored and therefore unprotected) with +// absent / empty optional fields dropped, signature forced to "" and the +// whole document canonicalized with schemapin-v1. +func SigningInput(sig *SkillSignature) ([]byte, error) { + if sig == nil { + return nil, fmt.Errorf("signature document is nil") + } + required := map[string]string{ + "schemapin_version": sig.SchemapinVersion, + "skill_name": sig.SkillName, + "skill_hash": sig.SkillHash, + "signed_at": sig.SignedAt, + "domain": sig.Domain, + "signer_kid": sig.SignerKid, + } + doc := make(map[string]interface{}, 12) + for k, v := range required { + if v == "" { + return nil, fmt.Errorf("signature document field %q is missing", k) + } + doc[k] = v + } + if sig.FileManifest == nil { + return nil, fmt.Errorf("file_manifest must be an object") + } + manifest := make(map[string]interface{}, len(sig.FileManifest)) + for k, v := range sig.FileManifest { + manifest[k] = v + } + doc["file_manifest"] = manifest + for k, v := range map[string]string{ + "expires_at": sig.ExpiresAt, + "schema_version": sig.SchemaVersion, + "previous_hash": sig.PreviousHash, + "canonicalization": sig.Canonicalization, + } { + if v != "" { + doc[k] = v + } + } + doc["signature"] = "" + canonical, err := core.CanonicalizeValue(doc) + if err != nil { + return nil, err + } + return []byte(canonical), nil +} + // SignSkill canonicalizes a skill directory, signs it, and writes .schemapin.sig. // // If signerKid is empty, it is auto-computed from the public key. // If skillName is empty, it is parsed from SKILL.md (or falls back to dir name). -// -// Preserved as a thin wrapper over SignSkillWithOptions for backward -// compatibility with v1.3 callers. -func SignSkill(skillDir, privateKeyPEM, domain string, signerKid, skillName string) (*SkillSignature, error) { - return SignSkillWithOptions(skillDir, privateKeyPEM, domain, SignOptions{ +func SignSkill(skillDir, privateKeyPEM, domainName string, signerKid, skillName string) (*SkillSignature, error) { + return SignSkillWithOptions(skillDir, privateKeyPEM, domainName, SignOptions{ SignerKid: signerKid, SkillName: skillName, }) } -// SignSkillWithOptions canonicalizes a skill directory, signs it, and writes -// .schemapin.sig. Mirrors the v1.4 Rust API sign_skill_with_options. -// -// When options.ExpiresIn > 0, an RFC 3339 expires_at timestamp is written -// (truncated to seconds, UTC, "Z" suffix) and the schemapin_version is -// bumped to "1.4". When ExpiresIn is zero, expires_at is omitted and the -// version stays at "1.3" -- bytewise-identical to the v1.3 wire format. -func SignSkillWithOptions(skillDir, privateKeyPEM, domain string, options SignOptions) (*SkillSignature, error) { - keyManager := crypto.NewKeyManager() +// SignSkillWithOptions canonicalizes a skill directory, signs it, and writes a +// v1.4 signature document. The whole document (including expires_at when +// options.ExpiresIn is non-zero, written as RFC 3339 "Z" with second +// precision) is covered by the signature. +func SignSkillWithOptions(skillDir, privateKeyPEM, domainName string, options SignOptions) (*SkillSignature, error) { + normalized, err := domain.ValidateDomain(domainName) + if err != nil { + return nil, err + } + domainName = normalized + keyManager := crypto.NewKeyManager() privateKey, err := keyManager.LoadPrivateKeyPEM(privateKeyPEM) if err != nil { return nil, fmt.Errorf("failed to load private key: %w", err) @@ -323,55 +511,77 @@ func SignSkillWithOptions(skillDir, privateKeyPEM, domain string, options SignOp } } - sigManager := crypto.NewSignatureManager() - signatureB64, err := sigManager.SignHash(rootHash, privateKey) - if err != nil { - return nil, fmt.Errorf("failed to sign hash: %w", err) - } - now := time.Now().UTC().Truncate(time.Second) expiresAt := "" - if options.ExpiresIn > 0 { + if options.ExpiresIn != 0 { expiresAt = now.Add(options.ExpiresIn).UTC().Format(time.RFC3339) } - // Any v1.4 optional field bumps the version stamp; pure v1.3 sigs stay - // "1.3" for byte-stable backward compatibility. - version := schemapinVersionV13 - if expiresAt != "" || options.SchemaVersion != "" || options.PreviousHash != "" || options.Canonicalization != "" { - version = schemapinVersionV14 - } - sig := &SkillSignature{ - SchemapinVersion: version, + SchemapinVersion: SchemapinVersion, SkillName: skillName, - SkillHash: fmt.Sprintf("sha256:%s", hex.EncodeToString(rootHash)), - Signature: signatureB64, + SkillHash: "sha256:" + hex.EncodeToString(rootHash), SignedAt: now.Format(time.RFC3339), ExpiresAt: expiresAt, SchemaVersion: options.SchemaVersion, PreviousHash: options.PreviousHash, Canonicalization: options.Canonicalization, - Domain: domain, + Domain: domainName, SignerKid: signerKid, FileManifest: manifest, } + input, err := SigningInput(sig) + if err != nil { + return nil, err + } + sig.Signature, err = crypto.NewSignatureManager().SignHash(input, privateKey) + if err != nil { + return nil, fmt.Errorf("failed to sign: %w", err) + } + sigJSON, err := json.MarshalIndent(sig, "", " ") if err != nil { return nil, fmt.Errorf("failed to marshal signature: %w", err) } - sigPath := filepath.Join(skillDir, SignatureFilename) if err := os.WriteFile(sigPath, append(sigJSON, '\n'), 0600); err != nil { // #nosec G306 return nil, fmt.Errorf("failed to write signature file: %w", err) } - return sig, nil } +func versionTuple(version string) (int, int, bool) { + parts := strings.Split(version, ".") + if len(parts) != 2 { + return 0, 0, false + } + major, err1 := strconv.Atoi(parts[0]) + minor, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || major < 0 || minor < 0 { + return 0, 0, false + } + return major, minor, true +} + +func isLegacyVersion(version string) bool { + major, minor, ok := versionTuple(version) + return ok && (major < 1 || (major == 1 && minor < 4)) +} + +func failure(domainName string, code verification.ErrorCode, msg string) *verification.VerificationResult { + return &verification.VerificationResult{ + Valid: false, + Domain: domainName, + ErrorCode: code, + ErrorMessage: msg, + } +} + // VerifySkillOffline verifies a signed skill folder offline using pre-fetched -// discovery and revocation data. Follows the 7-step verification flow. +// discovery and revocation data. The signature's own domain field is used for +// pinning and reporting with a domain_unbound warning; prefer +// VerifySkillOfflineWithOptions and bind the expected domain. func VerifySkillOffline( skillDir string, disc *discovery.WellKnownResponse, @@ -379,21 +589,41 @@ func VerifySkillOffline( rev *revocation.RevocationDocument, pinStore *verification.KeyPinStore, toolID string, +) *verification.VerificationResult { + return VerifySkillOfflineWithOptions(skillDir, disc, sig, rev, pinStore, VerifyOptions{ToolID: toolID}) +} + +// VerifySkillOfflineWithOptions verifies a signed skill folder offline. +// +// Steps: +// 1. Load or accept signature data; check canonicalization +// 2. Bind the domain (opts.Domain must equal the signature's domain) +// 3. Validate discovery document, load key, compute fingerprint +// 4. Check revocation +// 5. TOFU pin check (read-only) +// 6. Recompute the root hash, require skill_hash to match, verify the +// signature (over the whole document for 1.4, over the root hash for +// legacy 1.3) +// 7. Expiration check (fail-closed unless opts.AllowExpired) +// 8. Pin on first use, return the result with lineage metadata +func VerifySkillOfflineWithOptions( + skillDir string, + disc *discovery.WellKnownResponse, + sig *SkillSignature, + rev *revocation.RevocationDocument, + pinStore *verification.KeyPinStore, + opts VerifyOptions, ) *verification.VerificationResult { // Step 1: Load signature if nil if sig == nil { var err error sig, err = LoadSignature(skillDir) if err != nil { - return &verification.VerificationResult{ - Valid: false, - ErrorCode: verification.ErrSignatureInvalid, - ErrorMessage: "No .schemapin.sig found in skill directory", - } + return failure("", verification.ErrSignatureInvalid, "No usable .schemapin.sig found in skill directory: "+err.Error()) } } - domain := sig.Domain + toolID := opts.ToolID if toolID == "" { toolID = sig.SkillName if toolID == "" { @@ -401,150 +631,187 @@ func VerifySkillOffline( } } - // Step 1a (v1.4 alpha.3): canonicalization algorithm check. if bad := verification.CheckCanonicalization(sig.Canonicalization); bad != "" { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrCanonicalizationUnsupported, - ErrorMessage: fmt.Sprintf("Unsupported canonicalization algorithm: %s", bad), - } + return failure(opts.Domain, verification.ErrCanonicalizationUnsupported, + fmt.Sprintf("Unsupported canonicalization algorithm: %s", bad)) } - // Step 2: Validate discovery document - if disc == nil || disc.PublicKeyPEM == "" || !strings.Contains(disc.PublicKeyPEM, "-----BEGIN PUBLIC KEY-----") { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrDiscoveryInvalid, - ErrorMessage: "Discovery document missing or invalid public_key_pem", + // Step 2: Domain binding + var warnings []string + var domainName string + if opts.Domain != "" { + normalized, err := domain.ValidateDomain(opts.Domain) + if err != nil { + return failure(opts.Domain, verification.ErrDomainInvalid, err.Error()) + } + domainName = normalized + if strings.ToLower(sig.Domain) != domainName { + return failure(domainName, verification.ErrDomainMismatch, + fmt.Sprintf("signature domain %q does not match expected domain %q", sig.Domain, domainName)) + } + } else { + normalized, err := domain.ValidateDomain(sig.Domain) + if err != nil { + return failure("", verification.ErrDomainInvalid, "signature domain is invalid: "+err.Error()) } + domainName = normalized + warnings = append(warnings, verification.WarningDomainUnbound) } - // Step 3: Extract public key and compute fingerprint + // Step 3: Validate discovery document + if disc == nil || disc.PublicKeyPEM == "" || !strings.Contains(disc.PublicKeyPEM, "-----BEGIN PUBLIC KEY-----") { + return failure(domainName, verification.ErrDiscoveryInvalid, "Discovery document missing or invalid public_key_pem") + } keyManager := crypto.NewKeyManager() publicKey, err := keyManager.LoadPublicKeyPEM(disc.PublicKeyPEM) if err != nil { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrKeyNotFound, - ErrorMessage: fmt.Sprintf("Failed to load public key: %v", err), - } + return failure(domainName, verification.ErrKeyNotFound, fmt.Sprintf("Failed to load public key: %v", err)) } - fingerprint, err := keyManager.CalculateKeyFingerprintFromPEM(disc.PublicKeyPEM) if err != nil { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrKeyNotFound, - ErrorMessage: fmt.Sprintf("Failed to calculate fingerprint: %v", err), - } + return failure(domainName, verification.ErrKeyNotFound, fmt.Sprintf("Failed to calculate fingerprint: %v", err)) } // Step 4: Check revocation if err := revocation.CheckRevocationCombined(disc.RevokedKeys, rev, fingerprint); err != nil { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrKeyRevoked, - ErrorMessage: err.Error(), - } + return failure(domainName, verification.ErrKeyRevoked, err.Error()) } - // Step 5: TOFU key pinning + // Step 5: TOFU pin check (read-only until the signature verifies) var pinResult verification.PinResult if pinStore != nil { - pinResult = pinStore.CheckAndPin(toolID, domain, fingerprint) + pinResult = pinStore.Check(toolID, domainName, fingerprint) if pinResult == verification.PinChanged { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrKeyPinMismatch, - ErrorMessage: "Key fingerprint changed since last use", - } + return failure(domainName, verification.ErrKeyPinMismatch, "Key fingerprint changed since last use") } } - // Step 6: Canonicalize and verify signature - rootHash, _, err := CanonicalizeSkill(skillDir) + // Step 6: Canonicalize and verify + if _, _, ok := versionTuple(sig.SchemapinVersion); !ok { + return failure(domainName, verification.ErrSignatureInvalid, + fmt.Sprintf("unsupported schemapin_version %q", sig.SchemapinVersion)) + } + legacy := isLegacyVersion(sig.SchemapinVersion) + var rootHash []byte + if legacy { + rootHash, _, err = CanonicalizeSkillV1(skillDir) + } else { + rootHash, _, err = CanonicalizeSkill(skillDir) + } if err != nil { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrSchemaCanonicalizationFailed, - ErrorMessage: fmt.Sprintf("Failed to canonicalize skill: %v", err), - } + return failure(domainName, verification.ErrSchemaCanonicalizationFailed, fmt.Sprintf("Failed to canonicalize skill: %v", err)) } sigManager := crypto.NewSignatureManager() - valid := sigManager.VerifySignature(rootHash, sig.Signature, publicKey) - - if !valid { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrSignatureInvalid, - ErrorMessage: "Signature verification failed", + var valid bool + if legacy { + valid = sigManager.VerifySignature(rootHash, sig.Signature, publicKey) + warnings = append(warnings, verification.WarningLegacySignatureFormat) + } else { + if sig.SkillHash != "sha256:"+hex.EncodeToString(rootHash) { + return failure(domainName, verification.ErrSignatureInvalid, "skill_hash does not match the directory contents") + } + input, err := SigningInput(sig) + if err != nil { + return failure(domainName, verification.ErrSignatureInvalid, "malformed signature document: "+err.Error()) } + valid = sigManager.VerifySignature(input, sig.Signature, publicKey) + } + if !valid { + return failure(domainName, verification.ErrSignatureInvalid, "Signature verification failed") } - // Step 7: Return success + // Step 7: Expiration (fail-closed by default) + if warnings == nil { + warnings = []string{} + } result := &verification.VerificationResult{ Valid: true, - Domain: domain, + Domain: domainName, DeveloperName: disc.DeveloperName, - Warnings: []string{}, + Warnings: warnings, + } + result = result.WithExpirationPolicy(sig.ExpiresAt, opts.AllowExpired) + if !result.Valid { + return result } + // Step 8: Pin only now, then surface lineage metadata if pinStore != nil { - result.KeyPinning = &verification.KeyPinningStatus{ - Status: string(pinResult), + if pinResult == verification.PinFirstUse { + pinStore.Pin(toolID, domainName, fingerprint) } + result.KeyPinning = &verification.KeyPinningStatus{Status: string(pinResult)} } - - // v1.4: apply optional signature expiration check. No-op when ExpiresAt - // is empty; otherwise may set Expired/ExpiresAt and append a warning. - result = result.WithExpirationCheck(sig.ExpiresAt) - // v1.4 alpha.2: surface lineage metadata (informational; chain enforcement - // is opt-in via VerifyChain). return result.WithLineageMetadata(sig.SchemaVersion, sig.PreviousHash) } -// VerifySkillWithResolver verifies a signed skill folder using a resolver -// for discovery and revocation. +// VerifySkillWithResolver verifies a signed skill folder using a resolver for +// discovery and revocation. The signature's domain must equal domainName +// (checked before any network I/O) and revocation is fail-closed. func VerifySkillWithResolver( - skillDir, domain string, + skillDir, domainName string, + r resolver.SchemaResolver, + pinStore *verification.KeyPinStore, + toolID string, +) *verification.VerificationResult { + return VerifySkillWithResolverOptions(skillDir, domainName, r, pinStore, toolID, verification.ResolverOptions{}) +} + +// VerifySkillWithResolverOptions is VerifySkillWithResolver with explicit +// policy knobs. +func VerifySkillWithResolverOptions( + skillDir, domainName string, r resolver.SchemaResolver, pinStore *verification.KeyPinStore, toolID string, + ropts verification.ResolverOptions, ) *verification.VerificationResult { - disc, err := r.ResolveDiscovery(domain) + normalized, err := domain.ValidateDomain(domainName) if err != nil { - return &verification.VerificationResult{ - Valid: false, - Domain: domain, - ErrorCode: verification.ErrDiscoveryFetchFailed, - ErrorMessage: fmt.Sprintf("Could not resolve discovery for domain: %s", domain), - } + return failure(domainName, verification.ErrDomainInvalid, err.Error()) + } + domainName = normalized + + sig, err := LoadSignature(skillDir) + if err != nil { + return failure(domainName, verification.ErrSignatureInvalid, "No usable .schemapin.sig found in skill directory: "+err.Error()) + } + if strings.ToLower(sig.Domain) != domainName { + return failure(domainName, verification.ErrDomainMismatch, + fmt.Sprintf("signature domain %q does not match expected domain %q", sig.Domain, domainName)) + } + + disc, err := r.ResolveDiscovery(domainName) + if err != nil { + return failure(domainName, verification.ErrDiscoveryFetchFailed, fmt.Sprintf("Could not resolve discovery for domain: %s", domainName)) } - rev, _ := r.ResolveRevocation(domain, disc) + rev, err := r.ResolveRevocation(domainName, disc) + if err != nil { + if !ropts.AllowUnreachableRevocation { + return failure(domainName, verification.ErrDiscoveryFetchFailed, fmt.Sprintf("Revocation document unreachable (fail-closed): %v", err)) + } + rev = nil + } - return VerifySkillOffline(skillDir, disc, nil, rev, pinStore, toolID) + return VerifySkillOfflineWithOptions(skillDir, disc, sig, rev, pinStore, VerifyOptions{ + Domain: domainName, + ToolID: toolID, + AllowExpired: ropts.AllowExpired, + }) } // DetectTamperedFiles compares a current file manifest against a signed manifest. -// Returns a TamperedFiles struct with sorted Modified, Added, and Removed slices. +// Only meaningful for schemapin_version >= "1.4" documents, whose file_manifest +// is covered by the signature; for legacy 1.3 documents the manifest is +// unsigned and this is a diagnostic aid only. func DetectTamperedFiles(current, signed map[string]string) *TamperedFiles { result := &TamperedFiles{ Modified: []string{}, Added: []string{}, Removed: []string{}, } - - // Find added and modified for k, v := range current { if sv, ok := signed[k]; !ok { result.Added = append(result.Added, k) @@ -552,18 +819,14 @@ func DetectTamperedFiles(current, signed map[string]string) *TamperedFiles { result.Modified = append(result.Modified, k) } } - - // Find removed for k := range signed { if _, ok := current[k]; !ok { result.Removed = append(result.Removed, k) } } - sort.Strings(result.Modified) sort.Strings(result.Added) sort.Strings(result.Removed) - return result } @@ -575,6 +838,9 @@ const ( ChainErrorNoPreviousHash ChainErrorKind = iota + 1 // ChainErrorMismatch indicates current.PreviousHash != previous.SkillHash. ChainErrorMismatch + // ChainErrorLegacy indicates one of the documents is a legacy 1.3 + // signature whose skill_hash is not covered by its signature. + ChainErrorLegacy ) // ChainError is returned by VerifyChain on lineage failure. @@ -593,25 +859,25 @@ func (e *ChainError) Error() string { "previous_hash mismatch: current.previous_hash = %s, previous.skill_hash = %s", e.Got, e.Expected, ) + case ChainErrorLegacy: + return "legacy 1.3 signature documents carry an unsigned skill_hash and cannot anchor a chain" default: return "unknown chain error" } } // VerifyChain verifies that current is the legitimate successor of previous -// via the previous_hash lineage chain (v1.4 alpha.2). +// via the previous_hash lineage chain. // -// Checks current.PreviousHash == previous.SkillHash. -// -// This is a pure-metadata check -- no cryptography is re-evaluated. Both -// signatures must already be cryptographically verified separately via -// VerifySkillOffline for the chain check to be meaningful. -// -// Use this to defend against rug-pull attacks where an attacker substitutes -// a schema/skill out-of-band: a legitimate update declares the prior version's -// hash; an unauthorized substitution either omits previous_hash or points at -// a hash the verifier has not accepted as a valid ancestor. +// Checks current.PreviousHash == previous.SkillHash. This is a pure-metadata +// check -- no cryptography is re-evaluated. Both signatures must already be +// cryptographically verified separately via VerifySkillOffline, and both must +// be schemapin_version >= "1.4" documents so that skill_hash / previous_hash +// are covered by their signatures (legacy 1.3 documents are rejected). func VerifyChain(current, previous *SkillSignature) error { + if isLegacyVersion(current.SchemapinVersion) || isLegacyVersion(previous.SchemapinVersion) { + return &ChainError{Kind: ChainErrorLegacy} + } if current.PreviousHash == "" { return &ChainError{Kind: ChainErrorNoPreviousHash} } diff --git a/go/pkg/skill/skill_test.go b/go/pkg/skill/skill_test.go index 82bed3f..b6358bd 100644 --- a/go/pkg/skill/skill_test.go +++ b/go/pkg/skill/skill_test.go @@ -329,7 +329,7 @@ func TestLoadSignature(t *testing.T) { t.Errorf("expected domain 'example.com', got %q", loaded.Domain) } if loaded.SchemapinVersion != "1.3" { - t.Errorf("expected version '1.3', got %q", loaded.SchemapinVersion) + t.Errorf("expected version '1.3' (legacy documents still load), got %q", loaded.SchemapinVersion) } } @@ -349,8 +349,8 @@ func TestSignCreatesFile(t *testing.T) { if sig.Domain != "example.com" { t.Errorf("expected domain 'example.com', got %q", sig.Domain) } - if sig.SchemapinVersion != "1.3" { - t.Errorf("expected version '1.3', got %q", sig.SchemapinVersion) + if sig.SchemapinVersion != "1.4" { + t.Errorf("expected version '1.4', got %q", sig.SchemapinVersion) } // Check file was written diff --git a/go/pkg/verification/verification.go b/go/pkg/verification/verification.go index 5a396a4..95cd558 100644 --- a/go/pkg/verification/verification.go +++ b/go/pkg/verification/verification.go @@ -4,26 +4,54 @@ package verification import ( "encoding/json" "fmt" + "regexp" "strings" "time" "github.com/ThirdKeyAi/schemapin/go/pkg/core" "github.com/ThirdKeyAi/schemapin/go/pkg/crypto" "github.com/ThirdKeyAi/schemapin/go/pkg/discovery" + "github.com/ThirdKeyAi/schemapin/go/pkg/dns" + "github.com/ThirdKeyAi/schemapin/go/pkg/domain" "github.com/ThirdKeyAi/schemapin/go/pkg/resolver" "github.com/ThirdKeyAi/schemapin/go/pkg/revocation" ) +// ExpirationSkewSeconds is the clock-skew tolerance applied to expires_at +// comparisons. +const ExpirationSkewSeconds = 300 + +var rfc3339Re = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$`) + +// ParseRFC3339 parses a strict RFC 3339 timestamp (T separator, mandatory +// offset). Date-only strings and local times without an offset are rejected. +func ParseRFC3339(value string) (time.Time, bool) { + if !rfc3339Re.MatchString(value) { + return time.Time{}, false + } + ts, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{}, false + } + return ts.UTC(), true +} + // Warning constants for v1.4 signature expiration semantics. const ( // WarningSignatureExpired is appended to VerificationResult.Warnings when - // a signature carried an expires_at field that has passed. The result - // remains Valid (degraded, not failed). + // a signature carried an expires_at field that has passed and the caller + // opted in with allowExpired (degraded, not failed). WarningSignatureExpired = "signature_expired" // WarningSignatureExpiresAtUnparseable is appended when a signature's - // expires_at field could not be parsed as RFC 3339. The result remains - // Valid (fail-open) and is not marked Expired. + // expires_at field could not be parsed as RFC 3339 and the caller opted + // in with allowExpired. WarningSignatureExpiresAtUnparseable = "signature_expires_at_unparseable" + // WarningLegacySignatureFormat is appended when a skill signature uses the + // pre-1.4 root-hash-only format whose metadata fields are unsigned. + WarningLegacySignatureFormat = "legacy_signature_format" + // WarningDomainUnbound is appended when the caller did not bind the + // expected domain and the signature's own domain field was used. + WarningDomainUnbound = "domain_unbound" ) // ErrorCode represents structured error codes for verification results. @@ -51,6 +79,14 @@ const ( // ErrBundleExpired (v1.4) — a signed trust bundle's expires_at is in the // past (or unparseable). ErrBundleExpired ErrorCode = "bundle_expired" + // ErrSignatureExpired — a signature's expires_at is in the past (or + // unparseable) and the caller did not opt in with AllowExpired. Hard failure. + ErrSignatureExpired ErrorCode = "signature_expired" + // ErrDomainInvalid — the caller-supplied domain is not a valid RFC 1123 hostname. + ErrDomainInvalid ErrorCode = "domain_invalid" + // ErrBundleAuthorityUntrusted — a trust bundle's authority key is not + // pre-seeded in the authority store and first-use trust was not allowed. + ErrBundleAuthorityUntrusted ErrorCode = "bundle_authority_untrusted" ) // CanonicalizationV1 is the algorithm identifier (v1.4 alpha.3) for the @@ -110,32 +146,51 @@ type VerificationResult struct { PreviousHash string `json:"previous_hash,omitempty"` } -// WithExpirationCheck applies a v1.4 signature expiration check to a -// successful VerificationResult and returns the (possibly mutated) receiver. +// WithExpirationCheck applies a v1.4 signature expiration check and returns +// the (possibly mutated) receiver. It is fail-closed: see WithExpirationPolicy. +// Equivalent to WithExpirationPolicy(expiresAt, false). +func (r *VerificationResult) WithExpirationCheck(expiresAt string) *VerificationResult { + return r.WithExpirationPolicy(expiresAt, false) +} + +// WithExpirationPolicy applies a signature expiration check: // -// Semantics mirror the Rust reference implementation: // - expiresAt == "" returns the receiver unchanged. -// - Parseable RFC 3339 timestamp in the past sets Expired = true, copies -// ExpiresAt, and appends a "signature_expired" warning. Valid is left -// intact (degraded, not failed). -// - Parseable timestamp in the future just records ExpiresAt. -// - Unparseable input appends a "signature_expires_at_unparseable" warning -// and does not mark the result as expired (fail-open). +// - A value that is not strict RFC 3339 fails with ErrSignatureExpired +// (allowExpired downgrades this to a WarningSignatureExpiresAtUnparseable +// warning). +// - A timestamp more than ExpirationSkewSeconds in the past fails with +// ErrSignatureExpired and sets Expired. With allowExpired the result stays +// Valid but is marked Expired and carries a WarningSignatureExpired +// warning (the pre-1.4.0 "degraded, not failed" behavior). +// - A future timestamp just records ExpiresAt. // // The receiver may be nil; in that case it is returned unchanged. -func (r *VerificationResult) WithExpirationCheck(expiresAt string) *VerificationResult { +func (r *VerificationResult) WithExpirationPolicy(expiresAt string, allowExpired bool) *VerificationResult { if r == nil || expiresAt == "" { return r } - ts, err := time.Parse(time.RFC3339, expiresAt) - if err != nil { - r.Warnings = append(r.Warnings, WarningSignatureExpiresAtUnparseable) + ts, ok := ParseRFC3339(expiresAt) + if !ok { + if allowExpired { + r.Warnings = append(r.Warnings, WarningSignatureExpiresAtUnparseable) + return r + } + r.Valid = false + r.ErrorCode = ErrSignatureExpired + r.ErrorMessage = fmt.Sprintf("expires_at is not a valid RFC 3339 timestamp: %q", expiresAt) return r } r.ExpiresAt = expiresAt - if time.Now().UTC().After(ts.UTC()) { + if time.Now().UTC().After(ts.Add(ExpirationSkewSeconds * time.Second)) { r.Expired = true - r.Warnings = append(r.Warnings, WarningSignatureExpired) + if allowExpired { + r.Warnings = append(r.Warnings, WarningSignatureExpired) + } else { + r.Valid = false + r.ErrorCode = ErrSignatureExpired + r.ErrorMessage = fmt.Sprintf("signature expired at %s", expiresAt) + } } return r } @@ -170,7 +225,11 @@ const ( ) // KeyPinStore is a lightweight in-memory fingerprint-based pin store. -// Keys are stored by tool_id@domain. +// +// Keys are stored by tool_id@domain. Verification flows use Check before the +// signature check and Pin only after it succeeds, so a failed verification +// never leaves a pin behind. Operators can pre-seed expected fingerprints with +// Seed. type KeyPinStore struct { pins map[string]string } @@ -184,12 +243,10 @@ func pinKey(toolID, domain string) string { return toolID + "@" + domain } -// CheckAndPin checks and optionally pins a key fingerprint. -func (s *KeyPinStore) CheckAndPin(toolID, domain, fingerprint string) PinResult { - k := pinKey(toolID, domain) - existing, ok := s.pins[k] +// Check compares fingerprint with the pinned value without mutating the store. +func (s *KeyPinStore) Check(toolID, domain, fingerprint string) PinResult { + existing, ok := s.pins[pinKey(toolID, domain)] if !ok { - s.pins[k] = fingerprint return PinFirstUse } if existing == fingerprint { @@ -198,6 +255,26 @@ func (s *KeyPinStore) CheckAndPin(toolID, domain, fingerprint string) PinResult return PinChanged } +// Pin stores fingerprint for tool@domain (overwrites). +func (s *KeyPinStore) Pin(toolID, domain, fingerprint string) { + s.pins[pinKey(toolID, domain)] = fingerprint +} + +// Seed pre-seeds an expected fingerprint (out-of-band trust establishment). +func (s *KeyPinStore) Seed(toolID, domain, fingerprint string) { + s.Pin(toolID, domain, fingerprint) +} + +// CheckAndPin checks and pins on first use in one step. Prefer Check + Pin so +// that pinning can be deferred until after signature verification. +func (s *KeyPinStore) CheckAndPin(toolID, domain, fingerprint string) PinResult { + result := s.Check(toolID, domain, fingerprint) + if result == PinFirstUse { + s.Pin(toolID, domain, fingerprint) + } + return result +} + // GetPinned returns the pinned fingerprint for a tool@domain, or empty string. func (s *KeyPinStore) GetPinned(toolID, domain string) string { return s.pins[pinKey(toolID, domain)] @@ -218,22 +295,26 @@ func FromJSON(jsonStr string) (*KeyPinStore, error) { if err := json.Unmarshal([]byte(jsonStr), &store.pins); err != nil { return nil, err } + if store.pins == nil { + return nil, fmt.Errorf("pin store JSON must be an object of string fingerprints") + } return store, nil } // VerifySchemaOffline verifies a schema offline using pre-fetched discovery and revocation data. // -// 7-step verification flow: -// 1. Validate discovery document -// 2. Extract public key and compute fingerprint -// 3. Check revocation (both simple list + standalone doc) -// 4. TOFU key pinning check -// 5. Canonicalize schema and compute hash -// 6. Verify ECDSA signature against hash -// 7. Return structured result +// Verification flow: +// 1. Validate the canonicalization identifier and the domain syntax +// 2. Validate discovery document +// 3. Extract public key and compute fingerprint +// 4. Check revocation (both simple list + standalone doc) +// 5. TOFU pin check (read-only) +// 6. Canonicalize schema, compute hash, verify ECDSA signature +// 7. Pin the key on first use (only after a successful verification) +// 8. Return structured result // // Use VerifySchemaOfflineWithCanonicalization when the signature carries -// a v1.4 alpha.3 canonicalization algorithm identifier; this thin wrapper +// a v1.4 canonicalization algorithm identifier; this thin wrapper // passes the empty string (== default schemapin-v1). func VerifySchemaOffline( schema map[string]interface{}, @@ -250,7 +331,7 @@ func VerifySchemaOffline( } // VerifySchemaOfflineWithCanonicalization is VerifySchemaOffline plus the -// optional v1.4 alpha.3 canonicalization algorithm parameter. +// optional v1.4 canonicalization algorithm parameter. // // `canonicalization` mirrors the optional "canonicalization" field on // .schemapin.sig documents. Empty string or "schemapin-v1" are equivalent @@ -259,40 +340,50 @@ func VerifySchemaOffline( func VerifySchemaOfflineWithCanonicalization( schema map[string]interface{}, signatureB64 string, - domain string, + domainName string, toolID string, disc *discovery.WellKnownResponse, rev *revocation.RevocationDocument, pinStore *KeyPinStore, canonicalization string, ) *VerificationResult { - // Step 0 (v1.4 alpha.3): canonicalization algorithm check. + // Step 1: canonicalization algorithm + domain syntax. if bad := CheckCanonicalization(canonicalization); bad != "" { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, ErrorCode: ErrCanonicalizationUnsupported, ErrorMessage: fmt.Sprintf("Unsupported canonicalization algorithm: %s", bad), } } + normalized, err := domain.ValidateDomain(domainName) + if err != nil { + return &VerificationResult{ + Valid: false, + Domain: domainName, + ErrorCode: ErrDomainInvalid, + ErrorMessage: err.Error(), + } + } + domainName = normalized - // Step 1: Validate discovery document + // Step 2: Validate discovery document if disc == nil || disc.PublicKeyPEM == "" || !strings.Contains(disc.PublicKeyPEM, "-----BEGIN PUBLIC KEY-----") { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, ErrorCode: ErrDiscoveryInvalid, ErrorMessage: "Discovery document missing or invalid public_key_pem", } } - // Step 2: Extract public key and compute fingerprint + // Step 3: Extract public key and compute fingerprint keyManager := crypto.NewKeyManager() publicKey, err := keyManager.LoadPublicKeyPEM(disc.PublicKeyPEM) if err != nil { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, ErrorCode: ErrKeyNotFound, ErrorMessage: fmt.Sprintf("Failed to load public key: %v", err), } @@ -302,62 +393,69 @@ func VerifySchemaOfflineWithCanonicalization( if err != nil { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, ErrorCode: ErrKeyNotFound, ErrorMessage: fmt.Sprintf("Failed to calculate fingerprint: %v", err), } } - // Step 3: Check revocation + // Step 4: Check revocation if err := revocation.CheckRevocationCombined(disc.RevokedKeys, rev, fingerprint); err != nil { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, ErrorCode: ErrKeyRevoked, ErrorMessage: err.Error(), } } - // Step 4: TOFU key pinning - pinResult := pinStore.CheckAndPin(toolID, domain, fingerprint) + // Step 5: TOFU pin check (no write yet) + if pinStore == nil { + pinStore = NewKeyPinStore() + } + pinResult := pinStore.Check(toolID, domainName, fingerprint) if pinResult == PinChanged { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, ErrorCode: ErrKeyPinMismatch, ErrorMessage: "Key fingerprint changed since last use", } } - // Step 5: Canonicalize and hash + // Step 6: Canonicalize, hash, verify c := core.NewSchemaPinCore() schemaHash, err := c.CanonicalizeAndHash(schema) if err != nil { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, ErrorCode: ErrSchemaCanonicalizationFailed, ErrorMessage: fmt.Sprintf("Failed to canonicalize schema: %v", err), } } - // Step 6: Verify signature sigManager := crypto.NewSignatureManager() valid := sigManager.VerifySchemaSignature(schemaHash, signatureB64, publicKey) if !valid { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, ErrorCode: ErrSignatureInvalid, ErrorMessage: "Signature verification failed", } } - // Step 7: Return success + // Step 7: Pin only after the signature verified + if pinResult == PinFirstUse { + pinStore.Pin(toolID, domainName, fingerprint) + } + + // Step 8: Return success result := &VerificationResult{ Valid: true, - Domain: domain, + Domain: domainName, DeveloperName: disc.DeveloperName, KeyPinning: &KeyPinningStatus{ Status: string(pinResult), @@ -373,28 +471,106 @@ func VerifySchemaOfflineWithCanonicalization( return result } +// VerifySchemaOfflineWithDNS is VerifySchemaOffline plus a DNS TXT +// second-channel check. When txt is non-nil the discovery key's fingerprint +// must match the TXT record or the result is a hard ErrDomainMismatch failure. +// The DNS check runs before anything is pinned. +func VerifySchemaOfflineWithDNS( + schema map[string]interface{}, + signatureB64 string, + domainName string, + toolID string, + disc *discovery.WellKnownResponse, + rev *revocation.RevocationDocument, + pinStore *KeyPinStore, + txt *dns.DnsTxtRecord, + canonicalization string, +) *VerificationResult { + if txt != nil { + if err := dns.VerifyDnsMatch(disc, txt); err != nil { + return &VerificationResult{ + Valid: false, + Domain: domainName, + ErrorCode: ErrDomainMismatch, + ErrorMessage: err.Error(), + } + } + } + return VerifySchemaOfflineWithCanonicalization(schema, signatureB64, domainName, toolID, disc, rev, pinStore, canonicalization) +} + // VerifySchemaWithResolver verifies a schema using a resolver for discovery and revocation. +// +// Revocation is fail-closed: if the discovery document declares a +// revocation_endpoint that cannot be fetched, verification fails with +// ErrDiscoveryFetchFailed. Use VerifySchemaWithResolverOptions to opt out. func VerifySchemaWithResolver( schema map[string]interface{}, signatureB64 string, - domain string, + domainName string, toolID string, r resolver.SchemaResolver, pinStore *KeyPinStore, ) *VerificationResult { - disc, err := r.ResolveDiscovery(domain) + return VerifySchemaWithResolverOptions(schema, signatureB64, domainName, toolID, r, pinStore, ResolverOptions{}) +} + +// ResolverOptions tunes the resolver-based verification flows. +type ResolverOptions struct { + // AllowUnreachableRevocation treats a revocation fetch failure as "no + // revocations" instead of failing closed. + AllowUnreachableRevocation bool + // AllowExpired accepts expired skill signatures (degraded) instead of + // failing with ErrSignatureExpired. Ignored for schema signatures. + AllowExpired bool +} + +// VerifySchemaWithResolverOptions is VerifySchemaWithResolver with explicit +// policy knobs. +func VerifySchemaWithResolverOptions( + schema map[string]interface{}, + signatureB64 string, + domainName string, + toolID string, + r resolver.SchemaResolver, + pinStore *KeyPinStore, + opts ResolverOptions, +) *VerificationResult { + normalized, err := domain.ValidateDomain(domainName) if err != nil { return &VerificationResult{ Valid: false, - Domain: domain, + Domain: domainName, + ErrorCode: ErrDomainInvalid, + ErrorMessage: err.Error(), + } + } + domainName = normalized + + disc, err := r.ResolveDiscovery(domainName) + if err != nil { + return &VerificationResult{ + Valid: false, + Domain: domainName, ErrorCode: ErrDiscoveryFetchFailed, - ErrorMessage: fmt.Sprintf("Could not resolve discovery for domain: %s", domain), + ErrorMessage: fmt.Sprintf("Could not resolve discovery for domain: %s", domainName), } } - rev, _ := r.ResolveRevocation(domain, disc) + rev, err := r.ResolveRevocation(domainName, disc) + if err != nil { + if !opts.AllowUnreachableRevocation { + return &VerificationResult{ + Valid: false, + Domain: domainName, + ErrorCode: ErrDiscoveryFetchFailed, + ErrorMessage: fmt.Sprintf("Revocation document unreachable (fail-closed): %v", err), + } + } + rev = nil + } - return VerifySchemaOffline(schema, signatureB64, domain, toolID, disc, rev, pinStore) + return VerifySchemaOffline(schema, signatureB64, domainName, toolID, disc, rev, pinStore) } // VerifySchemaForA2A verifies a schema in the context of an A2A interaction diff --git a/javascript/src/index.js b/javascript/src/index.js index 709b1f6..954badd 100644 --- a/javascript/src/index.js +++ b/javascript/src/index.js @@ -2,7 +2,9 @@ * SchemaPin: Cryptographic schema integrity verification for AI tools. */ -export { SchemaPinCore } from './core.js'; +export { SchemaPinCore, CanonicalizationError, compareCodePoints } from './core.js'; +export { InvalidDomainError, validateDomain, isValidDomain } from './domain.js'; +export { TransportError, fetchJson, requireHttps, MAX_RESPONSE_BYTES } from './transport.js'; export { KeyManager, SignatureManager } from './crypto.js'; export { PublicKeyDiscovery } from './discovery.js'; export { KeyPinning, PinningMode, PinningPolicy } from './pinning.js'; @@ -25,6 +27,7 @@ export { // v1.2.0 new modules export { RevocationReason, + RevocationFetchError, buildRevocationDocument, addRevokedKey, checkRevocation, @@ -58,8 +61,11 @@ export { ErrorCode, KeyPinStore, verifySchemaOffline, + verifySchemaOfflineWithDns, verifySchemaWithResolver, applyExpirationCheck, + parseRfc3339, + EXPIRATION_SKEW_SECONDS, // v1.4 alpha.3 CANONICALIZATION_V1, checkCanonicalization, @@ -78,7 +84,14 @@ export { // v1.3.0 new module export { SIGNATURE_FILENAME, + SCHEMAPIN_VERSION, + SCHEMAPIN_VERSION_LEGACY, + SkillCanonicalizationError, + ChainError, + verifyChain, + signingInput, canonicalizeSkill, + canonicalizeSkillV1, parseSkillName, loadSignature, signSkill, diff --git a/javascript/src/skill.js b/javascript/src/skill.js index 68fb651..667943f 100644 --- a/javascript/src/skill.js +++ b/javascript/src/skill.js @@ -5,26 +5,67 @@ * (AgentSkills spec). Same keys, same .well-known discovery, new canonicalization * target. * - * v1.4 additions (additive, optional, backward-compatible): - * - `expires_at` field on `.schemapin.sig` via {@link signSkillWithOptions}. - * - DNS TXT cross-verification via {@link verifySkillOfflineWithDns}. + * ## Wire formats + * + * `schemapin_version: "1.4"` (the default for every new signature): + * + * - The directory is canonicalized with {@link canonicalizeSkill} (skill + * canonicalization v2, spec section 4b): every entry is hashed as + * `SHA-256("f" || be64(len(path)) || path || be64(len(content)) || content)` + * and the root hash is SHA-256 over `be64(len(path)) || path || digest` for + * every entry in code-point path order. Symlinks are rejected. + * - `skill_hash` is `"sha256:" + hex(root_hash)`. + * - The signature covers the **entire signature document**: the document is + * built with `signature: ""`, canonicalized with `schemapin-v1` and signed + * as `ECDSA-P256(SHA-256(canonical_bytes))`. `expires_at`, `schema_version`, + * `previous_hash`, `canonicalization`, `domain`, `skill_name`, `signer_kid`, + * `signed_at` and `file_manifest` are therefore all integrity-protected. + * + * `schemapin_version: "1.3"` (legacy, verify-only): + * + * - Entries hashed as `SHA-256(path || content)`, root hash SHA-256 of the + * concatenated hex digests, symlinks silently skipped, and the signature + * covers only the 32-byte root hash. None of the metadata fields are + * signed; verifiers surface a `legacy_signature_format` warning. + * + * Verification is fail-closed on expiration and on domain mismatch. */ import { createHash, createPrivateKey, createPublicKey } from 'node:crypto'; import { readFileSync, readdirSync, writeFileSync, lstatSync, existsSync } from 'node:fs'; import { join, relative, basename, resolve } from 'node:path'; +import { SchemaPinCore, compareCodePoints } from './core.js'; import { KeyManager, SignatureManager } from './crypto.js'; -import { checkRevocationCombined } from './revocation.js'; -import { ErrorCode, applyExpirationCheck, checkCanonicalization } from './verification.js'; +import { InvalidDomainError, validateDomain } from './domain.js'; +import { checkRevocationCombined, RevocationFetchError } from './revocation.js'; +import { ErrorCode, EXPIRATION_SKEW_SECONDS, applyExpirationCheck, checkCanonicalization } from './verification.js'; import { verifyDnsMatch } from './dns.js'; export const SIGNATURE_FILENAME = '.schemapin.sig'; -const SCHEMAPIN_VERSION_V13 = '1.3'; -const SCHEMAPIN_VERSION_V14 = '1.4'; +/** `schemapin_version` written on every new signature (signed-document format). */ +export const SCHEMAPIN_VERSION = '1.4'; +/** Legacy root-hash-only format; accepted on verification, never written. */ +export const SCHEMAPIN_VERSION_LEGACY = '1.3'; + +/** Caps enforced by {@link canonicalizeSkill}. */ +export const MAX_SKILL_FILES = 10000; +export const MAX_SKILL_TOTAL_BYTES = 256 * 1024 * 1024; + +const REQUIRED_FIELDS = ['schemapin_version', 'skill_name', 'skill_hash', 'signature', 'signed_at', 'domain', 'signer_kid', 'file_manifest']; +const OPTIONAL_FIELDS = ['expires_at', 'schema_version', 'previous_hash', 'canonicalization']; + +/** + * Thrown when a skill directory cannot be canonicalized. + */ +export class SkillCanonicalizationError extends Error { + constructor(message) { + super(message); + this.name = 'SkillCanonicalizationError'; + } +} /** * Format a Date as RFC 3339 UTC with seconds precision and `Z` suffix. - * Mirrors Rust's `chrono::SecondsFormat::Secs` rendering. * * @param {Date} date * @returns {string} e.g. `2026-04-30T21:41:50Z` @@ -34,16 +75,119 @@ function toRfc3339Seconds(date) { return iso.replace(/\.\d{3}Z$/, 'Z'); } +function be64(n) { + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(BigInt(n)); + return buf; +} + +function versionTuple(version) { + if (typeof version !== 'string') { + return null; + } + const m = /^(\d+)\.(\d+)$/.exec(version); + return m ? [Number(m[1]), Number(m[2])] : null; +} + +function isLegacyVersion(version) { + const t = versionTuple(version); + return t !== null && (t[0] < 1 || (t[0] === 1 && t[1] < 4)); +} + /** - * Recursively walk a directory in sorted order, collecting file entries. + * Recursive walk for skill canonicalization v2. * - * @param {string} dir - Current directory to walk - * @param {string} baseDir - Root skill directory for computing relative paths - * @param {Object} manifest - Accumulator for manifest entries + * @param {string} dir + * @param {string} baseDir + * @param {Array<[string, Buffer]>} entries + * @param {{ total: number }} state */ -function walkSorted(dir, baseDir, manifest) { +function walkV2(dir, baseDir, entries, state) { + const names = readdirSync(dir); + for (const name of names) { + const fullPath = join(dir, name); + const stats = lstatSync(fullPath); + if (stats.isSymbolicLink()) { + throw new SkillCanonicalizationError(`symlink not permitted in skill directory: ${fullPath}`); + } + if (stats.isDirectory()) { + walkV2(fullPath, baseDir, entries, state); + continue; + } + if (!stats.isFile()) { + throw new SkillCanonicalizationError(`non-regular file in skill directory: ${fullPath}`); + } + if (dir === baseDir && name === SIGNATURE_FILENAME) { + continue; + } + const relPath = relative(baseDir, fullPath).split('\\').join('/'); + const pathBytes = Buffer.from(relPath, 'utf-8'); + if (pathBytes.toString('utf-8') !== relPath || relPath.includes('\uFFFD')) { + throw new SkillCanonicalizationError(`path is not valid UTF-8: ${relPath}`); + } + const data = readFileSync(fullPath); + state.total += data.length; + if (entries.length + 1 > MAX_SKILL_FILES) { + throw new SkillCanonicalizationError(`skill exceeds ${MAX_SKILL_FILES} files`); + } + if (state.total > MAX_SKILL_TOTAL_BYTES) { + throw new SkillCanonicalizationError(`skill exceeds ${MAX_SKILL_TOTAL_BYTES} bytes`); + } + const digest = createHash('sha256') + .update('f') + .update(be64(pathBytes.length)) + .update(pathBytes) + .update(be64(data.length)) + .update(data) + .digest(); + entries.push([relPath, digest]); + } +} + +/** + * Canonicalize a skill directory deterministically and compute the v2 root hash. + * + * Algorithm (spec section 4b): + * 1. Walk the tree; directories are not entries. The top-level + * `.schemapin.sig` is excluded. + * 2. Any symlink (file or directory) or non-regular file aborts + * canonicalization -- unsigned content must not be reachable from a + * signed skill. + * 3. Paths are relative, forward-slash separated, UTF-8, sorted by code point. + * 4. Per entry: `SHA-256(0x66 || be64(len(path)) || path || be64(len(content)) || content)`. + * 5. Root: SHA-256 over `be64(len(path)) || path || entry_digest` for every + * entry in sorted order. + * + * @param {string} skillDir - Path to skill directory + * @returns {{ rootHash: Buffer, manifest: Object }} Root hash and file manifest + * @throws {SkillCanonicalizationError} + */ +export function canonicalizeSkill(skillDir) { + const skillPath = resolve(skillDir); + const entries = []; + walkV2(skillPath, skillPath, entries, { total: 0 }); + + if (entries.length === 0) { + throw new SkillCanonicalizationError(`Skill directory is empty or contains no signable files: ${skillDir}`); + } + entries.sort((a, b) => compareCodePoints(a[0], b[0])); + + const root = createHash('sha256'); + const manifest = {}; + for (const [relPath, digest] of entries) { + const pathBytes = Buffer.from(relPath, 'utf-8'); + root.update(be64(pathBytes.length)).update(pathBytes).update(digest); + manifest[relPath] = `sha256:${digest.toString('hex')}`; + } + return { rootHash: root.digest(), manifest }; +} + +/** + * Legacy (`schemapin_version: "1.3"`) walk: files first, then directories, + * symlinks and `.schemapin.sig` skipped at any depth. + */ +function walkV1(dir, baseDir, manifest) { const entries = readdirSync(dir, { withFileTypes: true }); - // Separate dirs and files, sort each by name const dirs = []; const files = []; for (const entry of entries) { @@ -56,73 +200,52 @@ function walkSorted(dir, baseDir, manifest) { dirs.sort(); files.sort(); - // Process files first (matching Python os.walk behavior within each directory) for (const fname of files) { if (fname === SIGNATURE_FILENAME) { continue; } const fullPath = join(dir, fname); - // Skip symlinks - const lstats = lstatSync(fullPath); - if (lstats.isSymbolicLink()) { + if (lstatSync(fullPath).isSymbolicLink()) { continue; } const relPath = relative(baseDir, fullPath).split('\\').join('/'); - const fileBytes = readFileSync(fullPath); - const pathBytes = Buffer.from(relPath, 'utf-8'); const digest = createHash('sha256') - .update(pathBytes) - .update(fileBytes) + .update(Buffer.from(relPath, 'utf-8')) + .update(readFileSync(fullPath)) .digest('hex'); manifest[relPath] = `sha256:${digest}`; } - // Recurse into subdirectories in sorted order for (const dname of dirs) { const subdir = join(dir, dname); - // Skip symlinked directories - const lstats = lstatSync(subdir); - if (lstats.isSymbolicLink()) { + if (lstatSync(subdir).isSymbolicLink()) { continue; } - walkSorted(subdir, baseDir, manifest); + walkV1(subdir, baseDir, manifest); } } /** - * Canonicalize a skill directory deterministically and compute a root hash. + * Legacy (`schemapin_version: "1.3"`) skill canonicalization. * - * Algorithm: - * 1. Recursive sorted walk via readdirSync - * 2. Skip .schemapin.sig and symlinks - * 3. Normalize paths to forward slashes - * 4. Per-file: sha256(rel_path_utf8 + file_bytes).hexdigest() - * 5. Root: sha256(concat of all hexdigests, sorted by rel_path).digest() + * Kept only so that pre-1.4 signatures still verify. Per-file digest is + * `SHA-256(path || content)`, the root hash is SHA-256 of the concatenated + * hex digests, symlinks are skipped and `.schemapin.sig` is excluded at any + * depth. Do not use for new signatures. * - * @param {string} skillDir - Path to skill directory - * @returns {{ rootHash: Buffer, manifest: Object }} Root hash and file manifest - * @throws {Error} If directory is empty or contains no signable files + * @param {string} skillDir + * @returns {{ rootHash: Buffer, manifest: Object }} */ -export function canonicalizeSkill(skillDir) { +export function canonicalizeSkillV1(skillDir) { const skillPath = resolve(skillDir); const manifest = {}; - - walkSorted(skillPath, skillPath, manifest); - + walkV1(skillPath, skillPath, manifest); if (Object.keys(manifest).length === 0) { - throw new Error(`Skill directory is empty or contains no signable files: ${skillDir}`); + throw new SkillCanonicalizationError(`Skill directory is empty or contains no signable files: ${skillDir}`); } - - // Root hash: sort manifest keys, extract hex digests, concatenate, sha256 - const sortedKeys = Object.keys(manifest).sort(); - const sortedDigests = sortedKeys.map(k => { - const parts = manifest[k].split(':'); - return parts.slice(1).join(':'); - }); - const rootHash = createHash('sha256') - .update(sortedDigests.join(''), 'utf-8') - .digest(); - + const sortedKeys = Object.keys(manifest).sort(compareCodePoints); + const sortedDigests = sortedKeys.map(k => manifest[k].slice('sha256:'.length)); + const rootHash = createHash('sha256').update(sortedDigests.join(''), 'utf-8').digest(); return { rootHash, manifest }; } @@ -163,7 +286,7 @@ export function parseSkillName(skillDir) { * * @param {string} skillDir - Path to skill directory * @returns {Object} Parsed signature document - * @throws {Error} If .schemapin.sig does not exist + * @throws {Error} If .schemapin.sig does not exist or is not a JSON object */ export function loadSignature(skillDir) { const sigPath = join(resolve(skillDir), SIGNATURE_FILENAME); @@ -171,14 +294,58 @@ export function loadSignature(skillDir) { throw new Error(`Signature file not found: ${sigPath}`); } const text = readFileSync(sigPath, 'utf-8'); - return JSON.parse(text); + const data = JSON.parse(text); + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('.schemapin.sig must contain a JSON object'); + } + return data; +} + +/** + * Build the canonical bytes a v1.4 signature covers. + * + * Takes the known signature fields (unknown fields are ignored and therefore + * unprotected), drops absent / empty optional fields, forces `signature` to + * `""` and canonicalizes with `schemapin-v1`. + * + * @param {Object} signatureData + * @returns {Buffer} + * @throws {Error} if a required field is missing or has the wrong type + */ +export function signingInput(signatureData) { + const doc = {}; + for (const key of REQUIRED_FIELDS) { + if (key === 'signature') { + continue; + } + const value = signatureData[key]; + if (key === 'file_manifest') { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('file_manifest must be an object'); + } + } else if (typeof value !== 'string') { + throw new Error(`signature document field ${JSON.stringify(key)} is missing`); + } + doc[key] = value; + } + for (const key of OPTIONAL_FIELDS) { + const value = signatureData[key]; + if (value === null || value === undefined || value === '') { + continue; + } + if (typeof value !== 'string') { + throw new Error(`signature document field ${JSON.stringify(key)} must be a string`); + } + doc[key] = value; + } + doc.signature = ''; + return Buffer.from(SchemaPinCore.canonicalizeSchema(doc), 'utf-8'); } /** * Canonicalize a skill directory, sign, and write .schemapin.sig. * - * v1.3 entry point. Preserved for backward compatibility — internally this is - * a thin wrapper over {@link signSkillWithOptions}. + * Thin wrapper over {@link signSkillWithOptions}. * * @param {string} skillDir - Path to the skill folder * @param {string} privateKeyPem - PEM-encoded ECDSA P-256 private key @@ -195,15 +362,17 @@ export function signSkill(skillDir, privateKeyPem, domain, signerKid = null, ski } /** - * v1.4 alpha.2 chain check error. + * Chain check error. * * Subclass of Error. Distinguish via the `kind` property: * - `'no_previous_hash'` — current signature has no `previous_hash` field * - `'mismatch'` — current.previous_hash !== previous.skill_hash + * - `'legacy'` — one of the documents is a legacy 1.3 signature whose + * `skill_hash` is not covered by its signature */ export class ChainError extends Error { /** - * @param {string} kind - 'no_previous_hash' | 'mismatch' + * @param {string} kind - 'no_previous_hash' | 'mismatch' | 'legacy' * @param {string} message */ constructor(kind, message) { @@ -215,24 +384,26 @@ export class ChainError extends Error { /** * Verify that `current` is the legitimate successor of `previous` via the - * `previous_hash` lineage chain (v1.4 alpha.2). + * `previous_hash` lineage chain. * * Checks `current.previous_hash === previous.skill_hash`. * * This is a pure-metadata check — no cryptography is re-evaluated. Both * signatures must already be cryptographically verified separately via - * {@link verifySkillOffline} for the chain check to be meaningful. - * - * Use this to defend against rug-pull attacks where an attacker substitutes - * a schema/skill out-of-band: a legitimate update declares the prior version's - * hash; an unauthorized substitution either omits `previous_hash` or points - * at a hash the verifier has not accepted as a valid ancestor. + * {@link verifySkillOffline}, and both must be `schemapin_version >= "1.4"` + * documents so that `skill_hash` / `previous_hash` are covered by their + * signatures (legacy 1.3 documents are rejected). * * @param {Object} current - The new signature dict (must have `previous_hash`) * @param {Object} previous - The prior signature dict (must have `skill_hash`) - * @throws {ChainError} On chain mismatch or missing `previous_hash` + * @throws {ChainError} */ export function verifyChain(current, previous) { + for (const [label, doc] of [['current', current], ['previous', previous]]) { + if (isLegacyVersion(doc.schemapin_version)) { + throw new ChainError('legacy', `${label} signature is a legacy 1.3 document; its skill_hash is unsigned`); + } + } const claimed = current.previous_hash; if (claimed === null || claimed === undefined) { throw new ChainError( @@ -250,47 +421,41 @@ export function verifyChain(current, previous) { } /** - * Canonicalize a skill directory, sign, and write .schemapin.sig with extended - * options (v1.4+). + * Canonicalize a skill directory, sign, and write a v1.4 signature document. * - * When `options.expiresIn` is set (milliseconds), an `expires_at` ISO 8601 - * timestamp (RFC 3339 with `Z` suffix, second precision) is written and the - * `schemapin_version` field is bumped from `"1.3"` to `"1.4"`. Verifiers past - * that timestamp emit a `signature_expired` warning instead of failing — see - * {@link verifySkillOffline}. + * The whole document (including `expires_at` when `options.expiresIn` is + * set, written as RFC 3339 `Z` with second precision) is covered by the + * signature. See the module docs. * * Options: * - `signerKid` - Override the signer key id (KID). Defaults to the * discovery fingerprint of the public key. * - `skillName` - Override the skill name. Defaults to the SKILL.md * frontmatter `name:` or directory basename. - * - `expiresIn` - Time-to-live for the signature, in milliseconds. When set, - * an `expires_at` field is written. + * - `expiresIn` - Time-to-live for the signature, in milliseconds. + * - `schemaVersion` - semver string identifying this version of the artifact. + * - `previousHash` - `sha256:` of the prior version's `skill_hash`. + * - `canonicalization` - algorithm identifier (`'schemapin-v1'`). * * @param {string} skillDir - Path to the skill folder * @param {string} privateKeyPem - PEM-encoded ECDSA P-256 private key * @param {string} domain - Signing domain - * @param {{ signerKid?: string|null, skillName?: string|null, expiresIn?: number|null }} [options] + * @param {{ signerKid?: string|null, skillName?: string|null, expiresIn?: number|null, schemaVersion?: string|null, previousHash?: string|null, canonicalization?: string|null }} [options] * @returns {Object} The signature document that was written + * @throws {InvalidDomainError|SkillCanonicalizationError} */ export function signSkillWithOptions(skillDir, privateKeyPem, domain, options = {}) { const { signerKid: optSignerKid = null, skillName: optSkillName = null, expiresIn = null, - // v1.4 alpha.2: caller-supplied semver string identifying *this* version - // of the signed artifact. Surfaced via VerificationResult for policy use. schemaVersion = null, - // v1.4 alpha.2: `sha256:` of the prior signed version's - // `skill_hash`, forming a hash chain. Pair with `verifyChain`. previousHash = null, - // v1.4 alpha.3: canonicalization algorithm identifier written to the - // signature. `null` is wire-equivalent to the implicit "schemapin-v1" - // default; pass `'schemapin-v1'` to declare it explicitly. canonicalization = null } = options; const skillPath = resolve(skillDir); + domain = validateDomain(domain); const privateKey = KeyManager.loadPrivateKeyPem(privateKeyPem); // Derive public key PEM from private key @@ -308,30 +473,17 @@ export function signSkillWithOptions(skillDir, privateKeyPem, domain, options = ? KeyManager.calculateKeyFingerprint(publicKeyPem) : optSignerKid; - const signatureB64 = SignatureManager.signHash(rootHash, privateKey); - const now = new Date(); let expiresAt = null; if (expiresIn !== null && expiresIn !== undefined) { expiresAt = toRfc3339Seconds(new Date(now.getTime() + expiresIn)); } - // Any v1.4 optional field bumps the version stamp; pure v1.3 sigs stay - // "1.3" for byte-stable backward compatibility. - const usesV14Field = - expiresAt !== null - || (schemaVersion !== null && schemaVersion !== undefined) - || (previousHash !== null && previousHash !== undefined) - || (canonicalization !== null && canonicalization !== undefined); - const version = usesV14Field ? SCHEMAPIN_VERSION_V14 : SCHEMAPIN_VERSION_V13; - - // Build the document in canonical field order. Optional v1.4 fields slot - // between `signed_at` and `domain`. const ordered = { - schemapin_version: version, + schemapin_version: SCHEMAPIN_VERSION, skill_name: skillName, skill_hash: `sha256:${rootHash.toString('hex')}`, - signature: signatureB64, + signature: '', signed_at: toRfc3339Seconds(now) }; if (expiresAt !== null) { @@ -350,22 +502,34 @@ export function signSkillWithOptions(skillDir, privateKeyPem, domain, options = ordered.signer_kid = signerKid; ordered.file_manifest = manifest; + ordered.signature = SignatureManager.signHash(signingInput(ordered), privateKey); + const sigPath = join(skillPath, SIGNATURE_FILENAME); writeFileSync(sigPath, JSON.stringify(ordered, null, 2) + '\n', 'utf-8'); return ordered; } +function failure(domain, code, message) { + return { valid: false, domain, error_code: code, error_message: message }; +} + /** - * Verify a signed skill folder offline (7-step flow). + * Verify a signed skill folder offline. * - * Mirrors verifySchemaOffline(): - * 1. Load or accept signature data - * 2. Validate discovery document - * 3. Extract public key and compute fingerprint + * Steps: + * 1. Load or accept signature data; check `canonicalization` + * 2. Bind the domain: when `expectedDomain` is given it must equal the + * signature's `domain` (`DOMAIN_MISMATCH` otherwise). Pass it whenever + * you know which domain the discovery document belongs to -- the + * signature's own `domain` field is display-only. + * 3. Validate discovery document, load key, compute fingerprint * 4. Check revocation - * 5. TOFU key pinning - * 6. Canonicalize skill and verify ECDSA signature - * 7. Return structured result + * 5. TOFU pin check (read-only) + * 6. Recompute the root hash, require `skill_hash` to match, verify the + * signature (over the whole document for 1.4, over the root hash for + * legacy 1.3) + * 7. Expiration check (fail-closed unless `allowExpired`) + * 8. Pin on first use, return the result with lineage metadata * * @param {string} skillDir - Path to skill directory * @param {Object} discovery - Well-known discovery document @@ -373,63 +537,76 @@ export function signSkillWithOptions(skillDir, privateKeyPem, domain, options = * @param {Object|null} revocationDoc - Standalone revocation document * @param {KeyPinStore|null} pinStore - TOFU pin store * @param {string|null} toolId - Tool identifier for pinning + * @param {string|null} [expectedDomain=null] - Domain to bind the skill to + * @param {boolean} [allowExpired=false] - Accept expired signatures (degraded) * @returns {Object} Verification result */ -export function verifySkillOffline(skillDir, discovery, signatureData = null, revocationDoc = null, pinStore = null, toolId = null) { +export function verifySkillOffline(skillDir, discovery, signatureData = null, revocationDoc = null, pinStore = null, toolId = null, expectedDomain = null, allowExpired = false) { const skillPath = resolve(skillDir); // Step 1: Load signature data if (signatureData === null || signatureData === undefined) { try { signatureData = loadSignature(skillPath); - } catch { + } catch (e) { return { valid: false, error_code: ErrorCode.SIGNATURE_INVALID, - error_message: 'No .schemapin.sig found in skill directory' + error_message: `No usable .schemapin.sig found in skill directory: ${e.message}` }; } } - const domain = signatureData.domain || ''; + const sigDomain = signatureData.domain; if (toolId === null || toolId === undefined) { toolId = signatureData.skill_name || basename(skillPath); } - // Step 1a (v1.4 alpha.3): canonicalization algorithm check. const unsupportedAlgo = checkCanonicalization(signatureData.canonicalization); if (unsupportedAlgo !== null) { - return { - valid: false, - domain, - error_code: ErrorCode.CANONICALIZATION_UNSUPPORTED, - error_message: `Unsupported canonicalization algorithm: ${unsupportedAlgo}` - }; + return failure(expectedDomain, ErrorCode.CANONICALIZATION_UNSUPPORTED, `Unsupported canonicalization algorithm: ${unsupportedAlgo}`); + } + + // Step 2: Domain binding + const warnings = []; + let domain; + if (expectedDomain !== null && expectedDomain !== undefined) { + try { + domain = validateDomain(expectedDomain); + } catch (e) { + if (e instanceof InvalidDomainError) { + return failure(expectedDomain, ErrorCode.DOMAIN_INVALID, e.message); + } + throw e; + } + if (typeof sigDomain !== 'string' || sigDomain.toLowerCase() !== domain) { + return failure(domain, ErrorCode.DOMAIN_MISMATCH, + `signature domain ${JSON.stringify(sigDomain)} does not match expected domain ${JSON.stringify(domain)}`); + } + } else { + try { + domain = validateDomain(typeof sigDomain === 'string' ? sigDomain : ''); + } catch (e) { + if (e instanceof InvalidDomainError) { + return { valid: false, error_code: ErrorCode.DOMAIN_INVALID, error_message: `signature domain is invalid: ${e.message}` }; + } + throw e; + } + warnings.push('domain_unbound'); } - // Step 2: Validate discovery document + // Step 3: Validate discovery document const publicKeyPem = discovery?.public_key_pem; if (!publicKeyPem || !publicKeyPem.includes('-----BEGIN PUBLIC KEY-----')) { - return { - valid: false, - domain, - error_code: ErrorCode.DISCOVERY_INVALID, - error_message: 'Discovery document missing or invalid public_key_pem' - }; + return failure(domain, ErrorCode.DISCOVERY_INVALID, 'Discovery document missing or invalid public_key_pem'); } - // Step 3: Extract public key and compute fingerprint let publicKey, fingerprint; try { publicKey = KeyManager.loadPublicKeyPem(publicKeyPem); fingerprint = KeyManager.calculateKeyFingerprint(publicKeyPem); } catch (e) { - return { - valid: false, - domain, - error_code: ErrorCode.KEY_NOT_FOUND, - error_message: `Failed to load public key: ${e.message}` - }; + return failure(domain, ErrorCode.KEY_NOT_FOUND, `Failed to load public key: ${e.message}`); } // Step 4: Check revocation @@ -437,70 +614,74 @@ export function verifySkillOffline(skillDir, discovery, signatureData = null, re try { checkRevocationCombined(simpleRevoked, revocationDoc, fingerprint); } catch (e) { - return { - valid: false, - domain, - error_code: ErrorCode.KEY_REVOKED, - error_message: e.message - }; + return failure(domain, ErrorCode.KEY_REVOKED, e.message); } - // Step 5: TOFU key pinning + // Step 5: TOFU pin check (read-only until the signature verifies) let pinResult = null; if (pinStore !== null && pinStore !== undefined) { - pinResult = pinStore.checkAndPin(toolId, domain, fingerprint); + pinResult = pinStore.check(toolId, domain, fingerprint); if (pinResult === 'changed') { - return { - valid: false, - domain, - error_code: ErrorCode.KEY_PIN_MISMATCH, - error_message: 'Key fingerprint changed since last use' - }; + return failure(domain, ErrorCode.KEY_PIN_MISMATCH, 'Key fingerprint changed since last use'); } } - // Step 6: Canonicalize and verify signature + // Step 6: Canonicalize and verify + const version = signatureData.schemapin_version; + if (versionTuple(version) === null) { + return failure(domain, ErrorCode.SIGNATURE_INVALID, `unsupported schemapin_version ${JSON.stringify(version)}`); + } + const legacy = isLegacyVersion(version); let rootHash; try { - const result = canonicalizeSkill(skillPath); - rootHash = result.rootHash; + rootHash = (legacy ? canonicalizeSkillV1(skillPath) : canonicalizeSkill(skillPath)).rootHash; } catch (e) { - return { - valid: false, - domain, - error_code: ErrorCode.SCHEMA_CANONICALIZATION_FAILED, - error_message: `Failed to canonicalize skill: ${e.message}` - }; + return failure(domain, ErrorCode.SCHEMA_CANONICALIZATION_FAILED, `Failed to canonicalize skill: ${e.message}`); } const signatureB64 = signatureData.signature || ''; - const valid = SignatureManager.verifySignature(rootHash, signatureB64, publicKey); + let valid; + if (legacy) { + valid = SignatureManager.verifySignature(rootHash, signatureB64, publicKey); + warnings.push('legacy_signature_format'); + } else { + const expectedHash = `sha256:${rootHash.toString('hex')}`; + if (signatureData.skill_hash !== expectedHash) { + return failure(domain, ErrorCode.SIGNATURE_INVALID, 'skill_hash does not match the directory contents'); + } + let input; + try { + input = signingInput(signatureData); + } catch (e) { + return failure(domain, ErrorCode.SIGNATURE_INVALID, `malformed signature document: ${e.message}`); + } + valid = SignatureManager.verifySignature(input, signatureB64, publicKey); + } if (!valid) { - return { - valid: false, - domain, - error_code: ErrorCode.SIGNATURE_INVALID, - error_message: 'Signature verification failed' - }; + return failure(domain, ErrorCode.SIGNATURE_INVALID, 'Signature verification failed'); } - // Step 7: Return success + // Step 7: Expiration (fail-closed by default) const resultObj = { valid: true, domain, developer_name: discovery.developer_name || null, - key_pinning: pinResult ? { status: pinResult } : null, - warnings: [] + key_pinning: null, + warnings }; - - // v1.4: apply signature expiration check (degraded, not failed). - if (signatureData.expires_at !== null && signatureData.expires_at !== undefined) { - applyExpirationCheck(resultObj, signatureData.expires_at); + applyExpirationCheck(resultObj, signatureData.expires_at ?? null, allowExpired, EXPIRATION_SKEW_SECONDS); + if (!resultObj.valid) { + return resultObj; } - // v1.4 alpha.2: surface lineage metadata (informational; chain enforcement - // is opt-in via verifyChain). + // Step 8: Pin only now, then surface lineage metadata + if (pinStore !== null && pinStore !== undefined && pinResult === 'first_use') { + pinStore.pin(toolId, domain, fingerprint); + } + if (pinResult !== null) { + resultObj.key_pinning = { status: pinResult }; + } if (signatureData.schema_version !== null && signatureData.schema_version !== undefined) { resultObj.schema_version = signatureData.schema_version; } @@ -514,11 +695,9 @@ export function verifySkillOffline(skillDir, discovery, signatureData = null, re /** * Verify a signed skill folder offline with an optional DNS TXT cross-check (v1.4). * - * Behaves identically to {@link verifySkillOffline}, then — when `dnsTxt` is - * non-null — verifies that the DNS TXT record's fingerprint matches the - * discovery key. A mismatch converts the result into a hard failure with - * `ErrorCode.DOMAIN_MISMATCH`. When `dnsTxt` is null, behaviour is unchanged - * (DNS TXT is an optional, additive trust signal). + * Behaves identically to {@link verifySkillOffline}, with a DNS TXT + * fingerprint cross-check performed first when `dnsTxt` is non-null. A + * mismatch is a hard `DOMAIN_MISMATCH` failure and nothing is pinned. * * @param {string} skillDir - Path to skill directory * @param {Object} discovery - Well-known discovery document @@ -527,59 +706,87 @@ export function verifySkillOffline(skillDir, discovery, signatureData = null, re * @param {KeyPinStore|null} pinStore - TOFU pin store * @param {string|null} toolId - Tool identifier for pinning * @param {Object|null} dnsTxt - Parsed DNS TXT record (`{ version, kid, fingerprint }`) + * @param {string|null} [expectedDomain=null] + * @param {boolean} [allowExpired=false] * @returns {Object} Verification result */ -export function verifySkillOfflineWithDns(skillDir, discovery, signatureData = null, revocationDoc = null, pinStore = null, toolId = null, dnsTxt = null) { - const result = verifySkillOffline(skillDir, discovery, signatureData, revocationDoc, pinStore, toolId); - - if (!result.valid) { - return result; - } - if (dnsTxt === null || dnsTxt === undefined) { - return result; - } - try { - verifyDnsMatch(discovery, dnsTxt); - return result; - } catch (e) { - return { - valid: false, - domain: result.domain, - error_code: ErrorCode.DOMAIN_MISMATCH, - error_message: e.message - }; +export function verifySkillOfflineWithDns(skillDir, discovery, signatureData = null, revocationDoc = null, pinStore = null, toolId = null, dnsTxt = null, expectedDomain = null, allowExpired = false) { + if (dnsTxt !== null && dnsTxt !== undefined) { + try { + verifyDnsMatch(discovery, dnsTxt); + } catch (e) { + return failure(expectedDomain, ErrorCode.DOMAIN_MISMATCH, e.message); + } } + return verifySkillOffline(skillDir, discovery, signatureData, revocationDoc, pinStore, toolId, expectedDomain, allowExpired); } /** * Verify a signed skill folder using a resolver for discovery. * + * The signature's `domain` must equal `domain` (checked before any network + * I/O); the discovery document is fetched for `domain` and the pin is keyed + * by it. Revocation is fail-closed unless `allowUnreachableRevocation`. + * * @param {string} skillDir - Path to skill directory - * @param {string} domain - Domain to resolve discovery for + * @param {string} domain - Domain to resolve discovery for and bind the skill to * @param {Object} resolver - SchemaResolver instance * @param {KeyPinStore|null} pinStore - TOFU pin store * @param {string|null} toolId - Tool identifier for pinning + * @param {boolean} [allowExpired=false] + * @param {boolean} [allowUnreachableRevocation=false] * @returns {Promise} Verification result */ -export async function verifySkillWithResolver(skillDir, domain, resolver, pinStore = null, toolId = null) { +export async function verifySkillWithResolver(skillDir, domain, resolver, pinStore = null, toolId = null, allowExpired = false, allowUnreachableRevocation = false) { + try { + domain = validateDomain(domain); + } catch (e) { + if (e instanceof InvalidDomainError) { + return failure(domain, ErrorCode.DOMAIN_INVALID, e.message); + } + throw e; + } + + let signatureData; + try { + signatureData = loadSignature(skillDir); + } catch (e) { + return failure(domain, ErrorCode.SIGNATURE_INVALID, `No usable .schemapin.sig found in skill directory: ${e.message}`); + } + const sigDomain = signatureData.domain; + if (typeof sigDomain !== 'string' || sigDomain.toLowerCase() !== domain) { + return failure(domain, ErrorCode.DOMAIN_MISMATCH, + `signature domain ${JSON.stringify(sigDomain)} does not match expected domain ${JSON.stringify(domain)}`); + } + const discovery = await resolver.resolveDiscovery(domain); if (!discovery) { - return { - valid: false, - domain, - error_code: ErrorCode.DISCOVERY_FETCH_FAILED, - error_message: `Could not resolve discovery for domain: ${domain}` - }; + return failure(domain, ErrorCode.DISCOVERY_FETCH_FAILED, `Could not resolve discovery for domain: ${domain}`); } - const revocation = await resolver.resolveRevocation(domain, discovery); + let revocation; + try { + revocation = await resolver.resolveRevocation(domain, discovery); + } catch (e) { + if (!(e instanceof RevocationFetchError)) { + throw e; + } + if (!allowUnreachableRevocation) { + return failure(domain, ErrorCode.DISCOVERY_FETCH_FAILED, `Revocation document unreachable (fail-closed): ${e.message}`); + } + revocation = null; + } - return verifySkillOffline(skillDir, discovery, null, revocation, pinStore, toolId); + return verifySkillOffline(skillDir, discovery, signatureData, revocation, pinStore, toolId, domain, allowExpired); } /** * Compare current file manifest against the signed manifest. * + * Only meaningful for `schemapin_version >= "1.4"` documents, whose + * `file_manifest` is covered by the signature; for legacy 1.3 documents the + * manifest is unsigned and this is a diagnostic aid only. + * * @param {Object} currentManifest - Current file manifest * @param {Object} signedManifest - Signed file manifest * @returns {{ modified: string[], added: string[], removed: string[] }} diff --git a/javascript/src/verification.js b/javascript/src/verification.js index 2d8b313..1320045 100644 --- a/javascript/src/verification.js +++ b/javascript/src/verification.js @@ -4,44 +4,84 @@ import { SchemaPinCore } from './core.js'; import { KeyManager, SignatureManager } from './crypto.js'; -import { checkRevocationCombined } from './revocation.js'; +import { checkRevocationCombined, RevocationFetchError } from './revocation.js'; import { a2aAllows } from './a2a.js'; +import { verifyDnsMatch } from './dns.js'; +import { InvalidDomainError, validateDomain } from './domain.js'; + +/** Clock-skew tolerance applied to `expires_at` comparisons (seconds). */ +export const EXPIRATION_SKEW_SECONDS = 300; + +const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; + +/** + * Parse a strict RFC 3339 timestamp (`T` separator, mandatory offset). + * + * `new Date(string)` accepts date-only and local-time strings that the other + * SDKs reject; this helper only accepts `YYYY-MM-DDTHH:MM:SS[.frac](Z|±HH:MM)`. + * + * @param {*} value + * @returns {number|null} milliseconds since the epoch, or null when invalid + */ +export function parseRfc3339(value) { + if (typeof value !== 'string' || !RFC3339_RE.test(value)) { + return null; + } + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : ms; +} /** * Apply a signature `expires_at` check to a verification result (v1.4). * - * Mirrors the Rust `VerificationResult::with_expiration_check` behaviour: + * Expiration is **fail-closed** by default: * - * - If `expiresAt` is null/undefined, the result is returned unchanged. - * - If parseable and in the past, sets `expired = true`, copies `expiresAt`, - * and pushes a `'signature_expired'` warning. `valid` is left intact — - * expired signatures are *degraded*, not failed. - * - If parseable and in the future, just records `expiresAt`. - * - If unparseable, pushes a `'signature_expires_at_unparseable'` warning - * (fail-open) and does not mark expired. + * - `expiresAt` of null/undefined leaves the result unchanged. + * - A value that is not strict RFC 3339 fails with `SIGNATURE_EXPIRED` + * (`allowExpired` downgrades this to a `signature_expires_at_unparseable` + * warning). + * - A timestamp more than `skewSeconds` in the past fails with + * `SIGNATURE_EXPIRED` and sets `expired = true`. With `allowExpired` the + * result stays valid but is marked `expired` and carries a + * `signature_expired` warning (the pre-1.4.0 "degraded" behavior). + * - A future timestamp just records `expires_at`. * * Mutates and returns the same `result` object for ergonomic chaining. * * @param {Object} result - VerificationResult-shaped object - * @param {string|null|undefined} expiresAt - ISO 8601 / RFC 3339 timestamp + * @param {string|null|undefined} expiresAt - RFC 3339 timestamp + * @param {boolean} [allowExpired=false] + * @param {number} [skewSeconds=EXPIRATION_SKEW_SECONDS] * @returns {Object} The (possibly mutated) result */ -export function applyExpirationCheck(result, expiresAt) { +export function applyExpirationCheck(result, expiresAt, allowExpired = false, skewSeconds = EXPIRATION_SKEW_SECONDS) { if (expiresAt === null || expiresAt === undefined) { return result; } if (!Array.isArray(result.warnings)) { result.warnings = []; } - const ts = new Date(expiresAt); - if (Number.isNaN(ts.getTime())) { - result.warnings.push('signature_expires_at_unparseable'); + const ms = parseRfc3339(expiresAt); + if (ms === null) { + if (allowExpired) { + result.warnings.push('signature_expires_at_unparseable'); + return result; + } + result.valid = false; + result.error_code = ErrorCode.SIGNATURE_EXPIRED; + result.error_message = `expires_at is not a valid RFC 3339 timestamp: ${JSON.stringify(expiresAt)}`; return result; } result.expires_at = expiresAt; - if (ts.getTime() < Date.now()) { + if (Date.now() > ms + skewSeconds * 1000) { result.expired = true; - result.warnings.push('signature_expired'); + if (allowExpired) { + result.warnings.push('signature_expired'); + } else { + result.valid = false; + result.error_code = ErrorCode.SIGNATURE_EXPIRED; + result.error_message = `signature expired at ${expiresAt}`; + } } return result; } @@ -69,7 +109,15 @@ export const ErrorCode = Object.freeze({ BUNDLE_UNSIGNED: 'bundle_unsigned', // v1.4 alpha.3 (bundle distribution): a trust bundle is past its // expires_at (or has an unparseable expires_at). - BUNDLE_EXPIRED: 'bundle_expired' + BUNDLE_EXPIRED: 'bundle_expired', + // A signature's expires_at is in the past (or unparseable) and the + // caller did not opt in with allowExpired. Hard failure. + SIGNATURE_EXPIRED: 'signature_expired', + // The caller-supplied domain is not a valid RFC 1123 hostname. + DOMAIN_INVALID: 'domain_invalid', + // A trust bundle's authority key is not pre-seeded in the authority store + // and the caller did not opt in to first-use trust. + BUNDLE_AUTHORITY_UNTRUSTED: 'bundle_authority_untrusted' }); /** @@ -102,7 +150,11 @@ export function checkCanonicalization(algorithm) { /** * Lightweight in-memory fingerprint-based pin store. - * Keys are stored by tool_id@domain. + * + * Keys are stored by tool_id@domain. Verification flows use {@link check} + * before the signature check and {@link pin} only after it succeeds, so a + * failed verification never leaves a pin behind. Operators can pre-seed + * expected fingerprints with {@link seed}. */ export class KeyPinStore { constructor() { @@ -114,24 +166,58 @@ export class KeyPinStore { } /** - * Check and optionally pin a key fingerprint. + * Compare `fingerprint` with the pinned value without mutating. * * @param {string} toolId * @param {string} domain * @param {string} fingerprint * @returns {string} "first_use", "pinned", or "changed" */ - checkAndPin(toolId, domain, fingerprint) { - const k = this._key(toolId, domain); - const existing = this._pins.get(k); + check(toolId, domain, fingerprint) { + const existing = this._pins.get(this._key(toolId, domain)); if (existing === undefined) { - this._pins.set(k, fingerprint); return 'first_use'; } - if (existing === fingerprint) { - return 'pinned'; + return existing === fingerprint ? 'pinned' : 'changed'; + } + + /** + * Pin `fingerprint` for tool@domain (overwrites). + * + * @param {string} toolId + * @param {string} domain + * @param {string} fingerprint + */ + pin(toolId, domain, fingerprint) { + this._pins.set(this._key(toolId, domain), fingerprint); + } + + /** + * Pre-seed an expected fingerprint (out-of-band trust establishment). + * + * @param {string} toolId + * @param {string} domain + * @param {string} fingerprint + */ + seed(toolId, domain, fingerprint) { + this.pin(toolId, domain, fingerprint); + } + + /** + * Check and pin on first use in one step. Prefer `check` + `pin` so that + * pinning can be deferred until after signature verification. + * + * @param {string} toolId + * @param {string} domain + * @param {string} fingerprint + * @returns {string} "first_use", "pinned", or "changed" + */ + checkAndPin(toolId, domain, fingerprint) { + const result = this.check(toolId, domain, fingerprint); + if (result === 'first_use') { + this.pin(toolId, domain, fingerprint); } - return 'changed'; + return result; } /** @@ -163,7 +249,13 @@ export class KeyPinStore { static fromJSON(jsonStr) { const store = new KeyPinStore(); const data = JSON.parse(jsonStr); + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('pin store JSON must be an object of string fingerprints'); + } for (const [key, value] of Object.entries(data)) { + if (typeof value !== 'string') { + throw new Error('pin store JSON must be an object of string fingerprints'); + } store._pins.set(key, value); } return store; @@ -173,14 +265,15 @@ export class KeyPinStore { /** * Verify a schema offline using pre-fetched discovery and revocation data. * - * 7-step verification flow: - * 1. Validate discovery document - * 2. Extract public key and compute fingerprint - * 3. Check revocation (both simple list + standalone doc) - * 4. TOFU key pinning check - * 5. Canonicalize schema and compute hash - * 6. Verify ECDSA signature against hash - * 7. Return structured result + * Verification flow: + * 1. Validate the canonicalization identifier and the domain syntax + * 2. Validate discovery document + * 3. Extract public key and compute fingerprint + * 4. Check revocation (both simple list + standalone doc) + * 5. TOFU pin check (read-only) + * 6. Canonicalize schema, compute hash, verify ECDSA signature + * 7. Pin the key on first use (only after a successful verification) + * 8. Return structured result * * @param {Object} schema * @param {string} signatureB64 @@ -189,14 +282,14 @@ export class KeyPinStore { * @param {Object} discovery - Well-known response * @param {Object|null} revocation - Standalone revocation document * @param {KeyPinStore} pinStore - * @param {string|null} [canonicalization] - v1.4 alpha.3 optional canonicalization + * @param {string|null} [canonicalization] - v1.4 optional canonicalization * algorithm identifier from the signature. `null` / `undefined` / * `'schemapin-v1'` are equivalent and accepted; any other value fails with * `CANONICALIZATION_UNSUPPORTED`. * @returns {Object} Verification result */ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discovery, revocation, pinStore, canonicalization = null) { - // Step 0 (v1.4 alpha.3): canonicalization algorithm check + // Step 1: canonicalization algorithm + domain syntax const unsupportedAlgo = checkCanonicalization(canonicalization); if (unsupportedAlgo !== null) { return { @@ -206,8 +299,16 @@ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discov error_message: `Unsupported canonicalization algorithm: ${unsupportedAlgo}` }; } + try { + domain = validateDomain(domain); + } catch (e) { + if (e instanceof InvalidDomainError) { + return { valid: false, domain, error_code: ErrorCode.DOMAIN_INVALID, error_message: e.message }; + } + throw e; + } - // Step 1: Validate discovery document + // Step 2: Validate discovery document const publicKeyPem = discovery?.public_key_pem; if (!publicKeyPem || !publicKeyPem.includes('-----BEGIN PUBLIC KEY-----')) { return { @@ -218,7 +319,7 @@ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discov }; } - // Step 2: Extract public key and compute fingerprint + // Step 3: Extract public key and compute fingerprint let publicKey, fingerprint; try { publicKey = KeyManager.loadPublicKeyPem(publicKeyPem); @@ -232,7 +333,7 @@ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discov }; } - // Step 3: Check revocation + // Step 4: Check revocation const simpleRevoked = discovery.revoked_keys || []; try { checkRevocationCombined(simpleRevoked, revocation, fingerprint); @@ -245,8 +346,8 @@ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discov }; } - // Step 4: TOFU key pinning - const pinResult = pinStore.checkAndPin(toolId, domain, fingerprint); + // Step 5: TOFU pin check (no write yet) + const pinResult = pinStore.check(toolId, domain, fingerprint); if (pinResult === 'changed') { return { valid: false, @@ -256,7 +357,7 @@ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discov }; } - // Step 5: Canonicalize and hash + // Step 6: Canonicalize, hash, verify let schemaHash; try { schemaHash = SchemaPinCore.canonicalizeAndHash(schema); @@ -269,7 +370,6 @@ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discov }; } - // Step 6: Verify signature const valid = SignatureManager.verifySchemaSignature(schemaHash, signatureB64, publicKey); if (!valid) { @@ -281,7 +381,12 @@ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discov }; } - // Step 7: Return success + // Step 7: Pin only after the signature verified + if (pinResult === 'first_use') { + pinStore.pin(toolId, domain, fingerprint); + } + + // Step 8: Return success const result = { valid: true, domain, @@ -300,18 +405,61 @@ export function verifySchemaOffline(schema, signatureB64, domain, toolId, discov return result; } +/** + * {@link verifySchemaOffline} plus a DNS TXT second-channel check. + * + * When `dnsTxt` is given (see `fetchDnsTxt`), the discovery key's + * fingerprint must match the TXT record or the result is a hard + * `DOMAIN_MISMATCH` failure. The DNS check runs before anything is pinned. + * + * @param {Object} schema + * @param {string} signatureB64 + * @param {string} domain + * @param {string} toolId + * @param {Object} discovery + * @param {Object|null} revocation + * @param {KeyPinStore} pinStore + * @param {Object|null} dnsTxt - Parsed DNS TXT record (`{ version, kid, fingerprint }`) + * @param {string|null} [canonicalization=null] + * @returns {Object} Verification result + */ +export function verifySchemaOfflineWithDns(schema, signatureB64, domain, toolId, discovery, revocation, pinStore, dnsTxt, canonicalization = null) { + if (dnsTxt !== null && dnsTxt !== undefined) { + try { + verifyDnsMatch(discovery, dnsTxt); + } catch (e) { + return { valid: false, domain, error_code: ErrorCode.DOMAIN_MISMATCH, error_message: e.message }; + } + } + return verifySchemaOffline(schema, signatureB64, domain, toolId, discovery, revocation, pinStore, canonicalization); +} + /** * Verify a schema using a resolver for discovery and revocation. * + * Revocation is fail-closed: if the discovery document declares a + * `revocation_endpoint` that cannot be fetched, verification fails with + * `DISCOVERY_FETCH_FAILED` unless `allowUnreachableRevocation` is true. + * * @param {Object} schema * @param {string} signatureB64 * @param {string} domain * @param {string} toolId * @param {SchemaResolver} resolver * @param {KeyPinStore} pinStore + * @param {boolean} [allowUnreachableRevocation=false] * @returns {Promise} Verification result */ -export async function verifySchemaWithResolver(schema, signatureB64, domain, toolId, resolver, pinStore) { +export async function verifySchemaWithResolver(schema, signatureB64, domain, toolId, resolver, pinStore, allowUnreachableRevocation = false) { + try { + domain = validateDomain(domain); + } catch (e) { + if (e instanceof InvalidDomainError) { + return { valid: false, domain, error_code: ErrorCode.DOMAIN_INVALID, error_message: e.message }; + } + throw e; + } + const discovery = await resolver.resolveDiscovery(domain); if (!discovery) { return { @@ -322,7 +470,23 @@ export async function verifySchemaWithResolver(schema, signatureB64, domain, too }; } - const revocation = await resolver.resolveRevocation(domain, discovery); + let revocation; + try { + revocation = await resolver.resolveRevocation(domain, discovery); + } catch (e) { + if (!(e instanceof RevocationFetchError)) { + throw e; + } + if (!allowUnreachableRevocation) { + return { + valid: false, + domain, + error_code: ErrorCode.DISCOVERY_FETCH_FAILED, + error_message: `Revocation document unreachable (fail-closed): ${e.message}` + }; + } + revocation = null; + } return verifySchemaOffline(schema, signatureB64, domain, toolId, discovery, revocation, pinStore); } diff --git a/javascript/tests/cross-language.test.js b/javascript/tests/cross-language.test.js index 6699f9f..38d7866 100644 --- a/javascript/tests/cross-language.test.js +++ b/javascript/tests/cross-language.test.js @@ -85,3 +85,59 @@ describe('cross-language signature vector', () => { }); } }); + +// --------------------------------------------------------------------------- +// Skill-directory fixtures (SP-1, SP-12, SP-13, SP-14) +// --------------------------------------------------------------------------- + +import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { SIGNATURE_FILENAME, canonicalizeSkill, canonicalizeSkillV1, loadSignature, verifySkillOffline } from '../src/skill.js'; + +const SKILL_FIXTURES = resolve(HERE, '..', '..', 'tests', 'cross-language', 'skill'); + +describe('cross-language skill fixtures', () => { + const expected = JSON.parse(readFileSync(join(SKILL_FIXTURES, 'expected.json'), 'utf-8')); + + test('v1.4 fixture hashes and verifies', () => { + const skillDir = join(SKILL_FIXTURES, expected.v14.skill_dir); + const { rootHash, manifest } = canonicalizeSkill(skillDir); + assert.strictEqual(`sha256:${rootHash.toString('hex')}`, expected.v14.skill_hash); + assert.deepStrictEqual(manifest, expected.v14.file_manifest); + const result = verifySkillOffline(skillDir, expected.discovery, null, null, null, null, expected.domain); + assert.strictEqual(result.valid, true, JSON.stringify(result)); + assert.ok(!result.warnings.includes('legacy_signature_format')); + assert.strictEqual(result.schema_version, '1.0.0'); + }); + + test('v1.4 fixture: tampering any signed field or file fails', () => { + const src = join(SKILL_FIXTURES, expected.v14.skill_dir); + const dir = mkdtempSync(join(tmpdir(), 'skill-fixture-')); + try { + for (const field of expected.v14.signed_fields_tamper_must_fail) { + const dst = join(dir, field); + cpSync(src, dst, { recursive: true }); + const sig = loadSignature(dst); + sig[field] = field === 'skill_hash' ? 'sha256:' + '00'.repeat(32) : 'tampered'; + writeFileSync(join(dst, SIGNATURE_FILENAME), JSON.stringify(sig), 'utf-8'); + assert.strictEqual(verifySkillOffline(dst, expected.discovery).valid, false, field); + } + const dst = join(dir, 'content'); + cpSync(src, dst, { recursive: true }); + writeFileSync(join(dst, 'scripts', 'run.sh'), '#!/bin/sh\nrm -rf /\n'); + assert.strictEqual(verifySkillOffline(dst, expected.discovery).valid, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('legacy 1.3 fixture verifies with a warning', () => { + const skillDir = join(SKILL_FIXTURES, expected.legacy_v13.skill_dir); + const { rootHash, manifest } = canonicalizeSkillV1(skillDir); + assert.strictEqual(`sha256:${rootHash.toString('hex')}`, expected.legacy_v13.skill_hash); + assert.deepStrictEqual(manifest, expected.legacy_v13.file_manifest); + const result = verifySkillOffline(skillDir, expected.discovery, null, null, null, null, expected.domain); + assert.strictEqual(result.valid, true, JSON.stringify(result)); + assert.ok(result.warnings.includes('legacy_signature_format')); + }); +}); diff --git a/javascript/tests/skill.test.js b/javascript/tests/skill.test.js index f9a5b73..6358e26 100644 --- a/javascript/tests/skill.test.js +++ b/javascript/tests/skill.test.js @@ -7,13 +7,18 @@ import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import * as nodeCrypto from 'node:crypto'; import { KeyManager } from '../src/crypto.js'; import { buildRevocationDocument, addRevokedKey, RevocationReason } from '../src/revocation.js'; -import { ErrorCode, KeyPinStore } from '../src/verification.js'; +import { ErrorCode, KeyPinStore, applyExpirationCheck } from '../src/verification.js'; +import { SchemaResolver } from '../src/resolver.js'; +import { KeyManager as KM, SignatureManager } from '../src/crypto.js'; +import { symlinkSync } from 'node:fs'; import { - SIGNATURE_FILENAME, canonicalizeSkill, parseSkillName, loadSignature, + SIGNATURE_FILENAME, canonicalizeSkill, canonicalizeSkillV1, parseSkillName, loadSignature, signSkill, signSkillWithOptions, verifySkillOffline, verifySkillOfflineWithDns, - detectTamperedFiles, verifyChain, ChainError + verifySkillWithResolver, detectTamperedFiles, verifyChain, ChainError, + SkillCanonicalizationError } from '../src/skill.js'; // --------------------------------------------------------------------------- @@ -304,7 +309,7 @@ describe('Sign and Verify', () => { 'SKILL.md': '---\nname: test-skill\n---\n# Hello' }); const sig = signSkill(skillDir, privateKey, 'example.com'); - assert.equal(sig.schemapin_version, '1.3'); + assert.equal(sig.schemapin_version, '1.4'); assert.equal(sig.skill_name, 'test-skill'); assert.ok(sig.skill_hash.startsWith('sha256:')); assert.equal(typeof sig.signature, 'string'); @@ -602,7 +607,7 @@ describe('Signature Expiration (v1.4)', () => { const skillDir = join(dir, 'skill'); createSkillDir(skillDir, { 'SKILL.md': '---\nname: no-ttl\n---\n# hi' }); const sig = signSkill(skillDir, privateKey, 'example.com'); - assert.equal(sig.schemapin_version, '1.3'); + assert.equal(sig.schemapin_version, '1.4'); assert.equal(sig.expires_at, undefined); const raw = readFileSync(join(skillDir, SIGNATURE_FILENAME), 'utf-8'); @@ -641,48 +646,54 @@ describe('Signature Expiration (v1.4)', () => { const skillDir = join(dir, 'skill'); createSkillDir(skillDir, { 'SKILL.md': '---\nname: past\n---\n# hi' }); - // Sign normally, then rewrite the .schemapin.sig with a past expires_at. - const sig = signSkill(skillDir, privateKey, 'example.com'); - const past = new Date(Date.now() - ONE_DAY_MS); - const expiredSig = { - ...sig, - schemapin_version: '1.4', - expires_at: past.toISOString().replace(/\.\d{3}Z$/, 'Z') - }; - writeFileSync( - join(skillDir, SIGNATURE_FILENAME), - JSON.stringify(expiredSig, null, 2) + '\n', - 'utf-8' - ); + // Sign with a TTL that is already in the past (allowed at sign time). + signSkillWithOptions(skillDir, privateKey, 'example.com', { expiresIn: -ONE_DAY_MS }); const result = verifySkillOffline(skillDir, makeDiscovery(publicKey)); - assert.equal(result.valid, true, 'expired sigs are degraded, not failed'); + assert.equal(result.valid, false, 'expired signatures fail closed by default'); + assert.equal(result.error_code, ErrorCode.SIGNATURE_EXPIRED); assert.equal(result.expired, true); - assert.ok(result.warnings.includes('signature_expired')); + + const degraded = verifySkillOffline(skillDir, makeDiscovery(publicKey), null, null, null, null, null, true); + assert.equal(degraded.valid, true, 'allowExpired restores degraded-not-failed'); + assert.equal(degraded.expired, true); + assert.ok(degraded.warnings.includes('signature_expired')); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it('should warn (not fail) when expires_at is unparseable', () => { + it('should fail closed when expires_at is unparseable (and warn with allowExpired)', () => { + for (const bad of ['not-a-timestamp', '2020-01-01', '2020-01-01T00:00:00', '2020-01-01 00:00:00Z']) { + const strict = applyExpirationCheck({ valid: true, warnings: [] }, bad); + assert.equal(strict.valid, false, bad); + assert.equal(strict.error_code, ErrorCode.SIGNATURE_EXPIRED); + const lenient = applyExpirationCheck({ valid: true, warnings: [] }, bad, true); + assert.equal(lenient.valid, true); + assert.ok(lenient.warnings.includes('signature_expires_at_unparseable')); + } + // Inside the skew window is not expired. + const justNow = new Date(Date.now() - 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + assert.equal(applyExpirationCheck({ valid: true, warnings: [] }, justNow).valid, true); + }); + + it('covers expires_at with the signature: stripping it invalidates (SP-1)', () => { const dir = mkdtempSync(join(tmpdir(), 'skill-test-')); try { const { privateKey, publicKey } = makeKeypair(); const skillDir = join(dir, 'skill'); - createSkillDir(skillDir, { 'SKILL.md': '---\nname: bad\n---\n# hi' }); - - const sig = signSkill(skillDir, privateKey, 'example.com'); - const badSig = { ...sig, expires_at: 'not-a-timestamp' }; - writeFileSync( - join(skillDir, SIGNATURE_FILENAME), - JSON.stringify(badSig, null, 2) + '\n', - 'utf-8' - ); - - const result = verifySkillOffline(skillDir, makeDiscovery(publicKey)); - assert.equal(result.valid, true); - assert.notEqual(result.expired, true); - assert.ok(result.warnings.includes('signature_expires_at_unparseable')); + createSkillDir(skillDir, { 'SKILL.md': '---\nname: strip\n---\n# hi' }); + const sig = signSkillWithOptions(skillDir, privateKey, 'example.com', { expiresIn: -ONE_DAY_MS }); + const stripped = { ...sig }; + delete stripped.expires_at; + writeFileSync(join(skillDir, SIGNATURE_FILENAME), JSON.stringify(stripped), 'utf-8'); + let r = verifySkillOffline(skillDir, makeDiscovery(publicKey)); + assert.equal(r.valid, false); + assert.equal(r.error_code, ErrorCode.SIGNATURE_INVALID); + stripped.schemapin_version = '1.3'; + writeFileSync(join(skillDir, SIGNATURE_FILENAME), JSON.stringify(stripped), 'utf-8'); + r = verifySkillOffline(skillDir, makeDiscovery(publicKey)); + assert.equal(r.valid, false); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -914,3 +925,226 @@ describe('Schema version binding (v1.4 alpha.2)', () => { } }); }); + + +// --------------------------------------------------------------------------- +// Signed signature document, domain binding, hashing v2 (SP-1, 8, 12, 13, 14) +// --------------------------------------------------------------------------- + +function signLegacyV13(skillDir, privateKey, domain) { + const { rootHash, manifest } = canonicalizeSkillV1(skillDir); + const publicKeyPem = nodeCrypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }); + const sig = { + schemapin_version: '1.3', + skill_name: 'legacy', + skill_hash: `sha256:${rootHash.toString('hex')}`, + signature: SignatureManager.signHash(rootHash, privateKey), + signed_at: '2026-01-01T00:00:00Z', + domain, + signer_kid: KM.calculateKeyFingerprint(publicKeyPem), + file_manifest: manifest + }; + writeFileSync(join(skillDir, SIGNATURE_FILENAME), JSON.stringify(sig), 'utf-8'); + return sig; +} + +function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'skill-test-')); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('signed signature document (SP-1)', () => { + for (const [field, value] of [ + ['schema_version', '9.9.9'], + ['previous_hash', 'sha256:' + 'ab'.repeat(32)], + ['skill_name', 'payments-tool'], + ['domain', 'good.example.com'], + ['signer_kid', 'sha256:' + '00'.repeat(32)], + ['signed_at', '2030-01-01T00:00:00Z'], + ['canonicalization', 'schemapin-v1'] + ]) { + it(`tampering ${field} invalidates the signature`, () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '---\nname: s\n---\n# hi' }); + signSkillWithOptions(skillDir, privateKey, 'example.com', { schemaVersion: '1.0.0' }); + const sig = loadSignature(skillDir); + sig[field] = value; + writeFileSync(join(skillDir, SIGNATURE_FILENAME), JSON.stringify(sig), 'utf-8'); + const r = verifySkillOffline(skillDir, makeDiscovery(publicKey)); + assert.equal(r.valid, false); + })); + } + + it('verifies skill_hash against the directory (SP-14)', () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + const sig = signSkill(skillDir, privateKey, 'example.com'); + assert.equal(sig.skill_hash, `sha256:${canonicalizeSkill(skillDir).rootHash.toString('hex')}`); + const other = join(dir, 'other'); + createSkillDir(other, { 'SKILL.md': '# other' }); + signSkill(other, privateKey, 'example.com'); + writeFileSync(join(skillDir, SIGNATURE_FILENAME), JSON.stringify(loadSignature(other)), 'utf-8'); + const r = verifySkillOffline(skillDir, makeDiscovery(publicKey)); + assert.equal(r.valid, false); + assert.match(r.error_message, /skill_hash/); + })); + + it('still verifies legacy 1.3 documents with a warning', () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi', 'sub/x.txt': 'x' }); + signLegacyV13(skillDir, privateKey, 'example.com'); + const r = verifySkillOffline(skillDir, makeDiscovery(publicKey), null, null, null, null, 'example.com'); + assert.equal(r.valid, true); + assert.ok(r.warnings.includes('legacy_signature_format')); + const sig = loadSignature(skillDir); + sig.schemapin_version = '1.4'; + writeFileSync(join(skillDir, SIGNATURE_FILENAME), JSON.stringify(sig), 'utf-8'); + assert.equal(verifySkillOffline(skillDir, makeDiscovery(publicKey)).valid, false); + })); + + it('verifyChain rejects legacy documents', () => { + assert.throws( + () => verifyChain({ schemapin_version: '1.4', previous_hash: 'sha256:aa' }, { schemapin_version: '1.3', skill_hash: 'sha256:aa' }), + (e) => e instanceof ChainError && e.kind === 'legacy' + ); + }); +}); + +describe('domain binding (SP-8)', () => { + it('offline: mismatch fails and nothing is pinned', () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + signSkill(skillDir, privateKey, 'evil.example.com'); + const store = new KeyPinStore(); + const r = verifySkillOffline(skillDir, makeDiscovery(publicKey), null, null, store, 'calc', 'good.example.com'); + assert.equal(r.valid, false); + assert.equal(r.error_code, ErrorCode.DOMAIN_MISMATCH); + assert.equal(store.getPinned('calc', 'good.example.com'), null); + assert.equal(store.getPinned('calc', 'evil.example.com'), null); + })); + + it('offline: bound domain is reported, unbound is warned', () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + signSkill(skillDir, privateKey, 'Example.COM'); + const r = verifySkillOffline(skillDir, makeDiscovery(publicKey), null, null, null, null, 'example.com'); + assert.equal(r.valid, true); + assert.equal(r.domain, 'example.com'); + assert.ok(!r.warnings.includes('domain_unbound')); + const r2 = verifySkillOffline(skillDir, makeDiscovery(publicKey)); + assert.ok(r2.warnings.includes('domain_unbound')); + })); + + it('resolver path binds the domain before any network access', async () => { + const dir = mkdtempSync(join(tmpdir(), 'skill-test-')); + try { + const { privateKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + signSkill(skillDir, privateKey, 'good.example.com'); + class Exploding extends SchemaResolver { + async resolveDiscovery() { throw new Error('network must not be touched'); } + async resolveRevocation() { throw new Error('unreachable'); } + } + const r = await verifySkillWithResolver(skillDir, 'evil.example.com', new Exploding()); + assert.equal(r.valid, false); + assert.equal(r.error_code, ErrorCode.DOMAIN_MISMATCH); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('rejects invalid domains', () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + signSkill(skillDir, privateKey, 'example.com'); + for (const bad of ['../skills/evil/key', 'localhost', 'http://example.com', '10.0.0.1']) { + const r = verifySkillOffline(skillDir, makeDiscovery(publicKey), null, null, null, null, bad); + assert.equal(r.valid, false, bad); + assert.equal(r.error_code, ErrorCode.DOMAIN_INVALID, bad); + } + })); +}); + +describe('pin after verify', () => { + it('a failed signature never leaves a pin behind', () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const other = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + signSkill(skillDir, privateKey, 'example.com'); + const store = new KeyPinStore(); + let r = verifySkillOffline(skillDir, makeDiscovery(other.publicKey), null, null, store, 't', 'example.com'); + assert.equal(r.valid, false); + assert.equal(store.getPinned('t', 'example.com'), null); + r = verifySkillOffline(skillDir, makeDiscovery(publicKey), null, null, store, 't', 'example.com'); + assert.equal(r.valid, true); + assert.equal(r.key_pinning.status, 'first_use'); + assert.notEqual(store.getPinned('t', 'example.com'), null); + })); + + it('an expired signature never leaves a pin behind', () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + signSkillWithOptions(skillDir, privateKey, 'example.com', { expiresIn: -ONE_DAY_MS }); + const store = new KeyPinStore(); + const r = verifySkillOffline(skillDir, makeDiscovery(publicKey), null, null, store, 't', 'example.com'); + assert.equal(r.valid, false); + assert.equal(store.getPinned('t', 'example.com'), null); + })); +}); + +describe('skill hashing v2 (SP-12 / SP-13)', () => { + it('rejects symlinked files and directories', () => withTmp((dir) => { + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + writeFileSync(join(dir, 'outside.txt'), 'unsigned'); + symlinkSync(join(dir, 'outside.txt'), join(skillDir, 'link.txt')); + assert.throws(() => canonicalizeSkill(skillDir), SkillCanonicalizationError); + rmSync(join(skillDir, 'link.txt')); + mkdirSync(join(dir, 'other')); + symlinkSync(join(dir, 'other'), join(skillDir, 'examples'), 'dir'); + assert.throws(() => canonicalizeSkill(skillDir), SkillCanonicalizationError); + })); + + it('a symlink added after signing fails verification', () => withTmp((dir) => { + const { privateKey, publicKey } = makeKeypair(); + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi' }); + signSkill(skillDir, privateKey, 'example.com'); + writeFileSync(join(dir, 'outside.txt'), 'unsigned'); + symlinkSync(join(dir, 'outside.txt'), join(skillDir, 'helper.sh')); + const r = verifySkillOffline(skillDir, makeDiscovery(publicKey)); + assert.equal(r.valid, false); + assert.equal(r.error_code, ErrorCode.SCHEMA_CANONICALIZATION_FAILED); + })); + + it('path/content boundary is not malleable', () => withTmp((dir) => { + const a = join(dir, 'a'); + const b = join(dir, 'b'); + createSkillDir(a, { 'run.sh.disabled': '\necho hi' }); + createSkillDir(b, { 'run.sh': '.disabled\necho hi' }); + assert.notDeepEqual(canonicalizeSkill(a).rootHash, canonicalizeSkill(b).rootHash); + // The legacy algorithm collides on exactly this re-split. + assert.deepEqual(canonicalizeSkillV1(a).rootHash, canonicalizeSkillV1(b).rootHash); + })); + + it('a nested .schemapin.sig is content, the top-level one is not', () => withTmp((dir) => { + const skillDir = join(dir, 'skill'); + createSkillDir(skillDir, { 'SKILL.md': '# hi', 'nested/.schemapin.sig': '{}' }); + const { manifest } = canonicalizeSkill(skillDir); + assert.ok('nested/.schemapin.sig' in manifest); + assert.ok(!('.schemapin.sig' in manifest)); + })); +}); diff --git a/python/schemapin/__init__.py b/python/schemapin/__init__.py index d657748..0c9b5e6 100644 --- a/python/schemapin/__init__.py +++ b/python/schemapin/__init__.py @@ -28,7 +28,7 @@ sign_trust_bundle, verify_trust_bundle, ) -from .core import SchemaPinCore +from .core import CanonicalizationError, SchemaPinCore from .crypto import KeyManager, SignatureManager from .discovery import PublicKeyDiscovery from .dns import ( @@ -38,6 +38,7 @@ txt_record_name, verify_dns_match, ) +from .domain import InvalidDomainError, is_valid_domain, validate_domain from .interactive import ( CallbackInteractiveHandler, ConsoleInteractiveHandler, @@ -58,6 +59,7 @@ ) from .revocation import ( RevocationDocument, + RevocationFetchError, RevocationReason, RevokedKey, add_revoked_key, @@ -67,10 +69,15 @@ fetch_revocation_document, ) from .skill import ( + SCHEMAPIN_VERSION, + SCHEMAPIN_VERSION_LEGACY, SCHEMAPIN_VERSION_V1_4, SIGNATURE_FILENAME, + ChainError, SignOptions, + SkillCanonicalizationError, SkillSigner, + verify_chain, ) from .utils import ( SchemaSigningWorkflow, @@ -80,6 +87,7 @@ from .verification import ( A2A_MAX_DELEGATION_DEPTH, CANONICALIZATION_V1, + EXPIRATION_SKEW_SECONDS, ErrorCode, KeyPinningStatus, KeyPinStore, @@ -87,6 +95,7 @@ check_canonicalization, verify_schema_for_a2a, verify_schema_offline, + verify_schema_offline_with_dns, verify_schema_with_resolver, ) @@ -131,7 +140,19 @@ "KeyPinningStatus", "VerificationResult", "verify_schema_offline", + "verify_schema_offline_with_dns", "verify_schema_with_resolver", + "EXPIRATION_SKEW_SECONDS", + "CanonicalizationError", + "InvalidDomainError", + "validate_domain", + "is_valid_domain", + "RevocationFetchError", + "SCHEMAPIN_VERSION", + "SCHEMAPIN_VERSION_LEGACY", + "ChainError", + "SkillCanonicalizationError", + "verify_chain", # v1.3.0 "SkillSigner", "SIGNATURE_FILENAME", diff --git a/python/schemapin/skill.py b/python/schemapin/skill.py index f9a6357..11d6570 100644 --- a/python/schemapin/skill.py +++ b/python/schemapin/skill.py @@ -4,13 +4,32 @@ (AgentSkills spec). Same keys, same .well-known discovery, new canonicalization target. -v1.4 additions (additive, backward-compatible with v1.3 verifiers): - -- Optional ``expires_at`` field on the ``.schemapin.sig`` document. When set, - the signature uses ``schemapin_version: "1.4"`` and verifiers degrade - (warn) past expiration rather than failing. -- Optional DNS TXT cross-verification via - :func:`SkillSigner.verify_skill_offline_with_dns`. +Wire formats +------------ + +``schemapin_version: "1.4"`` (the default for every new signature): + +* The directory is canonicalized with :meth:`SkillSigner.canonicalize_skill` + (skill canonicalization v2, spec section 4b): every entry is hashed as + ``SHA-256("f" || be64(len(path)) || path || be64(len(content)) || content)`` + and the root hash is ``SHA-256`` over ``be64(len(path)) || path || digest`` + for every entry in code-point path order. Symlinks are rejected. +* ``skill_hash`` is ``"sha256:" + hex(root_hash)``. +* The signature covers the **entire signature document**: the document is + built with ``signature: ""``, canonicalized with ``schemapin-v1`` and signed + as ``ECDSA-P256(SHA-256(canonical_bytes))``. ``expires_at``, + ``schema_version``, ``previous_hash``, ``canonicalization``, ``domain``, + ``skill_name``, ``signer_kid``, ``signed_at`` and ``file_manifest`` are + therefore all integrity-protected. + +``schemapin_version: "1.3"`` (legacy, verify-only): + +* Entries hashed as ``SHA-256(path || content)``, root hash ``SHA-256`` of the + concatenated hex digests, symlinks silently skipped, and the signature + covers only the 32-byte root hash. None of the metadata fields are signed; + verifiers surface a ``legacy_signature_format`` warning. + +Verification is fail-closed on expiration and on domain mismatch. """ import hashlib @@ -22,11 +41,14 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union +from .core import SchemaPinCore from .crypto import KeyManager, SignatureManager from .dns import DnsTxtRecord, verify_dns_match +from .domain import InvalidDomainError, validate_domain from .resolver import SchemaResolver from .revocation import RevocationDocument, check_revocation_combined from .verification import ( + EXPIRATION_SKEW_SECONDS, ErrorCode, KeyPinningStatus, KeyPinStore, @@ -35,10 +57,48 @@ ) SIGNATURE_FILENAME = ".schemapin.sig" -# Default ``schemapin_version`` written when no v1.4 features are used. -SCHEMAPIN_VERSION = "1.3" -# Version written when a v1.4 feature (e.g. expires_at) is present. +#: ``schemapin_version`` written on every new signature (signed-document format). +SCHEMAPIN_VERSION = "1.4" +#: Alias kept for callers that imported the v1.4 constant explicitly. SCHEMAPIN_VERSION_V1_4 = "1.4" +#: Legacy root-hash-only format; accepted on verification, never written. +SCHEMAPIN_VERSION_LEGACY = "1.3" + +#: Caps enforced by :meth:`SkillSigner.canonicalize_skill`. +MAX_SKILL_FILES = 10_000 +MAX_SKILL_TOTAL_BYTES = 256 * 1024 * 1024 + +#: Fields covered by a v1.4 signature document, in no particular order +#: (``schemapin-v1`` canonicalization sorts them). +SIGNED_FIELDS = ( + "schemapin_version", + "skill_name", + "skill_hash", + "signature", + "signed_at", + "expires_at", + "schema_version", + "previous_hash", + "canonicalization", + "domain", + "signer_kid", + "file_manifest", +) +_REQUIRED_FIELDS = ( + "schemapin_version", + "skill_name", + "skill_hash", + "signature", + "signed_at", + "domain", + "signer_kid", + "file_manifest", +) +_OPTIONAL_FIELDS = ("expires_at", "schema_version", "previous_hash", "canonicalization") + + +class SkillCanonicalizationError(ValueError): + """Raised when a skill directory cannot be canonicalized.""" @dataclass @@ -56,43 +116,148 @@ class SignOptions: signer_kid: Optional[str] = None skill_name: Optional[str] = None expires_in: Optional[timedelta] = None - # v1.4 alpha.2: caller-supplied semver string identifying *this* version of - # the signed artifact. Surfaced via VerificationResult for policy use. + # Caller-supplied semver string identifying *this* version of the signed + # artifact. Surfaced via VerificationResult for policy use. schema_version: Optional[str] = None - # v1.4 alpha.2: ``sha256:`` of the prior signed version's - # ``skill_hash``, forming a hash chain. Pair with :func:`verify_chain`. + # ``sha256:`` of the prior signed version's ``skill_hash``, forming a + # hash chain. Pair with :func:`verify_chain` / :class:`LineageStore`. previous_hash: Optional[str] = None - # v1.4 alpha.3: canonicalization algorithm identifier written to the - # signature. ``None`` is wire-equivalent to the implicit "schemapin-v1" - # default; pass ``"schemapin-v1"`` to declare it explicitly. + # Canonicalization algorithm identifier written to the signature. ``None`` + # is wire-equivalent to the implicit "schemapin-v1" default. canonicalization: Optional[str] = None +def _be64(n: int) -> bytes: + return n.to_bytes(8, "big") + + +def _version_tuple(version: Any) -> Optional[Tuple[int, int]]: + """Parse ``"major.minor"`` into a tuple, or ``None`` when malformed.""" + if not isinstance(version, str): + return None + m = re.match(r"^(\d+)\.(\d+)$", version) + if m is None: + return None + return int(m.group(1)), int(m.group(2)) + + +def _is_legacy_version(version: Any) -> bool: + parsed = _version_tuple(version) + return parsed is not None and parsed < (1, 4) + + class SkillSigner: """Sign and verify file-based skill folders using ECDSA P-256. - Mirrors the SchemaPinCore pattern — static/classmethod only. + Mirrors the SchemaPinCore pattern -- static/classmethod only. """ @staticmethod def canonicalize_skill( skill_dir: Union[str, Path], ) -> Tuple[bytes, Dict[str, str]]: - """Walk a skill directory deterministically and compute a root hash. - - Algorithm: - 1. os.walk() with sorted dirnames for deterministic order - 2. Skip .schemapin.sig and symlinks - 3. Normalize paths to forward slashes - 4. Per-file: sha256(rel_path_utf8 + file_bytes).hexdigest() - 5. Root: sha256(concat of all hexdigests, sorted by rel_path).digest() + """Walk a skill directory deterministically and compute the v2 root hash. + + Algorithm (spec section 4b): + 1. Walk the tree; directories are not entries. The top-level + ``.schemapin.sig`` is excluded. + 2. Any symlink (file or directory) or non-regular file aborts + canonicalization -- unsigned content must not be reachable from + a signed skill. + 3. Paths are relative, forward-slash separated, UTF-8, and sorted by + code point. + 4. Per entry: ``SHA-256(0x66 || be64(len(path)) || path || + be64(len(content)) || content)``. + 5. Root: ``SHA-256`` over ``be64(len(path)) || path || entry_digest`` + for every entry in sorted order. Returns: - Tuple of (root_hash_bytes, manifest) where manifest maps - relative paths to "sha256:". + Tuple of (root_hash_bytes, manifest) where manifest maps relative + paths to ``"sha256:"``. Raises: - ValueError: If the directory is empty (no files after filtering). + SkillCanonicalizationError: on symlinks, special files, non-UTF-8 + paths, an empty directory, or when the file-count / total-size + caps are exceeded. + """ + skill_path = Path(skill_dir) + if not skill_path.is_dir(): + raise SkillCanonicalizationError(f"not a directory: {skill_dir}") + skill_path = skill_path.resolve() + entries: List[Tuple[str, bytes]] = [] + total = 0 + + for dirpath, dirnames, filenames in os.walk(skill_path, followlinks=False): + current = Path(dirpath) + for d in dirnames: + if (current / d).is_symlink(): + raise SkillCanonicalizationError( + f"symlink not permitted in skill directory: {current / d}" + ) + dirnames.sort() + for fname in filenames: + full = current / fname + if full.is_symlink(): + raise SkillCanonicalizationError( + f"symlink not permitted in skill directory: {full}" + ) + if not full.is_file(): + raise SkillCanonicalizationError( + f"non-regular file in skill directory: {full}" + ) + if current == skill_path and fname == SIGNATURE_FILENAME: + continue + rel_str = full.relative_to(skill_path).as_posix() + try: + path_bytes = rel_str.encode("utf-8") + except UnicodeEncodeError as e: + raise SkillCanonicalizationError( + f"path is not valid UTF-8: {rel_str!r}" + ) from e + data = full.read_bytes() + total += len(data) + if len(entries) + 1 > MAX_SKILL_FILES: + raise SkillCanonicalizationError( + f"skill exceeds {MAX_SKILL_FILES} files" + ) + if total > MAX_SKILL_TOTAL_BYTES: + raise SkillCanonicalizationError( + f"skill exceeds {MAX_SKILL_TOTAL_BYTES} bytes" + ) + h = hashlib.sha256() + h.update(b"f") + h.update(_be64(len(path_bytes))) + h.update(path_bytes) + h.update(_be64(len(data))) + h.update(data) + entries.append((rel_str, h.digest())) + + if not entries: + raise SkillCanonicalizationError( + f"Skill directory is empty or contains no signable files: {skill_dir}" + ) + + entries.sort(key=lambda e: e[0]) + root = hashlib.sha256() + manifest: Dict[str, str] = {} + for rel_str, digest in entries: + path_bytes = rel_str.encode("utf-8") + root.update(_be64(len(path_bytes))) + root.update(path_bytes) + root.update(digest) + manifest[rel_str] = f"sha256:{digest.hex()}" + return root.digest(), manifest + + @staticmethod + def canonicalize_skill_v1( + skill_dir: Union[str, Path], + ) -> Tuple[bytes, Dict[str, str]]: + """Legacy (``schemapin_version: "1.3"``) skill canonicalization. + + Kept only so that pre-1.4 signatures still verify. Per-file digest is + ``SHA-256(path || content)``, the root hash is ``SHA-256`` of the + concatenated hex digests, symlinks are skipped and ``.schemapin.sig`` + is excluded at any depth. Do not use for new signatures. """ skill_path = Path(skill_dir).resolve() manifest: Dict[str, str] = {} @@ -105,9 +270,7 @@ def canonicalize_skill( full = Path(dirpath) / fname if full.is_symlink(): continue - rel = full.relative_to(skill_path) - # Normalize to forward slashes - rel_str = rel.as_posix() + rel_str = full.relative_to(skill_path).as_posix() file_bytes = full.read_bytes() digest = hashlib.sha256( rel_str.encode("utf-8") + file_bytes @@ -115,17 +278,12 @@ def canonicalize_skill( manifest[rel_str] = f"sha256:{digest}" if not manifest: - raise ValueError( + raise SkillCanonicalizationError( f"Skill directory is empty or contains no signable files: {skill_dir}" ) - # Root hash: sorted by rel_path, concat hex digests - sorted_digests = [ - manifest[k].split(":", 1)[1] for k in sorted(manifest) - ] - root_hash = hashlib.sha256( - "".join(sorted_digests).encode("utf-8") - ).digest() + sorted_digests = [manifest[k].split(":", 1)[1] for k in sorted(manifest)] + root_hash = hashlib.sha256("".join(sorted_digests).encode("utf-8")).digest() return root_hash, manifest @staticmethod @@ -152,7 +310,7 @@ def parse_skill_name(skill_dir: Union[str, Path]) -> str: ) if name_match: return name_match.group(1).strip() - except OSError: + except (OSError, UnicodeDecodeError): pass return skill_path.resolve().name @@ -164,10 +322,46 @@ def load_signature( Raises: FileNotFoundError: If .schemapin.sig does not exist. + ValueError: If the file is not a JSON object. """ sig_path = Path(skill_dir) / SIGNATURE_FILENAME text = sig_path.read_text(encoding="utf-8") - return json.loads(text) + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError(".schemapin.sig must contain a JSON object") + return data + + @staticmethod + def signing_input(signature_data: Dict[str, Any]) -> bytes: + """Build the canonical bytes a v1.4 signature covers. + + Takes the known signature fields (unknown fields are ignored and + therefore unprotected), drops absent / empty optional fields, forces + ``signature`` to ``""`` and canonicalizes with ``schemapin-v1``. + + Raises: + ValueError: if a required field is missing or has the wrong type. + """ + doc: Dict[str, Any] = {} + for key in _REQUIRED_FIELDS: + if key == "signature": + continue + value = signature_data.get(key) + if key == "file_manifest": + if not isinstance(value, dict): + raise ValueError("file_manifest must be an object") + elif not isinstance(value, str): + raise ValueError(f"signature document field {key!r} is missing") + doc[key] = value + for key in _OPTIONAL_FIELDS: + value = signature_data.get(key) + if value is None or value == "": + continue + if not isinstance(value, str): + raise ValueError(f"signature document field {key!r} must be a string") + doc[key] = value + doc["signature"] = "" + return SchemaPinCore.canonicalize_schema(doc).encode("utf-8") @staticmethod def sign_skill( @@ -179,8 +373,7 @@ def sign_skill( ) -> Dict[str, Any]: """Canonicalize a skill directory, sign, and write .schemapin.sig. - Backward-compatible v1.3 entry point. For v1.4 features (signature - expiration), use :meth:`sign_with_options`. + Convenience wrapper over :meth:`sign_with_options`. Args: skill_dir: Path to the skill folder. @@ -207,18 +400,18 @@ def sign_with_options( domain: str, options: SignOptions, ) -> Dict[str, Any]: - """Sign a skill directory with extended v1.4 options. + """Sign a skill directory and write a v1.4 signature document. - When ``options.expires_in`` is set, an ``expires_at`` ISO 8601 - timestamp (RFC 3339, ``Z`` suffix, second precision) is written and - ``schemapin_version`` becomes ``"1.4"``. Otherwise the document is - identical to :meth:`sign_skill` output (v1.3 wire format). + The whole document (including ``expires_at`` when + ``options.expires_in`` is set, written as RFC 3339 ``Z`` with second + precision) is covered by the signature. See the module docstring. - Verifiers past ``expires_at`` emit a ``signature_expired`` warning - but do **not** mark the result invalid -- expiration is a - confidence/policy signal, not a hard failure. + Raises: + InvalidDomainError: if ``domain`` is not a valid hostname. + SkillCanonicalizationError: if the directory cannot be hashed. """ skill_path = Path(skill_dir) + domain = validate_domain(domain) private_key = KeyManager.load_private_key_pem(private_key_pem) public_key = private_key.public_key() @@ -232,34 +425,19 @@ def sign_with_options( if signer_kid is None: signer_kid = KeyManager.calculate_key_fingerprint(public_key) - signature_b64 = SignatureManager.sign_hash(root_hash, private_key) - now = datetime.now(timezone.utc) signed_at = _format_rfc3339(now) expires_at: Optional[str] = None if options.expires_in is not None: expires_at = _format_rfc3339(now + options.expires_in) - uses_v1_4_field = ( - expires_at is not None - or options.schema_version is not None - or options.previous_hash is not None - or options.canonicalization is not None - ) - version = SCHEMAPIN_VERSION_V1_4 if uses_v1_4_field else SCHEMAPIN_VERSION - sig_doc: Dict[str, Any] = { - "schemapin_version": version, + "schemapin_version": SCHEMAPIN_VERSION, "skill_name": skill_name, "skill_hash": f"sha256:{root_hash.hex()}", - "signature": signature_b64, + "signature": "", "signed_at": signed_at, - "domain": domain, - "signer_kid": signer_kid, - "file_manifest": manifest, } - # Only emit v1.4 optional fields when set, so v1.3 verifiers see an - # unchanged document on the wire. if expires_at is not None: sig_doc["expires_at"] = expires_at if options.schema_version is not None: @@ -268,10 +446,17 @@ def sign_with_options( sig_doc["previous_hash"] = options.previous_hash if options.canonicalization is not None: sig_doc["canonicalization"] = options.canonicalization + sig_doc["domain"] = domain + sig_doc["signer_kid"] = signer_kid + sig_doc["file_manifest"] = manifest + + sig_doc["signature"] = SignatureManager.sign_hash( + SkillSigner.signing_input(sig_doc), private_key + ) sig_path = skill_path / SIGNATURE_FILENAME sig_path.write_text( - json.dumps(sig_doc, indent=2) + "\n", encoding="utf-8" + json.dumps(sig_doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) return sig_doc @@ -283,17 +468,25 @@ def verify_skill_offline( revocation_doc: Optional[RevocationDocument] = None, pin_store: Optional[KeyPinStore] = None, tool_id: Optional[str] = None, + domain: Optional[str] = None, + allow_expired: bool = False, ) -> VerificationResult: - """Verify a signed skill folder offline (7-step flow). - - Mirrors verify_schema_offline(): - 1. Load or accept signature data - 2. Validate discovery document - 3. Extract public key and compute fingerprint + """Verify a signed skill folder offline. + + Steps: + 1. Load or accept signature data; check ``canonicalization`` + 2. Bind the domain: when ``domain`` is given it must equal the + signature's ``domain`` (``DOMAIN_MISMATCH`` otherwise). Pass it + whenever you know which domain the discovery document belongs + to -- the signature's own ``domain`` field is display-only. + 3. Validate discovery document, load key, compute fingerprint 4. Check revocation - 5. TOFU key pinning - 6. Canonicalize skill and verify ECDSA signature - 7. Return structured result + 5. TOFU pin check (read-only) + 6. Recompute the root hash, require ``skill_hash`` to match, and + verify the signature (over the whole document for 1.4, over the + root hash for legacy 1.3) + 7. Expiration check (fail-closed unless ``allow_expired``) + 8. Pin on first use, return the result with lineage metadata """ skill_path = Path(skill_dir) @@ -301,18 +494,17 @@ def verify_skill_offline( if signature_data is None: try: signature_data = SkillSigner.load_signature(skill_path) - except FileNotFoundError: + except (FileNotFoundError, ValueError) as e: return VerificationResult( valid=False, error_code=ErrorCode.SIGNATURE_INVALID, - error_message="No .schemapin.sig found in skill directory", + error_message=f"No usable .schemapin.sig found in skill directory: {e}", ) - domain = signature_data.get("domain", "") + sig_domain = signature_data.get("domain") if tool_id is None: - tool_id = signature_data.get("skill_name", skill_path.name) + tool_id = signature_data.get("skill_name") or skill_path.name - # Step 1a (v1.4 alpha.3): canonicalization algorithm check. unsupported = check_canonicalization(signature_data.get("canonicalization")) if unsupported is not None: return VerificationResult( @@ -322,7 +514,39 @@ def verify_skill_offline( error_message=f"Unsupported canonicalization algorithm: {unsupported}", ) - # Step 2: Validate discovery document + # Step 2: Domain binding + warnings: List[str] = [] + if domain is not None: + try: + domain = validate_domain(domain) + except InvalidDomainError as e: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DOMAIN_INVALID, + error_message=str(e), + ) + if not isinstance(sig_domain, str) or sig_domain.lower() != domain: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DOMAIN_MISMATCH, + error_message=( + f"signature domain {sig_domain!r} does not match expected domain {domain!r}" + ), + ) + else: + try: + domain = validate_domain(sig_domain if isinstance(sig_domain, str) else "") + except InvalidDomainError as e: + return VerificationResult( + valid=False, + error_code=ErrorCode.DOMAIN_INVALID, + error_message=f"signature domain is invalid: {e}", + ) + warnings.append("domain_unbound") + + # Step 3: Validate discovery document public_key_pem = discovery.get("public_key_pem") if ( not public_key_pem @@ -335,7 +559,6 @@ def verify_skill_offline( error_message="Discovery document missing or invalid public_key_pem", ) - # Step 3: Extract public key and compute fingerprint try: public_key = KeyManager.load_public_key_pem(public_key_pem) fingerprint = KeyManager.calculate_key_fingerprint(public_key) @@ -350,9 +573,7 @@ def verify_skill_offline( # Step 4: Check revocation simple_revoked: List[str] = discovery.get("revoked_keys", []) try: - check_revocation_combined( - simple_revoked, revocation_doc, fingerprint - ) + check_revocation_combined(simple_revoked, revocation_doc, fingerprint) except ValueError as e: return VerificationResult( valid=False, @@ -361,10 +582,10 @@ def verify_skill_offline( error_message=str(e), ) - # Step 5: TOFU key pinning - pinning_status: Optional[KeyPinningStatus] = None + # Step 5: TOFU pin check (read-only until the signature verifies) + pin_result: Optional[str] = None if pin_store is not None: - pin_result = pin_store.check_and_pin(tool_id, domain, fingerprint) + pin_result = pin_store.check(tool_id, domain, fingerprint) if pin_result == "changed": return VerificationResult( valid=False, @@ -372,11 +593,22 @@ def verify_skill_offline( error_code=ErrorCode.KEY_PIN_MISMATCH, error_message="Key fingerprint changed since last use", ) - pinning_status = KeyPinningStatus(status=pin_result) - # Step 6: Canonicalize and verify signature + # Step 6: Canonicalize and verify + version = signature_data.get("schemapin_version") + if _version_tuple(version) is None: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.SIGNATURE_INVALID, + error_message=f"unsupported schemapin_version {version!r}", + ) + legacy = _is_legacy_version(version) try: - root_hash, _manifest = SkillSigner.canonicalize_skill(skill_path) + if legacy: + root_hash, _manifest = SkillSigner.canonicalize_skill_v1(skill_path) + else: + root_hash, _manifest = SkillSigner.canonicalize_skill(skill_path) except Exception as e: return VerificationResult( valid=False, @@ -386,9 +618,28 @@ def verify_skill_offline( ) signature_b64 = signature_data.get("signature", "") - valid = SignatureManager.verify_signature( - root_hash, signature_b64, public_key - ) + if legacy: + valid = SignatureManager.verify_signature(root_hash, signature_b64, public_key) + warnings.append("legacy_signature_format") + else: + expected_hash = f"sha256:{root_hash.hex()}" + if signature_data.get("skill_hash") != expected_hash: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.SIGNATURE_INVALID, + error_message="skill_hash does not match the directory contents", + ) + try: + signing_input = SkillSigner.signing_input(signature_data) + except ValueError as e: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.SIGNATURE_INVALID, + error_message=f"malformed signature document: {e}", + ) + valid = SignatureManager.verify_signature(signing_input, signature_b64, public_key) if not valid: return VerificationResult( @@ -398,16 +649,26 @@ def verify_skill_offline( error_message="Signature verification failed", ) - # Step 7: Return success, applying optional v1.4 expiration check - # and surfacing lineage metadata (schema_version + previous_hash). - developer_name = discovery.get("developer_name") + # Step 7: Expiration (fail-closed by default) result = VerificationResult( valid=True, domain=domain, - developer_name=developer_name, - key_pinning=pinning_status, + developer_name=discovery.get("developer_name"), + warnings=warnings, + ) + result = result.with_expiration_check( + signature_data.get("expires_at"), + allow_expired=allow_expired, + skew_seconds=EXPIRATION_SKEW_SECONDS, ) - result = result.with_expiration_check(signature_data.get("expires_at")) + if not result.valid: + return result + + # Step 8: Pin only now, then surface lineage metadata + if pin_store is not None and pin_result == "first_use": + pin_store.pin(tool_id, domain, fingerprint) + if pin_result is not None: + result.key_pinning = KeyPinningStatus(status=pin_result) return result.with_lineage_metadata( signature_data.get("schema_version"), signature_data.get("previous_hash"), @@ -422,37 +683,36 @@ def verify_skill_offline_with_dns( pin_store: Optional[KeyPinStore] = None, tool_id: Optional[str] = None, dns_txt: Optional[DnsTxtRecord] = None, + domain: Optional[str] = None, + allow_expired: bool = False, ) -> VerificationResult: """Offline verify with an additional DNS TXT cross-check (v1.4). - Behaves identically to :meth:`verify_skill_offline`, then -- when - ``dns_txt`` is not ``None`` -- verifies that the DNS TXT record's - fingerprint matches the discovery key. A mismatch converts the - result into a hard failure with - :attr:`ErrorCode.DOMAIN_MISMATCH`. When ``dns_txt`` is ``None``, - behaviour is unchanged (DNS TXT is an optional, additive trust - signal). + Behaves identically to :meth:`verify_skill_offline`, with a DNS TXT + fingerprint cross-check performed first when ``dns_txt`` is given. A + mismatch is a hard :attr:`ErrorCode.DOMAIN_MISMATCH` failure and + nothing is pinned. """ - result = SkillSigner.verify_skill_offline( + if dns_txt is not None: + try: + verify_dns_match(discovery, dns_txt) + except ValueError as e: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DOMAIN_MISMATCH, + error_message=str(e), + ) + return SkillSigner.verify_skill_offline( skill_dir, discovery, signature_data=signature_data, revocation_doc=revocation_doc, pin_store=pin_store, tool_id=tool_id, + domain=domain, + allow_expired=allow_expired, ) - if not result.valid or dns_txt is None: - return result - try: - verify_dns_match(discovery, dns_txt) - except ValueError as e: - return VerificationResult( - valid=False, - domain=result.domain, - error_code=ErrorCode.DOMAIN_MISMATCH, - error_message=str(e), - ) - return result @staticmethod def verify_skill_with_resolver( @@ -461,19 +721,61 @@ def verify_skill_with_resolver( resolver: Optional[SchemaResolver] = None, pin_store: Optional[KeyPinStore] = None, tool_id: Optional[str] = None, + allow_expired: bool = False, + allow_unreachable_revocation: bool = False, ) -> VerificationResult: """Verify a signed skill folder using a resolver for discovery. + The signature's ``domain`` must equal ``domain`` (checked before any + network I/O); the discovery document is fetched for ``domain`` and + the pin is keyed by it. + Args: skill_dir: Path to the skill folder. - domain: Domain to resolve discovery for. + domain: Domain to resolve discovery for and bind the skill to. resolver: SchemaResolver instance. If None, uses WellKnownResolver. pin_store: Optional TOFU pin store. tool_id: Optional tool identifier for pinning. + allow_expired: Accept expired signatures (degraded) instead of + failing with ``SIGNATURE_EXPIRED``. + allow_unreachable_revocation: Treat a revocation fetch failure + as "no revocations" instead of failing closed. Returns: VerificationResult. """ + from .revocation import RevocationFetchError + + try: + domain = validate_domain(domain) + except InvalidDomainError as e: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DOMAIN_INVALID, + error_message=str(e), + ) + + try: + signature_data = SkillSigner.load_signature(skill_dir) + except (FileNotFoundError, ValueError) as e: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.SIGNATURE_INVALID, + error_message=f"No usable .schemapin.sig found in skill directory: {e}", + ) + sig_domain = signature_data.get("domain") + if not isinstance(sig_domain, str) or sig_domain.lower() != domain: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DOMAIN_MISMATCH, + error_message=( + f"signature domain {sig_domain!r} does not match expected domain {domain!r}" + ), + ) + if resolver is None: from .resolver import WellKnownResolver @@ -488,14 +790,27 @@ def verify_skill_with_resolver( error_message=f"Could not resolve discovery for domain: {domain}", ) - revocation = resolver.resolve_revocation(domain, discovery) + try: + revocation = resolver.resolve_revocation(domain, discovery) + except RevocationFetchError as e: + if not allow_unreachable_revocation: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DISCOVERY_FETCH_FAILED, + error_message=f"Revocation document unreachable (fail-closed): {e}", + ) + revocation = None return SkillSigner.verify_skill_offline( skill_dir, discovery, + signature_data=signature_data, revocation_doc=revocation, pin_store=pin_store, tool_id=tool_id, + domain=domain, + allow_expired=allow_expired, ) @staticmethod @@ -505,8 +820,12 @@ def detect_tampered_files( ) -> Dict[str, List[str]]: """Compare current file manifest against the signed manifest. + Only meaningful for ``schemapin_version >= "1.4"`` documents, whose + ``file_manifest`` is covered by the signature; for legacy 1.3 documents + the manifest is unsigned and this is a diagnostic aid only. + Returns: - Dict with keys "modified", "added", "removed" — each a list of + Dict with keys "modified", "added", "removed" -- each a list of relative file paths. """ current_keys = set(current_manifest) @@ -546,28 +865,27 @@ class ChainError(ValueError): def verify_chain(current: Dict[str, Any], previous: Dict[str, Any]) -> None: - """Verify ``current`` is the legitimate successor of ``previous`` (v1.4 alpha.2). + """Verify ``current`` is the legitimate successor of ``previous``. Checks ``current["previous_hash"] == previous["skill_hash"]``. This is a pure-metadata check -- no cryptography is re-evaluated. Both signatures must already be cryptographically verified separately via :meth:`SkillSigner.verify_skill_offline` for the chain check to be - meaningful. - - Use this to defend against rug-pull attacks where an attacker substitutes - a schema/skill out-of-band: a legitimate update declares the prior - version's hash; an unauthorized substitution either omits ``previous_hash`` - or points at a hash the verifier has not accepted as a valid ancestor. - - Args: - current: The new signature dict (must have ``previous_hash`` set). - previous: The prior signature dict (must have ``skill_hash`` set). + meaningful, and both must be ``schemapin_version >= "1.4"`` documents so + that ``skill_hash`` and ``previous_hash`` are actually covered by their + signatures (legacy 1.3 documents are rejected). Raises: - ChainError: If ``current`` has no ``previous_hash`` field, or if it - does not match ``previous["skill_hash"]``. + ChainError: If ``current`` has no ``previous_hash`` field, if it + does not match ``previous["skill_hash"]``, or if either document + is a legacy 1.3 signature. """ + for label, doc in (("current", current), ("previous", previous)): + if _is_legacy_version(doc.get("schemapin_version")): + raise ChainError( + f"{label} signature is a legacy 1.3 document; its skill_hash is unsigned" + ) claimed = current.get("previous_hash") if claimed is None: raise ChainError("current signature has no previous_hash field") diff --git a/python/schemapin/verification.py b/python/schemapin/verification.py index 4de3039..2a5cfbe 100644 --- a/python/schemapin/verification.py +++ b/python/schemapin/verification.py @@ -1,13 +1,16 @@ """Offline and resolver-based schema verification for SchemaPin v1.2.""" import json +import re from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from enum import Enum from typing import Any, Dict, List, Optional from .core import SchemaPinCore from .crypto import KeyManager, SignatureManager +from .dns import DnsTxtRecord, verify_dns_match +from .domain import InvalidDomainError, validate_domain from .resolver import SchemaResolver from .revocation import RevocationDocument, check_revocation_combined @@ -35,6 +38,18 @@ class ErrorCode(Enum): BUNDLE_UNSIGNED = "bundle_unsigned" # v1.4: a signed trust bundle's expires_at is in the past (or unparseable). BUNDLE_EXPIRED = "bundle_expired" + # A signature's ``expires_at`` is in the past (or unparseable) and the + # caller did not opt in with ``allow_expired=True``. Hard failure. + SIGNATURE_EXPIRED = "signature_expired" + # The caller-supplied domain is not a valid RFC 1123 hostname. + DOMAIN_INVALID = "domain_invalid" + # A trust bundle's authority key is not pre-seeded in the authority store + # and the caller did not opt in to first-use trust. + BUNDLE_AUTHORITY_UNTRUSTED = "bundle_authority_untrusted" + + +#: Clock-skew tolerance applied to ``expires_at`` comparisons (seconds). +EXPIRATION_SKEW_SECONDS = 300 # v1.4 alpha.3: canonicalization algorithm identifier @@ -122,19 +137,25 @@ def to_dict(self) -> Dict[str, Any]: return d def with_expiration_check( - self, expires_at: Optional[str] + self, + expires_at: Optional[str], + allow_expired: bool = False, + skew_seconds: int = EXPIRATION_SKEW_SECONDS, ) -> "VerificationResult": """Apply a signature ``expires_at`` check to this result. - Semantics (mirrors the Rust ``VerificationResult::with_expiration_check``): + Expiration is **fail-closed** by default: - - If ``expires_at`` is ``None``, the result is returned unchanged. - - If parseable (RFC 3339) and in the past, sets ``expired = True``, - copies ``expires_at``, and appends a ``signature_expired`` warning. - ``valid`` is left intact (degraded, not failed). - - If parseable and in the future, just records ``expires_at``. - - If unparseable, appends ``signature_expires_at_unparseable`` and - does not mark the result expired (fail-open on parse). + - ``expires_at`` of ``None`` leaves the result unchanged. + - A value that is not strict RFC 3339 fails with + ``SIGNATURE_EXPIRED`` (``allow_expired=True`` downgrades this to a + ``signature_expires_at_unparseable`` warning). + - A timestamp more than ``skew_seconds`` in the past fails with + ``SIGNATURE_EXPIRED`` and sets ``expired = True``. With + ``allow_expired=True`` the result stays valid but is marked + ``expired`` and carries a ``signature_expired`` warning + (the pre-1.4.0 "degraded, not failed" behavior). + - A future timestamp just records ``expires_at``. Returns ``self`` for chaining. """ @@ -142,12 +163,22 @@ def with_expiration_check( return self ts = _parse_rfc3339(expires_at) if ts is None: - self.warnings.append("signature_expires_at_unparseable") + if allow_expired: + self.warnings.append("signature_expires_at_unparseable") + return self + self.valid = False + self.error_code = ErrorCode.SIGNATURE_EXPIRED + self.error_message = f"expires_at is not a valid RFC 3339 timestamp: {expires_at!r}" return self self.expires_at = expires_at - if datetime.now(timezone.utc) > ts: + if datetime.now(timezone.utc) > ts + timedelta(seconds=skew_seconds): self.expired = True - self.warnings.append("signature_expired") + if allow_expired: + self.warnings.append("signature_expired") + else: + self.valid = False + self.error_code = ErrorCode.SIGNATURE_EXPIRED + self.error_message = f"signature expired at {expires_at}" return self def with_lineage_metadata( @@ -170,22 +201,25 @@ def with_lineage_metadata( return self +_RFC3339_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$" +) + + def _parse_rfc3339(value: str) -> Optional[datetime]: - """Parse an RFC 3339 timestamp. + """Parse a strict RFC 3339 timestamp (``T`` separator, mandatory offset). - Accepts both ``...Z`` and ``...+HH:MM`` forms. Returns ``None`` on any - parse failure so callers can branch on success. + Accepts ``...Z`` and ``...+HH:MM`` forms only. Date-only strings, local + times without an offset and other ISO 8601 variants return ``None``. """ + if not isinstance(value, str) or not _RFC3339_RE.match(value): + return None try: - # Python 3.11+ accepts trailing Z; for 3.8-3.10 normalize manually. - normalized = value.strip() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" + normalized = value[:-1] + "+00:00" if value.endswith("Z") else value ts = datetime.fromisoformat(normalized) except (TypeError, ValueError): return None if ts.tzinfo is None: - # RFC 3339 requires a TZ offset; treat naive timestamps as malformed. return None return ts.astimezone(timezone.utc) @@ -193,7 +227,10 @@ def _parse_rfc3339(value: str) -> Optional[datetime]: class KeyPinStore: """Lightweight in-memory fingerprint-based pin store. - Keys are stored by tool_id@domain. + Keys are stored by ``tool_id@domain``. Verification flows use + :meth:`check` before the signature check and :meth:`pin` only after it + succeeds, so a failed verification never leaves a pin behind. Operators + can pre-seed expected fingerprints with :meth:`seed`. """ def __init__(self) -> None: @@ -202,24 +239,44 @@ def __init__(self) -> None: def _key(self, tool_id: str, domain: str) -> str: return f"{tool_id}@{domain}" + def check(self, tool_id: str, domain: str, fingerprint: str) -> str: + """Compare ``fingerprint`` with the pinned value without mutating. + + Returns: + "first_use" if nothing is pinned for this tool@domain, + "pinned" if the fingerprint matches, + "changed" if a different fingerprint is pinned. + """ + existing = self._pins.get(self._key(tool_id, domain)) + if existing is None: + return "first_use" + return "pinned" if existing == fingerprint else "changed" + + def pin(self, tool_id: str, domain: str, fingerprint: str) -> None: + """Pin ``fingerprint`` for ``tool_id@domain`` (overwrites).""" + self._pins[self._key(tool_id, domain)] = fingerprint + + def seed(self, tool_id: str, domain: str, fingerprint: str) -> None: + """Pre-seed an expected fingerprint (out-of-band trust establishment).""" + self.pin(tool_id, domain, fingerprint) + def check_and_pin( self, tool_id: str, domain: str, fingerprint: str ) -> str: - """Check and optionally pin a key fingerprint. + """Check and pin on first use in one step. + + Prefer :meth:`check` + :meth:`pin` so that pinning can be deferred + until after signature verification. Returns: "first_use" if this is a new tool@domain (key is pinned). "pinned" if the fingerprint matches the pinned key. "changed" if the fingerprint differs from the pinned key. """ - k = self._key(tool_id, domain) - existing = self._pins.get(k) - if existing is None: - self._pins[k] = fingerprint - return "first_use" - if existing == fingerprint: - return "pinned" - return "changed" + result = self.check(tool_id, domain, fingerprint) + if result == "first_use": + self.pin(tool_id, domain, fingerprint) + return result def get_pinned(self, tool_id: str, domain: str) -> Optional[str]: """Get the pinned fingerprint for a tool@domain, or None.""" @@ -233,7 +290,12 @@ def to_json(self) -> str: def from_json(cls, json_str: str) -> "KeyPinStore": """Deserialize a pin store from JSON.""" store = cls() - store._pins = json.loads(json_str) + data = json.loads(json_str) + if not isinstance(data, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in data.items() + ): + raise ValueError("pin store JSON must be an object of string fingerprints") + store._pins = data return store @@ -249,22 +311,22 @@ def verify_schema_offline( ) -> VerificationResult: """Verify a schema offline using pre-fetched discovery and revocation data. - 7-step verification flow: - 1. Validate discovery document - 2. Extract public key and compute fingerprint - 3. Check revocation (both simple list + standalone doc) - 4. TOFU key pinning check - 5. Canonicalize schema and compute hash - 6. Verify ECDSA signature against hash - 7. Return structured result - - ``canonicalization`` is a v1.4 alpha.3 addition: when the signature - carries a ``canonicalization`` field, the caller passes the value through - to this function. ``None`` or ``"schemapin-v1"`` are equivalent and - accepted; any other value fails with ``CANONICALIZATION_UNSUPPORTED`` - before any crypto work. + Verification flow: + 1. Validate the canonicalization identifier and the domain syntax + 2. Validate discovery document + 3. Extract public key and compute fingerprint + 4. Check revocation (both simple list + standalone doc) + 5. TOFU pin check (read-only) + 6. Canonicalize schema, compute hash, verify ECDSA signature + 7. Pin the key on first use (only after a successful verification) + 8. Return structured result + + ``canonicalization`` is a v1.4 addition: when the signature carries a + ``canonicalization`` field, the caller passes the value through. + ``None`` or ``"schemapin-v1"`` are equivalent and accepted; any other + value fails with ``CANONICALIZATION_UNSUPPORTED`` before any crypto work. """ - # Step 0 (v1.4): canonicalization algorithm check + # Step 1: canonicalization algorithm + domain syntax unsupported = check_canonicalization(canonicalization) if unsupported is not None: return VerificationResult( @@ -273,8 +335,17 @@ def verify_schema_offline( error_code=ErrorCode.CANONICALIZATION_UNSUPPORTED, error_message=f"Unsupported canonicalization algorithm: {unsupported}", ) + try: + domain = validate_domain(domain) + except InvalidDomainError as e: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DOMAIN_INVALID, + error_message=str(e), + ) - # Step 1: Validate discovery document + # Step 2: Validate discovery document public_key_pem = discovery.get("public_key_pem") if not public_key_pem or "-----BEGIN PUBLIC KEY-----" not in public_key_pem: return VerificationResult( @@ -284,7 +355,7 @@ def verify_schema_offline( error_message="Discovery document missing or invalid public_key_pem", ) - # Step 2: Extract public key and compute fingerprint + # Step 3: Extract public key and compute fingerprint try: public_key = KeyManager.load_public_key_pem(public_key_pem) fingerprint = KeyManager.calculate_key_fingerprint(public_key) @@ -296,7 +367,7 @@ def verify_schema_offline( error_message=f"Failed to load public key: {e}", ) - # Step 3: Check revocation + # Step 4: Check revocation simple_revoked = discovery.get("revoked_keys", []) try: check_revocation_combined(simple_revoked, revocation, fingerprint) @@ -308,8 +379,8 @@ def verify_schema_offline( error_message=str(e), ) - # Step 4: TOFU key pinning - pin_result = pin_store.check_and_pin(tool_id, domain, fingerprint) + # Step 5: TOFU pin check (no write yet) + pin_result = pin_store.check(tool_id, domain, fingerprint) if pin_result == "changed": return VerificationResult( valid=False, @@ -318,7 +389,7 @@ def verify_schema_offline( error_message="Key fingerprint changed since last use", ) - # Step 5: Canonicalize and hash + # Step 6: Canonicalize, hash, verify try: schema_hash = SchemaPinCore.canonicalize_and_hash(schema) except Exception as e: @@ -329,7 +400,6 @@ def verify_schema_offline( error_message=f"Failed to canonicalize schema: {e}", ) - # Step 6: Verify signature valid = SignatureManager.verify_schema_signature( schema_hash, signature_b64, public_key ) @@ -342,7 +412,11 @@ def verify_schema_offline( error_message="Signature verification failed", ) - # Step 7: Return success + # Step 7: Pin only after the signature verified + if pin_result == "first_use": + pin_store.pin(tool_id, domain, fingerprint) + + # Step 8: Return success developer_name = discovery.get("developer_name") pinning_status = KeyPinningStatus( status=pin_result, @@ -366,6 +440,46 @@ def verify_schema_offline( return result +def verify_schema_offline_with_dns( + schema: Dict[str, Any], + signature_b64: str, + domain: str, + tool_id: str, + discovery: Dict[str, Any], + revocation: Optional[RevocationDocument], + pin_store: KeyPinStore, + dns_txt: Optional[DnsTxtRecord], + canonicalization: Optional[str] = None, +) -> VerificationResult: + """:func:`verify_schema_offline` plus a DNS TXT second-channel check. + + When ``dns_txt`` is given (see :func:`schemapin.dns.fetch_dns_txt`), the + discovery key's fingerprint must match the TXT record or the result is a + hard ``DOMAIN_MISMATCH`` failure. The DNS check runs *before* the pin is + written so a mismatching key is never pinned. + """ + if dns_txt is not None: + try: + verify_dns_match(discovery, dns_txt) + except ValueError as e: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DOMAIN_MISMATCH, + error_message=str(e), + ) + return verify_schema_offline( + schema, + signature_b64, + domain, + tool_id, + discovery, + revocation, + pin_store, + canonicalization, + ) + + def verify_schema_with_resolver( schema: Dict[str, Any], signature_b64: str, @@ -373,8 +487,26 @@ def verify_schema_with_resolver( tool_id: str, resolver: SchemaResolver, pin_store: KeyPinStore, + allow_unreachable_revocation: bool = False, ) -> VerificationResult: - """Verify a schema using a resolver for discovery and revocation.""" + """Verify a schema using a resolver for discovery and revocation. + + Revocation is fail-closed: if the discovery document declares a + ``revocation_endpoint`` that cannot be fetched, verification fails with + ``DISCOVERY_FETCH_FAILED`` unless ``allow_unreachable_revocation=True``. + """ + from .revocation import RevocationFetchError + + try: + domain = validate_domain(domain) + except InvalidDomainError as e: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DOMAIN_INVALID, + error_message=str(e), + ) + # Resolve discovery discovery = resolver.resolve_discovery(domain) if discovery is None: @@ -385,8 +517,18 @@ def verify_schema_with_resolver( error_message=f"Could not resolve discovery for domain: {domain}", ) - # Resolve revocation - revocation = resolver.resolve_revocation(domain, discovery) + # Resolve revocation (fail-closed) + try: + revocation = resolver.resolve_revocation(domain, discovery) + except RevocationFetchError as e: + if not allow_unreachable_revocation: + return VerificationResult( + valid=False, + domain=domain, + error_code=ErrorCode.DISCOVERY_FETCH_FAILED, + error_message=f"Revocation document unreachable (fail-closed): {e}", + ) + revocation = None return verify_schema_offline( schema, signature_b64, domain, tool_id, discovery, revocation, pin_store diff --git a/python/tests/test_cross_language.py b/python/tests/test_cross_language.py index b046aac..458398e 100644 --- a/python/tests/test_cross_language.py +++ b/python/tests/test_cross_language.py @@ -90,3 +90,65 @@ def test_non_p256_public_keys_rejected(name): KeyManager.load_public_key_pem(pem) with pytest.raises(ValueError): KeyManager.calculate_key_fingerprint_from_pem(pem) + + +# --------------------------------------------------------------------------- +# Skill-directory fixtures (SP-1, SP-12, SP-13, SP-14) +# --------------------------------------------------------------------------- + +SKILL_FIXTURES = os.path.join(ROOT, "tests", "cross-language", "skill") + + +@pytest.fixture +def skill_expected(): + return _load_json(os.path.join(SKILL_FIXTURES, "expected.json")) + + +def test_skill_fixture_v14_hashes_and_verifies(skill_expected): + from schemapin.skill import SkillSigner + + skill_dir = os.path.join(SKILL_FIXTURES, skill_expected["v14"]["skill_dir"]) + root_hash, manifest = SkillSigner.canonicalize_skill(skill_dir) + assert f"sha256:{root_hash.hex()}" == skill_expected["v14"]["skill_hash"] + assert manifest == skill_expected["v14"]["file_manifest"] + result = SkillSigner.verify_skill_offline( + skill_dir, skill_expected["discovery"], domain=skill_expected["domain"] + ) + assert result.valid is True, result + assert "legacy_signature_format" not in result.warnings + assert result.schema_version == "1.0.0" + + +def test_skill_fixture_v14_tampered_fields_fail(skill_expected, tmp_path): + import shutil + + from schemapin.skill import SIGNATURE_FILENAME, SkillSigner + + src = os.path.join(SKILL_FIXTURES, skill_expected["v14"]["skill_dir"]) + for field in skill_expected["v14"]["signed_fields_tamper_must_fail"]: + dst = tmp_path / field + shutil.copytree(src, dst) + sig = SkillSigner.load_signature(dst) + sig[field] = "tampered" if field != "skill_hash" else "sha256:" + "00" * 32 + (dst / SIGNATURE_FILENAME).write_text(json.dumps(sig), encoding="utf-8") + result = SkillSigner.verify_skill_offline(dst, skill_expected["discovery"]) + assert result.valid is False, field + # Content tamper + dst = tmp_path / "content" + shutil.copytree(src, dst) + (dst / "scripts" / "run.sh").write_text("#!/bin/sh\nrm -rf /\n") + assert SkillSigner.verify_skill_offline(dst, skill_expected["discovery"]).valid is False + + +def test_skill_fixture_legacy_v13_verifies(skill_expected): + from schemapin.skill import SkillSigner + + skill_dir = os.path.join(SKILL_FIXTURES, skill_expected["legacy_v13"]["skill_dir"]) + root_hash, manifest = SkillSigner.canonicalize_skill_v1(skill_dir) + assert f"sha256:{root_hash.hex()}" == skill_expected["legacy_v13"]["skill_hash"] + assert manifest == skill_expected["legacy_v13"]["file_manifest"] + result = SkillSigner.verify_skill_offline( + skill_dir, skill_expected["discovery"], domain=skill_expected["domain"] + ) + assert result.valid is True, result + assert "legacy_signature_format" in result.warnings diff --git a/python/tests/test_skill.py b/python/tests/test_skill.py index 2d42a32..81e7849 100644 --- a/python/tests/test_skill.py +++ b/python/tests/test_skill.py @@ -8,6 +8,7 @@ import pytest from schemapin.crypto import KeyManager, SignatureManager +from schemapin.resolver import SchemaResolver from schemapin.revocation import ( RevocationReason, add_revoked_key, @@ -17,6 +18,7 @@ SIGNATURE_FILENAME, ChainError, SignOptions, + SkillCanonicalizationError, SkillSigner, verify_chain, ) @@ -255,7 +257,7 @@ def test_sig_structure(self, tmp_path: Path) -> None: {"SKILL.md": "---\nname: test-skill\n---\n# Hello"}, ) sig = SkillSigner.sign_skill(skill, priv_pem, "example.com") - assert sig["schemapin_version"] == "1.3" + assert sig["schemapin_version"] == "1.4" assert sig["skill_name"] == "test-skill" assert sig["skill_hash"].startswith("sha256:") assert isinstance(sig["signature"], str) @@ -562,13 +564,20 @@ def test_sign_and_verify_roundtrip(self, tmp_path: Path) -> None: ) assert sig_doc["skill_name"] == "cli-test" - # Verify with public key + # Verify with public key: the signature covers the whole document, + # so verification goes through verify_skill_offline with a synthetic + # discovery document (exactly what the CLI --public-key path does). root_hash, _manifest = SkillSigner.canonicalize_skill(skill) + assert sig_doc["skill_hash"] == f"sha256:{root_hash.hex()}" pub_key = KeyManager.load_public_key_pem(pub_pem) valid = SignatureManager.verify_signature( - root_hash, sig_doc["signature"], pub_key + SkillSigner.signing_input(sig_doc), sig_doc["signature"], pub_key ) assert valid is True + result = SkillSigner.verify_skill_offline( + skill, {"schema_version": "1.4", "public_key_pem": pub_pem}, domain="example.com" + ) + assert result.valid is True def test_sign_requires_domain(self, tmp_path: Path) -> None: """sign_skill requires a domain argument.""" @@ -610,7 +619,7 @@ def test_sign_with_ttl_writes_expires_at(self, tmp_path: Path) -> None: assert on_disk["expires_at"] == sig["expires_at"] def test_sign_without_ttl_omits_expires_at(self, tmp_path: Path) -> None: - """Default ``sign_skill`` keeps the v1.3 wire format unchanged.""" + """Default ``sign_skill`` writes no ``expires_at`` (signed-document format).""" priv_pem, _pub_pem = _make_keypair() skill = _create_skill_dir( tmp_path / "skill", @@ -618,7 +627,7 @@ def test_sign_without_ttl_omits_expires_at(self, tmp_path: Path) -> None: ) sig = SkillSigner.sign_skill(skill, priv_pem, "example.com") assert "expires_at" not in sig - assert sig["schemapin_version"] == "1.3" + assert sig["schemapin_version"] == "1.4" raw = (skill / SIGNATURE_FILENAME).read_text(encoding="utf-8") assert "expires_at" not in raw @@ -639,53 +648,88 @@ def test_verify_with_future_ttl_passes_no_warning(self, tmp_path: Path) -> None: assert result.expires_at is not None assert "signature_expired" not in result.warnings - def test_verify_with_past_ttl_passes_with_expired_warning( - self, tmp_path: Path - ) -> None: - """Past expiration degrades the result (warning) but stays valid.""" + def test_verify_with_past_ttl_fails_closed(self, tmp_path: Path) -> None: + """Past expiration is a hard SIGNATURE_EXPIRED failure by default.""" priv_pem, pub_pem = _make_keypair() skill = _create_skill_dir( tmp_path / "skill", {"SKILL.md": "---\nname: past\n---\n# hi"}, ) - # Sign normally, then rewrite ``.schemapin.sig`` with a past - # ``expires_at`` to simulate an aged signature. - SkillSigner.sign_skill(skill, priv_pem, "example.com") - sig = SkillSigner.load_signature(skill) - sig["schemapin_version"] = "1.4" - sig["expires_at"] = "2020-01-01T00:00:00Z" - (skill / SIGNATURE_FILENAME).write_text( - json.dumps(sig, indent=2) + "\n", encoding="utf-8" - ) + # Sign with a TTL that is already in the past (allowed at sign time). + opts = SignOptions(expires_in=timedelta(days=-30)) + SkillSigner.sign_with_options(skill, priv_pem, "example.com", opts) result = SkillSigner.verify_skill_offline( skill, _discovery(pub_pem), tool_id="past" ) - assert result.valid is True, "expired sigs are degraded, not failed" + assert result.valid is False + assert result.error_code == ErrorCode.SIGNATURE_EXPIRED assert result.expired is True - assert "signature_expired" in result.warnings - assert result.expires_at == "2020-01-01T00:00:00Z" + assert result.expires_at is not None - def test_verify_with_unparseable_expires_at_warns(self, tmp_path: Path) -> None: - """Unparseable expires_at triggers a warning, not expired/fail.""" + def test_verify_with_past_ttl_allow_expired_degrades(self, tmp_path: Path) -> None: + """``allow_expired=True`` restores the degraded-not-failed behavior.""" priv_pem, pub_pem = _make_keypair() skill = _create_skill_dir( tmp_path / "skill", - {"SKILL.md": "---\nname: bad\n---\n# hi"}, - ) - SkillSigner.sign_skill(skill, priv_pem, "example.com") - sig = SkillSigner.load_signature(skill) - sig["expires_at"] = "not-a-timestamp" - (skill / SIGNATURE_FILENAME).write_text( - json.dumps(sig, indent=2) + "\n", encoding="utf-8" + {"SKILL.md": "---\nname: past\n---\n# hi"}, ) + opts = SignOptions(expires_in=timedelta(days=-30)) + SkillSigner.sign_with_options(skill, priv_pem, "example.com", opts) result = SkillSigner.verify_skill_offline( - skill, _discovery(pub_pem), tool_id="bad" + skill, _discovery(pub_pem), tool_id="past", allow_expired=True ) assert result.valid is True - assert result.expired is False - assert "signature_expires_at_unparseable" in result.warnings + assert result.expired is True + assert "signature_expired" in result.warnings + + def test_expiration_skew_window(self) -> None: + """A timestamp inside the skew window is not expired.""" + from datetime import datetime, timezone + + from schemapin.verification import VerificationResult + + just_now = (datetime.now(timezone.utc) - timedelta(seconds=60)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + r = VerificationResult(valid=True).with_expiration_check(just_now) + assert r.valid is True and r.expired is False + long_ago = "2020-01-01T00:00:00Z" + r = VerificationResult(valid=True).with_expiration_check(long_ago) + assert r.valid is False and r.error_code == ErrorCode.SIGNATURE_EXPIRED + + def test_unparseable_expires_at_fails_closed(self) -> None: + """Unparseable / non-RFC-3339 expires_at is rejected unless allow_expired.""" + from schemapin.verification import VerificationResult + + for bad in ("not-a-timestamp", "2020-01-01", "2020-01-01T00:00:00", "2020-01-01 00:00:00Z"): + r = VerificationResult(valid=True).with_expiration_check(bad) + assert r.valid is False, bad + assert r.error_code == ErrorCode.SIGNATURE_EXPIRED + r = VerificationResult(valid=True).with_expiration_check(bad, allow_expired=True) + assert r.valid is True + assert "signature_expires_at_unparseable" in r.warnings + + def test_expires_at_is_covered_by_signature(self, tmp_path: Path) -> None: + """SP-1 regression: editing expires_at after signing invalidates the signature.""" + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir( + tmp_path / "skill", + {"SKILL.md": "---\nname: strip\n---\n# hi"}, + ) + opts = SignOptions(expires_in=timedelta(days=-30)) + SkillSigner.sign_with_options(skill, priv_pem, "example.com", opts) + sig = SkillSigner.load_signature(skill) + # Expiry stripping: delete expires_at (and try the v1.3 downgrade). + del sig["expires_at"] + (skill / SIGNATURE_FILENAME).write_text(json.dumps(sig), encoding="utf-8") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem), tool_id="strip") + assert r.valid is False and r.error_code == ErrorCode.SIGNATURE_INVALID + sig["schemapin_version"] = "1.3" + (skill / SIGNATURE_FILENAME).write_text(json.dumps(sig), encoding="utf-8") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem), tool_id="strip") + assert r.valid is False and r.error_code == ErrorCode.SIGNATURE_INVALID def test_to_dict_includes_v1_4_fields(self, tmp_path: Path) -> None: """Serialized result surfaces ``expired`` / ``expires_at`` only when set.""" @@ -811,3 +855,253 @@ def test_combined_v1_4_fields_round_trip(self, tmp_path: Path) -> None: assert on_disk["expires_at"] == sig["expires_at"] assert on_disk["schema_version"] == "1.0.0" assert on_disk["previous_hash"] == "sha256:cafebabe" + + +# --------------------------------------------------------------------------- +# Signed signature document, domain binding, hashing v2 (SP-1, 8, 12, 13, 14) +# --------------------------------------------------------------------------- + + +def _sign_legacy_v13(skill: Path, priv_pem: str, domain: str) -> Dict[str, Any]: + """Produce a pre-1.4 root-hash-only signature document for compatibility tests.""" + root_hash, manifest = SkillSigner.canonicalize_skill_v1(skill) + priv = KeyManager.load_private_key_pem(priv_pem) + sig = { + "schemapin_version": "1.3", + "skill_name": "legacy", + "skill_hash": f"sha256:{root_hash.hex()}", + "signature": SignatureManager.sign_hash(root_hash, priv), + "signed_at": "2026-01-01T00:00:00Z", + "domain": domain, + "signer_kid": KeyManager.calculate_key_fingerprint(priv.public_key()), + "file_manifest": manifest, + } + (skill / SIGNATURE_FILENAME).write_text(json.dumps(sig), encoding="utf-8") + return sig + + +class TestSignedSignatureDocument: + """SP-1: every metadata field is covered by the signature.""" + + @pytest.mark.parametrize( + "field,value", + [ + ("schema_version", "9.9.9"), + ("previous_hash", "sha256:" + "ab" * 32), + ("skill_name", "payments-tool"), + ("domain", "good.example.com"), + ("signer_kid", "sha256:" + "00" * 32), + ("signed_at", "2030-01-01T00:00:00Z"), + ("canonicalization", "schemapin-v1"), + ], + ) + def test_metadata_tamper_invalidates(self, tmp_path: Path, field: str, value: str) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "---\nname: s\n---\n# hi"}) + SkillSigner.sign_with_options( + skill, priv_pem, "example.com", SignOptions(schema_version="1.0.0") + ) + sig = SkillSigner.load_signature(skill) + sig[field] = value + (skill / SIGNATURE_FILENAME).write_text(json.dumps(sig), encoding="utf-8") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem)) + assert r.valid is False + assert r.error_code in (ErrorCode.SIGNATURE_INVALID, ErrorCode.DOMAIN_INVALID) + + def test_manifest_tamper_invalidates(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi", "a.txt": "A"}) + SkillSigner.sign_skill(skill, priv_pem, "example.com") + sig = SkillSigner.load_signature(skill) + sig["file_manifest"]["extra.txt"] = "sha256:" + "00" * 32 + (skill / SIGNATURE_FILENAME).write_text(json.dumps(sig), encoding="utf-8") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem)) + assert r.valid is False and r.error_code == ErrorCode.SIGNATURE_INVALID + + def test_skill_hash_is_verified(self, tmp_path: Path) -> None: + """SP-14: skill_hash must equal the recomputed root hash.""" + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + sig = SkillSigner.sign_skill(skill, priv_pem, "example.com") + root_hash, _ = SkillSigner.canonicalize_skill(skill) + assert sig["skill_hash"] == f"sha256:{root_hash.hex()}" + # Lineage splice attempt: a different (validly signed) artifact + # whose doc claims to be this skill fails on skill_hash. + other = _create_skill_dir(tmp_path / "other", {"SKILL.md": "# other"}) + SkillSigner.sign_skill(other, priv_pem, "example.com") + other_sig = SkillSigner.load_signature(other) + (skill / SIGNATURE_FILENAME).write_text(json.dumps(other_sig), encoding="utf-8") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem)) + assert r.valid is False and r.error_code == ErrorCode.SIGNATURE_INVALID + assert "skill_hash" in (r.error_message or "") + + def test_unknown_fields_are_ignored_not_signed(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + SkillSigner.sign_skill(skill, priv_pem, "example.com") + sig = SkillSigner.load_signature(skill) + sig["x-extra"] = "ignored" + (skill / SIGNATURE_FILENAME).write_text(json.dumps(sig), encoding="utf-8") + assert SkillSigner.verify_skill_offline(skill, _discovery(pub_pem)).valid is True + + def test_legacy_v13_document_still_verifies_with_warning(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi", "sub/x.txt": "x"}) + _sign_legacy_v13(skill, priv_pem, "example.com") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem), domain="example.com") + assert r.valid is True + assert "legacy_signature_format" in r.warnings + + def test_legacy_signature_cannot_be_upgraded_to_v14(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + sig = _sign_legacy_v13(skill, priv_pem, "example.com") + sig["schemapin_version"] = "1.4" + (skill / SIGNATURE_FILENAME).write_text(json.dumps(sig), encoding="utf-8") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem)) + assert r.valid is False + + def test_verify_chain_rejects_legacy_documents(self) -> None: + with pytest.raises(ChainError): + verify_chain( + {"schemapin_version": "1.4", "previous_hash": "sha256:aa"}, + {"schemapin_version": "1.3", "skill_hash": "sha256:aa"}, + ) + + +class TestDomainBinding: + """SP-8: the signature's domain is display-only; the caller binds the domain.""" + + def test_offline_domain_mismatch(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + SkillSigner.sign_skill(skill, priv_pem, "evil.example.com") + store = KeyPinStore() + r = SkillSigner.verify_skill_offline( + skill, _discovery(pub_pem), pin_store=store, tool_id="calc", domain="good.example.com" + ) + assert r.valid is False and r.error_code == ErrorCode.DOMAIN_MISMATCH + assert store.get_pinned("calc", "good.example.com") is None + assert store.get_pinned("calc", "evil.example.com") is None + + def test_offline_domain_bound_reports_bound_domain(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + SkillSigner.sign_skill(skill, priv_pem, "Example.COM") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem), domain="example.com") + assert r.valid is True and r.domain == "example.com" + assert "domain_unbound" not in r.warnings + r2 = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem)) + assert r2.valid is True and "domain_unbound" in r2.warnings + + def test_resolver_path_binds_domain_before_network(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + SkillSigner.sign_skill(skill, priv_pem, "good.example.com") + + class Exploding(SchemaResolver): + def resolve_discovery(self, domain): # pragma: no cover - must not run + raise AssertionError("network must not be touched on domain mismatch") + + def resolve_revocation(self, domain, discovery): # pragma: no cover + raise AssertionError("unreachable") + + r = SkillSigner.verify_skill_with_resolver(skill, "evil.example.com", resolver=Exploding()) + assert r.valid is False and r.error_code == ErrorCode.DOMAIN_MISMATCH + + def test_invalid_domain_rejected(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + SkillSigner.sign_skill(skill, priv_pem, "example.com") + for bad in ("../skills/evil/key", "localhost", "http://example.com", "10.0.0.1"): + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem), domain=bad) + assert r.valid is False and r.error_code == ErrorCode.DOMAIN_INVALID, bad + + +class TestPinAfterVerify: + """A failed verification must never leave a pin behind.""" + + def test_failed_signature_does_not_pin(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + _other_priv, other_pub = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + SkillSigner.sign_skill(skill, priv_pem, "example.com") + store = KeyPinStore() + r = SkillSigner.verify_skill_offline( + skill, _discovery(other_pub), pin_store=store, tool_id="t", domain="example.com" + ) + assert r.valid is False and r.error_code == ErrorCode.SIGNATURE_INVALID + assert store.get_pinned("t", "example.com") is None + r = SkillSigner.verify_skill_offline( + skill, _discovery(pub_pem), pin_store=store, tool_id="t", domain="example.com" + ) + assert r.valid is True and r.key_pinning.status == "first_use" + assert store.get_pinned("t", "example.com") is not None + + def test_expired_does_not_pin(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + SkillSigner.sign_with_options( + skill, priv_pem, "example.com", SignOptions(expires_in=timedelta(days=-1)) + ) + store = KeyPinStore() + r = SkillSigner.verify_skill_offline( + skill, _discovery(pub_pem), pin_store=store, tool_id="t", domain="example.com" + ) + assert r.valid is False and store.get_pinned("t", "example.com") is None + + +class TestSkillHashingV2: + """SP-12 / SP-13: symlink policy and length-prefixed hashing.""" + + def test_symlink_file_rejected(self, tmp_path: Path) -> None: + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + (tmp_path / "outside.txt").write_text("unsigned") + (skill / "link.txt").symlink_to(tmp_path / "outside.txt") + with pytest.raises(SkillCanonicalizationError): + SkillSigner.canonicalize_skill(skill) + + def test_symlink_dir_rejected(self, tmp_path: Path) -> None: + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + (tmp_path / "other").mkdir() + (tmp_path / "other" / "x.txt").write_text("x") + (skill / "examples").symlink_to(tmp_path / "other", target_is_directory=True) + with pytest.raises(SkillCanonicalizationError): + SkillSigner.canonicalize_skill(skill) + + def test_symlink_added_after_signing_fails_verification(self, tmp_path: Path) -> None: + priv_pem, pub_pem = _make_keypair() + skill = _create_skill_dir(tmp_path / "skill", {"SKILL.md": "# hi"}) + SkillSigner.sign_skill(skill, priv_pem, "example.com") + (tmp_path / "outside.txt").write_text("unsigned") + (skill / "helper.sh").symlink_to(tmp_path / "outside.txt") + r = SkillSigner.verify_skill_offline(skill, _discovery(pub_pem)) + assert r.valid is False + assert r.error_code == ErrorCode.SCHEMA_CANONICALIZATION_FAILED + + def test_path_content_boundary_is_not_malleable(self, tmp_path: Path) -> None: + a = _create_skill_dir(tmp_path / "a", {"run.sh.disabled": "\necho hi"}) + b = _create_skill_dir(tmp_path / "b", {"run.sh": ".disabled\necho hi"}) + ha, _ = SkillSigner.canonicalize_skill(a) + hb, _ = SkillSigner.canonicalize_skill(b) + assert ha != hb + # The legacy algorithm collides on exactly this re-split (SP-13). + la, _ = SkillSigner.canonicalize_skill_v1(a) + lb, _ = SkillSigner.canonicalize_skill_v1(b) + assert la == lb + + def test_nested_sig_file_is_content(self, tmp_path: Path) -> None: + skill = _create_skill_dir( + tmp_path / "skill", {"SKILL.md": "# hi", "nested/.schemapin.sig": "{}"} + ) + _, manifest = SkillSigner.canonicalize_skill(skill) + assert "nested/.schemapin.sig" in manifest + assert ".schemapin.sig" not in manifest + + def test_file_count_cap(self, tmp_path: Path, monkeypatch) -> None: + import schemapin.skill as skill_mod + + skill = _create_skill_dir(tmp_path / "skill", {"a": "1", "b": "2", "c": "3"}) + monkeypatch.setattr(skill_mod, "MAX_SKILL_FILES", 2) + with pytest.raises(SkillCanonicalizationError): + SkillSigner.canonicalize_skill(skill) diff --git a/python/tools/verify_schema.py b/python/tools/verify_schema.py index 61522e6..ed26074 100644 --- a/python/tools/verify_schema.py +++ b/python/tools/verify_schema.py @@ -258,6 +258,11 @@ def main(): help='Enable interactive key pinning prompts' ) + parser.add_argument( + '--allow-expired', + action='store_true', + help='Accept skill signatures whose expires_at has passed (degraded, not failed)' + ) parser.add_argument( '--auto-pin', action='store_true', @@ -335,21 +340,27 @@ def main(): elif args.skill: # Verify skill directory if args.public_key: - # Direct key verification - root_hash, _manifest = SkillSigner.canonicalize_skill(args.skill) + # Direct key verification: the v1.4 signature covers the whole + # signature document, so run the full offline flow against a + # synthetic discovery document built from the supplied key. + public_key_pem = args.public_key.read_text() public_key = load_public_key(args.public_key) sig_data = SkillSigner.load_signature(args.skill) - is_valid = SignatureManager.verify_signature( - root_hash, sig_data.get("signature", ""), public_key + vr = SkillSigner.verify_skill_offline( + args.skill, + {"schema_version": "1.4", "public_key_pem": public_key_pem}, + signature_data=sig_data, + tool_id=args.tool_id, + domain=args.domain, + allow_expired=args.allow_expired, ) - fingerprint = KeyManager.calculate_key_fingerprint(public_key) - result = { - 'valid': is_valid, + result = vr.to_dict() + result.update({ 'verification_method': 'public_key', - 'key_fingerprint': fingerprint, + 'key_fingerprint': KeyManager.calculate_key_fingerprint(public_key), 'key_source': str(args.public_key), 'skill_name': sig_data.get('skill_name', ''), - } + }) elif args.domain: # Resolver-based verification from schemapin.resolver import WellKnownResolver @@ -363,6 +374,7 @@ def main(): resolver=resolver, pin_store=pin_store, tool_id=args.tool_id, + allow_expired=args.allow_expired, ) result = vr.to_dict() result['verification_method'] = 'discovery' diff --git a/rust/src/pinning.rs b/rust/src/pinning.rs index 7f8dd03..b328ee2 100644 --- a/rust/src/pinning.rs +++ b/rust/src/pinning.rs @@ -34,8 +34,39 @@ impl KeyPinStore { format!("{}@{}", tool_id, domain) } + /// Compare `fingerprint` with the pinned keys for `tool_id@domain` without + /// mutating the store. `FirstUse` means nothing is pinned yet. + pub fn check(&self, tool_id: &str, domain: &str, fingerprint: &str) -> PinningResult { + match self.tools.get(&Self::composite_key(tool_id, domain)) { + None => PinningResult::FirstUse, + Some(pinned) + if pinned + .pinned_keys + .iter() + .any(|pk| pk.fingerprint == fingerprint) => + { + PinningResult::Matched + } + Some(_) => PinningResult::Changed, + } + } + + /// Pin `fingerprint` for `tool_id@domain` (idempotent). Use after a + /// successful verification, or to pre-seed an expected key. + pub fn pin(&mut self, tool_id: &str, domain: &str, fingerprint: &str) { + self.add_key(tool_id, domain, fingerprint); + } + + /// Pre-seed an expected fingerprint (out-of-band trust establishment). + pub fn seed(&mut self, tool_id: &str, domain: &str, fingerprint: &str) { + self.add_key(tool_id, domain, fingerprint); + } + /// Check a key fingerprint against the pin store. If this is the first time /// seeing this tool@domain, the key is pinned (TOFU). Returns the pinning result. + /// + /// Prefer [`KeyPinStore::check`] followed by [`KeyPinStore::pin`] so that + /// pinning can be deferred until after signature verification. pub fn check_and_pin( &mut self, tool_id: &str, @@ -124,6 +155,26 @@ impl KeyPinStore { } } +/// Non-mutating pin check that returns an error if the key has changed. +pub fn check_pinning_no_write( + store: &KeyPinStore, + tool_id: &str, + domain: &str, + fingerprint: &str, +) -> Result { + let result = store.check(tool_id, domain, fingerprint); + if result == PinningResult::Changed { + return Err(Error::Verification { + code: ErrorCode::KeyPinMismatch, + message: format!( + "Key for '{}@{}' has changed since last pinned (fingerprint: '{}')", + tool_id, domain, fingerprint + ), + }); + } + Ok(result) +} + /// Check pinning and return an error if key has changed (for use in verification flow). pub fn check_pinning( store: &mut KeyPinStore, diff --git a/rust/src/skill.rs b/rust/src/skill.rs index 8283951..cd9f278 100644 --- a/rust/src/skill.rs +++ b/rust/src/skill.rs @@ -2,31 +2,70 @@ //! //! Mirrors the Python `SkillSigner` class: same canonicalization algorithm, //! same `.schemapin.sig` format, cross-language interop. +//! +//! # Wire formats +//! +//! `schemapin_version: "1.4"` (the default for every new signature): +//! +//! - The directory is canonicalized with [`canonicalize_skill`] (skill +//! canonicalization v2, spec section 4b): every entry is hashed as +//! `SHA-256("f" || be64(len(path)) || path || be64(len(content)) || content)` +//! and the root hash is SHA-256 over `be64(len(path)) || path || digest` for +//! every entry in code-point path order. Symlinks are rejected. +//! - `skill_hash` is `"sha256:" + hex(root_hash)`. +//! - The signature covers the **entire signature document**: the document is +//! built with `signature: ""`, canonicalized with `schemapin-v1` and signed +//! as `ECDSA-P256(SHA-256(canonical_bytes))`. `expires_at`, `schema_version`, +//! `previous_hash`, `canonicalization`, `domain`, `skill_name`, `signer_kid`, +//! `signed_at` and `file_manifest` are therefore all integrity-protected. +//! +//! `schemapin_version: "1.3"` (legacy, verify-only): +//! +//! - Entries hashed as `SHA-256(path || content)`, root hash SHA-256 of the +//! concatenated hex digests, symlinks silently skipped, and the signature +//! covers only the 32-byte root hash. None of the metadata fields are +//! signed; verifiers surface a `legacy_signature_format` warning. +//! +//! Verification is fail-closed on expiration and on domain mismatch. use std::collections::BTreeMap; use std::fs; use std::path::Path; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use p256::pkcs8::{DecodePrivateKey, EncodePublicKey}; +use crate::canonicalize::canonicalize_schema; use crate::crypto; use crate::discovery::validate_well_known_response; +use crate::domain::validate_domain; use crate::error::{Error, ErrorCode}; -use crate::pinning::{check_pinning, KeyPinStore, PinningResult}; +use crate::pinning::{KeyPinStore, PinningResult}; use crate::resolver::SchemaResolver; use crate::revocation::check_revocation_combined; use crate::types::discovery::WellKnownResponse; use crate::types::revocation::RevocationDocument; -use crate::verification::{KeyPinningStatus, VerificationResult}; +use crate::verification::{KeyPinningStatus, ResolverOptions, VerificationResult}; + +/// `schemapin_version` written on every new signature (signed-document format). +pub const SCHEMAPIN_VERSION: &str = "1.4"; +/// Legacy root-hash-only format; accepted on verification, never written. +pub const SCHEMAPIN_VERSION_LEGACY: &str = "1.3"; + +/// Caps enforced by [`canonicalize_skill`]. +pub const MAX_SKILL_FILES: usize = 10_000; +pub const MAX_SKILL_TOTAL_BYTES: u64 = 256 * 1024 * 1024; + +const SIGNATURE_FILENAME: &str = ".schemapin.sig"; /// Signature metadata written to `.schemapin.sig`. /// /// Optional v1.4 fields: -/// - `expires_at` — verifiers past the expiration emit a `signature_expired` warning -/// (see [`verify_skill_offline`]); they do not fail. +/// - `expires_at` — verification fails with `SIGNATURE_EXPIRED` past this unless +/// the caller allows expired signatures (see [`VerifyOptions`]). /// - `schema_version` — caller-supplied semver string identifying *this* version of /// the signed artifact (e.g. `"2.1.0"`). Returned via [`crate::verification::VerificationResult`] /// so consumers can apply their own version policy. @@ -64,23 +103,137 @@ pub struct TamperedFiles { pub removed: Vec, } -/// Recursively walk a directory in sorted order, producing a deterministic -/// file manifest and root hash. +fn canon_err(msg: String) -> Error { + Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)) +} + +fn be64(n: usize) -> [u8; 8] { + (n as u64).to_be_bytes() +} + +/// Recursively walk a directory and compute the v2 root hash (spec section 4b). +/// +/// 1. Walk the tree; directories are not entries. The top-level +/// `.schemapin.sig` is excluded. +/// 2. Any symlink (file or directory) or non-regular file aborts +/// canonicalization -- unsigned content must not be reachable from a +/// signed skill. +/// 3. Paths are relative, forward-slash separated, UTF-8, sorted by code point. +/// 4. Per entry: `SHA-256(0x66 || be64(len(path)) || path || be64(len(content)) || content)`. +/// 5. Root: SHA-256 over `be64(len(path)) || path || entry_digest` for every +/// entry in sorted order. /// -/// Returns `(root_hash_bytes, manifest)` where each manifest entry maps -/// a forward-slash relative path to `"sha256:"`. +/// Returns `(root_hash_bytes, manifest)` where each manifest entry maps a +/// forward-slash relative path to `"sha256:"`. pub fn canonicalize_skill(skill_dir: &Path) -> Result<(Vec, BTreeMap), Error> { + if !skill_dir.is_dir() { + return Err(canon_err(format!( + "not a directory: {}", + skill_dir.display() + ))); + } + let mut entries: BTreeMap> = BTreeMap::new(); + let mut total: u64 = 0; + walk_v2(skill_dir, skill_dir, &mut entries, &mut total)?; + + if entries.is_empty() { + return Err(canon_err("skill directory contains no files".to_string())); + } + + let mut root = Sha256::new(); + let mut manifest = BTreeMap::new(); + for (path, digest) in &entries { + root.update(be64(path.len())); + root.update(path.as_bytes()); + root.update(digest); + manifest.insert(path.clone(), format!("sha256:{}", hex::encode(digest))); + } + Ok((root.finalize().to_vec(), manifest)) +} + +fn walk_v2( + base: &Path, + dir: &Path, + entries: &mut BTreeMap>, + total: &mut u64, +) -> Result<(), Error> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let meta = fs::symlink_metadata(&path)?; + if meta.is_symlink() { + return Err(canon_err(format!( + "symlink not permitted in skill directory: {}", + path.display() + ))); + } + if meta.is_dir() { + walk_v2(base, &path, entries, total)?; + continue; + } + if !meta.is_file() { + return Err(canon_err(format!( + "non-regular file in skill directory: {}", + path.display() + ))); + } + if dir == base && entry.file_name() == SIGNATURE_FILENAME { + continue; + } + let rel = path + .strip_prefix(base) + .map_err(|e| canon_err(e.to_string()))?; + let mut parts = Vec::new(); + for component in rel.components() { + let part = component + .as_os_str() + .to_str() + .ok_or_else(|| canon_err(format!("path is not valid UTF-8: {}", path.display())))?; + parts.push(part.to_string()); + } + let rel_str = parts.join("/"); + + let file_bytes = fs::read(&path)?; + *total += file_bytes.len() as u64; + if entries.len() + 1 > MAX_SKILL_FILES { + return Err(canon_err(format!( + "skill exceeds {} files", + MAX_SKILL_FILES + ))); + } + if *total > MAX_SKILL_TOTAL_BYTES { + return Err(canon_err(format!( + "skill exceeds {} bytes", + MAX_SKILL_TOTAL_BYTES + ))); + } + let mut hasher = Sha256::new(); + hasher.update(b"f"); + hasher.update(be64(rel_str.len())); + hasher.update(rel_str.as_bytes()); + hasher.update(be64(file_bytes.len())); + hasher.update(&file_bytes); + entries.insert(rel_str, hasher.finalize().to_vec()); + } + Ok(()) +} + +/// Legacy (`schemapin_version: "1.3"`) skill canonicalization. +/// +/// Kept only so that pre-1.4 signatures still verify. Per-file digest is +/// `SHA-256(path || content)`, the root hash is SHA-256 of the concatenated +/// hex digests, symlinks are skipped and `.schemapin.sig` is excluded at any +/// depth. Do not use for new signatures. +pub fn canonicalize_skill_v1( + skill_dir: &Path, +) -> Result<(Vec, BTreeMap), Error> { let mut manifest = BTreeMap::new(); - walk_sorted(skill_dir, skill_dir, &mut manifest)?; + walk_v1(skill_dir, skill_dir, &mut manifest)?; if manifest.is_empty() { - return Err(Error::Io(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "skill directory contains no files", - ))); + return Err(canon_err("skill directory contains no files".to_string())); } - // Root hash: sort keys, concat hex digests, SHA-256 let joined: String = manifest .values() .map(|v| v.strip_prefix("sha256:").unwrap_or(v)) @@ -91,36 +244,25 @@ pub fn canonicalize_skill(skill_dir: &Path) -> Result<(Vec, BTreeMap, -) -> Result<(), Error> { +fn walk_v1(base: &Path, dir: &Path, manifest: &mut BTreeMap) -> Result<(), Error> { let mut entries: Vec = fs::read_dir(dir)?.filter_map(|e| e.ok()).collect(); entries.sort_by_key(|e| e.file_name()); for entry in entries { let path = entry.path(); let meta = fs::symlink_metadata(&path)?; - - // Skip symlinks if meta.is_symlink() { continue; } - if meta.is_dir() { - walk_sorted(base, &path, manifest)?; + walk_v1(base, &path, manifest)?; } else if meta.is_file() { - let file_name = entry.file_name(); - if file_name == ".schemapin.sig" { + if entry.file_name() == SIGNATURE_FILENAME { continue; } - let rel = path .strip_prefix(base) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; - // Forward-slash normalize let rel_str = rel .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) @@ -131,9 +273,10 @@ fn walk_sorted( let mut hasher = Sha256::new(); hasher.update(rel_str.as_bytes()); hasher.update(&file_bytes); - let digest = hex::encode(hasher.finalize()); - - manifest.insert(rel_str, format!("sha256:{}", digest)); + manifest.insert( + rel_str, + format!("sha256:{}", hex::encode(hasher.finalize())), + ); } } Ok(()) @@ -141,9 +284,6 @@ fn walk_sorted( /// Extract the skill name from `SKILL.md` frontmatter, falling back to the /// directory basename. -/// -/// Parses YAML frontmatter (`---` delimited) and looks for a `name:` field. -/// No regex crate needed — pure string operations. pub fn parse_skill_name(skill_dir: &Path) -> String { let skill_md = skill_dir.join("SKILL.md"); if let Ok(text) = fs::read_to_string(&skill_md) { @@ -151,7 +291,6 @@ pub fn parse_skill_name(skill_dir: &Path) -> String { return name; } } - // Fallback: directory basename skill_dir .file_name() .map(|n| n.to_string_lossy().into_owned()) @@ -160,16 +299,13 @@ pub fn parse_skill_name(skill_dir: &Path) -> String { /// String-based frontmatter `name:` extraction (no regex). fn extract_frontmatter_name(text: &str) -> Option { - // Must start with "---\n" (or "---\r\n") - let text = text.trim_start_matches('\u{feff}'); // strip BOM + let text = text.trim_start_matches('\u{feff}'); if !text.starts_with("---") { return None; } let after_open = &text[3..]; let after_open = after_open.strip_prefix('\r').unwrap_or(after_open); let after_open = after_open.strip_prefix('\n')?; - - // Find closing "---" let close_idx = after_open.find("\n---")?; let frontmatter = &after_open[..close_idx]; @@ -177,7 +313,6 @@ fn extract_frontmatter_name(text: &str) -> Option { let trimmed = line.trim(); if let Some(rest) = trimmed.strip_prefix("name:") { let val = rest.trim(); - // Strip surrounding quotes let val = val .strip_prefix('\'') .and_then(|v| v.strip_suffix('\'')) @@ -194,12 +329,57 @@ fn extract_frontmatter_name(text: &str) -> Option { /// Read and parse the `.schemapin.sig` file from a skill directory. pub fn load_signature(skill_dir: &Path) -> Result { - let sig_path = skill_dir.join(".schemapin.sig"); + let sig_path = skill_dir.join(SIGNATURE_FILENAME); let data = fs::read_to_string(&sig_path)?; let sig: SkillSignature = serde_json::from_str(&data)?; Ok(sig) } +/// Build the canonical bytes a v1.4 signature covers. +/// +/// Takes the known signature fields (unknown fields are ignored and therefore +/// unprotected), drops absent / empty optional fields, forces `signature` to +/// `""` and canonicalizes with `schemapin-v1`. +pub fn signing_input(sig: &SkillSignature) -> Result, Error> { + let mut doc = Map::new(); + for (key, value) in [ + ("schemapin_version", &sig.schemapin_version), + ("skill_name", &sig.skill_name), + ("skill_hash", &sig.skill_hash), + ("signed_at", &sig.signed_at), + ("domain", &sig.domain), + ("signer_kid", &sig.signer_kid), + ] { + if value.is_empty() { + return Err(Error::Verification { + code: ErrorCode::SignatureInvalid, + message: format!("signature document field {:?} is missing", key), + }); + } + doc.insert(key.to_string(), Value::String(value.clone())); + } + let manifest: Map = sig + .file_manifest + .iter() + .map(|(k, v)| (k.clone(), Value::String(v.clone()))) + .collect(); + doc.insert("file_manifest".to_string(), Value::Object(manifest)); + for (key, value) in [ + ("expires_at", &sig.expires_at), + ("schema_version", &sig.schema_version), + ("previous_hash", &sig.previous_hash), + ("canonicalization", &sig.canonicalization), + ] { + if let Some(v) = value { + if !v.is_empty() { + doc.insert(key.to_string(), Value::String(v.clone())); + } + } + } + doc.insert("signature".to_string(), Value::String(String::new())); + Ok(canonicalize_schema(&Value::Object(doc))?.into_bytes()) +} + /// Optional sign-time parameters for [`sign_skill_with_options`]. /// /// Builder-style: all fields default to `None`. @@ -209,20 +389,14 @@ pub struct SignOptions<'a> { pub signer_kid: Option<&'a str>, /// Override the skill name. Defaults to the SKILL.md frontmatter `name:` or directory basename. pub skill_name: Option<&'a str>, - /// Time-to-live for the signature. When set, an `expires_at` field is written. + /// Time-to-live for the signature. When set, an `expires_at` field is written + /// (a negative duration yields an already-expired signature, useful for tests). pub expires_in: Option, /// Caller-supplied semver string identifying *this* version of the signed artifact. - /// Written to `schema_version` and surfaced on [`crate::verification::VerificationResult`]. pub schema_version: Option<&'a str>, /// `sha256:` hash of the *prior* signed version's `skill_hash`, forming a chain. - /// When set, written to `previous_hash`. Pair with [`verify_chain`] at verify time. pub previous_hash: Option<&'a str>, /// Canonicalization algorithm identifier to record in the signature. - /// - /// When set, written to `canonicalization`. The default (`None`) maps to "absent - /// field" on the wire, which v1.3+ verifiers MUST interpret as the implicit - /// `schemapin-v1` algorithm. Pass `Some("schemapin-v1")` explicitly to declare - /// the algorithm on the wire while staying behavior-equivalent. pub canonicalization: Option<&'a str>, } @@ -257,21 +431,31 @@ impl<'a> SignOptions<'a> { } /// Declare the canonicalization algorithm identifier on the wire. - /// - /// Pass [`crate::canonicalize::CANONICALIZATION_V1`] to write the default - /// `schemapin-v1` value. Unknown values will be rejected by every v1.4 - /// verifier that supports this field — there is currently exactly one - /// supported algorithm. pub fn with_canonicalization(mut self, algorithm: &'a str) -> Self { self.canonicalization = Some(algorithm); self } } +/// Policy knobs for [`verify_skill_offline_with_options`]. +#[derive(Debug, Default, Clone)] +pub struct VerifyOptions<'a> { + /// Bind the skill to an expected domain: the signature's `domain` must + /// equal it (`DOMAIN_MISMATCH` otherwise). Pass it whenever you know which + /// domain the discovery document belongs to -- the signature's own + /// `domain` field is display-only. When `None` the signature's domain is + /// used and a `domain_unbound` warning is surfaced. + pub domain: Option<&'a str>, + /// Pin-store key; defaults to the signature's `skill_name`. + pub tool_id: Option<&'a str>, + /// Accept expired signatures (degraded) instead of failing with + /// `SIGNATURE_EXPIRED`. + pub allow_expired: bool, +} + /// Sign a skill directory and write `.schemapin.sig`. /// -/// Convenience wrapper over [`sign_skill_with_options`] preserved for v1.3 callers. -/// Returns the signature document that was written. +/// Convenience wrapper over [`sign_skill_with_options`]. pub fn sign_skill( skill_dir: &Path, private_key_pem: &str, @@ -286,25 +470,22 @@ pub fn sign_skill( SignOptions { signer_kid, skill_name, - expires_in: None, - schema_version: None, - previous_hash: None, - canonicalization: None, + ..SignOptions::default() }, ) } -/// Sign a skill directory with extended options (v1.4+). +/// Sign a skill directory and write a v1.4 signature document. /// -/// When `options.expires_in` is `Some(ttl)`, an `expires_at` ISO 8601 timestamp -/// is written. Verifiers past that timestamp emit a warning instead of failing -/// (see [`verify_skill_offline`]). +/// The whole document (including `expires_at` when `options.expires_in` is +/// set) is covered by the signature. See the module documentation. pub fn sign_skill_with_options( skill_dir: &Path, private_key_pem: &str, domain: &str, options: SignOptions<'_>, ) -> Result { + let domain = validate_domain(domain)?; let (root_hash, manifest) = canonicalize_skill(skill_dir)?; let name = match options.skill_name { @@ -325,49 +506,51 @@ pub fn sign_skill_with_options( } }; - let signature_b64 = crypto::sign_data(private_key_pem, &root_hash)?; - let skill_hash = format!("sha256:{}", hex::encode(Sha256::digest(&root_hash))); - let now = chrono::Utc::now(); let expires_at = options .expires_in .map(|ttl| (now + ttl).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); - let schema_version = options.schema_version.map(str::to_string); - let previous_hash = options.previous_hash.map(str::to_string); - let canonicalization = options.canonicalization.map(str::to_string); - - // Any v1.4 optional field bumps the version stamp; pure v1.3 sigs stay "1.3" - // for byte-stable backward compatibility. - let uses_v1_4_field = expires_at.is_some() - || schema_version.is_some() - || previous_hash.is_some() - || canonicalization.is_some(); - let version = if uses_v1_4_field { "1.4" } else { "1.3" }; - - let sig_doc = SkillSignature { - schemapin_version: version.to_string(), + let mut sig_doc = SkillSignature { + schemapin_version: SCHEMAPIN_VERSION.to_string(), skill_name: name, - skill_hash, - signature: signature_b64, + skill_hash: format!("sha256:{}", hex::encode(&root_hash)), + signature: String::new(), signed_at: now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), expires_at, - schema_version, - previous_hash, - canonicalization, - domain: domain.to_string(), + schema_version: options.schema_version.map(str::to_string), + previous_hash: options.previous_hash.map(str::to_string), + canonicalization: options.canonicalization.map(str::to_string), + domain, signer_kid: kid, file_manifest: manifest, }; - let sig_path = skill_dir.join(".schemapin.sig"); + let input = signing_input(&sig_doc)?; + sig_doc.signature = crypto::sign_data(private_key_pem, &input)?; + + let sig_path = skill_dir.join(SIGNATURE_FILENAME); let json = serde_json::to_string_pretty(&sig_doc)?; fs::write(&sig_path, format!("{}\n", json))?; Ok(sig_doc) } -/// Offline 7-step skill verification, mirroring `verify_schema_offline`. +fn version_tuple(version: &str) -> Option<(u32, u32)> { + let (major, minor) = version.split_once('.')?; + if minor.contains('.') { + return None; + } + Some((major.parse().ok()?, minor.parse().ok()?)) +} + +fn is_legacy_version(version: &str) -> bool { + matches!(version_tuple(version), Some(v) if v < (1, 4)) +} + +/// Offline skill verification using the signature's own `domain` for pinning +/// and reporting (surfaced as a `domain_unbound` warning). Prefer +/// [`verify_skill_offline_with_options`] and bind the expected domain. /// /// If `signature_data` is `None`, loads from `.schemapin.sig` in the skill dir. pub fn verify_skill_offline( @@ -377,6 +560,40 @@ pub fn verify_skill_offline( revocation_doc: Option<&RevocationDocument>, pin_store: Option<&mut KeyPinStore>, tool_id: Option<&str>, +) -> VerificationResult { + verify_skill_offline_with_options( + skill_dir, + discovery, + signature_data, + revocation_doc, + pin_store, + &VerifyOptions { + tool_id, + ..VerifyOptions::default() + }, + ) +} + +/// Offline skill verification. +/// +/// Steps: +/// 1. Load or accept signature data; check `canonicalization` +/// 2. Bind the domain (`opts.domain` must equal the signature's domain) +/// 3. Validate discovery document, load key, compute fingerprint +/// 4. Check revocation +/// 5. TOFU pin check (read-only) +/// 6. Recompute the root hash, require `skill_hash` to match, verify the +/// signature (over the whole document for 1.4, over the root hash for +/// legacy 1.3) +/// 7. Expiration check (fail-closed unless `opts.allow_expired`) +/// 8. Pin on first use, return the result with lineage metadata +pub fn verify_skill_offline_with_options( + skill_dir: &Path, + discovery: &WellKnownResponse, + signature_data: Option<&SkillSignature>, + revocation_doc: Option<&RevocationDocument>, + pin_store: Option<&mut KeyPinStore>, + opts: &VerifyOptions<'_>, ) -> VerificationResult { // Step 1: Load signature let owned_sig; @@ -396,8 +613,6 @@ pub fn verify_skill_offline( }, }; - // Step 1a (v1.4): Reject unknown canonicalization algorithms before any - // crypto work — absent / "schemapin-v1" are equivalent and accepted. if let Err(declared) = crate::canonicalize::check_canonicalization(sig.canonicalization.as_deref()) { @@ -407,7 +622,42 @@ pub fn verify_skill_offline( ); } - // Step 2: Validate discovery document + // Step 2: Domain binding + let mut warnings = Vec::new(); + let domain = match opts.domain { + Some(expected) => { + let expected = match validate_domain(expected) { + Ok(d) => d, + Err(e) => { + return VerificationResult::failure(ErrorCode::DomainInvalid, &e.to_string()) + } + }; + if sig.domain.to_ascii_lowercase() != expected { + return VerificationResult::failure( + ErrorCode::DomainMismatch, + &format!( + "signature domain {:?} does not match expected domain {:?}", + sig.domain, expected + ), + ); + } + expected + } + None => match validate_domain(&sig.domain) { + Ok(d) => { + warnings.push("domain_unbound".to_string()); + d + } + Err(e) => { + return VerificationResult::failure( + ErrorCode::DomainInvalid, + &format!("signature domain is invalid: {}", e), + ) + } + }, + }; + + // Step 3: Validate discovery document if let Err(e) = validate_well_known_response(discovery) { return VerificationResult::failure( ErrorCode::DiscoveryInvalid, @@ -415,7 +665,6 @@ pub fn verify_skill_offline( ); } - // Step 3: Extract public key and compute fingerprint let fingerprint = match crypto::calculate_key_id(&discovery.public_key_pem) { Ok(fp) => fp, Err(e) => { @@ -436,23 +685,38 @@ pub fn verify_skill_offline( return VerificationResult::failure(code, &e.to_string()); } - // Step 5: TOFU key pinning - let effective_tool_id = tool_id + // Step 5: TOFU pin check (read-only until the signature verifies) + let effective_tool_id = opts + .tool_id .map(|s| s.to_string()) .unwrap_or_else(|| sig.skill_name.clone()); - let pin_result = if let Some(store) = pin_store { - match check_pinning(store, &effective_tool_id, &sig.domain, &fingerprint) { - Ok(r) => Some((r, store as &KeyPinStore)), - Err(e) => { - return VerificationResult::failure(ErrorCode::KeyPinMismatch, &e.to_string()); - } - } + let pin_result = pin_store + .as_ref() + .map(|store| store.check(&effective_tool_id, &domain, &fingerprint)); + if pin_result == Some(PinningResult::Changed) { + return VerificationResult::failure( + ErrorCode::KeyPinMismatch, + &format!( + "Key for '{}@{}' has changed since last pinned (fingerprint: '{}')", + effective_tool_id, domain, fingerprint + ), + ); + } + + // Step 6: Canonicalize and verify + if version_tuple(&sig.schemapin_version).is_none() { + return VerificationResult::failure( + ErrorCode::SignatureInvalid, + &format!("unsupported schemapin_version {:?}", sig.schemapin_version), + ); + } + let legacy = is_legacy_version(&sig.schemapin_version); + let canonicalized = if legacy { + canonicalize_skill_v1(skill_dir) } else { - None + canonicalize_skill(skill_dir) }; - - // Step 6: Canonicalize and verify signature - let (root_hash, _manifest) = match canonicalize_skill(skill_dir) { + let (root_hash, _manifest) = match canonicalized { Ok(v) => v, Err(e) => { return VerificationResult::failure( @@ -462,17 +726,37 @@ pub fn verify_skill_offline( } }; - let valid = - match crypto::verify_signature(&discovery.public_key_pem, &root_hash, &sig.signature) { - Ok(v) => v, + let verified = if legacy { + warnings.push("legacy_signature_format".to_string()); + crypto::verify_signature(&discovery.public_key_pem, &root_hash, &sig.signature) + } else { + if sig.skill_hash != format!("sha256:{}", hex::encode(&root_hash)) { + return VerificationResult::failure( + ErrorCode::SignatureInvalid, + "skill_hash does not match the directory contents", + ); + } + match signing_input(sig) { + Ok(input) => { + crypto::verify_signature(&discovery.public_key_pem, &input, &sig.signature) + } Err(e) => { return VerificationResult::failure( ErrorCode::SignatureInvalid, - &format!("Signature verification error: {}", e), - ); + &format!("malformed signature document: {}", e), + ) } - }; - + } + }; + let valid = match verified { + Ok(v) => v, + Err(e) => { + return VerificationResult::failure( + ErrorCode::SignatureInvalid, + &format!("Signature verification error: {}", e), + ); + } + }; if !valid { return VerificationResult::failure( ErrorCode::SignatureInvalid, @@ -480,34 +764,49 @@ pub fn verify_skill_offline( ); } - // Step 7: Build success result - let pin_status = match pin_result { - Some((PinningResult::FirstUse, _)) => KeyPinningStatus { - status: "first_use".to_string(), - first_seen: Some(chrono::Utc::now().to_rfc3339()), - }, - Some((PinningResult::Matched, store)) => { - let first_seen = store - .get_tool(&effective_tool_id, &sig.domain) - .and_then(|t| t.pinned_keys.first()) - .map(|pk| pk.first_seen.clone()); - KeyPinningStatus { - status: "pinned".to_string(), - first_seen, - } - } - Some((PinningResult::Changed, _)) => { - unreachable!("Changed case handled above") - } - None => KeyPinningStatus { - status: "first_use".to_string(), - first_seen: Some(chrono::Utc::now().to_rfc3339()), - }, - }; + // Step 7: Expiration (fail-closed by default) + let mut result = VerificationResult { + valid: true, + domain: Some(domain.clone()), + developer_name: discovery.developer_name.clone(), + key_pinning: None, + error_code: None, + error_message: None, + warnings, + expired: false, + expires_at: None, + schema_version: None, + previous_hash: None, + } + .with_expiration_policy(sig.expires_at.as_deref(), opts.allow_expired); + if !result.valid { + return result; + } - VerificationResult::success(&sig.domain, discovery.developer_name.as_deref(), pin_status) - .with_expiration_check(sig.expires_at.as_deref()) - .with_lineage_metadata(sig.schema_version.as_deref(), sig.previous_hash.as_deref()) + // Step 8: Pin only now, then surface lineage metadata + if let Some(store) = pin_store { + let status = match pin_result { + Some(PinningResult::FirstUse) => { + store.pin(&effective_tool_id, &domain, &fingerprint); + KeyPinningStatus { + status: "first_use".to_string(), + first_seen: Some(chrono::Utc::now().to_rfc3339()), + } + } + _ => { + let first_seen = store + .get_tool(&effective_tool_id, &domain) + .and_then(|t| t.pinned_keys.first()) + .map(|pk| pk.first_seen.clone()); + KeyPinningStatus { + status: "pinned".to_string(), + first_seen, + } + } + }; + result.key_pinning = Some(status); + } + result.with_lineage_metadata(sig.schema_version.as_deref(), sig.previous_hash.as_deref()) } /// Errors returned by [`verify_chain`]. @@ -517,6 +816,9 @@ pub enum ChainError { NoPreviousHash, /// `current.previous_hash` is present but doesn't match `previous.skill_hash`. Mismatch { expected: String, got: String }, + /// One of the documents is a legacy 1.3 signature whose `skill_hash` is + /// not covered by its signature and therefore cannot anchor a chain. + Legacy, } impl std::fmt::Display for ChainError { @@ -528,6 +830,10 @@ impl std::fmt::Display for ChainError { "previous_hash mismatch: current.previous_hash = {}, previous.skill_hash = {}", got, expected ), + Self::Legacy => write!( + f, + "legacy 1.3 signature documents carry an unsigned skill_hash and cannot anchor a chain" + ), } } } @@ -535,19 +841,21 @@ impl std::fmt::Display for ChainError { impl std::error::Error for ChainError {} /// Verify that `current` is the legitimate successor of `previous` via the -/// `previous_hash` lineage chain (v1.4 alpha.2). +/// `previous_hash` lineage chain. /// /// Returns `Ok(())` when `current.previous_hash == Some(previous.skill_hash)`. /// -/// This is a pure-metadata check — no cryptography is re-evaluated. Both signatures -/// must already be cryptographically verified separately via [`verify_skill_offline`] -/// for the chain check to be meaningful. -/// -/// Use this to defend against rug-pull attacks where an attacker substitutes a -/// schema/skill out-of-band: a legitimate update declares the prior version's hash; -/// an unauthorized substitution either omits `previous_hash` or points at a hash -/// the verifier hasn't accepted as a valid ancestor. +/// This is a pure-metadata check — no cryptography is re-evaluated. Both +/// signatures must already be cryptographically verified separately via +/// [`verify_skill_offline`], and both must be `schemapin_version >= "1.4"` +/// documents so that `skill_hash` / `previous_hash` are covered by their +/// signatures (legacy 1.3 documents are rejected). pub fn verify_chain(current: &SkillSignature, previous: &SkillSignature) -> Result<(), ChainError> { + if is_legacy_version(¤t.schemapin_version) + || is_legacy_version(&previous.schemapin_version) + { + return Err(ChainError::Legacy); + } let Some(claimed) = current.previous_hash.as_deref() else { return Err(ChainError::NoPreviousHash); }; @@ -563,11 +871,8 @@ pub fn verify_chain(current: &SkillSignature, previous: &SkillSignature) -> Resu /// Verify a skill folder offline with an additional DNS TXT cross-check (v1.4). /// -/// Behaves identically to [`verify_skill_offline`], then — when `dns_txt` is -/// `Some` — verifies that the DNS TXT record's fingerprint matches the -/// discovery key. A mismatch converts the result into a hard failure with -/// [`ErrorCode::DomainMismatch`]. When `dns_txt` is `None`, behaviour is -/// unchanged (DNS TXT is an optional, additive trust signal). +/// The DNS cross-check runs before verification so that a mismatching key is +/// never pinned; a mismatch is a hard [`ErrorCode::DomainMismatch`] failure. pub fn verify_skill_offline_with_dns( skill_dir: &Path, discovery: &WellKnownResponse, @@ -577,32 +882,54 @@ pub fn verify_skill_offline_with_dns( tool_id: Option<&str>, dns_txt: Option<&crate::dns::DnsTxtRecord>, ) -> VerificationResult { - let result = verify_skill_offline( + verify_skill_offline_with_dns_options( skill_dir, discovery, signature_data, revocation_doc, pin_store, - tool_id, - ); + dns_txt, + &VerifyOptions { + tool_id, + ..VerifyOptions::default() + }, + ) +} - if !result.valid { - return result; - } - let Some(txt) = dns_txt else { - return result; - }; - match crate::dns::verify_dns_match(discovery, txt) { - Ok(()) => result, - Err(crate::error::Error::Verification { code, message }) => { - VerificationResult::failure(code, &message) +/// [`verify_skill_offline_with_dns`] with explicit policy knobs. +pub fn verify_skill_offline_with_dns_options( + skill_dir: &Path, + discovery: &WellKnownResponse, + signature_data: Option<&SkillSignature>, + revocation_doc: Option<&RevocationDocument>, + pin_store: Option<&mut KeyPinStore>, + dns_txt: Option<&crate::dns::DnsTxtRecord>, + opts: &VerifyOptions<'_>, +) -> VerificationResult { + if let Some(txt) = dns_txt { + match crate::dns::verify_dns_match(discovery, txt) { + Ok(()) => {} + Err(crate::error::Error::Verification { code, message }) => { + return VerificationResult::failure(code, &message) + } + Err(e) => { + return VerificationResult::failure(ErrorCode::DomainMismatch, &e.to_string()) + } } - Err(e) => VerificationResult::failure(ErrorCode::DomainMismatch, &e.to_string()), } + verify_skill_offline_with_options( + skill_dir, + discovery, + signature_data, + revocation_doc, + pin_store, + opts, + ) } /// Resolve discovery via a [`SchemaResolver`], then delegate to -/// [`verify_skill_offline`]. +/// [`verify_skill_offline_with_options`]. The signature's `domain` must equal +/// `domain` (checked before any I/O) and revocation is fail-closed. pub fn verify_skill_with_resolver( skill_dir: &Path, domain: &str, @@ -610,37 +937,91 @@ pub fn verify_skill_with_resolver( pin_store: Option<&mut KeyPinStore>, tool_id: Option<&str>, ) -> VerificationResult { - let discovery = match resolver.resolve_discovery(domain) { - Ok(doc) => doc, + verify_skill_with_resolver_opts( + skill_dir, + domain, + resolver, + pin_store, + tool_id, + ResolverOptions::default(), + ) +} + +/// [`verify_skill_with_resolver`] with explicit policy knobs. +pub fn verify_skill_with_resolver_opts( + skill_dir: &Path, + domain: &str, + resolver: &dyn SchemaResolver, + pin_store: Option<&mut KeyPinStore>, + tool_id: Option<&str>, + ropts: ResolverOptions, +) -> VerificationResult { + let domain = match validate_domain(domain) { + Ok(d) => d, + Err(e) => return VerificationResult::failure(ErrorCode::DomainInvalid, &e.to_string()), + }; + + let sig = match load_signature(skill_dir) { + Ok(s) => s, Err(e) => { return VerificationResult::failure( - ErrorCode::DiscoveryFetchFailed, - &format!("Failed to resolve discovery document: {}", e), + ErrorCode::SignatureInvalid, + &format!("Failed to load .schemapin.sig: {}", e), ); } }; + if sig.domain.to_ascii_lowercase() != domain { + return VerificationResult::failure( + ErrorCode::DomainMismatch, + &format!( + "signature domain {:?} does not match expected domain {:?}", + sig.domain, domain + ), + ); + } - let revocation = match resolver.resolve_revocation(domain, &discovery) { + let discovery = match resolver.resolve_discovery(&domain) { Ok(doc) => doc, - Err(_) => { + Err(e) => { return VerificationResult::failure( ErrorCode::DiscoveryFetchFailed, - "Revocation document unreachable (fail-closed)", + &format!("Failed to resolve discovery document: {}", e), ); } }; - verify_skill_offline( + let revocation = match resolver.resolve_revocation(&domain, &discovery) { + Ok(doc) => doc, + Err(e) => { + if !ropts.allow_unreachable_revocation { + return VerificationResult::failure( + ErrorCode::DiscoveryFetchFailed, + &format!("Revocation document unreachable (fail-closed): {}", e), + ); + } + None + } + }; + + verify_skill_offline_with_options( skill_dir, &discovery, - None, + Some(&sig), revocation.as_ref(), pin_store, - tool_id, + &VerifyOptions { + domain: Some(&domain), + tool_id, + allow_expired: ropts.allow_expired, + }, ) } /// Compare two file manifests and report differences. +/// +/// Only meaningful for `schemapin_version >= "1.4"` documents, whose +/// `file_manifest` is covered by the signature; for legacy 1.3 documents the +/// manifest is unsigned and this is a diagnostic aid only. pub fn detect_tampered_files( current: &BTreeMap, signed: &BTreeMap, @@ -1146,7 +1527,7 @@ mod tests { let kp = generate_key_pair().unwrap(); let sig = sign_skill(dir.path(), &kp.private_key_pem, "example.com", None, None).unwrap(); assert!(sig.expires_at.is_none()); - assert_eq!(sig.schemapin_version, "1.3"); + assert_eq!(sig.schemapin_version, "1.4"); let raw = fs::read_to_string(dir.path().join(".schemapin.sig")).unwrap(); assert!(!raw.contains("expires_at"), "JSON should omit expires_at"); } @@ -1171,26 +1552,63 @@ mod tests { } #[test] - fn test_verify_with_past_ttl_passes_with_expired_warning() { - // Sign normally, then rewrite the .schemapin.sig with a past expires_at. + fn test_verify_with_past_ttl_fails_closed() { let dir = tempdir().unwrap(); make_skill_dir(dir.path(), &[("SKILL.md", b"---\nname: past\n---\n")]); let kp = generate_key_pair().unwrap(); - let mut sig = - sign_skill(dir.path(), &kp.private_key_pem, "example.com", None, None).unwrap(); - sig.expires_at = Some( - (chrono::Utc::now() - chrono::Duration::days(1)) - .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), - ); - sig.schemapin_version = "1.4".to_string(); - let json = serde_json::to_string_pretty(&sig).unwrap(); - fs::write(dir.path().join(".schemapin.sig"), format!("{}\n", json)).unwrap(); + let opts = SignOptions::new().with_expires_in(-chrono::Duration::days(1)); + sign_skill_with_options(dir.path(), &kp.private_key_pem, "example.com", opts).unwrap(); let discovery = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.3"); let result = verify_skill_offline(dir.path(), &discovery, None, None, None, Some("past")); - assert!(result.valid, "expired sigs are degraded, not failed"); + assert!(!result.valid, "expired signatures fail closed by default"); + assert_eq!(result.error_code, Some(ErrorCode::SignatureExpired)); assert!(result.expired); - assert!(result.warnings.iter().any(|w| w == "signature_expired")); + + let degraded = verify_skill_offline_with_options( + dir.path(), + &discovery, + None, + None, + None, + &VerifyOptions { + tool_id: Some("past"), + allow_expired: true, + ..VerifyOptions::default() + }, + ); + assert!(degraded.valid, "allow_expired degrades instead of failing"); + assert!(degraded.expired); + assert!(degraded.warnings.iter().any(|w| w == "signature_expired")); + } + + #[test] + fn test_expires_at_is_covered_by_signature() { + let dir = tempdir().unwrap(); + make_skill_dir(dir.path(), &[("SKILL.md", b"---\nname: strip\n---\n")]); + let kp = generate_key_pair().unwrap(); + let opts = SignOptions::new().with_expires_in(-chrono::Duration::days(1)); + let mut sig = + sign_skill_with_options(dir.path(), &kp.private_key_pem, "example.com", opts).unwrap(); + let discovery = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.4"); + + sig.expires_at = None; + fs::write( + dir.path().join(".schemapin.sig"), + serde_json::to_string(&sig).unwrap(), + ) + .unwrap(); + let r = verify_skill_offline(dir.path(), &discovery, None, None, None, None); + assert!(!r.valid); + assert_eq!(r.error_code, Some(ErrorCode::SignatureInvalid)); + + sig.schemapin_version = "1.3".to_string(); + fs::write( + dir.path().join(".schemapin.sig"), + serde_json::to_string(&sig).unwrap(), + ) + .unwrap(); + assert!(!verify_skill_offline(dir.path(), &discovery, None, None, None, None).valid); } // ── v1.4: DNS TXT cross-verification tests ───────────────────── @@ -1272,24 +1690,27 @@ mod tests { } #[test] - fn test_verify_with_unparseable_expires_at_warns() { - let dir = tempdir().unwrap(); - make_skill_dir(dir.path(), &[("SKILL.md", b"---\nname: bad\n---\n")]); - let kp = generate_key_pair().unwrap(); - let mut sig = - sign_skill(dir.path(), &kp.private_key_pem, "example.com", None, None).unwrap(); - sig.expires_at = Some("not-a-timestamp".to_string()); - let json = serde_json::to_string_pretty(&sig).unwrap(); - fs::write(dir.path().join(".schemapin.sig"), format!("{}\n", json)).unwrap(); - - let discovery = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.3"); - let result = verify_skill_offline(dir.path(), &discovery, None, None, None, Some("bad")); - assert!(result.valid); - assert!(!result.expired); - assert!(result - .warnings - .iter() - .any(|w| w == "signature_expires_at_unparseable")); + fn test_unparseable_expires_at_fails_closed() { + for bad in [ + "not-a-timestamp", + "2020-01-01", + "2020-01-01T00:00:00", + "2020-01-01 00:00:00Z", + ] { + let r = VerificationResult::failure(ErrorCode::SignatureInvalid, "x"); + let mut r = r; + r.valid = true; + r.error_code = None; + let strict = r.clone().with_expiration_policy(Some(bad), false); + assert!(!strict.valid, "{} must fail closed", bad); + assert_eq!(strict.error_code, Some(ErrorCode::SignatureExpired)); + let lenient = r.with_expiration_policy(Some(bad), true); + assert!(lenient.valid); + assert!(lenient + .warnings + .iter() + .any(|w| w == "signature_expires_at_unparseable")); + } } // ── v1.4 alpha.2: schema_version + previous_hash lineage tests ── @@ -1428,4 +1849,337 @@ mod tests { assert_eq!(on_disk.schema_version, sig.schema_version); assert_eq!(on_disk.previous_hash, sig.previous_hash); } + + // ── signed signature document, domain binding, hashing v2 ────── + + fn sign_legacy_v13(dir: &Path, private_key_pem: &str, domain: &str) -> SkillSignature { + let (root_hash, manifest) = canonicalize_skill_v1(dir).unwrap(); + let secret = p256::SecretKey::from_pkcs8_pem(private_key_pem).unwrap(); + let pem = secret + .public_key() + .to_public_key_pem(p256::pkcs8::LineEnding::LF) + .unwrap(); + let sig = SkillSignature { + schemapin_version: "1.3".to_string(), + skill_name: "legacy".to_string(), + skill_hash: format!("sha256:{}", hex::encode(&root_hash)), + signature: crypto::sign_data(private_key_pem, &root_hash).unwrap(), + signed_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: None, + schema_version: None, + previous_hash: None, + canonicalization: None, + domain: domain.to_string(), + signer_kid: crypto::calculate_key_id(&pem).unwrap(), + file_manifest: manifest, + }; + fs::write( + dir.join(".schemapin.sig"), + serde_json::to_string(&sig).unwrap(), + ) + .unwrap(); + sig + } + + fn write_sig(dir: &Path, sig: &SkillSignature) { + fs::write( + dir.join(".schemapin.sig"), + serde_json::to_string(sig).unwrap(), + ) + .unwrap(); + } + + #[test] + fn test_metadata_tamper_invalidates() { + let kp = generate_key_pair().unwrap(); + let discovery = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.4"); + type Mutation = Box; + let mutations: Vec = vec![ + Box::new(|s| s.schema_version = Some("9.9.9".to_string())), + Box::new(|s| s.previous_hash = Some(format!("sha256:{}", "ab".repeat(32)))), + Box::new(|s| s.skill_name = "payments-tool".to_string()), + Box::new(|s| s.domain = "good.example.com".to_string()), + Box::new(|s| s.signer_kid = format!("sha256:{}", "00".repeat(32))), + Box::new(|s| s.signed_at = "2030-01-01T00:00:00Z".to_string()), + Box::new(|s| s.canonicalization = Some("schemapin-v1".to_string())), + Box::new(|s| { + s.file_manifest.insert( + "extra.txt".to_string(), + format!("sha256:{}", "00".repeat(32)), + ); + }), + ]; + for (i, mutate) in mutations.iter().enumerate() { + let dir = tempdir().unwrap(); + make_skill_dir(dir.path(), &[("SKILL.md", b"---\nname: s\n---\n")]); + let opts = SignOptions::new().with_schema_version("1.0.0"); + let mut sig = + sign_skill_with_options(dir.path(), &kp.private_key_pem, "example.com", opts) + .unwrap(); + mutate(&mut sig); + write_sig(dir.path(), &sig); + let r = verify_skill_offline(dir.path(), &discovery, None, None, None, None); + assert!(!r.valid, "mutation {} must invalidate the signature", i); + } + } + + #[test] + fn test_skill_hash_is_hex_root_and_verified() { + let kp = generate_key_pair().unwrap(); + let dir = tempdir().unwrap(); + make_skill_dir(dir.path(), &[("SKILL.md", b"# hi")]); + let sig = sign_skill(dir.path(), &kp.private_key_pem, "example.com", None, None).unwrap(); + let (root, _) = canonicalize_skill(dir.path()).unwrap(); + assert_eq!(sig.skill_hash, format!("sha256:{}", hex::encode(&root))); + + let other = tempdir().unwrap(); + make_skill_dir(other.path(), &[("SKILL.md", b"# other")]); + let other_sig = + sign_skill(other.path(), &kp.private_key_pem, "example.com", None, None).unwrap(); + write_sig(dir.path(), &other_sig); + let discovery = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.4"); + let r = verify_skill_offline(dir.path(), &discovery, None, None, None, None); + assert!(!r.valid); + assert!(r.error_message.unwrap().contains("skill_hash")); + } + + #[test] + fn test_legacy_v13_document_verifies_with_warning() { + let kp = generate_key_pair().unwrap(); + let dir = tempdir().unwrap(); + make_skill_dir(dir.path(), &[("SKILL.md", b"# hi"), ("sub/x.txt", b"x")]); + let mut sig = sign_legacy_v13(dir.path(), &kp.private_key_pem, "example.com"); + let discovery = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.4"); + let r = verify_skill_offline_with_options( + dir.path(), + &discovery, + None, + None, + None, + &VerifyOptions { + domain: Some("example.com"), + ..VerifyOptions::default() + }, + ); + assert!(r.valid, "{:?}", r); + assert!(r.warnings.iter().any(|w| w == "legacy_signature_format")); + + sig.schemapin_version = "1.4".to_string(); + write_sig(dir.path(), &sig); + assert!(!verify_skill_offline(dir.path(), &discovery, None, None, None, None).valid); + + let current = SkillSignature { + previous_hash: Some("sha256:aa".to_string()), + ..sig.clone() + }; + let mut previous = sig.clone(); + previous.schemapin_version = "1.3".to_string(); + previous.skill_hash = "sha256:aa".to_string(); + assert_eq!(verify_chain(¤t, &previous), Err(ChainError::Legacy)); + } + + #[test] + fn test_domain_binding() { + let kp = generate_key_pair().unwrap(); + let dir = tempdir().unwrap(); + make_skill_dir(dir.path(), &[("SKILL.md", b"# hi")]); + sign_skill( + dir.path(), + &kp.private_key_pem, + "evil.example.com", + None, + None, + ) + .unwrap(); + let discovery = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.4"); + let mut store = KeyPinStore::new(); + let r = verify_skill_offline_with_options( + dir.path(), + &discovery, + None, + None, + Some(&mut store), + &VerifyOptions { + domain: Some("good.example.com"), + tool_id: Some("calc"), + allow_expired: false, + }, + ); + assert_eq!(r.error_code, Some(ErrorCode::DomainMismatch)); + assert!(store.get_tool("calc", "good.example.com").is_none()); + assert!(store.get_tool("calc", "evil.example.com").is_none()); + + for bad in [ + "../skills/evil/key", + "localhost", + "http://example.com", + "10.0.0.1", + ] { + let r = verify_skill_offline_with_options( + dir.path(), + &discovery, + None, + None, + None, + &VerifyOptions { + domain: Some(bad), + ..VerifyOptions::default() + }, + ); + assert_eq!(r.error_code, Some(ErrorCode::DomainInvalid), "{}", bad); + } + + let dir2 = tempdir().unwrap(); + make_skill_dir(dir2.path(), &[("SKILL.md", b"# hi")]); + sign_skill(dir2.path(), &kp.private_key_pem, "Example.COM", None, None).unwrap(); + let bound = verify_skill_offline_with_options( + dir2.path(), + &discovery, + None, + None, + None, + &VerifyOptions { + domain: Some("example.com"), + ..VerifyOptions::default() + }, + ); + assert!(bound.valid); + assert_eq!(bound.domain.as_deref(), Some("example.com")); + assert!(!bound.warnings.iter().any(|w| w == "domain_unbound")); + let unbound = verify_skill_offline(dir2.path(), &discovery, None, None, None, None); + assert!(unbound.warnings.iter().any(|w| w == "domain_unbound")); + } + + #[test] + fn test_resolver_path_binds_domain_before_io() { + struct Exploding; + impl SchemaResolver for Exploding { + fn resolve_discovery(&self, _domain: &str) -> Result { + panic!("network must not be touched on domain mismatch"); + } + } + let kp = generate_key_pair().unwrap(); + let dir = tempdir().unwrap(); + make_skill_dir(dir.path(), &[("SKILL.md", b"# hi")]); + sign_skill( + dir.path(), + &kp.private_key_pem, + "good.example.com", + None, + None, + ) + .unwrap(); + let r = verify_skill_with_resolver(dir.path(), "evil.example.com", &Exploding, None, None); + assert_eq!(r.error_code, Some(ErrorCode::DomainMismatch)); + } + + #[test] + fn test_pin_after_verify() { + let kp = generate_key_pair().unwrap(); + let other = generate_key_pair().unwrap(); + let dir = tempdir().unwrap(); + make_skill_dir(dir.path(), &[("SKILL.md", b"# hi")]); + sign_skill(dir.path(), &kp.private_key_pem, "example.com", None, None).unwrap(); + let wrong = build_well_known_response(&other.public_key_pem, Some("Dev"), vec![], "1.4"); + let right = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.4"); + let mut store = KeyPinStore::new(); + let opts = VerifyOptions { + domain: Some("example.com"), + tool_id: Some("t"), + allow_expired: false, + }; + let r = verify_skill_offline_with_options( + dir.path(), + &wrong, + None, + None, + Some(&mut store), + &opts, + ); + assert_eq!(r.error_code, Some(ErrorCode::SignatureInvalid)); + assert!( + store.get_tool("t", "example.com").is_none(), + "failed verification must not pin" + ); + let r = verify_skill_offline_with_options( + dir.path(), + &right, + None, + None, + Some(&mut store), + &opts, + ); + assert!(r.valid); + assert_eq!(r.key_pinning.unwrap().status, "first_use"); + assert!(store.get_tool("t", "example.com").is_some()); + + let dir2 = tempdir().unwrap(); + make_skill_dir(dir2.path(), &[("SKILL.md", b"# hi")]); + let sopts = SignOptions::new().with_expires_in(-chrono::Duration::days(1)); + sign_skill_with_options(dir2.path(), &kp.private_key_pem, "example.com", sopts).unwrap(); + let mut store2 = KeyPinStore::new(); + let r = verify_skill_offline_with_options( + dir2.path(), + &right, + None, + None, + Some(&mut store2), + &opts, + ); + assert!(!r.valid); + assert!( + store2.get_tool("t", "example.com").is_none(), + "expired must not pin" + ); + } + + #[cfg(unix)] + #[test] + fn test_symlinks_rejected() { + let base = tempdir().unwrap(); + let dir = base.path().join("skill"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("SKILL.md"), b"# hi").unwrap(); + let kp = generate_key_pair().unwrap(); + sign_skill(&dir, &kp.private_key_pem, "example.com", None, None).unwrap(); + fs::write(base.path().join("outside.txt"), b"unsigned").unwrap(); + std::os::unix::fs::symlink(base.path().join("outside.txt"), dir.join("helper.sh")).unwrap(); + assert!(canonicalize_skill(&dir).is_err()); + let discovery = build_well_known_response(&kp.public_key_pem, Some("Dev"), vec![], "1.4"); + let r = verify_skill_offline(&dir, &discovery, None, None, None, None); + assert_eq!(r.error_code, Some(ErrorCode::SchemaCanonicalizationFailed)); + fs::remove_file(dir.join("helper.sh")).unwrap(); + fs::create_dir_all(base.path().join("other")).unwrap(); + std::os::unix::fs::symlink(base.path().join("other"), dir.join("examples")).unwrap(); + assert!(canonicalize_skill(&dir).is_err()); + } + + #[test] + fn test_path_content_boundary_not_malleable() { + let a = tempdir().unwrap(); + let b = tempdir().unwrap(); + make_skill_dir(a.path(), &[("run.sh.disabled", b"\necho hi")]); + make_skill_dir(b.path(), &[("run.sh", b".disabled\necho hi")]); + assert_ne!( + canonicalize_skill(a.path()).unwrap().0, + canonicalize_skill(b.path()).unwrap().0 + ); + // The legacy algorithm collides on exactly this re-split. + assert_eq!( + canonicalize_skill_v1(a.path()).unwrap().0, + canonicalize_skill_v1(b.path()).unwrap().0 + ); + } + + #[test] + fn test_nested_sig_file_is_content() { + let dir = tempdir().unwrap(); + make_skill_dir( + dir.path(), + &[("SKILL.md", b"# hi"), ("nested/.schemapin.sig", b"{}")], + ); + let (_, manifest) = canonicalize_skill(dir.path()).unwrap(); + assert!(manifest.contains_key("nested/.schemapin.sig")); + assert!(!manifest.contains_key(".schemapin.sig")); + } } diff --git a/rust/src/verification.rs b/rust/src/verification.rs index 579dbe3..beb5ce1 100644 --- a/rust/src/verification.rs +++ b/rust/src/verification.rs @@ -4,13 +4,38 @@ use serde_json::Value; use crate::canonicalize::canonicalize_and_hash; use crate::crypto; use crate::discovery::validate_well_known_response; +use crate::dns::{verify_dns_match, DnsTxtRecord}; +use crate::domain::validate_domain; use crate::error::ErrorCode; -use crate::pinning::{check_pinning, KeyPinStore, PinningResult}; +use crate::pinning::{check_pinning_no_write, KeyPinStore, PinningResult}; use crate::resolver::SchemaResolver; use crate::revocation::check_revocation_combined; use crate::types::discovery::WellKnownResponse; use crate::types::revocation::RevocationDocument; +/// Clock-skew tolerance applied to `expires_at` comparisons (seconds). +pub const EXPIRATION_SKEW_SECONDS: i64 = 300; + +/// Parse a strict RFC 3339 timestamp (`T` separator, mandatory offset). +/// +/// chrono's `parse_from_rfc3339` also accepts a space separator and a +/// lowercase `t`; the other SDKs do not, so they are rejected here. +pub fn parse_rfc3339_strict(raw: &str) -> Option> { + let bytes = raw.as_bytes(); + if bytes.len() < 20 || bytes[10] != b'T' { + return None; + } + if !(raw.ends_with('Z') + || raw.as_bytes().get(raw.len() - 6) == Some(&b'+') + || raw.as_bytes().get(raw.len() - 6) == Some(&b'-')) + { + return None; + } + chrono::DateTime::parse_from_rfc3339(raw) + .ok() + .map(|ts| ts.with_timezone(&chrono::Utc)) +} + /// Structured verification result. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VerificationResult { @@ -107,29 +132,55 @@ impl VerificationResult { self } - /// Apply a signature `expires_at` check to a successful result. + /// Apply a signature `expires_at` check to a successful result + /// (fail-closed). Equivalent to `with_expiration_policy(expires_at, false)`. + pub fn with_expiration_check(self, expires_at: Option<&str>) -> Self { + self.with_expiration_policy(expires_at, false) + } + + /// Apply a signature `expires_at` check: /// - /// - If `expires_at` is `None`, the result is unchanged. - /// - If parseable and in the past, marks `expired = true`, copies - /// `expires_at`, and pushes a `signature_expired` warning. `valid` is - /// left intact (degraded, not failed). - /// - If parseable and in the future, just records `expires_at`. - /// - If unparseable, pushes a `signature_expires_at_unparseable` warning. - pub fn with_expiration_check(mut self, expires_at: Option<&str>) -> Self { + /// - `None` leaves the result unchanged. + /// - A value that is not strict RFC 3339 fails with + /// [`ErrorCode::SignatureExpired`] (`allow_expired` downgrades this to a + /// `signature_expires_at_unparseable` warning). + /// - A timestamp more than [`EXPIRATION_SKEW_SECONDS`] in the past fails + /// with [`ErrorCode::SignatureExpired`] and sets `expired`. With + /// `allow_expired` the result stays valid but is marked `expired` and + /// carries a `signature_expired` warning (the pre-1.4.0 "degraded, not + /// failed" behavior). + /// - A future timestamp just records `expires_at`. + pub fn with_expiration_policy(mut self, expires_at: Option<&str>, allow_expired: bool) -> Self { let Some(raw) = expires_at else { return self; }; - match chrono::DateTime::parse_from_rfc3339(raw) { - Ok(ts) => { + match parse_rfc3339_strict(raw) { + Some(ts) => { self.expires_at = Some(raw.to_string()); - if chrono::Utc::now() > ts.with_timezone(&chrono::Utc) { + let deadline = ts + chrono::Duration::seconds(EXPIRATION_SKEW_SECONDS); + if chrono::Utc::now() > deadline { self.expired = true; - self.warnings.push("signature_expired".to_string()); + if allow_expired { + self.warnings.push("signature_expired".to_string()); + } else { + self.valid = false; + self.error_code = Some(ErrorCode::SignatureExpired); + self.error_message = Some(format!("signature expired at {}", raw)); + } } } - Err(_) => { - self.warnings - .push("signature_expires_at_unparseable".to_string()); + None => { + if allow_expired { + self.warnings + .push("signature_expires_at_unparseable".to_string()); + } else { + self.valid = false; + self.error_code = Some(ErrorCode::SignatureExpired); + self.error_message = Some(format!( + "expires_at is not a valid RFC 3339 timestamp: {:?}", + raw + )); + } } } self @@ -174,6 +225,16 @@ pub fn verify_schema_offline( /// `.schemapin.sig` documents. `None` or `Some("schemapin-v1")` are /// equivalent and accepted; any other value fails with /// `CANONICALIZATION_UNSUPPORTED`. +/// +/// Verification flow: +/// 1. Validate the canonicalization identifier and the domain syntax +/// 2. Validate discovery document +/// 3. Extract public key and compute fingerprint +/// 4. Check revocation (simple list + standalone document) +/// 5. TOFU pin check (read-only) +/// 6. Canonicalize schema, compute hash, verify ECDSA signature +/// 7. Pin the key on first use (only after a successful verification) +/// 8. Return result #[allow(clippy::too_many_arguments)] pub fn verify_schema_offline_with_canonicalization( schema: &Value, @@ -185,16 +246,20 @@ pub fn verify_schema_offline_with_canonicalization( pin_store: &mut KeyPinStore, canonicalization: Option<&str>, ) -> VerificationResult { - // Step 0 (v1.4): Reject unknown canonicalization algorithms before any - // discovery / crypto work. + // Step 1: canonicalization identifier + domain syntax. if let Err(declared) = crate::canonicalize::check_canonicalization(canonicalization) { return VerificationResult::failure( ErrorCode::CanonicalizationUnsupported, &format!("Unsupported canonicalization algorithm: {}", declared), ); } + let domain = match validate_domain(domain) { + Ok(d) => d, + Err(e) => return VerificationResult::failure(ErrorCode::DomainInvalid, &e.to_string()), + }; + let domain = domain.as_str(); - // Step 1: Validate discovery document + // Step 2: Validate discovery document if let Err(e) = validate_well_known_response(discovery) { return VerificationResult::failure( ErrorCode::DiscoveryInvalid, @@ -202,7 +267,7 @@ pub fn verify_schema_offline_with_canonicalization( ); } - // Step 2: Extract public key and compute fingerprint + // Step 3: Extract public key and compute fingerprint let fingerprint = match crypto::calculate_key_id(&discovery.public_key_pem) { Ok(fp) => fp, Err(e) => { @@ -213,7 +278,7 @@ pub fn verify_schema_offline_with_canonicalization( } }; - // Step 3: Check revocation (both simple list and standalone doc) + // Step 4: Check revocation (both simple list and standalone doc) if let Err(e) = check_revocation_combined(&discovery.revoked_keys, revocation, &fingerprint) { let code = match &e { crate::error::Error::Verification { code, .. } => *code, @@ -222,15 +287,15 @@ pub fn verify_schema_offline_with_canonicalization( return VerificationResult::failure(code, &e.to_string()); } - // Step 4: TOFU key pinning - let pin_result = match check_pinning(pin_store, tool_id, domain, &fingerprint) { + // Step 5: TOFU pin check (no write yet) + let pin_result = match check_pinning_no_write(pin_store, tool_id, domain, &fingerprint) { Ok(r) => r, Err(e) => { return VerificationResult::failure(ErrorCode::KeyPinMismatch, &e.to_string()); } }; - // Step 5: Canonicalize schema and compute hash + // Step 6: Canonicalize, hash, verify let schema_hash = match canonicalize_and_hash(schema) { Ok(h) => h, Err(e) => { @@ -241,7 +306,6 @@ pub fn verify_schema_offline_with_canonicalization( } }; - // Step 6: Verify ECDSA signature let valid = match crypto::verify_signature(&discovery.public_key_pem, &schema_hash, signature_b64) { Ok(v) => v, @@ -260,7 +324,12 @@ pub fn verify_schema_offline_with_canonicalization( ); } - // Step 7: Build success result + // Step 7: Pin only after the signature verified + if pin_result == PinningResult::FirstUse { + pin_store.pin(tool_id, domain, &fingerprint); + } + + // Step 8: Build success result let pin_status = match pin_result { PinningResult::FirstUse => KeyPinningStatus { status: "first_use".to_string(), @@ -282,10 +351,63 @@ pub fn verify_schema_offline_with_canonicalization( VerificationResult::success(domain, discovery.developer_name.as_deref(), pin_status) } +/// [`verify_schema_offline`] plus a DNS TXT second-channel check. +/// +/// When `dns_txt` is `Some`, the discovery key's fingerprint must match the +/// TXT record or the result is a hard [`ErrorCode::DomainMismatch`] failure. +/// The DNS check runs before anything is pinned. +#[allow(clippy::too_many_arguments)] +pub fn verify_schema_offline_with_dns( + schema: &Value, + signature_b64: &str, + domain: &str, + tool_id: &str, + discovery: &WellKnownResponse, + revocation: Option<&RevocationDocument>, + pin_store: &mut KeyPinStore, + dns_txt: Option<&DnsTxtRecord>, + canonicalization: Option<&str>, +) -> VerificationResult { + if let Some(txt) = dns_txt { + match verify_dns_match(discovery, txt) { + Ok(()) => {} + Err(crate::error::Error::Verification { code, message }) => { + return VerificationResult::failure(code, &message) + } + Err(e) => { + return VerificationResult::failure(ErrorCode::DomainMismatch, &e.to_string()) + } + } + } + verify_schema_offline_with_canonicalization( + schema, + signature_b64, + domain, + tool_id, + discovery, + revocation, + pin_store, + canonicalization, + ) +} + +/// Policy knobs for the resolver-based verification flows. +#[derive(Debug, Clone, Copy, Default)] +pub struct ResolverOptions { + /// Treat a revocation fetch failure as "no revocations" instead of failing + /// closed with `DISCOVERY_FETCH_FAILED`. + pub allow_unreachable_revocation: bool, + /// Accept expired skill signatures (degraded) instead of failing with + /// `SIGNATURE_EXPIRED`. Ignored for schema signatures. + pub allow_expired: bool, +} + /// Verify a schema using a sync [`SchemaResolver`]. /// /// Resolves discovery and revocation documents from the resolver, then -/// delegates to [`verify_schema_offline`]. +/// delegates to [`verify_schema_offline`]. Revocation is fail-closed: a +/// declared `revocation_endpoint` that cannot be fetched fails with +/// `DISCOVERY_FETCH_FAILED` (see [`verify_schema_with_resolver_opts`]). pub fn verify_schema_with_resolver( schema: &Value, signature_b64: &str, @@ -294,7 +416,33 @@ pub fn verify_schema_with_resolver( resolver: &dyn SchemaResolver, pin_store: &mut KeyPinStore, ) -> VerificationResult { - let discovery = match resolver.resolve_discovery(domain) { + verify_schema_with_resolver_opts( + schema, + signature_b64, + domain, + tool_id, + resolver, + pin_store, + ResolverOptions::default(), + ) +} + +/// [`verify_schema_with_resolver`] with explicit policy knobs. +pub fn verify_schema_with_resolver_opts( + schema: &Value, + signature_b64: &str, + domain: &str, + tool_id: &str, + resolver: &dyn SchemaResolver, + pin_store: &mut KeyPinStore, + opts: ResolverOptions, +) -> VerificationResult { + let domain = match validate_domain(domain) { + Ok(d) => d, + Err(e) => return VerificationResult::failure(ErrorCode::DomainInvalid, &e.to_string()), + }; + + let discovery = match resolver.resolve_discovery(&domain) { Ok(doc) => doc, Err(e) => { return VerificationResult::failure( @@ -304,20 +452,23 @@ pub fn verify_schema_with_resolver( } }; - let revocation = match resolver.resolve_revocation(domain, &discovery) { + let revocation = match resolver.resolve_revocation(&domain, &discovery) { Ok(doc) => doc, - Err(_) => { - return VerificationResult::failure( - ErrorCode::DiscoveryFetchFailed, - "Revocation document unreachable (fail-closed)", - ); + Err(e) => { + if !opts.allow_unreachable_revocation { + return VerificationResult::failure( + ErrorCode::DiscoveryFetchFailed, + &format!("Revocation document unreachable (fail-closed): {}", e), + ); + } + None } }; verify_schema_offline( schema, signature_b64, - domain, + &domain, tool_id, &discovery, revocation.as_ref(), diff --git a/rust/tests/cross_language_skill.rs b/rust/tests/cross_language_skill.rs new file mode 100644 index 0000000..c6ac583 --- /dev/null +++ b/rust/tests/cross_language_skill.rs @@ -0,0 +1,124 @@ +//! Cross-language interop: skill-directory fixtures shared by every SDK +//! (see ../../tests/cross-language/README.md). + +use std::path::{Path, PathBuf}; + +use schemapin::skill::{ + canonicalize_skill, canonicalize_skill_v1, load_signature, verify_skill_offline, + verify_skill_offline_with_options, VerifyOptions, +}; +use schemapin::types::discovery::WellKnownResponse; + +#[derive(serde::Deserialize)] +struct Expected { + domain: String, + discovery: WellKnownResponse, + v14: SkillExpected, + legacy_v13: SkillExpected, +} + +#[derive(serde::Deserialize)] +struct SkillExpected { + skill_dir: String, + skill_hash: String, + file_manifest: std::collections::BTreeMap, + #[serde(default)] + signed_fields_tamper_must_fail: Vec, +} + +fn fixtures() -> (PathBuf, Expected) { + let root = Path::new("../tests/cross-language/skill").to_path_buf(); + let text = std::fs::read_to_string(root.join("expected.json")).expect("fixture present"); + (root, serde_json::from_str(&text).unwrap()) +} + +fn copy_dir(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let target = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), target).unwrap(); + } + } +} + +#[test] +fn v14_fixture_hashes_and_verifies() { + let (root, exp) = fixtures(); + let dir = root.join(&exp.v14.skill_dir); + let (root_hash, manifest) = canonicalize_skill(&dir).unwrap(); + assert_eq!( + format!("sha256:{}", hex::encode(root_hash)), + exp.v14.skill_hash + ); + assert_eq!(manifest, exp.v14.file_manifest); + let r = verify_skill_offline_with_options( + &dir, + &exp.discovery, + None, + None, + None, + &VerifyOptions { + domain: Some(&exp.domain), + ..VerifyOptions::default() + }, + ); + assert!(r.valid, "{:?}", r); + assert!(!r.warnings.iter().any(|w| w == "legacy_signature_format")); + assert_eq!(r.schema_version.as_deref(), Some("1.0.0")); +} + +#[test] +fn v14_fixture_tampered_fields_fail() { + let (root, exp) = fixtures(); + let src = root.join(&exp.v14.skill_dir); + let tmp = tempfile::tempdir().unwrap(); + for field in &exp.v14.signed_fields_tamper_must_fail { + let dst = tmp.path().join(field); + copy_dir(&src, &dst); + let raw = std::fs::read_to_string(dst.join(".schemapin.sig")).unwrap(); + let mut doc: serde_json::Value = serde_json::from_str(&raw).unwrap(); + doc[field] = serde_json::Value::String(if field == "skill_hash" { + format!("sha256:{}", "00".repeat(32)) + } else { + "tampered".to_string() + }); + std::fs::write(dst.join(".schemapin.sig"), doc.to_string()).unwrap(); + let r = verify_skill_offline(&dst, &exp.discovery, None, None, None, None); + assert!(!r.valid, "tampering {} must fail", field); + } + let dst = tmp.path().join("content"); + copy_dir(&src, &dst); + std::fs::write(dst.join("scripts/run.sh"), "#!/bin/sh\nrm -rf /\n").unwrap(); + assert!(!verify_skill_offline(&dst, &exp.discovery, None, None, None, None).valid); + // The signature document itself loads fine; only verification rejects it. + assert!(load_signature(&dst).is_ok()); +} + +#[test] +fn legacy_v13_fixture_verifies_with_warning() { + let (root, exp) = fixtures(); + let dir = root.join(&exp.legacy_v13.skill_dir); + let (root_hash, manifest) = canonicalize_skill_v1(&dir).unwrap(); + assert_eq!( + format!("sha256:{}", hex::encode(root_hash)), + exp.legacy_v13.skill_hash + ); + assert_eq!(manifest, exp.legacy_v13.file_manifest); + let r = verify_skill_offline_with_options( + &dir, + &exp.discovery, + None, + None, + None, + &VerifyOptions { + domain: Some(&exp.domain), + ..VerifyOptions::default() + }, + ); + assert!(r.valid, "{:?}", r); + assert!(r.warnings.iter().any(|w| w == "legacy_signature_format")); +} diff --git a/tests/cross-language/skill/expected.json b/tests/cross-language/skill/expected.json new file mode 100644 index 0000000..a00efcd --- /dev/null +++ b/tests/cross-language/skill/expected.json @@ -0,0 +1,43 @@ +{ + "description": "Skill-directory fixtures. fixture-skill carries a v1.4 signed signature document (skill canonicalization v2); legacy-v13-skill carries a pre-1.4 root-hash-only signature (v1 hashing). Both must verify in every SDK; editing any field of the v1.4 document must invalidate it.", + "domain": "fixtures.schemapin.test", + "discovery": { + "schema_version": "1.4", + "developer_name": "Fixture Developer", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEdaZK0oMLh47IvVYYe8bP2g2ko4c0\nJUmPmN2ecEQreZ9u8oTd0dzrdZRiFDuKcYPGqjSJNCIl/aE5ebj7SRXxjQ==\n-----END PUBLIC KEY-----\n", + "revoked_keys": [] + }, + "v14": { + "skill_dir": "fixture-skill", + "skill_hash": "sha256:35ee31a82b9caf07c5ad373ed7928c8dad9f8eaa459c799e74ed665cf7e34c2b", + "file_manifest": { + "README.md": "sha256:5ac0c74b485cddc378f412149d41693dcf9eb1e31a1c8a8e96b3bfc2b16ecf63", + "SKILL.md": "sha256:a028d613cb73203e3932440c780ed6a6582a7b2ef490e67ef8a8e98061a346f8", + "data/config.json": "sha256:a8fd70f74a8025da09711056c9b84c9ace8f44f05382c6789210a79e33ae15c1", + "nested/deep/bin.dat": "sha256:0be60061d17ec759d7683fc0b4a3d0d919181ddc269ccc8e75b2dfcb663309f9", + "nested/deep/empty.txt": "sha256:9c927c56efa29d4c87b3af8755674353d7131e0c79e010675da47f2223d7cd6d", + "scripts/run.sh": "sha256:10f97a3b8e1bf5f017803241627c5afe3b0c9f95cffa52a978c895ffe4fdd4b0" + }, + "signed_fields_tamper_must_fail": [ + "schema_version", + "skill_name", + "domain", + "signed_at", + "signer_kid", + "skill_hash", + "canonicalization" + ] + }, + "legacy_v13": { + "skill_dir": "legacy-v13-skill", + "skill_hash": "sha256:bc48bbb9c4b889644ec3cfb445afa822935a39da60979c5d9131e3ba73afe562", + "file_manifest": { + "README.md": "sha256:845a7665fc65107467bd9fe46ca750eacc5d9a08b270a521c017a7e1d8d6ef40", + "SKILL.md": "sha256:eb0d357b92d06cc7e57b5a7126f1f664b6668cf82c1c0226e479c0531a4919c1", + "data/config.json": "sha256:e3e958a82c2f57f99731b6ae1bc37bb9523d9a34ce67e71647eabfd5e4d542d8", + "nested/deep/bin.dat": "sha256:7735528cfab0c6173fcfbc56eaeba3a51becb5b8ef45aa312e5f52a1dc8fc89e", + "nested/deep/empty.txt": "sha256:1dd65c3871143fedcf69c39e7634ca8683c8f887f588366edfb073902a221fbb", + "scripts/run.sh": "sha256:df734fd75d4d1621eda9d7e9bad7c7b686c79a4b35ed3f5cc608b2709fa8c5ea" + } + } +} diff --git a/tests/cross-language/skill/fixture-skill/.schemapin.sig b/tests/cross-language/skill/fixture-skill/.schemapin.sig new file mode 100644 index 0000000..40f2dc5 --- /dev/null +++ b/tests/cross-language/skill/fixture-skill/.schemapin.sig @@ -0,0 +1,19 @@ +{ + "schemapin_version": "1.4", + "skill_name": "fixture-skill", + "skill_hash": "sha256:35ee31a82b9caf07c5ad373ed7928c8dad9f8eaa459c799e74ed665cf7e34c2b", + "signature": "MEUCIG8+FzojGSK5zDj89IDuKXbpWJGHy19OF1Nwy3ldAvyhAiEAppTyXGO2N/2kN/9rB0M5DBOY6cXhlygI1sRyeFoTVMs=", + "signed_at": "2026-10-09T00:00:00Z", + "schema_version": "1.0.0", + "canonicalization": "schemapin-v1", + "domain": "fixtures.schemapin.test", + "signer_kid": "sha256:bbdd8a900c49dbc8600300a54422f79f24f45977c09907a09e0072528d903c13", + "file_manifest": { + "README.md": "sha256:5ac0c74b485cddc378f412149d41693dcf9eb1e31a1c8a8e96b3bfc2b16ecf63", + "SKILL.md": "sha256:a028d613cb73203e3932440c780ed6a6582a7b2ef490e67ef8a8e98061a346f8", + "data/config.json": "sha256:a8fd70f74a8025da09711056c9b84c9ace8f44f05382c6789210a79e33ae15c1", + "nested/deep/bin.dat": "sha256:0be60061d17ec759d7683fc0b4a3d0d919181ddc269ccc8e75b2dfcb663309f9", + "nested/deep/empty.txt": "sha256:9c927c56efa29d4c87b3af8755674353d7131e0c79e010675da47f2223d7cd6d", + "scripts/run.sh": "sha256:10f97a3b8e1bf5f017803241627c5afe3b0c9f95cffa52a978c895ffe4fdd4b0" + } +} diff --git a/tests/cross-language/skill/fixture-skill/README.md b/tests/cross-language/skill/fixture-skill/README.md new file mode 100644 index 0000000..cdb3542 --- /dev/null +++ b/tests/cross-language/skill/fixture-skill/README.md @@ -0,0 +1,2 @@ +Characters: & "quotes" \ backslash +Unicode: café 日本語 😀 diff --git a/tests/cross-language/skill/fixture-skill/SKILL.md b/tests/cross-language/skill/fixture-skill/SKILL.md new file mode 100644 index 0000000..84ff616 --- /dev/null +++ b/tests/cross-language/skill/fixture-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: fixture-skill +description: Cross-language fixture +--- +# Fixture skill + +Used by every SDK test suite. diff --git a/tests/cross-language/skill/fixture-skill/data/config.json b/tests/cross-language/skill/fixture-skill/data/config.json new file mode 100644 index 0000000..302a701 --- /dev/null +++ b/tests/cross-language/skill/fixture-skill/data/config.json @@ -0,0 +1 @@ +{"b": 1, "a": [1, 2]} diff --git a/tests/cross-language/skill/fixture-skill/nested/deep/bin.dat b/tests/cross-language/skill/fixture-skill/nested/deep/bin.dat new file mode 100644 index 0000000000000000000000000000000000000000..c86626638e0bc8cf47ca49bb1525b40e9737ee64 GIT binary patch literal 256 zcmV+b0ssC00RjUA1qKHQ2?`4g4Gs?w5fT#=6&4p585$cL9UdPbAtECrB_<~*DJm;0 zEiNxGF)}kWH8wXmIXXK$Jw87`K|(`BMMg(RNlHshO-@fxQBqS>RaRG6Sz23MU0z>c zVPa!sWoBn+X=-b1ZEkOHadLBXb#`}nd3t+%eSUv{fr5jCg@%WSiHeJijgF6yk&=^? zm6n&7nVOrNot~edp`xRtrKYE-sj922t*)=Iv9hzYwYImoxw^Z&y}rM|!NSAD#m2|T z$;!*j&Cbuz(bCh@)z;V8+1lIO-QM5e;o{@u<>u$;>FVq3?e6dJ@$&QZ_4fDp`TG0( G{r>;0_J4r@ literal 0 HcmV?d00001 diff --git a/tests/cross-language/skill/fixture-skill/nested/deep/empty.txt b/tests/cross-language/skill/fixture-skill/nested/deep/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/cross-language/skill/fixture-skill/scripts/run.sh b/tests/cross-language/skill/fixture-skill/scripts/run.sh new file mode 100644 index 0000000..07c90f3 --- /dev/null +++ b/tests/cross-language/skill/fixture-skill/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "hello" diff --git a/tests/cross-language/skill/legacy-v13-skill/.schemapin.sig b/tests/cross-language/skill/legacy-v13-skill/.schemapin.sig new file mode 100644 index 0000000..73b707e --- /dev/null +++ b/tests/cross-language/skill/legacy-v13-skill/.schemapin.sig @@ -0,0 +1,17 @@ +{ + "schemapin_version": "1.3", + "skill_name": "fixture-skill", + "skill_hash": "sha256:bc48bbb9c4b889644ec3cfb445afa822935a39da60979c5d9131e3ba73afe562", + "signature": "MEUCIHY/rh/077W09/JFZEqJ4gxlBn4kVu56hJNrwfTKFgUeAiEAm939/Dpx1CGJjyVnbT3Ok2fC3fil70oMrzmKlqmjHMc=", + "signed_at": "2026-10-09T00:00:00Z", + "domain": "fixtures.schemapin.test", + "signer_kid": "sha256:bbdd8a900c49dbc8600300a54422f79f24f45977c09907a09e0072528d903c13", + "file_manifest": { + "README.md": "sha256:845a7665fc65107467bd9fe46ca750eacc5d9a08b270a521c017a7e1d8d6ef40", + "SKILL.md": "sha256:eb0d357b92d06cc7e57b5a7126f1f664b6668cf82c1c0226e479c0531a4919c1", + "data/config.json": "sha256:e3e958a82c2f57f99731b6ae1bc37bb9523d9a34ce67e71647eabfd5e4d542d8", + "nested/deep/bin.dat": "sha256:7735528cfab0c6173fcfbc56eaeba3a51becb5b8ef45aa312e5f52a1dc8fc89e", + "nested/deep/empty.txt": "sha256:1dd65c3871143fedcf69c39e7634ca8683c8f887f588366edfb073902a221fbb", + "scripts/run.sh": "sha256:df734fd75d4d1621eda9d7e9bad7c7b686c79a4b35ed3f5cc608b2709fa8c5ea" + } +} diff --git a/tests/cross-language/skill/legacy-v13-skill/README.md b/tests/cross-language/skill/legacy-v13-skill/README.md new file mode 100644 index 0000000..cdb3542 --- /dev/null +++ b/tests/cross-language/skill/legacy-v13-skill/README.md @@ -0,0 +1,2 @@ +Characters: & "quotes" \ backslash +Unicode: café 日本語 😀 diff --git a/tests/cross-language/skill/legacy-v13-skill/SKILL.md b/tests/cross-language/skill/legacy-v13-skill/SKILL.md new file mode 100644 index 0000000..84ff616 --- /dev/null +++ b/tests/cross-language/skill/legacy-v13-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: fixture-skill +description: Cross-language fixture +--- +# Fixture skill + +Used by every SDK test suite. diff --git a/tests/cross-language/skill/legacy-v13-skill/data/config.json b/tests/cross-language/skill/legacy-v13-skill/data/config.json new file mode 100644 index 0000000..302a701 --- /dev/null +++ b/tests/cross-language/skill/legacy-v13-skill/data/config.json @@ -0,0 +1 @@ +{"b": 1, "a": [1, 2]} diff --git a/tests/cross-language/skill/legacy-v13-skill/nested/deep/bin.dat b/tests/cross-language/skill/legacy-v13-skill/nested/deep/bin.dat new file mode 100644 index 0000000000000000000000000000000000000000..c86626638e0bc8cf47ca49bb1525b40e9737ee64 GIT binary patch literal 256 zcmV+b0ssC00RjUA1qKHQ2?`4g4Gs?w5fT#=6&4p585$cL9UdPbAtECrB_<~*DJm;0 zEiNxGF)}kWH8wXmIXXK$Jw87`K|(`BMMg(RNlHshO-@fxQBqS>RaRG6Sz23MU0z>c zVPa!sWoBn+X=-b1ZEkOHadLBXb#`}nd3t+%eSUv{fr5jCg@%WSiHeJijgF6yk&=^? zm6n&7nVOrNot~edp`xRtrKYE-sj922t*)=Iv9hzYwYImoxw^Z&y}rM|!NSAD#m2|T z$;!*j&Cbuz(bCh@)z;V8+1lIO-QM5e;o{@u<>u$;>FVq3?e6dJ@$&QZ_4fDp`TG0( G{r>;0_J4r@ literal 0 HcmV?d00001 diff --git a/tests/cross-language/skill/legacy-v13-skill/nested/deep/empty.txt b/tests/cross-language/skill/legacy-v13-skill/nested/deep/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/cross-language/skill/legacy-v13-skill/scripts/run.sh b/tests/cross-language/skill/legacy-v13-skill/scripts/run.sh new file mode 100644 index 0000000..07c90f3 --- /dev/null +++ b/tests/cross-language/skill/legacy-v13-skill/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "hello" From 5029ffd0cafed308d4d6778f15d7b1ea22840d88 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 15:03:02 -0700 Subject: [PATCH 05/14] Require an authority allow-list for trust bundles; rotate the fixture 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. --- .gitignore | 7 +- go/examples/client/demo_private_key.pem | 5 - go/examples/developer/demo_private_key.pem | 5 - go/pkg/bundle/authority_pin_store.go | 38 +- go/pkg/bundle/bundle.go | 6 +- go/pkg/bundle/distribution.go | 285 +++++++++--- go/pkg/bundle/distribution_test.go | 144 ++++++- go/schemapin_private.pem | 5 - javascript/demo_private_key.pem | 5 - javascript/examples/developer.js | 4 +- javascript/src/bundle.js | 242 +++++++++-- javascript/src/index.js | 3 + javascript/src/resolver.js | 9 +- javascript/tests/bundle-distribution.test.js | 118 ++++- python/examples/demo_private_key.pem | 5 - python/examples/tool_developer.py | 6 +- python/schemapin/__init__.py | 6 + python/schemapin/bundle_distribution.py | 267 +++++++++--- python/schemapin/crypto.py | 1 - python/schemapin/resolver.py | 19 +- python/tests/test_a2a.py | 1 - python/tests/test_bundle_distribution.py | 143 +++++- python/tools/keygen.py | 18 +- rust/src/bundle.rs | 432 ++++++++++++++++--- rust/src/resolver.rs | 8 + rust/tests/cross_language_bundle.rs | 43 +- rust/tests/cross_language_signature.rs | 8 +- tests/cross-language/signed_bundle.json | 8 +- 28 files changed, 1521 insertions(+), 320 deletions(-) delete mode 100644 go/examples/client/demo_private_key.pem delete mode 100644 go/examples/developer/demo_private_key.pem delete mode 100644 go/schemapin_private.pem delete mode 100644 javascript/demo_private_key.pem delete mode 100644 python/examples/demo_private_key.pem diff --git a/.gitignore b/.gitignore index 99d14a0..ee69f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -223,4 +223,9 @@ yarn-error.log* # Claude / AI tooling .claude/ -docs/superpowers/ \ No newline at end of file +docs/superpowers/ +# Private keys must never be committed (SP-18). Test-only key material lives +# under tests/cross-language/keys/ and is listed explicitly there. +*_private.pem +*_private_key.pem +schemapin_private.pem diff --git a/go/examples/client/demo_private_key.pem b/go/examples/client/demo_private_key.pem deleted file mode 100644 index 43d273f..0000000 --- a/go/examples/client/demo_private_key.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXQGnqvXabnSX21oK -kiae0+KyJKNHHQthnX/FmPPkuluhRANCAASvMxO+2FIj00UYCG1D5tem36B7ENJg -KPQJNqb530HZHW/ZFpC1jubuJIlVtv/jDKw79HSLpwRfVwaibKwZe2bw ------END PRIVATE KEY----- diff --git a/go/examples/developer/demo_private_key.pem b/go/examples/developer/demo_private_key.pem deleted file mode 100644 index 43d273f..0000000 --- a/go/examples/developer/demo_private_key.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXQGnqvXabnSX21oK -kiae0+KyJKNHHQthnX/FmPPkuluhRANCAASvMxO+2FIj00UYCG1D5tem36B7ENJg -KPQJNqb530HZHW/ZFpC1jubuJIlVtv/jDKw79HSLpwRfVwaibKwZe2bw ------END PRIVATE KEY----- diff --git a/go/pkg/bundle/authority_pin_store.go b/go/pkg/bundle/authority_pin_store.go index e568e67..aa3c888 100644 --- a/go/pkg/bundle/authority_pin_store.go +++ b/go/pkg/bundle/authority_pin_store.go @@ -41,15 +41,11 @@ func compositeKey(kid, domain string) string { return fmt.Sprintf("%s@%s", kid, domain) } -// CheckAndPin checks a fingerprint against the store. The first time a kid@domain -// is seen the fingerprint is pinned and PinningResultFirstUse is returned; -// thereafter a matching fingerprint returns PinningResultMatched and a differing -// one returns PinningResultChanged. -func (s *AuthorityPinStore) CheckAndPin(kid, domain, fingerprint string) PinningResult { - key := compositeKey(kid, domain) - pinned, ok := s.fingerprints[key] +// Check compares a fingerprint against the store without mutating it. +// PinningResultFirstUse means nothing is pinned for kid@domain. +func (s *AuthorityPinStore) Check(kid, domain, fingerprint string) PinningResult { + pinned, ok := s.fingerprints[compositeKey(kid, domain)] if !ok { - s.fingerprints[key] = fingerprint return PinningResultFirstUse } if pinned == fingerprint { @@ -57,3 +53,29 @@ func (s *AuthorityPinStore) CheckAndPin(kid, domain, fingerprint string) Pinning } return PinningResultChanged } + +// Pin stores fingerprint for kid@domain (overwrites). +func (s *AuthorityPinStore) Pin(kid, domain, fingerprint string) { + s.fingerprints[compositeKey(kid, domain)] = fingerprint +} + +// Seed pre-seeds an expected fingerprint (allow-list entry). +func (s *AuthorityPinStore) Seed(kid, domain, fingerprint string) { + s.Pin(kid, domain, fingerprint) +} + +// Get returns the pinned fingerprint for kid@domain, or "". +func (s *AuthorityPinStore) Get(kid, domain string) string { + return s.fingerprints[compositeKey(kid, domain)] +} + +// CheckAndPin checks a fingerprint against the store and pins it on first use. +// Prefer Check followed by Pin so that pinning can be deferred until after +// signature verification. +func (s *AuthorityPinStore) CheckAndPin(kid, domain, fingerprint string) PinningResult { + result := s.Check(kid, domain, fingerprint) + if result == PinningResultFirstUse { + s.Pin(kid, domain, fingerprint) + } + return result +} diff --git a/go/pkg/bundle/bundle.go b/go/pkg/bundle/bundle.go index 328635a..9ba3b76 100644 --- a/go/pkg/bundle/bundle.go +++ b/go/pkg/bundle/bundle.go @@ -120,11 +120,15 @@ func (b *SchemaPinTrustBundle) FindRevocation(domain string) *revocation.Revocat return nil } -// ParseTrustBundle parses a trust bundle from a JSON string. +// ParseTrustBundle parses a trust bundle from a JSON string. Bundles with +// duplicate domain entries are rejected so lookups are unambiguous. func ParseTrustBundle(jsonStr string) (*SchemaPinTrustBundle, error) { var bundle SchemaPinTrustBundle if err := json.Unmarshal([]byte(jsonStr), &bundle); err != nil { return nil, fmt.Errorf("failed to parse trust bundle: %w", err) } + if dup := DuplicateDomain(&bundle); dup != "" { + return nil, fmt.Errorf("failed to parse trust bundle: duplicate entries for domain %q", dup) + } return &bundle, nil } diff --git a/go/pkg/bundle/distribution.go b/go/pkg/bundle/distribution.go index 873e416..4d1eb0b 100644 --- a/go/pkg/bundle/distribution.go +++ b/go/pkg/bundle/distribution.go @@ -7,31 +7,47 @@ package bundle // Provides: // // - SignTrustBundle / VerifyTrustBundle — ECDSA P-256 over the canonical -// bundle bytes, with TOFU pinning of the authority key by kid. -// - MergeTrustBundles — combine bundles from multiple sources, newest entry -// wins per domain. +// bundle bytes, with an authority allow-list: the authority key must be +// pre-seeded in the AuthorityPinStore (SeedBundleAuthority) unless the +// caller explicitly opts in to first-use trust. +// - MergeTrustBundles — combine bundles from multiple sources with +// per-authority precedence and parsed-timestamp tie-breaking. // - BuildTrustBundleRequest / BuildTrustBundleResponse / // ParseTrustBundleResponse — the schemapin/trustBundle JSON-RPC envelope // for A2A bundle exchange. // // # Signing input // -// The signature covers the schemapin-v1 canonicalization (recursive sorted -// keys, compact, UTF-8) of the entire bundle object with the signature field -// set to the empty string "". All four SDKs build the identical byte string, -// so a bundle signed by any SDK verifies in every other. +// The signature covers the schemapin-v1 canonicalization of the bundle's +// known fields (unknown fields are ignored and therefore unprotected) with the +// signature field set to the empty string "". All four SDKs build the +// identical byte string, so a bundle signed by any SDK verifies in every other. import ( - "bytes" "encoding/json" "fmt" + "regexp" "sort" "time" + "github.com/ThirdKeyAi/schemapin/go/pkg/core" "github.com/ThirdKeyAi/schemapin/go/pkg/crypto" "github.com/ThirdKeyAi/schemapin/go/pkg/revocation" ) +var rfc3339Strict = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$`) + +func parseRFC3339Strict(value string) (time.Time, bool) { + if !rfc3339Strict.MatchString(value) { + return time.Time{}, false + } + ts, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{}, false + } + return ts.UTC(), true +} + // ErrorCode mirrors ErrorCode for the bundle-distribution error // surface. The values are kept identical to the verification package so callers // can compare across both. The bundle package defines them locally rather than @@ -50,10 +66,71 @@ const ( ErrKeyPinMismatch ErrorCode = "key_pin_mismatch" // ErrSignatureInvalid — the bundle signature does not verify. ErrSignatureInvalid ErrorCode = "signature_invalid" - // ErrDiscoveryInvalid — a JSON-RPC response was missing result.bundle. + // ErrDiscoveryInvalid — a JSON-RPC response was missing result.bundle, or + // a bundle carries duplicate domain entries. ErrDiscoveryInvalid ErrorCode = "discovery_invalid" + // ErrKeyNotFound — the bundle authority public key is not a valid P-256 key. + ErrKeyNotFound ErrorCode = "key_not_found" + // ErrBundleAuthorityUntrusted — the authority kid is not in the allow-list + // and first-use trust was not allowed. + ErrBundleAuthorityUntrusted ErrorCode = "bundle_authority_untrusted" ) +// BundleVerification is the successful outcome of VerifyTrustBundleWithOptions. +// FirstUse is true when the authority key was not pre-seeded and the caller +// allowed first-use trust; hosts should surface this exactly like a first-use +// tool key. +type BundleVerification struct { + Kid string + Fingerprint string + FirstUse bool +} + +// BundleVerifyOptions tunes VerifyTrustBundleWithOptions. +type BundleVerifyOptions struct { + // AllowFirstUse pins an unknown authority on first use (TOFU) instead of + // failing with ErrBundleAuthorityUntrusted. + AllowFirstUse bool +} + +// SeedBundleAuthority pre-seeds the expected fingerprint (sha256:) of a +// bundle authority. This is the allow-list entry VerifyTrustBundle requires. +func SeedBundleAuthority(store *AuthorityPinStore, kid, fingerprint string) { + store.Seed(kid, BundleAuthorityPinDomain, fingerprint) +} + +// SeedBundleAuthorityFromPEM seeds an authority from its PEM public key (for +// example the public_key_pem of the authority's own discovery document) and +// returns the fingerprint. +func SeedBundleAuthorityFromPEM(store *AuthorityPinStore, kid, publicKeyPEM string) (string, error) { + fingerprint, err := crypto.NewKeyManager().CalculateKeyFingerprintFromPEM(publicKeyPEM) + if err != nil { + return "", err + } + SeedBundleAuthority(store, kid, fingerprint) + return fingerprint, nil +} + +// DuplicateDomain returns a domain that appears more than once in the bundle's +// documents or revocations, or "" when every domain is unique. +func DuplicateDomain(b *SchemaPinTrustBundle) string { + seen := map[string]bool{} + for _, d := range b.Documents { + if seen[d.Domain] { + return d.Domain + } + seen[d.Domain] = true + } + seen = map[string]bool{} + for _, r := range b.Revocations { + if seen[r.Domain] { + return r.Domain + } + seen[r.Domain] = true + } + return "" +} + // BundleVersionSigned is the bundle-distribution wire format version stamped on // signed bundles. const BundleVersionSigned = "1.4" @@ -78,20 +155,10 @@ func newBundleError(code ErrorCode, msg string) *BundleError { return &BundleError{Code: code, Message: msg} } -// canonicalizeValue recursively sorts object keys and marshals to compact JSON -// with HTML escaping disabled, matching the Rust serde_json output used by -// canonicalize_schema. HTML escaping MUST be off so that '<', '>' and '&' are -// emitted literally (Go's encoding/json escapes them to \u00xx by default, -// which would diverge from every other SDK). +// canonicalizeValue produces the schemapin-v1 canonical form shared by every +// signing path in this module. func canonicalizeValue(v interface{}) (string, error) { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(v); err != nil { - return "", err - } - // json.Encoder appends a trailing newline; trim it. - return string(bytes.TrimRight(buf.Bytes(), "\n")), nil + return core.CanonicalizeValue(v) } // signingBytes builds the canonical bytes that a bundle's signature covers: the @@ -217,6 +284,10 @@ func SignTrustBundle(b *SchemaPinTrustBundle, privateKeyPEM, kid, signedAt, expi return nil, fmt.Errorf("failed to export public key: %w", err) } + if dup := DuplicateDomain(b); dup != "" { + return nil, newBundleError(ErrDiscoveryInvalid, fmt.Sprintf("trust bundle has duplicate entries for domain %q", dup)) + } + signed := b.clone() signed.SchemapinBundleVersion = BundleVersionSigned signed.BundleAuthority = &BundleAuthority{Kid: kid, PublicKeyPEM: publicKeyPEM} @@ -240,32 +311,47 @@ func SignTrustBundle(b *SchemaPinTrustBundle, privateKeyPEM, kid, signedAt, expi return signed, nil } -// VerifyTrustBundle verifies a signed trust bundle and TOFU-pins its authority -// key by kid. +// VerifyTrustBundle verifies a signed trust bundle against the authority +// allow-list: the authority kid must already be seeded in authorityPinStore +// (SeedBundleAuthority) or verification fails with ErrBundleAuthorityUntrusted. +// Use VerifyTrustBundleWithOptions to allow first-use pinning. +func VerifyTrustBundle(b *SchemaPinTrustBundle, authorityPinStore *AuthorityPinStore) error { + _, err := VerifyTrustBundleWithOptions(b, authorityPinStore, BundleVerifyOptions{}) + return err +} + +// VerifyTrustBundleWithOptions verifies a signed trust bundle. // // Steps: require bundle_authority + signature (else ErrBundleUnsigned); reject -// when expires_at is in the past or unparseable (ErrBundleExpired); TOFU-pin -// the authority's key fingerprint by kid (mismatch -> ErrKeyPinMismatch); -// verify the signature over the canonical bytes (failure -> ErrSignatureInvalid). +// duplicate domains (ErrDiscoveryInvalid); reject when expires_at is in the +// past or not strict RFC 3339 (ErrBundleExpired); look the authority's key +// fingerprint up by kid -- a different pinned key is ErrKeyPinMismatch, an +// unknown kid is ErrBundleAuthorityUntrusted unless opts.AllowFirstUse; verify +// the signature over the canonical bytes (ErrSignatureInvalid); pin the +// authority on first use only after the signature verified. // -// On a verification failure the returned error is a *BundleError whose Code is -// the relevant ErrorCode. -func VerifyTrustBundle(b *SchemaPinTrustBundle, authorityPinStore *AuthorityPinStore) error { +// The bundle carries its own public key, so without an allow-list any +// self-signed bundle under a fresh kid would pass. Treat AllowFirstUse exactly +// like TOFU for a tool key (prompt the user, log it). +func VerifyTrustBundleWithOptions(b *SchemaPinTrustBundle, authorityPinStore *AuthorityPinStore, opts BundleVerifyOptions) (*BundleVerification, error) { if b.BundleAuthority == nil { - return newBundleError(ErrBundleUnsigned, "trust bundle has no bundle_authority") + return nil, newBundleError(ErrBundleUnsigned, "trust bundle has no bundle_authority") } if b.Signature == "" { - return newBundleError(ErrBundleUnsigned, "trust bundle has no signature") + return nil, newBundleError(ErrBundleUnsigned, "trust bundle has no signature") + } + if dup := DuplicateDomain(b); dup != "" { + return nil, newBundleError(ErrDiscoveryInvalid, fmt.Sprintf("trust bundle has duplicate entries for domain %q", dup)) } if b.ExpiresAt != "" { - exp, err := time.Parse(time.RFC3339, b.ExpiresAt) - if err != nil { - return newBundleError(ErrBundleExpired, - fmt.Sprintf("unparseable expires_at '%s': %v", b.ExpiresAt, err)) + exp, ok := parseRFC3339Strict(b.ExpiresAt) + if !ok { + return nil, newBundleError(ErrBundleExpired, + fmt.Sprintf("unparseable expires_at '%s'", b.ExpiresAt)) } if time.Now().After(exp) { - return newBundleError(ErrBundleExpired, + return nil, newBundleError(ErrBundleExpired, fmt.Sprintf("trust bundle expired at %s", b.ExpiresAt)) } } @@ -273,70 +359,135 @@ func VerifyTrustBundle(b *SchemaPinTrustBundle, authorityPinStore *AuthorityPinS km := crypto.NewKeyManager() fingerprint, err := km.CalculateKeyFingerprintFromPEM(b.BundleAuthority.PublicKeyPEM) if err != nil { - return fmt.Errorf("failed to fingerprint authority key: %w", err) + return nil, newBundleError(ErrKeyNotFound, fmt.Sprintf("bundle authority key is invalid: %v", err)) + } + if authorityPinStore == nil { + authorityPinStore = NewAuthorityPinStore() } - if authorityPinStore.CheckAndPin(b.BundleAuthority.Kid, BundleAuthorityPinDomain, fingerprint) == PinningResultChanged { - return newBundleError(ErrKeyPinMismatch, - fmt.Sprintf("bundle authority key for kid '%s' has changed since last pinned", b.BundleAuthority.Kid)) + pinResult := authorityPinStore.Check(b.BundleAuthority.Kid, BundleAuthorityPinDomain, fingerprint) + switch { + case pinResult == PinningResultChanged: + return nil, newBundleError(ErrKeyPinMismatch, + fmt.Sprintf("bundle authority key for kid '%s' does not match the seeded authority key", b.BundleAuthority.Kid)) + case pinResult == PinningResultFirstUse && !opts.AllowFirstUse: + return nil, newBundleError(ErrBundleAuthorityUntrusted, + fmt.Sprintf("bundle authority kid '%s' is not in the authority allow-list; seed it with SeedBundleAuthority or set AllowFirstUse", b.BundleAuthority.Kid)) } pub, err := km.LoadPublicKeyPEM(b.BundleAuthority.PublicKeyPEM) if err != nil { - return fmt.Errorf("failed to load authority public key: %w", err) + return nil, newBundleError(ErrKeyNotFound, fmt.Sprintf("failed to load authority public key: %v", err)) } canonical, err := signingBytes(b) if err != nil { - return err + return nil, err } sm := crypto.NewSignatureManager() if !sm.VerifySignature([]byte(canonical), b.Signature, pub) { - return newBundleError(ErrSignatureInvalid, "trust bundle signature does not verify") + return nil, newBundleError(ErrSignatureInvalid, "trust bundle signature does not verify") } - return nil + if pinResult == PinningResultFirstUse { + authorityPinStore.Pin(b.BundleAuthority.Kid, BundleAuthorityPinDomain, fingerprint) + } + return &BundleVerification{ + Kid: b.BundleAuthority.Kid, + Fingerprint: fingerprint, + FirstUse: pinResult == PinningResultFirstUse, + }, nil +} + +// mergeRank orders bundles for MergeTrustBundles: higher wins. The first +// component is authority precedence, the second the parsed timestamp. +type mergeRank struct { + precedence int + ts time.Time +} + +func (r mergeRank) greater(o mergeRank) bool { + if r.precedence != o.precedence { + return r.precedence > o.precedence + } + return r.ts.After(o.ts) +} + +func rankOf(b *SchemaPinTrustBundle, precedence []string) mergeRank { + rank := 0 + if len(precedence) > 0 { + rank = -len(precedence) - 1 + if b.BundleAuthority != nil { + for i, kid := range precedence { + if kid == b.BundleAuthority.Kid { + rank = -i + break + } + } + } + } + raw := b.SignedAt + if raw == "" { + raw = b.CreatedAt + } + ts, ok := parseRFC3339Strict(raw) + if !ok { + ts = time.Time{} + } + return mergeRank{precedence: rank, ts: ts} } // MergeTrustBundles merges trust bundles, deduplicating discovery + revocation // documents by domain. When two bundles carry the same domain, the entry from -// the bundle with the newer timestamp (signed_at, else created_at) wins. -// -// The result is an unsigned bundle (a merge cannot carry a single authority's -// signature) stamped schemapin_bundle_version = "1.4" with created_at set to -// the newest source timestamp, sorted by domain. Re-sign it with -// SignTrustBundle before redistribution. +// the bundle with the newer parsed timestamp (signed_at, else created_at) +// wins; unparseable timestamps rank oldest. Equivalent to +// MergeTrustBundlesWithPrecedence(bundles, nil). func MergeTrustBundles(bundles []*SchemaPinTrustBundle) *SchemaPinTrustBundle { + return MergeTrustBundlesWithPrecedence(bundles, nil) +} + +// MergeTrustBundlesWithPrecedence is MergeTrustBundles with per-authority +// precedence: authorityPrecedence lists authority kids, earlier wins; bundles +// from unlisted or absent authorities rank last; the parsed timestamp breaks +// ties. Merge only bundles you have already verified with VerifyTrustBundle. +// +// The result is an unsigned bundle stamped schemapin_bundle_version = "1.4" +// with created_at set to the newest source timestamp, sorted by domain. +// Re-sign it with SignTrustBundle before redistribution. +func MergeTrustBundlesWithPrecedence(bundles []*SchemaPinTrustBundle, authorityPrecedence []string) *SchemaPinTrustBundle { type docEntry struct { - ts string - doc BundledDiscovery + rank mergeRank + doc BundledDiscovery } type revEntry struct { - ts string - rev revocation.RevocationDocument + rank mergeRank + rev revocation.RevocationDocument } docs := map[string]docEntry{} revs := map[string]revEntry{} - newestTS := "" + var newest *mergeRank + newestRaw := "" for _, b := range bundles { - ts := b.SignedAt - if ts == "" { - ts = b.CreatedAt - } - if ts > newestTS { - newestTS = ts + rank := rankOf(b, authorityPrecedence) + if newest == nil || rank.ts.After(newest.ts) { + r := rank + newest = &r + newestRaw = b.SignedAt + if newestRaw == "" { + newestRaw = b.CreatedAt + } } for _, d := range b.Documents { - if existing, ok := docs[d.Domain]; ok && existing.ts >= ts { + if existing, ok := docs[d.Domain]; ok && !rank.greater(existing.rank) { continue } - docs[d.Domain] = docEntry{ts: ts, doc: d} + docs[d.Domain] = docEntry{rank: rank, doc: d} } for _, r := range b.Revocations { - if existing, ok := revs[r.Domain]; ok && existing.ts >= ts { + if existing, ok := revs[r.Domain]; ok && !rank.greater(existing.rank) { continue } - revs[r.Domain] = revEntry{ts: ts, rev: r} + revs[r.Domain] = revEntry{rank: rank, rev: r} } } @@ -354,7 +505,7 @@ func MergeTrustBundles(bundles []*SchemaPinTrustBundle) *SchemaPinTrustBundle { return &SchemaPinTrustBundle{ SchemapinBundleVersion: BundleVersionSigned, - CreatedAt: newestTS, + CreatedAt: newestRaw, Documents: documents, Revocations: revocations, } diff --git a/go/pkg/bundle/distribution_test.go b/go/pkg/bundle/distribution_test.go index b5106ce..929d7e1 100644 --- a/go/pkg/bundle/distribution_test.go +++ b/go/pkg/bundle/distribution_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/ThirdKeyAi/schemapin/go/pkg/crypto" @@ -73,7 +74,7 @@ func TestSignVerifyRoundtrip(t *testing.T) { } store := NewAuthorityPinStore() - if err := VerifyTrustBundle(signed, store); err != nil { + if err := verifyFirstUse(signed, store); err != nil { t.Fatalf("verify: %v", err) } } @@ -88,7 +89,7 @@ func TestTamperedBundleFails(t *testing.T) { signed.Documents[0].Domain = "evil.com" store := NewAuthorityPinStore() - code := bundleErrCode(t, VerifyTrustBundle(signed, store)) + code := bundleErrCode(t, verifyFirstUse(signed, store)) if code != ErrSignatureInvalid { t.Errorf("code = %q, want %q", code, ErrSignatureInvalid) } @@ -97,7 +98,7 @@ func TestTamperedBundleFails(t *testing.T) { func TestUnsignedBundleRejected(t *testing.T) { b := makeDistBundle("example.com", "2026-05-15T00:00:00Z") store := NewAuthorityPinStore() - code := bundleErrCode(t, VerifyTrustBundle(b, store)) + code := bundleErrCode(t, verifyFirstUse(b, store)) if code != ErrBundleUnsigned { t.Errorf("code = %q, want %q", code, ErrBundleUnsigned) } @@ -111,7 +112,7 @@ func TestExpiredBundleRejected(t *testing.T) { t.Fatalf("sign: %v", err) } store := NewAuthorityPinStore() - code := bundleErrCode(t, VerifyTrustBundle(signed, store)) + code := bundleErrCode(t, verifyFirstUse(signed, store)) if code != ErrBundleExpired { t.Errorf("code = %q, want %q", code, ErrBundleExpired) } @@ -126,7 +127,7 @@ func TestExpiredBundleUnparseable(t *testing.T) { } signed.ExpiresAt = "not-a-date" store := NewAuthorityPinStore() - code := bundleErrCode(t, VerifyTrustBundle(signed, store)) + code := bundleErrCode(t, verifyFirstUse(signed, store)) if code != ErrBundleExpired { t.Errorf("code = %q, want %q", code, ErrBundleExpired) } @@ -148,10 +149,10 @@ func TestAuthorityTOFUMismatch(t *testing.T) { } store := NewAuthorityPinStore() - if err := VerifyTrustBundle(signed1, store); err != nil { + if err := verifyFirstUse(signed1, store); err != nil { t.Fatalf("verify1 (pins kp1): %v", err) } - code := bundleErrCode(t, VerifyTrustBundle(signed2, store)) + code := bundleErrCode(t, verifyFirstUse(signed2, store)) if code != ErrKeyPinMismatch { t.Errorf("code = %q, want %q", code, ErrKeyPinMismatch) } @@ -228,7 +229,7 @@ func TestJSONRPCEnvelopeRoundtrip(t *testing.T) { // The parsed bundle still verifies. store := NewAuthorityPinStore() - if err := VerifyTrustBundle(parsed, store); err != nil { + if err := verifyFirstUse(parsed, store); err != nil { t.Fatalf("verify parsed: %v", err) } } @@ -259,17 +260,134 @@ func TestBuildTrustBundleRequestOmitsEmptyDomain(t *testing.T) { // fails the canonicalization diverges from Rust. func TestCrossLanguageFixture(t *testing.T) { path := findFixture(t) - raw, err := os.ReadFile(path) //nolint:gosec // test fixture path + raw, err := os.ReadFile(path) // #nosec G304 -- test fixture if err != nil { - t.Fatalf("read fixture: %v", err) + t.Fatal(err) } var b SchemaPinTrustBundle if err := json.Unmarshal(raw, &b); err != nil { - t.Fatalf("unmarshal fixture: %v", err) + t.Fatal(err) + } + if b.BundleAuthority.Kid != "schemapin-test-fixture-authority" { + t.Fatalf("unexpected fixture kid %q", b.BundleAuthority.Kid) + } + // Unknown authority: refused unless seeded. + if code := bundleErrCode(t, VerifyTrustBundle(&b, NewAuthorityPinStore())); code != ErrBundleAuthorityUntrusted { + t.Fatalf("expected %s, got %s", ErrBundleAuthorityUntrusted, code) + } + pub, err := os.ReadFile(filepath.Join(filepath.Dir(path), "keys", "test-only-bundle-authority.pub.pem")) // #nosec G304 + if err != nil { + t.Fatal(err) + } + store := NewAuthorityPinStore() + if _, err := SeedBundleAuthorityFromPEM(store, b.BundleAuthority.Kid, string(pub)); err != nil { + t.Fatal(err) + } + res, err := VerifyTrustBundleWithOptions(&b, store, BundleVerifyOptions{}) + if err != nil { + t.Fatalf("cross-language fixture must verify once seeded: %v", err) + } + if res.FirstUse { + t.Fatal("seeded authority must not be reported as first use") + } + other := NewAuthorityPinStore() + SeedBundleAuthority(other, b.BundleAuthority.Kid, "sha256:"+strings.Repeat("00", 32)) + if code := bundleErrCode(t, VerifyTrustBundle(&b, other)); code != ErrKeyPinMismatch { + t.Fatalf("expected %s, got %s", ErrKeyPinMismatch, code) + } +} + +// verifyFirstUse verifies with first-use trust allowed (the TOFU semantics +// the pre-allow-list tests were written against). +func verifyFirstUse(b *SchemaPinTrustBundle, store *AuthorityPinStore) error { + _, err := VerifyTrustBundleWithOptions(b, store, BundleVerifyOptions{AllowFirstUse: true}) + return err +} + +func TestAuthorityAllowList(t *testing.T) { + priv := genKeyPair(t) + signed, err := SignTrustBundle(makeDistBundle("victim.com", "2026-05-15T00:00:00Z"), priv, "victim-authority-2026", "2026-05-15T00:00:00Z", "") + if err != nil { + t.Fatal(err) + } + if code := bundleErrCode(t, VerifyTrustBundle(signed, NewAuthorityPinStore())); code != ErrBundleAuthorityUntrusted { + t.Fatalf("self-signed bundle under a fresh kid must be refused, got %s", code) } store := NewAuthorityPinStore() - if err := VerifyTrustBundle(&b, store); err != nil { - t.Fatalf("cross-language fixture failed to verify: %v", err) + fp, err := SeedBundleAuthorityFromPEM(store, "victim-authority-2026", signed.BundleAuthority.PublicKeyPEM) + if err != nil { + t.Fatal(err) + } + res, err := VerifyTrustBundleWithOptions(signed, store, BundleVerifyOptions{}) + if err != nil || res.FirstUse || res.Fingerprint != fp { + t.Fatalf("seeded authority must pass strictly: %+v %v", res, err) + } + fresh := NewAuthorityPinStore() + res, err = VerifyTrustBundleWithOptions(signed, fresh, BundleVerifyOptions{AllowFirstUse: true}) + if err != nil || !res.FirstUse { + t.Fatalf("first use must be reported: %+v %v", res, err) + } + if _, err := VerifyTrustBundleWithOptions(signed, fresh, BundleVerifyOptions{}); err != nil { + t.Fatalf("pinned authority must pass strictly afterwards: %v", err) + } + // A failed signature never pins. + tampered, _ := SignTrustBundle(makeDistBundle("example.com", "2026-05-15T00:00:00Z"), priv, "auth", "2026-05-15T00:00:00Z", "") + tampered.Documents[0].Domain = "evil.com" + s2 := NewAuthorityPinStore() + if code := bundleErrCode(t, verifyFirstUse(tampered, s2)); code != ErrSignatureInvalid { + t.Fatalf("expected signature failure, got %s", code) + } + if s2.Get("auth", BundleAuthorityPinDomain) != "" { + t.Fatal("failed verification must not pin the authority") + } +} + +func TestStrictExpiresAtAndDuplicates(t *testing.T) { + priv := genKeyPair(t) + for _, bad := range []string{"2099-01-01", "2099-01-01T00:00:00", "2099-01-01 00:00:00Z"} { + signed, err := SignTrustBundle(makeDistBundle("example.com", "2026-05-15T00:00:00Z"), priv, "auth", "2026-05-15T00:00:00Z", bad) + if err != nil { + t.Fatal(err) + } + if code := bundleErrCode(t, verifyFirstUse(signed, NewAuthorityPinStore())); code != ErrBundleExpired { + t.Errorf("%q: expected %s, got %s", bad, ErrBundleExpired, code) + } + } + dup := makeDistBundle("example.com", "2026-05-15T00:00:00Z") + dup.Documents = append(dup.Documents, dup.Documents[0]) + if _, err := SignTrustBundle(dup, priv, "auth", "2026-05-15T00:00:00Z", ""); err == nil { + t.Fatal("duplicate domains must be rejected at sign time") + } + raw, _ := json.Marshal(dup) + if _, err := ParseTrustBundle(string(raw)); err == nil { + t.Fatal("duplicate domains must be rejected at parse time") + } +} + +func TestMergePrecedence(t *testing.T) { + trusted := genKeyPair(t) + rogue := genKeyPair(t) + named := func(name, ts string) *SchemaPinTrustBundle { + b := makeDistBundle("example.com", ts) + b.Documents[0].WellKnown.DeveloperName = name + return b + } + good, _ := SignTrustBundle(named("Good", "2026-01-01T00:00:00Z"), trusted, "trusted", "2026-01-01T00:00:00Z", "") + bad, _ := SignTrustBundle(named("Rogue", "2099-01-01T00:00:00Z"), rogue, "rogue", "2099-01-01T00:00:00Z", "") + if got := MergeTrustBundlesWithPrecedence([]*SchemaPinTrustBundle{bad, good}, []string{"trusted"}).Documents[0].WellKnown.DeveloperName; got != "Good" { + t.Fatalf("precedence must beat timestamp, got %s", got) + } + if got := MergeTrustBundles([]*SchemaPinTrustBundle{good, bad}).Documents[0].WellKnown.DeveloperName; got != "Rogue" { + t.Fatalf("newer parsed timestamp must win without precedence, got %s", got) + } + real := named("Real", "2026-01-01T00:00:00Z") + bogus := named("Bogus", "9999-99-99 bogus") + if got := MergeTrustBundles([]*SchemaPinTrustBundle{real, bogus}).Documents[0].WellKnown.DeveloperName; got != "Real" { + t.Fatalf("unparseable timestamp must not win, got %s", got) + } + offset := named("Offset", "2026-02-01T00:00:00+00:00") + if got := MergeTrustBundles([]*SchemaPinTrustBundle{real, offset}).Documents[0].WellKnown.DeveloperName; got != "Offset" { + t.Fatalf("offset timestamps must compare as instants, got %s", got) } } diff --git a/go/schemapin_private.pem b/go/schemapin_private.pem deleted file mode 100644 index c09f45d..0000000 --- a/go/schemapin_private.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQglYn/QKKB/Gttxwot -HEbPN+FWriyJRWvs/FRaMbOmsCehRANCAASD5qLHQ59PdiYzUT6z3w3oygY/sLkK -87UG0PkrgqYmC/o3QH4s20J/giC7pn3ijIihUdNRDqL9yNDWTNfHcy/T ------END PRIVATE KEY----- diff --git a/javascript/demo_private_key.pem b/javascript/demo_private_key.pem deleted file mode 100644 index 75b8e8a..0000000 --- a/javascript/demo_private_key.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgN6GxXuwcnuSdOWCc -9v9f5pYeCmfTArIanx1gGdZxsTChRANCAASqBFhX1VAcczsofmSmuUhHVHG+ZZMm -GzSQSL6WJk6Fzh3zrfJsektTH1RvGHbdXqCjNpAE1aWKouFNUqCsC+3s ------END PRIVATE KEY----- diff --git a/javascript/examples/developer.js b/javascript/examples/developer.js index 65ff241..1cfe535 100644 --- a/javascript/examples/developer.js +++ b/javascript/examples/developer.js @@ -66,8 +66,8 @@ function main() { // Step 5: Save files for demonstration console.log("\n5. Saving demonstration files..."); - // Save private key (in real use, keep this secure!) - writeFileSync("demo_private_key.pem", privateKeyPem); + // Save private key with owner-only permissions (in real use, keep this secure!) + writeFileSync("demo_private_key.pem", privateKeyPem, { mode: 0o600 }); // Save schema with signature const schemaWithSignature = { diff --git a/javascript/src/bundle.js b/javascript/src/bundle.js index 691e1f3..8c4ae65 100644 --- a/javascript/src/bundle.js +++ b/javascript/src/bundle.js @@ -24,7 +24,7 @@ import { createPublicKey } from 'crypto'; import { SchemaPinCore } from './core.js'; import { KeyManager, SignatureManager } from './crypto.js'; -import { ErrorCode } from './verification.js'; +import { ErrorCode, parseRfc3339 } from './verification.js'; /** Bundle-distribution wire format version stamped on signed bundles. */ export const BUNDLE_VERSION_SIGNED = '1.4'; @@ -48,18 +48,113 @@ function bundleError(code, message) { return err; } +const DOCUMENT_OPTIONAL_FIELDS = ['developer_name', 'contact', 'revocation_endpoint']; + /** - * Build the canonical bytes that a bundle's signature covers: the bundle with - * its `signature` field forced to `""`, `schemapin-v1`-canonicalized. + * Project a bundled discovery entry onto the signed field set (mirrors the + * Rust serde output: optional strings omitted when absent, `revoked_keys` + * always present). + */ +function normalizeDocument(doc) { + const out = { + domain: doc.domain ?? '', + schema_version: doc.schema_version ?? '', + public_key_pem: doc.public_key_pem ?? '', + revoked_keys: Array.isArray(doc.revoked_keys) ? [...doc.revoked_keys] : [] + }; + for (const key of DOCUMENT_OPTIONAL_FIELDS) { + if (doc[key] !== null && doc[key] !== undefined) { + out[key] = doc[key]; + } + } + return out; +} + +function normalizeRevocation(rev) { + return { + schemapin_version: rev.schemapin_version ?? '', + domain: rev.domain ?? '', + updated_at: rev.updated_at ?? '', + revoked_keys: (rev.revoked_keys ?? []).map((k) => ({ + fingerprint: k.fingerprint, + revoked_at: k.revoked_at, + reason: k.reason + })) + }; +} + +/** + * Build the canonical bytes that a bundle's signature covers: the bundle's + * known fields (unknown fields are ignored and therefore unprotected) with + * `signature` forced to `""`, `schemapin-v1`-canonicalized. * * @param {Object} bundle * @returns {string} Canonical JSON string */ function signingBytes(bundle) { - const obj = { ...bundle, signature: '' }; + const obj = { + schemapin_bundle_version: bundle.schemapin_bundle_version, + created_at: bundle.created_at, + documents: (bundle.documents ?? []).map(normalizeDocument), + revocations: (bundle.revocations ?? []).map(normalizeRevocation) + }; + if (bundle.bundle_authority) { + obj.bundle_authority = { kid: bundle.bundle_authority.kid, public_key_pem: bundle.bundle_authority.public_key_pem }; + } + if (bundle.signed_at !== null && bundle.signed_at !== undefined) { + obj.signed_at = bundle.signed_at; + } + if (bundle.expires_at !== null && bundle.expires_at !== undefined) { + obj.expires_at = bundle.expires_at; + } + obj.signature = ''; return SchemaPinCore.canonicalizeSchema(obj); } +/** + * @param {Object} bundle + * @returns {string|null} a domain that appears more than once, or null + */ +export function findDuplicateDomain(bundle) { + for (const list of [bundle.documents ?? [], bundle.revocations ?? []]) { + const seen = new Set(); + for (const entry of list) { + if (seen.has(entry.domain)) { + return entry.domain; + } + seen.add(entry.domain); + } + } + return null; +} + +/** + * Pre-seed the expected fingerprint (`sha256:`) of a bundle authority. + * This is the allow-list entry {@link verifyTrustBundle} requires. + * + * @param {import('./verification.js').KeyPinStore} store + * @param {string} kid + * @param {string} fingerprint + */ +export function seedBundleAuthority(store, kid, fingerprint) { + store.seed(kid, BUNDLE_AUTHORITY_PIN_DOMAIN, fingerprint); +} + +/** + * Seed an authority from its PEM public key (for example the `public_key_pem` + * of the authority's own discovery document). Returns the fingerprint. + * + * @param {import('./verification.js').KeyPinStore} store + * @param {string} kid + * @param {string} publicKeyPem + * @returns {string} + */ +export function seedBundleAuthorityFromPem(store, kid, publicKeyPem) { + const fingerprint = KeyManager.calculateKeyFingerprint(publicKeyPem); + seedBundleAuthority(store, kid, fingerprint); + return fingerprint; +} + /** * Create an empty trust bundle. * @@ -151,6 +246,10 @@ export function parseTrustBundle(jsonStr) { * @returns {Object} Signed trust bundle */ export function signTrustBundle(bundle, privateKeyPem, kid, signedAt, expiresAt = null) { + const dup = findDuplicateDomain(bundle); + if (dup !== null) { + throw new Error(`trust bundle has duplicate entries for domain ${JSON.stringify(dup)}`); + } // Derive the authority public key PEM from the private key. const publicKeyPem = createPublicKey(privateKeyPem) .export({ type: 'spki', format: 'pem' }); @@ -178,20 +277,29 @@ export function signTrustBundle(bundle, privateKeyPem, kid, signedAt, expiresAt } /** - * Verify a signed trust bundle and TOFU-pin its authority key by `kid`. + * Verify a signed trust bundle against the authority allow-list. * * Steps: require `bundle_authority` + `signature` (else `BUNDLE_UNSIGNED`); - * reject when `expires_at` is in the past or unparseable (`BUNDLE_EXPIRED`); - * TOFU-pin the authority's key fingerprint by `kid` (mismatch → - * `KEY_PIN_MISMATCH`); verify the signature over the canonical bytes - * (failure → `SIGNATURE_INVALID`). + * reject duplicate domains (`DISCOVERY_INVALID`); reject when `expires_at` + * is in the past or not strict RFC 3339 (`BUNDLE_EXPIRED`); look the + * authority's key fingerprint up by `kid` -- a different pinned key is + * `KEY_PIN_MISMATCH`, an unknown `kid` is `BUNDLE_AUTHORITY_UNTRUSTED` + * unless `allowFirstUse` is true; verify the signature over the canonical + * bytes (`SIGNATURE_INVALID`); pin the authority on first use only after the + * signature verified. + * + * The bundle carries its own public key, so without an allow-list any + * self-signed bundle under a fresh `kid` would pass. Seed expected + * authorities with {@link seedBundleAuthority}; treat `allowFirstUse` + * exactly like TOFU for a tool key (prompt the user, log it). * * @param {Object} bundle - Signed trust bundle * @param {import('./verification.js').KeyPinStore} authorityPinStore - * @returns {boolean} `true` on success + * @param {boolean} [allowFirstUse=false] + * @returns {{ kid: string, fingerprint: string, first_use: boolean }} * @throws {Error} With `.code` set to the relevant {@link ErrorCode} */ -export function verifyTrustBundle(bundle, authorityPinStore) { +export function verifyTrustBundle(bundle, authorityPinStore, allowFirstUse = false) { const authority = bundle.bundle_authority; if (!authority) { throw bundleError(ErrorCode.BUNDLE_UNSIGNED, 'trust bundle has no bundle_authority'); @@ -201,15 +309,20 @@ export function verifyTrustBundle(bundle, authorityPinStore) { throw bundleError(ErrorCode.BUNDLE_UNSIGNED, 'trust bundle has no signature'); } + const dup = findDuplicateDomain(bundle); + if (dup !== null) { + throw bundleError(ErrorCode.DISCOVERY_INVALID, `trust bundle has duplicate entries for domain ${JSON.stringify(dup)}`); + } + if (bundle.expires_at !== null && bundle.expires_at !== undefined) { - const exp = new Date(bundle.expires_at); - if (Number.isNaN(exp.getTime())) { + const exp = parseRfc3339(bundle.expires_at); + if (exp === null) { throw bundleError( ErrorCode.BUNDLE_EXPIRED, `unparseable expires_at '${bundle.expires_at}'` ); } - if (Date.now() > exp.getTime()) { + if (Date.now() > exp) { throw bundleError( ErrorCode.BUNDLE_EXPIRED, `trust bundle expired at ${bundle.expires_at}` @@ -217,16 +330,23 @@ export function verifyTrustBundle(bundle, authorityPinStore) { } } - const fingerprint = KeyManager.calculateKeyFingerprint(authority.public_key_pem); - const pinResult = authorityPinStore.checkAndPin( - authority.kid, - BUNDLE_AUTHORITY_PIN_DOMAIN, - fingerprint - ); + let fingerprint; + try { + fingerprint = KeyManager.calculateKeyFingerprint(authority.public_key_pem); + } catch (e) { + throw bundleError(ErrorCode.KEY_NOT_FOUND, `bundle authority key is invalid: ${e.message}`); + } + const pinResult = authorityPinStore.check(authority.kid, BUNDLE_AUTHORITY_PIN_DOMAIN, fingerprint); if (pinResult === 'changed') { throw bundleError( ErrorCode.KEY_PIN_MISMATCH, - `bundle authority key for kid '${authority.kid}' changed since last use` + `bundle authority key for kid '${authority.kid}' does not match the seeded authority key` + ); + } + if (pinResult === 'first_use' && !allowFirstUse) { + throw bundleError( + ErrorCode.BUNDLE_AUTHORITY_UNTRUSTED, + `bundle authority kid '${authority.kid}' is not in the authority allow-list; seed it with seedBundleAuthority() or pass allowFirstUse` ); } @@ -242,58 +362,88 @@ export function verifyTrustBundle(bundle, authorityPinStore) { 'trust bundle signature does not verify' ); } - return true; + if (pinResult === 'first_use') { + authorityPinStore.pin(authority.kid, BUNDLE_AUTHORITY_PIN_DOMAIN, fingerprint); + } + return { kid: authority.kid, fingerprint, first_use: pinResult === 'first_use' }; +} + +/** + * Rank used by {@link mergeTrustBundles}: higher wins. + * + * @returns {[number, number]} [authority precedence, parsed timestamp ms] + */ +function mergeRank(bundle, precedence) { + const kid = bundle.bundle_authority?.kid ?? null; + let rank = 0; + if (precedence && precedence.length > 0) { + rank = -precedence.length - 1; + const idx = kid === null ? -1 : precedence.indexOf(kid); + if (idx >= 0) { + rank = -idx; + } + } + const ts = parseRfc3339(bundle.signed_at ?? bundle.created_at ?? ''); + return [rank, ts === null ? Number.NEGATIVE_INFINITY : ts]; +} + +function rankGreater(a, b) { + return a[0] > b[0] || (a[0] === b[0] && a[1] > b[1]); } /** * Merge trust bundles, deduplicating discovery + revocation documents by - * domain. When two bundles carry the same domain, the entry from the bundle - * with the newer timestamp (`signed_at`, else `created_at`) wins. + * domain. + * + * When two bundles carry the same domain the winner is chosen by + * `authorityPrecedence` first (a list of authority `kid` values, earlier + * wins; bundles from unlisted or absent authorities rank last) and by the + * newer **parsed** RFC 3339 timestamp (`signed_at`, else `created_at`) + * second. Unparseable timestamps rank oldest, so a bundle cannot win a merge + * by claiming a bogus future date. Merge only bundles you have already + * verified with {@link verifyTrustBundle}. * - * The result is an *unsigned* bundle (a merge cannot carry a single - * authority's signature) stamped `schemapin_bundle_version = "1.4"` with - * `created_at` set to the newest source timestamp. Re-sign it with - * {@link signTrustBundle} before redistribution. Documents and revocations are - * sorted by domain. + * The result is an *unsigned* bundle stamped `schemapin_bundle_version = + * "1.4"` with `created_at` set to the newest source timestamp. Re-sign it + * with {@link signTrustBundle} before redistribution. * * @param {Object[]} bundles + * @param {string[]|null} [authorityPrecedence=null] * @returns {Object} Merged unsigned trust bundle */ -export function mergeTrustBundles(bundles) { - // domain -> { ts, doc } +export function mergeTrustBundles(bundles, authorityPrecedence = null) { const docs = new Map(); const revs = new Map(); - let newestTs = ''; + let newest = null; + let newestRaw = ''; for (const b of bundles) { - const ts = b.signed_at ?? b.created_at ?? ''; - if (ts > newestTs) { - newestTs = ts; + const rank = mergeRank(b, authorityPrecedence); + if (newest === null || rank[1] > newest) { + newest = rank[1]; + newestRaw = b.signed_at ?? b.created_at ?? ''; } for (const d of b.documents ?? []) { const existing = docs.get(d.domain); - if (!existing || existing.ts < ts) { - docs.set(d.domain, { ts, doc: d }); + if (!existing || rankGreater(rank, existing.rank)) { + docs.set(d.domain, { rank, doc: d }); } } for (const r of b.revocations ?? []) { const existing = revs.get(r.domain); - if (!existing || existing.ts < ts) { - revs.set(r.domain, { ts, doc: r }); + if (!existing || rankGreater(rank, existing.rank)) { + revs.set(r.domain, { rank, doc: r }); } } } - const documents = [...docs.values()] - .map((e) => e.doc) - .sort((a, b) => (a.domain < b.domain ? -1 : a.domain > b.domain ? 1 : 0)); - const revocations = [...revs.values()] - .map((e) => e.doc) - .sort((a, b) => (a.domain < b.domain ? -1 : a.domain > b.domain ? 1 : 0)); + const byDomain = (a, b) => (a.domain < b.domain ? -1 : a.domain > b.domain ? 1 : 0); + const documents = [...docs.values()].map((e) => e.doc).sort(byDomain); + const revocations = [...revs.values()].map((e) => e.doc).sort(byDomain); return { schemapin_bundle_version: BUNDLE_VERSION_SIGNED, - created_at: newestTs, + created_at: newestRaw, documents, revocations }; diff --git a/javascript/src/index.js b/javascript/src/index.js index 954badd..bd6ed80 100644 --- a/javascript/src/index.js +++ b/javascript/src/index.js @@ -45,6 +45,9 @@ export { BUNDLE_AUTHORITY_PIN_DOMAIN, signTrustBundle, verifyTrustBundle, + seedBundleAuthority, + seedBundleAuthorityFromPem, + findDuplicateDomain, mergeTrustBundles, buildTrustBundleRequest, buildTrustBundleResponse, diff --git a/javascript/src/resolver.js b/javascript/src/resolver.js index 95a9d4d..f67dbdc 100644 --- a/javascript/src/resolver.js +++ b/javascript/src/resolver.js @@ -7,7 +7,7 @@ import { join, resolve, sep } from 'node:path'; import { PublicKeyDiscovery } from './discovery.js'; import { fetchRevocationDocument, RevocationFetchError } from './revocation.js'; -import { findDiscovery, findRevocation, parseTrustBundle } from './bundle.js'; +import { findDiscovery, findDuplicateDomain, findRevocation, parseTrustBundle } from './bundle.js'; import { validateDomain } from './domain.js'; /** @@ -123,10 +123,17 @@ export class LocalFileResolver extends SchemaResolver { /** * Resolves discovery from an in-memory trust bundle. + * + * Only load bundles you have verified with `verifyTrustBundle`. Bundles with + * duplicate domain entries are rejected so lookups are unambiguous. */ export class TrustBundleResolver extends SchemaResolver { constructor(bundle) { super(); + const dup = findDuplicateDomain(bundle); + if (dup !== null) { + throw new Error(`trust bundle has duplicate entries for domain ${JSON.stringify(dup)}`); + } this._bundle = bundle; } diff --git a/javascript/tests/bundle-distribution.test.js b/javascript/tests/bundle-distribution.test.js index 4372cf3..649cb4e 100644 --- a/javascript/tests/bundle-distribution.test.js +++ b/javascript/tests/bundle-distribution.test.js @@ -13,6 +13,9 @@ import { dirname, resolve } from 'path'; import { signTrustBundle, verifyTrustBundle, + seedBundleAuthority, + seedBundleAuthorityFromPem, + BUNDLE_AUTHORITY_PIN_DOMAIN, mergeTrustBundles, buildTrustBundleRequest, buildTrustBundleResponse, @@ -21,6 +24,7 @@ import { createBundledDiscovery } from '../src/bundle.js'; import { ErrorCode, KeyPinStore } from '../src/verification.js'; +import { TrustBundleResolver } from '../src/resolver.js'; import { KeyManager } from '../src/crypto.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -54,7 +58,7 @@ describe('TrustBundle distribution (v1.4)', () => { assert.equal(signed.expires_at, undefined); const store = new KeyPinStore(); - assert.equal(verifyTrustBundle(signed, store), true); + assert.equal(verifyTrustBundle(signed, store, true).first_use, true); }); it('tampered bundle fails with SIGNATURE_INVALID', () => { @@ -67,7 +71,7 @@ describe('TrustBundle distribution (v1.4)', () => { const store = new KeyPinStore(); assert.throws( - () => verifyTrustBundle(signed, store), + () => verifyTrustBundle(signed, store, true), (e) => e.code === ErrorCode.SIGNATURE_INVALID ); }); @@ -76,7 +80,7 @@ describe('TrustBundle distribution (v1.4)', () => { const bundle = makeBundle('example.com', '2026-05-15T00:00:00Z'); const store = new KeyPinStore(); assert.throws( - () => verifyTrustBundle(bundle, store), + () => verifyTrustBundle(bundle, store, true), (e) => e.code === ErrorCode.BUNDLE_UNSIGNED ); }); @@ -89,7 +93,7 @@ describe('TrustBundle distribution (v1.4)', () => { ); const store = new KeyPinStore(); assert.throws( - () => verifyTrustBundle(signed, store), + () => verifyTrustBundle(signed, store, true), (e) => e.code === ErrorCode.BUNDLE_EXPIRED ); }); @@ -108,9 +112,9 @@ describe('TrustBundle distribution (v1.4)', () => { ); const store = new KeyPinStore(); - assert.equal(verifyTrustBundle(signed1, store), true); // pins kp1 + assert.equal(verifyTrustBundle(signed1, store, true).first_use, true); // pins kp1 assert.throws( - () => verifyTrustBundle(signed2, store), + () => verifyTrustBundle(signed2, store, true), (e) => e.code === ErrorCode.KEY_PIN_MISMATCH ); }); @@ -162,7 +166,7 @@ describe('TrustBundle distribution (v1.4)', () => { assert.deepEqual(parsed, signed); const store = new KeyPinStore(); - assert.equal(verifyTrustBundle(parsed, store), true); + assert.equal(verifyTrustBundle(parsed, store, true).first_use, true); }); it('parseTrustBundleResponse rejects missing result.bundle', () => { @@ -175,7 +179,105 @@ describe('TrustBundle distribution (v1.4)', () => { it('cross-language fixture verifies (interop proof)', () => { const raw = readFileSync(FIXTURE_PATH, 'utf8'); const bundle = JSON.parse(raw); + assert.equal(bundle.bundle_authority.kid, 'schemapin-test-fixture-authority'); + // Unknown authority: refused unless seeded. + assert.throws( + () => verifyTrustBundle(bundle, new KeyPinStore()), + (e) => e.code === ErrorCode.BUNDLE_AUTHORITY_UNTRUSTED + ); + const store = new KeyPinStore(); + const pub = readFileSync(resolve(REPO_ROOT, 'tests', 'cross-language', 'keys', 'test-only-bundle-authority.pub.pem'), 'utf8'); + seedBundleAuthorityFromPem(store, bundle.bundle_authority.kid, pub); + assert.equal(verifyTrustBundle(bundle, store).first_use, false); + // A different seeded key is an impersonation. + const other = new KeyPinStore(); + seedBundleAuthority(other, bundle.bundle_authority.kid, 'sha256:' + '00'.repeat(32)); + assert.throws(() => verifyTrustBundle(bundle, other), (e) => e.code === ErrorCode.KEY_PIN_MISMATCH); + }); +}); + +describe('authority allow-list (SP-6)', () => { + it('refuses a self-signed bundle under a fresh kid', () => { + const { privateKey } = KeyManager.generateKeypair(); + const signed = signTrustBundle(makeBundle('victim.com', '2026-05-15T00:00:00Z'), privateKey, 'victim-authority-2026', '2026-05-15T00:00:00Z'); + assert.throws(() => verifyTrustBundle(signed, new KeyPinStore()), (e) => e.code === ErrorCode.BUNDLE_AUTHORITY_UNTRUSTED); + }); + + it('seeded authority passes strictly; first use is reported and pinned', () => { + const { privateKey } = KeyManager.generateKeypair(); + const signed = signTrustBundle(makeBundle('example.com', '2026-05-15T00:00:00Z'), privateKey, 'auth', '2026-05-15T00:00:00Z'); + const store = new KeyPinStore(); + const fp = seedBundleAuthorityFromPem(store, 'auth', signed.bundle_authority.public_key_pem); + const r = verifyTrustBundle(signed, store); + assert.equal(r.first_use, false); + assert.equal(r.fingerprint, fp); + const fresh = new KeyPinStore(); + assert.equal(verifyTrustBundle(signed, fresh, true).first_use, true); + assert.equal(verifyTrustBundle(signed, fresh).first_use, false); + }); + + it('a failed signature never pins the authority', () => { + const { privateKey } = KeyManager.generateKeypair(); + const signed = signTrustBundle(makeBundle('example.com', '2026-05-15T00:00:00Z'), privateKey, 'auth', '2026-05-15T00:00:00Z'); + signed.documents[0].domain = 'evil.com'; const store = new KeyPinStore(); - assert.equal(verifyTrustBundle(bundle, store), true); + assert.throws(() => verifyTrustBundle(signed, store, true), (e) => e.code === ErrorCode.SIGNATURE_INVALID); + assert.equal(store.getPinned('auth', BUNDLE_AUTHORITY_PIN_DOMAIN), null); + }); +}); + +describe('parity (SP-19)', () => { + it('rejects lenient date strings for expires_at', () => { + const { privateKey } = KeyManager.generateKeypair(); + for (const bad of ['2099-01-01', '2099-01-01T00:00:00', '2099-01-01 00:00:00Z']) { + const signed = signTrustBundle(makeBundle('example.com', '2026-05-15T00:00:00Z'), privateKey, 'auth', '2026-05-15T00:00:00Z', bad); + assert.throws(() => verifyTrustBundle(signed, new KeyPinStore(), true), (e) => e.code === ErrorCode.BUNDLE_EXPIRED, bad); + } + }); + + it('unknown fields are not part of the signing input', () => { + const { privateKey } = KeyManager.generateKeypair(); + const bundle = makeBundle('example.com', '2026-05-15T00:00:00Z'); + bundle['x-extra'] = 'ignored'; + bundle.documents[0]['x-extra'] = 'ignored'; + const signed = signTrustBundle(bundle, privateKey, 'auth', '2026-05-15T00:00:00Z'); + delete signed['x-extra']; + delete signed.documents[0]['x-extra']; + assert.equal(verifyTrustBundle(signed, new KeyPinStore(), true).kid, 'auth'); + const b2 = makeBundle('example.com', '2026-05-15T00:00:00Z'); + delete b2.documents[0].revoked_keys; + const s2 = signTrustBundle(b2, privateKey, 'auth', '2026-05-15T00:00:00Z'); + s2.documents[0].revoked_keys = []; + assert.equal(verifyTrustBundle(s2, new KeyPinStore(), true).kid, 'auth'); + }); + + it('rejects duplicate domains', () => { + const { privateKey } = KeyManager.generateKeypair(); + const bundle = makeBundle('example.com', '2026-05-15T00:00:00Z'); + bundle.documents.push({ ...bundle.documents[0] }); + assert.throws(() => signTrustBundle(bundle, privateKey, 'auth', '2026-05-15T00:00:00Z'), /duplicate/); + assert.throws(() => new TrustBundleResolver(bundle), /duplicate/); + }); +}); + +describe('merge precedence (design 4)', () => { + function named(name, createdAt) { + const b = makeBundle('example.com', createdAt); + b.documents[0].developer_name = name; + return b; + } + + it('authority precedence beats timestamp; parsed timestamps break ties', () => { + const trusted = KeyManager.generateKeypair().privateKey; + const rogue = KeyManager.generateKeypair().privateKey; + const good = signTrustBundle(named('Good', '2026-01-01T00:00:00Z'), trusted, 'trusted', '2026-01-01T00:00:00Z'); + const bad = signTrustBundle(named('Rogue', '2099-01-01T00:00:00Z'), rogue, 'rogue', '2099-01-01T00:00:00Z'); + assert.equal(mergeTrustBundles([bad, good], ['trusted']).documents[0].developer_name, 'Good'); + assert.equal(mergeTrustBundles([good, bad]).documents[0].developer_name, 'Rogue'); + const real = named('Real', '2026-01-01T00:00:00Z'); + const bogus = named('Bogus', '9999-99-99 bogus'); + assert.equal(mergeTrustBundles([real, bogus]).documents[0].developer_name, 'Real'); + const offset = named('Offset', '2026-02-01T00:00:00+00:00'); + assert.equal(mergeTrustBundles([real, offset]).documents[0].developer_name, 'Offset'); }); }); diff --git a/python/examples/demo_private_key.pem b/python/examples/demo_private_key.pem deleted file mode 100644 index 0dfcee5..0000000 --- a/python/examples/demo_private_key.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgmkA3BzLWLcCpafwl -IoJUbtG94z1MZP2pSntY1xLQmPuhRANCAAT0hKKw49gRFcDRgiF8ij/yatddDpUQ -iK2gjPMzfRREA2vGp4oGUeyg8wRZvWLIaFUtJuWJAcbKL4aoZR7R4Ni/ ------END PRIVATE KEY----- diff --git a/python/examples/tool_developer.py b/python/examples/tool_developer.py index 936a7ea..a2cb9d0 100644 --- a/python/examples/tool_developer.py +++ b/python/examples/tool_developer.py @@ -2,6 +2,7 @@ """Example tool developer workflow for signing schemas.""" import json +import os import sys from pathlib import Path @@ -72,8 +73,9 @@ def main(): # Step 5: Save files for demonstration print("\n5. Saving demonstration files...") - # Save private key (in real use, keep this secure!) - with open("demo_private_key.pem", "w") as f: + # Save private key with owner-only permissions (in real use, keep this secure!) + fd = os.open("demo_private_key.pem", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: f.write(private_key_pem) # Save schema with signature diff --git a/python/schemapin/__init__.py b/python/schemapin/__init__.py index 0c9b5e6..be1c76b 100644 --- a/python/schemapin/__init__.py +++ b/python/schemapin/__init__.py @@ -20,11 +20,14 @@ from .bundle_distribution import ( BUNDLE_AUTHORITY_PIN_DOMAIN, BUNDLE_VERSION_SIGNED, + BundleVerification, BundleVerificationError, build_trust_bundle_request, build_trust_bundle_response, merge_trust_bundles, parse_trust_bundle_response, + seed_bundle_authority, + seed_bundle_authority_from_pem, sign_trust_bundle, verify_trust_bundle, ) @@ -180,6 +183,9 @@ "BUNDLE_AUTHORITY_PIN_DOMAIN", "sign_trust_bundle", "verify_trust_bundle", + "BundleVerification", + "seed_bundle_authority", + "seed_bundle_authority_from_pem", "merge_trust_bundles", "build_trust_bundle_request", "build_trust_bundle_response", diff --git a/python/schemapin/bundle_distribution.py b/python/schemapin/bundle_distribution.py index a759a13..980ffb7 100644 --- a/python/schemapin/bundle_distribution.py +++ b/python/schemapin/bundle_distribution.py @@ -4,25 +4,32 @@ so it can be exchanged between agents over A2A without per-bundle out-of-band trust establishment. Provides: -- :func:`sign_trust_bundle` / :func:`verify_trust_bundle` — ECDSA P-256 over - the canonical bundle bytes, with TOFU pinning of the authority key by ``kid``. -- :func:`merge_trust_bundles` — combine bundles from multiple sources, newest - entry wins per domain. +- :func:`sign_trust_bundle` / :func:`verify_trust_bundle` -- ECDSA P-256 over + the canonical bundle bytes, with an **authority allow-list**: the authority + key must be pre-seeded in the authority store (see + :func:`seed_bundle_authority`) unless the caller explicitly opts in to + first-use trust. +- :func:`merge_trust_bundles` -- combine bundles from multiple sources with + per-authority precedence and parsed-timestamp tie-breaking. - :func:`build_trust_bundle_request` / :func:`build_trust_bundle_response` / - :func:`parse_trust_bundle_response` — the ``schemapin/trustBundle`` JSON-RPC + :func:`parse_trust_bundle_response` -- the ``schemapin/trustBundle`` JSON-RPC envelope for A2A bundle exchange. Signing input ------------- -The signature covers the ``schemapin-v1`` canonicalization (recursive sorted -keys, compact, UTF-8) of the entire bundle object with the ``signature`` field -set to the empty string ``""``. All four SDKs build the identical byte string, -so a bundle signed by any SDK verifies in every other. +The signature covers the ``schemapin-v1`` canonicalization of the bundle's +**known fields** (unknown fields are ignored and therefore unprotected) with +``signature`` set to ``""``. The field set mirrors the Rust reference +serialization exactly: optional strings are omitted when absent, and +``revoked_keys`` / ``revocations`` are always present (possibly empty). All +four SDKs build the identical byte string, so a bundle signed by any SDK +verifies in every other. """ +from dataclasses import dataclass from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence, Tuple from .bundle import BundleAuthority, SchemaPinTrustBundle from .core import SchemaPinCore @@ -36,13 +43,15 @@ #: Authorities are pinned by ``kid``, independent of any tool domain. BUNDLE_AUTHORITY_PIN_DOMAIN = "_bundle_authority" +_DOCUMENT_OPTIONAL_FIELDS = ("developer_name", "contact", "revocation_endpoint") + class BundleVerificationError(Exception): """Raised when a trust bundle fails verification. Carries a structured :class:`~schemapin.verification.ErrorCode` (e.g. - ``BUNDLE_UNSIGNED``, ``BUNDLE_EXPIRED``, ``KEY_PIN_MISMATCH``, - ``SIGNATURE_INVALID``) alongside the human-readable message. + ``BUNDLE_UNSIGNED``, ``BUNDLE_EXPIRED``, ``BUNDLE_AUTHORITY_UNTRUSTED``, + ``KEY_PIN_MISMATCH``, ``SIGNATURE_INVALID``) alongside the message. """ def __init__(self, code: ErrorCode, message: str) -> None: @@ -51,14 +60,92 @@ def __init__(self, code: ErrorCode, message: str) -> None: self.message = message -def _signing_bytes(bundle: SchemaPinTrustBundle) -> bytes: - """Build the canonical bytes a bundle's signature covers: the bundle with - its ``signature`` field forced to ``""``, ``schemapin-v1``-canonicalized. +@dataclass +class BundleVerification: + """Successful :func:`verify_trust_bundle` outcome. + + ``first_use`` is ``True`` when the authority key was not pre-seeded and + the caller allowed first-use trust; hosts should surface this to the user + exactly like a first-use tool key. """ - obj = bundle.to_dict() + + kid: str + fingerprint: str + first_use: bool + + +def _normalize_document(doc: Dict[str, Any]) -> Dict[str, Any]: + """Project a bundled discovery entry onto the signed field set.""" + out: Dict[str, Any] = { + "domain": doc.get("domain", ""), + "schema_version": doc.get("schema_version", ""), + "public_key_pem": doc.get("public_key_pem", ""), + "revoked_keys": list(doc.get("revoked_keys") or []), + } + for key in _DOCUMENT_OPTIONAL_FIELDS: + value = doc.get(key) + if value is not None: + out[key] = value + return out + + +def _signing_value(bundle: SchemaPinTrustBundle) -> Dict[str, Any]: + obj: Dict[str, Any] = { + "schemapin_bundle_version": bundle.schemapin_bundle_version, + "created_at": bundle.created_at, + "documents": [_normalize_document(d) for d in bundle.documents], + "revocations": [r.to_dict() for r in bundle.revocations], + } + if bundle.bundle_authority is not None: + obj["bundle_authority"] = bundle.bundle_authority.to_dict() + if bundle.signed_at is not None: + obj["signed_at"] = bundle.signed_at + if bundle.expires_at is not None: + obj["expires_at"] = bundle.expires_at obj["signature"] = "" - canonical = SchemaPinCore.canonicalize_schema(obj) - return canonical.encode("utf-8") + return obj + + +def _signing_bytes(bundle: SchemaPinTrustBundle) -> bytes: + """Build the canonical bytes a bundle's signature covers.""" + return SchemaPinCore.canonicalize_schema(_signing_value(bundle)).encode("utf-8") + + +def _duplicate_domain(bundle: SchemaPinTrustBundle) -> Optional[str]: + seen: set = set() + for d in bundle.documents: + domain = d.get("domain") + if domain in seen: + return str(domain) + seen.add(domain) + seen = set() + for r in bundle.revocations: + if r.domain in seen: + return r.domain + seen.add(r.domain) + return None + + +def seed_bundle_authority( + store: KeyPinStore, kid: str, fingerprint: str +) -> None: + """Pre-seed the expected fingerprint (``sha256:``) of a bundle authority. + + This is the allow-list entry :func:`verify_trust_bundle` requires. Obtain + the fingerprint out of band -- for example from the authority's own + ``.well-known/schemapin.json`` via :func:`seed_bundle_authority_from_pem` + with the discovery document's ``public_key_pem``. + """ + store.seed(kid, BUNDLE_AUTHORITY_PIN_DOMAIN, fingerprint) + + +def seed_bundle_authority_from_pem( + store: KeyPinStore, kid: str, public_key_pem: str +) -> str: + """Seed an authority from its PEM public key; returns the fingerprint.""" + fingerprint = KeyManager.calculate_key_fingerprint_from_pem(public_key_pem) + seed_bundle_authority(store, kid, fingerprint) + return fingerprint def sign_trust_bundle( @@ -75,7 +162,14 @@ def sign_trust_bundle( ``expires_at``, then writes the base64 DER ECDSA P-256 ``signature``. ``signed_at`` / ``expires_at`` are caller-supplied RFC 3339 strings (kept out of the core so signing is deterministic and cross-language testable). + + Raises: + ValueError: if the bundle contains duplicate domains. """ + dup = _duplicate_domain(bundle) + if dup is not None: + raise ValueError(f"trust bundle has duplicate entries for domain {dup!r}") + private_key = KeyManager.load_private_key_pem(private_key_pem) public_key_pem = KeyManager.export_public_key_pem(private_key.public_key()) @@ -98,17 +192,31 @@ def sign_trust_bundle( def verify_trust_bundle( bundle: SchemaPinTrustBundle, authority_pin_store: KeyPinStore, -) -> None: - """Verify a signed trust bundle and TOFU-pin its authority key by ``kid``. - - Steps: require ``bundle_authority`` + ``signature`` (else - ``BUNDLE_UNSIGNED``); reject when ``expires_at`` is in the past or - unparseable (``BUNDLE_EXPIRED``); TOFU-pin the authority's key fingerprint - by ``kid`` (mismatch → ``KEY_PIN_MISMATCH``); verify the signature over the - canonical bytes (failure → ``SIGNATURE_INVALID``). - - Returns ``None`` on success; raises :class:`BundleVerificationError` - otherwise. + allow_first_use: bool = False, +) -> BundleVerification: + """Verify a signed trust bundle against the authority allow-list. + + Steps: + + 1. require ``bundle_authority`` + ``signature`` (else ``BUNDLE_UNSIGNED``); + 2. reject duplicate domains (``DISCOVERY_INVALID``); + 3. reject when ``expires_at`` is in the past or unparseable + (``BUNDLE_EXPIRED``); + 4. look the authority's key fingerprint up by ``kid`` in + ``authority_pin_store``: a different pinned key is + ``KEY_PIN_MISMATCH``; an unknown ``kid`` is + ``BUNDLE_AUTHORITY_UNTRUSTED`` unless ``allow_first_use=True``; + 5. verify the signature over the canonical bytes + (``SIGNATURE_INVALID``); + 6. pin the authority on first use (only after the signature verified). + + The bundle carries its own public key, so without an allow-list any + self-signed bundle under a fresh ``kid`` would pass. Seed expected + authorities with :func:`seed_bundle_authority`; treat ``allow_first_use`` + exactly like TOFU for a tool key (prompt the user, log it). + + Returns a :class:`BundleVerification`; raises + :class:`BundleVerificationError` otherwise. """ authority = bundle.bundle_authority if authority is None: @@ -121,6 +229,13 @@ def verify_trust_bundle( ErrorCode.BUNDLE_UNSIGNED, "trust bundle has no signature" ) + dup = _duplicate_domain(bundle) + if dup is not None: + raise BundleVerificationError( + ErrorCode.DISCOVERY_INVALID, + f"trust bundle has duplicate entries for domain {dup!r}", + ) + if bundle.expires_at is not None: exp = _parse_rfc3339(bundle.expires_at) if exp is None: @@ -134,17 +249,29 @@ def verify_trust_bundle( f"trust bundle expired at {bundle.expires_at}", ) - fingerprint = KeyManager.calculate_key_fingerprint_from_pem( - authority.public_key_pem - ) - pin_result = authority_pin_store.check_and_pin( + try: + fingerprint = KeyManager.calculate_key_fingerprint_from_pem( + authority.public_key_pem + ) + except Exception as e: + raise BundleVerificationError( + ErrorCode.KEY_NOT_FOUND, f"bundle authority key is invalid: {e}" + ) from e + pin_result = authority_pin_store.check( authority.kid, BUNDLE_AUTHORITY_PIN_DOMAIN, fingerprint ) if pin_result == "changed": raise BundleVerificationError( ErrorCode.KEY_PIN_MISMATCH, - f"bundle authority key for kid '{authority.kid}' changed since " - f"first use", + f"bundle authority key for kid '{authority.kid}' does not match the " + f"seeded authority key", + ) + if pin_result == "first_use" and not allow_first_use: + raise BundleVerificationError( + ErrorCode.BUNDLE_AUTHORITY_UNTRUSTED, + f"bundle authority kid '{authority.kid}' is not in the authority " + f"allow-list; seed it with seed_bundle_authority() or pass " + f"allow_first_use=True", ) canonical = _signing_bytes(bundle) @@ -155,41 +282,75 @@ def verify_trust_bundle( "trust bundle signature does not verify", ) + if pin_result == "first_use": + authority_pin_store.pin(authority.kid, BUNDLE_AUTHORITY_PIN_DOMAIN, fingerprint) + return BundleVerification( + kid=authority.kid, fingerprint=fingerprint, first_use=pin_result == "first_use" + ) + def _bundle_timestamp(bundle: SchemaPinTrustBundle) -> str: """Sort timestamp for a bundle: ``signed_at`` if present, else ``created_at``.""" return bundle.signed_at if bundle.signed_at is not None else bundle.created_at +def _merge_rank( + bundle: SchemaPinTrustBundle, precedence: Optional[Sequence[str]] +) -> Tuple[int, datetime]: + """Higher tuple wins: (authority precedence, parsed timestamp).""" + kid = bundle.bundle_authority.kid if bundle.bundle_authority else None + rank = 0 + if precedence: + rank = -len(precedence) - 1 + if kid is not None and kid in precedence: + rank = -list(precedence).index(kid) + ts = _parse_rfc3339(_bundle_timestamp(bundle)) or datetime.min.replace( + tzinfo=timezone.utc + ) + return rank, ts + + def merge_trust_bundles( bundles: List[SchemaPinTrustBundle], + authority_precedence: Optional[Sequence[str]] = None, ) -> SchemaPinTrustBundle: """Merge trust bundles, deduplicating discovery + revocation documents by - domain. When two bundles carry the same domain, the entry from the bundle - with the newer timestamp (``signed_at``, else ``created_at``) wins. - - The result is an *unsigned* bundle (a merge cannot carry a single - authority's signature) stamped ``schemapin_bundle_version = "1.4"`` with - ``created_at`` set to the newest source timestamp. Re-sign it with - :func:`sign_trust_bundle` before redistribution. + domain. + + When two bundles carry the same domain the winner is chosen by + ``authority_precedence`` first (a list of authority ``kid`` values, + earlier wins; bundles from unlisted or absent authorities rank last) and + by the newer **parsed** RFC 3339 timestamp (``signed_at``, else + ``created_at``) second. Unparseable timestamps rank oldest, so a bundle + cannot win a merge by claiming a bogus future date. + + Merge only bundles you have already verified with + :func:`verify_trust_bundle`. + + The result is an *unsigned* bundle stamped + ``schemapin_bundle_version = "1.4"`` with ``created_at`` set to the + newest source timestamp. Re-sign it with :func:`sign_trust_bundle` + before redistribution. """ - docs: Dict[str, Any] = {} # domain -> (timestamp, document) - revs: Dict[str, Any] = {} # domain -> (timestamp, RevocationDocument) - newest_ts = "" + docs: Dict[str, Any] = {} # domain -> (rank, document) + revs: Dict[str, Any] = {} # domain -> (rank, RevocationDocument) + newest_ts: Optional[datetime] = None + newest_raw = "" for b in bundles: - ts = _bundle_timestamp(b) - if ts > newest_ts: - newest_ts = ts + rank = _merge_rank(b, authority_precedence) + if newest_ts is None or rank[1] > newest_ts: + newest_ts = rank[1] + newest_raw = _bundle_timestamp(b) for d in b.documents: domain = d.get("domain") existing = docs.get(domain) - if existing is None or existing[0] < ts: - docs[domain] = (ts, d) + if existing is None or existing[0] < rank: + docs[domain] = (rank, d) for r in b.revocations: existing = revs.get(r.domain) - if existing is None or existing[0] < ts: - revs[r.domain] = (ts, r) + if existing is None or existing[0] < rank: + revs[r.domain] = (rank, r) documents = [v[1] for v in docs.values()] documents.sort(key=lambda d: d.get("domain", "")) @@ -198,7 +359,7 @@ def merge_trust_bundles( return SchemaPinTrustBundle( schemapin_bundle_version=BUNDLE_VERSION_SIGNED, - created_at=newest_ts, + created_at=newest_raw, documents=documents, revocations=revocations, ) diff --git a/python/schemapin/crypto.py b/python/schemapin/crypto.py index 6be14c9..7adb45c 100644 --- a/python/schemapin/crypto.py +++ b/python/schemapin/crypto.py @@ -11,7 +11,6 @@ EllipticCurvePublicKey, ) - #: The only curve SchemaPin accepts (spec section 10: "not valid ECDSA P-256 #: keys MUST be rejected"). REQUIRED_CURVE = "secp256r1" diff --git a/python/schemapin/resolver.py b/python/schemapin/resolver.py index eb835ce..04923ba 100644 --- a/python/schemapin/resolver.py +++ b/python/schemapin/resolver.py @@ -125,9 +125,26 @@ def resolve_revocation( class TrustBundleResolver(SchemaResolver): - """Resolves discovery from an in-memory trust bundle.""" + """Resolves discovery from an in-memory trust bundle. + + Only load bundles you have verified with + :func:`schemapin.bundle_distribution.verify_trust_bundle`. Bundles with + duplicate domain entries are rejected (``ValueError``) so lookups are + unambiguous. + """ def __init__(self, bundle: SchemaPinTrustBundle): + seen: set = set() + for doc in bundle.documents: + domain = doc.get("domain") + if domain in seen: + raise ValueError(f"trust bundle has duplicate entries for domain {domain!r}") + seen.add(domain) + seen = set() + for rev in bundle.revocations: + if rev.domain in seen: + raise ValueError(f"trust bundle has duplicate entries for domain {rev.domain!r}") + seen.add(rev.domain) self._bundle = bundle @classmethod diff --git a/python/tests/test_a2a.py b/python/tests/test_a2a.py index 36fe061..e54dcf0 100644 --- a/python/tests/test_a2a.py +++ b/python/tests/test_a2a.py @@ -21,7 +21,6 @@ verify_schema_offline, ) - # ────────────────────────────────────────────────────────────────────── # AllowedDomains helpers (mirrors AgentPin v0.3 §4.11 semantics) # ────────────────────────────────────────────────────────────────────── diff --git a/python/tests/test_bundle_distribution.py b/python/tests/test_bundle_distribution.py index c01f9d8..da366fd 100644 --- a/python/tests/test_bundle_distribution.py +++ b/python/tests/test_bundle_distribution.py @@ -58,8 +58,16 @@ def test_sign_verify_roundtrip(self): assert signed.bundle_authority.kid == "auth-2026-05" store = KeyPinStore() - # Returns None on success, does not raise. - assert verify_trust_bundle(signed, store) is None + # The authority is unknown: strict verification refuses it, first-use + # trust must be requested explicitly and is reported on the result. + with pytest.raises(BundleVerificationError) as ei: + verify_trust_bundle(signed, store) + assert ei.value.code == ErrorCode.BUNDLE_AUTHORITY_UNTRUSTED + result = verify_trust_bundle(signed, store, allow_first_use=True) + assert result.first_use is True + assert result.kid == "auth-2026-05" + # Now pinned: strict verification passes and reports first_use False. + assert verify_trust_bundle(signed, store).first_use is False def test_tampered_bundle_fails(self): priv = _keypair_pem() @@ -71,14 +79,16 @@ def test_tampered_bundle_fails(self): store = KeyPinStore() with pytest.raises(BundleVerificationError) as ei: - verify_trust_bundle(signed, store) + verify_trust_bundle(signed, store, allow_first_use=True) assert ei.value.code == ErrorCode.SIGNATURE_INVALID + # A failed signature must not leave the authority pinned. + assert store.get_pinned("auth", "_bundle_authority") is None def test_unsigned_bundle_rejected(self): bundle = _make_bundle() store = KeyPinStore() with pytest.raises(BundleVerificationError) as ei: - verify_trust_bundle(bundle, store) + verify_trust_bundle(bundle, store, allow_first_use=True) assert ei.value.code == ErrorCode.BUNDLE_UNSIGNED def test_expired_bundle_rejected(self): @@ -92,7 +102,7 @@ def test_expired_bundle_rejected(self): ) store = KeyPinStore() with pytest.raises(BundleVerificationError) as ei: - verify_trust_bundle(signed, store) + verify_trust_bundle(signed, store, allow_first_use=True) assert ei.value.code == ErrorCode.BUNDLE_EXPIRED def test_unparseable_expires_at_rejected(self): @@ -103,7 +113,7 @@ def test_unparseable_expires_at_rejected(self): ) store = KeyPinStore() with pytest.raises(BundleVerificationError) as ei: - verify_trust_bundle(signed, store) + verify_trust_bundle(signed, store, allow_first_use=True) assert ei.value.code == ErrorCode.BUNDLE_EXPIRED def test_authority_tofu_mismatch(self): @@ -116,9 +126,9 @@ def test_authority_tofu_mismatch(self): signed2 = sign_trust_bundle(bundle, priv2, "auth", "2026-05-16T00:00:00Z") store = KeyPinStore() - verify_trust_bundle(signed1, store) # pins priv1 + verify_trust_bundle(signed1, store, allow_first_use=True) # pins priv1 with pytest.raises(BundleVerificationError) as ei: - verify_trust_bundle(signed2, store) + verify_trust_bundle(signed2, store, allow_first_use=True) assert ei.value.code == ErrorCode.KEY_PIN_MISMATCH @@ -178,7 +188,7 @@ def test_envelope_roundtrip(self): # The parsed bundle still verifies. store = KeyPinStore() - verify_trust_bundle(parsed, store) + verify_trust_bundle(parsed, store, allow_first_use=True) def test_parse_missing_bundle(self): with pytest.raises(BundleVerificationError) as ei: @@ -187,20 +197,113 @@ def test_parse_missing_bundle(self): class TestCrossLanguage: - def test_rust_signed_bundle_verifies(self): - """Load the shared Rust-signed fixture and verify it. This proves the - Python canonicalization / signing input matches the Rust reference. - """ - # Resolve repo root robustly: this file lives at - # /python/tests/test_bundle_distribution.py + def test_cross_language_signed_bundle_verifies(self): + """The shared fixture (signed under the test-only authority) verifies + once that authority is seeded, and is refused without seeding.""" + import json + + from schemapin.bundle_distribution import seed_bundle_authority_from_pem + here = os.path.dirname(os.path.abspath(__file__)) root = os.path.abspath(os.path.join(here, "..", "..")) fixture = os.path.join(root, "tests", "cross-language", "signed_bundle.json") - assert os.path.exists(fixture), f"fixture not found: {fixture}" - + pub = os.path.join(root, "tests", "cross-language", "keys", "test-only-bundle-authority.pub.pem") with open(fixture, encoding="utf-8") as f: - bundle = SchemaPinTrustBundle.from_json(f.read()) + bundle = SchemaPinTrustBundle.from_dict(json.load(f)) + assert bundle.bundle_authority.kid == "schemapin-test-fixture-authority" + + with pytest.raises(BundleVerificationError) as ei: + verify_trust_bundle(bundle, KeyPinStore()) + assert ei.value.code == ErrorCode.BUNDLE_AUTHORITY_UNTRUSTED + + store = KeyPinStore() + with open(pub, encoding="utf-8") as f: + seed_bundle_authority_from_pem(store, bundle.bundle_authority.kid, f.read()) + result = verify_trust_bundle(bundle, store) + assert result.first_use is False + + # A different key seeded under the fixture kid is an impersonation. + other = KeyPinStore() + other.seed(bundle.bundle_authority.kid, "_bundle_authority", "sha256:" + "00" * 32) + with pytest.raises(BundleVerificationError) as ei: + verify_trust_bundle(bundle, other) + assert ei.value.code == ErrorCode.KEY_PIN_MISMATCH + +class TestAuthorityAllowList: + """SP-6: the bundle authority must be allow-listed, never TOFU'd silently.""" + + def test_self_signed_bundle_with_fresh_kid_is_refused(self): + attacker = _keypair_pem() + signed = sign_trust_bundle(_make_bundle("victim.com"), attacker, "victim-authority-2026", "2026-05-15T00:00:00Z") + with pytest.raises(BundleVerificationError) as ei: + verify_trust_bundle(signed, KeyPinStore()) + assert ei.value.code == ErrorCode.BUNDLE_AUTHORITY_UNTRUSTED + + def test_seeded_authority_passes_without_first_use(self): + from schemapin.bundle_distribution import seed_bundle_authority + + priv = _keypair_pem() + signed = sign_trust_bundle(_make_bundle(), priv, "auth", "2026-05-15T00:00:00Z") + fp = KeyManager.calculate_key_fingerprint_from_pem(signed.bundle_authority.public_key_pem) store = KeyPinStore() - # Must not raise — interop proof. - assert verify_trust_bundle(bundle, store) is None + seed_bundle_authority(store, "auth", fp) + result = verify_trust_bundle(signed, store) + assert result.first_use is False and result.fingerprint == fp + + +class TestSigningInputParity: + """SP-19: unknown fields are not part of the signing input in any SDK.""" + + def test_unknown_fields_do_not_affect_signature(self): + priv = _keypair_pem() + bundle = _make_bundle() + bundle.documents[0]["x-extra"] = "ignored" + signed = sign_trust_bundle(bundle, priv, "auth", "2026-05-15T00:00:00Z") + # Re-parse with the unknown field stripped (what Go/Rust do on load). + stripped = SchemaPinTrustBundle.from_dict(signed.to_dict()) + del stripped.documents[0]["x-extra"] + store = KeyPinStore() + assert verify_trust_bundle(stripped, store, allow_first_use=True).kid == "auth" + # And a document without revoked_keys signs identically to one with []. + b2 = _make_bundle() + del b2.documents[0]["revoked_keys"] + s2 = sign_trust_bundle(b2, priv, "auth", "2026-05-15T00:00:00Z") + s2.documents[0]["revoked_keys"] = [] + assert verify_trust_bundle(s2, store).kid == "auth" + + def test_duplicate_domains_rejected(self): + from schemapin.resolver import TrustBundleResolver + + priv = _keypair_pem() + bundle = _make_bundle() + bundle.documents.append(dict(bundle.documents[0])) + with pytest.raises(ValueError): + sign_trust_bundle(bundle, priv, "auth", "2026-05-15T00:00:00Z") + with pytest.raises(ValueError): + TrustBundleResolver(bundle) + + +class TestMergePrecedence: + """Design issue 4: merges honor authority precedence and parsed timestamps.""" + + def test_authority_precedence_beats_timestamp(self): + trusted = _keypair_pem() + rogue = _keypair_pem() + good = sign_trust_bundle(_make_bundle(developer_name="Good"), trusted, "trusted", "2026-01-01T00:00:00Z") + bad = sign_trust_bundle(_make_bundle(developer_name="Rogue"), rogue, "rogue", "2099-01-01T00:00:00Z") + merged = merge_trust_bundles([bad, good], authority_precedence=["trusted"]) + assert merged.documents[0]["developer_name"] == "Good" + # Without precedence the newer parsed timestamp wins. + merged2 = merge_trust_bundles([good, bad]) + assert merged2.documents[0]["developer_name"] == "Rogue" + + def test_unparseable_timestamp_cannot_win(self): + a = _make_bundle(created_at="2026-01-01T00:00:00Z", developer_name="Real") + b = _make_bundle(created_at="9999-99-99 bogus", developer_name="Bogus") + merged = merge_trust_bundles([a, b]) + assert merged.documents[0]["developer_name"] == "Real" + # Lexicographic comparison would have picked "9999-..." over "2026-...". + c = _make_bundle(created_at="2026-02-01T00:00:00+00:00", developer_name="Offset") + merged = merge_trust_bundles([a, c]) + assert merged.documents[0]["developer_name"] == "Offset" diff --git a/python/tools/keygen.py b/python/tools/keygen.py index ff3d75c..ce26921 100644 --- a/python/tools/keygen.py +++ b/python/tools/keygen.py @@ -2,6 +2,7 @@ """SchemaPin key generation tool.""" import argparse +import os import json import sys from pathlib import Path @@ -69,6 +70,16 @@ def calculate_rsa_fingerprint(public_key): return f"sha256:{fingerprint.finalize().hex()}" +def write_private_file(path: Path, data: bytes) -> None: + """Write ``data`` to ``path`` with owner-only permissions (0600).""" + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, data) + finally: + os.close(fd) + os.chmod(path, 0o600) + + def main(): """Main entry point for keygen tool.""" parser = argparse.ArgumentParser( @@ -191,12 +202,13 @@ def main(): private_key_file = args.output_dir / f"{args.prefix}_private{ext}" public_key_file = args.output_dir / f"{args.prefix}_public{ext}" - # Write key files + # Write key files. The private key is created with mode 0600 so it is + # never world-readable, regardless of the process umask. if args.format == 'pem': - private_key_file.write_text(private_key_data) + write_private_file(private_key_file, private_key_data.encode('utf-8')) public_key_file.write_text(public_key_data) else: # DER - private_key_file.write_bytes(private_key_data) + write_private_file(private_key_file, private_key_data) public_key_file.write_bytes(public_key_data) # Generate .well-known template if requested diff --git a/rust/src/bundle.rs b/rust/src/bundle.rs index fe99751..ade1f82 100644 --- a/rust/src/bundle.rs +++ b/rust/src/bundle.rs @@ -5,27 +5,86 @@ //! establishment. Provides: //! //! - [`sign_trust_bundle`] / [`verify_trust_bundle`] — ECDSA P-256 over the -//! canonical bundle bytes, with TOFU pinning of the authority key by `kid`. -//! - [`merge_trust_bundles`] — combine bundles from multiple sources, newest -//! entry wins per domain. +//! canonical bundle bytes, with an **authority allow-list**: the authority +//! key must be pre-seeded in the authority store ([`seed_bundle_authority`]) +//! unless the caller explicitly opts in to first-use trust. +//! - [`merge_trust_bundles`] — combine bundles from multiple sources with +//! per-authority precedence and parsed-timestamp tie-breaking. //! - [`build_trust_bundle_request`] / [`build_trust_bundle_response`] / //! [`parse_trust_bundle_response`] — the `schemapin/trustBundle` JSON-RPC //! envelope for A2A bundle exchange. //! //! ## Signing input //! -//! The signature covers the `schemapin-v1` canonicalization (recursive sorted -//! keys, compact, UTF-8) of the entire bundle object with the `signature` field -//! set to the empty string `""`. All four SDKs build the identical byte string, -//! so a bundle signed by any SDK verifies in every other. +//! The signature covers the `schemapin-v1` canonicalization of the bundle's +//! known fields (the serde representation; unknown fields are dropped on parse +//! and therefore unprotected) with the `signature` field set to `""`. All +//! four SDKs build the identical byte string, so a bundle signed by any SDK +//! verifies in every other. use serde_json::{json, Value}; use crate::canonicalize::canonicalize_schema; use crate::crypto::{calculate_key_id, sign_data, verify_signature, KeyManager}; use crate::error::{Error, ErrorCode}; -use crate::pinning::{check_pinning, KeyPinStore}; +use crate::pinning::{KeyPinStore, PinningResult}; use crate::types::bundle::{BundleAuthority, SchemaPinTrustBundle}; +use crate::verification::parse_rfc3339_strict; + +/// Successful [`verify_trust_bundle_with_options`] outcome. `first_use` is +/// `true` when the authority key was not pre-seeded and the caller allowed +/// first-use trust; hosts should surface this exactly like a first-use tool key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BundleVerification { + pub kid: String, + pub fingerprint: String, + pub first_use: bool, +} + +/// Policy knobs for [`verify_trust_bundle_with_options`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct BundleVerifyOptions { + /// Pin an unknown authority on first use (TOFU) instead of failing with + /// `BUNDLE_AUTHORITY_UNTRUSTED`. + pub allow_first_use: bool, +} + +/// Pre-seed the expected fingerprint (`sha256:`) of a bundle authority. +/// This is the allow-list entry [`verify_trust_bundle`] requires. +pub fn seed_bundle_authority(store: &mut KeyPinStore, kid: &str, fingerprint: &str) { + store.seed(kid, BUNDLE_AUTHORITY_PIN_DOMAIN, fingerprint); +} + +/// Seed an authority from its PEM public key (for example the +/// `public_key_pem` of the authority's own discovery document). Returns the +/// fingerprint. +pub fn seed_bundle_authority_from_pem( + store: &mut KeyPinStore, + kid: &str, + public_key_pem: &str, +) -> Result { + let fingerprint = calculate_key_id(public_key_pem)?; + seed_bundle_authority(store, kid, &fingerprint); + Ok(fingerprint) +} + +/// Return a domain that appears more than once in the bundle's documents or +/// revocations. +pub fn duplicate_domain(bundle: &SchemaPinTrustBundle) -> Option { + let mut seen = std::collections::HashSet::new(); + for d in &bundle.documents { + if !seen.insert(d.domain.as_str()) { + return Some(d.domain.clone()); + } + } + let mut seen = std::collections::HashSet::new(); + for r in &bundle.revocations { + if !seen.insert(r.domain.as_str()) { + return Some(r.domain.clone()); + } + } + None +} /// Bundle-distribution wire format version stamped on signed bundles. pub const BUNDLE_VERSION_SIGNED: &str = "1.4"; @@ -58,6 +117,12 @@ pub fn sign_trust_bundle( signed_at: &str, expires_at: Option<&str>, ) -> Result { + if let Some(dup) = duplicate_domain(bundle) { + return Err(Error::Verification { + code: ErrorCode::DiscoveryInvalid, + message: format!("trust bundle has duplicate entries for domain {:?}", dup), + }); + } let secret = KeyManager::load_private_key_pem(private_key_pem)?; let public_key_pem = KeyManager::export_public_key_pem(&secret.public_key())?; @@ -76,16 +141,38 @@ pub fn sign_trust_bundle( Ok(signed) } -/// Verify a signed trust bundle and TOFU-pin its authority key by `kid`. -/// -/// Steps: require `bundle_authority` + `signature`; reject when `expires_at` -/// is in the past; TOFU-pin the authority's key fingerprint by `kid` (mismatch -/// → `KEY_PIN_MISMATCH`); verify the signature over the canonical bytes -/// (failure → `SIGNATURE_INVALID`). +/// Verify a signed trust bundle against the authority allow-list: the +/// authority `kid` must already be seeded in `authority_pin_store` +/// ([`seed_bundle_authority`]) or verification fails with +/// `BUNDLE_AUTHORITY_UNTRUSTED`. Use [`verify_trust_bundle_with_options`] to +/// allow first-use pinning. pub fn verify_trust_bundle( bundle: &SchemaPinTrustBundle, authority_pin_store: &mut KeyPinStore, ) -> Result<(), Error> { + verify_trust_bundle_with_options(bundle, authority_pin_store, &BundleVerifyOptions::default()) + .map(|_| ()) +} + +/// Verify a signed trust bundle. +/// +/// Steps: require `bundle_authority` + `signature` (`BUNDLE_UNSIGNED`); +/// reject duplicate domains (`DISCOVERY_INVALID`); reject when `expires_at` +/// is in the past or not strict RFC 3339 (`BUNDLE_EXPIRED`); look the +/// authority's key fingerprint up by `kid` — a different pinned key is +/// `KEY_PIN_MISMATCH`, an unknown `kid` is `BUNDLE_AUTHORITY_UNTRUSTED` +/// unless `opts.allow_first_use`; verify the signature over the canonical +/// bytes (`SIGNATURE_INVALID`); pin the authority on first use only after the +/// signature verified. +/// +/// The bundle carries its own public key, so without an allow-list any +/// self-signed bundle under a fresh `kid` would pass. Treat `allow_first_use` +/// exactly like TOFU for a tool key (prompt the user, log it). +pub fn verify_trust_bundle_with_options( + bundle: &SchemaPinTrustBundle, + authority_pin_store: &mut KeyPinStore, + opts: &BundleVerifyOptions, +) -> Result { let authority = bundle .bundle_authority .as_ref() @@ -98,12 +185,18 @@ pub fn verify_trust_bundle( message: "trust bundle has no signature".to_string(), })?; + if let Some(dup) = duplicate_domain(bundle) { + return Err(Error::Verification { + code: ErrorCode::DiscoveryInvalid, + message: format!("trust bundle has duplicate entries for domain {:?}", dup), + }); + } + if let Some(expires_at) = &bundle.expires_at { - let exp = - chrono::DateTime::parse_from_rfc3339(expires_at).map_err(|e| Error::Verification { - code: ErrorCode::BundleExpired, - message: format!("unparseable expires_at '{}': {}", expires_at, e), - })?; + let exp = parse_rfc3339_strict(expires_at).ok_or_else(|| Error::Verification { + code: ErrorCode::BundleExpired, + message: format!("unparseable expires_at '{}'", expires_at), + })?; if chrono::Utc::now() > exp { return Err(Error::Verification { code: ErrorCode::BundleExpired, @@ -112,59 +205,121 @@ pub fn verify_trust_bundle( } } - let fingerprint = calculate_key_id(&authority.public_key_pem)?; - check_pinning( - authority_pin_store, - &authority.kid, - BUNDLE_AUTHORITY_PIN_DOMAIN, - &fingerprint, - )?; + let fingerprint = + calculate_key_id(&authority.public_key_pem).map_err(|e| Error::Verification { + code: ErrorCode::KeyNotFound, + message: format!("bundle authority key is invalid: {}", e), + })?; + let pin_result = + authority_pin_store.check(&authority.kid, BUNDLE_AUTHORITY_PIN_DOMAIN, &fingerprint); + match pin_result { + PinningResult::Changed => { + return Err(Error::Verification { + code: ErrorCode::KeyPinMismatch, + message: format!( + "bundle authority key for kid '{}' does not match the seeded authority key", + authority.kid + ), + }); + } + PinningResult::FirstUse if !opts.allow_first_use => { + return Err(Error::Verification { + code: ErrorCode::BundleAuthorityUntrusted, + message: format!( + "bundle authority kid '{}' is not in the authority allow-list; seed it with seed_bundle_authority or set allow_first_use", + authority.kid + ), + }); + } + _ => {} + } let canonical = signing_bytes(bundle)?; - if verify_signature(&authority.public_key_pem, canonical.as_bytes(), signature)? { - Ok(()) - } else { - Err(Error::Verification { + if !verify_signature(&authority.public_key_pem, canonical.as_bytes(), signature)? { + return Err(Error::Verification { code: ErrorCode::SignatureInvalid, message: "trust bundle signature does not verify".to_string(), - }) + }); } + if pin_result == PinningResult::FirstUse { + authority_pin_store.pin(&authority.kid, BUNDLE_AUTHORITY_PIN_DOMAIN, &fingerprint); + } + Ok(BundleVerification { + kid: authority.kid.clone(), + fingerprint, + first_use: pin_result == PinningResult::FirstUse, + }) +} + +/// Rank used by [`merge_trust_bundles_with_precedence`]: higher wins. +fn merge_rank( + bundle: &SchemaPinTrustBundle, + precedence: Option<&[&str]>, +) -> (i64, chrono::DateTime) { + let mut rank: i64 = 0; + if let Some(list) = precedence { + if !list.is_empty() { + rank = -(list.len() as i64) - 1; + if let Some(auth) = &bundle.bundle_authority { + if let Some(i) = list.iter().position(|k| *k == auth.kid) { + rank = -(i as i64); + } + } + } + } + let raw = bundle.signed_at.as_deref().unwrap_or(&bundle.created_at); + let ts = parse_rfc3339_strict(raw).unwrap_or(chrono::DateTime::::MIN_UTC); + (rank, ts) } /// Merge trust bundles, deduplicating discovery + revocation documents by /// domain. When two bundles carry the same domain, the entry from the bundle -/// with the newer timestamp (`signed_at`, else `created_at`) wins. -/// -/// The result is an *unsigned* bundle (a merge cannot carry a single -/// authority's signature) stamped `schemapin_bundle_version = "1.4"` with -/// `created_at` set to the newest source timestamp. Re-sign it with -/// [`sign_trust_bundle`] before redistribution. +/// with the newer **parsed** timestamp (`signed_at`, else `created_at`) wins; +/// unparseable timestamps rank oldest. Equivalent to +/// [`merge_trust_bundles_with_precedence`] with no precedence list. pub fn merge_trust_bundles(bundles: &[SchemaPinTrustBundle]) -> SchemaPinTrustBundle { + merge_trust_bundles_with_precedence(bundles, None) +} + +/// [`merge_trust_bundles`] with per-authority precedence: `authority_precedence` +/// lists authority `kid`s, earlier wins; bundles from unlisted or absent +/// authorities rank last; the parsed timestamp breaks ties. Merge only +/// bundles you have already verified with [`verify_trust_bundle`]. +/// +/// The result is an *unsigned* bundle stamped `schemapin_bundle_version = +/// "1.4"` with `created_at` set to the newest source timestamp. Re-sign it +/// with [`sign_trust_bundle`] before redistribution. +pub fn merge_trust_bundles_with_precedence( + bundles: &[SchemaPinTrustBundle], + authority_precedence: Option<&[&str]>, +) -> SchemaPinTrustBundle { use std::collections::HashMap; - // domain -> (timestamp, document) - let mut docs: HashMap = HashMap::new(); - let mut revs: HashMap = HashMap::new(); - let mut newest_ts = String::new(); + type Rank = (i64, chrono::DateTime); + let mut docs: HashMap = HashMap::new(); + let mut revs: HashMap = HashMap::new(); + let mut newest: Option> = None; + let mut newest_raw = String::new(); for b in bundles { - let ts = b.signed_at.clone().unwrap_or_else(|| b.created_at.clone()); - if ts > newest_ts { - newest_ts = ts.clone(); + let rank = merge_rank(b, authority_precedence); + if newest.map_or(true, |n| rank.1 > n) { + newest = Some(rank.1); + newest_raw = b.signed_at.clone().unwrap_or_else(|| b.created_at.clone()); } for d in &b.documents { match docs.get(&d.domain) { - Some((existing_ts, _)) if *existing_ts >= ts => {} + Some((existing, _)) if *existing >= rank => {} _ => { - docs.insert(d.domain.clone(), (ts.clone(), d.clone())); + docs.insert(d.domain.clone(), (rank, d.clone())); } } } for r in &b.revocations { match revs.get(&r.domain) { - Some((existing_ts, _)) if *existing_ts >= ts => {} + Some((existing, _)) if *existing >= rank => {} _ => { - revs.insert(r.domain.clone(), (ts.clone(), r.clone())); + revs.insert(r.domain.clone(), (rank, r.clone())); } } } @@ -177,7 +332,7 @@ pub fn merge_trust_bundles(bundles: &[SchemaPinTrustBundle]) -> SchemaPinTrustBu SchemaPinTrustBundle { schemapin_bundle_version: BUNDLE_VERSION_SIGNED.to_string(), - created_at: newest_ts, + created_at: newest_raw, documents, revocations, ..Default::default() @@ -231,6 +386,20 @@ mod tests { use crate::types::bundle::BundledDiscovery; use crate::types::discovery::WellKnownResponse; + fn verify_first_use( + bundle: &SchemaPinTrustBundle, + store: &mut KeyPinStore, + ) -> Result<(), Error> { + verify_trust_bundle_with_options( + bundle, + store, + &BundleVerifyOptions { + allow_first_use: true, + }, + ) + .map(|_| ()) + } + fn make_bundle(domain: &str, created_at: &str) -> SchemaPinTrustBundle { SchemaPinTrustBundle { schemapin_bundle_version: "1.2".to_string(), @@ -272,7 +441,7 @@ mod tests { ); let mut store = KeyPinStore::new(); - verify_trust_bundle(&signed, &mut store).unwrap(); + verify_first_use(&signed, &mut store).unwrap(); } #[test] @@ -291,7 +460,7 @@ mod tests { signed.documents[0].domain = "evil.com".to_string(); let mut store = KeyPinStore::new(); - let err = verify_trust_bundle(&signed, &mut store).unwrap_err(); + let err = verify_first_use(&signed, &mut store).unwrap_err(); assert!(matches!( err, Error::Verification { @@ -305,7 +474,7 @@ mod tests { fn test_unsigned_bundle_rejected() { let bundle = make_bundle("example.com", "2026-05-15T00:00:00Z"); let mut store = KeyPinStore::new(); - let err = verify_trust_bundle(&bundle, &mut store).unwrap_err(); + let err = verify_first_use(&bundle, &mut store).unwrap_err(); assert!(matches!( err, Error::Verification { @@ -328,7 +497,7 @@ mod tests { ) .unwrap(); let mut store = KeyPinStore::new(); - let err = verify_trust_bundle(&signed, &mut store).unwrap_err(); + let err = verify_first_use(&signed, &mut store).unwrap_err(); assert!(matches!( err, Error::Verification { @@ -363,8 +532,8 @@ mod tests { .unwrap(); let mut store = KeyPinStore::new(); - verify_trust_bundle(&signed1, &mut store).unwrap(); // pins kp1 - let err = verify_trust_bundle(&signed2, &mut store).unwrap_err(); + verify_first_use(&signed1, &mut store).unwrap(); // pins kp1 + let err = verify_first_use(&signed2, &mut store).unwrap_err(); assert!(matches!( err, Error::Verification { @@ -430,6 +599,157 @@ mod tests { // And the parsed bundle still verifies. let mut store = KeyPinStore::new(); - verify_trust_bundle(&parsed, &mut store).unwrap(); + verify_first_use(&parsed, &mut store).unwrap(); + } + + // ── authority allow-list (SP-6), parity (SP-19), merge precedence ── + + #[test] + fn test_authority_allow_list() { + let kp = generate_key_pair().unwrap(); + let signed = sign_trust_bundle( + &make_bundle("victim.com", "2026-05-15T00:00:00Z"), + &kp.private_key_pem, + "victim-authority-2026", + "2026-05-15T00:00:00Z", + None, + ) + .unwrap(); + let mut empty = KeyPinStore::new(); + match verify_trust_bundle(&signed, &mut empty).unwrap_err() { + Error::Verification { code, .. } => { + assert_eq!(code, ErrorCode::BundleAuthorityUntrusted) + } + e => panic!("unexpected error {:?}", e), + } + + let mut store = KeyPinStore::new(); + let fp = seed_bundle_authority_from_pem( + &mut store, + "victim-authority-2026", + &signed.bundle_authority.as_ref().unwrap().public_key_pem, + ) + .unwrap(); + let res = + verify_trust_bundle_with_options(&signed, &mut store, &BundleVerifyOptions::default()) + .unwrap(); + assert!(!res.first_use); + assert_eq!(res.fingerprint, fp); + + let mut fresh = KeyPinStore::new(); + let res = verify_trust_bundle_with_options( + &signed, + &mut fresh, + &BundleVerifyOptions { + allow_first_use: true, + }, + ) + .unwrap(); + assert!(res.first_use); + assert!(verify_trust_bundle(&signed, &mut fresh).is_ok()); + + // A failed signature never pins. + let mut tampered = sign_trust_bundle( + &make_bundle("example.com", "2026-05-15T00:00:00Z"), + &kp.private_key_pem, + "auth", + "2026-05-15T00:00:00Z", + None, + ) + .unwrap(); + tampered.documents[0].domain = "evil.com".to_string(); + let mut s2 = KeyPinStore::new(); + assert!(verify_first_use(&tampered, &mut s2).is_err()); + assert!(s2.get_tool("auth", BUNDLE_AUTHORITY_PIN_DOMAIN).is_none()); + } + + #[test] + fn test_strict_expires_at_and_duplicates() { + let kp = generate_key_pair().unwrap(); + for bad in ["2099-01-01", "2099-01-01T00:00:00", "2099-01-01 00:00:00Z"] { + let signed = sign_trust_bundle( + &make_bundle("example.com", "2026-05-15T00:00:00Z"), + &kp.private_key_pem, + "auth", + "2026-05-15T00:00:00Z", + Some(bad), + ) + .unwrap(); + match verify_first_use(&signed, &mut KeyPinStore::new()).unwrap_err() { + Error::Verification { code, .. } => { + assert_eq!(code, ErrorCode::BundleExpired, "{}", bad) + } + e => panic!("unexpected error {:?}", e), + } + } + let mut dup = make_bundle("example.com", "2026-05-15T00:00:00Z"); + dup.documents.push(dup.documents[0].clone()); + assert!(sign_trust_bundle( + &dup, + &kp.private_key_pem, + "auth", + "2026-05-15T00:00:00Z", + None + ) + .is_err()); + assert!(crate::resolver::TrustBundleResolver::from_json( + &serde_json::to_string(&dup).unwrap() + ) + .is_err()); + } + + #[test] + fn test_merge_precedence() { + let trusted = generate_key_pair().unwrap(); + let rogue = generate_key_pair().unwrap(); + let named = |name: &str, ts: &str| { + let mut b = make_bundle("example.com", ts); + b.documents[0].well_known.developer_name = Some(name.to_string()); + b + }; + let good = sign_trust_bundle( + &named("Good", "2026-01-01T00:00:00Z"), + &trusted.private_key_pem, + "trusted", + "2026-01-01T00:00:00Z", + None, + ) + .unwrap(); + let bad = sign_trust_bundle( + &named("Rogue", "2099-01-01T00:00:00Z"), + &rogue.private_key_pem, + "rogue", + "2099-01-01T00:00:00Z", + None, + ) + .unwrap(); + let merged = + merge_trust_bundles_with_precedence(&[bad.clone(), good.clone()], Some(&["trusted"])); + assert_eq!( + merged.documents[0].well_known.developer_name.as_deref(), + Some("Good") + ); + let merged = merge_trust_bundles(&[good, bad]); + assert_eq!( + merged.documents[0].well_known.developer_name.as_deref(), + Some("Rogue") + ); + let real = named("Real", "2026-01-01T00:00:00Z"); + let bogus = named("Bogus", "9999-99-99 bogus"); + assert_eq!( + merge_trust_bundles(&[real.clone(), bogus]).documents[0] + .well_known + .developer_name + .as_deref(), + Some("Real") + ); + let offset = named("Offset", "2026-02-01T00:00:00+00:00"); + assert_eq!( + merge_trust_bundles(&[real, offset]).documents[0] + .well_known + .developer_name + .as_deref(), + Some("Offset") + ); } } diff --git a/rust/src/resolver.rs b/rust/src/resolver.rs index 692ac01..9356942 100644 --- a/rust/src/resolver.rs +++ b/rust/src/resolver.rs @@ -181,8 +181,16 @@ impl TrustBundleResolver { } /// Build from a JSON string representing a [`SchemaPinTrustBundle`]. + /// Bundles with duplicate domain entries are rejected so lookups are + /// unambiguous. pub fn from_json(json: &str) -> Result { let bundle: SchemaPinTrustBundle = serde_json::from_str(json)?; + if let Some(dup) = crate::bundle::duplicate_domain(&bundle) { + return Err(Error::Discovery(format!( + "trust bundle has duplicate entries for domain {:?}", + dup + ))); + } Ok(Self::new(&bundle)) } } diff --git a/rust/tests/cross_language_bundle.rs b/rust/tests/cross_language_bundle.rs index b7f7a58..140f487 100644 --- a/rust/tests/cross_language_bundle.rs +++ b/rust/tests/cross_language_bundle.rs @@ -1,15 +1,48 @@ //! Cross-language interop: verify the shared signed-bundle fixture that every //! SDK checks (see ../../tests/cross-language/README.md). -use schemapin::bundle::verify_trust_bundle; +use schemapin::bundle::{ + seed_bundle_authority, seed_bundle_authority_from_pem, verify_trust_bundle, + verify_trust_bundle_with_options, BundleVerifyOptions, BUNDLE_AUTHORITY_PIN_DOMAIN, +}; +use schemapin::error::{Error, ErrorCode}; use schemapin::pinning::KeyPinStore; use schemapin::types::bundle::SchemaPinTrustBundle; -#[test] -fn verifies_shared_signed_bundle_fixture() { +fn fixture() -> SchemaPinTrustBundle { let json = std::fs::read_to_string("../tests/cross-language/signed_bundle.json") .expect("fixture present"); - let bundle: SchemaPinTrustBundle = serde_json::from_str(&json).unwrap(); + serde_json::from_str(&json).unwrap() +} + +#[test] +fn verifies_shared_signed_bundle_fixture_once_seeded() { + let bundle = fixture(); + let kid = bundle.bundle_authority.as_ref().unwrap().kid.clone(); + assert_eq!(kid, "schemapin-test-fixture-authority"); + + // Unknown authority: refused unless seeded. + match verify_trust_bundle(&bundle, &mut KeyPinStore::new()).unwrap_err() { + Error::Verification { code, .. } => assert_eq!(code, ErrorCode::BundleAuthorityUntrusted), + e => panic!("unexpected error {:?}", e), + } + + let pub_pem = + std::fs::read_to_string("../tests/cross-language/keys/test-only-bundle-authority.pub.pem") + .unwrap(); let mut store = KeyPinStore::new(); - verify_trust_bundle(&bundle, &mut store).expect("shared fixture must verify"); + seed_bundle_authority_from_pem(&mut store, &kid, &pub_pem).unwrap(); + let res = + verify_trust_bundle_with_options(&bundle, &mut store, &BundleVerifyOptions::default()) + .expect("shared fixture must verify"); + assert!(!res.first_use); + + // A different seeded key is an impersonation. + let mut other = KeyPinStore::new(); + seed_bundle_authority(&mut other, &kid, &format!("sha256:{}", "00".repeat(32))); + match verify_trust_bundle(&bundle, &mut other).unwrap_err() { + Error::Verification { code, .. } => assert_eq!(code, ErrorCode::KeyPinMismatch), + e => panic!("unexpected error {:?}", e), + } + let _ = BUNDLE_AUTHORITY_PIN_DOMAIN; } diff --git a/rust/tests/cross_language_signature.rs b/rust/tests/cross_language_signature.rs index a855af2..9558120 100644 --- a/rust/tests/cross_language_signature.rs +++ b/rust/tests/cross_language_signature.rs @@ -30,7 +30,10 @@ fn fixed_schema_signature_verifies() { assert_eq!(canonical, v.canonical); let digest = hash_canonical(&canonical); assert_eq!(hex::encode(&digest), v.sha256); - assert_eq!(calculate_key_id(&v.public_key_pem).unwrap(), v.key_fingerprint); + assert_eq!( + calculate_key_id(&v.public_key_pem).unwrap(), + v.key_fingerprint + ); assert!(verify_signature(&v.public_key_pem, &digest, &v.signature).unwrap()); } @@ -47,7 +50,8 @@ fn tampered_schema_and_trailing_der_bytes_rejected() { #[test] fn non_p256_public_keys_rejected() { for name in ["reject-p384-public.pem", "reject-rsa2048-public.pem"] { - let pem = std::fs::read_to_string(format!("../tests/cross-language/keys/{}", name)).unwrap(); + let pem = + std::fs::read_to_string(format!("../tests/cross-language/keys/{}", name)).unwrap(); assert!(calculate_key_id(&pem).is_err(), "{} must be rejected", name); assert!( verify_signature(&pem, b"x", "AAAA").is_err(), diff --git a/tests/cross-language/signed_bundle.json b/tests/cross-language/signed_bundle.json index b12e149..f264e20 100644 --- a/tests/cross-language/signed_bundle.json +++ b/tests/cross-language/signed_bundle.json @@ -13,10 +13,10 @@ ], "revocations": [], "bundle_authority": { - "kid": "schemapin-bundle-authority-2026", - "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEg+aix0OfT3YmM1E+s98N6MoGP7C5\nCvO1BtD5K4KmJgv6N0B+LNtCf4Igu6Z94oyIoVHTUQ6i/cjQ1kzXx3Mv0w==\n-----END PUBLIC KEY-----\n" + "kid": "schemapin-test-fixture-authority", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEv/LahRognUVulltAhWO21jYl99EK\ntB6ekEsIyTpPor1e9oKeAW9aQ24sULg2MG2qvOWv4CLpZT08Ty4lAXSJXw==\n-----END PUBLIC KEY-----\n" }, "signed_at": "2026-05-15T00:00:00Z", "expires_at": "2099-01-01T00:00:00Z", - "signature": "MEUCIQDyUMRPiPY6la8RTfOW7vRmOuIUkGMcrFjM4bAM/g9IRQIgQ3CRP/j/M1NrMzNU3s6f1S3kQm9h2a+ffLmfPHm9Uzs=" -} \ No newline at end of file + "signature": "MEQCIE+TK3k1VkNJbQbPTrTpH9doXlto+J7QN2NaIq4bdCr7AiBd58EMQzBC3F6K9V0Vi2vc/5U/7x7Bh9KV8w7iLeHN3w==" +} From 78ba2ef47cb2ddd347cb7599592edc1db8643d91 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 15:11:10 -0700 Subject: [PATCH 06/14] Key legacy pin stores by (tool_id, domain); harden the JS store file 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. --- go/pkg/pinning/pinning.go | 179 +++++++++++++++--- go/pkg/pinning/store_security_test.go | 103 +++++++++++ go/pkg/utils/utils.go | 60 +++--- javascript/src/index.js | 2 +- javascript/src/pinning.js | 246 +++++++++++++++++-------- javascript/src/utils.js | 49 ++--- javascript/tests/interactive.test.js | 20 +- javascript/tests/pinning-store.test.js | 91 +++++++++ python/schemapin/pinning.py | 154 +++++++++++----- python/schemapin/utils.py | 51 +++-- python/tests/test_pinning_store.py | 71 +++++++ python/tools/verify_schema.py | 2 +- 12 files changed, 806 insertions(+), 222 deletions(-) create mode 100644 go/pkg/pinning/store_security_test.go create mode 100644 javascript/tests/pinning-store.test.js create mode 100644 python/tests/test_pinning_store.py diff --git a/go/pkg/pinning/pinning.go b/go/pkg/pinning/pinning.go index 6a43ee1..addaceb 100644 --- a/go/pkg/pinning/pinning.go +++ b/go/pkg/pinning/pinning.go @@ -1,4 +1,14 @@ // Package pinning provides TOFU key storage and management using BoltDB. +// +// Deprecated: KeyPinning is the legacy PEM-by-tool store used by +// utils.SchemaVerificationWorkflow. New code should use verification.KeyPinStore +// (fingerprint pins keyed by tool_id@domain) with verification.VerifySchemaOffline; +// this package is retained as a persistence backend for existing deployments. +// +// Pins are keyed by toolID@domain. A pin made for one domain is never returned +// for another, so a key pinned for evil.com cannot satisfy a verification for +// good.com that happens to reuse the same tool id. Stores written by earlier +// releases (keyed by tool id only) are migrated in place on open. package pinning import ( @@ -6,6 +16,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "go.etcd.io/bbolt" @@ -106,15 +117,16 @@ func NewKeyPinning(dbPath string, mode PinningMode, handler interactive.Interact return nil, fmt.Errorf("failed to open database: %w", err) } - // Create buckets + // Create buckets and migrate legacy tool-id-only keys to toolID@domain. err = db.Update(func(tx *bbolt.Tx) error { - if _, err := tx.CreateBucketIfNotExists(pinnedKeysBucket); err != nil { + bucket, err := tx.CreateBucketIfNotExists(pinnedKeysBucket) + if err != nil { return fmt.Errorf("failed to create pinned_keys bucket: %w", err) } if _, err := tx.CreateBucketIfNotExists(domainPoliciesBucket); err != nil { return fmt.Errorf("failed to create domain_policies bucket: %w", err) } - return nil + return migrateLegacyKeys(bucket) }) if err != nil { _ = db.Close() @@ -135,6 +147,75 @@ func NewKeyPinning(dbPath string, mode PinningMode, handler interactive.Interact }, nil } +func compositeKey(toolID, domain string) []byte { + return []byte(toolID + "@" + domain) +} + +// migrateLegacyKeys rewrites entries stored under a bare tool id (pre-1.4 +// layout) to the composite toolID@domain key. +func migrateLegacyKeys(bucket *bbolt.Bucket) error { + type legacyEntry struct { + key []byte + info PinnedKeyInfo + } + var legacy []legacyEntry + err := bucket.ForEach(func(key, value []byte) error { + if strings.Contains(string(key), "@") { + return nil + } + var info PinnedKeyInfo + if err := json.Unmarshal(value, &info); err != nil { + return fmt.Errorf("pin store entry %q is corrupt: %w", string(key), err) + } + legacy = append(legacy, legacyEntry{key: append([]byte(nil), key...), info: info}) + return nil + }) + if err != nil { + return err + } + for _, e := range legacy { + if e.info.ToolID == "" { + e.info.ToolID = string(e.key) + } + data, err := json.Marshal(e.info) + if err != nil { + return err + } + if err := bucket.Put(compositeKey(e.info.ToolID, e.info.Domain), data); err != nil { + return err + } + if err := bucket.Delete(e.key); err != nil { + return err + } + } + return nil +} + +// lookupKey resolves the store key for a tool. With an empty domain the +// deprecated tool-id-only lookup is performed; it returns nil when the tool id +// is pinned for more than one domain. +func (k *KeyPinning) lookupKey(bucket *bbolt.Bucket, toolID, domain string) []byte { + if domain != "" { + key := compositeKey(toolID, domain) + if bucket.Get(key) != nil { + return key + } + return nil + } + prefix := []byte(toolID + "@") + var found []byte + count := 0 + c := bucket.Cursor() + for key, _ := c.Seek(prefix); key != nil && strings.HasPrefix(string(key), string(prefix)); key, _ = c.Next() { + found = append([]byte(nil), key...) + count++ + } + if count != 1 { + return nil + } + return found +} + // Close closes the database connection func (k *KeyPinning) Close() error { if k.db != nil { @@ -160,19 +241,29 @@ func (k *KeyPinning) PinKey(toolID, publicKeyPEM, domain, developerName string) return k.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(pinnedKeysBucket) - return bucket.Put([]byte(toolID), data) + return bucket.Put(compositeKey(toolID, domain), data) }) } -// GetPinnedKey retrieves the pinned public key for a tool +// GetPinnedKey retrieves the pinned public key for a tool. +// +// Deprecated: use GetPinnedKeyForDomain so that a pin recorded for a different +// domain is never returned. This lookup fails when the tool id is pinned for +// more than one domain. func (k *KeyPinning) GetPinnedKey(toolID string) (string, error) { + return k.GetPinnedKeyForDomain(toolID, "") +} + +// GetPinnedKeyForDomain retrieves the public key pinned for toolID at domain. +func (k *KeyPinning) GetPinnedKeyForDomain(toolID, domain string) (string, error) { var publicKeyPEM string err := k.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(pinnedKeysBucket) - data := bucket.Get([]byte(toolID)) - if data == nil { + key := k.lookupKey(bucket, toolID, domain) + if key == nil { return nil // Not found } + data := bucket.Get(key) var keyInfo PinnedKeyInfo if err := json.Unmarshal(data, &keyInfo); err != nil { @@ -193,20 +284,36 @@ func (k *KeyPinning) GetPinnedKey(toolID string) (string, error) { return publicKeyPEM, nil } -// IsKeyPinned checks if a key is pinned for a tool +// IsKeyPinned checks if a key is pinned for a tool. +// +// Deprecated: use IsKeyPinnedForDomain. func (k *KeyPinning) IsKeyPinned(toolID string) bool { - key, err := k.GetPinnedKey(toolID) + return k.IsKeyPinnedForDomain(toolID, "") +} + +// IsKeyPinnedForDomain checks if a key is pinned for toolID at domain. +func (k *KeyPinning) IsKeyPinnedForDomain(toolID, domain string) bool { + key, err := k.GetPinnedKeyForDomain(toolID, domain) return err == nil && key != "" } -// UpdateLastVerified updates the last verification timestamp +// UpdateLastVerified updates the last verification timestamp. +// +// Deprecated: use UpdateLastVerifiedForDomain. func (k *KeyPinning) UpdateLastVerified(toolID string) error { + return k.UpdateLastVerifiedForDomain(toolID, "") +} + +// UpdateLastVerifiedForDomain updates the last verification timestamp for +// toolID at domain. +func (k *KeyPinning) UpdateLastVerifiedForDomain(toolID, domain string) error { return k.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(pinnedKeysBucket) - data := bucket.Get([]byte(toolID)) - if data == nil { + key := k.lookupKey(bucket, toolID, domain) + if key == nil { return fmt.Errorf("tool not found: %s", toolID) } + data := bucket.Get(key) var keyInfo PinnedKeyInfo if err := json.Unmarshal(data, &keyInfo); err != nil { @@ -220,7 +327,7 @@ func (k *KeyPinning) UpdateLastVerified(toolID string) error { return fmt.Errorf("failed to marshal updated key info: %w", err) } - return bucket.Put([]byte(toolID), updatedData) + return bucket.Put(key, updatedData) }) } @@ -266,15 +373,24 @@ func (k *KeyPinning) GetDomainPolicy(domain string) PinningPolicy { return policy } -// GetKeyInfo retrieves complete information about a pinned key +// GetKeyInfo retrieves complete information about a pinned key. +// +// Deprecated: use GetKeyInfoForDomain. func (k *KeyPinning) GetKeyInfo(toolID string) (*PinnedKeyInfo, error) { + return k.GetKeyInfoForDomain(toolID, "") +} + +// GetKeyInfoForDomain retrieves complete information about the key pinned for +// toolID at domain. +func (k *KeyPinning) GetKeyInfoForDomain(toolID, domain string) (*PinnedKeyInfo, error) { var keyInfo *PinnedKeyInfo err := k.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(pinnedKeysBucket) - data := bucket.Get([]byte(toolID)) - if data == nil { + key := k.lookupKey(bucket, toolID, domain) + if key == nil { return nil // Not found } + data := bucket.Get(key) var info PinnedKeyInfo if err := json.Unmarshal(data, &info); err != nil { @@ -319,11 +435,22 @@ func (k *KeyPinning) ListPinnedKeys() ([]map[string]interface{}, error) { return keys, err } -// RemovePinnedKey removes a pinned key for a tool +// RemovePinnedKey removes a pinned key for a tool. +// +// Deprecated: use RemovePinnedKeyForDomain. func (k *KeyPinning) RemovePinnedKey(toolID string) error { + return k.RemovePinnedKeyForDomain(toolID, "") +} + +// RemovePinnedKeyForDomain removes the key pinned for toolID at domain. +func (k *KeyPinning) RemovePinnedKeyForDomain(toolID, domain string) error { return k.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(pinnedKeysBucket) - return bucket.Delete([]byte(toolID)) + key := k.lookupKey(bucket, toolID, domain) + if key == nil { + return nil + } + return bucket.Delete(key) }) } @@ -364,12 +491,12 @@ func (k *KeyPinning) ImportPinnedKeys(jsonData string, overwrite bool) (int, err imported := 0 for _, keyInfo := range keys { - if !overwrite && k.IsKeyPinned(keyInfo.ToolID) { + if !overwrite && k.IsKeyPinnedForDomain(keyInfo.ToolID, keyInfo.Domain) { continue } if overwrite { - _ = k.RemovePinnedKey(keyInfo.ToolID) + _ = k.RemovePinnedKeyForDomain(keyInfo.ToolID, keyInfo.Domain) } if err := k.PinKey(keyInfo.ToolID, keyInfo.PublicKeyPEM, keyInfo.Domain, keyInfo.DeveloperName); err == nil { @@ -396,8 +523,8 @@ func (k *KeyPinning) interactivePinKeyWithOptions(toolID, publicKeyPEM, domain, return k.PinKey(toolID, publicKeyPEM, domain, developerName) == nil, nil } - // Check if key is already pinned - existingKey, err := k.GetPinnedKey(toolID) + // Check if key is already pinned for this tool at this domain + existingKey, err := k.GetPinnedKeyForDomain(toolID, domain) if err != nil { return false, fmt.Errorf("failed to check existing key: %w", err) } @@ -405,7 +532,7 @@ func (k *KeyPinning) interactivePinKeyWithOptions(toolID, publicKeyPEM, domain, if existingKey != "" { if existingKey == publicKeyPEM { // Same key, just update verification time - _ = k.UpdateLastVerified(toolID) + _ = k.UpdateLastVerifiedForDomain(toolID, domain) return true, nil } else { // Different key - handle key change @@ -505,7 +632,7 @@ func (k *KeyPinning) handleKeyChange(toolID, domain, currentKeyPEM, newKeyPEM, d // Interactive prompt for key change if k.interactiveManager != nil { - currentKeyInfo, _ := k.GetKeyInfo(toolID) + currentKeyInfo, _ := k.GetKeyInfoForDomain(toolID, domain) currentKeyInfoMap := make(map[string]interface{}) if currentKeyInfo != nil { currentKeyInfoMap["tool_id"] = currentKeyInfo.ToolID @@ -533,11 +660,11 @@ func (k *KeyPinning) handleKeyChange(toolID, domain, currentKeyPEM, newKeyPEM, d switch decision { case interactive.UserDecisionAccept: // Remove old key and pin new one - _ = k.RemovePinnedKey(toolID) + _ = k.RemovePinnedKeyForDomain(toolID, domain) return k.PinKey(toolID, newKeyPEM, domain, developerName) == nil, nil case interactive.UserDecisionAlwaysTrust: _ = k.SetDomainPolicy(domain, PinningPolicyAlwaysTrust) - _ = k.RemovePinnedKey(toolID) + _ = k.RemovePinnedKeyForDomain(toolID, domain) return k.PinKey(toolID, newKeyPEM, domain, developerName) == nil, nil case interactive.UserDecisionNeverTrust: _ = k.SetDomainPolicy(domain, PinningPolicyNeverTrust) diff --git a/go/pkg/pinning/store_security_test.go b/go/pkg/pinning/store_security_test.go new file mode 100644 index 0000000..62fee77 --- /dev/null +++ b/go/pkg/pinning/store_security_test.go @@ -0,0 +1,103 @@ +package pinning + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "go.etcd.io/bbolt" +) + +const ( + pemA = "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----" + pemB = "-----BEGIN PUBLIC KEY-----\nBBBB\n-----END PUBLIC KEY-----" +) + +// TestPinsAreBoundToDomain is the SP-15 regression: a pin recorded for one +// domain must never satisfy a lookup for another. +func TestPinsAreBoundToDomain(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "pins.db") + store, err := NewKeyPinning(dbPath, PinningModeAutomatic, nil) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + if err := store.PinKey("calculator", pemA, "evil.com", ""); err != nil { + t.Fatal(err) + } + if key, _ := store.GetPinnedKeyForDomain("calculator", "good.com"); key != "" { + t.Fatal("evil.com pin must not satisfy good.com") + } + if store.IsKeyPinnedForDomain("calculator", "good.com") { + t.Fatal("IsKeyPinnedForDomain must be false for another domain") + } + if key, _ := store.GetPinnedKeyForDomain("calculator", "evil.com"); key != pemA { + t.Fatalf("expected evil.com pin, got %q", key) + } + if err := store.PinKey("calculator", pemB, "good.com", ""); err != nil { + t.Fatal(err) + } + if key, _ := store.GetPinnedKeyForDomain("calculator", "good.com"); key != pemB { + t.Fatalf("expected good.com pin, got %q", key) + } + // The deprecated domain-less lookup refuses to guess between domains. + if key, _ := store.GetPinnedKey("calculator"); key != "" { + t.Fatalf("ambiguous lookup must return nothing, got %q", key) + } + if err := store.RemovePinnedKeyForDomain("calculator", "evil.com"); err != nil { + t.Fatal(err) + } + if key, _ := store.GetPinnedKey("calculator"); key != pemB { + t.Fatalf("single remaining pin must resolve, got %q", key) + } +} + +// TestLegacyKeysAreMigrated opens a store written in the pre-1.4 layout +// (bucket keyed by bare tool id) and expects composite keys afterwards. +func TestLegacyKeysAreMigrated(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + db, err := bbolt.Open(dbPath, 0600, &bbolt.Options{Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + err = db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists(pinnedKeysBucket) + if err != nil { + return err + } + data, _ := json.Marshal(PinnedKeyInfo{ToolID: "calc", PublicKeyPEM: pemA, Domain: "example.com", PinnedAt: time.Now()}) + return b.Put([]byte("calc"), data) + }) + if err != nil { + t.Fatal(err) + } + _ = db.Close() + + store, err := NewKeyPinning(dbPath, PinningModeAutomatic, nil) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if key, _ := store.GetPinnedKeyForDomain("calc", "example.com"); key != pemA { + t.Fatalf("migrated pin missing, got %q", key) + } + err = store.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket(pinnedKeysBucket) + if b.Get([]byte("calc")) != nil { + t.Error("legacy key must be removed after migration") + } + if b.Get([]byte("calc@example.com")) == nil { + t.Error("composite key must exist after migration") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if st, err := os.Stat(dbPath); err == nil && st.Mode().Perm()&0o077 != 0 { + t.Errorf("store must be owner-only, got %o", st.Mode().Perm()) + } +} diff --git a/go/pkg/utils/utils.go b/go/pkg/utils/utils.go index 1f19b45..f49043a 100644 --- a/go/pkg/utils/utils.go +++ b/go/pkg/utils/utils.go @@ -11,6 +11,7 @@ import ( "github.com/ThirdKeyAi/schemapin/go/pkg/core" "github.com/ThirdKeyAi/schemapin/go/pkg/crypto" "github.com/ThirdKeyAi/schemapin/go/pkg/discovery" + "github.com/ThirdKeyAi/schemapin/go/pkg/domain" "github.com/ThirdKeyAi/schemapin/go/pkg/pinning" ) @@ -66,7 +67,12 @@ func (s *SchemaSigningWorkflow) GetPublicKeyPEM() (string, error) { return s.keyManager.ExportPublicKeyPEM(&s.privateKey.PublicKey) } -// SchemaVerificationWorkflow provides high-level verification operations +// SchemaVerificationWorkflow provides high-level verification operations. +// +// Deprecated: built on the legacy pinning.KeyPinning store. Prefer +// verification.VerifySchemaWithResolver with a verification.KeyPinStore, which +// pins by fingerprint only after a successful verification and returns a +// structured VerificationResult. type SchemaVerificationWorkflow struct { pinning *pinning.KeyPinning discovery *discovery.PublicKeyDiscovery @@ -124,7 +130,7 @@ func (s *SchemaVerificationWorkflow) Close() error { } // VerifySchema verifies a signed schema with optional auto-pinning -func (s *SchemaVerificationWorkflow) VerifySchema(ctx context.Context, schema map[string]interface{}, signatureB64, toolID, domain string, autoPin bool) (*VerificationResult, error) { +func (s *SchemaVerificationWorkflow) VerifySchema(ctx context.Context, schema map[string]interface{}, signatureB64, toolID, domainName string, autoPin bool) (*VerificationResult, error) { result := &VerificationResult{ Valid: false, Pinned: false, @@ -145,8 +151,15 @@ func (s *SchemaVerificationWorkflow) VerifySchema(ctx context.Context, schema ma return result, nil } - // Check for pinned key - pinnedKeyPEM, err := s.pinning.GetPinnedKey(toolID) + normalized, err := domain.ValidateDomain(domainName) + if err != nil { + result.Error = fmt.Sprintf("invalid domain: %v", err) + return result, nil + } + domainName = normalized + + // Check for a key pinned for this tool at this domain + pinnedKeyPEM, err := s.pinning.GetPinnedKeyForDomain(toolID, domainName) if err != nil { result.Error = fmt.Sprintf("failed to check pinned key: %v", err) return result, nil @@ -154,11 +167,12 @@ func (s *SchemaVerificationWorkflow) VerifySchema(ctx context.Context, schema ma var publicKeyPEM string var publicKey *ecdsa.PublicKey + shouldPin := false if pinnedKeyPEM != "" { // Use pinned key, but check if it's been revoked (fail-closed: an // unreachable revocation source is a verification failure). - isNotRevoked, err := s.discovery.ValidateKeyNotRevoked(ctx, pinnedKeyPEM, domain) + isNotRevoked, err := s.discovery.ValidateKeyNotRevoked(ctx, pinnedKeyPEM, domainName) if err != nil { result.Error = fmt.Sprintf("revocation status unavailable: %v", err) return result, nil @@ -179,14 +193,14 @@ func (s *SchemaVerificationWorkflow) VerifySchema(ctx context.Context, schema ma result.Pinned = true } else { // First use - discover key - discoveredKeyPEM, err := s.discovery.GetPublicKeyPEM(ctx, domain) + discoveredKeyPEM, err := s.discovery.GetPublicKeyPEM(ctx, domainName) if err != nil { result.Error = fmt.Sprintf("could not discover public key: %v", err) return result, nil } // Check if key is revoked (fail-closed) - isNotRevoked, err := s.discovery.ValidateKeyNotRevoked(ctx, discoveredKeyPEM, domain) + isNotRevoked, err := s.discovery.ValidateKeyNotRevoked(ctx, discoveredKeyPEM, domainName) if err != nil { result.Error = fmt.Sprintf("revocation status unavailable: %v", err) return result, nil @@ -207,39 +221,39 @@ func (s *SchemaVerificationWorkflow) VerifySchema(ctx context.Context, schema ma result.FirstUse = true // Get developer info - developerInfo, err := s.discovery.GetDeveloperInfo(ctx, domain) + developerInfo, err := s.discovery.GetDeveloperInfo(ctx, domainName) if err == nil { result.DeveloperInfo = developerInfo } + shouldPin = autoPin + } - // Auto-pin if requested - if autoPin { - developerName := "" - if result.DeveloperInfo != nil { - if name, ok := result.DeveloperInfo["developer_name"]; ok { - developerName = name - } - } + // Verify signature before anything is pinned + result.Valid = s.signatureManager.VerifySchemaSignature(schemaHash, signatureB64, publicKey) - if err := s.pinning.PinKey(toolID, publicKeyPEM, domain, developerName); err == nil { - result.Pinned = true + // Auto-pin only after a successful verification + if result.Valid && shouldPin { + developerName := "" + if result.DeveloperInfo != nil { + if name, ok := result.DeveloperInfo["developer_name"]; ok { + developerName = name } } + if err := s.pinning.PinKey(toolID, publicKeyPEM, domainName, developerName); err == nil { + result.Pinned = true + } } - // Verify signature - result.Valid = s.signatureManager.VerifySchemaSignature(schemaHash, signatureB64, publicKey) - // Update verification timestamp if valid and pinned if result.Valid && result.Pinned { - _ = s.pinning.UpdateLastVerified(toolID) + _ = s.pinning.UpdateLastVerifiedForDomain(toolID, domainName) } // Add metadata if fingerprint, err := s.keyManager.CalculateKeyFingerprintFromPEM(publicKeyPEM); err == nil { result.Metadata["key_fingerprint"] = fingerprint } - result.Metadata["domain"] = domain + result.Metadata["domain"] = domainName result.Metadata["tool_id"] = toolID return result, nil diff --git a/javascript/src/index.js b/javascript/src/index.js index bd6ed80..6f17119 100644 --- a/javascript/src/index.js +++ b/javascript/src/index.js @@ -7,7 +7,7 @@ export { InvalidDomainError, validateDomain, isValidDomain } from './domain.js'; export { TransportError, fetchJson, requireHttps, MAX_RESPONSE_BYTES } from './transport.js'; export { KeyManager, SignatureManager } from './crypto.js'; export { PublicKeyDiscovery } from './discovery.js'; -export { KeyPinning, PinningMode, PinningPolicy } from './pinning.js'; +export { KeyPinning, PinningMode, PinningPolicy, PinStoreCorruptError } from './pinning.js'; export { SchemaSigningWorkflow, SchemaVerificationWorkflow, diff --git a/javascript/src/pinning.js b/javascript/src/pinning.js index 5364933..51ab5f4 100644 --- a/javascript/src/pinning.js +++ b/javascript/src/pinning.js @@ -1,8 +1,19 @@ /** * Key pinning storage and management for Trust-On-First-Use (TOFU). + * + * @deprecated `KeyPinning` is the legacy PEM-by-tool store used by + * `SchemaVerificationWorkflow`. New code should use `KeyPinStore` + * (fingerprint pins keyed by `tool_id@domain`) with `verifySchemaOffline`; + * this class is retained as a persistence backend for existing deployments. + * + * Pins are keyed by `toolId@domain`. A pin made for one domain is never + * returned for another, so a key pinned for `evil.com` cannot satisfy a + * verification for `good.com` that happens to reuse the same tool id. The + * store file is written atomically with mode 0600 and a corrupt file is a + * hard error rather than a silent reset to "first use". */ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { @@ -11,6 +22,22 @@ import { PromptType, InteractiveHandler } from './interactive.js'; +import { PublicKeyDiscovery } from './discovery.js'; + +/** + * Thrown when the pin store file cannot be read or parsed. Callers must not + * treat this as "nothing pinned". + */ +export class PinStoreCorruptError extends Error { + constructor(message) { + super(message); + this.name = 'PinStoreCorruptError'; + } +} + +function compositeKey(toolId, domain) { + return `${toolId}@${domain}`; +} /** * Pinning operation modes. @@ -41,34 +68,41 @@ export class KeyPinning { * @param {string|null} dbPath - Path to JSON storage file. If null, uses default location. * @param {string} mode - Pinning operation mode (automatic, interactive, strict) * @param {InteractiveHandler|null} interactiveHandler - Handler for interactive prompts + * @param {{ revocationChecker?: (publicKeyPem: string, domain: string) => Promise }} [options] + * `revocationChecker` replaces the discovery-backed, fail-closed + * revocation check used by the interactive flows (for custom resolvers + * or offline bundles). + * @throws {PinStoreCorruptError} if an existing store file is unreadable or malformed */ - constructor(dbPath = null, mode = PinningMode.AUTOMATIC, interactiveHandler = null) { + constructor(dbPath = null, mode = PinningMode.AUTOMATIC, interactiveHandler = null, options = {}) { if (dbPath === null) { const homeDir = homedir(); dbPath = join(homeDir, '.schemapin', 'pinned_keys.json'); } - + this.dbPath = dbPath; this.mode = mode; this.interactiveManager = mode === PinningMode.INTERACTIVE ? new InteractivePinningManager(interactiveHandler) : null; + this._revocationChecker = options.revocationChecker + ?? ((publicKeyPem, domain) => PublicKeyDiscovery.validateKeyNotRevoked(publicKeyPem, domain)); this._ensureDbDirectory(); this._initDatabase(); } /** - * Ensure database directory exists. + * Ensure database directory exists (owner-only). * @private */ _ensureDbDirectory() { const dir = dirname(this.dbPath); if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); } } /** - * Initialize database file. + * Initialize database file, migrating older layouts in place. * @private */ _initDatabase() { @@ -77,54 +111,83 @@ export class KeyPinning { pinned_keys: {}, domain_policies: {} }); - } else { - // Migrate old format if needed - const data = this._loadData(); - if (!data.pinned_keys && !data.domain_policies) { - // Old format - migrate - this._saveData({ - pinned_keys: data, - domain_policies: {} - }); - } + return; + } + const data = this._loadData(); + if (data.migrated) { + delete data.migrated; + this._saveData(data); } } /** - * Load data from JSON file. + * Load data from the JSON file. Unreadable or malformed stores throw: + * silently starting over would turn every pin into "first use". * @private * @returns {Object} Pinned keys data */ _loadData() { + let parsed; try { - const data = readFileSync(this.dbPath, 'utf8'); - const parsed = JSON.parse(data); - - // Handle both old and new format - if (parsed.pinned_keys !== undefined) { - return parsed; - } else { - // Old format - return { - pinned_keys: parsed, - domain_policies: {} - }; - } + parsed = JSON.parse(readFileSync(this.dbPath, 'utf8')); } catch (error) { - return { - pinned_keys: {}, - domain_policies: {} - }; + throw new PinStoreCorruptError(`pin store ${this.dbPath} is unreadable or malformed: ${error.message}`); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new PinStoreCorruptError(`pin store ${this.dbPath} is malformed`); } + let data = parsed; + if (parsed.pinned_keys === undefined) { + // Oldest layout: the whole file was the pinned_keys map. + data = { pinned_keys: parsed, domain_policies: {}, migrated: true }; + } + if (data.pinned_keys === null || typeof data.pinned_keys !== 'object') { + throw new PinStoreCorruptError(`pin store ${this.dbPath} is malformed`); + } + if (!data.domain_policies || typeof data.domain_policies !== 'object') { + data.domain_policies = {}; + } + // Migrate entries keyed by bare tool id to toolId@domain. + for (const [key, info] of Object.entries(data.pinned_keys)) { + if (!key.includes('@') && info && typeof info.domain === 'string') { + delete data.pinned_keys[key]; + data.pinned_keys[compositeKey(key, info.domain)] = info; + data.migrated = true; + } + } + return data; } /** - * Save data to JSON file. + * Save data atomically (temp file + rename) with mode 0600. * @private * @param {Object} data - Data to save */ _saveData(data) { - writeFileSync(this.dbPath, JSON.stringify(data, null, 2), 'utf8'); + const { migrated: _migrated, ...toWrite } = data; + const tmp = `${this.dbPath}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(toWrite, null, 2), { encoding: 'utf8', mode: 0o600 }); + try { + chmodSync(tmp, 0o600); + } catch { + // Best effort on platforms without POSIX modes. + } + renameSync(tmp, this.dbPath); + } + + /** + * Resolve the store key for a tool. With `domain` null the deprecated + * tool-id-only lookup is performed; it returns null when the tool id is + * pinned for more than one domain. + * @private + */ + _findKey(data, toolId, domain) { + if (domain !== null && domain !== undefined) { + const key = compositeKey(toolId, domain); + return key in data.pinned_keys ? key : null; + } + const matches = Object.keys(data.pinned_keys).filter((k) => k.startsWith(`${toolId}@`)); + return matches.length === 1 ? matches[0] : null; } /** @@ -138,12 +201,13 @@ export class KeyPinning { */ pinKey(toolId, publicKeyPem, domain, developerName = null) { const data = this._loadData(); - - if (toolId in data.pinned_keys) { + const key = compositeKey(toolId, domain); + + if (key in data.pinned_keys) { return false; // Key already exists } - - data.pinned_keys[toolId] = { + + data.pinned_keys[key] = { public_key_pem: publicKeyPem, domain: domain, developer_name: developerName, @@ -156,41 +220,44 @@ export class KeyPinning { } /** - * Get pinned public key for tool. + * Get the pinned public key for a tool at a domain. * * @param {string} toolId - Tool identifier + * @param {string|null} [domain=null] - Tool provider domain. Always pass it: + * a pin recorded for a different domain is never returned. * @returns {string|null} PEM-encoded public key if pinned, null otherwise */ - getPinnedKey(toolId) { + getPinnedKey(toolId, domain = null) { const data = this._loadData(); - const keyInfo = data.pinned_keys[toolId]; - return keyInfo ? keyInfo.public_key_pem : null; + const key = this._findKey(data, toolId, domain); + return key ? data.pinned_keys[key].public_key_pem : null; } /** - * Check if key is pinned for tool. + * Check if a key is pinned for a tool at a domain. * * @param {string} toolId - Tool identifier + * @param {string|null} [domain=null] * @returns {boolean} True if key is pinned, false otherwise */ - isKeyPinned(toolId) { - return this.getPinnedKey(toolId) !== null; + isKeyPinned(toolId, domain = null) { + return this.getPinnedKey(toolId, domain) !== null; } /** * Update last verification timestamp for tool. * * @param {string} toolId - Tool identifier + * @param {string|null} [domain=null] * @returns {boolean} True if updated successfully, false if tool not found */ - updateLastVerified(toolId) { + updateLastVerified(toolId, domain = null) { const data = this._loadData(); - - if (!(toolId in data.pinned_keys)) { + const key = this._findKey(data, toolId, domain); + if (!key) { return false; } - - data.pinned_keys[toolId].last_verified = new Date().toISOString(); + data.pinned_keys[key].last_verified = new Date().toISOString(); this._saveData(data); return true; } @@ -203,8 +270,8 @@ export class KeyPinning { listPinnedKeys() { const data = this._loadData(); return Object.entries(data.pinned_keys) - .map(([toolId, keyInfo]) => ({ - tool_id: toolId, + .map(([key, keyInfo]) => ({ + tool_id: key.slice(0, key.length - keyInfo.domain.length - 1), domain: keyInfo.domain, developer_name: keyInfo.developer_name, pinned_at: keyInfo.pinned_at, @@ -217,16 +284,16 @@ export class KeyPinning { * Remove pinned key for tool. * * @param {string} toolId - Tool identifier + * @param {string|null} [domain=null] * @returns {boolean} True if removed successfully, false if not found */ - removePinnedKey(toolId) { + removePinnedKey(toolId, domain = null) { const data = this._loadData(); - - if (!(toolId in data.pinned_keys)) { + const key = this._findKey(data, toolId, domain); + if (!key) { return false; } - - delete data.pinned_keys[toolId]; + delete data.pinned_keys[key]; this._saveData(data); return true; } @@ -235,16 +302,18 @@ export class KeyPinning { * Get complete information about pinned key. * * @param {string} toolId - Tool identifier + * @param {string|null} [domain=null] * @returns {Object|null} Object with key information if found, null otherwise */ - getKeyInfo(toolId) { + getKeyInfo(toolId, domain = null) { const data = this._loadData(); - const keyInfo = data.pinned_keys[toolId]; - + const key = this._findKey(data, toolId, domain); + const keyInfo = key ? data.pinned_keys[key] : null; + if (!keyInfo) { return null; } - + return { tool_id: toolId, public_key_pem: keyInfo.public_key_pem, @@ -264,7 +333,7 @@ export class KeyPinning { const keys = this.listPinnedKeys(); // Add public key data for export keys.forEach(keyInfo => { - keyInfo.public_key_pem = this.getPinnedKey(keyInfo.tool_id); + keyInfo.public_key_pem = this.getPinnedKey(keyInfo.tool_id, keyInfo.domain); }); return JSON.stringify(keys, null, 2); @@ -283,8 +352,8 @@ export class KeyPinning { let imported = 0; for (const keyInfo of keys) { - if (overwrite && this.isKeyPinned(keyInfo.tool_id)) { - this.removePinnedKey(keyInfo.tool_id); + if (overwrite && this.isKeyPinned(keyInfo.tool_id, keyInfo.domain)) { + this.removePinnedKey(keyInfo.tool_id, keyInfo.domain); } if (this.pinKey( @@ -356,12 +425,12 @@ export class KeyPinning { return this.pinKey(toolId, publicKeyPem, domain, developerName); } - // Check if key is already pinned - const existingKey = this.getPinnedKey(toolId); + // Check if key is already pinned for this tool at this domain + const existingKey = this.getPinnedKey(toolId, domain); if (existingKey) { if (existingKey === publicKeyPem) { // Same key, just update verification time - this.updateLastVerified(toolId); + this.updateLastVerified(toolId, domain); return true; } else { // Different key - handle key change @@ -380,9 +449,18 @@ export class KeyPinning { * @private */ async _handleFirstTimeKey(toolId, domain, publicKeyPem, developerName, forcePrompt) { - // Note: In a full implementation, you'd check revocation here - // For now, we'll assume the key is not revoked - + // Revocation check (fail-closed): a key whose status cannot be + // confirmed is never pinned automatically. + if (!(await this._revocationChecker(publicKeyPem, domain))) { + if (this.interactiveManager) { + const decision = await this.interactiveManager.promptRevokedKey( + toolId, domain, publicKeyPem, { developer_name: developerName } + ); + return decision === UserDecision.TEMPORARY_ACCEPT; + } + return false; + } + // Automatic mode without force prompt if (this.mode === PinningMode.AUTOMATIC && !forcePrompt) { return this.pinKey(toolId, publicKeyPem, domain, developerName); @@ -419,8 +497,17 @@ export class KeyPinning { * @private */ async _handleKeyChange(toolId, domain, currentKeyPem, newKeyPem, developerName) { - // Note: In a full implementation, you'd check revocation here - + // Revocation check on the replacement key (fail-closed). + if (!(await this._revocationChecker(newKeyPem, domain))) { + if (this.interactiveManager) { + const decision = await this.interactiveManager.promptRevokedKey( + toolId, domain, newKeyPem, { developer_name: developerName } + ); + return decision === UserDecision.TEMPORARY_ACCEPT; + } + return false; + } + // In strict mode, always reject key changes if (this.mode === PinningMode.STRICT) { return false; @@ -428,22 +515,21 @@ export class KeyPinning { // Interactive prompt for key change if (this.interactiveManager) { - const currentKeyInfo = this.getKeyInfo(toolId); - // Note: In a full implementation, you'd fetch developer info here + const currentKeyInfo = this.getKeyInfo(toolId, domain); const developerInfo = { developer_name: developerName }; - + const decision = await this.interactiveManager.promptKeyChange( toolId, domain, currentKeyPem, newKeyPem, currentKeyInfo, developerInfo ); - + if (decision === UserDecision.ACCEPT) { // Remove old key and pin new one - this.removePinnedKey(toolId); + this.removePinnedKey(toolId, domain); return this.pinKey(toolId, newKeyPem, domain, developerName); } else if (decision === UserDecision.ALWAYS_TRUST) { this.setDomainPolicy(domain, PinningPolicy.ALWAYS_TRUST); - this.removePinnedKey(toolId); + this.removePinnedKey(toolId, domain); return this.pinKey(toolId, newKeyPem, domain, developerName); } else if (decision === UserDecision.NEVER_TRUST) { this.setDomainPolicy(domain, PinningPolicy.NEVER_TRUST); diff --git a/javascript/src/utils.js b/javascript/src/utils.js index 41f1843..36440f1 100644 --- a/javascript/src/utils.js +++ b/javascript/src/utils.js @@ -5,6 +5,7 @@ import { SchemaPinCore } from './core.js'; import { KeyManager, SignatureManager } from './crypto.js'; import { PublicKeyDiscovery } from './discovery.js'; +import { validateDomain } from './domain.js'; import { KeyPinning } from './pinning.js'; /** @@ -34,6 +35,10 @@ export class SchemaSigningWorkflow { /** * High-level workflow for clients to verify schemas. + * + * @deprecated Built on the legacy `KeyPinning` store. Prefer + * `verifySchemaWithResolver` with a `KeyPinStore`, which pins by fingerprint + * only after a successful verification and returns a structured result. */ export class SchemaVerificationWorkflow { /** @@ -66,60 +71,62 @@ export class SchemaVerificationWorkflow { }; try { - // Check for pinned key - const pinnedKeyPem = this.pinning.getPinnedKey(toolId); + domain = validateDomain(domain); + + // Check for a key pinned for this tool at this domain + const pinnedKeyPem = this.pinning.getPinnedKey(toolId, domain); let publicKey; + let publicKeyPem; + let shouldPin = false; if (pinnedKeyPem) { - // Use pinned key, but check if it's been revoked + // Use pinned key, but check if it's been revoked (fail-closed) const isNotRevoked = await PublicKeyDiscovery.validateKeyNotRevoked(pinnedKeyPem, domain); if (!isNotRevoked) { - result.error = 'Pinned public key has been revoked'; + result.error = 'Pinned public key has been revoked or its status is unavailable'; return result; } - + publicKey = KeyManager.loadPublicKeyPem(pinnedKeyPem); + publicKeyPem = pinnedKeyPem; result.pinned = true; } else { // First use - discover key - const publicKeyPem = await this.discovery.getPublicKeyPem(domain); + publicKeyPem = await this.discovery.getPublicKeyPem(domain); if (!publicKeyPem) { result.error = 'Could not discover public key'; return result; } - // Check if key is revoked + // Check if key is revoked (fail-closed) const isNotRevoked = await PublicKeyDiscovery.validateKeyNotRevoked(publicKeyPem, domain); if (!isNotRevoked) { - result.error = 'Public key has been revoked'; + result.error = 'Public key has been revoked or its status is unavailable'; return result; } publicKey = KeyManager.loadPublicKeyPem(publicKeyPem); result.first_use = true; result.developer_info = await this.discovery.getDeveloperInfo(domain); - - // Auto-pin if requested - if (autoPin) { - let developerName = null; - if (result.developer_info) { - developerName = result.developer_info.developer_name; - } - - this.pinning.pinKey(toolId, publicKeyPem, domain, developerName); - result.pinned = true; - } + shouldPin = autoPin; } - // Verify signature + // Verify signature before anything is pinned const schemaHash = SchemaPinCore.canonicalizeAndHash(schema); result.valid = SignatureManager.verifySchemaSignature( schemaHash, signatureB64, publicKey ); + // Auto-pin only after a successful verification + if (result.valid && shouldPin) { + const developerName = result.developer_info ? result.developer_info.developer_name : null; + this.pinning.pinKey(toolId, publicKeyPem, domain, developerName); + result.pinned = true; + } + // Update verification timestamp if valid and pinned if (result.valid && result.pinned) { - this.pinning.updateLastVerified(toolId); + this.pinning.updateLastVerified(toolId, domain); } } catch (error) { diff --git a/javascript/tests/interactive.test.js b/javascript/tests/interactive.test.js index 8e26556..875633e 100644 --- a/javascript/tests/interactive.test.js +++ b/javascript/tests/interactive.test.js @@ -17,6 +17,10 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from ' import { join, dirname } from 'path'; import { tmpdir } from 'os'; +// The legacy interactive flows check revocation fail-closed; stub the +// network lookup so these tests stay offline. +const NOT_REVOKED = { revocationChecker: async () => true }; + // Mock function helper function createMockFunction() { const calls = []; @@ -163,7 +167,7 @@ describe('KeyPinning Interactive Functionality', () => { const dbPath = join(tempDir, 'test_pinning.json'); try { - const pinning = new KeyPinning(dbPath, PinningMode.AUTOMATIC); + const pinning = new KeyPinning(dbPath, PinningMode.AUTOMATIC, null, NOT_REVOKED); const result = await pinning.interactivePinKey( 'test-tool', @@ -192,7 +196,7 @@ describe('KeyPinning Interactive Functionality', () => { displaySecurityWarning: createMockFunction() }; - const pinning = new KeyPinning(dbPath, PinningMode.INTERACTIVE, mockHandler); + const pinning = new KeyPinning(dbPath, PinningMode.INTERACTIVE, mockHandler, NOT_REVOKED); // Mock the interactive manager const mockPrompt = createMockFunction(); @@ -220,7 +224,7 @@ describe('KeyPinning Interactive Functionality', () => { const dbPath = join(tempDir, 'test_pinning.json'); try { - const pinning = new KeyPinning(dbPath, PinningMode.INTERACTIVE); + const pinning = new KeyPinning(dbPath, PinningMode.INTERACTIVE, null, NOT_REVOKED); // Set always trust policy pinning.setDomainPolicy('example.com', PinningPolicy.ALWAYS_TRUST); @@ -246,7 +250,7 @@ describe('KeyPinning Interactive Functionality', () => { const dbPath = join(tempDir, 'test_pinning.json'); try { - const pinning = new KeyPinning(dbPath, PinningMode.INTERACTIVE); + const pinning = new KeyPinning(dbPath, PinningMode.INTERACTIVE, null, NOT_REVOKED); // Set never trust policy pinning.setDomainPolicy('example.com', PinningPolicy.NEVER_TRUST); @@ -272,7 +276,7 @@ describe('KeyPinning Interactive Functionality', () => { const dbPath = join(tempDir, 'test_pinning.json'); try { - const pinning = new KeyPinning(dbPath, PinningMode.STRICT); + const pinning = new KeyPinning(dbPath, PinningMode.STRICT, null, NOT_REVOKED); // Pin initial key const initialKey = '-----BEGIN PUBLIC KEY-----\ninitial\n-----END PUBLIC KEY-----'; @@ -302,7 +306,7 @@ describe('Domain Policy Management', () => { const dbPath = join(tempDir, 'test_pinning.json'); try { - const pinning = new KeyPinning(dbPath); + const pinning = new KeyPinning(dbPath, PinningMode.AUTOMATIC, null, NOT_REVOKED); assert.strictEqual(pinning.getDomainPolicy('example.com'), PinningPolicy.DEFAULT); @@ -323,10 +327,10 @@ describe('Domain Policy Management', () => { const dbPath = join(tempDir, 'test_pinning.json'); try { - const pinning1 = new KeyPinning(dbPath); + const pinning1 = new KeyPinning(dbPath, PinningMode.AUTOMATIC, null, NOT_REVOKED); pinning1.setDomainPolicy('example.com', PinningPolicy.ALWAYS_TRUST); - const pinning2 = new KeyPinning(dbPath); + const pinning2 = new KeyPinning(dbPath, PinningMode.AUTOMATIC, null, NOT_REVOKED); assert.strictEqual(pinning2.getDomainPolicy('example.com'), PinningPolicy.ALWAYS_TRUST); } finally { if (existsSync(dbPath)) unlinkSync(dbPath); diff --git a/javascript/tests/pinning-store.test.js b/javascript/tests/pinning-store.test.js new file mode 100644 index 0000000..f24424f --- /dev/null +++ b/javascript/tests/pinning-store.test.js @@ -0,0 +1,91 @@ +/** + * Regression tests for the legacy KeyPinning store (SP-15). + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, readdirSync, statSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { KeyPinning, PinningMode, PinStoreCorruptError } from '../src/pinning.js'; +import { UserDecision } from '../src/interactive.js'; + +const PEM_A = '-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----'; +const PEM_B = '-----BEGIN PUBLIC KEY-----\nBBBB\n-----END PUBLIC KEY-----'; +const NOT_REVOKED = { revocationChecker: async () => true }; + +function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'pin-store-')); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('legacy KeyPinning store (SP-15)', () => { + test('pins are bound to the domain', () => withTmp((dir) => { + const store = new KeyPinning(join(dir, 'pins.json'), PinningMode.AUTOMATIC, null, NOT_REVOKED); + assert.equal(store.pinKey('calculator', PEM_A, 'evil.com'), true); + assert.equal(store.getPinnedKey('calculator', 'good.com'), null); + assert.equal(store.isKeyPinned('calculator', 'good.com'), false); + assert.equal(store.getPinnedKey('calculator', 'evil.com'), PEM_A); + assert.equal(store.pinKey('calculator', PEM_B, 'good.com'), true); + assert.equal(store.getPinnedKey('calculator', 'good.com'), PEM_B); + // Deprecated domain-less lookup refuses to guess between domains. + assert.equal(store.getPinnedKey('calculator'), null); + assert.equal(store.removePinnedKey('calculator', 'evil.com'), true); + assert.equal(store.getPinnedKey('calculator'), PEM_B); + const listed = store.listPinnedKeys(); + assert.deepEqual(listed.map((k) => [k.tool_id, k.domain]), [['calculator', 'good.com']]); + })); + + test('a corrupt store is a hard error, not a silent reset', () => withTmp((dir) => { + const path = join(dir, 'pins.json'); + writeFileSync(path, '{"pinned_keys": {"calc@example.com": {"public_key_pem": "x", "domain": "example.com"}}', 'utf8'); + assert.throws(() => new KeyPinning(path, PinningMode.AUTOMATIC, null, NOT_REVOKED), PinStoreCorruptError); + writeFileSync(path, '[]', 'utf8'); + assert.throws(() => new KeyPinning(path, PinningMode.AUTOMATIC, null, NOT_REVOKED), PinStoreCorruptError); + })); + + test('legacy layouts are migrated to toolId@domain keys', () => withTmp((dir) => { + const path = join(dir, 'pins.json'); + writeFileSync(path, JSON.stringify({ + pinned_keys: { calc: { public_key_pem: PEM_A, domain: 'example.com', developer_name: null, pinned_at: '2026-01-01T00:00:00.000Z', last_verified: null } }, + domain_policies: {} + }), 'utf8'); + const store = new KeyPinning(path, PinningMode.AUTOMATIC, null, NOT_REVOKED); + assert.equal(store.getPinnedKey('calc', 'example.com'), PEM_A); + const onDisk = JSON.parse(readFileSync(path, 'utf8')); + assert.ok('calc@example.com' in onDisk.pinned_keys); + assert.ok(!('calc' in onDisk.pinned_keys)); + })); + + test('writes are atomic and owner-only', () => withTmp((dir) => { + const path = join(dir, 'pins.json'); + const store = new KeyPinning(path, PinningMode.AUTOMATIC, null, NOT_REVOKED); + store.pinKey('calc', PEM_A, 'example.com'); + assert.deepEqual(readdirSync(dir), ['pins.json'], 'no temp files left behind'); + if (process.platform !== 'win32') { + assert.equal((statSync(path).mode & 0o777).toString(8), '600'); + } + })); + + test('interactive flows check revocation before pinning', async () => { + const dir = mkdtempSync(join(tmpdir(), 'pin-store-')); + try { + const revoked = { revocationChecker: async () => false }; + const store = new KeyPinning(join(dir, 'pins.json'), PinningMode.AUTOMATIC, null, revoked); + assert.equal(await store.interactivePinKey('calc', PEM_A, 'example.com', 'Dev'), false); + assert.equal(store.isKeyPinned('calc', 'example.com'), false); + // With an interactive handler the user may temporarily accept, but + // the key is still never pinned. + const handler = { promptUser: async () => UserDecision.TEMPORARY_ACCEPT }; + const interactive = new KeyPinning(join(dir, 'pins2.json'), PinningMode.INTERACTIVE, handler, revoked); + assert.equal(await interactive.interactivePinKey('calc', PEM_A, 'example.com', 'Dev'), true); + assert.equal(interactive.isKeyPinned('calc', 'example.com'), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/python/schemapin/pinning.py b/python/schemapin/pinning.py index a44b19e..900832c 100644 --- a/python/schemapin/pinning.py +++ b/python/schemapin/pinning.py @@ -1,7 +1,21 @@ -"""Key pinning storage and management for Trust-On-First-Use (TOFU).""" +"""Key pinning storage and management for Trust-On-First-Use (TOFU). + +.. deprecated:: 1.4.0 + :class:`KeyPinning` is the legacy PEM-by-tool store used by + :class:`~schemapin.utils.SchemaVerificationWorkflow`. New code should use + :class:`~schemapin.verification.KeyPinStore` (fingerprint pins keyed by + ``tool_id@domain``) with :func:`~schemapin.verification.verify_schema_offline`; + this class is retained as a persistence backend for existing deployments. + +Pins are keyed by ``(tool_id, domain)``. A pin made for one domain is never +returned for another, so a key pinned for ``evil.com`` cannot satisfy a +verification for ``good.com`` that happens to reuse the same tool id. +""" import json +import os import sqlite3 +import warnings from datetime import UTC, datetime from enum import Enum from pathlib import Path @@ -41,6 +55,12 @@ def __init__(self, mode: Pinning operation mode (automatic, interactive, strict) interactive_handler: Handler for interactive prompts """ + warnings.warn( + "KeyPinning is the legacy PEM-by-tool pin store; prefer " + "schemapin.verification.KeyPinStore with verify_schema_offline", + DeprecationWarning, + stacklevel=2, + ) if db_path is None: db_path = str(Path.home() / '.schemapin' / 'pinned_keys.db') @@ -51,20 +71,45 @@ def __init__(self, self._init_database() def _ensure_db_directory(self) -> None: - """Ensure database directory exists.""" - Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) + """Ensure database directory exists (owner-only).""" + Path(self.db_path).parent.mkdir(parents=True, exist_ok=True, mode=0o700) def _init_database(self) -> None: - """Initialize database schema.""" + """Initialize database schema, migrating legacy single-key tables.""" with sqlite3.connect(self.db_path) as conn: + row = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='pinned_keys'" + ).fetchone() + if row and "PRIMARY KEY (tool_id, domain)" not in row[0]: + # Legacy schema keyed by tool_id only: rebuild with the + # composite key, preserving every existing row. + conn.execute("ALTER TABLE pinned_keys RENAME TO pinned_keys_legacy") + conn.execute(''' + CREATE TABLE pinned_keys ( + tool_id TEXT NOT NULL, + public_key_pem TEXT NOT NULL, + domain TEXT NOT NULL, + developer_name TEXT, + pinned_at TEXT NOT NULL, + last_verified TEXT, + PRIMARY KEY (tool_id, domain) + ) + ''') + conn.execute(''' + INSERT OR IGNORE INTO pinned_keys + SELECT tool_id, public_key_pem, domain, developer_name, pinned_at, last_verified + FROM pinned_keys_legacy + ''') + conn.execute("DROP TABLE pinned_keys_legacy") conn.execute(''' CREATE TABLE IF NOT EXISTS pinned_keys ( - tool_id TEXT PRIMARY KEY, + tool_id TEXT NOT NULL, public_key_pem TEXT NOT NULL, domain TEXT NOT NULL, developer_name TEXT, pinned_at TEXT NOT NULL, - last_verified TEXT + last_verified TEXT, + PRIMARY KEY (tool_id, domain) ) ''') @@ -77,6 +122,18 @@ def _init_database(self) -> None: ) ''') conn.commit() + try: + os.chmod(self.db_path, 0o600) + except OSError: + pass + + @staticmethod + def _where(tool_id: str, domain: Optional[str]) -> tuple: + """Build the lookup predicate; ``domain=None`` is the deprecated + tool-id-only lookup kept for callers that predate domain binding.""" + if domain is None: + return "tool_id = ?", (tool_id,) + return "tool_id = ? AND domain = ?", (tool_id, domain) def pin_key( self, @@ -115,52 +172,59 @@ def pin_key( except sqlite3.IntegrityError: return False - def get_pinned_key(self, tool_id: str) -> Optional[str]: + def get_pinned_key(self, tool_id: str, domain: Optional[str] = None) -> Optional[str]: """ - Get pinned public key for tool. + Get the pinned public key for a tool at a domain. Args: tool_id: Tool identifier + domain: Tool provider domain. Always pass it: a pin recorded for + a different domain is never returned. ``None`` performs the + deprecated tool-id-only lookup and returns ``None`` when the + tool id is pinned for more than one domain. Returns: PEM-encoded public key if pinned, None otherwise """ + where, params = self._where(tool_id, domain) with sqlite3.connect(self.db_path) as conn: - cursor = conn.execute( - 'SELECT public_key_pem FROM pinned_keys WHERE tool_id = ?', - (tool_id,) - ) - result = cursor.fetchone() - return result[0] if result else None - - def is_key_pinned(self, tool_id: str) -> bool: + rows = conn.execute( + f'SELECT public_key_pem FROM pinned_keys WHERE {where}', params + ).fetchall() + if len(rows) != 1: + return None + return rows[0][0] + + def is_key_pinned(self, tool_id: str, domain: Optional[str] = None) -> bool: """ - Check if key is pinned for tool. + Check if a key is pinned for a tool at a domain. Args: tool_id: Tool identifier + domain: Tool provider domain (see :meth:`get_pinned_key`) Returns: True if key is pinned, False otherwise """ - return self.get_pinned_key(tool_id) is not None + return self.get_pinned_key(tool_id, domain) is not None - def update_last_verified(self, tool_id: str) -> bool: + def update_last_verified(self, tool_id: str, domain: Optional[str] = None) -> bool: """ Update last verification timestamp for tool. Args: tool_id: Tool identifier + domain: Tool provider domain Returns: True if updated successfully, False if tool not found """ + where, params = self._where(tool_id, domain) with sqlite3.connect(self.db_path) as conn: - cursor = conn.execute(''' - UPDATE pinned_keys - SET last_verified = ? - WHERE tool_id = ? - ''', (datetime.now(UTC).isoformat(), tool_id)) + cursor = conn.execute( + f'UPDATE pinned_keys SET last_verified = ? WHERE {where}', + (datetime.now(UTC).isoformat(), *params), + ) conn.commit() return cursor.rowcount > 0 @@ -180,44 +244,46 @@ def list_pinned_keys(self) -> List[Dict[str, str]]: ''') return [dict(row) for row in cursor.fetchall()] - def remove_pinned_key(self, tool_id: str) -> bool: + def remove_pinned_key(self, tool_id: str, domain: Optional[str] = None) -> bool: """ Remove pinned key for tool. Args: tool_id: Tool identifier + domain: Tool provider domain (``None`` removes every domain's pin) Returns: True if removed successfully, False if not found """ + where, params = self._where(tool_id, domain) with sqlite3.connect(self.db_path) as conn: - cursor = conn.execute( - 'DELETE FROM pinned_keys WHERE tool_id = ?', - (tool_id,) - ) + cursor = conn.execute(f'DELETE FROM pinned_keys WHERE {where}', params) conn.commit() return cursor.rowcount > 0 - def get_key_info(self, tool_id: str) -> Optional[Dict[str, str]]: + def get_key_info(self, tool_id: str, domain: Optional[str] = None) -> Optional[Dict[str, str]]: """ Get complete information about pinned key. Args: tool_id: Tool identifier + domain: Tool provider domain (see :meth:`get_pinned_key`) Returns: Dictionary with key information if found, None otherwise """ + where, params = self._where(tool_id, domain) with sqlite3.connect(self.db_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(''' + rows = conn.execute(f''' SELECT tool_id, public_key_pem, domain, developer_name, pinned_at, last_verified FROM pinned_keys - WHERE tool_id = ? - ''', (tool_id,)) - result = cursor.fetchone() - return dict(result) if result else None + WHERE {where} + ''', params).fetchall() + if len(rows) != 1: + return None + return dict(rows[0]) def export_pinned_keys(self) -> str: """ @@ -229,7 +295,7 @@ def export_pinned_keys(self) -> str: keys = self.list_pinned_keys() # Add public key data for export for key_info in keys: - key_info['public_key_pem'] = self.get_pinned_key(key_info['tool_id']) + key_info['public_key_pem'] = self.get_pinned_key(key_info['tool_id'], key_info['domain']) return json.dumps(keys, indent=2) @@ -249,8 +315,8 @@ def import_pinned_keys(self, json_data: str, overwrite: bool = False) -> int: imported = 0 for key_info in keys: - if overwrite and self.is_key_pinned(key_info['tool_id']): - self.remove_pinned_key(key_info['tool_id']) + if overwrite and self.is_key_pinned(key_info['tool_id'], key_info['domain']): + self.remove_pinned_key(key_info['tool_id'], key_info['domain']) if self.pin_key( key_info['tool_id'], @@ -337,12 +403,12 @@ def interactive_pin_key(self, elif domain_policy == PinningPolicy.ALWAYS_TRUST: return self.pin_key(tool_id, public_key_pem, domain, developer_name) - # Check if key is already pinned - existing_key = self.get_pinned_key(tool_id) + # Check if key is already pinned for this tool at this domain + existing_key = self.get_pinned_key(tool_id, domain) if existing_key: if existing_key == public_key_pem: # Same key, just update verification time - self.update_last_verified(tool_id) + self.update_last_verified(tool_id, domain) return True else: # Different key - handle key change @@ -418,7 +484,7 @@ def _handle_key_change(self, # Interactive prompt for key change if self.interactive_manager: - current_key_info = self.get_key_info(tool_id) + current_key_info = self.get_key_info(tool_id, domain) developer_info = PublicKeyDiscovery.get_developer_info(domain) decision = self.interactive_manager.prompt_key_change( @@ -428,11 +494,11 @@ def _handle_key_change(self, if decision == UserDecision.ACCEPT: # Remove old key and pin new one - self.remove_pinned_key(tool_id) + self.remove_pinned_key(tool_id, domain) return self.pin_key(tool_id, new_key_pem, domain, developer_name) elif decision == UserDecision.ALWAYS_TRUST: self.set_domain_policy(domain, PinningPolicy.ALWAYS_TRUST) - self.remove_pinned_key(tool_id) + self.remove_pinned_key(tool_id, domain) return self.pin_key(tool_id, new_key_pem, domain, developer_name) elif decision == UserDecision.NEVER_TRUST: self.set_domain_policy(domain, PinningPolicy.NEVER_TRUST) diff --git a/python/schemapin/utils.py b/python/schemapin/utils.py index bd53ec7..265794f 100644 --- a/python/schemapin/utils.py +++ b/python/schemapin/utils.py @@ -5,6 +5,7 @@ from .core import SchemaPinCore from .crypto import KeyManager, SignatureManager from .discovery import PublicKeyDiscovery +from .domain import InvalidDomainError, validate_domain from .pinning import KeyPinning @@ -35,7 +36,15 @@ def sign_schema(self, schema: Dict[str, Any]) -> str: class SchemaVerificationWorkflow: - """High-level workflow for clients to verify schemas.""" + """High-level workflow for clients to verify schemas. + + .. deprecated:: 1.4.0 + Built on the legacy :class:`~schemapin.pinning.KeyPinning` store. + Prefer :func:`~schemapin.verification.verify_schema_with_resolver` + with a :class:`~schemapin.verification.KeyPinStore`, which pins by + fingerprint only after a successful verification and reports a + structured :class:`~schemapin.verification.VerificationResult`. + """ def __init__(self, pinning_db_path: Optional[str] = None): """ @@ -77,16 +86,20 @@ def verify_schema( } try: - # Check for pinned key - pinned_key_pem = self.pinning.get_pinned_key(tool_id) + domain = validate_domain(domain) + + # Check for a key pinned for this tool at this domain + pinned_key_pem = self.pinning.get_pinned_key(tool_id, domain) + should_pin = False if pinned_key_pem: - # Use pinned key, but check if it's been revoked + # Use pinned key, but check if it's been revoked (fail-closed) if not self.discovery.validate_key_not_revoked(pinned_key_pem, domain): - result['error'] = 'Pinned public key has been revoked' + result['error'] = 'Pinned public key has been revoked or its status is unavailable' return result public_key = KeyManager.load_public_key_pem(pinned_key_pem) + public_key_pem = pinned_key_pem result['pinned'] = True else: # First use - discover key @@ -95,34 +108,36 @@ def verify_schema( result['error'] = 'Could not discover public key' return result - # Check if key is revoked + # Check if key is revoked (fail-closed) if not self.discovery.validate_key_not_revoked(public_key_pem, domain): - result['error'] = 'Public key has been revoked' + result['error'] = 'Public key has been revoked or its status is unavailable' return result public_key = KeyManager.load_public_key_pem(public_key_pem) result['first_use'] = True result['developer_info'] = self.discovery.get_developer_info(domain) + should_pin = auto_pin - # Auto-pin if requested - if auto_pin: - developer_name = None - if result['developer_info']: - developer_name = result['developer_info']['developer_name'] - - self.pinning.pin_key(tool_id, public_key_pem, domain, developer_name) - result['pinned'] = True - - # Verify signature + # Verify signature before anything is pinned schema_hash = SchemaPinCore.canonicalize_and_hash(schema) result['valid'] = SignatureManager.verify_schema_signature( schema_hash, signature_b64, public_key ) + # Auto-pin only after a successful verification + if result['valid'] and should_pin: + developer_name = None + if result['developer_info']: + developer_name = result['developer_info']['developer_name'] + self.pinning.pin_key(tool_id, public_key_pem, domain, developer_name) + result['pinned'] = True + # Update verification timestamp if valid and pinned if result['valid'] and result['pinned']: - self.pinning.update_last_verified(tool_id) + self.pinning.update_last_verified(tool_id, domain) + except InvalidDomainError as e: + result['error'] = f'Invalid domain: {e}' except Exception as e: result['error'] = str(e) diff --git a/python/tests/test_pinning_store.py b/python/tests/test_pinning_store.py new file mode 100644 index 0000000..fb19a80 --- /dev/null +++ b/python/tests/test_pinning_store.py @@ -0,0 +1,71 @@ +"""Regression tests for the legacy KeyPinning store (SP-15).""" + +import os +import sqlite3 +import warnings + +import pytest + +from schemapin.pinning import KeyPinning, PinningMode + +PEM_A = "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----" +PEM_B = "-----BEGIN PUBLIC KEY-----\nBBBB\n-----END PUBLIC KEY-----" + + +def _store(tmp_path): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return KeyPinning(db_path=str(tmp_path / "pins.db"), mode=PinningMode.AUTOMATIC) + + +def test_pins_are_bound_to_domain(tmp_path): + store = _store(tmp_path) + assert store.pin_key("calculator", PEM_A, "evil.com") + # The attacker's pin must not satisfy a lookup for a different domain. + assert store.get_pinned_key("calculator", "good.com") is None + assert not store.is_key_pinned("calculator", "good.com") + assert store.get_pinned_key("calculator", "evil.com") == PEM_A + # The same tool id can be pinned independently per domain. + assert store.pin_key("calculator", PEM_B, "good.com") + assert store.get_pinned_key("calculator", "good.com") == PEM_B + # The deprecated domain-less lookup refuses to guess between domains. + assert store.get_pinned_key("calculator") is None + assert store.remove_pinned_key("calculator", "evil.com") + assert store.get_pinned_key("calculator") == PEM_B + + +def test_legacy_schema_is_migrated(tmp_path): + db = tmp_path / "legacy.db" + with sqlite3.connect(db) as conn: + conn.execute( + """CREATE TABLE pinned_keys ( + tool_id TEXT PRIMARY KEY, public_key_pem TEXT NOT NULL, domain TEXT NOT NULL, + developer_name TEXT, pinned_at TEXT NOT NULL, last_verified TEXT)""" + ) + conn.execute( + "INSERT INTO pinned_keys VALUES (?, ?, ?, ?, ?, NULL)", + ("calc", PEM_A, "example.com", "Dev", "2026-01-01T00:00:00+00:00"), + ) + conn.commit() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + store = KeyPinning(db_path=str(db)) + assert store.get_pinned_key("calc", "example.com") == PEM_A + with sqlite3.connect(db) as conn: + sql = conn.execute( + "SELECT sql FROM sqlite_master WHERE name='pinned_keys'" + ).fetchone()[0] + assert "PRIMARY KEY (tool_id, domain)" in sql + # A second domain for the same tool now coexists. + assert store.pin_key("calc", PEM_B, "other.com") + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions") +def test_store_file_is_owner_only(tmp_path): + store = _store(tmp_path) + assert oct(os.stat(store.db_path).st_mode & 0o777) == "0o600" + + +def test_constructor_emits_deprecation_warning(tmp_path): + with pytest.warns(DeprecationWarning): + KeyPinning(db_path=str(tmp_path / "x.db")) diff --git a/python/tools/verify_schema.py b/python/tools/verify_schema.py index ed26074..173c733 100644 --- a/python/tools/verify_schema.py +++ b/python/tools/verify_schema.py @@ -82,7 +82,7 @@ def verify_with_discovery( interactive_manager = InteractivePinningManager() # Check if key is already pinned - pinned_key = workflow.pinning.get_pinned_key(tool_id) + pinned_key = workflow.pinning.get_pinned_key(tool_id, domain) if not pinned_key: # First time - prompt user From 19b9c3f99fcac88606b88f79a566606b55de6545 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 15:16:55 -0700 Subject: [PATCH 07/14] Authenticate and harden the reference .well-known server 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. --- server/README.md | 76 ++-- server/config.json | 17 +- server/tests/test_server.py | 153 ++++++++ server/well_known_server.py | 726 ++++++++++++++++++++---------------- 4 files changed, 615 insertions(+), 357 deletions(-) create mode 100644 server/tests/test_server.py diff --git a/server/README.md b/server/README.md index 997b4a4..e558579 100644 --- a/server/README.md +++ b/server/README.md @@ -1,17 +1,25 @@ # SchemaPin .well-known Server -Production-ready HTTP server for serving SchemaPin `.well-known/schemapin.json` endpoints with support for multiple developers, key management, and revocation lists. +Reference HTTP server for serving SchemaPin `.well-known/schemapin.json` endpoints for one or more developer domains, with authenticated key management and revocation lists. It is a reference implementation: review the security model below before exposing it, and always terminate TLS in front of it. ## Features -- **Multiple Developer Support**: Serve keys for multiple developers/domains -- **Key Management**: Upload, rotate, and revoke keys via REST API +- **Multiple Developer Support**: Serve keys for multiple developer domains, selected by the request `Host` (RFC 8615 layout) +- **Authenticated Key Management**: Upload, rotate, and revoke keys via a bearer-token REST API that is disabled by default +- **Safe Rotation**: Uploading a new key moves the previous key to `revoked_keys`; an upload never wipes existing revocations and a revoked key cannot be re-uploaded - **Revocation Lists**: Maintain and serve key revocation information -- **CORS Support**: Browser-compatible with configurable CORS policies -- **Rate Limiting**: Built-in protection against abuse -- **Logging & Monitoring**: Comprehensive logging and metrics endpoints -- **Docker Support**: Easy containerized deployment -- **Auto-Discovery**: Automatic setup with demo data +- **CORS Support**: Configurable origins; credentials are never combined with a wildcard origin +- **Rate Limiting**: Per-client limit enforced on every request +- **Logging & Monitoring**: Request logging and a metrics endpoint +- **Docker Support**: Containerized deployment + +## Security model + +- Every mutation under `/api/` requires `Authorization: Bearer `. The token comes from `security.admin_token` in `config.json` or the `SCHEMAPIN_ADMIN_TOKEN` environment variable, and `features.admin_api` must be `true`; otherwise the mutation routes return 404. +- `/.well-known/schemapin.json` resolves the developer from the request `Host` header. There is no "first developer" fallback: an unknown host is a 404, so a client resolving `bob.example.com` is never handed Alice's key. Each developer domain must therefore point at this server (or at a reverse proxy that preserves `Host`). +- `/.well-known/schemapin/{domain}.json` is a non-standard multi-tenant path for deployments that rewrite per host; clients following the specification never request it directly. +- Only ECDSA P-256 public keys are accepted. +- Defaults bind to `127.0.0.1` with `debug` and `reload` off. ## Quick Start @@ -32,7 +40,10 @@ python well_known_server.py # Health check curl http://localhost:8000/health -# Get .well-known for specific developer +# Get .well-known for a developer (selected by Host) +curl -H "Host: alice.example.com" http://localhost:8000/.well-known/schemapin.json + +# Non-standard multi-tenant path (behind a per-host rewrite) curl http://localhost:8000/.well-known/schemapin/alice.example.com.json # List all developers @@ -57,8 +68,8 @@ docker run -p 8000:8000 -v $(pwd)/keys:/app/keys schemapin-server | Endpoint | Method | Description | |----------|--------|-------------| -| `/.well-known/schemapin/{domain}.json` | GET | Get SchemaPin data for specific domain | -| `/.well-known/schemapin.json` | GET | Get default SchemaPin data (first enabled developer) | +| `/.well-known/schemapin.json` | GET | Discovery document for the developer matching the request `Host` (RFC 8615) | +| `/.well-known/schemapin/{domain}.json` | GET | Non-standard multi-tenant path for per-host rewrites | ### Management API @@ -67,8 +78,8 @@ docker run -p 8000:8000 -v $(pwd)/keys:/app/keys schemapin-server | `/health` | GET | Health check | | `/api/developers` | GET | List all developers | | `/api/developers/{domain}` | GET | Get developer details | -| `/api/developers/{domain}/keys` | POST | Upload new key | -| `/api/developers/{domain}/revoke` | POST | Revoke key | +| `/api/developers/{domain}/keys` | POST | Upload or rotate a key (bearer token, `features.admin_api`) | +| `/api/developers/{domain}/revoke` | POST | Revoke a key (bearer token, `features.admin_api`) | | `/api/metrics` | GET | Server metrics | ## Configuration @@ -78,13 +89,21 @@ Server configuration is managed via [`config.json`](config.json): ```json { "server": { - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 8000, - "debug": true + "debug": false, + "reload": false }, "cors": { "allow_origins": ["*"], - "allow_credentials": true + "allow_credentials": false + }, + "security": { + "rate_limit": { "requests_per_minute": 60 }, + "admin_token": null + }, + "features": { + "admin_api": false }, "developers": { "example.com": { @@ -98,9 +117,12 @@ Server configuration is managed via [`config.json`](config.json): ### Key Configuration Options -- **server.host**: Bind address (default: 0.0.0.0) +- **server.host**: Bind address (default: 127.0.0.1; put a TLS-terminating proxy in front before binding wider) - **server.port**: Port number (default: 8000) -- **cors.allow_origins**: Allowed CORS origins +- **cors.allow_origins**: Allowed CORS origins; `allow_credentials` is forced off when this contains `*` +- **security.admin_token**: Bearer token for the management API (or set `SCHEMAPIN_ADMIN_TOKEN`; prefer the environment variable so the token is not stored in the config file) +- **security.rate_limit.requests_per_minute**: Per-client request limit (0 disables) +- **features.admin_api**: Enables the mutation endpoints (default: off) - **storage.keys_directory**: Directory for key storage - **logging.level**: Log level (DEBUG, INFO, WARNING, ERROR) - **developers**: Pre-configured developer domains @@ -122,9 +144,10 @@ Server configuration is managed via [`config.json`](config.json): } ``` -2. **Upload Public Key**: +2. **Upload Public Key** (with `features.admin_api: true` and a token): ```bash curl -X POST http://localhost:8000/api/developers/newdev.example.com/keys \ + -H "Authorization: Bearer $SCHEMAPIN_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", @@ -141,16 +164,18 @@ curl -X POST http://localhost:8000/api/developers/newdev.example.com/keys \ python -m tools.keygen --type ecdsa --output-dir ./new_keys --developer "New Developer" ``` -2. **Upload New Key**: +2. **Upload New Key** (the previous key is moved to `revoked_keys` automatically): ```bash curl -X POST http://localhost:8000/api/developers/newdev.example.com/keys \ + -H "Authorization: Bearer $SCHEMAPIN_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d @new_key_upload.json ``` -3. **Revoke Old Key**: +3. **Revoke a Key Explicitly** (for example after a compromise): ```bash curl -X POST http://localhost:8000/api/developers/newdev.example.com/revoke \ + -H "Authorization: Bearer $SCHEMAPIN_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "fingerprint": "sha256:old_key_fingerprint", @@ -164,6 +189,7 @@ Revoke a compromised key immediately: ```bash curl -X POST http://localhost:8000/api/developers/example.com/revoke \ + -H "Authorization: Bearer $SCHEMAPIN_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "fingerprint": "sha256:compromised_key_fingerprint", @@ -194,9 +220,9 @@ server/ ### Production Deployment -1. **HTTPS Only**: Always use HTTPS in production -2. **Firewall**: Restrict access to management API endpoints -3. **Authentication**: Add authentication for management endpoints +1. **HTTPS Only**: Always terminate TLS in front of the server; SchemaPin clients refuse plaintext discovery +2. **Firewall**: Restrict access to management API endpoints in addition to the bearer token +3. **Authentication**: Keep `features.admin_api` off unless you need remote key management, and supply the token via `SCHEMAPIN_ADMIN_TOKEN` 4. **Rate Limiting**: Configure appropriate rate limits 5. **Monitoring**: Set up monitoring and alerting 6. **Backups**: Regular key backup and recovery procedures @@ -351,7 +377,7 @@ Enable debug logging: ### Testing ```bash -# Test all endpoints +# Run the server tests python -m pytest tests/ # Manual testing diff --git a/server/config.json b/server/config.json index b529953..1c18fa4 100644 --- a/server/config.json +++ b/server/config.json @@ -1,22 +1,23 @@ { "server": { - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 8000, - "debug": true, - "reload": true + "debug": false, + "reload": false }, "cors": { "allow_origins": ["*"], - "allow_credentials": true, - "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "allow_credentials": false, + "allow_methods": ["GET", "OPTIONS"], "allow_headers": ["*"] }, "security": { "rate_limit": { "requests_per_minute": 60 }, + "admin_token": null, "max_key_size": 8192, - "allowed_key_types": ["ECDSA", "RSA"] + "allowed_key_types": ["ECDSA"] }, "storage": { "keys_directory": "./keys", @@ -38,7 +39,7 @@ "enabled": true }, "bob.example.com": { - "name": "Bob Industries", + "name": "Bob Industries", "contact": "security@bob.example.com", "enabled": true }, @@ -55,4 +56,4 @@ "health_checks": true, "admin_api": false } -} \ No newline at end of file +} diff --git a/server/tests/test_server.py b/server/tests/test_server.py new file mode 100644 index 0000000..e16bba9 --- /dev/null +++ b/server/tests/test_server.py @@ -0,0 +1,153 @@ +"""Tests for the reference .well-known server (SP-7, design issue 11).""" + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python")) + +from fastapi.testclient import TestClient # noqa: E402 + +from schemapin.crypto import KeyManager # noqa: E402 +from well_known_server import ServerConfig, create_app # noqa: E402 + + +def _pem(): + priv, pub = KeyManager.generate_keypair() + return KeyManager.export_public_key_pem(pub), KeyManager.calculate_key_fingerprint(pub) + + +@pytest.fixture +def server(tmp_path, monkeypatch): + monkeypatch.delenv("SCHEMAPIN_ADMIN_TOKEN", raising=False) + cfg = { + "storage": {"keys_directory": str(tmp_path / "keys")}, + "logging": {"file": str(tmp_path / "server.log")}, + "developers": { + "alice.example.com": {"name": "Alice", "contact": "a@example.com", "enabled": True}, + "bob.example.com": {"name": "Bob", "contact": "b@example.com", "enabled": True}, + }, + "features": {"admin_api": True}, + "security": {"admin_token": "test-admin-token", "rate_limit": {"requests_per_minute": 1000}}, + "cors": {"allow_origins": ["*"], "allow_credentials": True}, + } + path = tmp_path / "config.json" + path.write_text(json.dumps(cfg)) + config = ServerConfig(path, setup_logging=False) + app = create_app(config) + return TestClient(app, base_url="http://alice.example.com") + + +AUTH = {"Authorization": "Bearer test-admin-token"} + + +def _upload(client, domain, pem, headers=AUTH): + return client.post( + f"/api/developers/{domain}/keys", + json={"public_key_pem": pem, "developer_name": "Alice", "contact": "a@example.com"}, + headers=headers, + ) + + +def test_defaults_are_safe(tmp_path): + path = tmp_path / "config.json" + path.write_text(json.dumps({"developers": {}})) + cfg = ServerConfig(path, setup_logging=False) + assert cfg.config["server"]["host"] == "127.0.0.1" + assert cfg.config["server"]["debug"] is False + assert cfg.config["server"]["reload"] is False + assert cfg.admin_api_enabled is False + assert cfg.admin_token is None + + +def test_mutations_require_bearer_token(server): + pem, _ = _pem() + assert _upload(server, "alice.example.com", pem, headers={}).status_code == 401 + assert _upload(server, "alice.example.com", pem, headers={"Authorization": "Bearer wrong"}).status_code == 401 + assert server.post("/api/developers/alice.example.com/revoke", json={"fingerprint": "x"}).status_code == 401 + assert _upload(server, "alice.example.com", pem).status_code == 200 + + +def test_admin_api_can_be_disabled(tmp_path): + cfg = { + "storage": {"keys_directory": str(tmp_path / "keys")}, + "developers": {"alice.example.com": {"name": "A", "contact": "a@x.com"}}, + "features": {"admin_api": False}, + "security": {"admin_token": "t"}, + } + path = tmp_path / "config.json" + path.write_text(json.dumps(cfg)) + client = TestClient(create_app(ServerConfig(path, setup_logging=False))) + pem, _ = _pem() + assert _upload(client, "alice.example.com", pem, headers={"Authorization": "Bearer t"}).status_code == 404 + + +def test_rotation_preserves_revocations_and_revokes_old_key(server): + pem1, fp1 = _pem() + pem2, fp2 = _pem() + pem3, fp3 = _pem() + assert _upload(server, "alice.example.com", pem1).status_code == 200 + assert server.post("/api/developers/alice.example.com/revoke", json={"fingerprint": "sha256:" + "ab" * 32}, headers=AUTH).status_code == 200 + assert _upload(server, "alice.example.com", pem2).status_code == 200 + info = server.get("/api/developers/alice.example.com").json() + assert info["current_key"]["fingerprint"] == fp2 + assert "sha256:" + "ab" * 32 in info["revoked_keys"], "upload must not wipe revocations" + assert fp1 in info["revoked_keys"], "rotation must revoke the previous key" + # A revoked key cannot be re-uploaded. + assert _upload(server, "alice.example.com", pem1).status_code == 400 + wk = server.get("/.well-known/schemapin.json").json() + assert wk["public_key_pem"] == pem2 + assert fp1 in wk["revoked_keys"] + assert _upload(server, "alice.example.com", pem3).status_code == 200 + + +def test_well_known_is_selected_by_host_with_no_fallback(server): + pem_a, _ = _pem() + pem_b, _ = _pem() + assert _upload(server, "alice.example.com", pem_a).status_code == 200 + assert _upload(server, "bob.example.com", pem_b).status_code == 200 + assert server.get("/.well-known/schemapin.json", headers={"host": "alice.example.com"}).json()["public_key_pem"] == pem_a + assert server.get("/.well-known/schemapin.json", headers={"host": "bob.example.com:8443"}).json()["public_key_pem"] == pem_b + assert server.get("/.well-known/schemapin.json", headers={"host": "carol.example.com"}).status_code == 404 + assert server.get("/.well-known/schemapin.json", headers={"host": "127.0.0.1"}).status_code == 404 + # Multi-tenant path still works and validates the domain. + assert server.get("/.well-known/schemapin/bob.example.com.json").json()["public_key_pem"] == pem_b + assert server.get("/.well-known/schemapin/..%2F..%2Fetc.json").status_code == 404 + + +def test_non_p256_keys_are_rejected(server): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + rsa_pem = ( + rsa.generate_private_key(public_exponent=65537, key_size=2048) + .public_key() + .public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + .decode() + ) + assert _upload(server, "alice.example.com", rsa_pem).status_code == 400 + + +def test_cors_wildcard_never_sends_credentials(server): + r = server.options( + "/.well-known/schemapin.json", + headers={"Origin": "https://evil.example", "Access-Control-Request-Method": "GET"}, + ) + assert r.headers.get("access-control-allow-origin") == "*" + assert "access-control-allow-credentials" not in r.headers + + +def test_rate_limit(tmp_path): + cfg = { + "storage": {"keys_directory": str(tmp_path / "keys")}, + "developers": {}, + "security": {"rate_limit": {"requests_per_minute": 3}}, + } + path = tmp_path / "config.json" + path.write_text(json.dumps(cfg)) + client = TestClient(create_app(ServerConfig(path, setup_logging=False))) + codes = [client.get("/health").status_code for _ in range(5)] + assert codes == [200, 200, 200, 429, 429] diff --git a/server/well_known_server.py b/server/well_known_server.py index abb6f27..4626e46 100644 --- a/server/well_known_server.py +++ b/server/well_known_server.py @@ -2,19 +2,45 @@ """ SchemaPin .well-known Server -Production-ready HTTP server for serving .well-known/schemapin.json endpoints -with support for multiple developers, key management, and revocation lists. +Reference HTTP server for serving ``.well-known/schemapin.json`` endpoints for +one or more developer domains, with authenticated key management and +revocation lists. + +Security model +-------------- + +* Every mutation under ``/api/`` requires a bearer token + (``security.admin_token`` in the config or the ``SCHEMAPIN_ADMIN_TOKEN`` + environment variable) and ``features.admin_api`` must be enabled; + otherwise the mutation routes do not exist. +* Key upload merges into the existing record: a previously published key is + moved to ``revoked_keys`` on rotation and the revocation list is never + wiped by an upload. +* CORS credentials are never combined with a wildcard origin. +* A per-client rate limit (``security.rate_limit.requests_per_minute``) is + enforced on every request. +* ``/.well-known/schemapin.json`` resolves the developer from the request + ``Host`` header (RFC 8615 layout). There is no "first developer" fallback: + an unknown host is a 404, so a client resolving ``bob.example.com`` can + never be handed Alice's key. ``/.well-known/schemapin/{domain}.json`` is a + non-standard multi-tenant path for deployments that rewrite per host. +* The server binds to ``127.0.0.1`` with debug and reload off unless the + configuration says otherwise. """ import json import logging +import os +import secrets import sys -from datetime import datetime +import threading +import time +from collections import deque +from datetime import datetime, timezone from pathlib import Path -from typing import Dict, Any, Optional +from typing import Any, Deque, Dict, Optional -import uvicorn -from fastapi import FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel @@ -22,7 +48,16 @@ # Add parent directory to path to import schemapin sys.path.insert(0, str(Path(__file__).parent.parent / "python")) -from schemapin.utils import create_well_known_response +from schemapin.crypto import KeyManager as SchemaPinKeyManager # noqa: E402 +from schemapin.domain import InvalidDomainError, validate_domain # noqa: E402 +from schemapin.utils import create_well_known_response # noqa: E402 + +SERVER_VERSION = "1.4.0" +ADMIN_TOKEN_ENV = "SCHEMAPIN_ADMIN_TOKEN" + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() # Pydantic models @@ -32,14 +67,6 @@ class DeveloperInfo(BaseModel): enabled: bool = True -class KeyInfo(BaseModel): - public_key_pem: str - fingerprint: str - created_at: datetime - revoked: bool = False - revoked_at: Optional[datetime] = None - - class RevocationRequest(BaseModel): fingerprint: str reason: Optional[str] = None @@ -52,410 +79,461 @@ class KeyUploadRequest(BaseModel): class ServerConfig: - """Server configuration management.""" - - def __init__(self, config_file: Path): + """Server configuration management with safe defaults.""" + + DEFAULTS: Dict[str, Any] = { + "server": {"host": "127.0.0.1", "port": 8000, "debug": False, "reload": False}, + "cors": { + "allow_origins": [], + "allow_credentials": False, + "allow_methods": ["GET", "OPTIONS"], + "allow_headers": ["*"], + }, + "security": {"rate_limit": {"requests_per_minute": 60}, "admin_token": None}, + "storage": {"keys_directory": "./keys"}, + "logging": {"level": "INFO", "file": "./logs/server.log"}, + "developers": {}, + "features": {"admin_api": False}, + } + + def __init__(self, config_file: Path, setup_logging: bool = True): self.config_file = config_file self.config = self.load_config() - self.setup_logging() - + if setup_logging: + self.setup_logging() + def load_config(self) -> Dict[str, Any]: - """Load configuration from file.""" + """Load configuration from file, layering it over the safe defaults.""" if not self.config_file.exists(): raise FileNotFoundError(f"Configuration file not found: {self.config_file}") - - with open(self.config_file, 'r') as f: - return json.load(f) - - def setup_logging(self): + + with open(self.config_file, "r") as f: + loaded = json.load(f) + + config: Dict[str, Any] = json.loads(json.dumps(self.DEFAULTS)) + for section, values in loaded.items(): + if isinstance(values, dict) and isinstance(config.get(section), dict): + config[section].update(values) + else: + config[section] = values + + token = os.environ.get(ADMIN_TOKEN_ENV) + if token: + config["security"]["admin_token"] = token + return config + + @property + def admin_token(self) -> Optional[str]: + return self.config["security"].get("admin_token") or None + + @property + def admin_api_enabled(self) -> bool: + return bool(self.config["features"].get("admin_api", False)) + + def setup_logging(self) -> None: """Set up logging configuration.""" log_config = self.config.get("logging", {}) - - # Create logs directory log_file = Path(log_config.get("file", "./logs/server.log")) log_file.parent.mkdir(parents=True, exist_ok=True) - + logging.basicConfig( level=getattr(logging, log_config.get("level", "INFO")), format=log_config.get("format", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"), - handlers=[ - logging.FileHandler(log_file), - logging.StreamHandler() - ] + handlers=[logging.FileHandler(log_file), logging.StreamHandler()], ) class ServerKeyManager: """Manages developer keys and revocation lists.""" - + def __init__(self, keys_directory: Path): self.keys_dir = keys_directory self.keys_dir.mkdir(parents=True, exist_ok=True) self.logger = logging.getLogger(__name__) - + def get_developer_key_file(self, domain: str) -> Path: - """Get the key file path for a developer domain.""" - return self.keys_dir / f"{domain}.json" - + """Get the key file path for a (validated) developer domain.""" + return self.keys_dir / f"{validate_domain(domain)}.json" + def load_developer_data(self, domain: str) -> Optional[Dict[str, Any]]: """Load developer data from file.""" - key_file = self.get_developer_key_file(domain) - + try: + key_file = self.get_developer_key_file(domain) + except InvalidDomainError: + return None if not key_file.exists(): return None - try: - with open(key_file, 'r') as f: + with open(key_file, "r") as f: return json.load(f) except Exception as e: self.logger.error(f"Error loading developer data for {domain}: {e}") return None - + def save_developer_data(self, domain: str, data: Dict[str, Any]) -> bool: """Save developer data to file.""" - key_file = self.get_developer_key_file(domain) - try: - with open(key_file, 'w') as f: + key_file = self.get_developer_key_file(domain) + with open(key_file, "w") as f: json.dump(data, f, indent=2, default=str) return True except Exception as e: self.logger.error(f"Error saving developer data for {domain}: {e}") return False - - def create_developer(self, domain: str, public_key_pem: str, - developer_name: str, contact: str) -> bool: - """Create a new developer entry.""" + + def create_developer( + self, domain: str, public_key_pem: str, developer_name: str, contact: str + ) -> bool: + """Create or rotate a developer's key. + + Uploading over an existing record *merges*: the previous current key + (if different) is moved to ``revoked_keys`` and existing revocations + are preserved, so a rotation can never un-revoke a compromised key. + """ try: - # Validate public key - from schemapin.crypto import KeyManager as SchemaPinKeyManager public_key = SchemaPinKeyManager.load_public_key_pem(public_key_pem) fingerprint = SchemaPinKeyManager.calculate_key_fingerprint(public_key) - - data = { - "domain": domain, - "developer_name": developer_name, - "contact": contact, - "schema_version": "1.1", - "current_key": { - "public_key_pem": public_key_pem, - "fingerprint": fingerprint, - "created_at": datetime.utcnow().isoformat(), - "revoked": False - }, - "revoked_keys": [], - "created_at": datetime.utcnow().isoformat(), - "updated_at": datetime.utcnow().isoformat() - } - - return self.save_developer_data(domain, data) - except Exception as e: - self.logger.error(f"Error creating developer {domain}: {e}") + self.logger.error(f"Rejected key for {domain}: {e}") + return False + + now = _utcnow() + existing = self.load_developer_data(domain) + revoked_keys = list(existing.get("revoked_keys", [])) if existing else [] + if existing and existing.get("current_key"): + old_fp = existing["current_key"].get("fingerprint") + if old_fp and old_fp != fingerprint and old_fp not in revoked_keys: + revoked_keys.append(old_fp) + if fingerprint in revoked_keys: + self.logger.error(f"Rejected previously revoked key for {domain}") return False - + + data = { + "domain": validate_domain(domain), + "developer_name": developer_name, + "contact": contact, + "schema_version": "1.2", + "current_key": { + "public_key_pem": public_key_pem, + "fingerprint": fingerprint, + "created_at": now, + "revoked": False, + }, + "revoked_keys": revoked_keys, + "created_at": existing.get("created_at", now) if existing else now, + "updated_at": now, + } + return self.save_developer_data(domain, data) + def revoke_key(self, domain: str, fingerprint: str, reason: Optional[str] = None) -> bool: """Revoke a key for a developer.""" data = self.load_developer_data(domain) if not data: return False - + try: - # Check if it's the current key if data["current_key"]["fingerprint"] == fingerprint: data["current_key"]["revoked"] = True - data["current_key"]["revoked_at"] = datetime.utcnow().isoformat() + data["current_key"]["revoked_at"] = _utcnow() if reason: data["current_key"]["revocation_reason"] = reason - - # Add to revoked keys list if not already there + if fingerprint not in data["revoked_keys"]: data["revoked_keys"].append(fingerprint) - - data["updated_at"] = datetime.utcnow().isoformat() - + + data["updated_at"] = _utcnow() return self.save_developer_data(domain, data) - except Exception as e: self.logger.error(f"Error revoking key {fingerprint} for {domain}: {e}") return False - + def get_well_known_response(self, domain: str) -> Optional[Dict[str, Any]]: """Generate .well-known response for a developer.""" data = self.load_developer_data(domain) if not data: return None - + # Don't serve revoked keys if data["current_key"]["revoked"]: return None - + return create_well_known_response( public_key_pem=data["current_key"]["public_key_pem"], developer_name=data["developer_name"], contact=data["contact"], revoked_keys=data["revoked_keys"], - schema_version=data["schema_version"] + schema_version=data["schema_version"], ) -# Global instances -config = ServerConfig(Path(__file__).parent / "config.json") -key_manager = ServerKeyManager(Path(config.config["storage"]["keys_directory"])) - -# FastAPI app -app = FastAPI( - title="SchemaPin .well-known Server", - description="Production server for SchemaPin key discovery and management", - version="1.1.7", - debug=config.config["server"]["debug"] -) - -# CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=config.config["cors"]["allow_origins"], - allow_credentials=config.config["cors"]["allow_credentials"], - allow_methods=config.config["cors"]["allow_methods"], - allow_headers=config.config["cors"]["allow_headers"], -) - -# Logging -logger = logging.getLogger(__name__) - - -@app.middleware("http") -async def log_requests(request: Request, call_next): - """Log all requests.""" - start_time = datetime.utcnow() - - response = await call_next(request) - - duration = (datetime.utcnow() - start_time).total_seconds() - - logger.info( - f"{request.client.host} - {request.method} {request.url.path} - " - f"{response.status_code} - {duration:.3f}s" - ) - - return response +class RateLimiter: + """Fixed-window per-client limiter (in-memory, single process).""" + def __init__(self, requests_per_minute: int): + self.limit = max(int(requests_per_minute), 0) + self._hits: Dict[str, Deque[float]] = {} + self._lock = threading.Lock() -@app.get("/health") -async def health_check(): - """Health check endpoint.""" - return { - "status": "healthy", - "timestamp": datetime.utcnow().isoformat(), - "version": "1.1.7" - } + def allow(self, client: str) -> bool: + if self.limit == 0: + return True + now = time.monotonic() + with self._lock: + window = self._hits.setdefault(client, deque()) + while window and now - window[0] > 60.0: + window.popleft() + if len(window) >= self.limit: + return False + window.append(now) + return True -@app.get("/.well-known/schemapin/{domain}.json") -async def get_well_known(domain: str): - """Get .well-known/schemapin.json for a specific developer domain.""" - logger.info(f"Serving .well-known for domain: {domain}") - - # Check if domain is configured - if domain not in config.config.get("developers", {}): - logger.warning(f"Domain not configured: {domain}") - raise HTTPException(status_code=404, detail="Developer domain not found") - - # Check if domain is enabled - dev_config = config.config["developers"][domain] - if not dev_config.get("enabled", True): - logger.warning(f"Domain disabled: {domain}") - raise HTTPException(status_code=404, detail="Developer domain not available") - - # Get well-known response - well_known = key_manager.get_well_known_response(domain) - if not well_known: - logger.warning(f"No valid key found for domain: {domain}") - raise HTTPException(status_code=404, detail="No valid key found for domain") - - return JSONResponse( - content=well_known, - headers={ - "Cache-Control": "public, max-age=3600", - "Content-Type": "application/json" - } +def _host_from_request(request: Request) -> Optional[str]: + host = request.headers.get("host", "") + host = host.split(":", 1)[0].strip().lower() + try: + return validate_domain(host) + except InvalidDomainError: + return None + + +def create_app(config: ServerConfig) -> FastAPI: + """Build the FastAPI application for ``config``.""" + key_manager = ServerKeyManager(Path(config.config["storage"]["keys_directory"])) + logger = logging.getLogger(__name__) + limiter = RateLimiter(config.config["security"]["rate_limit"]["requests_per_minute"]) + + app = FastAPI( + title="SchemaPin .well-known Server", + description="Reference server for SchemaPin key discovery and management", + version=SERVER_VERSION, + debug=bool(config.config["server"].get("debug", False)), ) + app.state.config = config + app.state.key_manager = key_manager + + cors = config.config["cors"] + allow_origins = list(cors.get("allow_origins", [])) + allow_credentials = bool(cors.get("allow_credentials", False)) + if "*" in allow_origins and allow_credentials: + logger.warning("CORS: allow_credentials disabled because allow_origins contains '*'") + allow_credentials = False + if allow_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=allow_origins, + allow_credentials=allow_credentials, + allow_methods=cors.get("allow_methods", ["GET", "OPTIONS"]), + allow_headers=cors.get("allow_headers", ["*"]), + ) + @app.middleware("http") + async def rate_limit_and_log(request: Request, call_next): + client = request.client.host if request.client else "unknown" + if not limiter.allow(client): + return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"}) + start_time = time.monotonic() + response = await call_next(request) + duration = time.monotonic() - start_time + logger.info( + f"{client} - {request.method} {request.url.path} - " + f"{response.status_code} - {duration:.3f}s" + ) + return response + + def require_admin(request: Request) -> None: + """Bearer-token gate for every mutation.""" + if not config.admin_api_enabled: + raise HTTPException(status_code=404, detail="Not Found") + token = config.admin_token + if not token: + raise HTTPException(status_code=503, detail="Admin API has no token configured") + header = request.headers.get("authorization", "") + scheme, _, presented = header.partition(" ") + if scheme.lower() != "bearer" or not secrets.compare_digest(presented.strip(), token): + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"WWW-Authenticate": "Bearer"}, + ) + + def developers() -> Dict[str, Any]: + return config.config.get("developers", {}) + + def serve_well_known(domain: str) -> JSONResponse: + if domain not in developers(): + logger.warning(f"Domain not configured: {domain}") + raise HTTPException(status_code=404, detail="Developer domain not found") + if not developers()[domain].get("enabled", True): + logger.warning(f"Domain disabled: {domain}") + raise HTTPException(status_code=404, detail="Developer domain not available") + well_known = key_manager.get_well_known_response(domain) + if not well_known: + logger.warning(f"No valid key found for domain: {domain}") + raise HTTPException(status_code=404, detail="No valid key found for domain") + return JSONResponse( + content=well_known, + headers={"Cache-Control": "public, max-age=3600"}, + ) -@app.get("/.well-known/schemapin.json") -async def get_default_well_known(): - """Get default .well-known/schemapin.json (first configured developer).""" - developers = config.config.get("developers", {}) - - if not developers: - raise HTTPException(status_code=404, detail="No developers configured") - - # Use first enabled developer - for domain, dev_config in developers.items(): - if dev_config.get("enabled", True): - return await get_well_known(domain) - - raise HTTPException(status_code=404, detail="No enabled developers found") - - -@app.get("/api/developers") -async def list_developers(): - """List all configured developers.""" - developers = [] - - for domain, dev_config in config.config.get("developers", {}).items(): + @app.get("/health") + async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "timestamp": _utcnow(), "version": SERVER_VERSION} + + @app.get("/.well-known/schemapin.json") + async def get_well_known_for_host(request: Request): + """RFC 8615 discovery: the developer is selected by the request Host.""" + host = _host_from_request(request) + if host is None: + raise HTTPException(status_code=404, detail="Developer domain not found") + return serve_well_known(host) + + @app.get("/.well-known/schemapin/{domain}.json") + async def get_well_known(domain: str): + """Non-standard multi-tenant path for deployments that rewrite per host.""" + try: + domain = validate_domain(domain) + except InvalidDomainError: + raise HTTPException(status_code=404, detail="Developer domain not found") + return serve_well_known(domain) + + @app.get("/api/developers") + async def list_developers(): + """List all configured developers.""" + result = [] + for domain, dev_config in developers().items(): + dev_data = key_manager.load_developer_data(domain) + result.append( + { + "domain": domain, + "name": dev_config["name"], + "contact": dev_config["contact"], + "enabled": dev_config.get("enabled", True), + "has_key": dev_data is not None, + "key_revoked": dev_data["current_key"]["revoked"] if dev_data else None, + } + ) + return {"developers": result} + + @app.get("/api/developers/{domain}") + async def get_developer_info(domain: str): + """Get detailed (public) information about a developer.""" + if domain not in developers(): + raise HTTPException(status_code=404, detail="Developer domain not found") dev_data = key_manager.load_developer_data(domain) - - developers.append({ + if not dev_data: + raise HTTPException(status_code=404, detail="No key data found for developer") + return { "domain": domain, - "name": dev_config["name"], - "contact": dev_config["contact"], - "enabled": dev_config.get("enabled", True), - "has_key": dev_data is not None, - "key_revoked": dev_data["current_key"]["revoked"] if dev_data else None - }) - - return {"developers": developers} - - -@app.get("/api/developers/{domain}") -async def get_developer_info(domain: str): - """Get detailed information about a developer.""" - if domain not in config.config.get("developers", {}): - raise HTTPException(status_code=404, detail="Developer domain not found") - - dev_data = key_manager.load_developer_data(domain) - if not dev_data: - raise HTTPException(status_code=404, detail="No key data found for developer") - - return { - "domain": domain, - "developer_name": dev_data["developer_name"], - "contact": dev_data["contact"], - "schema_version": dev_data["schema_version"], - "current_key": { - "fingerprint": dev_data["current_key"]["fingerprint"], - "created_at": dev_data["current_key"]["created_at"], - "revoked": dev_data["current_key"]["revoked"] - }, - "revoked_keys": dev_data["revoked_keys"], - "created_at": dev_data["created_at"], - "updated_at": dev_data["updated_at"] - } + "developer_name": dev_data["developer_name"], + "contact": dev_data["contact"], + "schema_version": dev_data["schema_version"], + "current_key": { + "fingerprint": dev_data["current_key"]["fingerprint"], + "created_at": dev_data["current_key"]["created_at"], + "revoked": dev_data["current_key"]["revoked"], + }, + "revoked_keys": dev_data["revoked_keys"], + "created_at": dev_data["created_at"], + "updated_at": dev_data["updated_at"], + } + @app.post("/api/developers/{domain}/keys", dependencies=[Depends(require_admin)]) + async def upload_key(domain: str, body: KeyUploadRequest): + """Upload (or rotate to) a new public key for a developer. Admin only.""" + if domain not in developers(): + raise HTTPException(status_code=404, detail="Developer domain not found") + success = key_manager.create_developer( + domain=domain, + public_key_pem=body.public_key_pem, + developer_name=body.developer_name, + contact=body.contact, + ) + if not success: + raise HTTPException(status_code=400, detail="Failed to upload key") + logger.info(f"Key uploaded for domain: {domain}") + return {"message": "Key uploaded successfully"} + + @app.post("/api/developers/{domain}/revoke", dependencies=[Depends(require_admin)]) + async def revoke_key(domain: str, body: RevocationRequest): + """Revoke a key for a developer. Admin only.""" + if domain not in developers(): + raise HTTPException(status_code=404, detail="Developer domain not found") + success = key_manager.revoke_key(domain=domain, fingerprint=body.fingerprint, reason=body.reason) + if not success: + raise HTTPException(status_code=400, detail="Failed to revoke key") + logger.info(f"Key revoked for domain {domain}: {body.fingerprint}") + return {"message": "Key revoked successfully"} + + @app.get("/api/metrics") + async def get_metrics(): + """Get server metrics.""" + devs = developers() + developers_with_keys = 0 + total_revoked_keys = 0 + for domain in devs: + dev_data = key_manager.load_developer_data(domain) + if dev_data: + developers_with_keys += 1 + total_revoked_keys += len(dev_data.get("revoked_keys", [])) + return { + "total_developers": len(devs), + "enabled_developers": len([d for d in devs.values() if d.get("enabled", True)]), + "developers_with_keys": developers_with_keys, + "total_revoked_keys": total_revoked_keys, + "timestamp": _utcnow(), + } -@app.post("/api/developers/{domain}/keys") -async def upload_key(domain: str, request: KeyUploadRequest): - """Upload a new public key for a developer.""" - if domain not in config.config.get("developers", {}): - raise HTTPException(status_code=404, detail="Developer domain not found") - - success = key_manager.create_developer( - domain=domain, - public_key_pem=request.public_key_pem, - developer_name=request.developer_name, - contact=request.contact - ) - - if not success: - raise HTTPException(status_code=400, detail="Failed to upload key") - - logger.info(f"Key uploaded for domain: {domain}") - - return {"message": "Key uploaded successfully"} - - -@app.post("/api/developers/{domain}/revoke") -async def revoke_key(domain: str, request: RevocationRequest): - """Revoke a key for a developer.""" - if domain not in config.config.get("developers", {}): - raise HTTPException(status_code=404, detail="Developer domain not found") - - success = key_manager.revoke_key( - domain=domain, - fingerprint=request.fingerprint, - reason=request.reason - ) - - if not success: - raise HTTPException(status_code=400, detail="Failed to revoke key") - - logger.info(f"Key revoked for domain {domain}: {request.fingerprint}") - - return {"message": "Key revoked successfully"} - - -@app.get("/api/metrics") -async def get_metrics(): - """Get server metrics.""" - developers = config.config.get("developers", {}) - - total_developers = len(developers) - enabled_developers = len([d for d in developers.values() if d.get("enabled", True)]) - - developers_with_keys = 0 - total_revoked_keys = 0 - - for domain in developers.keys(): - dev_data = key_manager.load_developer_data(domain) - if dev_data: - developers_with_keys += 1 - total_revoked_keys += len(dev_data.get("revoked_keys", [])) - - return { - "total_developers": total_developers, - "enabled_developers": enabled_developers, - "developers_with_keys": developers_with_keys, - "total_revoked_keys": total_revoked_keys, - "server_uptime": "N/A", # Would need to track start time - "timestamp": datetime.utcnow().isoformat() - } + return app -def setup_demo_data(): +def setup_demo_data(config: ServerConfig, key_manager: ServerKeyManager) -> None: """Set up demo data if keys directory is empty.""" - if not any(key_manager.keys_dir.glob("*.json")): - logger.info("Setting up demo data...") - - # Import demo keys from integration demo - demo_keys_dir = Path(__file__).parent.parent / "integration_demo" / "test_data" / "keys" - - if demo_keys_dir.exists(): - for domain in ["alice.example.com", "bob.example.com", "charlie.example.com"]: - public_key_file = demo_keys_dir / f"{domain}_public.pem" - - if public_key_file.exists(): - public_key_pem = public_key_file.read_text() - dev_config = config.config["developers"].get(domain, {}) - - key_manager.create_developer( - domain=domain, - public_key_pem=public_key_pem, - developer_name=dev_config.get("name", domain), - contact=dev_config.get("contact", f"security@{domain}") - ) - - logger.info(f"Created demo data for {domain}") - - -def main(): + logger = logging.getLogger(__name__) + if any(key_manager.keys_dir.glob("*.json")): + return + demo_keys_dir = Path(__file__).parent.parent / "integration_demo" / "test_data" / "keys" + if not demo_keys_dir.exists(): + return + logger.info("Setting up demo data...") + for domain, dev_config in config.config.get("developers", {}).items(): + public_key_file = demo_keys_dir / f"{domain}_public.pem" + if public_key_file.exists(): + key_manager.create_developer( + domain=domain, + public_key_pem=public_key_file.read_text(), + developer_name=dev_config.get("name", domain), + contact=dev_config.get("contact", f"security@{domain}"), + ) + logger.info(f"Created demo data for {domain}") + + +def main() -> None: """Main entry point.""" - # Set up demo data if needed - setup_demo_data() - - # Start server + import uvicorn + + config = ServerConfig(Path(__file__).parent / "config.json") + application = create_app(config) + setup_demo_data(config, application.state.key_manager) + server_config = config.config["server"] - - logger.info(f"Starting SchemaPin .well-known server on {server_config['host']}:{server_config['port']}") - + logger = logging.getLogger(__name__) + if config.admin_api_enabled and not config.admin_token: + logger.warning( + f"features.admin_api is enabled but no admin token is configured; " + f"set security.admin_token or {ADMIN_TOKEN_ENV}" + ) + logger.info( + f"Starting SchemaPin .well-known server on {server_config['host']}:{server_config['port']}" + ) uvicorn.run( - "well_known_server:app", + application, host=server_config["host"], - port=server_config["port"], - reload=server_config.get("reload", False), - log_level=config.config["logging"]["level"].lower() + port=int(server_config["port"]), + log_level=config.config["logging"]["level"].lower(), ) if __name__ == "__main__": - main() \ No newline at end of file + main() From 0849695e1ec6cf7325dca55dbcbe063841b113cd Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 15:19:08 -0700 Subject: [PATCH 08/14] Add a verifier-side LineageStore for schema-version binding 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. --- go/pkg/skill/lineage.go | 164 +++++++++++++ go/pkg/skill/lineage_store_test.go | 71 ++++++ javascript/src/index.js | 3 + javascript/src/lineage.js | 161 +++++++++++++ javascript/tests/lineage.test.js | 54 +++++ python/schemapin/__init__.py | 5 + python/schemapin/lineage.py | 161 +++++++++++++ python/tests/test_lineage.py | 63 +++++ rust/src/lib.rs | 1 + rust/src/lineage.rs | 354 +++++++++++++++++++++++++++++ 10 files changed, 1037 insertions(+) create mode 100644 go/pkg/skill/lineage.go create mode 100644 go/pkg/skill/lineage_store_test.go create mode 100644 javascript/src/lineage.js create mode 100644 javascript/tests/lineage.test.js create mode 100644 python/schemapin/lineage.py create mode 100644 python/tests/test_lineage.py create mode 100644 rust/src/lineage.rs diff --git a/go/pkg/skill/lineage.go b/go/pkg/skill/lineage.go new file mode 100644 index 0000000..47c8ece --- /dev/null +++ b/go/pkg/skill/lineage.go @@ -0,0 +1,164 @@ +package skill + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" +) + +// LineageRecord is the latest accepted lineage state for a tool_id@domain. +type LineageRecord struct { + SkillHash string `json:"skill_hash"` + SchemaVersion string `json:"schema_version,omitempty"` +} + +// LineageResult is the outcome of LineageStore.CheckAndAdvance. +type LineageResult string + +const ( + // LineageFirstUse — nothing was recorded; the document becomes the head. + LineageFirstUse LineageResult = "first_use" + // LineageUnchanged — the document's skill_hash is already the head. + LineageUnchanged LineageResult = "unchanged" + // LineageAdvanced — the document is a valid successor and is now the head. + LineageAdvanced LineageResult = "advanced" +) + +// LineageError is returned when a signature does not descend from the +// recorded lineage. +type LineageError struct { + Message string +} + +func (e *LineageError) Error() string { return e.Message } + +func lineageErr(format string, args ...interface{}) error { + return &LineageError{Message: fmt.Sprintf(format, args...)} +} + +var semverRe = regexp.MustCompile(`^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?`) + +func versionKey(version string) ([3]int, bool) { + m := semverRe.FindStringSubmatch(version) + if m == nil { + return [3]int{}, false + } + var key [3]int + for i := 0; i < 3; i++ { + if m[i+1] != "" { + key[i], _ = strconv.Atoi(m[i+1]) + } + } + return key, true +} + +// VersionIncreases reports whether current sorts strictly after previous. +// Numeric major.minor.patch prefixes compare numerically ("1.10.0" > "1.9.0"); +// when either side is not semver-like the comparison falls back to a plain +// string comparison, which still rejects reuse of the same value. +func VersionIncreases(previous, current string) bool { + p, pok := versionKey(previous) + c, cok := versionKey(current) + if pok && cok && p != c { + for i := 0; i < 3; i++ { + if c[i] != p[i] { + return c[i] > p[i] + } + } + } + return current > previous +} + +// LineageStore is the verifier-side latest_known_hash store (spec section +// 18.4) keyed by tool_id@domain. +// +// VerifyChain compares two signature documents the caller already holds. A +// verifier that wants protection against rollback and forks needs to remember +// the latest accepted skill_hash for each tool and insist that every new +// signature descends from it. Only feed it documents that VerifySkillOffline +// has already accepted, and only schemapin_version >= "1.4" documents: their +// skill_hash, previous_hash and schema_version are covered by the signature. +// Legacy 1.3 documents are refused. +type LineageStore struct { + latest map[string]LineageRecord +} + +// NewLineageStore creates an empty LineageStore. +func NewLineageStore() *LineageStore { + return &LineageStore{latest: map[string]LineageRecord{}} +} + +func lineageKey(toolID, domain string) string { return toolID + "@" + domain } + +// Get returns the latest accepted record for toolID@domain. +func (s *LineageStore) Get(toolID, domain string) (LineageRecord, bool) { + r, ok := s.latest[lineageKey(toolID, domain)] + return r, ok +} + +// Seed pre-seeds the latest accepted state (for example from a trusted manifest). +func (s *LineageStore) Seed(toolID, domain, skillHash, schemaVersion string) { + s.latest[lineageKey(toolID, domain)] = LineageRecord{SkillHash: skillHash, SchemaVersion: schemaVersion} +} + +// CheckAndAdvance checks sig against the lineage and advances on success. +// With requireChain a document for a known tool must carry previous_hash. +func (s *LineageStore) CheckAndAdvance(toolID, domain string, sig *SkillSignature, requireChain bool) (LineageResult, error) { + if sig == nil { + return "", lineageErr("signature document is nil") + } + if isLegacyVersion(sig.SchemapinVersion) { + return "", lineageErr("legacy 1.3 signature documents carry an unsigned skill_hash and cannot anchor a lineage") + } + if sig.SkillHash == "" { + return "", lineageErr("signature document has no skill_hash") + } + key := lineageKey(toolID, domain) + record, ok := s.latest[key] + if !ok { + s.latest[key] = LineageRecord{SkillHash: sig.SkillHash, SchemaVersion: sig.SchemaVersion} + return LineageFirstUse, nil + } + if record.SkillHash == sig.SkillHash { + return LineageUnchanged, nil + } + if sig.PreviousHash == "" { + if requireChain { + return "", lineageErr("signature for %s has no previous_hash; expected a descendant of %s", key, record.SkillHash) + } + } else if sig.PreviousHash != record.SkillHash { + return "", lineageErr("previous_hash %s does not match the latest accepted skill_hash %s for %s (fork or rollback)", sig.PreviousHash, record.SkillHash, key) + } + if sig.SchemaVersion != "" && record.SchemaVersion != "" && !VersionIncreases(record.SchemaVersion, sig.SchemaVersion) { + return "", lineageErr("schema_version %q does not increase over %q for %s", sig.SchemaVersion, record.SchemaVersion, key) + } + s.latest[key] = LineageRecord{SkillHash: sig.SkillHash, SchemaVersion: sig.SchemaVersion} + return LineageAdvanced, nil +} + +// ToJSON serializes the store. +func (s *LineageStore) ToJSON() (string, error) { + data, err := json.Marshal(s.latest) + if err != nil { + return "", err + } + return string(data), nil +} + +// LineageStoreFromJSON deserializes a store. +func LineageStoreFromJSON(jsonStr string) (*LineageStore, error) { + store := NewLineageStore() + if err := json.Unmarshal([]byte(jsonStr), &store.latest); err != nil { + return nil, err + } + if store.latest == nil { + return nil, fmt.Errorf("lineage store JSON must be an object") + } + for key, rec := range store.latest { + if rec.SkillHash == "" { + return nil, fmt.Errorf("lineage store entry %q has no skill_hash", key) + } + } + return store, nil +} diff --git a/go/pkg/skill/lineage_store_test.go b/go/pkg/skill/lineage_store_test.go new file mode 100644 index 0000000..26b646d --- /dev/null +++ b/go/pkg/skill/lineage_store_test.go @@ -0,0 +1,71 @@ +package skill + +import ( + "errors" + "testing" +) + +func lineageDoc(hash, previous, version, schemapin string) *SkillSignature { + return &SkillSignature{SchemapinVersion: schemapin, SkillHash: hash, PreviousHash: previous, SchemaVersion: version} +} + +func TestLineageStoreFlow(t *testing.T) { + s := NewLineageStore() + if r, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:a", "", "1.0.0", "1.4"), true); err != nil || r != LineageFirstUse { + t.Fatalf("first use: %v %v", r, err) + } + if r, _ := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:a", "", "1.0.0", "1.4"), true); r != LineageUnchanged { + t.Fatalf("expected unchanged, got %v", r) + } + if r, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:b", "sha256:a", "1.1.0", "1.4"), true); err != nil || r != LineageAdvanced { + t.Fatalf("advance: %v %v", r, err) + } + if rec, _ := s.Get("calc", "example.com"); rec.SkillHash != "sha256:b" { + t.Fatalf("head not advanced: %+v", rec) + } + // Fork from the old head and missing previous_hash are rejected. + var le *LineageError + if _, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:bx", "sha256:a", "", "1.4"), true); !errors.As(err, &le) { + t.Fatalf("fork must be rejected, got %v", err) + } + if _, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:c", "", "", "1.4"), true); !errors.As(err, &le) { + t.Fatalf("missing previous_hash must be rejected, got %v", err) + } + if r, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:c", "", "", "1.4"), false); err != nil || r != LineageAdvanced { + t.Fatalf("requireChain=false must advance: %v %v", r, err) + } +} + +func TestLineageVersionAndLegacy(t *testing.T) { + s := NewLineageStore() + _, _ = s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:a", "", "1.9.0", "1.4"), true) + if _, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:b", "sha256:a", "1.9.0", "1.4"), true); err == nil { + t.Fatal("equal schema_version must be rejected") + } + if _, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:b", "sha256:a", "1.2.0", "1.4"), true); err == nil { + t.Fatal("lower schema_version must be rejected") + } + if r, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:b", "sha256:a", "1.10.0", "1.4"), true); err != nil || r != LineageAdvanced { + t.Fatalf("1.10.0 must sort after 1.9.0: %v %v", r, err) + } + if !VersionIncreases("1.9.0", "1.10.0") || VersionIncreases("2.0.0", "2.0.0") || !VersionIncreases("abc", "abd") { + t.Fatal("VersionIncreases semantics") + } + if _, err := s.CheckAndAdvance("calc", "example.com", lineageDoc("sha256:z", "sha256:b", "", "1.3"), true); err == nil { + t.Fatal("legacy documents must be refused") + } + js, err := s.ToJSON() + if err != nil { + t.Fatal(err) + } + restored, err := LineageStoreFromJSON(js) + if err != nil { + t.Fatal(err) + } + if rec, ok := restored.Get("calc", "example.com"); !ok || rec.SchemaVersion != "1.10.0" { + t.Fatalf("round trip lost state: %+v", rec) + } + if _, err := LineageStoreFromJSON("[]"); err == nil { + t.Fatal("array must be rejected") + } +} diff --git a/javascript/src/index.js b/javascript/src/index.js index 6f17119..88252f0 100644 --- a/javascript/src/index.js +++ b/javascript/src/index.js @@ -105,6 +105,9 @@ export { detectTamperedFiles } from './skill.js'; +// Verifier-side lineage store (spec section 18.4) +export { LineageStore, LineageError, versionIncreases } from './lineage.js'; + // v1.4.0-alpha.1: DNS TXT cross-verification export { parseTxtRecord, diff --git a/javascript/src/lineage.js b/javascript/src/lineage.js new file mode 100644 index 0000000..9e20186 --- /dev/null +++ b/javascript/src/lineage.js @@ -0,0 +1,161 @@ +/** + * Verifier-side lineage store for schema-version binding (spec section 18.4). + * + * `verifyChain` compares two signature documents the caller already holds. A + * verifier that wants protection against rollback and forks needs to remember + * the latest accepted `skill_hash` for each tool and insist that every new + * signature descends from it. `LineageStore` is that memory. + * + * Only feed it signature documents that `verifySkillOffline` has already + * accepted, and only `schemapin_version >= "1.4"` documents: their + * `skill_hash`, `previous_hash` and `schema_version` are covered by the + * signature, which is what makes the chain meaningful. Legacy 1.3 documents + * are refused. + */ + +/** + * Thrown when a signature does not descend from the recorded lineage. + */ +export class LineageError extends Error { + constructor(message) { + super(message); + this.name = 'LineageError'; + } +} + +const SEMVER_RE = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/; + +function versionKey(version) { + const m = SEMVER_RE.exec(version); + return m ? [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)] : null; +} + +function isLegacyVersion(version) { + const m = /^(\d+)\.(\d+)$/.exec(typeof version === 'string' ? version : ''); + return m !== null && (Number(m[1]) < 1 || (Number(m[1]) === 1 && Number(m[2]) < 4)); +} + +/** + * Return true when `current` sorts strictly after `previous`. + * + * Numeric `major.minor.patch` prefixes compare numerically (`1.10.0` > + * `1.9.0`); when either side is not semver-like the comparison falls back to + * a plain string comparison, which still rejects reuse of the same value. + * + * @param {string} previous + * @param {string} current + * @returns {boolean} + */ +export function versionIncreases(previous, current) { + const p = versionKey(previous); + const c = versionKey(current); + if (p && c && (p[0] !== c[0] || p[1] !== c[1] || p[2] !== c[2])) { + for (let i = 0; i < 3; i++) { + if (c[i] !== p[i]) { + return c[i] > p[i]; + } + } + } + return current > previous; +} + +/** + * In-memory `latest_known_hash` store keyed by `toolId@domain`. + */ +export class LineageStore { + constructor() { + this._latest = new Map(); + } + + _key(toolId, domain) { + return `${toolId}@${domain}`; + } + + /** + * @param {string} toolId + * @param {string} domain + * @returns {{ skill_hash: string, schema_version: string|null }|null} + */ + get(toolId, domain) { + return this._latest.get(this._key(toolId, domain)) ?? null; + } + + /** + * Pre-seed the latest accepted state (for example from a trusted manifest). + * + * @param {string} toolId + * @param {string} domain + * @param {string} skillHash + * @param {string|null} [schemaVersion=null] + */ + seed(toolId, domain, skillHash, schemaVersion = null) { + this._latest.set(this._key(toolId, domain), { skill_hash: skillHash, schema_version: schemaVersion }); + } + + /** + * Check `signatureData` against the lineage and advance on success. + * + * @param {string} toolId + * @param {string} domain + * @param {Object} signatureData - an already-verified v1.4 signature document + * @param {boolean} [requireChain=true] + * @returns {'first_use'|'unchanged'|'advanced'} + * @throws {LineageError} + */ + checkAndAdvance(toolId, domain, signatureData, requireChain = true) { + if (isLegacyVersion(signatureData.schemapin_version)) { + throw new LineageError('legacy 1.3 signature documents carry an unsigned skill_hash and cannot anchor a lineage'); + } + const skillHash = signatureData.skill_hash; + if (typeof skillHash !== 'string' || skillHash === '') { + throw new LineageError('signature document has no skill_hash'); + } + const schemaVersion = typeof signatureData.schema_version === 'string' ? signatureData.schema_version : null; + const key = this._key(toolId, domain); + const record = this._latest.get(key); + if (!record) { + this._latest.set(key, { skill_hash: skillHash, schema_version: schemaVersion }); + return 'first_use'; + } + if (record.skill_hash === skillHash) { + return 'unchanged'; + } + const previousHash = signatureData.previous_hash; + if (previousHash === null || previousHash === undefined) { + if (requireChain) { + throw new LineageError(`signature for ${key} has no previous_hash; expected a descendant of ${record.skill_hash}`); + } + } else if (previousHash !== record.skill_hash) { + throw new LineageError(`previous_hash ${previousHash} does not match the latest accepted skill_hash ${record.skill_hash} for ${key} (fork or rollback)`); + } + if (schemaVersion !== null && record.schema_version !== null && !versionIncreases(record.schema_version, schemaVersion)) { + throw new LineageError(`schema_version ${JSON.stringify(schemaVersion)} does not increase over ${JSON.stringify(record.schema_version)} for ${key}`); + } + this._latest.set(key, { skill_hash: skillHash, schema_version: schemaVersion }); + return 'advanced'; + } + + /** @returns {string} */ + toJSON() { + return JSON.stringify(Object.fromEntries(this._latest)); + } + + /** + * @param {string} jsonStr + * @returns {LineageStore} + */ + static fromJSON(jsonStr) { + const data = JSON.parse(jsonStr); + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('lineage store JSON must be an object'); + } + const store = new LineageStore(); + for (const [key, value] of Object.entries(data)) { + if (!value || typeof value.skill_hash !== 'string') { + throw new Error('lineage store entries must carry a skill_hash'); + } + store._latest.set(key, { skill_hash: value.skill_hash, schema_version: value.schema_version ?? null }); + } + return store; + } +} diff --git a/javascript/tests/lineage.test.js b/javascript/tests/lineage.test.js new file mode 100644 index 0000000..bae155e --- /dev/null +++ b/javascript/tests/lineage.test.js @@ -0,0 +1,54 @@ +/** + * Tests for the verifier-side LineageStore (design issue 3). + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { LineageError, LineageStore, versionIncreases } from '../src/lineage.js'; + +const doc = (skillHash, previous = null, version = null, schemapin = '1.4') => ({ + schemapin_version: schemapin, + skill_hash: skillHash, + ...(previous !== null ? { previous_hash: previous } : {}), + ...(version !== null ? { schema_version: version } : {}) +}); + +describe('LineageStore', () => { + test('first use, advance, unchanged', () => { + const store = new LineageStore(); + assert.equal(store.checkAndAdvance('calc', 'example.com', doc('sha256:a', null, '1.0.0')), 'first_use'); + assert.equal(store.checkAndAdvance('calc', 'example.com', doc('sha256:a', null, '1.0.0')), 'unchanged'); + assert.equal(store.checkAndAdvance('calc', 'example.com', doc('sha256:b', 'sha256:a', '1.1.0')), 'advanced'); + assert.equal(store.get('calc', 'example.com').skill_hash, 'sha256:b'); + }); + + test('forks and rollbacks are rejected', () => { + const store = new LineageStore(); + store.checkAndAdvance('calc', 'example.com', doc('sha256:v4')); + store.checkAndAdvance('calc', 'example.com', doc('sha256:v5', 'sha256:v4')); + assert.throws(() => store.checkAndAdvance('calc', 'example.com', doc('sha256:v5x', 'sha256:v4')), /fork or rollback/); + assert.throws(() => store.checkAndAdvance('calc', 'example.com', doc('sha256:v6')), /no previous_hash/); + assert.equal(store.checkAndAdvance('calc', 'example.com', doc('sha256:v6'), false), 'advanced'); + }); + + test('schema_version must increase', () => { + const store = new LineageStore(); + store.checkAndAdvance('calc', 'example.com', doc('sha256:a', null, '1.9.0')); + assert.throws(() => store.checkAndAdvance('calc', 'example.com', doc('sha256:b', 'sha256:a', '1.9.0')), LineageError); + assert.throws(() => store.checkAndAdvance('calc', 'example.com', doc('sha256:b', 'sha256:a', '1.2.0')), LineageError); + assert.equal(store.checkAndAdvance('calc', 'example.com', doc('sha256:b', 'sha256:a', '1.10.0')), 'advanced'); + assert.equal(versionIncreases('1.9.0', '1.10.0'), true); + assert.equal(versionIncreases('2.0.0', '2.0.0'), false); + assert.equal(versionIncreases('abc', 'abd'), true); + }); + + test('legacy documents refused; tools isolated; JSON round trip', () => { + const store = new LineageStore(); + assert.throws(() => store.checkAndAdvance('calc', 'example.com', doc('sha256:a', null, null, '1.3')), /legacy/); + store.seed('calc', 'example.com', 'sha256:a', '1.0.0'); + assert.equal(store.checkAndAdvance('calc', 'other.com', doc('sha256:z')), 'first_use'); + const restored = LineageStore.fromJSON(store.toJSON()); + assert.equal(restored.get('calc', 'example.com').schema_version, '1.0.0'); + assert.throws(() => LineageStore.fromJSON('[]')); + }); +}); diff --git a/python/schemapin/__init__.py b/python/schemapin/__init__.py index be1c76b..a2de775 100644 --- a/python/schemapin/__init__.py +++ b/python/schemapin/__init__.py @@ -52,6 +52,7 @@ PromptType, UserDecision, ) +from .lineage import LineageError, LineageRecord, LineageStore, version_increases from .pinning import KeyPinning, PinningMode, PinningPolicy from .resolver import ( ChainResolver, @@ -156,6 +157,10 @@ "ChainError", "SkillCanonicalizationError", "verify_chain", + "LineageStore", + "LineageRecord", + "LineageError", + "version_increases", # v1.3.0 "SkillSigner", "SIGNATURE_FILENAME", diff --git a/python/schemapin/lineage.py b/python/schemapin/lineage.py new file mode 100644 index 0000000..487cff8 --- /dev/null +++ b/python/schemapin/lineage.py @@ -0,0 +1,161 @@ +"""Verifier-side lineage store for schema-version binding (spec section 18.4). + +:func:`schemapin.skill.verify_chain` compares two signature documents the +caller already holds. A verifier that wants protection against rollback and +forks needs to remember the *latest accepted* ``skill_hash`` for each tool +and insist that every new signature descends from it. :class:`LineageStore` +is that memory. + +Only feed it signature documents that :meth:`SkillSigner.verify_skill_offline` +has already accepted, and only ``schemapin_version >= "1.4"`` documents: +their ``skill_hash``, ``previous_hash`` and ``schema_version`` are covered by +the signature, which is what makes the chain meaningful. Legacy 1.3 documents +are refused. +""" + +import json +import re +from dataclasses import asdict, dataclass +from typing import Any, Dict, Optional, Tuple + +from .skill import _is_legacy_version + + +class LineageError(ValueError): + """Raised when a signature does not descend from the recorded lineage.""" + + +@dataclass +class LineageRecord: + """Latest accepted lineage state for a ``tool_id@domain``.""" + + skill_hash: str + schema_version: Optional[str] = None + + +_SEMVER_RE = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?") + + +def _version_key(version: str) -> Optional[Tuple[int, int, int]]: + m = _SEMVER_RE.match(version) + if m is None: + return None + return tuple(int(g or 0) for g in m.groups()) # type: ignore[return-value] + + +def version_increases(previous: str, current: str) -> bool: + """Return ``True`` when ``current`` sorts strictly after ``previous``. + + Numeric ``major.minor.patch`` prefixes compare numerically (``1.10.0`` > + ``1.9.0``); when either side is not semver-like the comparison falls back + to a plain string comparison, which still rejects reuse of the same value. + """ + p, c = _version_key(previous), _version_key(current) + if p is not None and c is not None and p != c: + return c > p + return current > previous + + +class LineageStore: + """In-memory ``latest_known_hash`` store keyed by ``tool_id@domain``.""" + + def __init__(self) -> None: + self._latest: Dict[str, LineageRecord] = {} + + @staticmethod + def _key(tool_id: str, domain: str) -> str: + return f"{tool_id}@{domain}" + + def get(self, tool_id: str, domain: str) -> Optional[LineageRecord]: + """Return the latest accepted record, or ``None``.""" + return self._latest.get(self._key(tool_id, domain)) + + def seed( + self, + tool_id: str, + domain: str, + skill_hash: str, + schema_version: Optional[str] = None, + ) -> None: + """Pre-seed the latest accepted state (for example from a trusted manifest).""" + self._latest[self._key(tool_id, domain)] = LineageRecord(skill_hash, schema_version) + + def check_and_advance( + self, + tool_id: str, + domain: str, + signature_data: Dict[str, Any], + require_chain: bool = True, + ) -> str: + """Check ``signature_data`` against the lineage and advance on success. + + Returns: + ``"first_use"`` when nothing was recorded yet (the document is + recorded as the lineage head), ``"unchanged"`` when the document's + ``skill_hash`` is already the head, or ``"advanced"`` when the + document is a valid successor and becomes the new head. + + Raises: + LineageError: for legacy 1.3 documents, a missing ``previous_hash`` + (when ``require_chain``), a ``previous_hash`` that is not the + current head (fork / rollback), or a ``schema_version`` that + does not increase. + """ + version = signature_data.get("schemapin_version") + if _is_legacy_version(version): + raise LineageError( + "legacy 1.3 signature documents carry an unsigned skill_hash and cannot anchor a lineage" + ) + skill_hash = signature_data.get("skill_hash") + if not isinstance(skill_hash, str) or not skill_hash: + raise LineageError("signature document has no skill_hash") + schema_version = signature_data.get("schema_version") + + key = self._key(tool_id, domain) + record = self._latest.get(key) + if record is None: + self._latest[key] = LineageRecord(skill_hash, schema_version) + return "first_use" + if record.skill_hash == skill_hash: + return "unchanged" + + previous_hash = signature_data.get("previous_hash") + if previous_hash is None: + if require_chain: + raise LineageError( + f"signature for {key} has no previous_hash; expected a descendant of {record.skill_hash}" + ) + elif previous_hash != record.skill_hash: + raise LineageError( + f"previous_hash {previous_hash} does not match the latest accepted " + f"skill_hash {record.skill_hash} for {key} (fork or rollback)" + ) + if ( + isinstance(schema_version, str) + and record.schema_version is not None + and not version_increases(record.schema_version, schema_version) + ): + raise LineageError( + f"schema_version {schema_version!r} does not increase over " + f"{record.schema_version!r} for {key}" + ) + + self._latest[key] = LineageRecord(skill_hash, schema_version) + return "advanced" + + def to_json(self) -> str: + """Serialize the store to JSON.""" + return json.dumps({k: asdict(v) for k, v in self._latest.items()}, sort_keys=True) + + @classmethod + def from_json(cls, json_str: str) -> "LineageStore": + """Deserialize a store from JSON.""" + store = cls() + data = json.loads(json_str) + if not isinstance(data, dict): + raise ValueError("lineage store JSON must be an object") + for key, value in data.items(): + if not isinstance(value, dict) or not isinstance(value.get("skill_hash"), str): + raise ValueError("lineage store entries must carry a skill_hash") + store._latest[key] = LineageRecord(value["skill_hash"], value.get("schema_version")) + return store diff --git a/python/tests/test_lineage.py b/python/tests/test_lineage.py new file mode 100644 index 0000000..caa730d --- /dev/null +++ b/python/tests/test_lineage.py @@ -0,0 +1,63 @@ +"""Tests for the verifier-side LineageStore (design issue 3).""" + +import pytest + +from schemapin.lineage import LineageError, LineageStore, version_increases + + +def _doc(skill_hash, previous=None, version=None, schemapin="1.4"): + d = {"schemapin_version": schemapin, "skill_hash": skill_hash} + if previous is not None: + d["previous_hash"] = previous + if version is not None: + d["schema_version"] = version + return d + + +def test_first_use_then_advance_then_unchanged(): + store = LineageStore() + assert store.check_and_advance("calc", "example.com", _doc("sha256:a", version="1.0.0")) == "first_use" + assert store.check_and_advance("calc", "example.com", _doc("sha256:a", version="1.0.0")) == "unchanged" + assert ( + store.check_and_advance("calc", "example.com", _doc("sha256:b", previous="sha256:a", version="1.1.0")) + == "advanced" + ) + assert store.get("calc", "example.com").skill_hash == "sha256:b" + + +def test_fork_and_rollback_rejected(): + store = LineageStore() + store.check_and_advance("calc", "example.com", _doc("sha256:v4")) + store.check_and_advance("calc", "example.com", _doc("sha256:v5", previous="sha256:v4")) + # Fork from the old head. + with pytest.raises(LineageError, match="fork or rollback"): + store.check_and_advance("calc", "example.com", _doc("sha256:v5x", previous="sha256:v4")) + # Missing previous_hash on a known tool. + with pytest.raises(LineageError, match="no previous_hash"): + store.check_and_advance("calc", "example.com", _doc("sha256:v6")) + assert store.check_and_advance("calc", "example.com", _doc("sha256:v6"), require_chain=False) == "advanced" + + +def test_schema_version_must_increase(): + store = LineageStore() + store.check_and_advance("calc", "example.com", _doc("sha256:a", version="1.9.0")) + with pytest.raises(LineageError, match="does not increase"): + store.check_and_advance("calc", "example.com", _doc("sha256:b", previous="sha256:a", version="1.9.0")) + with pytest.raises(LineageError, match="does not increase"): + store.check_and_advance("calc", "example.com", _doc("sha256:b", previous="sha256:a", version="1.2.0")) + assert store.check_and_advance("calc", "example.com", _doc("sha256:b", previous="sha256:a", version="1.10.0")) == "advanced" + assert version_increases("1.9.0", "1.10.0") + assert not version_increases("2.0.0", "2.0.0") + assert version_increases("abc", "abd") + + +def test_legacy_documents_refused_and_tools_isolated(): + store = LineageStore() + with pytest.raises(LineageError, match="legacy"): + store.check_and_advance("calc", "example.com", _doc("sha256:a", schemapin="1.3")) + store.seed("calc", "example.com", "sha256:a", "1.0.0") + assert store.check_and_advance("calc", "other.com", _doc("sha256:z")) == "first_use" + restored = LineageStore.from_json(store.to_json()) + assert restored.get("calc", "example.com").schema_version == "1.0.0" + with pytest.raises(ValueError): + LineageStore.from_json("[]") diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3d25437..3aa81c3 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -72,6 +72,7 @@ pub mod canonicalize; pub mod discovery; pub mod domain; pub mod error; +pub mod lineage; pub mod pinning; pub mod resolver; pub mod revocation; diff --git a/rust/src/lineage.rs b/rust/src/lineage.rs new file mode 100644 index 0000000..3f86444 --- /dev/null +++ b/rust/src/lineage.rs @@ -0,0 +1,354 @@ +//! Verifier-side lineage store for schema-version binding (spec section 18.4). +//! +//! [`crate::skill::verify_chain`] compares two signature documents the caller +//! already holds. A verifier that wants protection against rollback and forks +//! needs to remember the *latest accepted* `skill_hash` for each tool and +//! insist that every new signature descends from it. [`LineageStore`] is that +//! memory. +//! +//! Only feed it signature documents that [`crate::skill::verify_skill_offline`] +//! has already accepted, and only `schemapin_version >= "1.4"` documents: +//! their `skill_hash`, `previous_hash` and `schema_version` are covered by the +//! signature, which is what makes the chain meaningful. Legacy 1.3 documents +//! are refused. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::skill::SkillSignature; + +/// Latest accepted lineage state for a `tool_id@domain`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LineageRecord { + pub skill_hash: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_version: Option, +} + +/// Outcome of [`LineageStore::check_and_advance`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineageResult { + /// Nothing was recorded; the document becomes the head. + FirstUse, + /// The document's `skill_hash` is already the head. + Unchanged, + /// The document is a valid successor and is now the head. + Advanced, +} + +/// Returned when a signature does not descend from the recorded lineage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LineageError(pub String); + +impl std::fmt::Display for LineageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for LineageError {} + +fn version_key(version: &str) -> Option<[u64; 3]> { + let v = version.strip_prefix('v').unwrap_or(version); + let mut key = [0u64; 3]; + let mut any = false; + for (i, part) in v.split('.').take(3).enumerate() { + let digits: String = part.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + break; + } + key[i] = digits.parse().ok()?; + any = true; + if digits.len() != part.len() { + break; + } + } + any.then_some(key) +} + +fn is_legacy_version(version: &str) -> bool { + let Some((major, minor)) = version.split_once('.') else { + return false; + }; + match (major.parse::(), minor.parse::()) { + (Ok(ma), Ok(mi)) => (ma, mi) < (1, 4), + _ => false, + } +} + +/// Return `true` when `current` sorts strictly after `previous`. +/// +/// Numeric `major.minor.patch` prefixes compare numerically (`1.10.0` > +/// `1.9.0`); when either side is not semver-like the comparison falls back to +/// a plain string comparison, which still rejects reuse of the same value. +pub fn version_increases(previous: &str, current: &str) -> bool { + match (version_key(previous), version_key(current)) { + (Some(p), Some(c)) if p != c => c > p, + _ => current > previous, + } +} + +/// In-memory `latest_known_hash` store keyed by `tool_id@domain`. +#[derive(Debug, Default, Clone)] +pub struct LineageStore { + latest: HashMap, +} + +impl LineageStore { + pub fn new() -> Self { + Self::default() + } + + fn key(tool_id: &str, domain: &str) -> String { + format!("{}@{}", tool_id, domain) + } + + /// Return the latest accepted record. + pub fn get(&self, tool_id: &str, domain: &str) -> Option<&LineageRecord> { + self.latest.get(&Self::key(tool_id, domain)) + } + + /// Pre-seed the latest accepted state (for example from a trusted manifest). + pub fn seed( + &mut self, + tool_id: &str, + domain: &str, + skill_hash: &str, + schema_version: Option<&str>, + ) { + self.latest.insert( + Self::key(tool_id, domain), + LineageRecord { + skill_hash: skill_hash.to_string(), + schema_version: schema_version.map(str::to_string), + }, + ); + } + + /// Check `sig` against the lineage and advance on success. With + /// `require_chain` a document for a known tool must carry `previous_hash`. + pub fn check_and_advance( + &mut self, + tool_id: &str, + domain: &str, + sig: &SkillSignature, + require_chain: bool, + ) -> Result { + if is_legacy_version(&sig.schemapin_version) { + return Err(LineageError( + "legacy 1.3 signature documents carry an unsigned skill_hash and cannot anchor a lineage".to_string(), + )); + } + if sig.skill_hash.is_empty() { + return Err(LineageError( + "signature document has no skill_hash".to_string(), + )); + } + let key = Self::key(tool_id, domain); + let Some(record) = self.latest.get(&key).cloned() else { + self.latest.insert( + key, + LineageRecord { + skill_hash: sig.skill_hash.clone(), + schema_version: sig.schema_version.clone(), + }, + ); + return Ok(LineageResult::FirstUse); + }; + if record.skill_hash == sig.skill_hash { + return Ok(LineageResult::Unchanged); + } + match sig.previous_hash.as_deref() { + None if require_chain => { + return Err(LineageError(format!( + "signature for {} has no previous_hash; expected a descendant of {}", + key, record.skill_hash + ))); + } + Some(prev) if prev != record.skill_hash => { + return Err(LineageError(format!( + "previous_hash {} does not match the latest accepted skill_hash {} for {} (fork or rollback)", + prev, record.skill_hash, key + ))); + } + _ => {} + } + if let (Some(cur), Some(prev)) = (&sig.schema_version, &record.schema_version) { + if !version_increases(prev, cur) { + return Err(LineageError(format!( + "schema_version {:?} does not increase over {:?} for {}", + cur, prev, key + ))); + } + } + self.latest.insert( + key, + LineageRecord { + skill_hash: sig.skill_hash.clone(), + schema_version: sig.schema_version.clone(), + }, + ); + Ok(LineageResult::Advanced) + } + + /// Serialize the store to JSON. + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(&self.latest)?) + } + + /// Deserialize a store from JSON. + pub fn from_json(json: &str) -> Result { + let latest: HashMap = serde_json::from_str(json)?; + if latest.values().any(|r| r.skill_hash.is_empty()) { + return Err(Error::Verification { + code: crate::error::ErrorCode::SignatureInvalid, + message: "lineage store entries must carry a skill_hash".to_string(), + }); + } + Ok(Self { latest }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn doc( + hash: &str, + previous: Option<&str>, + version: Option<&str>, + schemapin: &str, + ) -> SkillSignature { + SkillSignature { + schemapin_version: schemapin.to_string(), + skill_name: "calc".to_string(), + skill_hash: hash.to_string(), + signature: String::new(), + signed_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: None, + schema_version: version.map(str::to_string), + previous_hash: previous.map(str::to_string), + canonicalization: None, + domain: "example.com".to_string(), + signer_kid: String::new(), + file_manifest: BTreeMap::new(), + } + } + + #[test] + fn flow_first_use_advance_unchanged() { + let mut s = LineageStore::new(); + assert_eq!( + s.check_and_advance( + "calc", + "example.com", + &doc("sha256:a", None, Some("1.0.0"), "1.4"), + true + ), + Ok(LineageResult::FirstUse) + ); + assert_eq!( + s.check_and_advance( + "calc", + "example.com", + &doc("sha256:a", None, Some("1.0.0"), "1.4"), + true + ), + Ok(LineageResult::Unchanged) + ); + assert_eq!( + s.check_and_advance( + "calc", + "example.com", + &doc("sha256:b", Some("sha256:a"), Some("1.1.0"), "1.4"), + true + ), + Ok(LineageResult::Advanced) + ); + assert_eq!(s.get("calc", "example.com").unwrap().skill_hash, "sha256:b"); + } + + #[test] + fn forks_rollbacks_versions_and_legacy() { + let mut s = LineageStore::new(); + s.check_and_advance( + "calc", + "example.com", + &doc("sha256:v4", None, Some("1.9.0"), "1.4"), + true, + ) + .unwrap(); + s.check_and_advance( + "calc", + "example.com", + &doc("sha256:v5", Some("sha256:v4"), Some("1.9.1"), "1.4"), + true, + ) + .unwrap(); + assert!(s + .check_and_advance( + "calc", + "example.com", + &doc("sha256:v5x", Some("sha256:v4"), None, "1.4"), + true + ) + .is_err()); + assert!(s + .check_and_advance( + "calc", + "example.com", + &doc("sha256:v6", None, None, "1.4"), + true + ) + .is_err()); + assert!(s + .check_and_advance( + "calc", + "example.com", + &doc("sha256:v6", Some("sha256:v5"), Some("1.9.1"), "1.4"), + true + ) + .is_err()); + assert!(s + .check_and_advance( + "calc", + "example.com", + &doc("sha256:v6", Some("sha256:v5"), Some("1.2.0"), "1.4"), + true + ) + .is_err()); + assert_eq!( + s.check_and_advance( + "calc", + "example.com", + &doc("sha256:v6", Some("sha256:v5"), Some("1.10.0"), "1.4"), + true + ), + Ok(LineageResult::Advanced) + ); + assert!(s + .check_and_advance( + "calc", + "example.com", + &doc("sha256:z", Some("sha256:v6"), None, "1.3"), + true + ) + .is_err()); + assert!(version_increases("1.9.0", "1.10.0")); + assert!(!version_increases("2.0.0", "2.0.0")); + assert!(version_increases("abc", "abd")); + let restored = LineageStore::from_json(&s.to_json().unwrap()).unwrap(); + assert_eq!( + restored + .get("calc", "example.com") + .unwrap() + .schema_version + .as_deref(), + Some("1.10.0") + ); + assert!(LineageStore::from_json("[]").is_err()); + } +} From 67ffeaa3df4cb263f737302553b721b3ee5966c3 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 15:19:08 -0700 Subject: [PATCH 09/14] Bump optional-feature dependencies and pin a patched Go toolchain 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. --- go/go.mod | 2 +- rust/Cargo.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/go/go.mod b/go/go.mod index 4704946..6ba4234 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,7 +2,7 @@ module github.com/ThirdKeyAi/schemapin/go go 1.23 -toolchain go1.26.0 +toolchain go1.26.9 require ( github.com/spf13/cobra v1.8.0 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 812228a..db90c92 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -103,9 +103,9 @@ checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -193,9 +193,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -502,9 +502,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1421,9 +1421,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "rustls-pki-types", @@ -1443,9 +1443,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", From 11c6c0e0e6ab3354470bc363cae75378ff08fe63 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 15:29:24 -0700 Subject: [PATCH 10/14] Add property and fuzz tests; parse floats with correct rounding in Rust 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. --- go/pkg/bundle/fuzz_test.go | 19 +++ go/pkg/core/fuzz_test.go | 39 ++++++ go/pkg/crypto/fuzz_test.go | 21 ++++ go/pkg/dns/fuzz_test.go | 18 +++ go/pkg/domain/fuzz_test.go | 21 ++++ go/pkg/skill/fuzz_test.go | 28 +++++ javascript/tests/property.test.js | 108 +++++++++++++++++ python/pyproject.toml | 4 +- python/tests/test_property_fuzz.py | 93 ++++++++++++++ rust/Cargo.lock | 114 +++++++++++++++++- rust/Cargo.toml | 7 +- rust/fuzz/Cargo.toml | 45 +++++++ rust/fuzz/README.md | 14 +++ rust/fuzz/fuzz_targets/bundle.rs | 17 +++ rust/fuzz/fuzz_targets/canonicalize.rs | 12 ++ rust/fuzz/fuzz_targets/pem_loader.rs | 10 ++ rust/fuzz/fuzz_targets/skill_signature.rs | 9 ++ rust/fuzz/fuzz_targets/txt_record.rs | 11 ++ rust/tests/property.rs | 59 +++++++++ .../canonicalization/34-float-17-digits.json | 8 ++ 20 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 go/pkg/bundle/fuzz_test.go create mode 100644 go/pkg/core/fuzz_test.go create mode 100644 go/pkg/crypto/fuzz_test.go create mode 100644 go/pkg/dns/fuzz_test.go create mode 100644 go/pkg/domain/fuzz_test.go create mode 100644 go/pkg/skill/fuzz_test.go create mode 100644 javascript/tests/property.test.js create mode 100644 python/tests/test_property_fuzz.py create mode 100644 rust/fuzz/Cargo.toml create mode 100644 rust/fuzz/README.md create mode 100644 rust/fuzz/fuzz_targets/bundle.rs create mode 100644 rust/fuzz/fuzz_targets/canonicalize.rs create mode 100644 rust/fuzz/fuzz_targets/pem_loader.rs create mode 100644 rust/fuzz/fuzz_targets/skill_signature.rs create mode 100644 rust/fuzz/fuzz_targets/txt_record.rs create mode 100644 rust/tests/property.rs create mode 100644 tests/cross-language/canonicalization/34-float-17-digits.json diff --git a/go/pkg/bundle/fuzz_test.go b/go/pkg/bundle/fuzz_test.go new file mode 100644 index 0000000..4d39f4c --- /dev/null +++ b/go/pkg/bundle/fuzz_test.go @@ -0,0 +1,19 @@ +package bundle + +import "testing" + +// FuzzParseTrustBundle asserts bundle parsing and verification never panic. +func FuzzParseTrustBundle(f *testing.F) { + f.Add(`{"schemapin_bundle_version":"1.2","created_at":"x","documents":[],"revocations":[]}`) + f.Add(`{"schemapin_bundle_version":"1.4","created_at":"x","documents":[{"domain":"a"},{"domain":"a"}],"revocations":[],"bundle_authority":{"kid":"k","public_key_pem":"bad"},"signature":"AAAA"}`) + f.Add(`[]`) + f.Add(``) + f.Fuzz(func(t *testing.T, doc string) { + b, err := ParseTrustBundle(doc) + if err != nil { + return + } + _, _ = VerifyTrustBundleWithOptions(b, NewAuthorityPinStore(), BundleVerifyOptions{AllowFirstUse: true}) + _ = MergeTrustBundles([]*SchemaPinTrustBundle{b, b}) + }) +} diff --git a/go/pkg/core/fuzz_test.go b/go/pkg/core/fuzz_test.go new file mode 100644 index 0000000..6324029 --- /dev/null +++ b/go/pkg/core/fuzz_test.go @@ -0,0 +1,39 @@ +package core + +import ( + "encoding/json" + "testing" +) + +// FuzzParseSchemaJSON asserts the strict parser never panics and that its +// output canonicalizes idempotently. +func FuzzParseSchemaJSON(f *testing.F) { + for _, seed := range []string{ + `{"a":1}`, `[1,2.5,"x",null,true]`, `{"b":{"a":[1e-7,0.1]},"
":"<>&"}`, + `{"a":1,"a":2}`, `{"a":NaN}`, `{"a":"\ud800"}`, `{"a":9007199254740993}`, `{"__proto__":1}`, + "", "{", "\xff", `"\u0000"`, + } { + f.Add([]byte(seed)) + } + f.Fuzz(func(t *testing.T, data []byte) { + v, err := ParseSchemaJSON(data) + if err != nil { + return + } + c1, err := CanonicalizeValue(v) + if err != nil { + t.Fatalf("parsed value failed to canonicalize: %v", err) + } + v2, err := ParseSchemaJSON([]byte(c1)) + if err != nil { + t.Fatalf("canonical output does not re-parse: %v", err) + } + c2, _ := CanonicalizeValue(v2) + if c1 != c2 { + t.Fatalf("not idempotent: %q vs %q", c1, c2) + } + if !json.Valid([]byte(c1)) { + t.Fatalf("canonical output is not valid JSON: %q", c1) + } + }) +} diff --git a/go/pkg/crypto/fuzz_test.go b/go/pkg/crypto/fuzz_test.go new file mode 100644 index 0000000..ae7dc03 --- /dev/null +++ b/go/pkg/crypto/fuzz_test.go @@ -0,0 +1,21 @@ +package crypto + +import "testing" + +// FuzzLoadPublicKeyPEM asserts the PEM loader never panics and only yields +// P-256 keys. +func FuzzLoadPublicKeyPEM(f *testing.F) { + km := NewKeyManager() + priv, _ := km.GenerateKeypair() + pem, _ := km.ExportPublicKeyPEM(&priv.PublicKey) + f.Add(pem) + f.Add("-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----") + f.Add("") + f.Fuzz(func(t *testing.T, s string) { + key, err := km.LoadPublicKeyPEM(s) + if err == nil && key.Curve.Params().Name != "P-256" { + t.Fatalf("accepted non-P-256 key %s", key.Curve.Params().Name) + } + _ = NewSignatureManager().VerifySignature([]byte("x"), s, &priv.PublicKey) + }) +} diff --git a/go/pkg/dns/fuzz_test.go b/go/pkg/dns/fuzz_test.go new file mode 100644 index 0000000..3ed8c00 --- /dev/null +++ b/go/pkg/dns/fuzz_test.go @@ -0,0 +1,18 @@ +package dns + +import "testing" + +// FuzzParseTxtRecord asserts the TXT parser never panics on arbitrary input. +func FuzzParseTxtRecord(f *testing.F) { + for _, seed := range []string{ + "v=schemapin1; fp=sha256:abc", "v=schemapin1;kid=x;fp=SHA256:ABC", "fp=sha256:abc", "v=;", "=", ";;;", "", + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, s string) { + rec, err := ParseTxtRecord(s) + if err == nil && rec.Fingerprint == "" { + t.Fatal("accepted record without fingerprint") + } + }) +} diff --git a/go/pkg/domain/fuzz_test.go b/go/pkg/domain/fuzz_test.go new file mode 100644 index 0000000..b92edff --- /dev/null +++ b/go/pkg/domain/fuzz_test.go @@ -0,0 +1,21 @@ +package domain + +import "testing" + +// FuzzValidateDomain asserts the validator never panics and is idempotent on +// its own output. +func FuzzValidateDomain(f *testing.F) { + for _, seed := range []string{"example.com", "Example.COM", "../x", "10.0.0.1", "a@b.c", "", "xn--bcher-kva.example"} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, s string) { + normalized, err := ValidateDomain(s) + if err != nil { + return + } + again, err := ValidateDomain(normalized) + if err != nil || again != normalized { + t.Fatalf("not idempotent: %q -> %q (%v)", normalized, again, err) + } + }) +} diff --git a/go/pkg/skill/fuzz_test.go b/go/pkg/skill/fuzz_test.go new file mode 100644 index 0000000..b4d61ea --- /dev/null +++ b/go/pkg/skill/fuzz_test.go @@ -0,0 +1,28 @@ +package skill + +import ( + "os" + "path/filepath" + "testing" +) + +// FuzzLoadSignature asserts .schemapin.sig parsing never panics and that any +// document it accepts can be turned into a signing input or is rejected +// cleanly. +func FuzzLoadSignature(f *testing.F) { + f.Add(`{"schemapin_version":"1.4","skill_name":"x","skill_hash":"sha256:00","signature":"","signed_at":"2026-01-01T00:00:00Z","domain":"example.com","signer_kid":"k","file_manifest":{"a":"sha256:00"}}`) + f.Add(`{"canonicalization":""}`) + f.Add(`[]`) + f.Add(``) + f.Fuzz(func(t *testing.T, doc string) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, SignatureFilename), []byte(doc), 0600); err != nil { + t.Fatal(err) + } + sig, err := LoadSignature(dir) + if err != nil { + return + } + _, _ = SigningInput(sig) + }) +} diff --git a/javascript/tests/property.test.js b/javascript/tests/property.test.js new file mode 100644 index 0000000..dc22719 --- /dev/null +++ b/javascript/tests/property.test.js @@ -0,0 +1,108 @@ +/** + * Property-based tests for the parsers that face untrusted input. + * + * Uses a small seeded generator (no extra dependency) to produce random JSON + * values and random strings: canonicalization must be idempotent and the + * parsers must fail only with their documented error types. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { SchemaPinCore, CanonicalizationError, MAX_SAFE_INTEGER } from '../src/core.js'; +import { parseTxtRecord } from '../src/dns.js'; +import { InvalidDomainError, validateDomain } from '../src/domain.js'; +import { signingInput } from '../src/skill.js'; + +function rng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const ALPHABET = 'ab\u00e9\u65e5\u{1F600}"\\/<>&\n\t\u0000\u2028\u2029 .-_@:{}[],0123456789'; + +function randomString(r, max = 8) { + const n = Math.floor(r() * max); + let out = ''; + for (let i = 0; i < n; i++) { + const chars = Array.from(ALPHABET); + out += chars[Math.floor(r() * chars.length)]; + } + return out; +} + +function randomValue(r, depth = 0) { + const k = r(); + if (depth > 3 || k < 0.15) return null; + if (k < 0.3) return r() < 0.5; + if (k < 0.5) return Math.floor((r() * 2 - 1) * MAX_SAFE_INTEGER); + if (k < 0.6) return (r() * 2 - 1) * 1e15 / (1 + r() * 1e6); + if (k < 0.75) return randomString(r); + if (k < 0.88) return Array.from({ length: Math.floor(r() * 4) }, () => randomValue(r, depth + 1)); + const obj = {}; + for (let i = 0; i < Math.floor(r() * 4); i++) obj[randomString(r)] = randomValue(r, depth + 1); + return obj; +} + +describe('property: canonicalization', () => { + test('is idempotent and agrees with JSON.parse on its own output', () => { + const r = rng(42); + for (let i = 0; i < 500; i++) { + const value = randomValue(r); + const canonical = SchemaPinCore.canonicalizeSchema(value); + const reparsed = SchemaPinCore.parseSchemaJson(canonical); + assert.equal(SchemaPinCore.canonicalizeSchema(reparsed), canonical); + assert.deepEqual(JSON.parse(canonical), reparsed); + } + }); + + test('strict parser only throws CanonicalizationError', () => { + const r = rng(7); + for (let i = 0; i < 1000; i++) { + const text = randomString(r, 24); + try { + SchemaPinCore.canonicalizeSchema(SchemaPinCore.parseSchemaJson(text)); + } catch (e) { + assert.ok(e instanceof CanonicalizationError, `${e.name}: ${e.message}`); + } + } + }); +}); + +describe('property: untrusted-input parsers', () => { + test('TXT parser and domain validator only throw their documented errors', () => { + const r = rng(99); + for (let i = 0; i < 1000; i++) { + const text = randomString(r, 32); + try { + assert.ok(parseTxtRecord(text).fingerprint.startsWith('sha256:')); + } catch (e) { + assert.ok(e instanceof Error && !(e instanceof TypeError) && !(e instanceof RangeError), e.name); + } + try { + const normalized = validateDomain(text); + assert.equal(validateDomain(normalized), normalized); + } catch (e) { + assert.ok(e instanceof InvalidDomainError, e.name); + } + } + }); + + test('signing input only throws Error on arbitrary documents', () => { + const r = rng(5); + for (let i = 0; i < 500; i++) { + const doc = randomValue(r, 2); + if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) continue; + try { + signingInput(doc); + } catch (e) { + assert.ok(e instanceof Error && !(e instanceof TypeError), e.name); + } + } + }); +}); diff --git a/python/pyproject.toml b/python/pyproject.toml index 9651727..5bfdce3 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ dev = [ "pytest==9.0.3", "pytest-cov==6.2.1", + "hypothesis>=6.100", "ruff==0.12.4", "bandit==1.8.6", "mypy==1.17.0", @@ -61,7 +62,8 @@ dev = [ ] test = [ "pytest>=7.4.0", - "pytest-cov>=4.1.0" + "pytest-cov>=4.1.0", + "hypothesis>=6.100" ] docs = [ "sphinx>=7.0.0", diff --git a/python/tests/test_property_fuzz.py b/python/tests/test_property_fuzz.py new file mode 100644 index 0000000..7359dbb --- /dev/null +++ b/python/tests/test_property_fuzz.py @@ -0,0 +1,93 @@ +"""Property-based tests for the parsers that face untrusted input. + +Canonicalization must be idempotent and total over valid JSON values, and +every parser must fail only with its documented exception type on arbitrary +input (never with a TypeError, IndexError, or a crash). +""" + +import json +import math + +from hypothesis import given, settings +from hypothesis import strategies as st + +from schemapin.core import MAX_SAFE_INTEGER, CanonicalizationError, SchemaPinCore +from schemapin.dns import parse_txt_record +from schemapin.domain import InvalidDomainError, validate_domain +from schemapin.skill import SkillSigner + +json_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(min_value=-MAX_SAFE_INTEGER, max_value=MAX_SAFE_INTEGER), + st.floats(allow_nan=False, allow_infinity=False, min_value=-1e15, max_value=1e15), + st.text(), +) +json_values = st.recursive( + json_scalars, + lambda children: st.one_of(st.lists(children, max_size=6), st.dictionaries(st.text(), children, max_size=6)), + max_leaves=40, +) + + +@settings(max_examples=300, deadline=None) +@given(json_values) +def test_canonicalization_is_idempotent_and_order_independent(value): + canonical = SchemaPinCore.canonicalize_schema(value) + reparsed = SchemaPinCore.parse_schema_json(canonical) + assert SchemaPinCore.canonicalize_schema(reparsed) == canonical + # Standard parser agrees with the strict one on well-formed output. + assert json.loads(canonical) == reparsed + + +@settings(max_examples=300, deadline=None) +@given(st.text()) +def test_strict_parser_only_raises_canonicalization_error(text): + try: + value = SchemaPinCore.parse_schema_json(text) + except CanonicalizationError: + return + assert SchemaPinCore.canonicalize_schema(value) is not None + + +@settings(max_examples=200, deadline=None) +@given(st.floats(allow_nan=False, allow_infinity=False)) +def test_number_policy_is_consistent(x): + try: + out = SchemaPinCore.canonicalize_schema(x) + except CanonicalizationError: + assert abs(x) > MAX_SAFE_INTEGER + return + assert abs(x) <= MAX_SAFE_INTEGER + assert float(out) == x + assert "e+" not in out or x != math.trunc(x) + + +@settings(max_examples=300, deadline=None) +@given(st.text()) +def test_txt_parser_only_raises_value_error(text): + try: + rec = parse_txt_record(text) + except ValueError: + return + assert rec.fingerprint.startswith("sha256:") + + +@settings(max_examples=300, deadline=None) +@given(st.text()) +def test_domain_validator_only_raises_invalid_domain(text): + try: + normalized = validate_domain(text) + except InvalidDomainError: + return + assert normalized == normalized.lower() + assert validate_domain(normalized) == normalized + + +@settings(max_examples=200, deadline=None) +@given(st.dictionaries(st.text(), json_values, max_size=8)) +def test_signing_input_only_raises_value_error(doc): + try: + SkillSigner.signing_input(doc) + except ValueError: + pass diff --git a/rust/Cargo.lock b/rust/Cargo.lock index db90c92..9f237cd 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -58,6 +58,21 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.10.0" @@ -1250,6 +1265,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.40" @@ -1278,10 +1318,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -1303,6 +1353,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1312,12 +1372,30 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1327,6 +1405,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.28" @@ -1458,6 +1542,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1492,6 +1588,7 @@ dependencies = [ "hex", "hickory-resolver", "p256", + "proptest", "rand 0.8.6", "reqwest", "serde", @@ -1953,6 +2050,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2006,6 +2109,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 6210c49..f3249b6 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -20,7 +20,7 @@ dns = ["hickory-resolver", "tokio", "async-trait"] [dependencies] serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["float_roundtrip"] } p256 = { version = "0.13", features = ["ecdsa", "pem"] } rand = "0.8" sha2 = "0.10" @@ -39,6 +39,11 @@ hickory-resolver = { version = "0.26", optional = true } [dev-dependencies] tempfile = "3.0" +proptest = "1.4" + +[workspace] +members = ["."] +exclude = ["fuzz"] [package.metadata.docs.rs] all-features = true diff --git a/rust/fuzz/Cargo.toml b/rust/fuzz/Cargo.toml new file mode 100644 index 0000000..84e524c --- /dev/null +++ b/rust/fuzz/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "schemapin-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +serde_json = "1.0" + +[dependencies.schemapin] +path = ".." + +[[bin]] +name = "canonicalize" +path = "fuzz_targets/canonicalize.rs" +test = false +doc = false + +[[bin]] +name = "txt_record" +path = "fuzz_targets/txt_record.rs" +test = false +doc = false + +[[bin]] +name = "skill_signature" +path = "fuzz_targets/skill_signature.rs" +test = false +doc = false + +[[bin]] +name = "bundle" +path = "fuzz_targets/bundle.rs" +test = false +doc = false + +[[bin]] +name = "pem_loader" +path = "fuzz_targets/pem_loader.rs" +test = false +doc = false diff --git a/rust/fuzz/README.md b/rust/fuzz/README.md new file mode 100644 index 0000000..8b1b9aa --- /dev/null +++ b/rust/fuzz/README.md @@ -0,0 +1,14 @@ +# Fuzz targets + +libFuzzer targets for the parsers that face untrusted input. They are not part +of the regular test run; install `cargo-fuzz` (nightly) and run, for example: + +```bash +cargo +nightly fuzz run canonicalize -- -max_total_time=300 +``` + +Targets: `canonicalize` (strict JSON parser + idempotence), `txt_record` +(DNS TXT parser), `skill_signature` (`.schemapin.sig` documents), +`bundle` (trust bundle parse/verify/merge) and `pem_loader` (public keys and +signatures). The same properties run under `proptest` in +`tests/property.rs` on stable as part of `cargo test`. diff --git a/rust/fuzz/fuzz_targets/bundle.rs b/rust/fuzz/fuzz_targets/bundle.rs new file mode 100644 index 0000000..42279c1 --- /dev/null +++ b/rust/fuzz/fuzz_targets/bundle.rs @@ -0,0 +1,17 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use schemapin::bundle::{merge_trust_bundles, verify_trust_bundle_with_options, BundleVerifyOptions}; +use schemapin::pinning::KeyPinStore; +use schemapin::types::bundle::SchemaPinTrustBundle; + +fuzz_target!(|data: &[u8]| { + if let Ok(bundle) = serde_json::from_slice::(data) { + let mut store = KeyPinStore::new(); + let _ = verify_trust_bundle_with_options( + &bundle, + &mut store, + &BundleVerifyOptions { allow_first_use: true }, + ); + let _ = merge_trust_bundles(&[bundle.clone(), bundle]); + } +}); diff --git a/rust/fuzz/fuzz_targets/canonicalize.rs b/rust/fuzz/fuzz_targets/canonicalize.rs new file mode 100644 index 0000000..dd27a0d --- /dev/null +++ b/rust/fuzz/fuzz_targets/canonicalize.rs @@ -0,0 +1,12 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use schemapin::canonicalize::{canonicalize_schema, parse_schema_json}; + +fuzz_target!(|data: &[u8]| { + let Ok(text) = std::str::from_utf8(data) else { return }; + if let Ok(value) = parse_schema_json(text) { + let c1 = canonicalize_schema(&value).expect("parsed value must canonicalize"); + let again = parse_schema_json(&c1).expect("canonical output must re-parse"); + assert_eq!(canonicalize_schema(&again).unwrap(), c1, "not idempotent"); + } +}); diff --git a/rust/fuzz/fuzz_targets/pem_loader.rs b/rust/fuzz/fuzz_targets/pem_loader.rs new file mode 100644 index 0000000..37df0b4 --- /dev/null +++ b/rust/fuzz/fuzz_targets/pem_loader.rs @@ -0,0 +1,10 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use schemapin::crypto::{calculate_key_id, verify_signature}; + +fuzz_target!(|data: &[u8]| { + if let Ok(text) = std::str::from_utf8(data) { + let _ = calculate_key_id(text); + let _ = verify_signature(text, b"x", "AAAA"); + } +}); diff --git a/rust/fuzz/fuzz_targets/skill_signature.rs b/rust/fuzz/fuzz_targets/skill_signature.rs new file mode 100644 index 0000000..f350acf --- /dev/null +++ b/rust/fuzz/fuzz_targets/skill_signature.rs @@ -0,0 +1,9 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use schemapin::skill::{signing_input, SkillSignature}; + +fuzz_target!(|data: &[u8]| { + if let Ok(sig) = serde_json::from_slice::(data) { + let _ = signing_input(&sig); + } +}); diff --git a/rust/fuzz/fuzz_targets/txt_record.rs b/rust/fuzz/fuzz_targets/txt_record.rs new file mode 100644 index 0000000..168c633 --- /dev/null +++ b/rust/fuzz/fuzz_targets/txt_record.rs @@ -0,0 +1,11 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use schemapin::dns::parse_txt_record; + +fuzz_target!(|data: &[u8]| { + if let Ok(text) = std::str::from_utf8(data) { + if let Ok(rec) = parse_txt_record(text) { + assert!(rec.fingerprint.starts_with("sha256:")); + } + } +}); diff --git a/rust/tests/property.rs b/rust/tests/property.rs new file mode 100644 index 0000000..b5480cc --- /dev/null +++ b/rust/tests/property.rs @@ -0,0 +1,59 @@ +//! Property-based tests (proptest) for the parsers that face untrusted input. + +use proptest::prelude::*; +use schemapin::canonicalize::{canonicalize_schema, parse_schema_json, MAX_SAFE_INTEGER}; +use schemapin::dns::parse_txt_record; +use schemapin::domain::validate_domain; +use serde_json::Value; + +fn json_value() -> impl Strategy { + let leaf = prop_oneof![ + Just(Value::Null), + any::().prop_map(Value::Bool), + (-(MAX_SAFE_INTEGER as i64)..=(MAX_SAFE_INTEGER as i64)).prop_map(Value::from), + (-1e15f64..1e15f64).prop_map(|f| serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null)), + ".*".prop_map(Value::String), + ]; + leaf.prop_recursive(4, 40, 6, |inner| { + prop_oneof![ + prop::collection::vec(inner.clone(), 0..6).prop_map(Value::Array), + prop::collection::btree_map(".*", inner, 0..6) + .prop_map(|m| Value::Object(m.into_iter().collect())), + ] + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(300))] + + #[test] + fn canonicalization_is_idempotent(value in json_value()) { + let c1 = canonicalize_schema(&value).unwrap(); + let again = parse_schema_json(&c1).unwrap(); + prop_assert_eq!(canonicalize_schema(&again).unwrap(), c1.clone()); + prop_assert_eq!(serde_json::from_str::(&c1).unwrap(), again); + } + + #[test] + fn strict_parser_never_panics(text in ".*") { + if let Ok(v) = parse_schema_json(&text) { + let _ = canonicalize_schema(&v); + } + } + + #[test] + fn txt_parser_never_panics(text in ".*") { + if let Ok(rec) = parse_txt_record(&text) { + prop_assert!(rec.fingerprint.starts_with("sha256:")); + } + } + + #[test] + fn domain_validator_is_idempotent(text in ".*") { + if let Ok(n) = validate_domain(&text) { + prop_assert_eq!(validate_domain(&n).unwrap(), n); + } + } +} diff --git a/tests/cross-language/canonicalization/34-float-17-digits.json b/tests/cross-language/canonicalization/34-float-17-digits.json new file mode 100644 index 0000000..b1c7975 --- /dev/null +++ b/tests/cross-language/canonicalization/34-float-17-digits.json @@ -0,0 +1,8 @@ +{ + "name": "34-float-17-digits", + "description": "Floats with 17 significant digits must be parsed with correct rounding; a best-effort parser (serde_json without float_roundtrip) yields a different double and therefore different bytes.", + "input": "[-118307074858543.23,0.30000000000000004,1.7976931348623157e-10,9007199254740991.0,0.1,123456789012.34567]", + "reject": false, + "expected_canonical": "[-118307074858543.23,0.30000000000000004,1.7976931348623158e-10,9007199254740991,0.1,123456789012.34567]", + "expected_sha256": "4ab42896d9ef9b7cdcfb50b9b9ff1b4994e6508e9cb9088405dbfed63e9c6818" +} From 7ebe3113dd6d90cb2011083af98488ecdd017b6f Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Fri, 9 Oct 2026 15:29:24 -0700 Subject: [PATCH 11/14] Bring the spec, docs, demo and CLI in line with the security fixes 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. --- CHANGELOG.md | 101 ++++++++++++++++++++++++++++++ README.md | 2 +- SECURITY.md | 21 +++++-- SKILL.md | 2 +- TECHNICAL_SPECIFICATION.md | 85 ++++++++++++++++--------- docs/api-reference.md | 2 +- docs/dns-txt.md | 2 +- docs/getting-started.md | 7 ++- docs/index.md | 2 +- docs/schema-version-binding.md | 2 +- docs/signature-expiration.md | 64 +++++++++---------- docs/skill-signing.md | 68 ++++++++++---------- docs/trust-bundle-distribution.md | 36 +++++++---- go/README.md | 3 +- integration_demo/demo_scenario.py | 60 +++++++++++++----- javascript/README.md | 4 +- python/README.md | 9 +-- python/tests/test_cli_tools.py | 21 ++----- python/tools/keygen.py | 6 +- tests/cross-language/README.md | 30 ++++++++- 20 files changed, 366 insertions(+), 161 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bdb26c..5e5c7c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,107 @@ All notable changes to the SchemaPin project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Security + +Fixes from the 2026-10 security review of all four SDKs, the reference server +and the demo. IDs refer to the review's findings. Wire-format changes are +called out explicitly; everything else is behavior. + +- **SP-1** — Skill signatures now cover the whole `.schemapin.sig` document + (`schemapin_version: "1.4"`): `expires_at`, `schema_version`, + `previous_hash`, `canonicalization`, `domain`, `skill_name`, `signer_kid`, + `signed_at` and `file_manifest` are all signed. Verifiers recompute the root + hash, require `skill_hash` to match, and verify over the canonical document. + Legacy `"1.3"` documents still verify (with a `legacy_signature_format` + warning) but cannot anchor a lineage. **Wire-format change** for new + signatures. +- **SP-2** — Go signed the schema digest directly while Python, JavaScript and + Rust sign `ECDSA(SHA-256(digest))`; Go now matches. Go-produced schema and + skill signatures from earlier releases will not verify elsewhere (they never + did). A fixed cross-language ECDSA vector is committed. +- **SP-3 / SP-4 / SP-5** — `schemapin-v1` canonicalization is now specified + normatively (spec §4) and implemented identically: code-point key order, + literal `<>&` and U+2028/9, minimal escaping, ES6 number formatting with a + 2^53−1 magnitude limit, rejection of `NaN`/`Infinity`, duplicate keys, lone + surrogates and invalid UTF-8, `__proto__` preserved. 33 shared vectors in + `tests/cross-language/canonicalization/` are asserted by all four suites. + Schemas containing floats such as `1.0` or integers above 2^53 canonicalize + differently than before (or are rejected). +- **SP-6** — Trust-bundle authorities must be allow-listed + (`seed_bundle_authority`); unknown authorities fail with + `BUNDLE_AUTHORITY_UNTRUSTED` unless first-use trust is explicitly allowed, + and the result reports `first_use`. +- **SP-7** — Reference server: bearer-token authentication on all mutations + (off by default), merge-on-upload that never wipes revocations, no CORS + credentials with wildcard origins, enforced rate limit, localhost/debug-off + defaults, host-based `/.well-known/schemapin.json` with no first-developer + fallback. +- **SP-8** — Skill verification binds the caller's domain: resolver paths fail + with `DOMAIN_MISMATCH` before any I/O when the signature's `domain` differs, + offline paths accept an expected domain, and the signature's own `domain` + is display-only. +- **SP-9** — Revocation is fail-closed everywhere: an unreachable + `revocation_endpoint` or `.well-known` document is `DISCOVERY_FETCH_FAILED` + (opt-out: `allow_unreachable_revocation`), `ChainResolver` propagates + errors, and the legacy workflows consult `revocation_endpoint`. +- **SP-10** — Only ECDSA P-256 keys are accepted in every SDK (load, sign, + verify, fingerprint). +- **SP-11** — Discovery transport: `https://` only, no redirects, 64 KiB body + cap, timeouts in every fetcher, and a shared RFC 1123 domain validator + applied before URLs, pin keys and file paths. +- **SP-12 / SP-13** — Skill canonicalization v2: symlinks and special files + abort hashing, per-entry hashes are length-prefixed + (`"f" ‖ be64(len(path)) ‖ path ‖ be64(len(content)) ‖ content`), the root + covers path/digest pairs, and entry count and total size are capped. + **Wire-format change** (selected by `schemapin_version: "1.4"`). +- **SP-14** — `skill_hash` is `hex(root_hash)` in all SDKs (Rust previously + hashed the root hash again) and is verified. +- **SP-15** — Legacy `KeyPinning` stores are keyed by `(tool_id, domain)` with + in-place migration; the JavaScript store fails hard on corruption, writes + atomically with mode 0600 and checks revocation in its interactive flows; + the legacy workflows pin only after a successful verification. +- **SP-16** — `LocalFileResolver` validates the domain and confines resolved + paths to the configured directory. +- **SP-17** — Expiration fails closed (`SIGNATURE_EXPIRED`, 5-minute skew, + strict RFC 3339); `allow_expired` restores the degraded behavior per call. +- **SP-18** — Private keys are written with mode 0600; committed demo and + fixture private keys were removed and the fixture bundle re-signed under the + test-only authority `schemapin-test-fixture-authority`. +- **SP-19** — Parity fixes: strict RFC 3339 parsing in JavaScript, Go rejects + an empty `canonicalization` field and trailing DER bytes, `go.mod` pins a + toolchain, bundle signing input is built from the known field set in every + SDK, duplicate bundle domains are rejected. +- **SP-20** — Documentation, CLI and demo drift fixed (see below). +- Pins are written only after signature verification in every verification + path; `KeyPinStore` gains `check` / `pin` / `seed`. +- Trust-bundle merges honor an authority precedence list and compare parsed + timestamps. + +### Added + +- `LineageStore` (all SDKs): verifier-side `latest_known_hash` per + `tool_id@domain` with fork/rollback and monotonic `schema_version` + enforcement (spec §18.4). +- `verify_schema_offline_with_dns` (all SDKs). +- Cross-language fixtures: canonicalization corpus, ECDSA signature vector, + v1.4 and legacy skill directories, re-signed trust bundle, and test-only keys + under `tests/cross-language/keys/`. +- Property-based tests (hypothesis, proptest, Go native fuzz seeds, a seeded + generator in JavaScript) and libFuzzer targets under `rust/fuzz/`. +- `server/tests/` for the reference server. + +### Changed + +- Dependency bumps: `h2`, `rustls`, `crossbeam-epoch`, `chacha20` (Rust, + optional features); Go toolchain pinned to go1.26.9. +- `KeyPinning` / `SchemaVerificationWorkflow` are deprecated in favor of + `KeyPinStore` + `verify_schema_with_resolver`. +- `rust`: `canonicalize_schema` / `canonicalize_and_hash` return `Result`; + `verify_trust_bundle` is strict (use `verify_trust_bundle_with_options` + for first-use trust). + ## [1.4.0-alpha.4] - 2026-06-21 ### Added diff --git a/README.md b/README.md index 6d0e2e7..4c1c80d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ SchemaPin lets tool developers sign their schemas and skill folders with ECDSA P - **Skill folder signing** for AgentSkills (SKILL.md + file manifests) - **Cross-language** — Python, JavaScript, Go, and Rust implementations -> **v1.4.0-alpha.2** (all four languages): three additive optional features — [signature expiration (`expires_at`)](https://docs.schemapin.org/signature-expiration/) with degraded-not-failed verification, [DNS TXT cross-verification](https://docs.schemapin.org/dns-txt/) at `_schemapin.{domain}` for second-channel trust, and [schema version binding (`schema_version` + `previous_hash`)](https://docs.schemapin.org/schema-version-binding/) for opt-in lineage chain enforcement that defends against rug-pull substitutions. v1.3 verifiers ignore the new fields; v1.4 verifiers handle both. The remaining v1.4 items (canonicalization id, A2A context, A2A trust bundles, scan-aware sigs, cross-agent schema cache) ship in subsequent alphas before stable v1.4.0. +> **v1.4.0-alpha.2** (all four languages): three additive optional features — [signature expiration (`expires_at`)](https://docs.schemapin.org/signature-expiration/) with fail-closed verification (`allow_expired` opt-out), [DNS TXT cross-verification](https://docs.schemapin.org/dns-txt/) at `_schemapin.{domain}` for second-channel trust, and [schema version binding (`schema_version` + `previous_hash`)](https://docs.schemapin.org/schema-version-binding/) for opt-in lineage chain enforcement that defends against rug-pull substitutions. v1.3 verifiers ignore the new fields; v1.4 verifiers handle both. The remaining v1.4 items (canonicalization id, A2A context, A2A trust bundles, scan-aware sigs, cross-agent schema cache) ship in subsequent alphas before stable v1.4.0. ## Quick Start diff --git a/SECURITY.md b/SECURITY.md index 33b1f1f..9fb62bc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,22 @@ We actively support the following versions of SchemaPin with security updates: -| Version | Supported | -| ------- | ------------------ | -| 1.1.x | :white_check_mark: | -| 1.0.x | :white_check_mark: | -| < 1.0 | :x: | +| Version | Supported | +| -------------- | ------------------ | +| 1.4.x (alpha) | :white_check_mark: | +| 1.3.x | :white_check_mark: | +| 1.2.x | :white_check_mark: | +| < 1.2 | :x: | + +## Security Properties and Known Limitations + +- **Fail-closed by default.** Expired skill signatures (`SIGNATURE_EXPIRED`), unreachable revocation sources (`DISCOVERY_FETCH_FAILED`), unknown trust-bundle authorities (`BUNDLE_AUTHORITY_UNTRUSTED`) and domain mismatches all fail verification. Each has an explicit, per-call opt-out (`allow_expired`, `allow_unreachable_revocation`, `allow_first_use`) that a host must choose deliberately. +- **Transport.** Discovery and revocation fetches are `https://` only, never follow redirects, cap the response body and apply timeouts. Domains are validated against the RFC 1123 hostname grammar before they are used as URLs, pin keys or file names. +- **Keys.** Only ECDSA P-256 keys are accepted anywhere. Private keys produced by the tooling are written with mode 0600; never commit them. +- **Pinning.** A pin is written only after a signature has verified, and pins are keyed by tool id *and* domain. Automatic first-use pinning is an opt-in; interactive or pre-seeded (`KeyPinStore.seed`, DNS TXT) trust is recommended. +- **Legacy skill signatures** (`schemapin_version: "1.3"`) cover only the root hash: `expires_at`, `schema_version`, `previous_hash`, `file_manifest` and the other metadata fields are not signed. They still verify, with a `legacy_signature_format` warning, but cannot anchor a lineage. Re-sign skills with a current SDK to get the signed document format. +- **Trust bundles** carry their own authority key. Seed the authority fingerprint out of band; treat `first_use` on a bundle exactly like a first-use tool key. +- **Reference server.** The server in `server/` is a reference implementation; its management API is disabled by default and requires a bearer token when enabled. Terminate TLS in front of it. ## Reporting a Vulnerability diff --git a/SKILL.md b/SKILL.md index 13d5234..d378e80 100644 --- a/SKILL.md +++ b/SKILL.md @@ -357,7 +357,7 @@ Both features are **additive optional fields/records**: v1.3 verifiers ignore th ### Signature Expiration (`expires_at`) -Optional ISO 8601 / RFC 3339 timestamp on `.schemapin.sig`. Verifiers past the timestamp degrade the result (warning) instead of failing — `valid` stays `true`, `expired` becomes `true`, and a `signature_expired` warning is appended. +Optional RFC 3339 timestamp on `.schemapin.sig`, covered by the signature. Verifiers past the timestamp (plus 5 minutes of skew) fail with `SIGNATURE_EXPIRED`; with the explicit `allow_expired` opt-in the result degrades instead — `valid` stays `true`, `expired` becomes `true`, and a `signature_expired` warning is appended. ```rust use schemapin::skill::{sign_skill_with_options, SignOptions, verify_skill_offline}; diff --git a/TECHNICAL_SPECIFICATION.md b/TECHNICAL_SPECIFICATION.md index ba7aeea..75e7c08 100644 --- a/TECHNICAL_SPECIFICATION.md +++ b/TECHNICAL_SPECIFICATION.md @@ -42,12 +42,16 @@ To ensure universal compatibility and strong security, implementations of Schema ### **4. Schema Canonicalization** -To ensure that a signature is valid across different platforms and environments, the tool schema's JSON object **MUST** be converted to a canonical string format before hashing and signing. The canonicalization process is as follows: +To ensure that a signature is valid across different platforms and environments, the tool schema's JSON object **MUST** be converted to a canonical string format before hashing and signing. The algorithm is identified as `schemapin-v1` (see Section 19) and is defined normatively here; the shared conformance corpus in `tests/cross-language/canonicalization/` is the reference for the exact bytes, and every implementation **MUST** pass it. -1. **Encoding:** The JSON string **MUST** be encoded using **UTF-8**. +1. **Encoding:** The canonical string **MUST** be encoded as UTF-8. Input that is not valid UTF-8 **MUST** be rejected. 2. **Whitespace:** All insignificant whitespace between JSON elements **MUST** be removed. -3. **Key Sorting:** The keys (names) in all JSON objects **MUST** be sorted lexicographically (alphabetically). This must be applied recursively to any nested objects. -4. **Serialization:** JSON data types MUST be serialized according to a strict, consistent format. +3. **Key Sorting:** The names in every JSON object **MUST** be sorted by Unicode code point (equivalently, by the UTF-8 byte sequence of the name). This is *not* UTF-16 code-unit order: `"z"` (U+007A) sorts before U+FFFF, which sorts before U+1F600. Sorting is applied recursively to nested objects; array element order is preserved. +4. **Duplicate Keys:** An object containing the same name more than once has no canonical form and **MUST** be rejected. +5. **Strings:** Strings are emitted with the minimal escaping set: `"` as `\"`, `\` as `\\`, U+0008, U+000C, U+000A, U+000D and U+0009 as `\b`, `\f`, `\n`, `\r`, `\t`, and every other control character below U+0020 as lowercase `\u00xx`. Every other code point — including `/`, `<`, `>`, `&`, U+007F, U+2028 and U+2029 and all non-ASCII — **MUST** be emitted literally. Lone surrogates have no canonical form and **MUST** be rejected. +6. **Numbers:** Numbers are interpreted as IEEE 754 binary64 values. Any number whose absolute value exceeds 2^53 − 1 (9007199254740991) **MUST** be rejected, so that no two distinct inputs can collide after rounding; `NaN` and infinities are not JSON and **MUST** be rejected. Integral values are emitted as plain decimal integers without a sign for zero (`1.0` → `1`, `1e3` → `1000`, `-0` → `0`). Non-integral values are emitted with the ECMAScript `Number::toString` algorithm (RFC 8785 Section 3.2.2.3): shortest round-trip digits, fixed notation for exponents between −7 and 21, otherwise `d.ddde±N` (`0.1`, `0.000001`, `1e-7`, `123.456`). +7. **Literals:** `true`, `false` and `null` are emitted as such. +8. **Special-looking keys:** Names such as `__proto__`, `constructor` or `prototype` are ordinary keys and **MUST** be preserved and signed; implementations in languages where such names have prototype semantics **MUST** serialize without materializing intermediate objects. Example: @@ -65,6 +69,24 @@ Becomes the following canonical string before signing: `{"description":"Calculates the sum","name":"calculate_sum","parameters":{"a":"integer","b":"integer"}}` +#### **4.1. Signature Input** + +The schema signature is an ECDSA P-256 signature with SHA-256 whose message is the 32-byte SHA-256 digest of the canonical UTF-8 bytes (that is, ECDSA is applied to SHA-256(SHA-256(canonical))). All implementations **MUST** use exactly this construction; `tests/cross-language/signatures/schema-signature.json` is the reference vector. + +### **4b. Skill Directory Canonicalization (v1.4)** + +Skill signing (Section 16 and `docs/skill-signing.md`) hashes a directory tree rather than a JSON document. The algorithm, identified by `schemapin_version: "1.4"` in the signature document, is: + +1. Walk the directory. Directories themselves are not entries; empty directories are not covered. The top-level `.schemapin.sig` is excluded. +2. Any symbolic link (to a file or a directory) or non-regular file **MUST** abort canonicalization: unsigned content must never be reachable from a signed skill. Paths that are not valid UTF-8 **MUST** be rejected. Implementations **MUST** cap the number of entries and the total size (the reference SDKs use 10 000 entries and 256 MiB). +3. Each entry's path is its forward-slash-separated path relative to the skill root, encoded as UTF-8; entries are sorted by code point. +4. Entry digest: `SHA-256(0x66 ‖ be64(len(path)) ‖ path ‖ be64(len(content)) ‖ content)` where `be64` is the 64-bit big-endian length. The `file_manifest` maps each path to `"sha256:" + hex(entry digest)`. +5. Root hash: `SHA-256` over the concatenation, in sorted order, of `be64(len(path)) ‖ path ‖ entry digest` for every entry. `skill_hash` is `"sha256:" + hex(root hash)`. + +The signature document is then built with every field present and `signature` set to `""`, canonicalized with `schemapin-v1` (Section 4), and signed as ECDSA P-256 with SHA-256 over the canonical bytes. Verifiers **MUST** recompute the root hash, require `skill_hash` to equal it, rebuild the signing input from the parsed known fields (unknown fields are ignored and therefore unprotected) and verify the signature over those bytes. `tests/cross-language/skill/` holds the reference fixture. + +Documents with `schemapin_version` below `"1.4"` use the legacy algorithm (`SHA-256(path ‖ content)` per file, root hash over the concatenated hex digests, symlinks skipped, signature over the 32-byte root hash only). Verifiers **MAY** accept them for compatibility but **MUST** surface that none of their metadata fields is signed (the reference SDKs emit a `legacy_signature_format` warning), and **MUST NOT** anchor lineage (Section 18) on them. New signatures **MUST NOT** use the legacy format. + ### **5. Signature and Key Formats** - **Signature:** The generated signature **MUST** be a **detached signature**, encoded in **Base64**. It should be distributed as a separate file or field alongside the schema itself (e.g., in an `x-schema-signature` HTTP header or a `signature` field in a manifest file). @@ -124,7 +146,7 @@ The `contact` and `revocation_endpoint` fields are OPTIONAL. The `revocation_end a. Check for a locally "pinned" public key for this tool/developer. b. If no key is pinned, construct the .well-known URI based on the tool's domain and fetch the public key. - c. Pin the Key: Upon successful first fetch, store the public key locally and associate it with the tool's identifier. This is the "pinning" step. The user should be prompted for confirmation before trusting a new key. + c. Pin the Key: After the signature in step 3 has verified, store the public key locally and associate it with the tool's identifier *and* the domain it was fetched for. This is the "pinning" step; a failed verification **MUST NOT** leave a pin behind. The user **SHOULD** be prompted for confirmation before trusting a new key — automatic first-use pinning is an explicit opt-in, not the default — and a fingerprint obtained out of band (for example from a DNS TXT record, Section 17) **MAY** be seeded in advance. 3. Verify: a. Canonicalize the fetched schema using the exact rules in Section 4. @@ -217,7 +239,8 @@ Clients MUST check both revocation sources when available: - **Key Revocation:** Keys MUST be immediately revoked upon suspected compromise. Clients MUST check revocation status before each verification. - **Key Compromise:** A developer whose private key is compromised must generate a new key pair and work with client applications to transition trust to the new key. -- **Transport Security:** All communications for public key discovery MUST use HTTPS to prevent man-in-the-middle attacks during the initial key fetch. +- **Transport Security:** All communications for public key discovery MUST use HTTPS to prevent man-in-the-middle attacks during the initial key fetch. Implementations MUST refuse `http://` (including domain strings that carry a scheme), MUST NOT follow redirects, MUST apply connect and read timeouts, MUST cap the response body (the reference SDKs use 64 KiB), and MUST validate the domain against the RFC 1123 hostname grammar (ASCII, at least two labels, no scheme, path, port, userinfo or IP literal) before building a URL, a pin key or a local file path from it. +- **Revocation Availability:** A declared `revocation_endpoint` (or `.well-known` document) that cannot be fetched is a verification failure (`DISCOVERY_FETCH_FAILED`), not "not revoked". Implementations MAY offer an explicit opt-out for air-gapped use, but the default MUST fail closed. - **Key Pinning:** Once a public key is pinned for a tool, clients MUST NOT automatically accept a different key without explicit user consent, even if served from the same `.well-known` endpoint. - **Signature Validation:** Clients MUST validate signatures before executing any tool functionality. Invalid signatures MUST result in immediate rejection of the tool. - **Canonical Form Consistency:** All implementations MUST use identical canonicalization rules to ensure signature compatibility across different platforms and libraries. @@ -320,10 +343,10 @@ SchemaPin v1.2 defines `verify_schema_offline()` as the core verification primit 1. **Validate discovery document** — Check that the well-known response is structurally valid (non-empty PEM key, valid schema version). 2. **Extract public key and compute fingerprint** — Parse the PEM public key and calculate its SHA-256 fingerprint. 3. **Check revocation** — Check both the simple `revoked_keys` list and any standalone revocation document. -4. **TOFU key pinning** — Check the key fingerprint against the pin store. On first use, pin the key. On match, continue. On change, reject. +4. **TOFU key pinning check** — Compare the key fingerprint with the pin store for `tool_id@domain` without writing. On match, continue. On change, reject with `KEY_PIN_MISMATCH`. On first use, remember that a pin must be written later. 5. **Canonicalize schema** — Apply the canonicalization rules from Section 4 and compute the SHA-256 hash. 6. **Verify ECDSA signature** — Verify the Base64-encoded signature against the schema hash using the public key. -7. **Return result** — Return a structured `VerificationResult` with the domain, developer name, key pinning status, and any errors or warnings. +7. **Pin and return** — Only after step 6 succeeds, pin the fingerprint on first use, then return a structured `VerificationResult` with the domain, developer name, key pinning status, and any errors or warnings. A failed verification never writes a pin. The `domain` argument MUST be validated (Section 9) and is the only source of the pin key and of the reported domain. ### **16. Signature Expiration (v1.4)** @@ -347,20 +370,15 @@ The `expires_at` field is an ISO 8601 / RFC 3339 timestamp in UTC: } ``` -A signature document with `expires_at` MUST set `schemapin_version` to `"1.4"` or higher; documents without `expires_at` MAY retain the v1.3 version string. +A signature document with `expires_at` MUST set `schemapin_version` to `"1.4"` or higher, which also selects the signed-document format of Section 4b so that `expires_at` is covered by the signature. New signatures use `"1.4"` whether or not they expire. #### **16.2. Verifier Semantics** -When the current time is past `expires_at`, verifiers MUST treat the result as **degraded**, not failed: - -- `valid` remains `true` (the cryptographic signature is intact) -- An `expired: true` flag is set on the result -- A `signature_expired` warning is appended -- The `expires_at` value is mirrored on the result for confidence scoring +Expiration is **fail-closed**. When the current time is more than the clock-skew allowance (300 seconds) past `expires_at`, verifiers MUST fail with `SIGNATURE_EXPIRED` and MUST set `expired: true` and mirror `expires_at` on the result. `expires_at` MUST be a strict RFC 3339 timestamp (`T` separator, mandatory `Z` or numeric offset); a value that does not parse MUST also fail with `SIGNATURE_EXPIRED` — a malformed expiry is indistinguishable from a stripped one. -A degraded result is intended to feed policy decisions: clients MAY refuse to load expired skills, or downgrade them to a lower trust tier, while preserving the ability to inspect signed metadata. +Verifiers MAY offer an explicit per-call opt-in (`allow_expired`) that restores the original "degraded" behavior: `valid` stays `true`, `expired` is set, and a `signature_expired` (or `signature_expires_at_unparseable`) warning is appended for confidence scoring. That mode is a policy decision by the host and MUST NOT be the default. -If `expires_at` cannot be parsed as RFC 3339, verifiers MUST emit a `signature_expires_at_unparseable` warning and MUST NOT treat the signature as expired (fail-open on parse, not fail-closed — malformed metadata should not silently invalidate otherwise-valid signatures). +**Risk statement.** The degraded mode was the default in v1.4.0-alpha.1 through alpha.4. Because every library exit reported `valid: true` for expired signatures and no shipped consumer checked `expired`, expiry provided no security property; combined with the then-unsigned `expires_at` field (Section 4b) it could be stripped or altered freely. From this revision `expires_at` is covered by the signature and expiry fails closed. #### **16.3. Backward Compatibility** @@ -369,6 +387,8 @@ If `expires_at` cannot be parsed as RFC 3339, verifiers MUST emit a `signature_e ### **17. DNS TXT Cross-Verification (v1.4)** +The cross-check applies to both skill and schema verification (`verify_skill_offline_with_dns` and `verify_schema_offline_with_dns`), runs before any pin is written, and a mismatch is a hard `DOMAIN_MISMATCH` failure. + SchemaPin v1.4 adds an OPTIONAL second-channel verification mechanism via DNS TXT records. The DNS credential chain is independent of the HTTPS hosting credential chain, so an attacker compromising one does not automatically gain control of the other. #### **17.1. TXT Record Format** @@ -484,12 +504,12 @@ A publisher MAINTAINING a chain SHOULD: 2. When signing v_{n+1}, set `previous_hash = skill_hash_of_v_n`. 3. Distribute v_{n+1} alongside (or with the ability for verifiers to fetch) v_n. -A verifier APPLYING chain enforcement SHOULD maintain a per-tool `latest_known_hash` pinned alongside the TOFU public key. On encountering a signature with `previous_hash`: +A verifier APPLYING chain enforcement SHOULD maintain a per-tool `latest_known_hash` pinned alongside the TOFU public key. The reference SDKs provide this as `LineageStore` (`check_and_advance(tool_id, domain, signature)`), keyed by `tool_id@domain` and persisted as JSON. On encountering a new, already-verified `schemapin_version >= "1.4"` signature: -- Match against `latest_known_hash` → accept and roll forward. -- No match → prompt the operator (similar to TOFU key rotation) before accepting; an unauthorized substitution would either omit `previous_hash` or claim a hash the verifier has not seen. - -This pairs cleanly with `schema_version`: the verifier can also enforce monotonic version progression as a policy (e.g., refuse downgrades). +- `skill_hash` equals `latest_known_hash` → unchanged. +- `previous_hash` equals `latest_known_hash` and `schema_version` (when both carry one) increases numerically (`major.minor.patch`) → accept and roll forward. +- `previous_hash` absent or naming any other hash → reject (fork or rollback); an unauthorized substitution would either omit `previous_hash` or claim a hash the verifier has not seen. Operators MAY override after inspection, exactly like a TOFU key rotation. +- A legacy 1.3 document MUST be refused as a lineage anchor: its `skill_hash` and `previous_hash` are not covered by its signature. #### **18.5. Backward Compatibility** @@ -593,7 +613,9 @@ Implementations SHOULD copy these helpers from this section rather than taking a #### **21.1. Purpose** -Allow a **bundle authority** to sign a trust bundle so it can be exchanged between agents over A2A without per-bundle out-of-band trust establishment. The receiving verifier authenticates the bundle and TOFU-pins the authority key by `kid`, the same trust model used for tool signing keys. All fields are optional and additive — an unsigned bundle is unchanged from v1.2/v1.3. +Allow a **bundle authority** to sign a trust bundle so it can be exchanged between agents over A2A without per-bundle out-of-band trust establishment. The receiving verifier authenticates the bundle against an **authority allow-list**: the authority's key fingerprint MUST be known to the verifier before a bundle from that `kid` is accepted. All fields are optional and additive — an unsigned bundle is unchanged from v1.2/v1.3. + +**Risk statement.** A bundle carries its own authority public key, so the `kid` inside the bundle is attacker-chosen. Pinning the authority on first use by `kid` (the alpha.4 behavior) accepted any self-signed bundle under a fresh `kid`, which let a malicious A2A peer load keys for arbitrary domains. First-use trust therefore requires an explicit opt-in and MUST be surfaced to the operator like any other first-use key. #### **21.2. Wire Format** @@ -618,20 +640,23 @@ The authority public key is carried as `public_key_pem` (consistent with discove #### **21.3. Signing Input** -The signature covers the `schemapin-v1` canonicalization (§19; recursive sorted keys, compact, UTF-8) of the entire bundle object with the `signature` field set to the empty string `""`. All four SDKs MUST produce the identical byte string so a bundle signed by any SDK verifies in every other. +The signature covers the `schemapin-v1` canonicalization (Section 4) of the bundle's **known fields** with the `signature` field set to the empty string `""`: `schemapin_bundle_version`, `created_at`, `documents` (each entry's `domain`, `schema_version`, `public_key_pem`, `revoked_keys` — always present, possibly empty — and, when present, `developer_name`, `contact`, `revocation_endpoint`), `revocations` (always present), `bundle_authority`, `signed_at` and `expires_at` when present. Unknown fields are not part of the signing input and are therefore unprotected. The signature is ECDSA P-256 with SHA-256 over the canonical bytes. All four SDKs MUST produce the identical byte string so a bundle signed by any SDK verifies in every other; `tests/cross-language/signed_bundle.json` (authority `kid` `schemapin-test-fixture-authority`, a test-only identity) is the reference. #### **21.4. Operations** - `sign_trust_bundle(bundle, private_key_pem, kid, signed_at, expires_at?)` — derive the authority public key, stamp `bundle_authority` / `signed_at` / `expires_at`, and write the signature. -- `verify_trust_bundle(bundle, authority_pin_store)`: +- `seed_bundle_authority(store, kid, fingerprint)` / `seed_bundle_authority_from_pem(store, kid, public_key_pem)` — record an expected authority (the allow-list). The fingerprint is obtained out of band, for example from the authority domain's own `.well-known/schemapin.json` or DNS TXT record. +- `verify_trust_bundle(bundle, authority_pin_store, allow_first_use=false)`: 1. Require `bundle_authority` and `signature` — else `BUNDLE_UNSIGNED`. - 2. Reject when `expires_at` is present and in the past or unparseable — `BUNDLE_EXPIRED`. - 3. TOFU-pin the authority key fingerprint by `kid`; a different key under a pinned `kid` is `KEY_PIN_MISMATCH`. - 4. Verify the signature over the canonical bytes — failure is `SIGNATURE_INVALID`. -- `merge_trust_bundles(bundles)` — deduplicate `documents` and `revocations` by domain, the newer source (`signed_at`, else `created_at`) winning. Returns an UNSIGNED bundle to be re-signed before redistribution. + 2. Reject bundles with duplicate domain entries — `DISCOVERY_INVALID`. + 3. Reject when `expires_at` is present and in the past or not strict RFC 3339 — `BUNDLE_EXPIRED`. + 4. Look the authority's key fingerprint up by `kid`: a different seeded key is `KEY_PIN_MISMATCH`; an unknown `kid` is `BUNDLE_AUTHORITY_UNTRUSTED` unless `allow_first_use` is set. + 5. Verify the signature over the canonical bytes — failure is `SIGNATURE_INVALID`. + 6. Pin the authority on first use only after step 5, and return a structured result carrying `kid`, `fingerprint` and `first_use` so hosts can gate on it. +- `merge_trust_bundles(bundles, authority_precedence?)` — deduplicate `documents` and `revocations` by domain. The winner is chosen first by authority precedence (an ordered list of `kid`s; unlisted or absent authorities rank last) and then by the newer **parsed** RFC 3339 timestamp (`signed_at`, else `created_at`); unparseable timestamps rank oldest. Only verified bundles MUST be merged. Returns an UNSIGNED bundle to be re-signed before redistribution. - `schemapin/trustBundle` JSON-RPC envelope helpers — `build_trust_bundle_request` / `build_trust_bundle_response` / `parse_trust_bundle_response`. The libraries define the message envelope; transport and the receiving pin-store update are the host application's responsibility. #### **21.5. Backward Compatibility** - Unsigned bundles omit all four fields and are byte-identical to pre-v1.4 bundles; existing resolvers ignore the new fields. -- The `BUNDLE_UNSIGNED` and `BUNDLE_EXPIRED` error codes are new in v1.4. +- The `BUNDLE_UNSIGNED`, `BUNDLE_EXPIRED` and `BUNDLE_AUTHORITY_UNTRUSTED` error codes are new in v1.4. diff --git a/docs/api-reference.md b/docs/api-reference.md index e889f8d..d8a41ea 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -418,7 +418,7 @@ All v1.4 additions are optional fields/parameters — v1.3 clients are unaffecte ### Signature Expiration — alpha.1 -Optional `expires_at` on signatures; expired signatures degrade to a warning rather than failing. See [Signature Expiration](signature-expiration.md). +Optional `expires_at` on signatures; expired signatures fail with `SIGNATURE_EXPIRED` unless the caller opts in with `allow_expired`. See [Signature Expiration](signature-expiration.md). ### DNS TXT Cross-Verification — alpha.1 diff --git a/docs/dns-txt.md b/docs/dns-txt.md index 3700998..84eae4d 100644 --- a/docs/dns-txt.md +++ b/docs/dns-txt.md @@ -1,6 +1,6 @@ # DNS TXT Cross-Verification -> **Status:** v1.4.0-alpha.1 — **Rust only.** Python, JavaScript, and Go ports follow in subsequent alphas before the v1.4.0 release. The wire format is frozen; only the implementations need to catch up. +> **Status:** v1.4 — implemented in **Rust, Python, JavaScript, and Go** for both skills (`verify_skill_offline_with_dns`) and schemas (`verify_schema_offline_with_dns`). The cross-check runs before any key is pinned; a mismatch is a hard `DOMAIN_MISMATCH` failure. SchemaPin v1.3 anchors trust in HTTPS: a public key published at `https://example.com/.well-known/schemapin.json`, served over TLS, fingerprinted, and pinned. That works — but it leans on a single credential chain. An attacker with control of the TLS cert, the web origin, or the static-asset bucket can publish a forged discovery document and serve it as if it were the real one. diff --git a/docs/getting-started.md b/docs/getting-started.md index 0b8c649..375662a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -274,12 +274,15 @@ from schemapin.utils import SchemaVerificationWorkflow workflow = SchemaVerificationWorkflow() +# auto_pin=False (the default) never pins: first-use keys must be accepted +# interactively (KeyPinning in PinningMode.INTERACTIVE) or pre-seeded. +# auto_pin=True trusts whatever the domain serves on first use -- only use +# it where a human or a second channel (DNS TXT) has confirmed the key. result = workflow.verify_schema( schema=schema, signature_b64=signature, tool_id="example.com/calculate_sum", domain="example.com", - auto_pin=True, ) if result["valid"]: @@ -361,7 +364,7 @@ const result = verifySkillOffline('./my-skill/', discoveryData, sig, null, pinSt - [Skill Signing](skill-signing.md) — SkillSigner deep dive - [Trust Bundles](trust-bundles.md) — Offline and air-gapped verification - [Revocation](revocation.md) — Rotate keys and serve signed revocation documents -- [Signature Expiration](signature-expiration.md) — v1.4-alpha (all 4 languages): `expires_at` and degraded-not-failed verification +- [Signature Expiration](signature-expiration.md) — v1.4-alpha (all 4 languages): `expires_at`, signed and fail-closed - [DNS TXT Cross-Verification](dns-txt.md) — v1.4-alpha (all 4 languages): second-channel `_schemapin.{domain}` lookups - [Schema Version Binding](schema-version-binding.md) — v1.4-alpha (all 4 languages): `schema_version` + `previous_hash` lineage chain - [A2A Verification Context](a2a-context.md) — v1.4-alpha (all 4 languages): scope verification to caller-trusted domains diff --git a/docs/index.md b/docs/index.md index d0e6d5c..5b72450 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,7 +77,7 @@ All four implementations use identical crypto (ECDSA P-256 + SHA-256) — cross- | [Skill Signing](skill-signing.md) | Sign and verify skill directories (v1.3) | | [Trust Bundles](trust-bundles.md) | Offline verification and pluggable resolvers | | [Revocation](revocation.md) | Rotate keys, publish revocation documents, fail closed | -| [Signature Expiration](signature-expiration.md) | `expires_at` field for degraded-not-failed verification (v1.4-alpha, all 4 languages) | +| [Signature Expiration](signature-expiration.md) | `expires_at` field, signed and fail-closed (v1.4, all 4 languages) | | [DNS TXT Cross-Verification](dns-txt.md) | Second-channel `_schemapin.{domain}` lookups (v1.4-alpha, all 4 languages) | | [Schema Version Binding](schema-version-binding.md) | `schema_version` + `previous_hash` lineage chain to defend against rug-pull substitutions (v1.4-alpha, all 4 languages) | | [A2A Verification Context](a2a-context.md) | Scope schema verification to caller-trusted domains across A2A boundaries (v1.4-alpha, all 4 languages) | diff --git a/docs/schema-version-binding.md b/docs/schema-version-binding.md index 360925f..2398fdb 100644 --- a/docs/schema-version-binding.md +++ b/docs/schema-version-binding.md @@ -203,7 +203,7 @@ A publisher rolling a chain SHOULD: 2. When signing v_{n+1}, set `previous_hash = skill_hash_of_v_n`. 3. Distribute v_{n+1} so verifiers can resolve v_n (e.g., publish both, or include v_n's hash in a registry). -A verifier enforcing the chain SHOULD maintain a per-tool `latest_known_hash` next to the TOFU public-key pin. On encountering a new signature with `previous_hash`: +A verifier enforcing the chain SHOULD maintain a per-tool `latest_known_hash` next to the TOFU public-key pin. Every SDK ships this as `LineageStore` (`check_and_advance(tool_id, domain, signature)` returns `first_use`, `unchanged` or `advanced`; `seed` pre-loads a trusted head; `to_json` / `from_json` persist it). Only feed it documents that already verified and that use the signed-document format (`schemapin_version >= "1.4"`); legacy 1.3 documents carry an unsigned `skill_hash` and are refused. On encountering a new signature with `previous_hash`: | State | Action | |-------|--------| diff --git a/docs/signature-expiration.md b/docs/signature-expiration.md index f8bf4fb..6e03532 100644 --- a/docs/signature-expiration.md +++ b/docs/signature-expiration.md @@ -1,12 +1,12 @@ # Signature Expiration (`expires_at`) -> **Status:** v1.4.0-alpha.1 — **Rust only.** Python, JavaScript, and Go ports follow in subsequent alphas before the v1.4.0 release. The wire format is frozen; only the implementations need to catch up. +> **Status:** v1.4 — implemented in **Rust, Python, JavaScript, and Go**. `expires_at` is covered by the skill signature (signed-document format, `schemapin_version: "1.4"`) and verification is **fail-closed** past the expiry. A v1.3 signature is valid forever once issued. There's a `signed_at` timestamp on every `.schemapin.sig`, but no expiration — a signature minted two years ago on an abandoned tool reads as "valid" identically to one minted yesterday. There's no forcing function for developers to re-sign after a security review, and verifiers can't distinguish *actively maintained* from *signed once and forgotten*. -SchemaPin v1.4 adds an OPTIONAL `expires_at` field to `.schemapin.sig`. Past the expiration, verifiers **degrade** the result (warning, lower confidence) — they don't fail it. Cryptographically the signature is still intact, so callers retain the ability to inspect signed metadata. What changes is the trust posture: an expired signature feeds policy gating and confidence scoring, not a hard refusal. +SchemaPin v1.4 adds an OPTIONAL `expires_at` field to `.schemapin.sig`. Past the expiration (plus a 5-minute clock-skew allowance) verifiers **fail** with `SIGNATURE_EXPIRED`. A host that prefers the older "degraded, not failed" posture can opt in per call with `allow_expired`; then `valid` stays `true`, `expired` is set and a `signature_expired` warning is appended for policy gating. The field is part of the signed document, so it cannot be stripped or altered without invalidating the signature. -This page covers the Rust API. The other languages follow the same wire format. +This page shows the Rust API; the other languages expose the same options (`allow_expired` in Python, `allowExpired` in JavaScript, `VerifyOptions.AllowExpired` in Go). --- @@ -64,53 +64,51 @@ let opts = SignOptions::new() --- -## Verifier semantics: degraded, not failed +## Verifier semantics: fail-closed, with an explicit opt-out -`verify_skill_offline` automatically applies the expiration check after a successful signature verification. The `VerificationResult` gains two new fields: +`verify_skill_offline` applies the expiration check after a successful signature verification. The `VerificationResult` carries: | Field | Type | Meaning | |-------|------|---------| -| `valid` | `bool` | Cryptographic signature validity — **unchanged by expiration** | -| `expired` | `bool` | `true` if `expires_at` is set and now > expires_at | +| `valid` | `bool` | `false` when expired (default) or when `expires_at` is not strict RFC 3339 | +| `error_code` | `Option` | `SignatureExpired` for both cases | +| `expired` | `bool` | `true` if `expires_at` is set and now > expires_at + 300 s | | `expires_at` | `Option` | Mirrors the value from the signature when present | -| `warnings` | `Vec` | Includes `"signature_expired"` when expired | +| `warnings` | `Vec` | `"signature_expired"` / `"signature_expires_at_unparseable"` only when `allow_expired` was used | ```rust -use schemapin::skill::verify_skill_offline; +use schemapin::skill::{verify_skill_offline_with_options, VerifyOptions}; -let result = verify_skill_offline( - &skill_dir, &discovery, None, revocation.as_ref(), - Some(&mut pin_store), Some("payments-tool"), +let result = verify_skill_offline_with_options( + &skill_dir, &discovery, None, revocation.as_ref(), Some(&mut pin_store), + &VerifyOptions { domain: Some("example.com"), tool_id: Some("payments-tool"), allow_expired: false }, ); if !result.valid { - return Err("signature invalid".into()); + // SignatureExpired is a hard failure by default. + return Err(format!("{:?}: {}", result.error_code, result.error_message.unwrap_or_default()).into()); } +``` + +### Opting in to degraded verification -if result.expired { - log::warn!( - "skill signature expired at {} — treat as degraded trust", - result.expires_at.as_deref().unwrap_or("(unknown)"), - ); - // Policy decision: refuse, downgrade, prompt, or allow with caveat +```rust +let degraded = verify_skill_offline_with_options( + &skill_dir, &discovery, None, None, None, + &VerifyOptions { allow_expired: true, ..VerifyOptions::default() }, +); +if degraded.valid && degraded.expired { + log::warn!("skill signature expired at {} — policy decision", degraded.expires_at.as_deref().unwrap_or("?")); } ``` -### Why degrade instead of fail? - -A hard fail would create a deployment cliff every time a maintainer misses a renewal — the same UX problem TLS certificate expiry causes. SchemaPin's design choice: surface the staleness, let the policy layer decide. A risk-averse runtime can refuse expired signatures; a permissive one can prompt; a CI pipeline can fail builds when expiration is within a configurable window. +### Why fail closed? -The cryptographic guarantee (this artifact was signed by this key) is unchanged by elapsed time. What changes is the freshness signal that an actively-maintained publisher would re-sign periodically. +The original alpha design degraded instead of failing, by analogy with the TLS deployment cliff. In practice no consumer checked `expired`, so an expired signature was indistinguishable from a fresh one — and because the field was not signed it could simply be removed. Failing closed makes the expiry a real security property; the deployment-cliff concern belongs to the policy layer, which is why `allow_expired` exists as a deliberate, per-call choice rather than the default. ### Unparseable timestamps -If `expires_at` is present but cannot be parsed as RFC 3339, the verifier emits a `signature_expires_at_unparseable` warning and treats the signature as **not expired** — fail-open on parse, not fail-closed. Malformed metadata should not silently invalidate otherwise-valid signatures. - -```rust -if result.warnings.iter().any(|w| w == "signature_expires_at_unparseable") { - // Log; the publisher's tooling is producing malformed metadata. -} -``` +`expires_at` must be strict RFC 3339 (`2026-10-30T12:00:00Z` or with a numeric offset). A value that does not parse is treated like an expired signature (`SIGNATURE_EXPIRED`): a malformed expiry is indistinguishable from a stripped one. With `allow_expired` it degrades to a `signature_expires_at_unparseable` warning instead. --- @@ -123,7 +121,7 @@ Together with the existing `signed_at`, `expires_at` enables a simple confidence | `signed_at` recent (< 30 days), `expires_at` distant | high | | `signed_at` old, `expires_at` distant | medium | | `expires_at` near (< 7 days) | medium-low | -| `expired = true` | degraded — gate on policy | +| `expired = true` (only reachable with `allow_expired`) | degraded — gate on policy | | no `expires_at` (v1.3) | unchanged — assume long-lived | Implementations that need scoring can compute it from the result: @@ -153,7 +151,7 @@ fn confidence(result: &VerificationResult) -> &'static str { | Verifier ↓ / Signature → | v1.3 sig (no `expires_at`) | v1.4 sig (with `expires_at`) | |--------------------------|----------------------------|------------------------------| | **v1.3 verifier** | works | works (field ignored) | -| **v1.4 verifier** | works (no expiration applied) | works (degrades when past `expires_at`) | +| **v1.4 verifier** | works (no expiration applied) | works; fails with `SIGNATURE_EXPIRED` past `expires_at` | Both directions are intentional — v1.4 is purely additive. There is no situation where bumping a verifier or a signer to v1.4 breaks an existing deployment. @@ -164,4 +162,4 @@ Both directions are intentional — v1.4 is purely additive. There is no situati - [Technical specification — Section 16](https://github.com/ThirdKeyAI/SchemaPin/blob/main/TECHNICAL_SPECIFICATION.md#16-signature-expiration-v14) — normative wire format - [DNS TXT cross-verification](dns-txt.md) — the other v1.4 feature, also additive - [Skill signing](skill-signing.md) — the v1.3 base this builds on -- [Revocation](revocation.md) — orthogonal: revocation is hard-fail, expiration is degraded +- [Revocation](revocation.md) — orthogonal: both are hard failures by default diff --git a/docs/skill-signing.md b/docs/skill-signing.md index 30811c8..d804bc9 100644 --- a/docs/skill-signing.md +++ b/docs/skill-signing.md @@ -1,6 +1,6 @@ -# Skill Signing with SkillSigner (v1.3) +# Skill Signing with SkillSigner (v1.3 / v1.4) -SchemaPin v1.3 introduces SkillSigner — sign entire skill directories with ECDSA P-256. This produces a `.schemapin.sig` manifest alongside the files, proving no file has been tampered with. +SchemaPin v1.3 introduced SkillSigner — sign entire skill directories with ECDSA P-256. This produces a `.schemapin.sig` manifest alongside the files, proving no file has been tampered with. Since v1.4 the signature covers the **whole signature document** (name, domain, expiry, lineage fields and the file manifest), not just the root hash, and the directory hashing is length-prefixed and refuses symbolic links. Both changes are selected by `schemapin_version: "1.4"`; 1.3 signatures still verify with a `legacy_signature_format` warning. --- @@ -17,7 +17,7 @@ my-skill/ └── config.json # Optional configuration ``` -SkillSigner hashes every file in the directory (except `.schemapin.sig` itself), creates a file manifest, signs the root hash, and writes the result to `.schemapin.sig`. +SkillSigner hashes every regular file in the directory (except the top-level `.schemapin.sig`), creates a file manifest, builds the signature document, signs it, and writes the result to `.schemapin.sig`. Symbolic links, sockets and other special files are refused: unsigned content must never be reachable from a signed skill. Empty directories and file modes are not covered. --- @@ -26,18 +26,14 @@ SkillSigner hashes every file in the directory (except `.schemapin.sig` itself), ### Python ```python -from schemapin.skill import sign_skill +from schemapin.skill import SkillSigner -# Sign the skill directory (writes .schemapin.sig) -sig = sign_skill( - skill_dir="./my-skill/", - private_key_pem=private_key_pem, - domain="example.com", -) +# Sign the skill directory (writes .schemapin.sig); returns the document as a dict +sig = SkillSigner.sign_skill("./my-skill/", private_key_pem, "example.com") -print(f"Skill signed: {sig.skill_name}") -print(f"Root hash: {sig.skill_hash}") -print(f"Files: {list(sig.file_manifest.keys())}") +print(f"Skill signed: {sig['skill_name']}") +print(f"Root hash: {sig['skill_hash']}") +print(f"Files: {list(sig['file_manifest'])}") ``` ### JavaScript @@ -45,10 +41,10 @@ print(f"Files: {list(sig.file_manifest.keys())}") ```javascript import { signSkill } from 'schemapin/skill'; -const sig = await signSkill('./my-skill/', privateKeyPem, 'example.com'); -console.log('Skill signed:', sig.skillName); -console.log('Root hash:', sig.skillHash); -console.log('Files:', Object.keys(sig.fileManifest)); +const sig = signSkill('./my-skill/', privateKeyPem, 'example.com'); // synchronous +console.log('Skill signed:', sig.skill_name); +console.log('Root hash:', sig.skill_hash); +console.log('Files:', Object.keys(sig.file_manifest)); ``` ### Go @@ -66,9 +62,10 @@ fmt.Printf("Skill signed: %s\n", sig.SkillName) ### Rust ```rust +use std::path::Path; use schemapin::skill::sign_skill; -let sig = sign_skill("./my-skill/", &private_key_pem, "example.com", None, None)?; +let sig = sign_skill(Path::new("./my-skill/"), &private_key_pem, "example.com", None, None)?; println!("Skill signed: {}", sig.skill_name); ``` @@ -80,7 +77,7 @@ After signing, `.schemapin.sig` contains: ```json { - "schemapin_version": "1.3", + "schemapin_version": "1.4", "skill_name": "my-skill", "skill_hash": "sha256:a1b2c3d4e5f6...", "signature": "MEUCIQD7y2F8...", @@ -97,16 +94,17 @@ After signing, `.schemapin.sig` contains: | Field | Description | |-------|-------------| -| `schemapin_version` | Protocol version (`1.3`) | +| `schemapin_version` | Format version. `1.4` = signed document (current); `1.3` = legacy root-hash-only signature (verify only) | | `skill_name` | Name of the skill (from directory name or frontmatter) | -| `skill_hash` | SHA-256 hash of the concatenated file hashes (root hash) | -| `signature` | Base64-encoded ECDSA signature over the root hash | -| `signed_at` | ISO 8601 timestamp of when the skill was signed | -| `domain` | Domain of the signer | +| `skill_hash` | `sha256:` + hex of the root hash (see below); verifiers recompute and compare it | +| `signature` | Base64 DER ECDSA P-256 signature over the canonical signature document with `signature` set to `""` | +| `signed_at` | RFC 3339 timestamp of when the skill was signed | +| `expires_at`, `schema_version`, `previous_hash`, `canonicalization` | Optional v1.4 fields; all covered by the signature | +| `domain` | Domain of the signer. Verifiers bind it to the domain they resolved; it is never used as a trust input on its own | | `signer_kid` | SHA-256 fingerprint of the signing key | -| `file_manifest` | Map of relative file paths to their SHA-256 hashes | +| `file_manifest` | Map of relative file paths to their entry hashes; covered by the signature | -The `.schemapin.sig` file is automatically excluded from hashing — you can re-sign a directory without removing the old signature first. +**Hashing (v1.4).** Each entry is hashed as `SHA-256("f" ‖ be64(len(path)) ‖ path ‖ be64(len(content)) ‖ content)` and the root hash is `SHA-256` over `be64(len(path)) ‖ path ‖ entry digest` for every entry in code-point path order. The length prefixes make it impossible to move bytes between a file name and its content without changing the hash. The top-level `.schemapin.sig` is excluded, so you can re-sign a directory without removing the old signature first; a nested file of that name is ordinary content. Entry count (10 000) and total size (256 MiB) are capped. The normative definition is spec section 4b and `tests/cross-language/skill/` is the reference fixture. --- @@ -119,23 +117,25 @@ Verify a skill without HTTP calls using a pre-fetched discovery document: #### Python ```python -from schemapin.skill import verify_skill_offline +from schemapin.skill import SkillSigner from schemapin.verification import KeyPinStore -result = verify_skill_offline( - skill_dir="./my-skill/", - discovery_data=discovery_doc, - signature=sig, +result = SkillSigner.verify_skill_offline( + "./my-skill/", + discovery_doc, # the .well-known document for `domain` revocation_doc=None, # pass a parsed revocation doc to fail closed on revoked keys pin_store=KeyPinStore(), + domain="example.com", # bind the skill to the domain the discovery doc belongs to ) if result.valid: print("Skill verified successfully") else: - print(f"Verification failed: {result.error}") + print(f"Verification failed: {result.error_code}: {result.error_message}") ``` +Always pass `domain`: the signature's own `domain` field is display-only, and a mismatch is a hard `DOMAIN_MISMATCH` failure. Expired signatures fail with `SIGNATURE_EXPIRED` unless you pass `allow_expired=True`. The pin is written only after the signature verified. + **Pass a `revocation_doc`** to fail closed when a publisher has rotated keys. Skill verification honours the same combined inline + standalone revocation rules as schema verification — see the [Revocation guide](revocation.md) for fetching, parsing, and operational guidance. #### JavaScript @@ -173,6 +173,8 @@ let result = verify_skill_offline( ## Tamper Detection +`detect_tampered_files` compares the current manifest with the signed one and is a diagnostic aid that tells you *which* files changed. For `schemapin_version: "1.4"` documents the manifest is covered by the signature; for legacy 1.3 documents it is not, so an attacker can edit the files and the manifest together — rely on the signature result, not on the diff, for the trust decision. + Check if files have been modified, added, or removed since signing: ### Python @@ -200,7 +202,7 @@ if tampered.removed: import { detectTamperedFiles, canonicalizeSkill } from 'schemapin/skill'; const { manifest: currentManifest } = canonicalizeSkill('./my-skill/'); -const tampered = detectTamperedFiles(currentManifest, sig.fileManifest); +const tampered = detectTamperedFiles(currentManifest, sig.file_manifest); if (tampered.modified.length > 0) { console.log('Modified:', tampered.modified); diff --git a/docs/trust-bundle-distribution.md b/docs/trust-bundle-distribution.md index 9e803a7..028c6ec 100644 --- a/docs/trust-bundle-distribution.md +++ b/docs/trust-bundle-distribution.md @@ -4,7 +4,7 @@ A [trust bundle](trust-bundles.md) pre-packages discovery and revocation documents for offline verification. Until v1.4 a bundle had no authenticity of its own — you had to trust however it reached you. That is fine for a bundle you build and ship yourself, but not for one an agent hands to another agent over A2A. -v1.4 lets a **bundle authority** sign a trust bundle so it can be exchanged between agents without per-bundle out-of-band trust establishment. The receiving agent verifies the signature and TOFU-pins the authority key by `kid`, exactly as it would pin a tool's signing key. +v1.4 lets a **bundle authority** sign a trust bundle so it can be exchanged between agents without per-bundle out-of-band trust establishment. The receiving agent verifies the signature against an **authority allow-list**: the authority's key fingerprint must have been seeded (out of band — for example from the authority domain's own `.well-known/schemapin.json`) before a bundle from that `kid` is accepted. First-use trust is an explicit opt-in that is reported on the result, because the bundle carries its own public key and its `kid` is chosen by whoever produced it. All additions are optional fields — an unsigned bundle is byte-for-byte what it was before v1.4, and v1.2/v1.3 consumers ignore the new fields. @@ -21,7 +21,7 @@ A signed bundle gains four optional top-level fields: "documents": [ /* ... */ ], "revocations": [ /* ... */ ], "bundle_authority": { - "kid": "schemapin-bundle-authority-2026", + "kid": "my-bundle-authority-2026", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }, "signed_at": "2026-05-15T00:00:00Z", @@ -47,8 +47,9 @@ The signature covers the **`schemapin-v1` canonicalization** (recursive sorted k | Operation | Purpose | |-----------|---------| | `sign_trust_bundle(bundle, private_key_pem, kid, signed_at, expires_at?)` | Stamp authority metadata and write the signature. Derives the authority public key from the private key. | -| `verify_trust_bundle(bundle, authority_pin_store)` | Verify the signature, reject expired bundles, and TOFU-pin the authority key by `kid`. | -| `merge_trust_bundles(bundles)` | Combine bundles from multiple sources, deduplicating by domain (newest wins). Returns an **unsigned** bundle to re-sign before redistribution. | +| `seed_bundle_authority(store, kid, fingerprint)` / `seed_bundle_authority_from_pem(store, kid, pem)` | Add an authority to the allow-list. | +| `verify_trust_bundle(bundle, authority_pin_store, allow_first_use=false)` | Verify the signature, reject expired or duplicate-domain bundles, and require the authority to be allow-listed (`BUNDLE_AUTHORITY_UNTRUSTED` otherwise). Returns `{ kid, fingerprint, first_use }`. | +| `merge_trust_bundles(bundles, authority_precedence?)` | Combine verified bundles from multiple sources, deduplicating by domain: authority precedence first, then the newer parsed timestamp. Returns an **unsigned** bundle to re-sign before redistribution. | | `build_trust_bundle_request` / `build_trust_bundle_response` / `parse_trust_bundle_response` | `schemapin/trustBundle` JSON-RPC envelope helpers for A2A exchange. | (Function names are camel/Pascal-cased per language — e.g. `signTrustBundle` in JS, `SignTrustBundle` in Go.) @@ -58,9 +59,11 @@ The signature covers the **`schemapin-v1` canonicalization** (recursive sorted k `verify_trust_bundle` runs: 1. Require `bundle_authority` and `signature` — else `BUNDLE_UNSIGNED`. -2. If `expires_at` is present and in the past (or unparseable) — `BUNDLE_EXPIRED`. -3. TOFU-pin the authority key fingerprint by `kid`. A different key reusing a pinned `kid` — an impersonation attempt — fails with `KEY_PIN_MISMATCH`. -4. Verify the signature over the canonical bytes — failure is `SIGNATURE_INVALID`. +2. Reject duplicate domain entries — `DISCOVERY_INVALID`. +3. If `expires_at` is present and in the past (or not strict RFC 3339) — `BUNDLE_EXPIRED`. +4. Look the authority key fingerprint up by `kid`. A different seeded key — an impersonation attempt — fails with `KEY_PIN_MISMATCH`; an unknown `kid` fails with `BUNDLE_AUTHORITY_UNTRUSTED` unless `allow_first_use` was passed. +5. Verify the signature over the canonical bytes — failure is `SIGNATURE_INVALID`. +6. Pin the authority on first use only now, and report `first_use` on the result so the host can surface it like any first-use key. --- @@ -74,12 +77,13 @@ use schemapin::pinning::KeyPinStore; let signed = sign_trust_bundle( &bundle, &authority_private_pem, - "schemapin-bundle-authority-2026", + "my-bundle-authority-2026", "2026-05-15T00:00:00Z", Some("2026-08-15T00:00:00Z"), )?; -// A receiving agent verifies it and TOFU-pins the authority. +// A receiving agent seeds the authority it expects, then verifies. +seed_bundle_authority_from_pem(&mut authorities, "my-bundle-authority-2026", &authority_public_pem)?; let mut authorities = KeyPinStore::new(); verify_trust_bundle(&signed, &mut authorities)?; @@ -97,7 +101,17 @@ responsibility (e.g. a Symbiont coordinator receiving the message over A2A). The bundle authority is a long-lived signing key. To rotate it, sign new bundles under a new `kid` and distribute the new authority public key through -the same channel you bootstrap any first-use key. Verifiers TOFU-pin per `kid`, -so a new `kid` is a first-use pin, not a mismatch. +the same out-of-band channel you used to seed the original one. Verifiers +seed the new `kid` before accepting bundles from it; a bundle under an +unseeded `kid` is refused, not silently pinned. The merge helper's +`authority_precedence` list lets a receiver prefer entries from its primary +authority over anything a peer forwards. + +## Fixture + +`tests/cross-language/signed_bundle.json` is signed by the test-only authority +`schemapin-test-fixture-authority` whose key lives under +`tests/cross-language/keys/`. It is not a production identity; never seed a +persistent store with it. See [Technical specification](https://github.com/ThirdKeyAI/SchemaPin/blob/main/TECHNICAL_SPECIFICATION.md) for the normative definition. diff --git a/go/README.md b/go/README.md index c814d56..442d705 100644 --- a/go/README.md +++ b/go/README.md @@ -179,7 +179,8 @@ Options: --tool-id string Tool identifier for key pinning --public-key string Explicit public key file (skips discovery) --db-path string Key pinning database path (default "~/.schemapin/keys.db") - --auto-pin Automatically pin keys on first use + --auto-pin Pin keys on first use without prompting (requires --tool-id; opt-in, + trusts whatever the domain serves -- prefer --interactive) --interactive Enable interactive key pinning prompts --timeout duration Discovery timeout (default 10s) ``` diff --git a/integration_demo/demo_scenario.py b/integration_demo/demo_scenario.py index 27c1b3a..77dfbbc 100644 --- a/integration_demo/demo_scenario.py +++ b/integration_demo/demo_scenario.py @@ -8,6 +8,7 @@ import argparse import json +import os import subprocess import sys import time @@ -18,6 +19,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "python")) from schemapin.crypto import KeyManager +from schemapin.discovery import PublicKeyDiscovery from schemapin.utils import SchemaSigningWorkflow, SchemaVerificationWorkflow, create_well_known_response from schemapin.interactive import ConsoleInteractiveHandler @@ -232,12 +234,21 @@ def prompt_pin_key(self, context): return True verification_workflow = SchemaVerificationWorkflow() - - # Mock discovery for demo + + # The demo runs offline, so discovery is served from the local demo + # keys through a resolver-style stub. Revocation is checked against + # the same local discovery document (no revoked keys) instead of being + # disabled: never bypass validate_key_not_revoked in real code. public_key_pem = (self.keys_dir / "bob.example.com_public.pem").read_text() - verification_workflow.discovery.get_public_key_pem = lambda domain: public_key_pem + local_discovery = {"schema_version": "1.4", "developer_name": "Bob Industries", + "public_key_pem": public_key_pem, "revoked_keys": []} + verification_workflow.discovery.fetch_well_known = lambda domain, timeout=10: local_discovery + verification_workflow.discovery.get_public_key_pem = lambda domain: local_discovery["public_key_pem"] verification_workflow.discovery.get_developer_info = lambda domain: {"developer_name": "Bob Industries"} - verification_workflow.discovery.validate_key_not_revoked = lambda key_pem, domain: True + verification_workflow.discovery.validate_key_not_revoked = ( + lambda key_pem, domain, timeout=10, allow_unreachable=False: + not PublicKeyDiscovery.check_key_revocation(key_pem, local_discovery["revoked_keys"]) + ) result = verification_workflow.verify_schema( signed_data["schema"], @@ -298,8 +309,17 @@ def scenario_3_key_revocation(self): verification_workflow = SchemaVerificationWorkflow() # Mock discovery to return new key but show old key as revoked + # Local discovery document that lists the old key as revoked; the + # revocation check is evaluated against it rather than bypassed. + old_fingerprint = KeyManager.calculate_key_fingerprint_from_pem(old_public_pem) + local_discovery = {"schema_version": "1.4", "developer_name": "Alice Corp", + "public_key_pem": new_public_pem, "revoked_keys": [old_fingerprint]} + verification_workflow.discovery.fetch_well_known = lambda domain, timeout=10: local_discovery verification_workflow.discovery.get_public_key_pem = lambda domain: new_public_pem - verification_workflow.discovery.validate_key_not_revoked = lambda key_pem, domain: key_pem != old_public_pem + verification_workflow.discovery.validate_key_not_revoked = ( + lambda key_pem, domain, timeout=10, allow_unreachable=False: + not PublicKeyDiscovery.check_key_revocation(key_pem, local_discovery["revoked_keys"]) + ) # This should fail because signature was made with revoked key result = verification_workflow.verify_schema( @@ -371,8 +391,13 @@ def scenario_4_batch_processing(self): # Verify all with Python verification_workflow = SchemaVerificationWorkflow() public_key_pem = (self.keys_dir / "alice.example.com_public.pem").read_text() + local_discovery = {"schema_version": "1.4", "public_key_pem": public_key_pem, "revoked_keys": []} + verification_workflow.discovery.fetch_well_known = lambda domain, timeout=10: local_discovery verification_workflow.discovery.get_public_key_pem = lambda domain: public_key_pem - verification_workflow.discovery.validate_key_not_revoked = lambda key_pem, domain: True + verification_workflow.discovery.validate_key_not_revoked = ( + lambda key_pem, domain, timeout=10, allow_unreachable=False: + not PublicKeyDiscovery.check_key_revocation(key_pem, local_discovery["revoked_keys"]) + ) valid_count = 0 for signed_item in signed_schemas: @@ -403,23 +428,30 @@ def scenario_5_server_discovery(self): print("\n🧪 Scenario 5: Server-based Discovery") print("=" * 70) - print("🌐 This scenario requires the .well-known server to be running") - print(" Start the server with: cd ../server && python well_known_server.py") - print(" Then run this scenario again") - + print("🌐 This scenario requires the .well-known server behind TLS") + print(" Start the server (cd ../server && python well_known_server.py), put a TLS") + print(" terminator in front of it, and point SCHEMAPIN_DEMO_SERVER_URL at the https URL.") + print(" SchemaPin clients refuse plaintext discovery, so http:// is not accepted here.") + + server_url = os.environ.get("SCHEMAPIN_DEMO_SERVER_URL", "") + if not server_url.startswith("https://"): + print(" SCHEMAPIN_DEMO_SERVER_URL is not set to an https:// URL; skipping.") + return True + # Check if server is running try: import requests - response = requests.get("http://localhost:8000/health", timeout=2) + response = requests.get(f"{server_url}/health", timeout=2, allow_redirects=False) if response.status_code == 200: print("✅ Server is running!") - # Test discovery endpoints + # Test discovery endpoints (RFC 8615 path, developer selected by Host) developers = ["alice.example.com", "bob.example.com", "charlie.example.com"] for domain in developers: try: - well_known_url = f"http://localhost:8000/.well-known/schemapin/{domain}.json" - response = requests.get(well_known_url, timeout=2) + well_known_url = f"{server_url}/.well-known/schemapin.json" + response = requests.get(well_known_url, timeout=2, allow_redirects=False, + headers={"Host": domain}) if response.status_code == 200: data = response.json() print(f" ✅ {domain}: {data.get('developer_name')}") diff --git a/javascript/README.md b/javascript/README.md index 24f170e..a00997c 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -86,7 +86,7 @@ const result = await verificationWorkflow.verifySchema( signature, "yourdomain.com/calculate_sum", "yourdomain.com", - true // auto-pin on first use + false // auto-pin is an opt-in; interactive or pre-seeded pins are recommended ); if (result.valid) { @@ -166,7 +166,7 @@ signSkillWithOptions(skillDir, privateKeyPem, 'example.com', { }); ``` -Verifiers past the expiration treat the result as **degraded, not failed**: +Verifiers past the expiration **fail** with `SIGNATURE_EXPIRED`; with `allowExpired` they treat the result as degraded instead: `result.valid` stays `true`, `result.expired` is set to `true`, and `'signature_expired'` is appended to `result.warnings`. diff --git a/python/README.md b/python/README.md index b6eee78..cd97d0b 100644 --- a/python/README.md +++ b/python/README.md @@ -118,8 +118,8 @@ schemapin-verify --schema signed.json --public-key public.pem # Verify with domain discovery and interactive pinning schemapin-verify --schema signed.json --domain example.com --tool-id my-tool --interactive -# Batch verify with auto-pinning -schemapin-verify --batch ./signed/ --domain example.com --auto-pin +# Batch verify, pinning on first use (opt-in: trusts whatever the domain serves) +schemapin-verify --batch ./signed/ --domain example.com --tool-id my-tool --auto-pin # Verify from stdin with JSON output echo '{"schema": {...}, "signature": "..."}' | schemapin-verify --stdin --public-key public.pem --json @@ -181,13 +181,14 @@ from schemapin.utils import SchemaVerificationWorkflow verification_workflow = SchemaVerificationWorkflow() -# Verify schema with automatic key pinning +# Verify the schema. auto_pin defaults to False: first-use keys must be +# accepted interactively or pre-seeded; pass auto_pin=True only where a human +# or a second channel (DNS TXT) has confirmed the key. result = verification_workflow.verify_schema( schema, signature, "yourdomain.com/calculate_sum", "yourdomain.com", - auto_pin=True ) if result['valid']: diff --git a/python/tests/test_cli_tools.py b/python/tests/test_cli_tools.py index 7929764..4570bb4 100644 --- a/python/tests/test_cli_tools.py +++ b/python/tests/test_cli_tools.py @@ -55,28 +55,19 @@ def test_keygen_ecdsa_basic(self): calculated_fingerprint = KeyManager.calculate_key_fingerprint_from_pem(public_key_pem) assert calculated_fingerprint == output["fingerprint"] - def test_keygen_rsa_basic(self): - """Test basic RSA key generation.""" + def test_keygen_rejects_rsa(self): + """Only ECDSA P-256 keys are accepted by the protocol (SP-10), so the + key generator refuses to produce RSA keys.""" with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - # Run keygen result = subprocess.run([ sys.executable, "-m", "tools.keygen", "--type", "rsa", - "--key-size", "2048", - "--output-dir", str(tmpdir_path), + "--output-dir", tmpdir, "--json" ], capture_output=True, text=True, ) - assert result.returncode == 0 - output = json.loads(result.stdout) - - assert output["key_type"] == "rsa" - assert output["key_size"] == 2048 - assert output["format"] == "pem" - assert "fingerprint" in output - assert output["fingerprint"].startswith("sha256:") + assert result.returncode != 0 + assert "invalid choice" in result.stderr def test_keygen_well_known_template(self): """Test .well-known template generation.""" diff --git a/python/tools/keygen.py b/python/tools/keygen.py index ce26921..e162c03 100644 --- a/python/tools/keygen.py +++ b/python/tools/keygen.py @@ -2,8 +2,8 @@ """SchemaPin key generation tool.""" import argparse -import os import json +import os import sys from pathlib import Path @@ -88,14 +88,14 @@ def main(): epilog=""" Examples: %(prog)s --type ecdsa --output-dir ./keys --developer "Alice Corp" - %(prog)s --type rsa --key-size 4096 --format der --output-dir ./keys + %(prog)s --type ecdsa --format der --output-dir ./keys %(prog)s --type ecdsa --well-known --developer "Bob Inc" --contact "security@bob.com" """ ) parser.add_argument( '--type', - choices=['ecdsa', 'rsa'], + choices=['ecdsa'], default='ecdsa', help='Key type to generate (default: ecdsa)' ) diff --git a/tests/cross-language/README.md b/tests/cross-language/README.md index 92b31d1..273d48d 100644 --- a/tests/cross-language/README.md +++ b/tests/cross-language/README.md @@ -32,10 +32,30 @@ Consumers: - `go/pkg/core/cross_language_test.go` - `rust/tests/cross_language_canonicalization.rs` +## `signatures/schema-signature.json` + +A fixed ECDSA P-256 schema signature: the schema, its canonical form and +SHA-256, the public key and fingerprint, the signature, a copy of the signature +with a trailing DER byte (must be rejected) and a tampered schema (must be +rejected). Proves the signing construction `ECDSA(SHA-256(digest))` is shared. + +## `skill/` + +`fixture-skill/` is a skill directory with a v1.4 signed signature document +(skill canonicalization v2); `legacy-v13-skill/` carries a pre-1.4 root-hash +only signature. `expected.json` lists the expected `skill_hash` and +`file_manifest` for both, the discovery document to verify against, and the +signed fields whose tampering must fail. Both directories must verify in every +SDK; the v1.4 one must fail for every tampered field and for any content +change. + ## `signed_bundle.json` -A v1.4 signed trust bundle. See the `keys/` section below for the test -authority that signed it. +A v1.4 signed trust bundle signed by the test-only authority +`schemapin-test-fixture-authority`. Each SDK's test seeds that authority from +`keys/test-only-bundle-authority.pub.pem` and asserts the bundle verifies, +that it is refused without seeding, and that a different seeded key is +reported as `KEY_PIN_MISMATCH`. ## `keys/` @@ -43,3 +63,9 @@ Test-only key material. Nothing in this directory is a production identity: the bundle authority kid is deliberately named `schemapin-test-fixture-authority` so it cannot be mistaken for a real authority, and verifiers must never seed a persistent authority store with it outside of a test run. + +| File | Purpose | +|------|---------| +| `test-only-signing-key.pem` / `.pub.pem` | Signs the schema vector and the skill fixtures | +| `test-only-bundle-authority.pem` / `.pub.pem` | Signs `signed_bundle.json` | +| `reject-p384-public.pem`, `reject-rsa2048-public.pem` | Keys every SDK must refuse (spec section 10) | From 7db6fd7115719317d796600c7cb63dd13603c2bd Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Sat, 10 Oct 2026 12:40:21 -0700 Subject: [PATCH 12/14] Refresh vulnerable SDK and reference server dependencies --- CHANGELOG.md | 7 +++++- javascript/package-lock.json | 48 ++++++++++++++++++++++-------------- python/pyproject.toml | 2 +- python/requirements.txt | 2 +- server/requirements.txt | 13 ++++------ 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5c7c7..67e118b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Updated the pinned Python cryptography dependency and JavaScript development + dependency lockfile to clear current dependency advisories. The reference + server uses patched FastAPI, Starlette, AnyIO and Pydantic versions and no + longer installs unused JWT, password, multipart or file-helper libraries. + Fixes from the 2026-10 security review of all four SDKs, the reference server and the demo. IDs refer to the review's findings. Wire-format changes are called out explicitly; everything else is behavior. @@ -531,4 +536,4 @@ cross-protocol loop with AgentPin v0.3's `AllowedDomains` typed wrapper --- -For more details on any release, see the [GitHub releases page](https://github.com/thirdkey/schemapin/releases). \ No newline at end of file +For more details on any release, see the [GitHub releases page](https://github.com/thirdkey/schemapin/releases). diff --git a/javascript/package-lock.json b/javascript/package-lock.json index 6eac18e..5c08852 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -195,29 +195,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -305,7 +319,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -324,9 +337,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -378,9 +391,9 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -555,7 +568,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -987,9 +999,9 @@ } }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { diff --git a/python/pyproject.toml b/python/pyproject.toml index 5bfdce3..f74d17a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -46,7 +46,7 @@ classifiers = [ ] requires-python = ">=3.8" dependencies = [ - "cryptography==48.0.1", + "cryptography==50.0.0", "requests==2.33.0" ] diff --git a/python/requirements.txt b/python/requirements.txt index bc783a1..479f598 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,4 +1,4 @@ -cryptography==48.0.1 +cryptography==50.0.0 requests==2.33.0 pytest==9.0.3 ruff==0.8.4 diff --git a/server/requirements.txt b/server/requirements.txt index 5327992..c12ca76 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,10 +1,7 @@ -fastapi==0.104.1 +fastapi==0.143.0 +starlette==1.7.0 +anyio==4.15.1 uvicorn[standard]==0.24.0 -pydantic==2.5.0 -python-multipart==0.0.31 -aiofiles==23.2.1 -python-jose[cryptography]==3.4.0 -passlib[bcrypt]==1.7.4 -python-dotenv==1.2.2 +pydantic==2.14.0 requests==2.33.0 -cryptography==48.0.1 \ No newline at end of file +cryptography==50.0.0 From e09cf98baa2712a58cc1d0b835dd85c821783724 Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Sat, 10 Oct 2026 13:14:46 -0700 Subject: [PATCH 13/14] Align supported runtimes with patched dependencies --- .github/workflows/compatibility.yml | 52 +++++++++++++++++++++++++++++ CHANGELOG.md | 6 ++++ README.md | 4 +++ python/README.md | 2 +- python/pyproject.toml | 8 ++--- python/setup.cfg | 8 ++--- python/setup.py | 10 +++--- rust/Cargo.toml | 2 +- rust/README.md | 2 ++ 9 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/compatibility.yml diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 0000000..b708070 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,52 @@ +name: Runtime compatibility + +on: + push: + branches: [main] + paths: ['rust/**', 'python/**', 'server/**', 'tests/**', '.github/workflows/compatibility.yml'] + pull_request: + paths: ['rust/**', 'python/**', 'server/**', 'tests/**', '.github/workflows/compatibility.yml'] + +permissions: + contents: read + +jobs: + rust: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + rust: ['1.89', stable] + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: ${{ matrix.rust }} + components: rustfmt, clippy + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: rust + - run: cargo test --all-features --locked + - run: cargo fmt --check + if: matrix.rust == 'stable' + - run: cargo clippy --all-features --all-targets --locked -- -D warnings + if: matrix.rust == 'stable' + + python: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.12'] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install --upgrade pip + - run: python -m pip install -e './python[dev]' -r server/requirements.txt httpx pip-audit + - run: python -m pytest python/tests server/tests + - run: pip-audit --progress-spinner off diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e118b..637ae79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Supported runtimes + +- Python now requires 3.10 or later and Rust requires 1.89 or later, matching + the patched dependency graph. Packaging metadata and current documentation + agree on these minimums; CI tests each minimum alongside a newer runtime. + ### Security - Updated the pinned Python cryptography dependency and JavaScript development diff --git a/README.md b/README.md index 4c1c80d..795ae9c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ result = verifier.verify_schema(schema, signature, "example.com/my_tool", "examp ### Python +The upcoming security release requires Python 3.10 or later. + ```bash pip install schemapin ``` @@ -59,6 +61,8 @@ go install github.com/ThirdKeyAi/schemapin/go/cmd/...@latest ### Rust +The upcoming security release requires Rust 1.89 or later. + ```toml [dependencies] schemapin = "1.3.0" diff --git a/python/README.md b/python/README.md index cd97d0b..6d94989 100644 --- a/python/README.md +++ b/python/README.md @@ -274,7 +274,7 @@ bandit -r . --exclude tests/ ## Requirements -- Python 3.8 or higher +- Python 3.10 or higher - cryptography library for ECDSA operations - requests library for HTTP operations - sqlite3 (built-in) for key storage diff --git a/python/pyproject.toml b/python/pyproject.toml index f74d17a..15f2dc9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -33,8 +33,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -44,7 +42,7 @@ classifiers = [ "Topic :: System :: Systems Administration :: Authentication/Directory", "Typing :: Typed" ] -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = [ "cryptography==50.0.0", "requests==2.33.0" @@ -94,7 +92,7 @@ schemapin = ["py.typed"] [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py310" [tool.ruff.lint] select = [ @@ -127,7 +125,7 @@ python_functions = ["test_*"] addopts = "--verbose" [tool.mypy] -python_version = "3.8" +python_version = "3.10" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true diff --git a/python/setup.cfg b/python/setup.cfg index 7dcfd01..412dacf 100644 --- a/python/setup.cfg +++ b/python/setup.cfg @@ -24,8 +24,6 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 @@ -38,11 +36,11 @@ classifiers = [options] zip_safe = False include_package_data = True -python_requires = >=3.8 +python_requires = >=3.10 packages = find: install_requires = - cryptography>=41.0.0 - requests>=2.31.0 + cryptography==50.0.0 + requests==2.33.0 [options.packages.find] exclude = diff --git a/python/setup.py b/python/setup.py index 3e012b6..6cf2a21 100644 --- a/python/setup.py +++ b/python/setup.py @@ -11,7 +11,7 @@ setup( name="schemapin", - version="1.1.4", + version="1.4.0a4", author="ThirdKey", author_email="contact@thirdkey.ai", description="Cryptographic schema integrity verification for AI tools", @@ -25,18 +25,16 @@ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Security :: Cryptography", "Topic :: Software Development :: Libraries :: Python Modules", ], - python_requires=">=3.8", + python_requires=">=3.10", install_requires=[ - "cryptography>=41.0.0", - "requests>=2.31.0", + "cryptography==50.0.0", + "requests==2.33.0", ], extras_require={ "dev": [ diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f3249b6..8f30e7e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,7 +2,7 @@ name = "schemapin" version = "1.4.0-alpha.4" edition = "2021" -rust-version = "1.70" +rust-version = "1.89" description = "Cryptographic schema integrity verification for AI tools - Rust implementation" license = "MIT" readme = "README.md" diff --git a/rust/README.md b/rust/README.md index 2da09ed..c2b6675 100644 --- a/rust/README.md +++ b/rust/README.md @@ -27,6 +27,8 @@ This implementation is fully compatible with the Python, JavaScript, and Go impl ## Installation +Requires Rust 1.89 or later. + Add this to your `Cargo.toml`: ```toml From e684b8c125283285864792a7b73986604fe6bc1a Mon Sep 17 00:00:00 2001 From: Jascha Wanger Date: Sat, 10 Oct 2026 13:19:49 -0700 Subject: [PATCH 14/14] Fix minimum-runtime compatibility and patched build tooling --- .github/workflows/compatibility.yml | 2 +- CHANGELOG.md | 2 ++ python/pyproject.toml | 4 ++-- python/schemapin/pinning.py | 8 ++++---- rust/src/bundle.rs | 2 +- rust/src/skill.rs | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index b708070..0307432 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - - run: python -m pip install --upgrade pip + - run: python -m pip install --upgrade pip 'setuptools>=83.0.0' wheel - run: python -m pip install -e './python[dev]' -r server/requirements.txt httpx pip-audit - run: python -m pytest python/tests server/tests - run: pip-audit --progress-spinner off diff --git a/CHANGELOG.md b/CHANGELOG.md index 637ae79..6e7b00b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Supported runtimes +- Python package builds and CI use setuptools 83.0.0 or later to include its + security fixes. - Python now requires 3.10 or later and Rust requires 1.89 or later, matching the patched dependency graph. Packaging metadata and current documentation agree on these minimums; CI tests each minimum alongside a newer runtime. diff --git a/python/pyproject.toml b/python/pyproject.toml index 15f2dc9..3a0fe8f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=83.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -137,4 +137,4 @@ warn_redundant_casts = true warn_unused_ignores = true warn_no_return = true warn_unreachable = true -strict_equality = true \ No newline at end of file +strict_equality = true diff --git a/python/schemapin/pinning.py b/python/schemapin/pinning.py index 900832c..488dd34 100644 --- a/python/schemapin/pinning.py +++ b/python/schemapin/pinning.py @@ -16,7 +16,7 @@ import os import sqlite3 import warnings -from datetime import UTC, datetime +from datetime import datetime, timezone from enum import Enum from pathlib import Path from typing import Dict, List, Optional @@ -165,7 +165,7 @@ def pin_key( public_key_pem, domain, developer_name, - datetime.now(UTC).isoformat() + datetime.now(timezone.utc).isoformat() )) conn.commit() return True @@ -223,7 +223,7 @@ def update_last_verified(self, tool_id: str, domain: Optional[str] = None) -> bo with sqlite3.connect(self.db_path) as conn: cursor = conn.execute( f'UPDATE pinned_keys SET last_verified = ? WHERE {where}', - (datetime.now(UTC).isoformat(), *params), + (datetime.now(timezone.utc).isoformat(), *params), ) conn.commit() return cursor.rowcount > 0 @@ -347,7 +347,7 @@ def set_domain_policy(self, domain: str, policy: PinningPolicy) -> bool: INSERT OR REPLACE INTO domain_policies (domain, policy, created_at) VALUES (?, ?, ?) - ''', (domain, policy.value, datetime.now(UTC).isoformat())) + ''', (domain, policy.value, datetime.now(timezone.utc).isoformat())) conn.commit() return True except sqlite3.Error: diff --git a/rust/src/bundle.rs b/rust/src/bundle.rs index ade1f82..6df7654 100644 --- a/rust/src/bundle.rs +++ b/rust/src/bundle.rs @@ -303,7 +303,7 @@ pub fn merge_trust_bundles_with_precedence( for b in bundles { let rank = merge_rank(b, authority_precedence); - if newest.map_or(true, |n| rank.1 > n) { + if newest.is_none_or(|n| rank.1 > n) { newest = Some(rank.1); newest_raw = b.signed_at.clone().unwrap_or_else(|| b.created_at.clone()); } diff --git a/rust/src/skill.rs b/rust/src/skill.rs index cd9f278..a8d5578 100644 --- a/rust/src/skill.rs +++ b/rust/src/skill.rs @@ -262,7 +262,7 @@ fn walk_v1(base: &Path, dir: &Path, manifest: &mut BTreeMap) -> } let rel = path .strip_prefix(base) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + .map_err(|e| std::io::Error::other(e.to_string()))?; let rel_str = rel .components() .map(|c| c.as_os_str().to_string_lossy().into_owned())