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-unverifiedflag that stampsverified: falseon 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
signaturefield omitted. - The
public_keyfield 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):
- If
signatureis absent: succeed in “unverified” mode iff--allow-unverifiedwas passed; otherwise error. The imported person stampsverified: false. - If
signatureis present:public_keymust also be present and appear in the[identity] trusted_keyslist configured in~/.config/synapse/config.toml(or the per-workspace cascade). Verification usesed25519_dalek::VerifyingKey::verify_strict. - 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 importand reading theverifiedflag 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-onlyed25519-dalek,base64,rand_core. - Importers without
trusted_keysconfigured default to accepting any embeddedpublic_key(single-user mode). This is documented in docs/IDENTITY.md and is what--allow-unverifiedis 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-unverifiedis a one-line escape hatch.