How the protocol works
Overview#
DMCNP is seven layers. Speak all of them and you interoperate with the network.
| Layer | What it does |
|---|---|
| User identity | An Ed25519 signing key and an X25519 key-exchange key. The address is local@domain, and its record signs itself. |
| Resolution | A _dmcn.<domain> TXT record gives you a fingerprint to trust and a few nodes to dial. You fetch signed records from the nodes it names and check them against the fingerprint. |
| Message model | PlaintextMessage, then SignedMessage, then EncryptedEnvelope. One AES-256-GCM key per message, wrapped to each recipient over X25519. Header and body are sealed separately, and both are padded to fixed size classes. |
| Routing | RelayHints say which relays hold a mailbox. They sit outside the owner's signature, so an operator can move a mailbox without the owner's key, and the address never changes. |
| Relay service | /dmcn/relay/1.0.0: store, fetch, mailbox operations, record lookups and onion forwarding, as length-prefixed protobuf over libp2p. |
| Trust and federation | Each domain has an authority record, anchored in DNS, that delegates to issuers. Peers exchange and verify credentials at /dmcn/join before they federate. |
| Transport | libp2p streams. Discovery is seeded from DNS, with no DHT, on purpose. |
The protocol itself is four .proto files, defining identity records, credentials, the message
envelope and the relay wire format. They're the contract. If the spec and the schema
ever disagree, the schema wins, and the reference further down this page is generated from it.
Why there's no global directory#
Most decentralised messaging puts identity in a shared overlay: a DHT, a chain, a consensus set. DMCNP doesn't, and the reason is practical. A big enough hostile majority in a shared overlay can quietly withhold records, and for something meant to replace email that's fatal.
So resolution works the way mail delivery already does. A domain publishes a _dmcn TXT record
with its trust anchor and a few seed nodes. You read it, dial the nodes it names, fetch the
signed record, and check it against the anchor from DNS.
A domain is served by the nodes its own DNS names: its own, or a host it explicitly delegates to. It's never served by a shared pool it didn't choose. Records sign themselves, so a server that isn't your domain's authority can refuse to answer you, but it can't lie to you.
Core and extensions#
The core is what you need to interoperate: resolve an address, verify an identity, send mail and receive it. Two capabilities are optional and can be skipped entirely: onion routing, and an SMTP bridge to ordinary email.
Anything an operator wants but the network doesn't need is an extension: fleet administration, hosting permits, entitlements, quotas. Extensions attach through surfaces the core sets aside for them, never through new core fields.
The rule that split protects: ignore every extension and you still interoperate. An extension can
give an operator new powers. It can never make your implementation stop working with the network.
Retired field numbers stay reserved forever.
Status#
This is a snapshot of the reference implementation, not a frozen standard. The schema moves with the implementation, and where this site and the implementation disagree, the implementation wins. Formal versioning and a conformance suite are on the roadmap and aren't done yet.
The wire schema is the compatibility contract. Everything under internal/ in the repository is
how one implementation happens to work, and carries no stability promise.
Licence#
Apache-2.0 covers the code and the schema, with an express patent grant. It doesn't cover the names: implement the protocol under whatever name you like, but don't call something DMCNP unless it really conforms. The FAQ has the detail.
Identity and addressing#
An address such as alice@example.com is a keypair: an Ed25519 signing key and an X25519 key-agreement key, bound in a self-certifying IdentityRecord that any reader can verify on its own. No certificate authority issues it.
The record has two ownership zones. The identity core (address, keys, validity, tier and revision) is covered by the owner's self-signature, so only the key holder can change it. The operational side is mailbox routing. It travels as an operator-signed credential that is deliberately excluded from the self-signature, so a domain operator can re-point or rebalance routing without ever holding the owner's key. That credential verifies against the domain authority instead (see Credentials and authority).
The owner-signed revision never goes down. Fleets and readers reject any record whose revision is lower than one they've already verified, so a replayed old record can never roll an identity back.
IdentityRecord fields
| # | field | type | notes |
|---|---|---|---|
| 1 | version | uint32 | record format version |
| 2 | address | string | local@domain |
| 3 | ed25519_public_key | bytes | signing key (32 bytes) |
| 4 | x25519_public_key | bytes | key-agreement key (32 bytes) |
| 5 | created_at | int64 | unix seconds |
| 6 | expires_at | int64 | unix seconds; 0 = no expiry |
| 7 | relay_hints | repeated string | reader-facing mirror only, not covered by the self-signature; the authoritative copy lives inside routing_credential (25) |
| 8 | verification_tier | VerificationTier | trust tier (see the tier table); enforcement is reader-side |
| 9 | attestations | repeated AttestationRecord | web-of-trust attestations (in-person, fingerprint, network, organizational) |
| 10 | self_signature | bytes | owner Ed25519 (64 bytes) over fields 1–6, 8, 23, 26, 29, 30; ctx dmcn-identity-self-v1\0 |
| 11–22 | (reserved) | — | do not reuse |
| 23 | require_onion | bool | mailbox requires onion delivery, so relays reject direct stores; covered by the self-signature |
| 24 | address_credential | Credential | the domain's attestation of the address↔key binding (role address); issued after self-signing, excluded from the self-signature |
| 25 | routing_credential | Credential | operator-owned routing (role routing) carrying the authoritative relay_hints; excluded from the self-signature so operators can re-point routing without the owner's key |
| 26 | revision | uint64 | monotonic and owner-signed, so a lower revision can never overwrite a higher one (anti-rollback) |
| 27 | (reserved) | — | do not reuse |
| 28 | operator_credentials | repeated Credential | generic operator extension point: operator-attached credentials beyond routing (semantics by role/attributes); excluded from the self-signature; anti-rollback tiebreaks on the newest issued_at across 25 and these |
| 29 | rotation_chain | repeated RotationEntry | the address's own key-change history: one owner-authorized transition per entry, each signed BOTH by the key it retires and by the key taking over; covered by the self-signature; capped, with the complete history in the address's AddressHistoryRecord |
| 30 | recovery_ed25519_public_key | bytes | owner-held key, kept apart from the active one, that may authorize the next rotation when the active key is lost; covered by the self-signature; empty when none is enrolled |
Verification tiers
| value | name | meaning |
|---|---|---|
| 0 | VERIFICATION_TIER_UNVERIFIED | valid but untrusted; addresses register here and still work, and trust is upgraded, never gated at registration |
| 1 | (reserved) | do not reuse |
| 2 | VERIFICATION_TIER_DOMAIN_DNS | raised by a domain attestation (address credential) chained to the DNS-anchored domain authority |
| 3 | VERIFICATION_TIER_DANE | reserved highest tier: DNSSEC/DANE-anchored |
Resolving an address#
The record format, and what a resolver checks. The design behind it is under Why there's no global directory.
_dmcn.<domain> TXT "dmcn-verification=v1; fp=<40-hex>[; fleet=<domain>][; seed=<multiaddr>]..." fp trust anchor: first 20 bytes of SHA-256(ed25519_pub ‖ x25519_pub), uppercase hex fleet optional deferral to a hosting fleet's domain, for discovery only; spoofing it is DoS, never forgery seed bootstrap multiaddrs; each ends /p2p/<peerID>, so the transport handshake authenticates the endpoint
$ dig +short TXT _dmcn.example.com "dmcn-verification=v1; fp=9F2A…C41E; seed=/dns4/id1.example.com/tcp/4001/p2p/12D3Koo…"
A resolver dials a seed, fetches the domain authority record and the identity record, and verifies them in this order:
- the domain authority record's root self-signature is valid;
- the authority's fingerprint equals the DNS
fp=anchor (fail-closed); - if the authority declares a fleet domain, it matches the DNS
fleet=deferral (opt-in pinning); - the identity record's owner self-signature is valid;
- the address credential chains to the domain authority (Credentials and authority);
- neither key is blocked and the address is not tombstoned.
Failures are strict on purpose. No _dmcn record means the address doesn't exist, and a transient DNS failure fails closed rather than downgrading trust.
Messages and encryption#
A message is written in plaintext, signed with the sender's key, then sealed once with a fresh per-message key that's wrapped separately for each recipient. That makes three layers: PlaintextMessage → SignedMessage → EncryptedEnvelope.
Envelope version 2 seals the content in two parts. The header is small and listable: sender, subject, snippet, recipient lists, and signed commitments to the body. The body holds the content and attachments and is fetched separately, so a mailbox can list mail without pulling bodies. The bcc list appears only on the sender's own Sent copy. Recipient copies carry an empty one.
cek = random 32 bytes # one per message
for each recipient device:
eph = fresh X25519 keypair
shared = X25519(eph_priv, recipient_pub)
kwk = HKDF-SHA256(shared, salt="", info=<context>)
wrapped_cek = AES-256-GCM(kwk, cek) # nonce 12, tag 16
content = AES-256-GCM(cek, plaintext, aad) # header and body sealed separately in v2
# each wrap declares its own derivation in recipient_record.kdf; absent means 1
kdf=1: info = "dmcn-cek-wrap-v1" # bare label, binds nothing
kdf=2: info = "dmcn-cek-wrap-v2" ‖ eph_pub ‖ recipient_pub # RFC 9180 §4.1 kem_context
# header and body share one CEK, so each seal binds a label to keep them apart.
# the labels are selected by the same kdf value, so blobs and wraps cannot disagree.
kdf=1: aad = none
kdf=2: aad = "dmcn-aad-hdr-v1\0" | "dmcn-aad-body-v1\0"
A reader dispatches on the declared value and never tries derivations in turn. The field sits on the recipient record rather than the envelope because the record is what survives storage. A mailbox keeps per-recipient entries and drops the envelope's version, message id and timestamp.
A reader must also check that the sender addressed the message to the mailbox reading it, meaning some address in the signed audience (recipient_address, to, cc) resolves to the key the envelope was sealed to. That check, not the encryption, is what catches a message re-targeted at someone it wasn't addressed to. A legitimate recipient holds the message key, so it can re-seal the same signed header to a third party, but it can't change the audience the sender signed. The comparison is on the key rather than the address text, so several addresses sharing one mailbox need no special case.
Sealed blobs are padded into standard size buckets so ciphertext length gives away as little as possible. The padded layout is [4-byte BE length][payload][zero padding]. The body blob also carries a cleartext content address that any relay can verify without a key:
body_content_address = 0x01 0x55 0x12 0x20 ‖ SHA-256(body_nonce ‖ encrypted_body ‖ body_tag)
CIDv1(raw, sha2-256), 36 bytes; the signed copy inside the header is authoritative
v1 gives at-rest and in-transit confidentiality with per-message keys, but not full forward secrecy. The ratchet_pub_key field is reserved for a double-ratchet upgrade and is zero today.
EncryptedEnvelope fields
| # | field | type | notes |
|---|---|---|---|
| 1 | version | uint32 | 1 = single-blob payload; 2 = split header/body |
| 2 | message_id | bytes | 16-byte UUID |
| 3 | recipients | repeated RecipientRecord | one per recipient device: ephemeral X25519 public key + the wrapped CEK (nonce 12, tag 16) |
| 4 | encrypted_payload | bytes | v1 only: AES-256-GCM ciphertext of the whole SignedMessage |
| 5 | payload_nonce | bytes | 12 bytes |
| 6 | payload_tag | bytes | 16 bytes |
| 7 | payload_size_class | uint32 | padded size in bytes, one of the size classes |
| 8 | created_at | int64 | unix seconds |
| 9 | ratchet_pub_key | bytes | reserved for forward secrecy (protocol v2); zero in v1 |
| 10 | encrypted_header | bytes | v2 split: the sealed SignedHeader, small and listable (sender, subject, snippet, recipient lists, body commitments) |
| 11 | header_nonce | bytes | 12 bytes |
| 12 | header_tag | bytes | 16 bytes |
| 13 | header_size_class | uint32 | padded size in bytes, one of the size classes |
| 14 | encrypted_body | bytes | v2 split: sealed MessageContent (body + attachments), fetchable separately from the header |
| 15 | body_nonce | bytes | 12 bytes |
| 16 | body_tag | bytes | 16 bytes |
| 17 | body_size_class | uint32 | padded size in bytes, one of the size classes |
| 18 | body_content_address | bytes | cleartext CIDv1 of the body blob, so relays can verify integrity without any key; the authoritative copy is signed inside the header |
Envelope size classes
| size_class value | bucket |
|---|---|
| 1024 | 1 KB |
| 4096 | 4 KB |
| 16384 | 16 KB |
| 65536 | 64 KB |
| 262144 | 256 KB |
| 1048576 | 1 MB |
| n × 1048576 | above 1 MB: the payload plus its 4-byte length, rounded up to the next whole MB |
Wire protocols (libp2p)#
Every DMCN conversation is a libp2p stream, an authenticated and encrypted transport whose handshake proves the peer's identity. A peer ID can't be spoofed without its key.
Three protocol IDs carry the core. Device pairing has no protocol of its own, on purpose. The pairing exchange uses the ordinary relay mailbox path, under a synthetic pairing.local domain.
libp2p protocol IDs and framing
| protocol ID | framing | purpose |
|---|---|---|
| /dmcn/relay/1.0.0 | 4-byte big-endian length prefix + protobuf; 4 MB frame cap; bodies chunked past it (to 64 MB) | the workhorse: store, fetch, mailbox, resolve and onion ops |
| /dmcn/peers/1.0.0 | length-prefixed JSON | cluster peer discovery: a node returns its configured peer list |
| /dmcn/join/1.0.0 | varint-delimited protobuf | mutual credential handshake; gates federation, deny-by-default |
| (extensions) | — | operator surfaces ride their own protocols and are not part of the core |
Relay operations#
One protocol carries all relay work. Every core operation authorizes in one of three ways, and none of them is a password.
Message-authenticated ops carry their proof in the message itself. A store is valid because the sender signed the envelope, and a fetch is valid because the client answers the relay's nonce challenge with a signature from the account key. Connection-gated ops need the calling peer to have presented a valid credential at the join handshake. Public reads return signed records the reader verifies for itself. The one public write, put_record, re-verifies every record on ingest, so a record that doesn't verify can't be stored.
The operator surface (fleet administration and operator-attached entitlements) uses separate extension protocols, /dmcn/operate and /dmcn/mailbox-ext, outside the core. The arm numbers it once used here are reserved, and the table marks them.
Relay operations
| # | op | auth class | authorization |
|---|---|---|---|
| 1 | store | message | sender's Ed25519 signature over the envelope; the sender must resolve and pass the recipient domain's policy gates |
| 2 | fetch_init | message | opens the mailbox challenge; the relay returns a fresh nonce |
| 3 | fetch_proof | message | Ed25519 proof over the relay's nonce with the account key; no passwords, no bearer tokens |
| 4 | ack | connection | credential-admitted federated peer |
| 5 | ping | public | liveness |
| 6 | mailbox_op | message | list / body / delete and personal-KV ops, each under a fresh fetch proof |
| 7 | store_init | message | chunked store for bodies past the frame cap; sender signature like store, or node role for relay→relay drain handoff |
| 8 | onion_forward | connection | credential-admitted federated peer; peel one layer, then forward or deliver |
| 9–18 | (reserved) | — | vacated by the operator extension protocols; do not reuse |
| 19 | get_identity | public read | returns the signed IdentityRecord; the reader verifies it against the DNS anchor |
| 20 | get_dar | public read | returns the signed domain authority record; verified against the DNS fingerprint |
| 21 | get_fleet_roster | public read | fleet-root-signed node roster |
| 22 | get_removal | public read | root-signed address tombstone |
| 23 | get_blocklist | public read | root-signed credential revocation list |
| 24 | put_record | self-gating | every record is re-verified on ingest (self-certifying); a fleet may add publisher gating |
| 25 | get_relay_descriptor | public read | onion descriptor, self-anchored to the relay's peer ID |
| 26 | (reserved) | — | vacated by the operator extension protocols; do not reuse |
| 27 | get_history | public read | the address's complete rotation history; self-authenticating through the signatures on its own entries, so serving it discloses nothing a resolver could not verify |
Credentials and authority#
Trust is anchored in the domain and spelled out in credentials. A credential's roles say what a key is and its grants say what it may do. No key can sign anything its grants don't cover.
Every credential chain leads back to the domain's root key, the one the DNS fingerprint anchors (Resolving an address). Delegation is monotone: an issuer can never grant more than it holds, scope can only narrow (a subdomain scope covers its children, never its parents), and chains are capped at depth 8. By convention the powerful grants stay on offline keys, and the busy online services carry only what they need to run.
The domain authority record is the domain's administrative root. It holds the issuer set, reserved local-parts, key-rotation history and policy flags. Administrative authority over an address is separate from cryptographic ownership of it. A domain authority can provision, attest and revoke bindings in its namespace, but it never holds private keys and can never read mail. How a domain owner lets a fleet operator host it is an operator extension, outside this core reference.
Credential fields
| # | field | type | notes |
|---|---|---|---|
| 1 | version | uint32 | credential format version |
| 2 | subject | bytes | Ed25519 public key: the libp2p peer ID for infrastructure and clients, the identity key for addresses |
| 3 | domain | string | the domain this credential is scoped to |
| 4 | address | string | role address only: the attested local@domain |
| 5 | roles | repeated string | what the subject is (see the roles table) |
| 6 | grants | repeated string | what the subject may issue or do (see the grants table) |
| 7 | attributes | map<string, string> | free-form: multiaddr, ip, x25519 onion key, … |
| 8 | issued_at | int64 | unix seconds |
| 9 | not_after | int64 | unix seconds; 0 = never expires |
| 10 | scope | string | authority credentials: subdomain scope (empty = whole domain); delegation can only narrow it |
| 11 | issuer_pub | bytes | the domain root, or any enrolled issuer whose own grants cover this credential |
| 12 | signature | bytes | issuer Ed25519 over fields 1–11, 13 and 14; ctx dmcn-credential-v1\0 |
| 13 | relay_hints | repeated string | role routing only: the authoritative operator-signed mailbox relays |
| 14 | effective_from | int64 | unix seconds not-before; the validity window is [effective_from, not_after] |
| 15 | (reserved) | — | do not reuse |
RotationEntry fields
| # | field | type | notes |
|---|---|---|---|
| 1 | version | uint32 | entry format version |
| 2 | address | string | the address this transition belongs to; an entry signed for one address can never be replayed into another's history |
| 3 | retired_ed25519_public_key | bytes | the signing key being given up (32 bytes) |
| 4 | retired_x25519_public_key | bytes | the mailbox key being vacated (32 bytes) |
| 5 | next_ed25519_public_key | bytes | the signing key taking over (32 bytes) |
| 6 | next_x25519_public_key | bytes | the mailbox key taking over (32 bytes) |
| 7 | rotated_at | int64 | when the enrolling device attests this transition happened (unix seconds) |
| 8 | next_revision | uint64 | the IdentityRecord revision this transition mints; must advance |
| 9 | prev_signature_hash | bytes | SHA-256 of the previous entry's signature, empty at the first rotation; this is what makes truncation visible rather than silent |
| 10 | authorizing_ed25519_public_key | bytes | the key that produced `signature`, either the retiring key or the owner's recovery key (32 bytes) |
| 11 | device_credential | Credential | the domain's attestation of the enrolled device that authorised this rotation; its issued_at is the device's enrolment date, which a tenure rule is measured against |
| 12 | device_signature | bytes | 64 bytes by the device; a credential alone is public and could be lifted from an earlier transition, so the device signs this one |
| 13 | signature | bytes | the outgoing key's consent: 64 bytes by authorizing_ed25519_public_key, ctx dmcn-identity-rotation-v1\0 |
| 14 | next_signature | bytes | the incoming key's acceptance, covering the consent: 64 bytes by next_ed25519_public_key, ctx dmcn-identity-rotation-accept-v1\0 |
AddressHistoryRecord fields
| # | field | type | notes |
|---|---|---|---|
| 1 | version | uint32 | record format version |
| 2 | domain | string | the address's domain |
| 3 | address | string | local@domain; the record is keyed on SHA-256 of this, like the removal record |
| 4 | chain | repeated RotationEntry | the complete rotation history, oldest first. No container signature: every entry is already signed by the keys it names, so extending the history takes keys the extender must hold. Append-only: a stored history may only be replaced by one that strictly extends it |
Credential roles
| role | what the subject is |
|---|---|
| authority | a domain's root signing authority (its key anchors the DNS fingerprint) |
| sub-authority | a delegated issuer enrolled by the root |
| node | a relay/storage node |
| bridge | an SMTP bridge |
| client | an end-user-facing client peer (mints nothing by itself) |
| address | an attested address↔key binding (rides in IdentityRecord.address_credential) |
| routing | an operator routing attestation (rides in IdentityRecord.routing_credential) |
| device | one enrolled device of an account, keyed by a signing key generated on that device and held nowhere else |
| (extensions) | further roles carry operator-attached entitlements and are not part of the core |
Credential grants
| grant | what the holder may do |
|---|---|
| address | sign address credentials (attest address↔key bindings) |
| routing | sign routing credentials (attest mailbox routing) |
| device | sign device credentials (attest an account's enrolled devices) |
| grant | delegate: issue credentials that themselves carry grants |
| (extensions) | further grants cover operator entitlements and fleet administration and are not part of the core |
Domain policy flags (DAR policy_flags bits)
| bit | flag | effect |
|---|---|---|
| 1 << 0 | REQUIRE_COUNTERSIGN | uncountersigned addresses are unusable on this domain; enforced reader-side and at the mailbox fetch gate |
| 1 << 2 | REQUIRE_ONION | every address on the domain must receive via onion delivery |
| 1 << 3 | REPLICATE_MAILBOX | senders store to every listed relay hint instead of first-reachable failover |
| 1 << 5 | ALLOW_KEY_ROTATION | account holders may re-key their own address with an owner-signed rotation chain; off by default |
| 1 << 1, 1 << 4, 1 << 6 and up | (extensions) | reserved for extensions |
Signing convention#
Every signature in DMCN follows one rule.
sig = Ed25519(priv, ctx ‖ deterministic_protobuf(message_without_signature))
The canonical form is deterministic protobuf serialization (stable map ordering) of the message with its signature field cleared. ctx is a per-type, NUL-terminated domain-separation tag, so a signature over one record type can never be replayed as another. There's one deliberate exception. The whole-message SignedMessage signature is computed over the canonical plaintext with no context tag.
Signature context tags
| context tag | signs |
|---|---|
| dmcn-identity-self-v1\0 | IdentityRecord owner self-signature |
| dmcn-identity-rotation-v1\0 | RotationEntry: the outgoing key's consent |
| dmcn-identity-rotation-accept-v1\0 | RotationEntry: the incoming key's acceptance |
| dmcn-identity-rotation-device-v1\0 | RotationEntry: the enrolled device's attestation |
| dmcn-dar-self-v1\0 | DomainAuthorityRecord root self-signature |
| dmcn-credential-v1\0 | every Credential |
| dmcn-subauthority-request-v1\0 | a requester's self-signed sub-authority request |
| dmcn-address-removal-v1\0 | AddressRemovalRecord (root-signed tombstone) |
| dmcn-key-compromise-v1\0 | KeyCompromiseRecord |
| dmcn-fleet-roster-v1\0 | FleetRoster |
| dmcn-msg-header-v1\0 | SignedHeader (the split-format message header) |
| (extensions) | further tags sign operator-surface records and are not part of the core |
Onion routing (optional)#
An optional delivery mode that hides who is talking to whom from the relays in between. The packet format below is implemented and the design default is multi-hop, but today's deployments run single-hop, so don't count on the metadata privacy described here yet.
A route is 3 hops by default, ending at the recipient's home relay. Hops must sit in distinct /24 networks and distinct domains, so no two hops share an operator, and an optional stable guard can be the entry. Each relay publishes a RelayDescriptor with its onion key and addresses, signed by its own libp2p key. That key can be recovered from the relay's peer ID, so a descriptor verifies without trusting whoever served it.
OnionPacket { version, ephemeral_pub (32), nonce (12), tag (16), encrypted_layer }
OnionLayer { next_hop | "DELIVER", ttl_unix, inner_packet | delivery }
each relay: derive layer key from ephemeral_pub + own X25519 key → peel → forward (or deliver)
Only the innermost delivery layer is padded to its own bucket classes (up to 36 MB, matching the message ceiling). Forwarding layers add routing overhead only. A mailbox sets require_onion (or its domain sets the REQUIRE_ONION policy) to make the relay reject direct stores with ONION_REQUIRED.