Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-016: Portable identity v2: mandatory ed25519 + revocation

Date: 2026-05-25 Status: Accepted (supersedes ADR-014 §v0.3 explicit non-goals) Author: Rômulo Febasi


Context

ADR-014 shipped the me.synapse bundle as an unsigned JSON artefact and explicitly deferred the cryptographic layer to “v0.4 if there is demand”. The demand showed up immediately: any consumer who imports a bundle has no way to prove the person frontmatter or the attestation list have not been edited mid-flight, and any human who realises an attestation is wrong (employer dispute, role title change, security incident) has no way to retract it without re-issuing every downstream copy by hand.

The EU AI Act Article 50 timeline (force of law on 2026-08-02) is the external pressure: a regulator looking at the bundle must be able to verify the chain of custody. v0.3’s “trust the file” model is not a defensible answer.

The relevant constraints:

  • Single-user keypair. Synapse is local-first; there is no central issuer. The user owns the key, period.
  • No PKI. A regulator-grade chain via Sigstore or X.509 is out-of-scope for v0.4 (kept as ADR-014 §Future direction).
  • Backward compatibility. v0.3 bundles in the wild must still import, behind an explicit --allow-unverified flag that stamps verified: false on the imported person.
  • Self-describing. The verifier must not need a side-channel to find the signer’s key: the bundle carries the public key.

Decision

Adopt an ed25519 signature scheme with a workspace-local trusted-keys allow-list and a revocation list embedded in the bundle.

Key generation. syn identity keygen [--dir PATH] writes a raw ed25519 keypair under ~/.config/synapse/identity/ (etcetera XDG strategy; per-platform). Files: private.key (base64 of 32 bytes, chmod 600 on Unix) and public.key (base64 of 32 bytes). The CLI never moves the private key off-disk.

Canonical JSON. Both the signer and the verifier serialise the bundle to JSON with:

  • Recursively sorted object keys (BTreeMap).
  • The signature field omitted.
  • The public_key field present (so the verifier and the signer agree on the byte sequence over which the signature is computed).

This canonicalisation is byte-stable across Rust versions because the implementation lives in core::identity::canonicalise_value and never delegates to serde_json::to_string for ordering.

Signing. syn identity export --key PATH loads the private key, computes the canonical bytes, signs them with ed25519, and writes signature (base64 of 64-byte signature) + public_key (base64 of 32-byte verifying key) into the bundle. When no key is configured, the bundle ships unsigned: the importer rejects it unless given --allow-unverified.

Verification + trust. syn identity import PATH runs verify_signature(bundle, trusted_keys):

  1. If signature is absent: succeed in “unverified” mode iff --allow-unverified was passed; otherwise error. The imported person stamps verified: false.
  2. If signature is present: public_key must also be present and appear in the [identity] trusted_keys list configured in ~/.config/synapse/config.toml (or the per-workspace cascade). Verification uses ed25519_dalek::VerifyingKey::verify_strict.
  3. On success the imported person stamps verified: true.

The trusted_keys allow-list is the user’s explicit “this signer is me / this team / this employer”. An empty allow-list is allowed and short-circuits the membership check (matches v0.3 behaviour for unsigned bundles): kept on the table because v0.4 must not break the single-user case where the bundle’s only consumer is the same person.

Revocation. The bundle carries a revoked: ["project/cliente-alfa", …] array. On import, attestations whose {type}/{id} (preferred) or bare id appear in revoked are filtered out before being written to attested_involvement. syn identity revoke <bundle> --target project/cliente-alfa --key PATH appends to the list and re-signs the bundle so revocation propagates the next time a consumer imports. Re-signing drops the old signature first so an incompletely-resigned bundle never lies about being verified.

Backward compatibility. v0.3 bundles (no signature, no public_key) still import behind --allow-unverified. The imported person carries verified: false. syn identity import --trusted KEY inlines a one-shot trusted key for cases where the cascade config has not been edited.

Consequences

  • Regulators and downstream consumers can answer “did the human sign this bundle?” by running syn identity import and reading the verified flag on the materialised person entity.
  • The user is responsible for the private key: losing it forfeits the ability to sign or revoke; bundles already in the wild remain verifiable but no new revocations can attach to them.
  • Re-signing is cheap (canonical JSON + ed25519 signature in microseconds) and the implementation lives entirely in core/identity (mod.rs + experience.rs). No new crate dependencies beyond the v0.4-only ed25519-dalek, base64, rand_core.
  • Importers without trusted_keys configured default to accepting any embedded public_key (single-user mode). This is documented in docs/IDENTITY.md and is what --allow-unverified is not: the latter accepts unsigned content too.
  • The format is byte-stable: a bundle exported on day N and re-verified on day N+M produces the same hash. Two implementations of canonical JSON would diverge; that risk is contained because Synapse owns the only producer and the only consumer today.

Alternatives considered

  • Sigstore (cosign). Solves the chain-of-trust problem properly but requires Sigstore infrastructure (Fulcio CA, Rekor transparency log) and OIDC identity. v0.4 is local-first; defer to v0.5+ if Synapse ever ships a public exchange.
  • X.509 / PKI. Same problem at higher cost; rejected for v0.4.
  • PGP via gpgme. GPG dependency, key-server semantics, and a UX every user dreads. Rejected.
  • HMAC with a shared secret. Would work for the single-user case but does not generalise to “verify a bundle a stranger handed you” , and the v0.4 brief calls out cross-org exchange as the goal.
  • JWS / detached signature. Standard but requires JOSE plumbing and a header schema we would not otherwise use. ed25519 over canonical JSON keeps the bundle a single readable artefact.
  • No backward compat (v0.4 hard break). Rejected: v0.3 bundles exist and --allow-unverified is a one-line escape hatch.