Status: Accepted Date: 2026-08-12 Related: [[ADR-010-identity-system]], ADR-012 (Browser WASM Parity), ADR-014 (plugin-provided service), ADR-025 (Access Tokens), ADR-026 (Namespaces), ADR-027 (Permissions)
"laye would just be another identity provider"
"satisfying a qntx contract"
laye is the wasm that ships with QNTX web.
relaye ceases to exist. QNTX signs bindings with its own node DID and is the peer laye bootstraps from.
The node DID is the anchor. A QNTX deployment signs bindings with
internal/nodedid/'s key and is its own root.
An identity provider gives QNTX web three operations:
did() -> string did:key for this browser
sign(bytes) -> signature proof of possession
bindings() -> SignedBinding[] external identities bound to the key
sign is the only use the private key is put to. That is what makes "never
leaves the tab" enforceable.
The contract speaks did:key. The consumer names the shape and the provider
converts.
auth.root_identities is a list of ways to reach an identity rather than a
list of identities. An entry is either a did:key, where the login signature is
the whole proof, or an account, which stands on a binding.
auth.binding_signers says whose signature on a binding counts. A binding
carries the key that signed it, so verifying one proves it is self-consistent
and nothing else — the list is the whole of what makes it mean something. Both
sides read it: the node from am.toml, laye from /auth/status, because a
browser that skips the check believes any peer that signs its own claim.
Each provider names accounts its own way. Mastodon by profile URL, atproto by
DID, Google and Apple by the sub each puts on an ID token. The string in am.toml is
whatever the provider calls the account, qualified where that name does not say
what it is:
"google:110169484474386276334, i want to know what it is ??"
A profile URL and a did: carry their own provenance. A bare number does not,
so Google's sub is written and matched as google: and the string stays
self-describing wherever it travels — including admitted_as, which travels
alone. Adding a provider adds a vocabulary rather than a field.
There is one ROOT User. Listing several is how that one User is reached from more than one place — a key it holds, an account it holds — not how a deployment admits several people.
A deployment reachable off loopback names root identities or does not start. An empty list on a bind the network can reach is a door with nobody behind it.
The host comes from the route. A Mastodon entry in auth.root_identities
carries its own instance, so the ceremony reads it there.
Every entry reaches the same person, whichever one is used:
"and in the future, when you try to login with a root_identity atproto, you get access to the user it belongs to, root in our case"
The node runs it. It registers with the provider, spends the token once to ask which account it belongs to, and signs the binding — the browser proposes no part of the answer it is going to be judged on.
Google does not let a node register itself, so the OAuth client is the operator's and lives where the rest of their deployment does:
"that Google's client id and secret live in am.toml under [auth.provider.google] — yes, mine will live there"
The secret is a reference, never a literal: am.toml ships as a world-readable SSM parameter, so a secret written into it is disclosed rather than configured.
The element draws it. /auth/binding/providers describes what each provider
asks for, so a provider appears in the UI by existing on the node — and Google
exists on a node only once it has been given a client, so a button that could
only fail is never drawn. The one window that still opens is the provider's own
consent screen.
Linking happens before anyone can log in, so the ceremony cannot be gated on a session. It is gated on a ticket instead: starting one sets a cookie, and the callback and the result are refused without it. Otherwise a stranger starts a ceremony naming their own key, sends the authorize URL to someone else, and the node signs a binding saying the stranger holds that person's account.
The ticket is also what the result is filed under. A binding is collected once, by the browser that earned it.
A ceremony started as a navigation ends by sending the person back to the door they left, carrying the ticket. The node sends people only to an origin am.toml named as a door. An app is a door too: its own scheme stands in the door's origins, the ceremony runs in Safari where the person's accounts already are, and Safari hands the ticket back through the scheme. A page at a scheme sends no Referer, so the navigation names its door, and the node accepts the name only when am.toml already did. A scheme is never a passkey origin.
"security is a server concern"
"we could probably do Apple's login as well" / "as identity provider"
Apple names an account by the sub on its identity token, a bare opaque
string, so it is written and matched as apple:<sub> for the reason Google's
is qualified.
Apple hands the operator a signing key rather than a client secret. The secret the token endpoint wants is a JWT the node mints and signs per exchange, living minutes. Nothing long-lived is held.
Apple returns by POST, and a SameSite=Lax ticket cookie does not ride a
cross-site POST. The node answers the POST with a redirect to the same callback
as a GET, which the cookie rides, and the ticket and state checks run there
unchanged. The POST decides nothing on its own.
What the operator registers, and what Apple refuses: identity/apple.md.
A passkey records the identity that was being admitted when it was enrolled. That is the moment when a biometric and an account are tied together.
A passkey stands on several devices. Apple copies one from a phone to a laptop, and each device derives its own key from it. Each device it stands on records the key it derived, under the provider-proven admission it first answered on; the enrolling device is the first. A login from a device the passkey has not stood on is the same passkey on one more device, and is recorded, not refused.
"Clearly I want the honest model."
The ROOT User always stands on a device. laye proves the key in the tab and finds the account, and that gets as far as the passkey rather than past it — no session is issued there. An account with no device enrols one, which is what a first login is; an account with devices asserts one of them, and a device holding none of them enrols itself on the same half-admission.
"My key, I'm Root. I want to have as many keys or devices as I want."
A login is offered the devices of the person laye admitted — every route that reaches their User (ADR-031) — and no others.
Login asks am.toml again rather than trusting the enrolment, so striking an
account out of root_identities takes its devices with it. A half-admission
carries the binding that reached the list, and the ceremony that spends it
re-verifies the binding — signer still in binding_signers, signature still
good, account still listed — rather than the name alone.
A root identity arriving at a door does the passkey at the node's own domain and is sent back to the door with a session.
A credential that cannot name both — the key the browser derived and the identity that admitted it — is not stored. An ownerless credential is a provenance failure: it authenticates whoever holds the authenticator, and no later check can recover who it was for. So enrolment requires a session to speak for, and a browser whose authenticator offers no PRF cannot enrol here.
identity:admitted, identity:refused and identity:released, in the
system namespace, signed by the node. Both outcomes: a refusal is a fact
about the deployment in the same way an admission is.
Recording never fails the thing it records. A login that worked is not undone by failing to write it down.
A session and a token name the identity and hold no binding. The binding the User's account was reached by (ADR-031) is asked about again when a passkey answers, where the User is already read; a live session is not re-verified per request, because reading the User store is a list of every User.
root_identities can no longer enrol a
passkey, because there is no identity for the credential to speak for.
A passkey answering only to itself was the state every install predating
this was in, and it is the state that made the credential ownerless.