Deed KeyAuth
Passwordless authentication built on device key pairs (Ed25519), with no passwords to
store or leak. The user's public key is their identity. The flow is a two-step handshake: the
slice mints a one-time challenge nonce, the client signs it with its private key, and
verify checks the signature and returns your slice's own session JWT.
// Driftfile: name post:auth/challenge, handler PostAuthChallenge
nonce, _ := drift.Deed.KeyAuth.Challenge(pubkey) // single use, 120s
slice := drift.Deed.JWT.SliceID() // the client signs this too; return both
// Driftfile: name post:auth/verify, handler PostAuthVerify
token, _ := drift.Deed.KeyAuth.Verify(pubkey, sig, "my-app") // → this slice's JWT
The domain argument namespaces the signature per app, so a signature made for one app
can't be replayed against another on the same slice. The signed message also carries this slice's
own identity, filled in by the slice itself rather than trusted from the request: every slice on the
platform runs the identical binary, so without that field two slices would sign and verify identical
bytes for the same domain, nonce and device key. The private key never leaves the device; the slice
only ever sees public keys and signatures, and stores nothing about the user. The token's
sub is the identity and it expires 30 days out. A device that Link
has revoked is refused here even though its signature is genuinely valid.
Signing the challenge in the browser
The slice verifies a signature over one exact byte string: compact JSON, keys in sorted
order, no whitespace, over four fields: domain, nonce, pubkey
and slice. Build it literally rather than trusting a serializer's key ordering.
const hex = b => [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, "0")).join("");
const domain = "my-app"; // the same string your Verify call passes
// 1. one keypair per device. The private key is non-extractable and never leaves.
const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);
const pubkey = hex(await crypto.subtle.exportKey("raw", pair.publicKey)); // 64 hex chars
// 2. ask your own function for a nonce AND this slice's identity
const { nonce, slice } = await (await fetch("/api/auth/challenge", {
method: "POST", body: JSON.stringify({ pubkey }),
})).json();
// 3. sign the canonical message: this exact string, nothing else
const msg = `{"domain":${JSON.stringify(domain)},"nonce":${JSON.stringify(nonce)},"pubkey":${JSON.stringify(pubkey)},"slice":${JSON.stringify(slice)}}`;
const bytes = new TextEncoder().encode(msg);
const sig = hex(await crypto.subtle.sign({ name: "Ed25519" }, pair.privateKey, bytes));
// 4. POST pubkey + sig to your verify function; it hands back the slice's JWTThe signed message must match byte for byte.
401 bad signature with no
further detail. The client and Verify must use the same domain string,
and there is no platform-wide fallback for an empty one: a client that signs an empty domain must
call Verify with an empty one too, and vice versa.
Authentication walks the same flow end to end, from login to a protected route.