Beta

Identity & pairing

An Identity is your encryption keypair. It's separate from your PDS session because the PDS never sees it. Until one is in Storage for your DID, Opake.init({ storage }) throws IdentityMissing. Three paths put one there: fresh creation, seed-phrase recovery, and device pairing.

What's in an Identity #

Identity
interface Identity {
  did: string;
  x25519_public_key: string;    // X25519, base64. Classical half of the hybrid KEM.
  x25519_private_key: string;   // X25519, base64. Lives in Storage, read by WASM only.
  ml_kem_public_key: string;    // ML-KEM-768, base64. Post-quantum half.
  ml_kem_private_key: string;   // ML-KEM-768, base64. Lives in Storage, read by WASM only.
  signing_key?: string;         // Ed25519, base64. Lives in Storage, read by WASM only.
  verify_key?: string;          // Ed25519, base64.
}

Derived deterministically from a 24-word BIP-39 mnemonic plus the DID. The same phrase produces the same keys for the same DID, every time, regardless of device. X25519 is for file encryption and key wrapping; Ed25519 is for signing requests to the Indexer.

The private fields live in Storage and are read by WASM directly when a crypto operation needs them. Your code sees the Identity type only briefly, during the moments right after generation or recovery, before handing it off to storage.saveIdentity.

Creating a new identity #

First-run path: the user has no existing identity anywhere, and no other device to pair with. Generate a phrase, show it to the user to back up, derive keys, save, publish the public key.

create-identity.ts
// Generate a new 24-word BIP-39 phrase.
const phrase = await Opake.generateSeedPhrase();

// UX: show `phrase` to the user and require confirmation it's written
// down BEFORE you call createIdentity. Once the derived identity is
// saved to Storage, the phrase is the only recovery path if Storage
// is ever wiped.
await requireUserConfirmedBackup(phrase);

// Derive the X25519 + Ed25519 keypairs from the phrase.
const identity = await Opake.createIdentity(phrase, did);
await storage.saveIdentity(did, identity);

// Publish the public key so others can encrypt to you.
const opake = await Opake.init({ storage, did });
await opake.publishPublicKey();

The phrase is shown once, and only once. You don't hold onto it after createIdentity returns, and neither does WASM. If you want a "show again" feature later, your app has to store the phrase itself (encrypted under a passphrase, or behind a biometric prompt, or similar).

publishPublicKey writes at.opake.publicKey/self on the PDS as an idempotent putRecord. Without it, other users can't encrypt files to this DID.

Recovering from a seed phrase #

User already has a phrase: from another device, a backup, a password manager. Validate before deriving — garbage input produces garbage keys that fail later at decrypt time with no obvious connection to the typo two pages back.

recover.ts
// Validate before deriving. Garbage input would silently produce
// garbage keys that only fail later at decrypt time.
if (!(await Opake.validateSeedPhrase(phrase))) {
  throw new Error("That doesn't look like a valid 24-word phrase.");
}

const identity = await Opake.createIdentity(phrase, did);
await storage.saveIdentity(did, identity);

const opake = await Opake.init({ storage, did });

// publishPublicKey is idempotent. If the user has used Opake before on
// another device, the record is already there and the putRecord
// overwrites with the same content. Safe to call either way.
await opake.publishPublicKey();

One gotcha: the SDK doesn't check that the derived hybrid public-key bundle matches whatever is already published on the PDS's publicKey/self record. If the user types the wrong phrase but it's still a valid 24-word BIP-39 sequence, validation passes, the derived keys are saved, and every encryption that touches existing documents fails silently later. If you want a matches-the-PDS check, fetch publicKey/self after init and compare both halves of the published bundle against identity.x25519_public_key and identity.ml_kem_public_key before you consider the recovery complete. Surfacing this as an in-SDK helper is on the list.

Pairing: the new device side #

The user has Opake running on another device and would rather not type 24 words. They open the pair flow on this new device. The SDK generates an ephemeral hybrid (X25519 + ML-KEM-768) keypair, writes an at.opake.pairRequest record to the PDS containing both public halves, and stashes the private bundle in Storage for awaitPairCompletion to read back later. JS never sees the private halves.

pair-start.ts
// New device. User has a live session (from login) but no Identity yet.

const { uri, rkey, ephemeralPublicKey } = await Opake.createPairRequest(
  storage,
  did,
);

// Show a fingerprint so the user can verify on their other device that
// the request really is from this one. First 8 bytes of the ephemeral
// public key, hex-paired.
const fingerprint = Array.from(ephemeralPublicKey.slice(0, 8))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join(":");
displayToUser(`Fingerprint: ${fingerprint}`);

Now wait for approval from the other device. awaitPairCompletion polls the PDS for a matching at.opake.pairResponse record, decrypts the identity payload inside WASM using the stored ephemeral private key, writes the resulting Identity to Storage, and deletes both the request and response records. One call covers all of that.

pair-await.ts
const controller = new AbortController();

try {
  await Opake.awaitPairCompletion(storage, did, rkey, {
    pollIntervalMs: 3000,
    timeoutMs: 10 * 60 * 1000, // 10 minutes
    signal: controller.signal,
  });

  // Identity is in Storage. Opake.init now succeeds.
  const opake = await Opake.init({ storage, did });
} catch (err) {
  if (err instanceof DOMException && err.name === "AbortError") {
    // User cancelled before approval arrived. Wipe the request.
    await Opake.cancelPairRequest(storage, did, rkey);
    return;
  }
  throw err;
}

The AbortController / signal is how you cancel from the UI. cancelPairRequest tears down both sides of the pair state: wipes the ephemeral private key from Storage and deletes the PDS request record, so it isn't visible to the approving device anymore.

Pairing: the existing device side #

On the device that already has an identity, list the requests and approve the one the user recognises.

pair-approve.ts
// Existing device. Already has an Identity in Storage, so `opake` exists.

const requests = await opake.listPairRequests();
// requests: {
//   uri: string;
//   x25519EphemeralKey: Uint8Array;  // 32 bytes — what fingerprints
//   mlKemEphemeralKey: Uint8Array;   // 1184 bytes — post-quantum half
//   createdAt: string;
// }[]

// Present them with fingerprints (X25519 half). The user picks the one
// matching what their new device is showing. Do NOT auto-approve — the
// fingerprint check is the only defense against a MITM injecting a fake
// request.
const selected = await askUserToPickRequest(requests);

await opake.approvePairRequest(
  selected.uri,
  selected.x25519EphemeralKey,
  selected.mlKemEphemeralKey,
);
// The new device's awaitPairCompletion resolves within one poll.

approvePairRequest wraps this device's identity under the requester's ephemeral public key, writes the encrypted bundle as at.opake.pairResponse, and returns. The requesting device's awaitPairCompletion picks it up on its next poll.

Fingerprints are doing real work #

The pairing handshake is secure against a passive observer. The PDS sees ciphertexts and ephemeral public keys; that's not enough to recover an identity. Against an active attacker who can write to your PDS, though, it's different. They could inject their own pair request record with their ephemeral public key, wait for you to tap Approve, and receive your wrapped identity in the response.

The fingerprint comparison is what closes that gap. Both devices display the same 8-byte prefix of the same ephemeral public key. If the user reads them off and they match, the request on the approving device really is from the new one, not an attacker's forgery. If they don't match, abort.

Show fingerprints on both sides. Don't auto-approve. An SDK can't enforce the out-of-band check for you.

What never crosses the JS boundary #

A quick map of who holds what during pairing:

  • Ephemeral hybrid private bundle (new device): generated inside WASM (X25519 + ML-KEM-768), written straight to Storage via the storage adapter, read back by WASM during awaitPairCompletion, wiped on success or cancellation. Your JS sees the public halves only, and only for fingerprint display.
  • The received Identity bundle (new device): unwrapped inside WASM from the pair response body, written to Storage through the adapter, never returned to JS as an object with token fields populated.
  • The existing device's identity during approval: read from Storage into WASM, wrapped under the requester's ephemeral hybrid bundle inside WASM. JS signs nothing and holds no key material during this call.

The only identity-shaped values your application code ever touches directly are the public did, x25519_public_key, ml_kem_public_key, and verify_key fields on the Identity type during the narrow window after generation, plus the x25519EphemeralPublicKey / mlKemEphemeralPublicKey returns from createPairRequest for fingerprint rendering.