at_auth

Important: applications are not meant to use at_auth directly. Everything an app needs — onboarding, login, enrollment, the connection state — is on at_client's Atsign verbs (open, activate, enroll), behind at_client_flutter's dialogs for Flutter apps and at_client_cli's CLIBase and commands for CLI and server apps. at_auth is 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_cli or at_client_flutter, to your pubspec.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.

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:

  1. Authenticating to the atServer with the CRAM key.
  2. Generating the atSign's cryptographic keypairs (PKAM signing, encryption, self-encryption).
  3. Publishing the public halves and registering the PKAM public key on the atServer.
  4. Writing the private halves to a local .atKeys file (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_auth to onboard an atSign should tell the user to back up the .atKeys file / keychain entry. at_client_flutter surfaces 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 .atKeys flat apkamPublicKey/apkamPrivateKey fields 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 and signingAlgorithmForEnrollment picks the routine.
  • activateAtSign's mintLegacyMaterial governs the RSA encryption keypair, the self-encryption key, and whether public:publickey is 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's AtClient.put of a public value throws AtPrivateKeyNotFoundException, 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:

  1. 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.
  2. An already-authenticated session holding the master AtKeys (or any session whose keys include the __manage namespace) approves or denies the request.
  3. 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.
  4. 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 your charts.acme data 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:

  1. Optional passphrase envelope — when a passPhrase is 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 of iv.
  2. Self-encrypted legacy fields — the document's four legacy RSA fields (aesPkamPublicKey, aesPkamPrivateKey, aesEncryptPublicKey, aesEncryptPrivateKey) are AES-256-encrypted with the document's own plaintext selfEncryptionKey, using a deterministic IV (so identical plaintext always produces identical ciphertext).
  3. The document — one of two shapes:
    • Legacy flat (no version field): a flat JSON object of the fields above plus selfEncryptionKey, apkamSymmetricKey, and enrollmentId.

    • Typed-keys ("version": 1): adds atsign, an empty top-level keys array, 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 its enrollmentId, an optional namespaces/appName/deviceName snapshot of its enrollment record, and its own keys array.
      • atsignKeys — a keys array for material belonging to the atSign rather than to any enrollment: the PQ signing root, an nskey private.

      Both group materials by keyId into a material array. A key entry carries no enrollmentId — its container states the owner once — so a keyId is unique within its container, not across the document: two enrollments may each hold auth: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 keys array is always written empty, because readers in the field may expect it wherever there is a version; typed material never goes there. A "version": 1 document whose top-level keys is 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 throws AtKeysSourceAbsentException if 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 is getAtSignKey(keyId, type). addKey is deliberately not split: a material states its own owner through its enrollmentId, and a null one routes it to the atSign's container.

  • flush(atsign, atKeys) — persist the current in-memory state. If the file exists, flush first validates that nothing it holds would be lost (key material is never removed — a key's status may only move forward, pending → active → retired → dead), then rewrites it; flushing a legacy file upgrades it in place to a typed-keys document. flush refuses a keyfile that holds no credential and names an atSign other than atsign. If the file does not exist, flush creates 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 AtAuth package contains common logic for onboarding/authenticating an atSign to a secondary server
at_auth_io
The dart:io half of at_auth: everything that needs a filesystem, a raw socket, or the dart:io HTTP stack.