feat(sdk): port provenance attestation to the TypeScript, Python, and Go SDKs - #131
Merged
Merged
Conversation
There was a problem hiding this comment.
Sorry @macanderson, you've used your own review budget of 250,000 diff characters for the last 7 days.
You can request another review in 16 hours and 38 minutes by commenting @sourcery-ai review. Upgrade to get a review now.
macanderson
force-pushed
the
feat/sdk-attestation-ports-b726a4bc
branch
from
August 30, 2026 04:13
93d53d4 to
5a291cb
Compare
Reviewer's GuideThis PR ports SPEC §6.5 provenance attestation to the TypeScript, Python, and Go SDKs, including canonical commitments, Merkle proofs, and strict Ed25519 verification, then validates all implementations against shared adversarial vectors wired into CI. Sequence diagram for frame attestation verificationsequenceDiagram
participant Auditor
participant SDK
participant Commitment
participant Verifier
Auditor->>SDK: verifyFrameAttestation(providerID, frame, attestation, publicKey)
SDK->>Commitment: frameCommitment(providerID, frame)
Commitment->>Commitment: provenanceChainHead(links)
Commitment-->>SDK: expected commitment
SDK->>Verifier: verifyCommitment(expected, attestation, publicKey)
Verifier-->>SDK: named verdict
SDK-->>Auditor: AttestationVerdict
Flow diagram for provenance commitment and Merkle proof validationflowchart TD
Link[Provenance links] --> Encode[encodeProvenanceLink]
Encode --> Chain[provenanceChainHead]
Frame[Frame identity and content digest] --> Commitment[frameCommitment]
Chain --> Commitment
Commitment --> Leaves[Frame commitments]
Leaves --> Root[merkleRoot]
Leaves --> Proof[inclusionProof]
Proof --> Recompute[rootFromProof]
Root --> Compare[Compare recomputed root]
Recompute --> Compare
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
This was referenced Aug 30, 2026
added 7 commits
August 29, 2026 21:23
… can actually fail The published set could not separate a correct port from an incorrect one. Every string in it was ASCII, so a length prefix counting UTF-16 code units or code points computed the same bytes; the only multi-leaf Merkle vector had four leaves, where RFC 6962's split and the duplicate-the-last-leaf shortcut agree; and there was no signature and no inclusion proof at all, so the two halves of section 6.5.4 had no oracle. Adds a link whose fields are multi-byte UTF-8 ending in an astral-plane character (24 UTF-8 bytes reading as 17 either other way), one-, three- and seven-leaf roots, a seven-leaf inclusion proof, a fixed Ed25519 key with the signature it produces, and the verdict vocabulary. No existing value changes. Mirrors every value into tests/vectors/attestation-vectors.json, which the TypeScript, Python and Go suites read, and asserts here that the mirror agrees — a digest transcribed into four languages is four things that can drift. Refs #93 Signed-off-by: Mac Anderson <ops@oxagen.sh>
… the published vectors The SPEC.md 6.5 constructions in TypeScript: the length-prefixed link encoding, the source-first chain fold, the frame commitment, the RFC 6962 root and inclusion proofs, and strict Ed25519 verification with the seven named verdicts. Every length prefix is measured off the bytes TextEncoder produced, never off String.prototype.length. With the published unicode vector wired in, swapping the two turns the suite red on a 0x11 where the vector says 0x18 — which is exactly the divergence an ASCII-only vector set could not see. Node's Ed25519 (OpenSSL) accepts a small-order public key, which 6.5.4 says a strict verifier should not, so verifyCommitment rejects the eight small-order encodings and any key whose y is not reduced before OpenSSL ever sees it. The new script compiles test/attest.test.ts and runs it against tests/vectors/attestation-vectors.json under node --test. Refs #93 Signed-off-by: Mac Anderson <ops@oxagen.sh>
… published vectors
The same constructions in Python, with the length prefix taken off
s.encode("utf-8") rather than len(s) — len counts code points, so the
published unicode vector separates the two.
Ed25519 verification needed a decision: the standard library has none, and the
SDK promises zero dependencies. contextgraph_sdk._ed25519 is a self-contained
RFC 8032 verifier — verification only, never signing — matching dalek's
verify_strict on all four counts: the cofactorless equation, a reduced S,
canonical encodings, and small-order rejection. It is checked against RFC 8032
7.1's own vectors, against this repository's dalek-produced signature, and
differentially against the cryptography package wherever that is installed.
Because it has real field arithmetic, the Python suite is also what proves the
small-order table the TypeScript and Go ports carry: it recomputes 8P =
identity for every entry rather than trusting the list.
Runs on a bare interpreter with python3 -m unittest discover -s tests.
Refs #93
Signed-off-by: Mac Anderson <ops@oxagen.sh>
…ines SPEC.md 6.5.4 says a verifier should reject small-order public keys and non-canonical encodings, and named neither set. The Rust reference gets both from ed25519_dalek::verify_strict; a port on Node's OpenSSL or Go's crypto/ed25519 gets neither, because both accept a small-order key. So the set is published rather than left to each port to rediscover: the eight canonical encodings of a point P with 8P = identity, plus the two non-canonical y values a verifier that reduces mod p would misread as y = 0 and y = 1. Labelled as verifier guidance rather than a wire vector, because it constrains what a verifier accepts and not what anything encodes. The Python suite recomputes 8P = identity for every entry from its own field arithmetic, so the table is checked rather than trusted. Refs #93 Signed-off-by: Mac Anderson <ops@oxagen.sh>
Go is the one language of the three where the native string length is already a UTF-8 byte count, so the port is short — but it has its own trap the others do not: contextgraph.Provenance carries its optional fields as string with omitempty and cannot tell an absent URI from a present empty one, which is exactly the distinction the SPEC.md 6.5.1 presence byte makes normative. attest.Link takes pointers, and LinkFromProvenance states the collapse it performs rather than hiding it. Go's crypto/ed25519 accepts a small-order public key, as Node's OpenSSL does, so VerifyCommitment declines the published small-order set and any key whose y is not reduced before the standard library sees them. CI gains one step per SDK job plus, on the Rust side, the one that turned out to matter most: the attestation feature is off by default and no workspace member enables it, so the existing workspace-wide run had never compiled contextgraph_types::attest or its vectors. The oracle three ports now reconcile against was itself unrun. Closes #93 Signed-off-by: Mac Anderson <ops@oxagen.sh>
SPEC.md's digest grammar is 64 lowercase hex characters, and contextgraph_types::is_well_formed_digest enforces it. The ports were inconsistent with each other: TypeScript rejected uppercase, Python's bytes.fromhex accepted it and also skipped whitespace between byte pairs, and Go's encoding/hex accepted it. Three implementations now agree, and each has a named test. The reference itself is the remaining outlier — attest::from_hex accepts A-F, so an uppercase signed_commitment verifies in Rust and is malformed_commitment in all three SDKs. Filed as #145 rather than changed here, because it is a behaviour change to a published Rust function and belongs in its own diff. Refs #93 Signed-off-by: Mac Anderson <ops@oxagen.sh>
#106 landed `mypy --strict` over the Python SDK while this branch was open, and a bare `dict` in a signature fails `--strict`: the package ships `py.typed`, so an unparameterized mapping leaks `Any` across the exact boundary that promise covers. `LinkLike` and `FrameLike` name the two shapes the attestation surface accepts — the typed one and any decoded JSON mapping — so a caller reads what is allowed instead of inferring it, and the aliases carry the absent-vs-empty rule the encoding depends on. Refs #93 Signed-off-by: Mac Anderson <ops@oxagen.sh>
macanderson
force-pushed
the
feat/sdk-attestation-ports-b726a4bc
branch
from
August 30, 2026 04:24
d9b297f to
37fb994
Compare
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull request
Summary
Ports the
SPEC.md§6.5 provenance-attestation constructions to theTypeScript, Python and Go SDKs — and first fixes the published vectors, which
could not have told a correct port from an incorrect one.
Closes #93
What changed
The vectors were unable to fail a wrong port. Every string in them was
ASCII, so a length prefix counting UTF-16 code units or code points computed
the same bytes; the only multi-leaf Merkle vector had four leaves, where RFC
6962's split and the duplicate-the-last-leaf shortcut agree; and there was no
signature and no inclusion proof, so §6.5.3 and §6.5.4 had no oracle at all.
Fixed first, because a port reconciled against a set that cannot fail proves
nothing:
U+1D11E— 24 UTF-8bytes reading as 17 either other way, and 10 vs 8 for the astral field;
produces, and the seven verdicts;
enumerate.
No previously published value changed — that would be a wire break
(
SPEC.md§15).One copy, four readers.
tests/vectors/attestation-vectors.jsonholds thevectors as data. The Rust suite still writes them out inline and asserts the
file agrees, so the reference stays readable as a specification while the three
ports cannot reconcile against a stale transcription.
The three ports. Each covers the length-prefixed link encoding, the
source-first chain fold, the frame commitment, the RFC 6962 root and inclusion
proofs, and strict Ed25519 verification with the named verdicts. No port
reaches for a JSON canonicalizer (ADR 0010), and no SDK gains a dependency.
Each carries the trap specific to its language, named in the module header and
the README:
String.prototype.lengthis UTF-16 code units. Lengths comeoff what
TextEncoderproduced. Node's Ed25519 (OpenSSL) accepts asmall-order public key, so
verifyCommitmentdeclines those, and any keywhose
yis not reduced, before OpenSSL sees them.len()on astris code points. Lengths come off.encode("utf-8"). The standard library has no Ed25519 and the SDK promisesno dependencies, so
contextgraph_sdk._ed25519is a self-contained RFC 8032verifier — verification only, never signing — matching dalek's
verify_stricton the cofactorless equation, reducedS, canonicalencodings and small-order rejection. See the judgement call below.
contextgraph.Provenancecarries its optional fields asstringwithomitemptyand cannot tell an absent URI from a present empty one, which isexactly the distinction the presence byte makes normative.
attest.Linkusespointers and
LinkFromProvenancestates the collapse it performs; the wirestruct itself is The Go SDK's wire Provenance cannot tell an absent field from an empty one, which the attestation encoding requires #124.
CI. One step per SDK job, plus the Rust one that turned out to matter most:
the
attestationfeature is off by default and no workspace member enables it,so the existing workspace-wide run had never compiled
contextgraph_types::attestor its vectors. The oracle three ports nowreconcile against was itself unrun. #117 is the durable version of that finding
and stays open; if #114's
featuresjob lands first, my one line is redundantand I will drop it in a rebase.
Evidence
Every suite, run on this branch. Toolchains present: node v24.18.0, python
3.14.7, go 1.26.5, cargo 1.97.0. No toolchain was missing.
test_the_two_verifiers_agree_on_every_caseran rather than skipped here —cryptography49.0.0 is installed on this machine. On CI it will skip, and theRFC 8032 vectors are what run there.
The negative direction
A suite that only ever passes proves nothing, so each port was broken on
purpose and the vectors were asked to notice.
TypeScript — the UTF-16 length.
bytes.length→s.lengthinencodeString:0x11is 17, the UTF-16 count;0x18is 24, the UTF-8 count. Note which testsstayed green: the frame commitment and every Merkle root, because their inputs
are ASCII. That is exactly the blind spot the old vector set had.
Python — the code-point length.
len(raw)→len(s)in_enc_str:Go — the endianness.
binary.BigEndian→binary.LittleEndian:All three files were restored and re-run green before committing; the working
tree contains none of these edits.
One judgement call worth a reviewer's attention
Python carries an Ed25519 verifier. The three alternatives were each worse:
a hard dependency breaks the zero-dependency promise for every user including
those who never verify; an optional one makes a verifier answer differently
depending on what happens to be installed; and none at all leaves the Python
SDK unable to do the half of §6.5 that turns a trace into evidence.
It verifies and never signs — no key material, no nonces — and it is held to
three independent oracles: RFC 8032 §7.1's own published vectors, this
repository's dalek-produced signature, and a differential test against
cryptographycovering rejections as well as acceptances. Say so if you wouldrather have the dependency; #127 is where the signing side is tracked.
Checklist
fmt,clippy -D warnings, scopedtest.SCR-001 forbids a full workspace run here; the scoped transcripts are
above and CI runs the rest.
main(the modules do not exist), and the deliberate-bug transcriptsabove show the vectors failing a wrong implementation rather than only
passing a right one
tests/vectors/README.mdgit commit -s, DCO)CHANGELOG.mdupdated under[Unreleased]No test was deleted.
Registry submission (only if adding a row to
docs/registry.md)Protocol-stability impact (if a spec/wire change)
contextgraph/1New vectors are published; no existing vector value changes and the encoding
is untouched. Publishing a case the set could not previously distinguish is
additive. A diff to a published value would not be, and there is none — the
Rust suite's parity test would fail if there were.
CI, and what did not review this PR
All 24 checks pass on
37fb994(run 33292431566), including the four newsteps — the three SDK jobs each ran
... attestation port reproduces the published vectorsand thetestjob rancargo test -p contextgraph-types --features attestation, allsuccess.Sourcery did not review this PR. The
Sourcery reviewcheck reportsskipping: the repository's review budget is exhausted for roughly the nextsixteen hours. It did post a Reviewer's Guide comment, and that comment
carries an "Assessment against linked issues" table with three ✅ rows and no
❌ — but a guide is not a review, and I am not reporting it as one. There are
no ❌ rows to settle because no review ran to produce them.
Rebased onto #106
#106 landed
mypy --strictover the Python SDK andtsc --noEmitover theTypeScript one while this branch was open. Rebased onto it; the two new jobs
are green above, and one commit here types the attestation surface
(
LinkLike,FrameLike) because a baredictleaksAnyacross the boundarya
py.typedpackage promises to keep. Theci.ymledits merged withoutconflict.
One incidental change
sdk/typescript/package-lock.jsonmoves1.0.0→2.0.0. It was stale onmain—package.jsonsays2.0.0and the lock still said1.0.0— andnpm installsynced it while I was adding the test script. Left in rather thanreverted, because a lockfile that disagrees with its manifest is a defect
whoever notices it should fix. It is the whole of the diff to that file.
Merge-conflict note
.github/workflows/ci.ymlandCHANGELOG.mdare also touched by #106 and#114. My edits to both are additive at distinct anchors, kept deliberately
small for that reason. Happy to rebase behind either.
Residue filed
Provenancecannot represent a present-but-empty fieldbyte is agreed by convention rather than checked
validate-examples.pyvalidates every JSON file intests/fixtures/against the record schema, which is why the new vectors live elsewhere
attest::from_hexaccepts uppercase, so the Rust reference verifiesattestations all three SDKs reject. Found while porting; the ports were made
consistent with each other and with the grammar in this PR, and the
reference is left to its own diff because tightening a published Rust
function is a behaviour change.
Already filed by others and not duplicated: #117 (a feature-gated test file
reports green when the feature is off) and #123 (two PRs can claim the same
ADR number — three open PRs currently claim 0012).
License
By submitting this pull request, I agree to dual-license this contribution
under MIT OR Apache-2.0, as certified by my DCO sign-off.