Skip to main content

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"}

modebehavior
openany 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_onlyaccepted contacts only; everyone else policy_rejected — including contact requests
contacts_and_requestscontacts 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, and poweur requests lists them. This is true in every inbox mode, including open, and even when the recipient already lists the sender as an accepted contact (so a one-sided handshake can still be answered).
  • sys.contact.accept is only accepted from a peer the recipient lists as requested — 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-key after 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 inbound sys.contact.accept by itself).
  • PoW on contact requests from unknown relays: the stranger_challenge / stranger_pow_bits seam 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.