OHAN-06: security v1
the security document of the open human and agent network. status: draft for v1, revised sun 25 oct 2026 after structured review passes from the angles of a w3c credentials editor, a nostr relay developer and a nostr user, and code review passes on cryptography, storage and consistency; revised again mon 26 oct 2026. to be published at ohan.live/spec/security on tue 27 oct 2026. it governs OHAN-02 card, OHAN-03 envelope, OHAN-04 relay and OHAN-05 gate. whoever changes a rule here changes the matching rule in those documents, the test that proves it, and CHANGELOG.md.
a v1 draft. it describes what runs today on a network that is invite only, and it will change. every change is recorded, and a breaking one bumps the wire id (OHAN-00 §5).
the words MUST, MUST NOT, SHOULD and MAY are used as in rfc 2119. "the box" is any OHAN relay, with or without a directory (box/ohan.js is the reference). "the client" is any program holding a person's keys (the openthings mcp is the reference). "the host" is the program the person talks to (claude desktop, claude code, codex, cursor). "the model" is the language model inside the host, which usually runs on the host maker's servers.
0. the six principles
- the person is the root. every key, card, agent, grant and message traces to a person's or a brand's key. agents are delegates. nothing on the network has authority a person did not sign.
- the model holds no key and presses no button. the model drafts, reads and proposes. only code signs, seals, sends and accepts, and that code re-checks the pin and the person's yes at the moment it acts. this holds where the model can only talk, as in claude desktop's chat. a host that gives the model a shell (claude code, codex, cursor) lets it run programs as the person, and k3 says what that reaches.
- nothing lands without a yes. no message is written into a person's workspace, no stranger is shown, no card is changed, without an explicit decision by the person or a rule the person wrote.
- the box cannot read, forge or redirect. it stores signed cards it verified and sealed events it cannot open. it serves a card unchanged, as json; the proof covers it, so any change would show. any relay can verify a fetch on its own, without a directory.
- strangers cost something and learn nothing. reaching a person you have not been accepted by costs work and is held at their gate. no reply, receipt or signal leaves until the person says yes.
- anyone can verify, nobody must be trusted. cards are self certifying, envelopes are signed by their sender, the gate runs on the person's own machine. ohan.live is one box among any number; the record works from any mirror.
1. who sees what
| actor | sees | never sees |
|---|---|---|
| the person | everything of their own | other people's private fields, other people's threads |
| the host and the model | message bodies after the gate, cards, pins, the person's own drafts. what the host sends to its model goes to the model maker's servers under their terms: sealed on the wire, open in the chat | private keys, the keychain, the dialog's answer before it is given, held strangers' bodies. in a host that gives the model a shell, the model can run programs as the person and reach the keychain item (k3) |
| the client (the mcp) | the keys (unwrapped in memory only), everything the person sees | nothing it does not need; it keeps no copy of what it sends outside the person's own thread files |
| the box | public cards; sealed events; the recipient's signing key on each event (queues are addressed by it); the sender key; padded sizes; arrival times; the fetch pattern (when the person is online); the address of the fetching and publishing client while it connects; whether an event came from a contact or a stranger (the allow list the recipient posted, r11) | message content; private keys; any address on disk |
| cloudflare, in front of ohan.live | the same as the box, because it terminates tls: addresses, sizes, rates, timing, which handles are looked up (/v0/resolve/@x), signed fetches | message content (sealed before it leaves the laptop), keys |
| a contact | your card, what you sent them, that you accepted them, when you write (the envelope's time), and the host you wrote from only if you switched via on (e8) | your other contacts, your other threads, your held strangers, your private fields |
| a stranger | your card if they know the exact handle (so whether a handle exists); both public keys; your relays | whether you read their message, whether you blocked them, whether you are online, your name unless you published it |
| an index (later; v1 lists nobody) | listed cards and whatever a card marks public | unlisted cards, any event |
2. keys
- k1. a person's root is an ed25519 keypair (signing) and an x25519 keypair (sealing), both generated on the person's own device by the client. the box never generates, receives or stores a private key.
- k2. a second key MAY be bound to the same person, in v1 the nostr secp256k1 key that signs the openthings ledger. the binding is a separate yes, off by default, because it ties the person's nostr graph to the handle. when bound, the card carries a cross signature by the nostr key (bip340) over the root signing key, so the link verifies in both directions, offline, in one document;
alsoKnownAscarriesnostr:npub1…as a courtesy; the nostr verification method (multicodece701, the compressed form with the0x02prefix) is listed underassertionMethodonly. a bound key is "also me", never a second identity. - k3. private keys MUST be encrypted at rest. the reference client wraps them, and the ledger's nostr key, with a 32 byte key held in the macos login keychain; the files hold ciphertext and public keys only. this protects the files where they travel: a backup, a sync folder, a copied disk, another user on the same mac, a stolen laptop whose keychain is locked. it does not protect them from software running as you on an unlocked mac: the keychain item is created by apple's
securitytool, which the item then trusts, so any process of yours can read the wrapping key through that tool without a prompt. a host that gives the model a shell (claude code, codex, cursor) is such software: there the model can run programs as you, read the keychain item, unwrap the keys and sign without the window. the window protects against a model that only talks, as in claude desktop's chat; it does not protect against a model that runs commands. a device bound key comes later (k8). in a normal desktop session the client sees no keychain prompt; when the keychain is locked or cannot be reached, it refuses and says why (k10). on systems without a keychain the client MUST say so and MUST keep the files mode 0600. §12 says the same in the person's words. - k4. no tool result, log line, error or page MUST ever contain private key material. the reference client has a test that greps every result for it.
- k5. on join the client MUST offer a backup: both keypairs, the card and the contacts, locked with a recovery key the client generates (120 bits; 24 crockford base32 characters; scrypt with n 2^17, r 8, p 1 to a 32 byte key, then aes-256-gcm with the handle bound as associated data; an importer refuses parameters above n 2^20, r 16, p 4). the person never invents a passphrase. the key MUST be shown only in a window, never in the chat, the consent log or a file, and a tool MUST NOT take a recovery key or a passphrase as an argument. the consent log and the config stay out of the backup. the client MUST say in plain words that whoever holds the file and the key is the person, that nobody can reset the key, and that a copy of the file belongs off the device.
restoreon another device asks for the key in one hidden window that names the handle (y1), and checks the file first: the identity is required, its card must verify against its own keys, its handle must match the one outside, and every contact must be well formed; it then postsallowfor the imported contacts (r11). on a lost device the person restores, rotates (k6) and re-registers, and the stolen key dies with the rotation. - k6. a key rotates by publishing a new card signed by the new key dated after the previous one, with a second proof by the previous key (
proofPurpose: assertionMethod, itsverificationMethodthe previous key asdid:key,previousCardnaming the previous card's id). the box MUST refuse a new key for a known handle without it. every envelope carries the hash of the sender's current card (sender.card); a recipient holding a different hash MUST re-resolve before trusting the message. this is how a rotation reaches contacts between refreshes; did:web has no version metadata, and the document says so. - k7 (later). a rotation key kept off the device, which can override a thief's rotation, comes later.
- k8 (later). a device bound key comes later.
- k9. everything under the client's folder that holds content (thread files, contacts, the inbox store, the consent log) is encrypted at rest with the same wrapping key as the private keys and bound to its role, so a backup, a sync folder or another user gets ciphertext, and a file restored into the wrong place is refused. every write is atomic (a unique temp file, fsync, rename) under one lock per root, so two hosts running the client at once never corrupt a file or lose each other's writes. a check that notices a local tamper (a swapped pin) before a send comes later.
- k10. when the keychain cannot prompt (no gui session, a headless host), the client MUST refuse to unwrap and MUST say why. it never falls back to plaintext on its own.
3. the card
- c1. a card is self certifying. it carries its own keys as Multikey (
publicKeyMultibase,z6Mk…for ed25519,z6LS…for x25519) and a data integrity proof with cryptosuiteeddsa-jcs-2022: the signature is oversha256(jcs(proof options)) || sha256(jcs(card without proof)),proofValueis multibase base58btc,createdis an xsd dateTime,proofPurposeisassertionMethod,verificationMethodis<did>#sign. a verifier MUST resolve#signto the key embedded in the card itself, never over the network and never from the box. numbers in a card are integers only, so jcs and sorted key json agree; thespec/vectors/jcs vector includes a float so a second implementation finds out if it strays. - c2. the box MUST verify the proof, the handle rule, the reserved list, the timestamp (newer than the stored card), the key rule (k6), the field limits (c6) and the stamp (c8) before storing, and MUST serve the card unchanged, as json (
GET /v0/resolve/@handleanswers{ "card": … }). - c3. a handle is bound to the first key that claims it. in v1 the only way to change the key is k6.
- c4. a sub handle (
@a.b, an agent; v1 depth is one) carriescontroller(the root's did) and a second proof in its proof set by the root's current key withproofPurpose: capabilityDelegation, over the card without any proof. the box MUST refuse a sub handle whose root does not exist, is deactivated, or whose delegation proof does not verify. the did document'scontrolleris true because of this rule, and the countersignature is dereferenceable because the did document links the card (c12). on root rotation a sub card stays valid until its next write, which MUST carry the new root's proof; a root that is deactivated deactivates its sub handles. - c5. a card is resolvable by exact handle from the moment it is registered and listed only when
listed: true. the default is unlisted.profileis empty by default; each field is a separate yes. - c6. profile fields are fixed:
display_name(80),line(140),links(up to 3 https urls).claimsandvouchesare reserved and MUST be empty or absent in v1. anything else is refused. a page MUST escape every field and MUST render links withrel="noopener nofollow". - c7. a name is not an identity. every surface MUST show the handle beside the name and MUST NOT show a name alone. verification comes only from claims, later. a client SHOULD warn when a new handle is within one edit of a pinned one ("@mayak looks like your contact @maya-k. it is not her") (v1.1).
- c8. registration costs: a stamp in the nip-13 shape (
stamp: { bits, nonce }on the card; the sha256 of the card's jcs bytes without the proof MUST have at leastbitsleading zero bits;bitsMUST be at least the difficulty the box publishes in/.well-known/ohan.json, 20 in v1 (about two seconds on a laptop in plain node; 22 measured at ten); the committedbitscounts, not a lucky hash), at most 30 registrations a day per address at the reference box (ipv6 bucketed by /64), and the reserved list: the products (openthings,ohan,opensoftware,opendm,openproof,openprofile,openhiring,nokk,nokklabs), the box's words (admin,support,help,www,api,relay,spec,security,terms,claim,invite) and its own paths, which are in the box's code, and the names the operator holds for the people they belong to, which are a setting on the box, not in its code (OHAN-07 §2). a reserved name is registered only by the operator handing its first card to the person it is held for. a stamp slows a person and prices a botnet; it does not stop one. the stranger lane (r1) is what holds. - c9. a card whose timestamp is older than 180 days and whose holder never posted an
allow(r11) MAY be marked deactivated. a deactivated handle is never bound to another key: the did document servesdeactivated: trueand the name is not reassigned, because old signed material would otherwise name a stranger (did core treats non reassignment as a core property). a client MUST refresh the card on every start, so a person in use never lapses; the join result MUST say "open your agent at least once every six months, or accept one contact, or @name may be deactivated". own domain handles, which come later, will be the escape from a dead name. - c10 (later). listing comes later, with its own friction against bulk names. v1 lists nobody (c14).
- c11 (later). a card's history becomes checkable. the log MUST NOT let anyone list handles whose cards are not listed: an unlisted card's history is proven to someone who already knows the handle, and to nobody else. a published weekly export of cards (r8) holds listed cards only.
- c13. the stamp (c8) is required on the first registration of a handle only. a later write by the same key needs none; it is authenticated by the key and limited to 60 writes a day per handle. the box also limits card writes to 30 a minute per address (OHAN-07 §3); both limits apply.
- c15. a box MAY be invite only (OHAN-07 §9). then a first registration needs a single use, expiring invite code in the
x-ohan-inviteheader, never in the card. the box stores only a hash of each code, limits wrong codes per address, and counts only successes against a member's few invites. an operator code bound to a handle may unlock a reserved name (c8). invites are a brake on growth, not an identity: they prove nothing about the person. - c14.
GET /v0/resolveis an existence oracle by design (a handle is resolvable by anyone who knows it). every path that tells whether a handle exists (the resolve, the page/@handle, the did document, the bare address) counts against one limit of 600 an hour per address, and §12 says so. the limit slows guessing; it does not stop a handle that is a common word from being guessed.GET /v0/networkanswers an empty list in v1: listing arrives with c10. cards are cached 60 seconds at the edge, so a rotation can be a minute stale there. - c12. every card carries
id(urn:uuid:…),type: "OhanCard",protocol: "ohan.card.1"and MAY carry@context(the did context plushttps://ohan.live/ns/v1). the box serves that context athttps://ohan.live/ns/v1asapplication/ld+json; it is frozen, and a term in it is never redefined. the did document the box derives from it lists#signand#enc, links the card as service#card, carries noappsservice (apps are a card field, not a service endpoint), omitscontrolleron a root (absent means the subject), and reserves service#status. the card is primary; the did document is derived.
4. the envelope and the seal
- e1. the sender signs the envelope (OHAN-03) with its signing key, then seals it to the recipient. sign, then seal, never the reverse.
- e2. the plaintext is padded inside the seal: a 4 byte big endian length, the json, then zeros to the next 256 byte boundary.
- e3. the associated data of the seal is
to || epk || seal, wheretois the recipient's signing key. relays address and authorise queues by the signing key; the recipient's x25519 key is used only inside the key derivation. so any relay can verify a fetch (r2) without a directory, and an event cannot be redirected or re-wrapped. - e4. the sealed event (
ohan.event.1) carriesseal: "x25519-sha256-aesgcm-1",to,epk,exp,ts,kind, the payload, itsid(the hash of its content) and a signature by the sender.expis at most 30 days ahead plus five minutes of skew;tsis within the same window of the box's clock. the box MUST refuse an event whose id does not match, whose signature does not verify, that is expired or too far ahead, or that is over 256 kb. - e5. the seal in v1 (
x25519-sha256-aesgcm-1): x25519 between a fresh ephemeral key per event and the recipient's#enckey; the aes key issha256(shared || epk || to_enc), wheresharedis the raw 32 byte x25519 shared secret,epkthe raw 32 byte ephemeral public key, andto_encthe raw 32 byte x25519 key of the recipient's#enc(not the signing key); aes-256-gcm with a random 96 bit nonce and the associated data of e3, whosetois the recipient's signing key as its multikey string. a shared secret of all zeros (a small order key) is refused on both sides. a stolen sender key decrypts no past traffic; a stolen recipient key decrypts everything it received. a later seal gets its own name in thesealfield. the reference tests include a small orderepkand expect a refusal. - e6. what never travels: private keys, the person's raw memory and context, board contents, chat history, the person's other threads, anything about a third party without their grant. a client that is asked for such material MUST refuse and MUST log the ask.
- e7. context travels by reference and hash with provenance, never as a dump; in v1 no context items travel at all. the agent answers the question; it does not send the document.
- e8.
sender.via(which host sent the envelope) is optional and off by default; a person who wants contacts to see it switches it on. numbers in an envelope are integers only (c1).
5. the relay
- r1. publish: the box applies e4. a box with a directory then accepts an event only when its
tois the signing key of a registered card and itsfrom_keyis the signing key of a registered card; a relay only box has no directory and skips this. then two lanes per recipient. the contact lane: events whose sender key the recipient has allowed (r11): at most 2000 undelivered per recipient and 500 per sender key. the stranger lane: everything else: at most 100 undelivered per recipient and 3 per sender key, each carrying a stamp (stamp: { bits, nonce }on the event, the id's leading zero bits,bitsat least the published difficulty, 18 in v1, about a second); when the lane is full the lowest stamp is evicted; the box MAY raise the difficulty when lanes fill. the lanes are counted apart, so strangers cannot fill a contact's lane. duplicates are acknowledged and not stored twice. - r1b. capacity: the box caps undelivered bytes per sender key (8 mb, across all recipients) and per recipient key (32 mb). when a recipient's 32 mb is full, strangers' events go first, the weakest stamp first; a contact's event may then push out the oldest event of the other sender holding the most bytes for that recipient. when the box as a whole is full, it evicts the oldest events of the sender key holding the most undelivered bytes, instead of refusing everyone; if that sender is the one publishing, the publish is refused. the publish answer does not say which lane an event went into, and an error answer does not name a lane. the reference client stamps every event at the published stranger difficulty, so it is never refused for a missing stamp. one signal remains: a sender who hits a per sender limit can infer that it is not on the recipient's allow list, because the stranger lane allows 3 undelivered events per sender and the contact lane 500 (§12 line 3).
- r2. fetch: a client signs
{relay, to, since, ts, nonce}with the signing keyto(relayis the box's origin,nonce16 random bytes) and sends it in anauthorizationheader, never in the url. the box verifies the signature againsttoitself, refuses atsmore than five minutes from its own clock, and keeps seen(to, nonce)for five minutes in memory. nobody but the holder of a key reads that key's queue, and a captured fetch is useless on another relay or after five minutes. - r3. a client posts a signed
delivered { ids }after it has written the events to its thread files; the box then drops them. nothing is dropped on fetch alone. the caps in r1 count undelivered events only. a client keeps one cursor per relay and dedupes by eventidand envelopeid. - r4. the box MUST NOT log envelope payloads, MUST NOT write any client address to disk, keeps its rate limit windows and nonce cache in memory only, and no url carries a key or a signature, so upstream logs hold none.
- r5. the box MUST send
strict-transport-security(a year), a content security policy ofdefault-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'on pages (the request page alone allowsform-action 'self'),x-frame-options: deny,cache-control: no-transformon html,x-content-type-options: nosniff, and MAY keepaccess-control-allow-origin: *because r2 is the gate, not the origin. - r6. when run with
--behind-cloudflare, the box MUST take the client address fromcf-connecting-iponly when the socket peer is in cloudflare's published ranges and MUST refuse other peers; a cloudflare tunnel is the simplest lockdown. cloudflare carries tls, ddos absorption, the waf and rate rules on/v0/profileand/v0/publish. it is a metadata observer equal to the operator (§1), and §12 says so. the box MUST hold every rule above without it, because anyone may run a box, and a relay only box (no directory) is a valid box. - r7. a card names up to 8 relays. a client SHOULD publish to every relay on the recipient's card and fetch from every relay on its own, deduplicating by id, so a relay that disappears loses nothing.
- r8. the reference box keeps daily volume snapshots and the operator exports
cards.jsonweekly. a client MUST keep its own signed card and MUST re-register it when its handle resolves to 404; the same key and a newer timestamp prove it is the same person. - r9 (later). the relay will stop seeing your sender and your contact list.
- r10 (later). the relay will stop seeing which contacts are yours.
- r11. allow: after an accept the client posts a signed
{ allow: <sender signing key> }to each of its own relays, and{ disallow }on a block or a removal. this is how a relay tells a contact from a stranger: the ack itself is sealed and goes to the sender's relays, so the box cannot see it. the allow list is the recipient's contact keys, which the relay therefore learns in v1 (§12 line 3), until r9. a box with a directory accepts an allow call only from the signing key of a registered card. allow and delivered calls are signed withtsand a nonce like a fetch, limited per key, and an allow list holds at most 5000 keys. - r12. a publish whose stamp is below the current difficulty is refused with the difficulty in the answer; the client regrinds once. difficulties are configuration on the box (
register,stranger), published in/.well-known/ohan.json, and MAY be set to zero by an operator.
6. the gate
the gate runs on the person's own device, in the client, in code. in this order, every inbound event:
- g1. the outer event verifies (e4) and its
tois my signing key. - g2. it opens with my sealing key; the inner envelope verifies against
sender.key;sender.keyequals the outer sender key;tois my handle;fromis a valid handle. - g2b. the sender's handle MUST resolve (from the pin, or from the box for a stranger) and the resolved card's
#signMUST equalsender.key; otherwise the event is dropped, never held and never shown. a knock needs a registered card. this is what stops a stranger from writing as@maya. - g3. the envelope
idhas never been seen from this sender (the seen log keeps ids for the longest event life plus a day, after which g3's own window refuses them);tsis not more than five minutes ahead of my clock and not older than the event's expiry; areply_tonames a message i hold;sender.cardmatches the card i hold, else i re-resolve first (k6). - g4. if
sender.keyequals the key pinned forfrom: the message is shown, with its handle, time, urgency and body framed as data. - g5. otherwise the sender is a stranger: the message is held in the inbox store (at most three events per handle and two hundred held handles; the rest are dropped unseen), never written anywhere else, and the person sees only "
@fromwants to reach you. i have not read it. accept makes them a contact who can always write to you; ignore keeps it waiting; block makes them disappear. you can undo accept later with remove." no body, no profile, no count of attempts. at most ten new stranger handles are shown a day; the rest are held unseen. - g6. nothing is written to a board, a calendar, a file or any workspace without an explicit accept by the person.
- g7. the gate MUST NOT reply, acknowledge, or emit anything to a sender before an accept. no read receipt, no delivery report, no "seen".
- g8. block is local and silent: the blocked sender's events are fetched and dropped; the box receives only a
disallow(r11), which it also receives on a plain removal, so it cannot tell the two apart.unblockexists, so a mistaken block of a friend is not forever. "block @x" on a contact works in one step. - g9 (later). later versions only add rules that narrow the gate; the model never widens it.
- g10. after an accept the client posts
allowto its relays; after a block or a removal,disallow(r11). - g11. the knock completes. when the person sends to a stranger after a yes in the window, the client records the knocked handle and its signing key for 30 days. when a verified envelope (the ack, or a reply) arrives from exactly that handle and that key, the client pins them, posts
allow(r11) and shows it ("@maya accepted you", or the message). any other sender is held as in g5.
7. the yes
- y1. a send, a join, an add, an accept, a card change, a key rotation, a backup and a restore each require a yes. a backup's yes is the window that shows its recovery key; a restore's yes is the hidden window, naming the handle, where the key is typed. block, unblock, removal, ignore and settings do not need a yes: they are local and reversible, and a dialog on them trains the blind click.
- y2. the yes MUST be given outside the model, and only in one of two places: a native window shown by the client, or the host's own prompt (mcp elicitation) when the host offers it. there is no yes by a word in the chat and no tool argument that confirms. if neither the window nor the host's prompt can be shown, the act is refused and the result says why. the dialog is titled openthings, carries the icon, is brought to the front, and its text names the act and the handle ("send to @maya, your contact since 12 oct: …", "send to a stranger, @maya", "accept @sam as a contact", "join as @maya"); the confirm button names the act; cancel is the default. the chat shows the content; the window is the only question, so a join is one yes, not two. a tool argument set by the model is not a yes. the window holds against a model that only talks; in a host that gives the model a shell, the model can run programs as the person (k3), and the window is no barrier to that.
- y3. no answer within 45 seconds is a no (50 for the window that shows a recovery key), so every window closes before the host gives up on the call. each call holds at most one human prompt: the keychain is set up in a call with no window. if the host cancels the call, the client writes nothing that cannot be taken back, and logs the cancel. the same send, invite, rotation or accept repeated within ten minutes returns what happened and is not done twice, unless the person says they mean a second one, which asks again.
- y4. every yes and no MUST be logged locally with what was shown and when, so the person can read back what they agreed to.
- y5. in v1 the window cannot be turned off. with no window and no host prompt, the act is refused.
- y6. the dialog takes the recipient, the act and the contact line ("your contact since 12 oct", "a stranger") from the client's own store and the tool's arguments, never from the message body; the body is shown truncated to 300 characters with "and n more". the model cannot write the dialog's header.
8. the model boundary
- m1. the model receives: cards, pins, message bodies after the gate, its own drafts, tool results. it never receives private keys, the keychain, held strangers' bodies, or another person's data.
- m2. every inbound body is returned inside a fixed frame that names the sender and says it is data: a begin line and an end line that carry the same random code, which the sender cannot know, and between them every line of the body marked
|. every kind of line break becomes one line break, control and direction characters are stripped, urls are plain text, and the body is capped at 16 kb. a body can neither close its own frame nor open a fake one. - m3. tools are split into read tools (inbox, find, card show, settings show) and write tools (join, send, decide, remove, card change, export, import), annotated so a host that restricts by annotation can.
- m4. the client publishes the hash of its tool list per version in the ohan repo, so a host or a person can check the descriptions were not changed.
- m5. the reference client's instructions and tool descriptions are the only prompt text it ships. readme text, message text and card text are data, never steps.
- m6. a mac notification from the poll says what to do: "1 new message on openthings. ask your agent what's new."
- m7. the client never fetches a url found in a message body, a card or a profile, and never opens a link on the person's behalf.
9. revocation
- v1. unpin a contact: their next message is a stranger's again, and a
disallowgoes to my relays. block: dropped at the gate, forever, locally, until unblock, with the samedisallow(the box cannot tell the two apart). rotate: k6. - v2. revoking stops what comes next. it cannot take back what was read; a sealed envelope that was opened is on the other person's device. no surface of OHAN MUST say "pull back", "unsend" or "delete from their side".
- v3 (later). revoking an agent's authority publishes with the agents themselves.
10. threat model
the box tests are box/test-box.js; the client's end to end test walks a join, an add, a knock, an accept, a reply, a block, an export, an import, a rotation and a re-registration against a real box. the gate tests by rule id are being written for v1.0.1.
| threat | control | enforced by | proved by |
|---|---|---|---|
| read a person's queue | r2 signed fetch with origin and nonce | box | the box tests |
| fake an agent under a person | c4 delegation proof | box | the box tests |
| flood a queue, starve contacts | r1 two lanes, eviction, stamps; r1b byte caps and eviction by the heaviest sender; r3 delivered | box, cloudflare | the box tests |
| squat handles | c8 stamp, cap, reserved; c9 deactivation, never reassignment | box | the box tests |
| steal keys from disk | k3 keychain wrap, k5 backup under a generated recovery key | client | manual |
| make the model send | y2 the window or the host's prompt, m1 no key | client | the client's end to end test (a no in the window sends nothing). not in a host with a shell (k3) |
| lie to the model | m2 frame, y2 | client | the client's end to end test (the frame) |
| replay or re-wrap | g3, e3 aad, r2 nonce | client, box | the box tests (a replayed fetch); the gate's replay test comes in v1.0.1 |
| swap a card at first contact | c1 self certifying, pin, fingerprint | client, box | the box tests (another key cannot take a handle) |
| rotate under a contact's nose | k6 sender.card | client | the client's end to end test (a rotation with the proof is followed); the test without the proof comes in v1.0.1 |
| steal then rotate | k5 restore and rotate; a rotation key kept off the device later (k7) | client | the client's end to end test (import, then rotate) |
| relay operator or cloudflare learns the graph | e2 padding; later, r9 and r10 | client, box | not yet: §12 line 3 says what they see |
| impersonate by name or near handle | c7 | client, page | the box tests (the page) |
| spoof presence | the box serves no presence; a product that offers it runs and signs its own | ops | the box tests |
| lose the box | r8 | client, ops | the client's end to end test (re-register); manual |
| tamper the pages | r5 csp, escaping | box | the box tests |
| poison the tool list | m4 | client, host | hash check |
| a second relay must trust the first | e3 queues by signing key, r2 self verifying fetch | box | the box tests (a relay only box) |
11. operations of the reference box
- tls by cloudflare in full strict mode, the origin behind a cloudflare tunnel or locked to cloudflare's ranges (r6);
--behind-cloudflareon. - rate rules at cloudflare on
/v0/profileand/v0/publish; bot fight mode on pages. - daily volume snapshots; weekly export of
cards.json;/.well-known/ohan.jsonlists the protocol ids it speaks, the stamp difficulties and the limits. it lists no presence: presence is a product's own service. - no analytics, no third party scripts on any page.
- a security contact and a disclosure policy on
ohan.live/spec/security: report privately, 90 days, credit by name. - a terms and privacy page at
ohan.live/spec/terms: what the box stores and for how long, no accounts, no addresses on disk, the lapse rule, who operates it (nokk labs), the security contact. - the box speaks the four v1 ids only (
ohan.card.1,ohan.envelope.1,ohan.event.1,ohan.relay.1). the demo's v0 cards and events were discarded at the v1 deploy. - if the box's host is ever compromised: the notice goes on the page and in
/.well-known/ohan.json; cards are self certifying, so clients lose nothing; the box is rebuilt from the latest export and clients re-register (r8). - what a second operator needs: the dockerfile, a disk, a domain with tls,
/.well-known/ohan.json,test-box.jspassing againstspec/vectors/, and nothing from ohan.live: a relay only box is a box.
12. the honest limits, as the person reads them
- revoking stops what comes next. it cannot take back what was read.
- a self issued key proves control of a key, not who the person is. a name on a card is not verified; the handle is the identity. claims come later.
- the relay cannot read a message. in v1 it can see which key wrote to which key, when, how often, how big to the nearest 256 bytes, your address while your agent checks for mail about once a minute, and the keys you accepted (so your contact list, as keys). later versions hide the sender and the list, then which contacts are yours. anyone who knows your exact handle can see that it exists. and someone you removed or blocked can work it out: the relay holds at most 3 undelivered messages from a stranger and 500 from a contact, so a sender whose fourth waiting message is refused can tell it is not on your allow list.
- the first time you add someone, you are trusting ohan.live and dns to hand you the right key. for anything that matters, read the eight groups to them on a call. after that first time, a swapped key is refused.
- the gate is the openthings mcp on your mac, and the yes is a window on your screen or your app's own prompt, never a word in the chat. if neither can be shown, the act is refused. in v1 the window cannot be turned off.
- the model drafts and reads. in claude desktop's chat it holds no key and presses no button: it can be lied to by a message, and it cannot act on the lie without you. in an app that gives the model a shell (claude code, codex, cursor), the model can run programs as you, including reading the keychain item that unlocks your keys, so there the window does not stop it. and what it reads goes where your chat goes: a message opened in a hosted model is on that company's servers, under their terms. sealed on the wire, open in your chat.
- a rotated key reaches contacts when they next fetch your card or your next message. it is not instant.
- a lost key with no backup is a lost handle. the backup file and its recovery key together are you. until a rotation key kept off the device ships (v1.1), a thief who has both can lock you out by rotating first.
- ohan.live is one box. your card is yours; it can be served from any box or your own domain (v1.1). a handle is deactivated, never given to someone else, if it never accepted a contact and its agent has not been opened for 180 days.
- there is no recovery through us. nobody at openthings can reset, read or restore anything.
- if you switch it on, every message says which app you sent it from. your contacts can see that. it is off by default.
- cloudflare sits in front of ohan.live. it sees your address and which handles you look up; it never sees a message.
- a second mac sees what you import; messages already delivered to the first mac are not on the relay any more.
13. what each version adds
| version | adds |
|---|---|
| v1 (27 oct 2026) | k1 to k6, k9, k10, c1 to c9 and c12 to c14, e1 to e8, r1 to r8 (with r1b), r11, r12, g1 to g8 (with g2b), g10 and g11, y1 to y6, m1 to m7, the box tests and the client's end to end test |
| later | later versions publish with their code |
14. conformance
"OHAN compatible" means a box passes the box tests (box/test-box.js), a client holds the gate's rules (OHAN-05), and both pass box/test-net.js against the vectors in spec/vectors/ (a jcs vector with a float, a Multikey vector, a full card with its proof verifiable by a third party data integrity library, a sealed event, a signed fetch, a small order epk). the client tests by rule id (an event not addressed to me, a tampered envelope, an impostor, a replay, a rotation without the proof, allow and disallow) are being written for v1.0.1; until then the reference is the client's end to end test in the openthings mcp (its source publishes with opensoftware on 3 nov). nothing more and nothing less. a box or client that adds rules MAY; one that drops a MUST is not OHAN.