Passkey wallet
How an operator controls an institution's registry with a passkey, and how a scoped key lets the orchestrator publish.
Only the owner of an institution's registry can publish to it. The owner can be a passkey smart wallet, and the orchestrator then publishes through the wallet with a scoped publishing key, so no person approves each cycle.
A passkey is a WebAuthn credential, the Face ID, Touch ID or security key prompt used to sign in to websites. A passkey smart wallet is a Soroban account contract that an operator's passkey controls. When the wallet owns the institution's registry, every publish to that registry is authorized through the wallet.
Why a passkey
A seed phrase is one secret, and anyone who sees it can copy it. A passkey's private key is created inside the device's secure hardware or a security key and does not leave it. Signing is a WebAuthn assertion over a challenge, so the operator never types a secret.
The smart wallet
The wallet is the passkey-kit smart wallet, deployed unmodified from the Wasm with hash 8991e3262e6099e2a18acb3583af4d9d1da886403609e7170467df4337450dca on Testnet. The console's code for it is in apps/web/src/lib/passkey.
When an operator creates a wallet, the console:
- Runs a WebAuthn registration. The passkey's public key is a secp256r1 (P-256) key.
- Derives the wallet's address before deploying it. The address comes from the deployer account and a salt, the hash of the passkey's credential id.
- Deploys the wallet at that address with the passkey as its first
Secp256r1signer. If a contract already exists at the address, it uses that one.
The deployer is a session key the browser creates and funds from Friendbot, so the console needs no server and no stored secret. Because the address depends on the deployer, it is stable for one passkey within one browser session key, not for the passkey alone.
Stellar verifies the passkey's signature on chain with the secp256r1 host function added in Protocol 21. The wallet holds three kinds of signer, each with an optional expiry and optional limits: Secp256r1 for passkeys, Ed25519 for service keys, and Policy for policy contracts.
The wallet owns the registry
Each institution has its own proof-registry contract. Its owner is set once, by __constructor(owner, vk). publish_proof starts by requiring the owner's authorization:
pub fn publish_proof(env: Env, proof: Bytes, pub_inputs: PubInputs) -> Result<u64, Error> {
let owner: Address = env
.storage()
.instance()
.get(&DataKey::Owner)
.ok_or(Error::NotInitialized)?;
owner.require_auth();owner.require_auth() fails the call unless the owner authorizes it. When the owner is a smart wallet, the wallet's __check_auth runs in the same transaction and decides. No other account can publish to that registry.
The owner can also be an ordinary Stellar account (G...). The registries that just e2e and just demo deploy are owned by the tester account, which signs the publish directly. To make a wallet the owner, pass its C... address to just provision-tenant <tenant-id> <wallet-address>, which also records the wallet on the tenant row.
Authorizing the orchestrator
The orchestrator publishes every cycle, so it cannot wait for a passkey prompt each time. The operator grants it a scoped publishing key once. From the console, the operator adds the orchestrator's Ed25519 public key as a wallet signer, limited to the registry contract:
const signer: Signer = {
tag: "Ed25519",
values: [
Buffer.from(pubKey),
[undefined],
[new Map([[args.registryContract, undefined]])],
{ tag: "Persistent", values: undefined },
],
};The four values are the public key, no expiry, a limit map whose only entry is the registry contract, and persistent storage for the signer. The add_signer call itself is authorized with the passkey.
At publish time, the orchestrator simulates publish_proof, finds the wallet's authorization entry, signs it with its Ed25519 key in the layout the wallet's __check_auth expects, and simulates again so the fee covers the wallet check. It then submits the transaction.
The full flow
- The operator signs in with a passkey. The console deploys the smart wallet with the passkey as its signer.
- The institution's registry is deployed with the wallet as its owner.
- The operator adds the orchestrator's Ed25519 key as a signer limited to that registry, authorized by the passkey.
- Each cycle, the orchestrator calls
publish_proof. The registry callsowner.require_auth(), the wallet's__check_authchecks the scoped signature, and the proof is recorded.
Security properties
- No raw keys held by people. The passkey's private key stays in the device.
- Least privilege for the service. The orchestrator's signer is limited by
SignerLimitsto calls on the one registry contract. What else the limit forbids is decided by the passkey-kit wallet contract. - Human control. The passkey stays a signer of the wallet. The wallet contract has a
remove_signerfunction, so the scoped key can be removed without moving the wallet or the registry. The console has no button for it, so call the wallet contract directly. - Isolation. Each institution has its own registry and its own owner, so one institution cannot authorize a publish to another's registry.
Beyond one operator
The wallet accepts several signers, which is how control extends past one person:
- Several passkeys Coming soon, on different devices, signing for the same wallet. This gives an institution recovery and shared control.
- Policy signers Coming soon, which express approval rules such as requiring more than one signer for a sensitive action.
The wallet contract supports both kinds of signer.
Next: Contract reference.