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_onboarding_cli's 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. Addat_clientorat_client_flutterto 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_onboarding_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, in-memory and keychain implementations |
| 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, delivered
to the registered email address, that proves first-time ownership.
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.
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. - On approval, the atServer issues a new, scoped AtKeys set — it can only read/write within the granted namespaces.
- The enrolling app stores those scoped 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_onboarding_cli/example/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{"content": ..., "iv": ..., "hashingAlgoType": "argon2id"}. 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,active→retired→dead), then rewrites it; flushing a legacy file upgrades it in place to a typed-keys document. 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.
Migrating from 3.x
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_onboarding_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) |
AtKeys.authenticationFor(id) for the AtChops and its algorithm; authenticationKeyPairFor(id), encryptionKeyPair and selfEncryptionKey for the material alone |
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: ...) |
still accepted, deprecated: 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;
and 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.
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
final client = await Atsign(atSign).open(
keys: FileAtKeysIo(filePath: (_) => keysPath),
preference: AtClientPreference()..namespace = 'my_app');
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);
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);
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);
final approved = 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);
final client = await pending.client(preference); // 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 or server app | at_onboarding_cli + at_cli_commons |
| A Flutter app | at_client_flutter — includes pre-built onboarding widgets |
| Anything that reads/writes data after auth | 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.