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_onboarding_cli's 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 at_client 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_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.

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:

  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.

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. On approval, the atServer issues a new, scoped AtKeys set — it can only read/write within the granted namespaces.
  4. 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 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_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:

  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 {"content": ..., "iv": ..., "hashingAlgoType": "argon2id"}. 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, 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, 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.

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 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.