Compact form
sd1.<base64url header>.<base64url payload>.<base64url signature>
| Segment | Encoding | Role |
|---|---|---|
sd1 | literal ASCII | Protocol and version marker |
| header | unpadded base64url of UTF-8 JSON | Key-selection labels and authenticated context |
| payload | unpadded base64url | Arbitrary bytes; may be empty |
| signature | unpadded base64url | Raw bytes returned by the chosen signer |
The signer receives the exact ASCII bytes of:
sd1.<header segment>.<payload segment>
A verifier must not decode and reserialize the JSON before checking the signature. Different languages may order valid JSON keys differently and still verify the exact token they produced.
Header fields
| Field | Required | Meaning |
|---|---|---|
algorithm | yes | Identifier declared by the caller-supplied signer |
key_id | no | Opaque verification-key selection label; not signer identity under HMAC |
purpose | no | Application context preventing cross-purpose reuse |
issued_at | no | Non-negative integer Unix timestamp |
expires_at | no | Exclusive expiry boundary; greater than issued_at |
description | no | JSON object describing otherwise opaque payload bytes |
The compatibility parser rejects unknown header fields because sd1 has no critical-field mechanism. Every accepted header value remains untrusted until the signature verifies.
Trust sequence
- Parse the four strict wire segments.
- Optionally inspect untrusted routing hints.
- Select a signer through trusted application policy.
- Require its algorithm and key ID to match the header.
- Verify the exact original signing input.
- If the token carries a purpose, require the caller's complete expected purpose and reject omission or mismatch; then apply expiry policy.
- Only now trust or deserialize the payload.
Included HMAC profile
The Wasm, JavaScript, and Ruby implementations include hmac-sha256. Signature bytes are the 32-byte HMAC-SHA256 digest over the exact signing input. Wasm and Ruby provide constant-time byte comparison; the native browser path delegates verification to Web Crypto.
The envelope itself stays algorithm-agnostic. P-256 or Ed25519 can satisfy the signer contract without changing sd1.
Optional ParcelStream profile
Format ID: signed-data/parcel-stream/1
| Field | Width | Encoding |
|---|---|---|
| type | 2 bytes | unsigned integer, network byte order |
| length | 4 bytes | unsigned integer, network byte order |
| value | length | opaque bytes |
Parcels repeat until clean end-of-input. There is no stream header, count, or terminator. Duplicate types, ordering, and empty values are preserved. Truncated headers and values are invalid.
When signed through the serializer API, description.serialization carries the format ID and sha256: content ID of the exact serialized payload.
The optional incremental decoder can identify complete parcels across arbitrary chunk boundaries. That is a framing feature, not partial sd1 verification. See the partial-stream guide.
Portable vectors
Two committed fixtures prevent documentation drift:
test/fixtures/v1_hmac_sha256.jsonfixes a complete Ruby-produced token that Node and the browser runtime reproduce exactly.test/fixtures/parcel_stream_v1.jsonfixes parcels, encoded bytes, and SHA-256 content identity.
The browser contract suite uses the same files as Ruby. A pretty example cannot silently become a different protocol.
Deliberate non-goals
SignedData does not provide encryption, replay storage, identity discovery, routing, mailboxes, torrents, fragmentation, generic object serialization, or automatic algorithm and serializer negotiation.
Those boundaries let a form library, a browser, a Caravan-like mailbox, or a recoverable peer fabric share the envelope without becoming the same project.
Build a legacy sd1 token → or start a new integration with SDB1 →