at_auth
Important: applications are not meant to use
at_authdirectly. Everything an app needs — onboarding, login, enrollment, the connection state — is onat_client'sAtsignverbs (open,activate,enroll), behindat_client_flutter's dialogs for Flutter apps andat_client_cli'sCLIBaseand commands for CLI and server apps.at_authis the protocol layer those build on: its API changes with the protocol and carries no compatibility promise to applications, and a 4.0 removed every type an app once held. Add your platform package,at_client_cliorat_client_flutter, to yourpubspec.yaml, not this.
Platform-neutral core of activation, authentication and APKAM
enrollment for the Atsign Protocol: the key material, the .atKeys store
and the handshakes. Applications reach it through
at_client's Atsign verbs — activate, open, enroll —
and through at_client_cli (CLI / server apps)
and at_client_flutter (Flutter apps), which build
on those, rather than consuming at_auth directly.
What at_auth does
| Capability | Entry point |
|---|---|
| CRAM activation of a new atSign | activateAtSign(atSign: ..., cramSecret: ..., keys: ..., signingAlgo: ..., atLookUp: ...) |
| APKAM enrollment (request side) | AtEnrollment.submit(...), then AtEnrollment.waitForApproval(...) |
| APKAM enrollment (approve side) | AtEnrollment.approve(...) |
The .atKeys store |
AtKeys, AtKeysIo and its file and in-memory implementations (keychain: at_client_flutter) |
| Free atSign registration | RegistrarService (fetches CRAM key by email) |
Logging in is at_client's: Atsign('@alice').open(keys: ..., preference: ...)
builds the client and authenticates on the client's own connection, and
Atsign('@alice').authenticatesAs(keys: ...) checks which enrollment a key
source authenticates as without building one. So are denying and revoking an
enrollment (client.enrollments.deny(...) / .revoke(...)) and an approved
enrollment amending its own record (EnrollmentUpdater, from
package:at_client/at_client_mixins.dart).
See example/onboard.dart and
example/enrollment_request.dart for
end-to-end usage.
The atSign lifecycle
The full journey from "I don't own an atSign yet" to "my app is talking to the atServer" breaks into three phases. Understanding all three is important because each produces credentials that need to be handled correctly.
Phase 1 — Provision an atSign (get a CRAM key)
An atSign is a namespaced identity (e.g. @alice) with a matching
personal server (the atServer). Before any cryptographic keys exist,
you need to claim the atSign itself.
- Free atSigns: my.noports.com/no-ports-plans
- Paid / custom atSigns: my.atsign.com
Registration produces a CRAM key — a high-entropy secret that proves
first-time ownership. The registrar emails a one-time code to the
registered address and returns the CRAM key once that code is confirmed.
Programmatic registration is available via RegistrarService (see
lib/src/registrar/).
At this point the atSign exists on the root directory but its atServer has no authenticated user and no encryption keys. The CRAM key is the one-time bootstrap credential.
Phase 2 — Onboard the atSign (generate the master AtKeys)
Onboarding happens exactly once per atSign and consists of:
- Authenticating to the atServer with the CRAM key.
- Generating the atSign's cryptographic keypairs (PKAM signing, encryption, self-encryption).
- Publishing the public halves and registering the PKAM public key on the atServer.
- Writing the private halves to a local
.atKeysfile (for CLI apps) or to the device keychain (for Flutter apps).
The result is a master AtKeys set. These keys:
- Are the root of trust for the atSign — losing them is approximately as bad as forgetting the password to a cryptocurrency wallet; there is no recovery path that doesn't involve reclaiming the atSign from scratch.
- MUST be backed up by the end user. Any app that uses
at_authto onboard an atSign should tell the user to back up the.atKeysfile / keychain entry.at_client_fluttersurfaces an "export keys" flow; CLI apps typically just write the file and leave it to the user. - Are the only keys allowed to approve or deny APKAM enrollment requests — see Phase 3.
After Phase 2, the CRAM key is no longer usable (onboarding is
single-shot). Use example/onboard.dart to walk
through it end-to-end.
Post-quantum onboarding (opt-in)
Pass signingAlgo: SigningAlgoType.mldsa65 to activateAtSign and step 2
mints an ML-DSA-65 PKAM keypair instead of an RSA one. Two consequences are worth
knowing before turning it on:
- The APKAM is filed as typed material under the enrollment id, and the
.atKeysflatapkamPublicKey/apkamPrivateKeyfields are left empty. That is deliberate: a reader of the flat fields alone —AtKeys.authenticationKeyPairFor(null), or a tool that predates typed material — finds no APKAM and fails outright instead of signing an ML-DSA key with the RSA routine. The authenticator at_auth builds resolves such an enrollment on its own:AtKeys.enrollmentToAuthenticateAs()names it andsigningAlgorithmForEnrollmentpicks the routine. activateAtSign'smintLegacyMaterialgoverns the RSA encryption keypair, the self-encryption key, and whetherpublic:publickeyis published. It is an opt-out: leave it null and all three are still produced, because whether this atSign will ever need to talk to a pre-quantum peer is decided by the apps that adopt it rather than at activation. Set it false and a pre-quantum peer cannot send to the atSign at all, and at_client'sAtClient.putof a public value throwsAtPrivateKeyNotFoundException, since it signs the value with the RSA encryption private key.
activateAtSign's metadataBuilder attaches metadata to the first
enrollment's record. It runs on the request that creates that record, whose
metadata is never rewritten — so it is the only opportunity there will be.
Phase 3 — Authenticate apps via APKAM (per-app scoped AtKeys)
The master AtKeys are powerful — they can read anything on the atServer and approve new devices. You don't want every app on every device holding them. APKAM (App-level Pkam Key Authentication Mechanism) solves this.
How APKAM works:
- A new app / device submits an enrollment request specifying the
namespaces it needs and the access level it needs on each
(e.g.
{'todos': 'rw', 'profile': 'r'}). Each request gets a short-lived OTP to bind the request to a human-approved session. - An already-authenticated session holding the master AtKeys (or any
session whose keys include the
__managenamespace) approves or denies the request. - The enrolling app minted its own APKAM keypair for the request. On approval, the approver seals the atSign's encryption private key and self-encryption key for it; the atServer keeps the sealed copies and the enrollment's public key, and can open none of the secrets. The enrollment can only read/write within the granted namespaces.
- The enrolling app stores its keys (disk or keychain) and uses them for normal PKAM authentication thereafter.
Why this matters:
- Scope limits blast radius. An "evil" or simply buggy app with
scoped keys can only damage data in its own namespace. A todos app
with
{'todos': 'rw'}can never read yourcharts.acmedata even if it's fully compromised. - Scoped keys are revocable. The master-keys holder can revoke any previously-approved enrollment; the atServer rejects future authentications from those keys immediately.
- Audit trail. Every active enrollment is listed with its
appName,deviceName, namespace permissions, and status (pending,approved,denied,revoked,expired).
See example/enrollment_request.dart
for the submitting side; the approve/deny side is demonstrated in
at_client_cli/example/bin/apkam_examples/enroll_app_listen.dart.
The .atKeys file format
FileAtKeysIo reads and writes .atKeys files
(default path ~/.atsign/keys/<atsign>_key.atKeys). It lives behind the
dart:io barrel, package:at_auth/at_auth_io.dart; package:at_auth/at_auth.dart
stays platform-neutral. A file has up to
three layers, outermost first:
- Optional passphrase envelope — when a
passPhraseis configured, the whole document is AES-encrypted with a key derived from the passphrase (argon2id) and stored as{"v": 1, "content": ..., "iv": ..., "hashingAlgoType": "argon2id", "salt": ..., "kdfParams": {...}}. Detected by the presence ofiv. - Self-encrypted legacy fields — the document's four legacy RSA
fields (
aesPkamPublicKey,aesPkamPrivateKey,aesEncryptPublicKey,aesEncryptPrivateKey) are AES-256-encrypted with the document's own plaintextselfEncryptionKey, using a deterministic IV (so identical plaintext always produces identical ciphertext). - The document — one of two shapes:
-
Legacy flat (no
versionfield): a flat JSON object of the fields above plusselfEncryptionKey,apkamSymmetricKey, andenrollmentId. -
Typed-keys (
"version": 1): addsatsign, an empty top-levelkeysarray, and two containers of typed key materials, while the legacy fields stay flat at the top level — a typed-keys file's legacy portion is byte-identical to a legacy-only file, so legacy readers can still use it.enrollments— one entry per enrollment, carrying itsenrollmentId, an optionalnamespaces/appName/deviceNamesnapshot of its enrollment record, and its ownkeysarray.atsignKeys— akeysarray for material belonging to the atSign rather than to any enrollment: the PQ signing root, an nskey private.
Both group materials by
keyIdinto amaterialarray. A key entry carries noenrollmentId— its container states the owner once — so a keyId is unique within its container, not across the document: two enrollments may each holdauth:mldsa65:1, and identity is(enrollment, keyId).Structured keyIds are
<role>:<algorithm>:<generation>(auth:mldsa65:1,sign:rsa2048:1,root:mldsa65:1), the generation counted per role and algorithm. An entry addressed by a kid — a key package's, whose id is a digest of the key itself — keeps that kid.⚠️ The top-level
keysarray is always written empty, because readers in the field may expect it wherever there is aversion; typed material never goes there. A"version": 1document whose top-levelkeysis populated is an older shape and is refused, naming itself, rather than read as a legacy-only file.
-
In memory, AtKeys always holds plaintext; all three layers are applied
and peeled exclusively by FileAtKeysIo.
Persistence has three verbs:
-
write(atsign, atKeys)— create-only initial persist (fresh onboard); throws if the file already exists. -
update(atsign, mutate)— the one to reach for when adding key material. It reads, applies your mutation and persists as a single operation, holding the keyfile lock across all three steps. It never creates the file: it throwsAtKeysSourceAbsentExceptionif the keyfile is absent when it starts, and also if it is deleted while the update is in flight. The callback returns whether anything changed, so finding the material already there costs no write:await atKeysIo.update(atSign.toAtsign(), (keys) { if (keys.getKey(enrollmentId, keyId, CryptographicMaterialRole.privateDecapsulation) != null) { return false; // already filed; nothing to write } keys.addKey(material); return true; });The lookup takes the enrollment because identity is
(enrollment, keyId). For material the atSign owns rather than any one enrollment — the signing root, an nskey private — the sibling isgetAtSignKey(keyId, type).addKeyis deliberately not split: a material states its own owner through itsenrollmentId, and a null one routes it to the atSign's container. -
flush(atsign, atKeys)— persist the current in-memory state. If the file exists,flushfirst validates that nothing it holds would be lost (key material is never removed — a key'sstatusmay only move forward,pending→active→retired→dead), then rewrites it; flushing a legacy file upgrades it in place to a typed-keys document.flushrefuses a keyfile that holds no credential and names an atSign other thanatsign. If the file does not exist,flushcreates it.
Do not hand-roll read → mutate → flush. Those three steps
interleave: two callers running concurrently both read the same state, and
the second's flush presents a candidate missing the first's addition.
flush is right to refuse it — nothing may be lost — so what you get is a
thrown assurance exception and one addition silently gone. Preventing that
is what update is for, and the lock it takes serialises coroutines inside
one process as well as separate processes. For the same reason update
must never be nested, and flush must not be called from inside one: the
lock is not reentrant.
All three verbs write atomically (write-to-temp + rename), so a crash
mid-write can never truncate the keyfile, and a rewrite over an existing
file first preserves the previous state as <file>.bak alongside it, less
any pending enrollment the write removes. On Linux and macOS the keyfile, its
.bak and its .pre-v1 copy are written
readable and writable by their owner alone (mode 0600), whatever mode the
keyfile had before. Written as root, the keyfile and its .bak and .pre-v1
copies take the owner and group of the keyfile as it was read; a keyfile that
did not exist stays owned by root, and root does not rewrite a keyfile that is
a symbolic link or anything other than a regular file.
Migrating from 3.x
Upgrading to at_client 4.0 takes an app through the whole move; this section covers at_auth's own changes.
4.0 keeps the key material, the .atKeys stores and the enrollment
handshakes, and hands everything an application used to call to
at_client's Atsign verbs. An app on at_client_flutter
or at_client_cli follows those packages' own migration notes and never
sees this table; it is for code that imported package:at_auth/at_auth.dart
directly.
| 3.x | 4.0 |
|---|---|
AtAuth.create().onboard(AtOnboardingRequest(atSign)..rootDomain = ..., cramSecret) |
activateAtSign(atSign: ..., cramSecret: ..., keys: ..., signingAlgo: ..., rootDomain: ..., atLookUp: ...) here, or Atsign(atSign).activate(...) in at_client, which opens the client as well |
AtAuth.create().authenticate(AtAuthRequest(atSign, atKeysIo: ...)) |
Atsign(atSign).open(keys: ..., preference: ...); Atsign(atSign).authenticatesAs(keys: ..., rootDomain: ...) for the check that builds no client |
AtAuthResponse.atChops, .atLookUp, .atAuthKeys; AtAuthSession.atLookUp |
the AtClient open hands back; its keys are read from the store it opened on (keys.read(atSign)), and the connection is its own |
AtAuth.atChops, AtAuth.atLookUp, AtAuth.completeActivation() |
gone; activateAtSign completes the activation itself |
AtEnrollment.submit(request, atLookUp) / .waitForApproval(response) |
waitForApproval(response, atLookup: ...) takes the connection it runs on and leaves it open; or Atsign(atSign).enroll(...) and PendingEnrollment.client(...), which file the request in the keys store and resume it after a restart |
AtEnrollment.approve(decision, atLookUp) |
client.enrollments.approve(enrollmentId); here, approve(decision, atLookUp, approverKeys: ...) requires the approver's encryption private key and self-encryption key |
AtEnrollment.deny(...) / .revoke(...) |
client.enrollments.deny(id) / .revoke(id) |
AtEnrollment.list(statuses, atLookUp) |
client.enrollments.list(statuses: ...), .pending(), .fetch(id) |
AtEnrollment.generateOtp(...) / .setSpp(...), answering an Otp |
client.enrollments.otp() / .spp(value), answering a Passcode |
AtEnrollment.update(EnrollmentUpdateRequest, atLookUp) |
EnrollmentUpdater().update(request, atLookUp) from package:at_client/at_client_mixins.dart |
AtAuthRequest.enrollmentId, or any caller naming the enrollment to authenticate as |
the keys decide: AtKeys.enrollmentToAuthenticateAs() — the enrollment holding active typed authentication material, else the flat stored id, else primary |
AtKeys.toAtChops() / .toAtChopsForEnrollment(id) |
authenticationKeyPairFor(id) for the keypair and the algorithm that signs with it; encryptionKeyPair and selfEncryptionKey for the rest |
KeyIOMixin's decryptAtKeysWithSelfEncKey, encryptAtKeysWithSelfEncKey, generateKeyPairs, decodeAtKeys |
FileAtKeysIo.read / .write, which apply the passphrase envelope and the self-encryption themselves |
AtKeys.copyWith(...) |
AtKeys.addKey(...) |
AtKeys.apkamPublicKey, .apkamPrivateKey, .apkamSymmetricKey, .defaultEncryptionPublicKey, .defaultEncryptionPrivateKey, .defaultSelfEncryptionKey, .enrollmentId |
still present, deprecated: write a legacy document with AtKeys.legacy(...) or fileLegacyMaterial(...), and read it through authenticationKeyPairFor, encryptionKeyPair, selfEncryptionKey, enrollmentSymmetricKey and storedEnrollmentId |
import 'package:at_auth/at_auth.dart' for FileAtKeysIo |
import 'package:at_auth/at_auth_io.dart', the dart:io barrel; at_client re-exports it |
ActivateApiEndpoint, RegistrarApiEndpoint.login / .validate |
RegistrarApiEndpoint.requestOtp / .validateOtp |
AtEnrollmentRequest(atSign: ..., rootDomain: ..., apkamPublicKey: ..., encryptedAPKAMSymmetricKey: ...) |
removed: pass session: AtAuthSession(...), which names the atSign, the root domain and the key destination |
AtEnrollmentResponse.atSign, .rootDomain, .atAuthKeys |
still present, deprecated: read session.atSign, session.rootDomain and the keys from session.atKeysIo |
Behaviour that changed with no signature to catch it: a self-enrollment no
longer approves its own request, since the atServer approves a retrofit
outright; waitForApproval no longer pauses 500 ms before each attempt;
the first write that gives a flat keyfile typed material leaves a
one-off <keyfile>.pre-v1 copy of the flat document beside it, announced
at shout; and on Linux and macOS FileAtKeysIo writes the keyfile, its
.bak and its .pre-v1 copy at mode 0600, so a keyfile another account
read through its group or other bits is no longer readable by that account
once it has been rewritten.
Before and after
What code that imported at_auth directly commonly did, each as it was and
as it is now. In every case the 4.0 side is an at_client verb: the thing a
3.x caller went on to build from the response — an AtClient — is what the
verb hands back.
Authenticate from a keyfile
// 3.x: a response carrying an authenticated connection and key material
final atAuth = AtAuth.create();
final response = await atAuth.authenticate(AtAuthRequest(atSign,
atKeysIo: FileAtKeysIo(filePath: (_) => keysPath),
rootDomain: AtRootDomain('root.atsign.org', 64)));
final AtLookUp lookUp = response.atLookUp!;
final AtChops chops = response.atChops!;
// 4.0: the client, whose connection and keys those were. pqReady is an app
// built on 3.x's first step to post-quantum; see the upgrade guide.
final client = await Atsign(atSign).open(
keys: FileAtKeysIo(filePath: (_) => keysPath),
storage: HiveAtClientStorage( // package:at_client/hive.dart
atSign: atSign, storagePath: storagePath, closedByClient: true),
preference: AtClientPreference(posture: PqPosture.pqReady)..namespace = 'my_app',
lookUps: secureSocketLookUps(),
secondaryAddressFinder: CacheableSecondaryAddressFinder('root.atsign.org', 64));
client.connection.current; // did the atServer accept the keys
final keys = await client.atKeysIo!.read(atSign); // the material, from the store
// Which enrollment the keyfile authenticates as, with no client built:
final principal = await Atsign(atSign).authenticatesAs(
keys: FileAtKeysIo(filePath: (_) => keysPath),
rootDomain: AtRootDomain.atsignDomain,
lookUps: secureSocketLookUps(),
secondaryAddressFinder: CacheableSecondaryAddressFinder('root.atsign.org', 64));
Onboard with a CRAM secret
// 3.x
final response = await AtAuth.create().onboard(
AtOnboardingRequest(atSign)..rootDomain = 'root.atsign.org', cramSecret);
// 4.0, here: the activation alone, writing the keys to `keys`, over a
// connection the app builds with its lookUps factory and closes itself
final lookUp = secureSocketLookUps()(
atSign: atSign, rootDomain: AtRootDomain.atsignDomain, authenticator: null);
final enrollmentId = await activateAtSign(
atSign: atSign, cramSecret: cramSecret,
keys: FileAtKeysIo(filePath: (_) => keysPath),
signingAlgo: SigningAlgoType.mldsa65,
atLookUp: lookUp, awaitProvisioning: true);
await lookUp.close();
// 4.0, in at_client: the activation and the client it opens
final client = await Atsign(atSign).activate(
cramSecret: cramSecret, keys: keys, preference: preference,
storage: storage, lookUps: lookUps, secondaryAddressFinder: atDirectory);
Request an enrollment and wait for its approval
// 3.x: the app opened a connection itself and handed it in
final AtLookUp lookUp = openConnection(atSign); // however the app built one
final enrollment = AtEnrollment.create();
final submitted = await enrollment.submit(
AtEnrollmentRequest(
session: AtAuthSession(atSign: atSign, rootDomain: rootDomain, atKeysIo: keys),
appName: 'my_app', deviceName: 'laptop',
namespaces: {'my_app': 'rw'}, otp: otp),
lookUp);
await enrollment.waitForApproval(submitted);
// 4.0: the same handshake, filed in `keys` so a restart resumes it
final pending = await Atsign(atSign).enroll(
otp: otp, app: 'my_app', device: 'laptop',
namespaces: {'my_app': 'rw'}, keys: keys, preference: preference,
lookUps: lookUps, secondaryAddressFinder: atDirectory);
final client = await pending.client(preference,
storage: storage); // throws AtEnrollmentException on a denial
Approve, deny, revoke; passcodes
// 3.x: a decision object and a connection of the approver's
await AtEnrollment.create().approve(
EnrollmentRequestDecision.approved(
enrollmentId: id, apkamSymmetricKey: symmetricKey, atSign: atSign),
approverLookUp);
final Otp otp = await AtEnrollment.create().generateOtp(approverLookUp);
// 4.0: the approver's client
await client.enrollments.approve(id); // or deny(id), revoke(id)
final Passcode otp = await client.enrollments.otp();
final requests = await client.enrollments.pending();
client.enrollments.requests.listen((request) => ...);
AtEnrollment.approve(decision, atLookUp, approverKeys: ...) is still here for
code that holds the approver's key material itself; approverKeys is required,
because the approval seals the approver's encryption private key and
self-encryption key for the enrollee.
Where to go next
| If you're building… | Use |
|---|---|
| A CLI, daemon or server app | at_client_cli — CLIBase, CliKeys and the at_activate tools |
| A Flutter app | at_client_flutter — includes pre-built onboarding widgets |
| A platform package, or a program that passes every dependency itself | at_client |
Open source usage and contributions
BSD3-licensed. See CONTRIBUTING.md for
guidance on setting up tools, running tests, and raising a PR.
Libraries
- at_auth
- The
AtAuthpackage contains common logic for onboarding/authenticating an atSign to a secondary server - at_auth_io
- The
dart:iohalf of at_auth: everything that needs a filesystem, a raw socket, or thedart:ioHTTP stack.