Getting started
From an empty Tauri v2 app to a signed, verified request. Every step here is the short version of something the explainer explains properly.
1. Install the Rust crate and the JS package
Pin the tag. The plugin is not on crates.io or npm, deliberately: four languages ship from one tag, and publishing only the Rust and JS halves would let the native sides drift out of step with them.
cargo add tauri-plugin-sign-keypair --git https://github.com/vaam-apps/tauri-sign-keypair --tag v0.2.0
npm install github:vaam-apps/tauri-sign-keypair#v0.2.0Without --tag / #v0.2.0 you track main, which is not a release and may contain half-finished work. Keep the two in step: a Rust crate and a JS package from different commits disagree about the wire vocabulary, and that shows up as an unrecognised protection tag at the first generateDeviceKeys() call rather than at build time.
The JS package is tauri-plugin-sign-keypair-api; that is the name you import from.
2. Register the plugin
In src-tauri/src/lib.rs:
tauri::Builder::default()
.plugin(tauri_plugin_sign_keypair::init())
// ...The plugin registers itself as sign-keypair, which is the prefix of its permissions below.
3. Grant the frontend access
In src-tauri/capabilities/default.json:
{
"permissions": ["sign-keypair:default"]
}sign-keypair:default allows all five commands. None of them can leak key material — they return public halves, a backing tag and signatures — but delete_key has a real blast radius: it strands a device whose backend registration points at the deleted key. An app that never needs to unenrol from the frontend should list the individual permissions it wants instead:
| Permission | Allows |
|---|---|
sign-keypair:allow-capabilities | probing what the platform can do |
sign-keypair:allow-generate-key | creating a key |
sign-keypair:allow-get-key | reading a key handle: its public half and backing |
sign-keypair:allow-sign | signing with a key |
sign-keypair:allow-delete-key | deleting a key, which strands its backend registration |
Each has a matching deny-* permission. For example, everything but deletion:
{
"permissions": [
"sign-keypair:allow-capabilities",
"sign-keypair:allow-generate-key",
"sign-keypair:allow-get-key",
"sign-keypair:allow-sign"
]
}The generated reference for these is permissions/autogenerated/reference.md.
4. Platform setup
- Android.
BiometricPromptneeds the host activity to be aFragmentActivity. Tauri's generated activity extendsAppCompatActivity, which is one, so nothing is required unless you have replaced it. No manifest permissions are needed. - iOS. Nothing to configure. An app that shows Face ID should still carry
NSFaceIDUsageDescriptioninInfo.plist, as Apple requires. - macOS. A Secure Enclave key needs a code-signed build with a
keychain-access-groupsentitlement. Without one the plugin falls back to the software signer and reportssoftware— it does not fail. - Windows, Linux, the browser. These run, and report what they really are. See Platforms for each one, including what they refuse.
5. Enrol, then sign
import { SignKeypair, DEFAULT_USER_PRESENT_KEY_ID } from "tauri-plugin-sign-keypair-api";
const signer = new SignKeypair();
// Once, at enrolment. Both keys or neither: if the user-present key cannot be
// created, the ambient one is rolled back.
const keys = await signer.generateDeviceKeys();
await registerDeviceWithBackend({
ambient: keys.ambient.publicKey, // {crv, kty, x, y}
userPresent: keys.userPresent.publicKey,
});
if (!keys.hardwareBacked) {
console.warn("a device key is software-backed");
}
// On every authenticated request: the ambient key, which never prompts.
const jws = await signer.signCompactJws({
timestamp_ms: Date.now(),
device_id: deviceId,
method: "POST",
path: "/v1/payments/transfer",
});
// For a sensitive operation: the user-present key, which prompts every time.
// Pass your own localized reason.
const sensitive = await signer.signCompactJws(
{ amount: 500_00, to: recipient },
{ keyId: DEFAULT_USER_PRESENT_KEY_ID, reason: "Confirm this transfer" },
);registerDeviceWithBackend, deviceId and recipient are yours. signCompactJws returns a compact JWS, header.payload.signature, under a fixed {"alg":"ES256","typ":"JWT"} header. If you need a raw 64-byte r‖s signature over your own signing input instead, use signRaw.
In an ordinary browser, outside Tauri, the same code runs against a WebCrypto backend instead, which reports software.
6. Verify a signature
Verify against the public key the plugin reported, with a verifier that is not the plugin's. This is what the example app's verify button does, in the WebView:
const [header, body, signature] = jws.split(".");
const publicKey = await signer.getPublicKeyJwk(); // the ambient key's JWK
const key = await crypto.subtle.importKey(
"jwk",
{ ...publicKey, ext: true, key_ops: ["verify"] },
{ name: "ECDSA", namedCurve: "P-256" },
true,
["verify"],
);
const verified = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
key,
base64urlDecode(signature), // your own base64url decoder
new TextEncoder().encode(`${header}.${body}`),
);In production the verifying side is your backend, holding the JWK the device sent at enrolment; any ES256-capable JWS library will do. Two things to know:
- An ES256 signature is exactly 64 bytes,
r‖s. A 70–72-byte DER signature is the wrong format, and a bug there fails roughly 1 time in 256 rather than every time. - Signatures are not stable. Android, the Secure Enclave and WebCrypto use a random
k, so signing the same payload twice gives different signatures. Never use one as a cache key or an idempotency token.
7. Handle the errors that matter
Every failure is a SignKeypairError; switch on its code. Three of them come from a user-present signature, and each needs different handling:
code | What happened | Do |
|---|---|---|
user_authentication_cancelled | the person dismissed the prompt | nothing |
user_authentication_required | the device has no screen lock | send them to Settings |
key_invalidated | a biometric was re-enrolled | generate a fresh user-present key, register it |
hardware_unavailable | hardware or user presence was demanded on a target that cannot provide it | treat the target as degraded |
None of the first three is a reason to log the user out or to re-run enrolment. An unfamiliar code arrives as unknown, with the platform's original string on rawCode.
From Rust
The same signer is reachable from any Manager — an AppHandle, a Window, the App itself:
use tauri_plugin_sign_keypair::{jws, KeyProtection, SignKeypairExt, DEFAULT_KEY_ID};
let signer = app.sign_keypair();
let key = signer.generate_key(DEFAULT_KEY_ID, false, false, KeyProtection::Ambient)?;
let payload = r#"{"nonce":"abc"}"#;
let signing_input = jws::signing_input(payload);
let signature = signer.sign(DEFAULT_KEY_ID, signing_input.as_bytes(), None)?;
let compact = jws::compact(&signing_input, &signature);jws::signing_input takes the payload as an already-serialized string: re-encoding a map reorders its keys, and a payload whose key order differs from what was signed fails verification on the server.
Try the example app
examples/tauri-app/ is a minimal Tauri v2 app wired to the plugin — enrol, sign, verify, delete — that prints the result of every call:
npm install && npm run build # the example consumes the built JS package
cd examples/tauri-app && npm install && npm run tauri devOn desktop, generateDeviceKeys() fails with hardware_unavailable and the ambient key is rolled back: that is the enrolment rollback working, not a broken demo. generateKey() — ambient only gives you a usable key there.
Next
- The problem — the start of the explainer, if you want to know why any of this is shaped the way it is.
- The two-key model — why
ambientanduser_presentare separate keys. - Attestation — why
hardwareBackedis the device's own word for it, and should not gate a high-value operation on its own.