Contacts, inbox policy & key pinning
Poweur's spam answer is recipient consent, not reputation: hosted identities are cheap, so per-identity reputation is weak — instead, your relay only accepts what your policy allows, and strangers get exactly one knock on the door. This page specifies the contact model (EPIC-007), the enforcement flow, and the key-pinning trust model.
The two policy files
Both live in the relay-readable zone (.poweur/relay/), are owner-written through the owner system-file API
(so they sync across devices like any file), schema-validated on write, and are never
visible to other users.
contacts.json (schema):
{"version": 1, "contacts": [
{"identity": "bob.example.org", "state": "accepted",
"pinned_key": "ed25519:…", "petname": "Bob", "added_at": "…", "source": "request"}]}
States: requested (an open request exists), accepted, blocked.
inbox-policy.json: {"version": 1, "mode": "contacts_and_requests"}
| mode | behavior |
|---|---|
open | any valid Poweur ID may message (pre-policy behavior; the default when no file exists, for compatibility). Contact request and accept envelopes still land in the requests queue, so the handshake does not depend on who may send chat |
contacts_only | accepted contacts only; everyone else policy_rejected — including contact requests |
contacts_and_requests | contacts message normally; a stranger's first sys.contact.request lands in the requests queue; everything else is rejected until accepted |
trusted_auth_services (optional) lists the OAuth bridges (EPIC-022) whose
sys.auth.request sign-in prompts the relay admits — in every mode, and only that type. A
listed service is not a contact: it cannot chat, share or send any other sys.* message, and
being a contact does not let anyone send prompts. A blocked service is refused like any other
blocked sender. Prompts must be small and expire within ten minutes.
Clients SHOULD write contacts_and_requests for human identities (poweur policy set contacts_and_requests). Blocked senders are rejected in every mode — and receive the
same generic policy_rejected as strangers, so a block is indistinguishable from a
closed inbox. Your own identity always reaches your own inbox (multi-device).
The request lifecycle
none ──sys.contact.request──► pending (requests queue, ONE slot per sender)
pending ──recipient accepts──► accepted (both sides write contacts.json, keys pinned)
pending ──recipient ignores──► re-request only after 7-day cooldown
any ──recipient blocks───► blocked (silent)
- The request rides messaging as a typed envelope:
type: "sys.contact.request"(plaintext type — the relay routes on it without reading the E2E-encrypted intro, which is capped at 4 KB). The type is bound into the message signature (CanonicalMessageTyped), so it cannot be forged onto a signed message. - The queue is separate from the inbox (
GET /requests/{identity}, challenge-signed) — requests never pollute the message stream, andpoweur requestslists them. This is true in every inbox mode, includingopen, and even when the recipient already lists the sender as an accepted contact (so a one-sided handshake can still be answered). sys.contact.acceptis only accepted from a peer the recipient lists asrequested— an unsolicited "accept" from a stranger is rejected.- Enforcement happens on the recipient's relay, which is where cross-relay forwarded traffic arrives too — a sender's relay cannot bypass policy.
CLI: poweur contacts request/accept/block/rm/ls, poweur requests, poweur policy show/set.
poweur requests decrypts each pending intro before printing it. That is not a
convenience: contacts_and_requests exists so a stranger can say who they are before you
decide, and a queue showing a name and a timestamp asks you to accept or block someone on
no evidence at all.
A rejected send offers to become a request. When the relay answers policy_rejected,
poweur send asks whether to send a contact request instead and carries the message
across as the intro — the intro is an ordinary short E2E-encrypted message, which is
exactly what the sender already typed. --request-on-reject answers yes up front (scripts,
and a non-TTY stdin, take this path or none). Two rules keep it honest: it never fires for
sys.* envelopes, so a rejected contact request cannot answer itself with another one; and
a message too long to be an intro (over 2 KB of plaintext) sends a plain request and still
exits non-zero, because that message genuinely did not go anywhere.
Web app (EPIC-015 E15-T2): Contacts lists the same document with its states and
petnames; Messages → Requests is the handshake tray — the relay parks contact
requests there in every inbox mode except contacts_only, including when you already
list the sender as a contact. Accept/Block act there, and a message from someone you
hold no entry for carries a one-tap Add.
Key pinning (the safety-number model)
Accepting (or adding) a contact pins their current signing key in contacts.json. On
every poweur send, the resolved key is compared against the pin:
- Match → send.
- Mismatch, but the new identity document lists the pinned key in
previous_keys(a signed rotation statement, PCP-0002) → legitimate rotation: re-pin automatically, note to the user. - Mismatch with no rotation statement → refuse to send, print both safety
numbers and both keys, and require explicit
--accept-new-keyafter out-of-band verification. This is what a compromised relay or registrar swapping a contact's key looks like.
The web app runs the same three-way check before every send; the mismatch case is a
blocking dialog showing both safety numbers, and "Trust new key" is its
--accept-new-key.
Safety numbers (the fingerprint format)
A pin is only worth what the out-of-band comparison that bootstrapped it is worth, and nobody compares 43 characters of base64url. Every Poweur client therefore renders a key as a safety number: four groups of five digits.
safety number: 56963 45073 70021 85367
Derivation — canonical, identical in Go, @poweur/client and the web app, and pinned by
the fingerprints conformance vectors:
canonical = "poweur-fingerprint-v1" LF <normalized key string>
digest = SHA-256(canonical)
group i = uint40(digest[5i .. 5i+5]) mod 100000, zero-padded to 5 digits
fingerprint = groups 0..3, joined by single spaces
The normalized key string keeps its algorithm prefix (ed25519:…, x25519:…), so a
signing key and an encryption key with identical bytes never share a safety number, and
every base64 variant of one key converges on one value. 20 digits is ≈66 bits — a
second-preimage search an attacker must run against one specific victim's pin.
Why digits and not emoji. Emoji short-auth-strings are friendlier on a phone screen and denser per symbol, but they lose everywhere this protocol needs them to hold: Poweur's primary comparison surface is a terminal (emoji there range from correct to double-width-misaligned to tofu, and the CLI cannot tell which it got); the comparison is usually spoken, and digits are pronounceable identically in any language two contacts share while emoji names are not; digits can be typed back in, searched for in a log, and written on paper; and an emoji alphabet is a versioned dependency — adding or reordering one symbol silently changes everybody's fingerprint. Digits need no such table.
Where they appear: poweur contacts ls, poweur contacts add/accept (at pin time, when
verification is still cheap), poweur identity lookup, the send-time mismatch refusal,
the web contact panel, and the web key-mismatch dialog.
Pinning is client-side defense-in-depth: it fails open when contacts are unreachable (the resolver chain still applies), and it fails closed on an actual mismatch.
Privacy analysis
- Contact lists are sensitive. Readers: the owner's devices and the enforcing relay — never other users, never other relays. The E2EE design study (E03-T7) covers the longer-term option of hiding them from the relay too; today the relay must read them to enforce.
- Whether you blocked someone, and your policy mode, are not observable from rejection
responses (uniform
policy_rejected). - A sender learns only: delivered, rejected-by-policy, or request-queued/cooldown — the minimum needed for honest UX.
Deferred (tracked in EPIC-007)
sys.contact.*client auto-processing (the accept notification is delivered but clients handle it manually — both clients pin on accept, neither acts on an inboundsys.contact.acceptby itself).- PoW on contact requests from unknown relays: the
stranger_challenge/stranger_pow_bitsseam is specified (EPIC-014 E14-T3) and not yet wired.
Beyond the individual inbox — per-sender-relay request metering, sys.abuse.report,
and shareable signed blocklists — is
Relay reputation & abuse pressure.
Completing and clearing a request
One approval completes the handshake: B records A as accepted and sends
sys.contact.accept; when A's client receives that answer, it promotes B from
requested to accepted without another approval. Automatic promotion checks
the key pinned when A sent the request. Unknown or blocked identities are never
promoted, and a failed resolution or changed key leaves the request unpromoted.
The Go CLI processes replies on pickup; the TypeScript SDK processes them in
requests() and requestsAndArchive(). The web app also processes archived
answers on history restore, so an interrupted contact write can be retried.
The web app keeps answered-request positions in the existing encrypted
read-state.json conversation map, using contact-request:<lowercase identity>
keys and the request's timestamp/ID. These marks are separate from chat read
marks: reading chat cannot approve a request. Accept and Block persist the mark
before removing the pending row. History reloads and other devices use that
mark to suppress answered requests while allowing newer requests from the same
identity. Repeated clicks during an approval share the same operation.