minca

07 — Inbox

A DID is a name, not an address: it is on everything an account signs, so knowing it must not be enough to reach the account. Two people who share a space can become contacts through it, with a knock left there (03 §16). This part covers two people who share nothing yet.

An account's inbox is an address it hands out on purpose, and can replace:

What it isWho sees it
Inbox keyA P-256 key pair knocks are sealed to, derived from the contact key and the inbox's genIts public half: whoever has the code
Inbox signing keyA second P-256 key pair, derived likewise, that signs the code and proves ownership of the boxIts public half: whoever has the code; its hash is the inbox's topic
Places1–3 relays whose mailboxes hold knocks on itWhoever has the code
Inbox codeA sys.inbox loose version holding the two public keys, gen, the places and a nameWhoever it is given to

Someone holding a code knocks: they make a private space for the two of them, and leave a sys.knock carrying its signed invitation, sealed to the inbox key, in the inbox's mailboxes. The owner's devices fetch knocks, check them, and accept one by joining the space. Joining is the answer: only the holder of the inbox key could open the invite.

This part specifies what a relay and an inbox's owner check: the keys, the code, the topic, the mailbox and the knock. Where an account keeps its inbox's settings, and how a knock becomes a contact on each side, are a convention on top: see Inbox in the library's docs.

The account behind an inbox is never in the code, the topic, or anything a relay holds. The knocker learns it when the owner joins.

Lengths in this part count Unicode code points; text MUST be shortened without splitting one.

Rationale: this is Nostr's outbox model (the address names its own relays, so relays never need to know about each other) with SimpleX-style addresses (an address is a revocable queue on servers the recipient picked, not an identity). No relay can take an inbox down while another it names is up, and an owner whose relays all go away names others, or replaces the inbox.


1. Inbox keys

Both of an inbox's keys are derived from the account's contact key (01 — Identity §10.3) and the inbox's generation, gen:

ikm         = contact key private scalar (32 bytes)
inbox key   = P-256 scalar from HKDF-SHA-256(ikm, salt = empty, info = UTF-8("minca/p256-inbox-key/v1|" + gen), L = 48)
signing key = P-256 scalar from HKDF-SHA-256(ikm, salt = empty, info = UTF-8("minca/p256-inbox-sign-key/v1|" + gen), L = 48)

The 48 bytes are reduced to a scalar exactly as the root key is (01 — Identity).

  • gen is a whole number from 0, written in decimal. Replacing the inbox is taking the next gen: codes for the old one still read, but nothing sealed to them is opened any more, and nothing ties the two inboxes together.
  • Public halves are compressed P-256 points (33 bytes), base64url (44 characters): the same form as a contact key's.
  • The inbox key is used only for ECDH (opening knocks). The signing key is used only for ECDSA P-256 / SHA-256 signatures: over the inbox code (§3) and the purge message (§5). Neither is used for the other job.

Every device and app that holds the contact key derives the same inbox keys. The contact key's public half is published on the account's profile in every space it writes in; an inbox's keys are not, and nothing links them.

Source: packages/core/src/identity/contact-key.ts (deriveInboxKeyBytes, deriveInboxSignKeyBytes, signingKeyOf, signWithScalar, verifyWithPoint). Tests: packages/core/tests/inbox.test.ts ("an inbox code").

2. The inbox's settings

Not protocol: an account keeps which gen its inbox is at, its places and its name as a std.inbox record in its own contacts space, which only its devices read. See Inbox in the library's docs.

3. The inbox code

An inbox code is a loose version (02 §3.5) in sys.inbox, signed by the inbox's own signing key, for itself:

code = base64url( UTF-8( canonicalJSON(inbox) ) )
  • author is the did:key of the signing key (sign below), and there is no proof: nothing in the code names the account.
  • The body:
FieldType
keystringThe inbox key's public half
signstringThe inbox signing key's public half; MUST differ from key
geninteger ≥ 0Which inbox of the account's this is
placesstring[], 1–3Mailbox URLs; see below
namestring ≤64, optionalWho the owner says they are. It proves nothing.

A reader MUST accept a bare code, or a link carrying one as inbox=<code> after #, ? or & (e.g. https://app.example/#inbox=eyJhdXRob3Ii…), and MUST refuse a code unless:

  1. it is a valid loose version in sys.inbox (02 §3.5) with no proof;
  2. its author is the did:key of sign;
  3. its body has no member outside the table; key and sign are valid P-256 points, and differ; gen is a whole number from 0; places holds 1–3 relay URLs that pass the space relay check (each wss://, or ws:// on localhost only, at most 200 characters, no duplicates); name, when present, is a string of at most 64 characters.

A signed code means nobody passing it on can change where knocks go, or the name it shows, without the owner's signing key.

Example: the contact key of 01 §10.1, gen 0, the record key aaaaaaaaaaaaaaaaaaaaaaaaaa and the time fixed. Inbox key scalar 3eb6fe8cd7d3d571c860e3867262a326d0f6337a6e5b2f23ec8894c6b7712bff, signing key scalar d439cfa23bbb48af5cac7e829c00683812d3adf402868a994a8bbc9527790001, topic HxW8ECmsKlUka0UEOGFx8bgChj6ToCYTOreTaBnVCtY. The code, decoded (the signature varies; any valid one verifies):

{
  "id": "bdcf6dsnoxnzjnoeyc5q5o7sceaxswkb5pwsk5jhgm4zyvuf64zma",
  "author": "did:key:zDnaetEy5TtXJYGtyxUAzR7a7Uj6H4aVcNgULLRxPuNMBtWJK",
  "collection": "sys.inbox",
  "createdAt": "2026-10-01T12:00:00.000Z",
  "key": "aaaaaaaaaaaaaaaaaaaaaaaaaa",
  "seq": 0,
  "bodyHash": "bfxrrrcuuku34hhu3clsxab3afxgijzrcrqtd46fzilkluoljfuza",
  "body": {
    "key": "Az7e4Rrb7cRwOBUTo2R3DHPN_XhU_eEELZCaHnEdENoo",
    "sign": "A5014XmFHNvGb4sJVDmiDas-v4R4QJFmBRsO-N1lEMNY",
    "gen": 0,
    "places": ["wss://relay.example"],
    "name": "Anna"
  },
  "signature": "Q9Wrz4jPqhDYjXpB8vz5m9YrvWxXiyyTg7E9AM7F8vY6t7bNYLgK8mHBpgyqxnZ1IJB5atvHrTtGrsz5KyP9qw"
}

Rationale: a code in a URL goes in the fragment, like an invite, so it isn't sent to the server that serves the page.

Source: packages/core/src/inbox/inbox.ts (encodeInbox, readInbox, inboxProblem, inboxAuthor), packages/core/src/records/loose.ts (checkLoose). Tests: packages/core/tests/inbox.test.ts ("an inbox code"), packages/core/tests/inbox-example.test.ts (the example's values).

4. Topics

An inbox's knocks are filed under its topic, a hash of its signing key:

topic = base64url( SHA-256( UTF-8("minca/inbox-topic/v1|" + sign) ) )     — 43 characters

A relay can check that whoever clears a topic holds the key it came from (§5), without learning anything else. Knowing a topic lets anyone fetch its sealed blobs, which open only with the inbox key.

Not yet specified: topics that change over time (e.g. per week), so a relay can't follow one inbox across months.

Source: packages/core/src/inbox/inbox.ts (inboxTopic).

5. The relay mailbox

An inbox's knocks wait in the mailboxes of the relays its code names: each is dropped there and fetched later (04 — Network §1.1), on a relay of any kind (04 §1.2). A knock is dropped with a time to live of 14 days. A fetch reads all its relay's kind gives (04 §1.2); the same blob from several relays is one knock, by its id (§6).

A relay keeps no time a reader trusts: what a knock's time is, is the knocker's word, signed (§6).

challenge / purge — the inbox's owner clears knocks, on a relay that can purge (04 §1.2). On a Minca relay:

→ { "type": "challenge" }
← { "type": "challenge", "nonce": "<24 chars>" }
→ { "type": "purge", "topic": "<topic>", "sign": "<signing key public half>", "ids": ["<id>", …], "sig": "<base64url>" }
← { "type": "purged", "topic": "<topic>", "count": 2 }
  • sig is the inbox signing key over UTF-8("minca/inbox-purge/v1|" + topic + "|" + nonce + "|" + ids), where ids is the ids sorted and joined with ,, or * when ids is absent (clear everything).
  • The relay MUST check that topic is the hash of sign (§4) and that sig verifies against sign, and MUST refuse otherwise. A nonce is good for one purge on the socket that asked for it, whether or not the purge succeeds.

A relay MUST ignore mailbox messages whose topic is not 43 base64url characters (no answer).

Limits

Blob size and blobs per topic are the same on every Minca relay. Every other limit is the relay operator's to set; a client learns of one only from refused (or, for watches, from silence). The values below are the reference relay's. An address is an IPv4 address or an IPv6 /64.

LimitValueOn breach
Blob size12,000 chars (every Minca relay)refused
Blobs per topic64 (every Minca relay)refused
Drops per topic per address per hour4refused
Drops per address per hour, all topics30refused
Topics held50,000refused
Total held64 Mi chars, plus a reserve of 8 Mi only topics holding fewer than 2 blobs may userefused
TTL14 dayscapped

These are a Minca relay's; another kind keeps what its operator lets it. The socket's message budget, size cap, heartbeat and watches are the relay's usual ones (04 — Network §1.4).

A full inbox refuses new knocks rather than dropping old ones, so a flood can't push out real knocks; a full relay still takes a knock or two on every inbox, from the reserve. An owner whose inbox is flooded clears it (purge), which keeps its code working. On a relay that can't purge, junk stays until it expires, and where a fetch is bounded it buries the knocks before it (04 §1.2): an inbox that names such a relay should name one that can purge too.

A drop carries no proof of work. Work a phone does in a second, a server core does in a tenth of one, so it would hold back nobody the address budgets above, or a Nostr relay's own limits, don't.

Known defect: both budgets are weaker than this section and §8 promise. One IPv6 /64 counts as one address, so a home /56 (256 of them) can fill the mailbox within an hour. And a topic holding no blobs may use the reserve, so knocks on random new topics can spend it, after which a full relay takes no knock on an existing inbox. A fix budgets IPv6 at /64, /56 and /48 together, and keeps the reserve for topics that existed before the mailbox filled. Other implementations SHOULD NOT copy these limits. Tracked in #162.

A relay need not keep its mailbox across a restart (the reference relay keeps it in memory), which is one reason a knock goes to every place an inbox names.

Short links. The mailbox also keeps sealed invites under a short link's topic (03 §7.7). The relay can't tell them from knocks, and does nothing different. No key hashes to such a topic, so a purge of it always fails: nobody can clear it. A host's relay also answers a fetch with the links its accounts keep (06 §4.2).

Source: packages/relay/relay.mjs (fetchMail, dropMail, purge, sweep, MAILBOX_LIMITS, mailboxFrom), packages/core/src/network/relays.ts (createMailboxClient), packages/core/src/network/relay-minca.ts (createMincaMailbox, mincaRelay), packages/core/src/network/relay-nostr.ts (createNostrMailbox, nostrRelay), packages/core/src/node/node-contacts.ts (purgeInbox). Tests: packages/core/tests/inbox.test.ts ("the relay's mailbox"), packages/core/tests/invite-link.test.ts ("a mailbox that keeps blobs for others", "nobody can clear it…"), packages/core/tests/relay-nostr.test.ts ("a Nostr relay as a mailbox").

6. Knocks

The knock

A knock is a loose version (02 §3.5) in sys.knock, signed by the knocker's session key under its account's note, with to the key it is sealed to: an inbox key (§1), or a contact key when left in a space both belong to (03 §16). Its body:

FieldType
inviteobjectThe invitation (03 §7.4) to a private space for two the knocker made, as signed
secretstringIts invite secret (03 §7.1), base64url: 43 characters
namestring ≤64The name they give (defaults to "Someone"); nothing vouches for it
notestring ≤2000, optional
sealed = HPKE(to, "minca/mail/v1", { to }, UTF-8(JSON(knock)))

HPKE is sealing to a key (01 §9.2): to to, with label minca/mail/v1 and bound { "to": <that key> }. The blob is base64url(0x01 ‖ ephemeral point (65 bytes) ‖ ciphertext). A sealed knock MUST fit a mailbox: 12,000 characters (§5).

A knocker drops a knock as soon as it signs it.

Opening a knock

A recipient MUST treat a blob as a knock only if all of these hold, and otherwise ignore it, wherever it came from: a mailbox, or a record in a space both share.

  1. It opens with one of the recipient's keys (an inbox key it holds, or its contact key), label minca/mail/v1, bound { "to": <that key's public half> }; and the knock's to is that key.
  2. It is a valid loose version in sys.knock (02 §3.5): its signature checks out, and its note, when it carries one, is valid at its createdAt.
  3. Authority. Its note, when it carries one, grants /account/write for the account as a whole, with arguments {} (01 §8): a note to write for the whole account. An app given some spaces, or a key given records only, may not knock. With no note its author is the account.
  4. Time. createdAt is no more than 14 days before now, and no more than 5 minutes after it.
  5. name is a string of at most 64 characters, note when present one of at most 2000, and secret 43 base64url characters.
  6. invite checks out as an invitation (03 §7.4), and speaks for the same account as the knock; its space is private and it carries the space key; its space's constitution names that account as its one founder and assigns no roles (03 §1.1); it names a role; and the space checks out (its id is the hash of its constitution).

A mailbox knock's id is base64url(SHA-256(UTF-8(blob))), the same on every relay. A client MUST de-duplicate by it, computing it itself rather than trusting a relay's id.

The constitution's checks say only that the knocker founded the space alone and assigned nobody a role in it; its history may since have given others places. Who else holds one shows once the recipient has joined.

Which of the knocks that pass these checks a client offers the person (leaving out blocked accounts, for one) is up to it; see Inbox.

Rationale.

  • A space's genesis isn't signed, so the invite alone could not prove who made it. The knock's signature under the account's note proves it came from that account before anyone joins anything, as a record's does, and the invitation, signed under the same account, says the same of the relays and nodes it names. to is signed, so whoever opens a knock can't seal it again to another key and pass it off as sent there.
  • Authority (3): knocking makes a space and hands out its invite, so it needs what making spaces for the account needs: a note that grants /account/write for the whole account.
  • Time (4): knocks are read days later, long after an hour-long session note ran out, so the note is judged when the knock was signed. That time is the signer's word, and no relay's is asked for: one rule wherever a knock waited, and any relay can hold one. A key stolen while its note was good can sign knocks dated then and leave them for 14 days (§8).

Source: packages/core/src/inbox/inbox.ts (sealKnock, openKnock, knockId), packages/core/src/space/invitation.ts (checkInvitation), packages/core/src/identity/abilities.ts. Tests: packages/core/tests/inbox.test.ts ("a knock").

7. The exchange

Not protocol: where an account keeps its inbox's settings (std.inbox) and the knocks it left (std.knock), and how each side turns a knock into a contact, are records a peer syncs and judges like any other. See Inbox in the library's docs.

There is no answer record. The recipient accepts by joining the space for two with the knock's invite (03 §7.6). The knocker takes the first account other than its own that holds a place there as the contact, and closes the invite (03 §7.2). An account home MUST NOT give the contact key with a note that grants records only (06 §2.8).

8. Security considerations

  • What each side learns. A relay: topics, blob sizes and times, and the addresses that drop, fetch and purge; not whose inbox it is, who knocked or what was said. Topics are stable per inbox (§4). A knocker: only the name the code gives, and the owner's account once they join. The owner: the knocker's account, proven, and the name and note they chose, which prove nothing. The code says nothing about the account, and a new gen gives keys nothing ties to the old ones.
  • A changed code. A code is signed by its own key (§3), so whoever passes it on can't change its places or name. They can hand out a code of their own instead: only where a code came from says whose it is.
  • Spam. Anyone with a code can knock. The owner clears the inbox, which keeps every printed code working, or replaces it; blocking hides an account's knocks everywhere. Relay budgets are in §5, and weaker than they should be (the known defect there). A knock only shows as a request.
  • Authority. Only a note that grants /account/write for the whole account can knock (§6). A knock is judged at the time it says it was signed, wherever it waited, so whoever has a key, even after its note ran out, can sign knocks dated into the note's life and leave them until 14 days after it ran out. A note that was revoked but hasn't run out yet still can, since the owner can't see the knocker's revocations (#165).
  • Spam on a relay that can't purge. Where its fetch is bounded, anyone with the code can bury earlier knocks there with drops past the bound, dated ahead where newest is the dropper's word, as on Nostr (§5, 04 §1.2).
  • Leaked invites. The invite in a knock is multi-use until the knocker closes it, and whoever joins first is taken as the contact. Someone else joins first only if the recipient passed it on, lost a device, or gave an app the contact key that the inbox keys are derived from (#313). They show under their own account, though they may give the recipient's name.
  • Replays and lost relays. A knock dropped again is the same blob, the same knock, and fails the time check 14 days after it was signed (§6). Knocks go to every place an inbox names; a relay can drop them but can't read, forge or alter them, nor hide one another relay holds.

9. Usernames

A username is a name at a domain, leif@minca.social, that leads to an inbox code: someone can say it aloud or put it in a bio where a code works today. It never leads to the account's DID, which would tie everything the account signed to the name. The code is signed by the inbox's own key (§3), so whoever answers for the domain can hand out another inbox, but can't change where this one's knocks go.

username = name "@" domain
name     = 1–64 of a–z 0–9 . _ -, starting and ending with a letter or digit
domain   = a hostname with at least one dot, ≤253 characters, labels of 1–63 of
           a–z 0–9 - not starting or ending with -, the last with a letter (so
           never an IP address); or localhost[:port], for trying it out

A reader MUST compare usernames in lower case, and MAY accept one written with a leading @ (@leif@minca.social). It looks one up at its domain:

GET https://<domain>/.well-known/minca.json?name=<name>
← 200 { "names": { "<name>": "<inbox code>" } }

The scheme is http only for localhost, with or without a port. A reader MUST refuse a username unless:

  1. the domain answers the request itself, with 2xx: a redirect is refused;
  2. the answer is a JSON object whose names holds a string under name;
  3. that string is an inbox code a reader accepts (§3).

A reader ignores other members, MUST refuse an answer larger than 1 MiB, and MAY refuse one that takes too long. A domain that answers for names MUST send Access-Control-Allow-Origin: *, so that a page anywhere can look a name up. For a name it doesn't hold it answers 404, or an answer without it: a small file served as it is, listing a few hundred names, answers correctly, since the query is only a hint.

A username leads to an inbox's current code, so anyone who had an old code and knows the name finds the new one: replacing a leaked inbox (§1) sheds it only for those who don't know the name.

Who may have which name at a domain, and how they claim it, is the domain's business, not the protocol's. The name service minca.social runs, which anyone can run, is described in Usernames in the library's docs.

Name services are dependencies of finding someone only: contacts already made are Minca spaces and survive them. A domain can always answer with an inbox of its own, so a username is as good as the domain that answers for it, and a reader shows which domain that is. ATProto handles as usernames are an idea for later (#340).

Rationale: the lookup is NIP-05's, so a domain with only a static web server can answer for its people.

Source: packages/core/src/inbox/names.ts (parseUsername, usernameUrl, resolveUsername). Tests: packages/names/tests/names.test.ts ("a username", "a reader takes an answer only with 2xx, and only so large", "a lookup that is redirected is refused…"), packages/core/tests/inbox.test.ts ("is signed by its own signing key: one signed by another key, or edited, is refused").

10. Not yet specified

  • Topics that change over time (§4).
  • Revocation checks on a knocker's note (§8).
  • Several inboxes at once, so an account can hand out ones nobody can link to each other.

On this page