Skip to content

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.

bash
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.0

Without --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:

rust
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:

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:

PermissionAllows
sign-keypair:allow-capabilitiesprobing what the platform can do
sign-keypair:allow-generate-keycreating a key
sign-keypair:allow-get-keyreading a key handle: its public half and backing
sign-keypair:allow-signsigning with a key
sign-keypair:allow-delete-keydeleting a key, which strands its backend registration

Each has a matching deny-* permission. For example, everything but deletion:

json
{
  "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. BiometricPrompt needs the host activity to be a FragmentActivity. Tauri's generated activity extends AppCompatActivity, 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 NSFaceIDUsageDescription in Info.plist, as Apple requires.
  • macOS. A Secure Enclave key needs a code-signed build with a keychain-access-groups entitlement. Without one the plugin falls back to the software signer and reports software — 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 ​

ts
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:

ts
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:

codeWhat happenedDo
user_authentication_cancelledthe person dismissed the promptnothing
user_authentication_requiredthe device has no screen locksend them to Settings
key_invalidateda biometric was re-enrolledgenerate a fresh user-present key, register it
hardware_unavailablehardware or user presence was demanded on a target that cannot provide ittreat 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:

rust
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:

bash
npm install && npm run build      # the example consumes the built JS package
cd examples/tauri-app && npm install && npm run tauri dev

On 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 ambient and user_present are separate keys.
  • Attestation — why hardwareBacked is the device's own word for it, and should not gate a high-value operation on its own.

Released under the MIT License.