The bag.dev protocol

What bag v0 fixes on the wire: Ed25519 addresses, HPKE-sealed envelopes, one canonical form, the relay API, pairing by six digits and the test vectors.

bag v0 is a protocol for signed, sealed messages between agents. Every agent has an address that is its public key, every message is an envelope that its sender signs and seals, and relays store and forward envelopes they cannot open. The specification is the contract. This page summarizes it, and where the two differ, the specification is right.

Status: a security preview of version 0 of the wire format. No outside cryptographic review has signed, and nothing here says “reviewed” until a reviewer signs. The security policy says how to report a flaw.

The parts at a glance#

Part In version 0
Identity An Ed25519 key pair. Its address is its public key.
Address ed25519: followed by 64 lowercase hex characters.
Signatures Ed25519 over the SHA-256 digest of a canonical preimage.
Sealing HPKE (RFC 9180) base mode with X25519, HKDF-SHA256 and AES-256-GCM.
Canonical form A fixed-position JSON array, hashed with SHA-256.
Delivery Store-and-forward relays: publish, subscribe and fetch.
Pairing A short code, then six check digits compared by people.
Conformance Test vectors that every implementation reproduces for the parts it covers.

Suite 1#

A suite fixes the algorithms of identity and sealing. Version 0 has one (§3.1).

Role Algorithm
Identity and signatures Ed25519 (RFC 8032)
Key encapsulation DHKEM(X25519, HKDF-SHA256), KEM 0x0020
Key derivation HKDF-SHA256, KDF 0x0001
Encryption AES-256-GCM, AEAD 0x0002

Sealing uses RFC 9180 in base mode: no pre-shared key, and no sender authentication inside HPKE. The sender’s Ed25519 signature on the envelope says who sent it. A signing key and a sealing key are separate keys, each made from 32 bytes of a secure random generator (§3.3).

An envelope names its suite as an integer, and an address names its suite in its prefix. A second suite is a new number and a new prefix, not a rewrite, and a reader refuses a suite it does not know.

How one envelope is sealed#

  1. The sender draws a fresh 32-byte content key and a 12-byte nonce for the envelope (§4.3).
  2. It encrypts the content once with AES-256-GCM. The associated data is the envelope’s version, recipients and sender, so the ciphertext is bound to them.
  3. It wraps the content key to each recipient’s sealing key with HPKE, using the recipient’s address as associated data, so a wrap moved to another recipient does not open.

An envelope has 1 to 16 recipients. When the sender is honest, they all read the same content under one envelope id.

The canonical form#

Everything that is signed is first written in one canonical form: a fixed-position JSON array with no white space, hashed with SHA-256 (§2).

  • Values. A canonical value is a string, an integer from 0 to 2^53 − 1, null, or a list of canonical values. Fractions, negative numbers, true, false and objects have no canonical form, and a reader refuses them.
  • Writing. The bytes are what JavaScript’s JSON.stringify writes for these values, and what Python’s json.dumps(value, separators=(',', ':'), ensure_ascii=False) writes. Nothing is normalized.
  • Domain tags. Position 0 of every preimage but the envelope’s is a tag such as ac/hop/1 or ac/addr/1, so a signature made for one kind of object cannot be replayed as another.
  • Signatures. Every signature is Ed25519 over the 32 digest bytes of its preimage. An envelope’s id is that digest in hex.
  • Strict verification. A signature verifies only under the cofactorless equation, with canonical encodings, no small-order points and S below the order of the prime subgroup (§2.3). A verifier that accepts more than this does not conform.

Addresses#

An address is ed25519: followed by the 32-byte Ed25519 public key as 64 lowercase hex characters, and nothing else (§3.2). Another prefix, upper-case hex or another length is not an address. Addresses are public by design: they may be printed and logged.

The envelope#

An envelope is a JSON object of at most 65,536 bytes with nine outer members (§5.1). Relays see these members and nothing more.

Member What it holds
v The envelope format version, the integer 1
id The envelope id: the SHA-256 digest of its preimage, in hex
to 1 to 16 recipient addresses
from The address of the key that signs
auth The authority chain: 0 to 8 signed hops from a root to the sender
suite The suite, the integer 1
ts When the sender built it, in Unix seconds
sealed The wraps, the nonce and the ciphertext
sig The sender’s signature over the id

The message’s type, thread and body are inside sealed. A relay never sees them.

The frozen core and what may still change#

The frozen core, called L0, is everything that travels on the wire and that signatures cover (§1). Changing any of it needs a new version of the wire format:

  • the canonical form and the signature rule;
  • the ed25519: address format;
  • the envelope and its sealed part, the authority chain and its caveats, and the signed records that relays store;
  • the request preimages of fetch, subscribe and pairing.

Two parts are pre-release and may change in place until version 1 without a new wire version: the relay’s behaviour and limits, and the requirements on the agent-side edge. If a security fix needs a change to the wire format, it ships as a new wire version with a public note.

Version 0 does not define presence, discovery, payload schemas, capability negotiation, group semantics, wake, payments or user interfaces. Naming addresses under an organisation’s domain (§12) is an L2 section outside the frozen core. Deriving a sealing key from an Ed25519 key, an organisation relay’s smarthost role and persistent rate counters are deferred.

The relay API#

A relay is a store-and-forward server with no semantics of its own. It enforces one thing: the policy a mailbox’s owner signed, which names the roots the mailbox takes mail from (§8). It carries sealed envelopes it cannot open.

Call Request What it does
publish POST /v0/publish Stores an envelope, or a signed record, after checking it against its recipients’ policy
subscribe WebSocket GET /v0/subscribe Streams a mailbox’s envelopes, in order, to a client that signs the relay’s challenge with the mailbox key or a key it delegates
fetch GET /v0/fetch/{id} Returns one envelope to a caller that proves a key
fetch, records GET /v0/fetch/record/{address}/{kind} Returns the exact bytes of the latest stored record
health GET /v0/health Says the relay is up
pair/open POST /v0/pair/open Optional: opens a pairing nameplate
pair/claim GET /v0/pair/claim/{nameplate} Optional: reads a nameplate to complete a pairing

Publish, subscribe and fetch are the three calls every relay must offer. A relay keeps an envelope for 7 days by default. A sender publishes the same bytes to up to 3 of each recipient’s listed relays, and the recipient drops duplicates by envelope id (§4.3).

Pairing with six check digits#

Two people pair their agents with a short pairing code, <nameplate>-<word>-<word>-<word> (§8.10). The nameplate is a number the rendezvous relay allocates, a locator and not a secret. The three words come from the EFF Short Wordlist #1 and seed the key that authenticates the pairing request. A nameplate lives at most 600 seconds.

The code does not protect the pairing. Six check digits do:

  1. Both sides compute six digits from the inviter, the invitation, the requester and its sealing key, and the code’s key. The digits are the first 4 bytes of a SHA-256 digest, read as a number, modulo one million.
  2. The accepting side shows its digits once and reads them aloud to the other person.
  3. The inviting side types the digits it heard, and its own software compares them. It never shows the digits it expects.
  4. A match admits the peer. After 3 mismatched tries, the inviting side gives up and tells the other side nothing.

A man-in-the-middle who substitutes its own keys changes the digits, so the people see a mismatch. Its chance of passing in one try is about 1 in 1,000,000. The relay never sees the check digits. The two pairing calls are optional: an agent whose relay lacks them pairs with the longer invite string instead.

The test vectors#

spec/vectors/ holds nine JSON files: the canonical form, envelopes, envelopes to many recipients, valid and invalid authority chains, records, the RFC 9180 subset for suite 1, discovery and the pairing code (§11). Each case states every expected value, from canonical strings and digests to ids, signatures and ciphertexts, so an implementation in any language needs no other source. An implementation conforms when it reproduces every vector that covers its part.

Implementation Language Its part
packages/core TypeScript §2 to §7 in full: the canonical form, identity, sealing, the envelope, the authority chain and the records
packages/edge TypeScript The agent-side edge of §9, which sends, receives, verifies, unseals, evaluates and routes
crates/ac-core Rust Verification, with no sealing and no caveat evaluation. Its conformance tests read every vector list but those of opening, sealing and caveats.
crates/ac-relay Rust The relay of §8. Its conformance tests send every parse, verify, chain and record case through publish.
scripts/verify_vectors.py Python An independent check. It recomputes every canonical string, hash and id, verifies every signature and opens every wrap with its own RFC 9180.

The repository also ships a participant written in Python from the specification alone, in examples/python-join. Most files are generated from fixed seeds, and a check regenerates them and fails unless each is byte-identical. The pairing-code file is maintained by hand.

What it is not#

  • Not a live session. Delivery is asynchronous. The sender seals to keys the recipient publishes in signed records, before the recipient is reached, and relays hold the sealed envelope until it is read or expires.
  • No key for each message. An individual’s mail is sealed to a short-lived key that is deleted after a set horizon, so a stolen sealing key exposes only mail within that horizon (§10). One-time keys for each message are a deferred tier. The specification notes that MLS carries the same bound, which comes with asynchronous delivery.
  • Not a group protocol. Version 0 defines no group semantics.
  • It does not hide metadata. A relay sees who writes to whom and when, sizes, timing, the client’s IP address and the authority chain. Hiding the sender and sealing the caveats are for later suites.
  • It does not solve root compromise. Nobody does. Short-lived keys, revocation and countersigned rotation bound it (§10).

Read the specification#

  • spec/SPEC.md on GitHub: every format, preimage and rule, the error codes (Appendix A) and the implementation defaults (Appendix B).
  • Security policy: how to report a vulnerability, the scope, and the threat model in one paragraph.
  • How it works: the same model in plain words.