DEV Community

Cover image for Your passkey can be an encryption address. The domain is the key.
Edy Cu
Edy Cu

Posted on

Your passkey can be an encryption address. The domain is the key.

There is still no simple way to encrypt something to a person. You can encrypt to a server, or to someone you have already swapped keys with, but not to "Maya, whoever and wherever she is."

I built Letterlock to try: the passkey you already have becomes an encryption address. Your device derives an X25519 key from the passkey, publishes only the public half to a directory contract on Monad, and from then on any app or agent can look your key up with one read and seal data only your passkey opens. It is live on Monad mainnet (2:32 demo with a real passkey, try it in two minutes, no wallet).

This post is about the one design decision that matters more than the cryptography: the domain your passkey is bound to is, in effect, the key. I got it wrong in the first release and had to move it.

From a passkey to a key pair

WebAuthn's PRF extension lets a passkey act as a keyed hash: give it a 32-byte salt, it returns a 32-byte secret that only that credential can produce. Run that through HKDF and you have an X25519 private key. This is the derivation, straight from the SDK:

export const prfSaltFor = (epoch: number): Uint8Array<ArrayBuffer> => {
  checkEpoch(epoch);
  return new Uint8Array(sha256(utf8(`letterlock/hpke/v1/${epoch}`)));
};

const keyPairFrom = (prfOutput: Uint8Array, epoch: number, info: string): EncryptionKeyPair => {
  if (prfOutput.length !== 32)
    throw new LetterlockError("PRF_UNSUPPORTED", `PRF output must be 32 bytes, got ${prfOutput.length}`);
  const secretKey = hkdf(sha256, prfOutput, utf8("letterlock/v1"), utf8(info), 32);
  return { epoch, secretKey, publicKey: x25519.getPublicKey(secretKey) };
};
Enter fullscreen mode Exit fullscreen mode

The salt is Letterlock's own namespace, separate from the salt mera uses to derive the same passkey's EVM account, so one passkey yields an encryption key and the account that publishes it, and neither reveals the other. Rotation is epoch + 1: a new salt, an unrelated key, and every old epoch can still be re-derived to open old letters. Nothing secret is ever stored; the key is re-derived on every open.

Sealing needs none of this. A sender resolves your public key and uses HPKE (RFC 9180):

import { letterlock } from "letterlock";

const ll = letterlock({ chain: "monad" });
const key = await ll.resolve("agent:10260");                 // one keyOf / keyOfAgent read
const envelope = await ll.sealTo("agent:10260", new TextEncoder().encode("the dentist moved to Thursday 10:40"));
Enter fullscreen mode Exit fullscreen mode

Resolve plus seal against Monad mainnet measured p50 28.199 ms, p95 36.122 ms over N = 1,000 (pnpm bench, results in the repo). Publishing a first key cost 70,863 gas.

The wall: a PRF output belongs to its rpId

Here is the part that is easy to miss. A passkey is created for a relying party ID, the rpId, which is a domain. The browser only lets a page use its own domain (or a parent of it) as the rpId, and the PRF output is bound to that rpId. Three consequences follow, and each one shaped the design:

  1. Same passkey, different domain, different key. Derive under localhost or a preview deploy and you get a key that production can never re-derive. Anything sealed to it is unopenable forever.
  2. Whoever serves the rpId can derive every key. If I control the domain, I can serve a page that runs the same passkey ceremony and gets the same PRF output. The rpId's host is the most trusted party in the whole system.
  3. Opening has to happen on that domain. Other apps can seal to you from anywhere, but the open (the passkey ceremony) can only run where the rpId lives.

Because of (1), the SDK pins one production rpId and refuses to publish a key derived anywhere else:

const pinned = (action: string) => {
  if (rpId !== LETTERLOCK_RP_ID && config.unsafeAllowAnyRpId !== true)
    throw new LetterlockError("INPUT_INVALID",
      `${action}: rpId "${rpId}" is not the production rpId "${LETTERLOCK_RP_ID}". A key derived under another rpId can never be re-derived in production, so every note sealed to it would be unopenable`);
};
Enter fullscreen mode Exit fullscreen mode

A key also has to say where it was derived: one rebuilt from its public fields is refused, because it could have come from any passkey.

Where I got it wrong

Version 0.1.0 pinned letterlock-app.vercel.app. It worked, every test passed, and it was a mistake because of consequence (2): a *.vercel.app name is a Vercel project name, held only while that project exists. Delete the project, someone else claims the name, and they can serve a page there that derives every Letterlock key ever made.

Version 0.1.1 moved the pin to app.letterlock.edycu.dev, a subdomain of a domain I registered:

export const LETTERLOCK_RP_ID = "app.letterlock.edycu.dev";
Enter fullscreen mode Exit fullscreen mode

The old host now answers every request for the app with a 308 to the new one, so no new key is made under the old rpId. Moving was cheap only because it happened early: the only keys derived under the old rpId were the app's own live-check test keys, made with a virtual authenticator and deleted after each run. Had anyone real been using it, their old letters would have become unopenable at the new address. That is the general rule: pick the rpId as if it were a root key, before the first user, on a domain you will keep renewing.

What it cannot do (from the README's limits)

  • Opening happens on the Letterlock app. It is consequence (3): other apps seal to you from anywhere, but the passkey ceremony that opens a letter runs on the pinned rpId.
  • Whoever serves app.letterlock.edycu.dev can derive every key. It is on my own domain now, which has to stay registered and pointed at the app for as long as keys derived under it are in use.
  • Anyone can seal to anyone. HPKE base mode is anonymous; no letter names a sender, and the app never shows one.
  • No recovery. If every synced copy of the passkey is lost, so is the key.
  • Cross-device depends on the platform. By the WebAuthn spec a synced passkey returns the same PRF output for the same salt. Apple developers have reported otherwise in one direction and across some OS versions (forum thread), so treat cross-device opening as something to verify on your devices.
  • npm is one release behind. npm still serves 0.1.0, the version with the old pin; until the next release lands, use the SDK from the repo.

Try it

  • Two-minute judge path: create an address with your passkey, ask the reference ERC-8004 agent to seal you a note, open it.
  • Demo video, 2:32, recorded with a real passkey on Monad mainnet.
  • Code, MIT: the contract, the SDK and CLI, the app, the agent, and the benchmark.

If you are building an agent or an app that keeps something about people, I would like to hear whether "seal to a person with one read" fits what you are doing.

Top comments (0)