Sealed notes
Anyone can send you a note that only your key opens. ONE carries it and cannot read it. The browser, CLI, age tool and SDK paths, the HTTP doors, the formats, and what it does not defend.
Reading this as an agent? The same page in raw markdown: /docs/sealed-notes.md
claude "Read https://one.ie/docs/sealed-notes.md. Explain Sealed notes back to me in five lines, then name the one thing on that page I am most likely to get wrong."
ONE is the courier. It carries envelopes it cannot open.
Your 24 words give you a lock (age1…). Hand the lock to anyone. They seal a
note to it on their own machine, and ONE stores the sealed block. Only your key
opens it: in your browser, in the one CLI, or in the standard age tool. Writing
to you needs no account. Reading needs your key.
24 words ─► master (32 bytes)
├─ wallet keys ............ money
└─ message-x25519-v1 ...... notes
│
┌────────┴──────────┐
age1… AGE-SECRET-KEY-1…
the lock your key
give it out never leaves your machine
writer's machine note + your lock → seal (age)
│
▼ sealed block
ONE stores the bytes, cannot read them
│
▼
your machine your key opens it
The format is age, not a format of ONE’s. A note sealed by any age tool opens in ONE, and a note sealed in ONE opens in any age tool. The honest category is encrypted email, or a drop box: being reachable without trusting the courier. It is not a chat app. See What it does not defend.
Quickstart
Four ways in. They all produce the same armored block and land it in the same place.
In the browser
- Get your lock. Open /keys with your key open. The card at the top reads Your lock, with Copy and Share link.
- Publish it once. Press Publish my lock. Your lock is signed by your Sui
key and filed under your name, so anyone can look it up and check it
(
GET /api/lock/<your-name>). - Hand out the link. Share link copies
https://one.ie/write#to=<your age1…>&from=<your name>&ws=<your workspace>. The part after#never reaches a server. - They write. The writer opens the link, types the note (and, if they like, a name that arrives marked “not verified”), presses Seal it, then Send to …. The note is sealed in their browser before anything is sent. They need no key and no account.
- You read. The note lands in your workspace’s Sealed notes thread. Open it
in /messages and press Open with my key. A dedicated
/sealed/inboxpage is landing in this cycle.
A note from someone who has a ONE key and has published their own lock is signed inside the envelope, and opens with the line Signed by tony · checked.
CLI
The one CLI ships the whole loop in oneie@5.7.3 (npx oneie …; the bins are
one and oneie). Every key command is offline except publish-lock, lock-of
and write. None prompts. Each takes --json.
The key is a key file (one key new --out me.json --yes-i-accept-plaintext-file) or
the ONE_WALLET_MNEMONIC environment variable. The key never comes from stdin; the
note does.
| Command | Does |
|---|---|
one key lock --key me.json | Prints your age1… lock |
one key seal [file] --to <age1…> [--to …] [--self] [--key me.json] | Seals a note (file or stdin) for one or more locks; --self adds your own so you can re-read it |
one key open [file] --key me.json | Opens a sealed block (file or stdin) and prints the note |
one key identity --key me.json | Prints the AGE-SECRET-KEY-1… line for the age tool. A secret: it warns on stderr |
one key publish-lock --key me.json [--slug <name>] [--derivation v1|v2] | Signs your lock with your Sui key and publishes it under your name. Needs one login |
one key publish-lock --key agent.json --agent <id> [--workspace <ws>] | Publishes an agent’s lock as <workspace>/<id>. Run with that agent’s own API key |
one key lock-of <name> · one key lock-of <ws>/<agent> | Looks up a published lock and checks the signature here before printing it |
one write <name> [file] [--to <age1…>] [--api <url>] | Seals on this machine and posts the block to /api/write. Without --to, it seals to <name>’s published lock, checked first |
one write <ws>/<agent> [file] | Seals to an agent’s checked lock; the note lands in the workspace’s Sealed notes |
A real run, with a throwaway key, on 2026-11-08:
$ one key lock --key me.json
age1hycjlyrfy9uf529cgwf9f5zd7tagdwj0a4lw335sllqjndc9le5qfjx7ac
$ echo "meet at the north gate at six" | one key seal --to age1hycjlyrfy9uf529cgwf9f5zd7tagdwj0a4lw335sllqjndc9le5qfjx7ac > note.age
$ cat note.age
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyejFYUk9TYkRvcEpNank3
TEtQSkZkUmZtaThCdU9LSGJNS3ZLQU1HUW40CnNnZU1CMjNqMU1DTHJRK3VPR3NC
SFhYNGhkdGlHYVNldEhSb0pmamx3MjgKLS0tIG9jSmJRQ1JqcHE0VkxaRjJoY2s2
eEJBYnM5M3dPRlozUURzK0Y0S2E2WncKO3/gI+iyb8kH3hsrZgBQIMTpX6eNeGal
6sRXbRxPqaFJypthKO/NLwJdVR2ZF3vR51KJ84jB6ibg3h6bdKo=
-----END AGE ENCRYPTED FILE-----
$ one key open --key me.json note.age
meet at the north gate at six
$ one key lock-of tony
age1ehrauxe37vjx730qy5nxnzlnfqammqgxp33uny6n8ftn6gnxf32swq8x62
checked signed by Sui address 0xef6d838e4617c18ed4b10e7ebc7862045c81253e116182171f1d788efbe98d45; the signature is checked here; that this Sui address belongs to the name is ONE's word, from the account's registered wallet
one write tony would seal to that checked lock and post it. A note sealed for
another key is refused by name:
$ one key open --key me.json someone-elses.age --json
{"ok":false,"error":"this note is not for your key — it was sealed to a different lock","code":"no_identity"}
Exit codes: 0 done, 1 refused. publish-lock, lock-of and write-by-name use
finer codes: 2 key or name input, 3 not signed in, 4 wrong signer or no Sui
address, 5 bad signature or name, 6 unreachable, 7 not published, 8 a
published lock that does not verify (never used).
The age tool
Any age build works. No ONE code needed on the
machine. Measured with age v1.3.1:
$ echo "a second note, from the age tool" | age -a -r age1hycjlyrfy9uf529cgwf9f5zd7tagdwj0a4lw335sllqjndc9le5qfjx7ac > note2.age
$ one key open --key me.json note2.age
a second note, from the age tool
$ one key identity --key me.json > me.key
warning this line opens every note sealed to your lock — keep it out of logs, chat and shell history
$ age -d -i me.key note.age
meet at the north gate at six
me.key holds one line, AGE-SECRET-KEY-1…. On the web, Take my key on
/keys gives the same line. To send with age and deliver through ONE, post
the block to POST /api/write.
SDK and agents
@oneie/sdk@0.17.5 exports three subpaths for this: @oneie/sdk/age (the format),
@oneie/sdk/message-key (your lock from your words) and @oneie/sdk/message-lock
(looking up and checking a published lock). Import the subpaths, never the bare
barrel. Run on 2026-11-08 with Bun against production:
import { sealText, openText } from '@oneie/sdk/age'
import { messageKeyFromPhrase } from '@oneie/sdk/message-key'
import { verifyLockRecord } from '@oneie/sdk/message-lock'
// 1. This agent's own lock, from its 24 words.
const me = messageKeyFromPhrase(process.env.ONE_WALLET_MNEMONIC!)
// 2. Look up a person's published lock and check it here, not on trust.
const rec = await (await fetch('https://one.ie/api/lock/tony')).json()
const v = verifyLockRecord('tony', rec)
if (!v.ok) throw new Error(v.detail)
// 3. Seal for tony and for itself, so it can re-read what it sent.
const sealed = sealText('the staging key rotates on Friday', [v.lock, me.recipient])
// 4. Deliver. The plaintext never left this process.
const res = await fetch('https://one.ie/api/write', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ws: 'tony', sealed, name: 'Deploy bot' }),
})
console.log(await res.json()) // { ok: true, threadId, messageId }
// Opening is the same call in reverse.
console.log(openText(sealed, [me.secret])) // the staging key rotates on Friday
me.secret.fill(0)
Steps 1 to 3 and the open printed exactly that on 2026-11-08 (Tony’s lock checked, 519-character block). Step 4 was not sent, so no test note landed in a real inbox. The door’s refusals were probed instead; see below.
Other exports: deriveMessageKey(master) from 32 bytes, messageIdentityText(key)
for the AGE-SECRET-KEY-1… line, encrypt/decrypt/armor/dearmor on bytes,
lockMessage, agentLockMessage, parseLockName, verifySuiPersonalMessage.
HTTP reference
POST /api/write
Drops one sealed block into a workspace’s Sealed notes thread. No auth.
| Field | Type | Rule |
|---|---|---|
ws | string | Workspace name, ^[a-z0-9][a-z0-9-]{0,62}$ (trimmed, lower-cased) |
sealed | string | One armored age block, at most 8,000 bytes |
name | string, optional | The writer’s own name. Shown as Sam (not verified); bidi and invisible characters stripped, 60 characters kept. Absent: Someone with your lock |
from is ignored. It used to name the recipient, so every stranger’s note looked
like it came from the inbox’s owner.
Success: 200 {"ok":true,"threadId":"…","messageId":"…"}.
The checks run in this order, and each answers before anything is written:
| Status | Body | When |
|---|---|---|
| 503 | {"ok":false,"error":"no_db"} | The store is not bound |
| 429 | {"error":"rate_limited","retryAfter":n} + Retry-After | More than 10 requests a minute from one address (CF-Connecting-IP), per isolate |
| 413 | {"ok":false,"error":"too_large"} | Body over 16 KiB, or sealed over 8,000 bytes |
| 400 | {"ok":false,"error":"bad_json"} | Body is not JSON |
| 400 | {"ok":false,"error":"bad_workspace"} | ws is not a workspace name |
| 400 | {"ok":false,"error":"not_sealed"} | sealed is not one armored age block with an X25519 reader, a MAC and a payload |
| 404 | {"ok":false,"error":"unknown_workspace"} | No such workspace |
| 429 | {"error":"rate_limited","retryAfter":n} | More than 30 notes a minute to one workspace, per isolate |
| 429 | {"ok":false,"error":"inbox_busy","retryAfter":60} + Retry-After: 60 | 60 notes reached this workspace in the last 600 seconds, counted across all isolates |
| 503 | {"ok":false,"error":"no_door"} | The append receiver is not available |
| 502 | {"ok":false,"error":"append_failed"} (or the receiver’s error) | The append failed |
Probed on production, 2026-11-08:
$ curl -s -X POST https://one.ie/api/write -H 'content-type: application/json' -d 'nope'
{"ok":false,"error":"bad_json"} # 400
$ curl -s -X POST https://one.ie/api/write -H 'content-type: application/json' -d '{"ws":"Bad Slug!","sealed":"x"}'
{"ok":false,"error":"bad_workspace"} # 400
$ curl -s -X POST https://one.ie/api/write -H 'content-type: application/json' -d '{"ws":"zz-none","sealed":"hello"}'
{"ok":false,"error":"not_sealed"} # 400
$ curl -s -X POST https://one.ie/api/write -H 'content-type: application/json' --data @real-block-to-zz-none-lanec.json
{"ok":false,"error":"unknown_workspace"} # 404
No CORS. /api/write sends no access-control-allow-origin, and a preflight
OPTIONS answers 404. A script on another site cannot post from a visitor’s browser.
Link to /write, or post from a server, a script or the CLI.
The note lands in the thread named Sealed notes (group sealed, thread key
channel:sealed), tagged sealed, through the thread:append receiver. A sealed
post wakes no agent: the router skips it.
GET /api/lock/<name>
The public lookup for a person’s published lock. Also
GET /api/lock/<workspace>/<agent-id> for an agent’s own lock. Both are the
wallet:lock-get receiver behind a plain GET.
- CORS:
access-control-allow-origin: * - Cache:
public, max-age=30 - Rate: 60 a minute per address (
rate_limited429) - Names: person
^[a-z0-9][a-z0-9-]{0,62}$; agent id^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$
Published (production, 2026-11-08):
{
"ok": true,
"published": true,
"lock": "age1ehrauxe37vjx730qy5nxnzlnfqammqgxp33uny6n8ftn6gnxf32swq8x62",
"suiAddress": "0xef6d838e4617c18ed4b10e7ebc7862045c81253e116182171f1d788efbe98d45",
"signature": "AAVXgYecKNJk5Fdc…",
"message": "ONE message lock v1\ntony\nage1ehrauxe37vjx730qy5nxnzlnfqammqgxp33uny6n8ftn6gnxf32swq8x62",
"publishedAt": "2026-10-17T20:29:00.000Z",
"withheld": "practice_net_only",
"closed": "read"
}
Not published: {"ok":true,"published":false,"closed":"read"}. Errors:
400 bad_slug, 400 bad_agent, 503 no_db | no_door | lookup_failed | db_error.
| Field | Meaning |
|---|---|
lock | The age1… to seal to |
suiAddress | The Sui address that signed it. It must still be the account’s registered address, or the answer is published:false |
signature | Sui personal-message signature over message |
message | Exactly ONE message lock v1\n<name>\n<lock>. For an agent, <name> is <workspace>/<agent-id> |
publishedAt | ISO time of the last publish |
withheld, brake, card* | Pay-card fields for private payments. Not part of sealed notes; ignore them here |
closed | The loop close (read) |
Never trust the answer; check it. Rebuild message yourself from the name and
the lock, verify the signature, and use the suiAddress it recovers.
verifyLockRecord(name, record, agent?) in @oneie/sdk/message-lock does exactly
that; one key lock-of and /write run the same check. What no client can check is
that the Sui address belongs to the name. That link is ONE’s word, from the
account’s registered wallet.
Publishing is the wallet:lock-publish receiver ({lock, signature} for a person,
{lock, signature, agent, workspace?} for an agent), signed in. The server rebuilds
the message from the session’s name; refusals are bad_lock, bad_signature,
wrong_signer, no_sui_address, not_the_agent.
Formats
The sealed block
age v1 (age-encryption.org/v1), X25519 readers only, ASCII-armored:
-----BEGIN AGE ENCRYPTED FILE-----
<base64, 64 columns>
-----END AGE ENCRYPTED FILE-----
Passphrase and SSH readers are refused as unsupported. Open errors are
no_identity (sealed for another key), tampered (changed after sealing),
unsupported, malformed (not a whole note).
The lock and the key
| Format | Derivation | |
|---|---|---|
| Lock | age1 + 58 bech32 characters | X25519 public key |
| Key | AGE-SECRET-KEY-1… | HKDF-SHA256(master, salt = "", info = "message-x25519-v1"), 32 bytes |
The master is the 32-byte entropy of the 24 words. The label message-x25519-v1 is
locked: changing it would make every note already sent unreadable.
The signed note
A writer with a ONE key and a published lock signs before sealing. The signature rides inside the sealed block, so ONE never sees who signed. The plaintext is:
ONE signed note v1
slug: tony
address: 0x<64 hex>
signature: <base64 Sui personal-message signature>
to: age1… one line per reader, sorted, no repeats
to: age1…
<the note, exactly>
The bytes signed are rebuilt by the reader, never read from the envelope:
ONE signed note v1
<slug>
<reader lock> sorted, one per line
<sha256 hex of the note's UTF-8 bytes>
The reader locks bind the note to who it was written to. A note re-sealed to someone
else reads “written to a different lock than yours”. Every reader of one note sees
the other readers’ locks. A note that does not start with ONE signed note v1 is an
ordinary unsigned note.
What a reader sees:
| Verdict | Line |
|---|---|
| checked | Signed by tony · checked |
| mismatch | Signed, but not by the account it names |
| unpublished | Signed, but tony has not published a lock, so the name cannot be checked |
| unverifiable | Signed, but tony could not be looked up just now, so it is not checked |
| other lock | Signed, but written to a different lock than yours. It may have been passed on |
| forged | Says it is signed, but the signature does not hold |
| unsigned | Not signed |
Signing ships in the browser only (/write, the /messages composers). The CLI
seals and opens; it does not sign, and one key open prints a signed note’s
envelope as plain text. To check one offline, see
the how-to.
Size caps
| Cap | Value | Where |
|---|---|---|
| One sealed block | 8,000 bytes | SEALED_MAX_BYTES, enforced by /api/write |
| Request body | 16 KiB | /api/write |
| What a person may type | 5,000 bytes (not characters; Thai is 3 bytes a letter, emoji 4) | NOTE_MAX_BYTES, the browser composers |
| Readers per note | 128 | MAX_STANZAS in the age module; more is refused before any work |
The 8,000-byte block fills before 128 readers do. The composers’ budget
(maxNoteBytes, deliberately conservative) gives:
| Readers | Unsigned note | Signed note |
|---|---|---|
| 1–2 | 5,000 bytes | 5,000 bytes |
| 5 | 5,000 | 4,603 |
| 10 | 4,754 | 3,763 |
| 20 | 3,754 | 2,083 |
| 40 | 1,754 | 0 |
Text only. No files, no pictures.
What it does not defend
| Defends | Accepts |
|---|---|
| A breach of ONE’s database, logs or backups: they hold sealed blocks only | No forward secrecy. Whoever gets your 24 words opens every note ever sent to your lock. That is the price of recovery from paper |
A changed byte: refused as tampered | Metadata is visible. ONE sees which inbox, when and how big. Checking a signed note asks /api/lock/<name> for the name it claims |
| The router and agents reading content: a sealed post wakes no agent | The page code is ONE’s. The browser seals with JavaScript ONE serves. The escape hatch is the CLI or the age tool, which skip the page |
Plain text posing as a note: /api/write refuses anything that is not an age block | Structure, not secrecy. The door can prove a block is shaped like age, never that its payload is ciphertext |
| A forged signature, or a note passed on: the reader rebuilds and checks | The name ↔ address link is ONE’s word. The signature is checked on your machine; that the Sui address belongs to the name comes from ONE’s registered-wallet record |
| A flood: 10 a minute per address, 60 per inbox per 10 minutes | A flood makes an inbox busy for honest writers too. And the writer is anonymous unless they sign |
The gaps, plainly
These are not built:
- Forward secrecy. A choice, not an oversight: your paper opens every old note.
- Files and pictures. Text up to 5,000 bytes.
- A checkable page. No published bundle hash proves the
/writepage is the code you reviewed. Use the CLI oragewhen that matters. - PGP. No OpenPGP certificate at
/.well-known/openpgpkeyfrom the same key. - Outside review. The age code was reviewed internally against the C2SP test
vectors and the real
agebinary in both directions. Nobody outside ONE has reviewed it yet. - Signing outside the browser. The CLI and SDK do not build signed notes.
- An agent’s own inbox. A note to
<ws>/<agent>lands in the workspace’s Sealed notes, and no CLI command reads that thread yet. A self-registered agent (its workspace is its own id) has no owner row, so/api/writeanswersunknown_workspacefor it.
Task recipes: Sealed notes: how-to.