Quickstart
Five lines to a sealed, reopened record. One passkey prompt per visit, then any number of namespaces without another.
Seal and reopen
import { openOrEnrol, ns } from "satchel-sdk";
const mode = "enrol"; // "open" when this person already has a passkey; a first visit needs one, later visits ignore it
const s = await openOrEnrol({ rpId: location.hostname, appName: "My app", mode });
const memory = ns("memory/assistant");
const blob = await s.seal(memory, { likes: "metric units" }); // Sealed, safe to store anywhere
const back = await s.unsealJson(memory, blob); // { likes: "metric units" }The passkey needs the WebAuthn PRF extension: iCloud Keychain, Google Password Manager, Windows Hello, 1Password, Proton Pass and YubiKey 5 have it. The buttons run the exact text above, import line included, against this site's own passkey. openOrEnrol shows one passkey prompt per call, so a first visit asks you which one: Create a passkey runs the code as written, I already have one runs it with the mode line set to "open", each its own click and its own prompt. Once this browser remembers the passkey there is one button, Run the code above, and mode is ignored. If the remembered passkey is gone (deleted, or made on another device), the call forgets it, throws PASSKEY_NOT_FOUND and both buttons come back. openOrEnrol lists the order it tries.
Install
npm i satchel-sdk@0.1.0 @category-labs/mera@0.2.0The registry holds satchel-sdk 0.1.0, so the line works in an empty directory today. Mera is the passkey layer and is pinned at 0.2.0. A compatibility tarball stays on this site for projects using the existing download URL.
The same in Node
There is no passkey in Node. A server, a test or a script that already holds the 32 byte PRF output opens the same vault with fromPrfOutput. Save this as quickstart.mjs after the install line and run node quickstart.mjs.
import { fromPrfOutput, ns } from "satchel-sdk";
const prf = crypto.getRandomValues(new Uint8Array(32)); // a real app passes the 32 byte PRF output its passkey returned
const s = await fromPrfOutput(prf, { rpId: "localhost" });
const memory = ns("memory/assistant");
const blob = await s.seal(memory, { likes: "metric units" });
const back = await s.unsealJson(memory, blob);
console.log(JSON.stringify(back));What correct looks like
sealed ns=memory/assistant epoch=1 97 B fp=4cda9f4b (1 prompt)
unsealed {"likes":"metric units"}97 bytes is 57 bytes of header, 24 bytes of JSON and a 16 byte authentication tag. The fingerprint is yours: it is the same eight characters on every device that holds your passkey, so it doubles as the cross-device check.
A second namespace is one more string
const notes = ns("memory/notes");
const note = await s.seal(notes, "call the dentist"); // a new folder, no second prompt
await s.fingerprint(notes); // b4e6cf3b, equal on every device with this passkeyBoth namespaces come from the same passkey output through HKDF with different info strings, so the second one asks for nothing. Namespaces says how.
Give another app a key, then take it back
import { createGrantee, fetchGrant, ns, openOrEnrol } from "satchel-sdk";
const registry = { address: "0xd6c895f5c9254dc6eaafc335fff0930fd7012e6d" }; // Monad testnet
const memory = ns("memory/assistant");
// App B (Fern) makes its own key. It never touches the passkey. fern.publicKey goes to the owner over any channel.
const fern = await createGrantee();
// App A (Pip) holds the passkey: it seals a record and grants Fern.
const s = await openOrEnrol({ rpId: location.hostname, appName: "Pip", registry, mode: "enrol" });
const blob = await s.seal(memory, { likes: "metric units" });
const grant = await s.grant(memory, fern.publicKey, { label: "Fern" }); // one Monad transaction
// App B needs only grant.txHash and the sealed bytes. It rebuilds the grant from the receipt and reads.
const mine = await fetchGrant({ txHash: grant.txHash, registry, ns: memory });
await fern.reader(mine).unsealJson(blob);
// App A takes the access back: epoch 1 to 2, one Monad transaction.
await s.revoke(memory, grant.id);grant and revoke are transactions on the registry, sent from s.registryOwner, an address derived from your passkey. Sealing and reading need no MON, and a send from an unfunded owner stops before it is sent with OWNER_UNFUNDED, naming the address and the shortfall. Revoking moves the namespace to the next epoch for every reader, so another app that should keep reading new records needs regrant: Grants and epochs has the rule. The live version of exactly this is the demo: Pip holds the passkey and Fern reads through the grant, which it rebuilds from a transaction hash.
Next
- Passkey and PRF, why the raw output is never a cipher key.
- API reference, every export with its Throws line.
- Two apps, how a second origin gets the grant and the sealed blobs.
- Migrate from per-note vaults, one file in trustset, six prompts to one.
- Proof, the live checks and the recorded runs behind them.