Skip to content

Proposal: require canonical base64url encoding for the embedded signature (#247) - #401

Merged
imran-siddique merged 5 commits into
agentrust-io:mainfrom
chernistry:feat/canonical-signature-encoding
Sep 30, 2026
Merged

imran-siddique merged 5 commits into
agentrust-io:mainfrom
chernistry:feat/canonical-signature-encoding

Conversation

@chernistry

Copy link
Copy Markdown
Contributor

What this changes

Section 3.2.2 already names the embedded signature field's encoding as
base64url, no padding. It does not say whether a different spelling of the
same bytes
is the same record. This proposal adds one sentence that says it
isn't: the value must be canonically encoded, and a verifier must reject a
record whose signature is not.

This is a proposal, not a merge request for accepted text. Opened per the
request on #247 for a small, focused change with vectors and a scan of
existing data, rather than a bundle covering every related field.

What's open today

A signature over a Trust Record's canonical JSON form is 64 raw bytes for
the two algorithms records use in practice (Ed25519, and ES256 over P-256 as
raw r‖s); ES384, which section 3.2.1 also lists for JWT contexts, yields
96 bytes, 128 characters and no unused bits, so the question does not arise
there. Base64url without padding spends 86 characters (516 bits) encoding
512 real bits, so the final character carries 4 bits that no signature
algorithm produces. RFC 4648 section 3.5 requires those bits to be zero;
a decoder that only restores padding and decodes (rather than also checking
them) silently accepts a spelling where they aren't. Section 3.2.2 is silent
on which behavior is required, so two conformant-looking verifiers can
currently disagree about whether the same 64 bytes, spelled two different
ways, are the same record.

What this change does

  • Spec (spec/trace-v0.2.md, section 3.2.2, embedded-signature bullet):
    adds one sentence requiring the canonical base64url spelling (RFC 4648
    section 3.5) and requiring a verifier to reject a non-canonical one.
  • Schema (schema/trace-claim.json, its packaged copy at
    src/agentrust_trace/schema/trace-v0.2.json, and the v0.3 draft schema,
    which inherits signature from the canonical schema and picks up the
    same change when regenerated): tightens the signature pattern so an
    86-character value's final character must be A, Q, g, or w — the
    four base64url characters whose low 4 bits are zero. No other pattern in
    any of these files is touched.
  • Model (src/agentrust_trace/models.py): the same tightened pattern on
    the signature field, plus an extended docstring.
  • Validator (src/agentrust_trace/validate.py): the ECMA-to-Python
    pattern-translation registry updated to carry the new pattern string
    (required — the registry is keyed by exact pattern text, so a schema
    pattern change without a matching entry here would raise KeyError at
    validation time rather than silently doing the wrong thing).
  • Docs (docs/schema.md): the signature field row gets the same
    canonical-encoding note.
  • Vectors (examples/signature-encoding/): three signed Trust Records
    and a re-runnable scan script; see below.
  • Tests (tests/test_signature_encoding.py,
    tests/test_adequacy_all_sets.py): recompute the vectors' claims from
    committed bytes rather than trusting them, and register the new vector
    set with the repo's vector-adequacy checks.

Scan: how many published signatures would stop passing

examples/signature-encoding/scan_published_signatures.py walks any number
of given roots, finds every 86-character value at the key signature, and
reports how many are already canonically encoded. It is not scoped to this
repo — pass it other checkouts to extend the count.

python examples/signature-encoding/scan_published_signatures.py \
  examples \
  /path/to/trace-tests-checkout-at-3af2b53 \
  /path/to/trace-registry-checkout

Run against this repository's own examples/, the agentrust-io/trace-tests
conformance corpus pinned at 3af2b53, and a read-only clone of
agentrust-io/trace-registry:

Root Signatures found Canonical Non-canonical
examples/ (this repo) 224 222 2
trace-tests @ 3af2b53 6 6 0
trace-registry 4 4 0
Total 234 232 2

The 2 non-canonical values are not pre-existing records: they are this
proposal's own two deliberately non-canonical example vectors (below),
counted because the script scans examples/ unconditionally. Excluding
those 3 new files (1 canonical reuse + 2 non-canonical), 231 signatures
predate this change, and all 231 are already canonical.
Under this
proposal, none of them would stop passing.

The 216 I gave on #247 on 22 September for the first two roots was an
undercount: the same two roots hold 227 signatures, all canonical, so the
conclusion there stands.

Vectors

examples/signature-encoding/ adds:

  • 01-canonical-signature.json — a signed Trust Record whose signature is
    canonically spelled. Reuses the record, trusted key, and signature already
    published in examples/verifier-compatibility/01-known-version-verified.json,
    so the set introduces no new signing key.
  • 02-non-canonical-respelling-low-bit.json and
    03-non-canonical-respelling-all-bits.json — the same 64 signature bytes,
    respelled with the final character's unused bits set to 0001 and 1111
    respectively. Two rejecting vectors rather than one, so the boundary isn't
    covered by a single bad string a shortcut implementation could
    special-case.

All three carry a decoded_signature_hex; tests/test_signature_encoding.py
decodes each record.signature itself and asserts the raw bytes match across
all three, rather than trusting the declared value. It also confirms the raw
Ed25519 bytes verify cryptographically under every spelling, isolating the
encoding as the rejection cause rather than a broken signature.

See examples/signature-encoding/README.md for the full format and the
adequacy rationale.

Current behavior of both implementations

Checked against main at the base this branch was cut from (includes #390):

So the two implementations already disagree, today, before this proposal.
Tightening the schema pattern (this PR) makes the reference library agree
with that refusal — through the schema-validation step both implementations
already run every record through first, rather than through a new check
specific to this one field.

Out of scope (follow-ups, not folded into this proposal)

  • cnf.jwk.x and other embedded public-key coordinates raise the same
    canonical-encoding question and are not covered here.
  • Trusted-key entries in verifier configuration have the same open question.
  • sig.value in schema/trace-revocation.json /
    schema/trace-revocation-bundle.json carries the same base64url pattern
    and the same question, independently noticed while scoping this change.
    Its pattern is untouched by this PR.

Change classification

This PR doesn't set MAJOR/MINOR/PATCH for itself — that's the maintainer's
call. The scan above is meant to give that decision an evidence base: as far
as this scan reaches, no currently-published signature stops validating.

Type of change

  • Editorial (typo, link fix, clarification: no normative effect)
  • Non-breaking spec change (new optional field, new platform profile, informative addition)
  • Breaking spec change (requires 14-day comment period and Project Lead sign-off)
  • Schema change
  • Example addition

Breaking or non-breaking is left to you; see "Change classification" above.

Spec section

§3.2.2 (Mandatory signature and freshness binding), the embedded-signature bullet.

Checklist

  • DCO sign-off on all commits (git commit -s)
  • CHANGELOG.md updated
  • Change marked with <!-- CHANGED: #247 - embedded signature MUST be canonical base64url --> in spec text
  • Backward compatibility statement included (for breaking changes): the scan above is the evidence; classification is yours

Context

Requested by @imran-siddique on #247, who asked for a small, focused
proposal rather than a bundle. Proposed by @chernistry.

…trust-io#247)

Section 3.2.2 already names the embedded `signature` field's encoding
as base64url, no padding, but does not say whether a different
spelling of the same bytes is the same record. A 64-byte signature
(Ed25519 or ES256) spends 86 base64url characters on 512 bits, leaving
4 unused bits in the final character. RFC 4648 section 3.5 requires
those bits to be zero.

Add one sentence to the embedded-signature bullet requiring the
canonical spelling and requiring a verifier to reject a record whose
signature is not canonically encoded. Tighten the schema `pattern` so
an 86-character value's final character must be one of the four
canonical values (A, Q, g, w). Apply the same change to the packaged
schema copy and to the v0.3 draft schema (regenerated from the
canonical schema by its own generator script, which propagates the
change unchanged). Update validate.py's ECMA-to-Python pattern
registry to match, and extend docs/schema.md's field description.

No other schema pattern is touched.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
…ner (agentrust-io#247)

Add examples/signature-encoding/: a canonical signed Trust Record and
two non-canonical respellings of the same 64 signature bytes, one
with the final character's unused bits set to 0001 and one to 1111.
Two rejecting vectors rather than one, so the boundary is not covered
by a single bad string a shortcut implementation could special-case.
The canonical vector reuses the record, key, and signature already
published in examples/verifier-compatibility/, introducing no new
signing key.

Add scan_published_signatures.py, a re-runnable script that walks any
number of given roots for 86-character `signature` values and reports
how many are already canonically encoded.

Add tests/test_signature_encoding.py, which recomputes from the
committed bytes (not the fixtures' own declarations) that all three
signatures decode to the same 64 bytes, that canonicality matches each
fixture's `encoding` field, that the canonical vector validates and
verifies, that both respellings are rejected by schema validation, by
verify_record, and by the pydantic model, and that the raw signature
bytes still verify cryptographically under every spelling, isolating
the encoding as the rejection cause rather than a broken signature.

Register the new vector set in the adequacy framework and add a
CHANGELOG entry.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
@chernistry
chernistry requested review from a team and lywinged as code owners September 23, 2026 09:31

Copy link
Copy Markdown
Member

Alex, classify this as an incompatible tightening of the draft’s acceptance rules. I support requiring canonical encoding. Please document which previously accepted spellings are rejected and how existing records should be handled. Keep the scan’s conclusion limited to the repositories and revisions examined. The normative wording will need a maintainer to carry it; this scope decision is not implementation approval.

…spellings, say how existing records are handled, scope the scan (agentrust-io#247)

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
@imran-siddique

Copy link
Copy Markdown
Member

@chernistry c07fc772 covers the three asks: the classification, the one affected length and its 15 rejected spellings, handling of existing records including the parent_record_hash consequence, and a scan scoped to the revisions read. Main has since landed #418, which makes the decoder refuse non-canonical spellings, and #412, which moved schema regex semantics onto a shared helper. models.py and validate.py now conflict. Please rebase onto main and route the new signature pattern through the #412 helper; the schema, spec sentence and vectors stay as they are. The normative sentence still needs a maintainer to carry it, and I will do that once the rebase is green.

…lper (agentrust-io#247)

main gained agentrust-io#412 (shared schema regex semantics) and agentrust-io#418 (strict base64url
decoding) after this branch was cut, and both touch the files this proposal
changes.

- models.py: `signature` takes `_SIGNATURE_RE` through `_require_pattern`,
  the same way `subject`, the digests and `retention` do.
- _patterns.py: the fourth shipped pattern form is the signature pattern,
  so the table holds the proposed string in place of the previous one.
- validate.py: main's version; the table it carried now lives in
  _patterns.py.
- CHANGELOG.md: both sides kept, this entry first under Unreleased.
- tests/test_signature_encoding.py: `verify_record` still refuses a
  non-canonical respelling, but since agentrust-io#418 the decoder refuses it before
  the schema step runs, so the test pins the refusal and the field it
  names, not which step raises it.

The schema, the specification sentence and the vectors are unchanged.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
….0; hold the schema and the model to one answer (agentrust-io#247)

The README and the CHANGELOG entry described the reference library as
verifying a non-canonical respelling. That was true through 0.10.0. Since
0.11.0 (agentrust-io#418) its decoder refuses the spelling, and verify_record reports
that refusal before the schema step. Both texts now say so, and say what
the proposal still adds: the refusal on the two surfaces that never decode
the value, schema validation and the models.

Tests:
- the non-canonical vectors are refused by the schema at `signature`, by
  `pattern`, checked through iter_errors;
- a boundary sweep runs the schema and the model over the same eleven
  values (85, 86 canonical, 86 non-canonical, 87, 128, empty, four line
  terminators, padding) and requires the same verdict from both;
- the independent ECMA-262 comparison gains 85- and 86-character samples,
  so Node checks the branch of the pattern this proposal adds.

Scan re-run at today's revisions: examples/ at 63f4d15, trace-tests at
da4369b, trace-registry at e26b85a: 232 signatures, all canonical.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
@imran-siddique
imran-siddique merged commit 152c843 into agentrust-io:main Sep 30, 2026
8 of 9 checks passed
chernistry added a commit to chernistry/trace-spec that referenced this pull request Oct 5, 2026
Conflicts in CHANGELOG.md, src/agentrust_trace/intent_bridge.py and
tests/test_adequacy_all_sets.py, after agentrust-io#401, agentrust-io#432 and agentrust-io#451.

Not mechanical, so listed:

- intent_bridge.py. agentrust-io#432 moved the observation out of the bridge transcript
  into the successor envelope and its signed artifact. The envelope's
  `observed_at` keeps main's wording and this branch's decision by value.
  The artifact's own `observed_at`, which main added with a type test, is
  decided by value as well: the artifact is signed over its RFC 8785 form
  and is compared with the envelope, so `150.0` there was a spelling the
  signature covers and only the type test refused. Each refusal names its
  reason, as the envelope's does.
- tests/test_integer_by_value.py. The two observation-time tests are
  rewritten against sign_successor_artifact and verify_successor_artifact,
  with the same refusals for the artifact's copy of the time.
- examples/number-spelling/scan_published_numbers.py. `observed_at` is read
  from schema/pic-trace-successor-v1.json now, so it leaves the short list
  of members named only in prose. The scan's member list gains the integer
  members of the schemas added on main; it still finds no published example
  the rule changes.
- tests/test_adequacy_all_sets.py keeps both sets, number-spelling and
  signature-encoding.
- CHANGELOG.md: main's entries, then this proposal's, with the bridge
  sentence naming the successor envelope and artifact.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants