The Atsign FoundationThe Atsign Foundation

pub package pub points build status gitHub license

at_client

The non-platform-specific client SDK for building apps on the Atsign Protocol. An atSign owns a personal server (the atServer); at_client talks to that server on its owner's behalf, transparently handling key management, end-to-end encryption, sync, and notifications.

at_client runs on both Dart (CLI / server) and Flutter (mobile / desktop / IoT). It is intentionally platform-neutral: the actual onboarding dialogs, secure key storage, and CLI scaffolding live in the platform packages that depend on at_client, and an app imports its platform package rather than this one (the SDK shape). Flutter web is not supported — atSign onboarding and key storage rely on platform plugins that don't have web implementations today.

Upgrading from 3.x: see Upgrading to at_client 4.0.

Which package do I actually want?

If you're building… Start with
A Dart CLI, daemon, server or IoT program at_client_cli — CLIBase, CliKeys and the at_activate / at_register tools
A Flutter app (mobile/desktop/IoT) at_client_flutter — onboarding / APKAM / keychain widgets (web not supported)
A platform package, or a program that passes every dependency itself this package
Understanding the atSign lifecycle at_auth — platform-neutral onboarding / authentication core with a detailed lifecycle writeup

Core surface

The AtClient interface (lib/src/client/at_client_spec.dart) is the main entry point once authentication is complete.

  • collection<T>(namespace, defaultExpiration, {fromJson, typeTag, eventSource, cleanupOrphansOnCreation}) — returns a Future<AtCollection<T>>. fromJson and typeTag travel together; pass either both or neither. AtCollections hide the low-level keystore plumbing and let you work directly with your own domain objects and types (see Collections below).
  • notificationService — fire-and-forget and pub/sub messaging (lib/src/service/notification_service.dart)
  • syncService — background sync between the local store and the atServer
  • put(AtKey, value) / get(AtKey) / delete(AtKey) — low level CRUD against the keystore. You should almost never need to do this if you are using AtCollections.
  • tryAcquireServerMutex(key, ttl:) — a short-lived lock that every client of the atSign shares, held on the atServer for at most 15 minutes; AtRpc's enableRequestMutex uses it (lib/src/client/server_mutex.dart).

atServer version for the server mutex. It needs an atServer from p3.13.0 (at_commons 5.10.0), the first to parse :nc; an older one refuses every lock, so tryAcquireServerMutex throws and an AtRpc responder with enableRequestMutex handles no requests. A lock stays out of the commit log from c3.16.3 and p3.16.5; between p3.13.0 and those, the atServer records the commit anyway. An expired lock stops blocking at once from c3.16.2 and p3.16.5; before that it blocks until the atServer's expiry sweep, up to about ten and a half minutes. The client doesn't check the version.

Examples

The API in at_client has evolved substantially over several years. The authoritative examples of current usage are the worked programs under these two directories — start there rather than with older tutorials or blog posts:

  • Dart / CLI examples: example/README.md walks through every program in example/bin/ — primitives, domain objects, polymorphic / binary collections, a full interactive TUI todos app, raw notifications, RPCs, and the dockerstats CLI publisher / subscriber.

  • Flutter examples:

    • ../at_client_flutter/example/ — onboarding + auth UI walkthroughs (CRAM, .atKeys-file, keychain, APKAM).
    • ../at_client_flutter/example/todos/README.md — full shared-todos Flutter app. The idiomatic Flutter consumer of AtCollection<T> — every common collection pattern (typed AtCollection, sub-collections, queries, watches, read receipts, sharing, schedule-via-availableAt) wired up the way a real app would. Wire-compatible with the CLI sibling so the two apps can share data live.
    • ../at_client_flutter/example/dockerstats/README.md — live telemetry dashboard. The deliberate counterpart to todos, dockerstats is the canonical worked example of a pattern the SDK supports but doesn't impose: deliver via short-lived notifications, store in a relational database. It demonstrates the trade-off explicitly because mis-applying AtCollection<T> to this shape of workload — a high-frequency stream of observations whose query / aggregation / windowing is the dominant design concern — would be wrong.

atSign lifecycle (short version)

Before an app can read or write, an atSign must be registered, onboarded, and the app must authenticate. The detailed writeup of all three phases — including why APKAM matters for "evil app" protection — is in at_auth's README. The summary is:

  1. Register an atSign (free at my.noports.com/no-ports-plans or paid/custom at my.atsign.com). Once you have a registered atSign, the application code needs to get the CRAM key for step 2 below. Typically this is done by the app, once it knows the atSign to be onboarded, requesting that an OTP for the atSign be sent to the registered owner's email address. (There are other processes possible for all of this, but this is typical.)
  2. Onboard the atSign exactly once: CRAM-authenticate, generate the master keypairs, and write them to disk (.atKeys file for CLI) or the device keychain (Flutter). These master AtKeys are the root of trust — end users must back them up, losing them means losing the atSign.
  3. Authenticate subsequent apps via APKAM enrollment: the app mints its own keypair and requests only the namespaces it needs (e.g. {'todos': 'rw'}), and the master-keys holder approves, sealing the atSign's encryption keys for it; the atServer keeps the sealed copies and the enrollment's public key, and can open none of the secrets. Scoped keys can be revoked at any time; a compromised scoped key can only damage data in its granted namespaces.

In code: four verbs

at_client owns the whole of that lifecycle. An app holds an AtKeysIo (a .atKeys file or the platform keychain; activate, enroll and resumeEnrollment write to it, so they take a WrittenAtKeysIo) and asks the atSign for a client; every verb hands back an AtClient the app owns and stops.

import 'package:at_client/at_client_io.dart';
import 'package:at_client/hive.dart';

// Every client keeps local storage, here Hive in a directory the app picks.
AtClientStorage storage() => HiveAtClientStorage(
    atSign: '@alice', storagePath: '/data/alice', closedByClient: true);

// How the client reaches its atServer, and the atDirectory that says where.
final lookUps = secureSocketLookUps();
final atDirectory = CacheableSecondaryAddressFinder('root.atsign.org', 64);

// Log in: a client on keys the app already holds. It comes back online,
// offline or refused, and says which; offline it serves its local store.
final client = await Atsign('@alice').open(
    keys: FileAtKeysIo(filePath: (_) => '/keys/@alice_key.atKeys'),
    storage: storage(),
    preference: AtClientPreference(posture: PqPosture.pqActive)..namespace = 'todos',
    lookUps: lookUps,
    secondaryAddressFinder: atDirectory);
client.connection.current;                  // online | offline | refused
client.connection.changes.listen((state) => print(state));
await client.connection.awaitOnline();      // wait, with a budget

// Onboard: activate a newly registered atSign with its CRAM secret. The
// keys it mints land in `keys`, and the client opens on them.
final owner = await Atsign('@alice').activate(
    cramSecret: secret, keys: keys, storage: storage(), preference: preference,
    lookUps: lookUps, secondaryAddressFinder: atDirectory);

// Enroll: ask the atSign's owner to approve this app. The request is filed
// in `keys` as pending, so a restart resumes it rather than repeating it.
final pending = await Atsign('@alice').enroll(otp: otp, app: 'todos',
    device: 'phone', namespaces: {'todos': 'rw'}, keys: keys,
    preference: preference, lookUps: lookUps,
    secondaryAddressFinder: atDirectory);
final enrolled = await pending.client(preference,
    storage: storage());                                 // waits for approval
final resumed = await Atsign('@alice').resumeEnrollment(app: 'todos',
    device: 'phone', keys: keys, preference: preference, lookUps: lookUps,
    secondaryAddressFinder: atDirectory);

// Approve, from an enrolled client: the atSign's roster and passcodes.
final requests = await owner.enrollments.pending();
print(requests.first.apkamKeysExpiryDuration);  // the key expiry it asks for
final approved =
    await owner.enrollments.approve(requests.first.enrollmentId!);
print(approved.expiresAt);                      // when it now expires
owner.enrollments.requests.listen((request) => print(request.appName));
final passcode = await owner.enrollments.otp();

// An app that keeps one current client for its screens makes it so.
AtClientManager.getInstance().use(client);

Four things are the platform's to supply, and every verb takes them the same way; at_client_cli and at_client_flutter supply each for their apps. keys: is where the atSign's keys live (FileAtKeysIo or the Flutter keychain), and it must outlive the process, since a client files key material there that exists nowhere else; storage: is the client's local store, each backend from its own import (HiveAtClientStorage from package:at_client/hive.dart, SqliteAtClientStorage from package:at_client/sqlite.dart, InMemoryAtClientStorage from package:at_client/memory.dart); secondaryAddressFinder: is the atDirectory that says where the atServer is (CacheableSecondaryAddressFinder natively); and lookUps: is how the client reaches its atServer. The last is an AtLookUpFactory, a function that builds every connection the client opens - its own, its sync's, its monitor's - so a transport or a proxy convention is chosen once:

import 'package:at_client/at_client_io.dart';
import 'package:at_client/hive.dart';

final client = await Atsign('@alice').open(
    keys: FileAtKeysIo(filePath: (_) => '/keys/@alice_key.atKeys'),
    storage: HiveAtClientStorage(atSign: '@alice', storagePath: dir, closedByClient: true),
    preference: AtClientPreference(posture: PqPosture.pqActive)..namespace = 'todos',
    secondaryAddressFinder: CacheableSecondaryAddressFinder('root.atsign.org', 64),
    lookUps: secureSocketLookUps(
        config: SecureSocketConfig()..pathToCerts = '/certs',
        onConnect: (connection) => connection.sendSync('from:@alice\n')));

lookUps builds every connection the client opens. secureSocketLookUps connects with TLS on TCP, taking a SecureSocketConfig and an onConnect run on each new connection before anything else (a proxy that routes on from: is what that is for). A factory of your own can hand back any AtLookUp.

Every client keeps local storage, even if only in memory: storage is required, and InMemoryAtClientStorage keeps nothing on disk. End-to-end-encrypted values are stored in local storage as their ciphertext, never in the clear. A backend of your own needs types at_client doesn't export, so raise a feature request for it.

Atsign.authenticatesAs(keys: ..., rootDomain: ..., lookUps: ..., secondaryAddressFinder: ...) answers which enrollment a keys store authenticates as without building a client. Flutter apps get the same verbs behind dialogs in at_client_flutter; CLI apps get them behind at_client_cli's CLIBase and at_activate commands.

AtClientManager.setCurrentAtSign and fromAuthSession, which built the current client for the caller, are removed in 4.0: build the client with a verb above and make it current with use.

Collections

For the common "CRUD on typed, shareable records" use case, AtCollection<T> (lib/src/collections/collections.dart) hides almost all of the AtKey / Metadata / notification-regex ceremony behind a small set of verbs.

Sketch:

final todos = await atClient.collection<Todo>(
  'todos.my_app',            // fully-qualified namespace
  const Duration(days: 7),
  fromJson: Todo.fromJson,
  typeTag: 'Todo',           // wire-format identifier — required
);

final item = await todos.create(
  obj: Todo('write readme'),
  sharedWith: {'@bob'.toAtsign()},
);

item.obj.done = true;
await todos.update(item);

// Add @carol without rewriting the self copy:
await todos.updateSharedWith(item, {'@bob'.toAtsign(), '@carol'.toAtsign()});

await for (final e in todos.updates) {
  print('updated: ${e.id} by ${e.owner}');
}

A share reads the recipient's key from their atServer whenever this client has no current copy of it, so it can need the network when the self copy does not. With the atServer out of reach, create(sharedWith: ...) writes the self copy and then throws a CollectionOpException naming each recipient's copy it could not write. With an atServer that stops answering mid-connection, that takes up to about a minute at the default networkTimeout, and a call abandoned before then can still complete. The item is listed, and nothing sends the share later. Catch CollectionOpException<T>: its item is the item written, naming only the recipients whose copies were written, so updateSharedWith(e.item!, ...) retries the rest. Never retry with create, which would write a second item. When the self copy itself cannot be written, nothing else is: the exception names the self copy, and its results list each recipient's copy as OpNotAttempted.

update / delete / create fire CItemUpdated / CItemDeleted on the writing collection's event streams as soon as the local write lands — no waiting for the network round-trip — so a UI using Query.watch() redraws immediately. The same event re-fires on the round-trip ~50–200 ms later (and ~10–30 ms excluding network transit once fsync ships); Query.watch's delta path is idempotent so the second occurrence is invisible.

AtCollection<T> executes reads on-device by default, against a local copy that the at_client SDK keeps current via real-time sync with the atServer. Value-level filtering is always on-device — records are end-to-end encrypted between atSigns, so the server never sees plaintext to filter on.

A record this device cannot read yet, such as one sealed to a namespace key it does not hold, fails getItems() with a CItemReadException naming the record and why. getItemsAsStream() yields the same exception as an error and goes on, so a listener with an onError handler lists the rest.

A composable Query<T> builder covers the common filter / sort / paginate / aggregate patterns:

final overdue = todos.query()
    .where((t) => !t.obj.done)
    .where((t) => t.obj.due.isBefore(DateTime.now()))
    .orderBy((t) => t.obj.due)
    .thenBy((t) => t.obj.title)   // tiebreak within same due date
    .limit(20);

final list = await overdue.get();     // one-shot List
final live = overdue.watch();         // Stream<List<CItem<Todo>>>

final openCount = await todos.query().where((t) => !t.obj.done).count();
final byOwner   = await todos.query().groupBy<Atsign>((t) => t.owner);

Queries are immutable values — build once, store, pass, get() or watch(). watch() does incremental delta maintenance for non-paginated queries (single-item read on each event, not a full re-scan). For ad-hoc pipelines outside the builder's vocabulary, getItemsAsStream().where(...) remains supported as an escape hatch.

For typed, introspectable predicates that a future indexed executor can push down to a secondary index, declare PathFields on your domain type and use wherePath:

abstract class $Todo {
  static final done = PathField<bool>(
    path: ['obj', 'done'],
    extract: (item) => (item.obj as Todo).done,
  );
  static final due = PathField<DateTime>(
    path: ['obj', 'due'],
    extract: (item) => (item.obj as Todo).due,
  );
}

final overdue = await todos.query()
    .wherePath($Todo.done.eq(false))
    .wherePath($Todo.due.lt(DateTime.now()))
    .get();

Multi-level parent → children → grandchildren joins use watchWithTree:

final stream = posts.query().watchWithTree([
  SubSpec<Comment>(
    subName: 'comments',
    subDefaultExpiration: const Duration(days: 30),
    subFromJson: Comment.fromJson,
    subTypeTag: 'Comment',
    children: [
      SubSpec<Reply>(
        subName: 'replies',
        subDefaultExpiration: const Duration(days: 30),
        subFromJson: Reply.fromJson,
        subTypeTag: 'Reply',
      ),
    ],
  ),
]);
// → Stream<List<TreeNode<Post>>> — branches['comments'] holds
//   per-comment TreeNodes whose own branches['replies'] hold
//   per-reply TreeNodes.

Timer-driven events for items written with availableAt (scheduled visibility) and expiresAt (TTL):

// Fires when each scheduled item becomes visible.
todos.availableEvents.listen((e) {
  print('Item ${e.id} just became available');
});

// Fires `leadTime` before each item expires — useful for
// reminder UIs that need to nudge the user before the atServer
// expires the record.
todos.expiringSoonEvents(leadTime: const Duration(minutes: 30))
    .listen((e) {
  print('Item ${e.id} expires at ${e.expiresAt}');
});

Read receipts ship built-in — one call on each side, no app-level bookkeeping:

// Reader side: idempotent, no-op on self-owned items.
await incomingItem.markReadByMe();

// Owner side: who has read this? Maintained live via events.
final readers = await myItem.readBy;   // Future<Set<Atsign>>
todos.readReceipts.listen((e) => print('${e.from} read ${e.id}'));

Sub-collections are AtCollection<U> instances scoped to a parent CItem — comments on a blog post, line-items on an invoice — with opt-in cascade-delete:

final comments = posts.subCollection<Comment>(
  parent: post,
  subName: 'comments',
  defaultExpiration: const Duration(days: 30),
  fromJson: Comment.fromJson,
  typeTag: 'Comment',
);
await comments.create(obj: Comment('nice one'), sharedWith: {/* … */});

await posts.delete(post, cascade: true);   // removes comments too

Worked examples (Dart / CLI):

For Flutter, the canonical reference app is ../at_client_flutter/example/todos — same feature set as collections_todos.dart above, rendered through the mobile / desktop widget stack the way a shipping app would use it.

Key-length note. atServer keys are capped at 255 chars and atSigns at 55. The absolute worst-case wire shape is the cached-copy form cached:<other>:<itemId>.<composedNs>@<self>, which fixes the wrapper overhead at 118 chars (cached: + 55 + : + 55) and leaves 137 chars for everything inside (item id + every level of namespace). With 8-char auto-generated ids and 1 char for the separator, composedNs is capped at 128 chars — a budget enforced by subCollection(...) at construction time with a hard ArgumentError, so oversized keys never reach the wire. Plenty of room: with 1-char collection / sub-collection names and a 15-char application namespace, the theoretical ceiling is 11 levels (root + 10 nested sub-collections).

Crypto providers

By default, encrypted writes use the built-in legacy Atsign encryption provider. Apps that need their own encryption configure AtClientPreference.crypto with a CryptoConfig listing one or more CryptoProvider instances. The SDK records the provider's id in the record's appMetadata.providerId and uses it to route future decrypts back to the same provider. appMetadata is the wire field for this: the SDK owns providerId (routing) and isEncrypted; any entries a provider adds to appMetadata.additional are provider-owned, opaque to the SDK, and visible to the atServer as plaintext metadata.

A CryptoProvider is stateless and implements three members — everything it needs arrives per call via CryptoContext (the client) and the AtKey:

class DemoCryptoProvider extends CryptoProvider {
  static const providerId = 'demo-v1';

  @override
  String get id => providerId;

  @override
  Future<String> encrypt(
      CryptoContext context, AtKey atKey, String plaintext) async {
    // The SDK stamps providerId + isEncrypted for you. Carry anything decrypt
    // needs (an IV, a key id, a format version) in appMetadata.additional.
    atKey.metadata.appMetadata =
        AppMetadata(providerId: id, additional: {'format': 'demo'});
    return 'demo:$plaintext';
  }

  @override
  Future<String> decrypt(
      CryptoContext context, AtKey atKey, String ciphertext) async {
    // Read back what encrypt stored, if you need it.
    final _ = atKey.metadata.appMetadata?.additional?['format'];
    return ciphertext.replaceFirst('demo:', '');
  }
}

final preference = AtClientPreference()
  ..crypto = CryptoConfig(
    defaultProviderId: DemoCryptoProvider.providerId,
    providers: [DemoCryptoProvider()],
  );

plaintext/ciphertext are opaque strings — for binary records (putBinary) a Base2e15-encoded string, so treat them as bytes, not text. Throw an AtException subclass (AtEncryptionException / AtDecryptionException) on failure. Supply a fresh provider instance per atSign only if your provider holds per-atSign state.

Per-write provider overrides use PutRequestOptions.cryptoProviderId; notification overrides use NotificationService.send(..., cryptoProviderId:) or NotificationParams.cryptoProviderId. For a runnable end-to-end example, see example/bin/custom_crypto_provider.dart.

For compact examples of provider registration and per-write overrides, see example/bin/custom_crypto_provider.dart, test/at_client_impl_test.dart and test/put_request_test.dart. For notification provider selection, see test/notification_service_test.dart. For lazy-provider recovery behavior, see test/crypto_runtime_test.dart.

Post-quantum cryptography

at_client can run every path an adversary could record today — data shared between atSigns, an atSign's own data, and the secrets an enrollment approval hands a new device — under post-quantum key establishment, and authenticate with a post-quantum signature. It is opt-in per client, through the preference's posture, which is fixed at construction:

final preference = AtClientPreference(posture: PqPosture.pqActive)
  ..namespace = 'todos';

A new app names pqActive. An app built on 3.x moves in two steps, as the rollout ladder describes.

The goals

  • Close the harvest-now, decrypt-later hole. Anything recorded off the wire or off an atServer today — shared records, an atSign's own records, and the secrets handed to a newly approved device — is established under post-quantum key exchange, so a quantum computer later cannot open it. Authentication moves to a post-quantum signature for the same reason.
  • Lose nothing. Every capability the SDK has — encrypt for another atSign, encrypt for another client of your own atSign, enroll a device, revoke one — works the same way under post-quantum keys.
  • Every app upgrades on its own schedule. No flag day. Each record carries the id of the scheme that wrote it, a client keeps every older scheme it ever read, and a writer only ever uses a scheme every reader of that record supports. Upgrading adds read capability and takes nothing away.
  • Crypto-agility. Algorithms are named, not assumed: the X-Wing hybrid today, pure ML-KEM-1024 beside it, and whatever comes next drops in as another provider without a migration of stored data.

The rollout ladder, and why it is a ladder

The default posture moves one stage at a time: to pqReady in a 4.x release after 4.0.0, and to pqActive at 5.0.0. An app that names no posture rides the default; an app can name a later stage at any time, or name legacy to stay put.

at_client Default posture A client that names no posture
3.x, 4.0 PqPosture.legacy Byte-for-byte the pre-posture SDK: RSA authentication, legacy encryption, no post-quantum startup, reads no post-quantum data
later 4.x PqPosture.pqReady ML-DSA-65 authentication, publishes its key package and namespace keys, reads post-quantum data, still writes legacy so every peer can read it
5.x PqPosture.pqActive Writes post-quantum by default and refuses a legacy write

The order follows from the one invariant above: a record may only be written in a scheme every reader of it supports. So the whole population of clients has to be able to read post-quantum data (pqReady) before any client writes it by default (pqActive), and the stage that adds the reading has to be rolled out before the stage that changes the writing. Two stages give every app one release to become a reader and another to become a writer, with the SDK's default carrying apps that never think about it. Legacy key material is still minted at every stage, because when an atSign can stop holding it is a question about every client of that atSign, not about one build.

You do not have to wait for the default to move. The stages are postures, and a posture is a construction-time choice, so an app names the stage it wants:

// Reads post-quantum data, authenticates with ML-DSA-65, writes legacy
// until every reader of your namespaces has done the same.
final ready = AtClientPreference(posture: PqPosture.pqReady)..namespace = 'todos';

// Writes post-quantum by default and refuses legacy writes: for a
// deployment whose every client already runs pqReady or later.
final active = AtClientPreference(posture: PqPosture.pqActive)..namespace = 'todos';

An app built on 3.x, whose data is legacy-encrypted, takes two steps: rebuild it with PqPosture.pqReady and roll that out to every client, then rebuild it with PqPosture.pqActive and roll that out. A new app has no legacy readers and starts at pqActive. A posture is a floor for what a client drives, never a downgrade of what its atSign already holds.

A client also has a floor of its own, and runs at the higher of the two: a keyfile holding a namespace key's private sets pqReady, and an active ML-DSA-65 data signing key sets pqActive. A client configured below its floor logs a warning and is raised to it on authentication, the data signing key, and reading and writing post-quantum. It seeds no namespace keys and mints no signing root that its configured posture would not, and its other settings, disallowLegacyEncryption among them, stay as configured.

Posture Authentication Data written Reads post-quantum data
PqPosture.legacy (default) RSA-2048 APKAM legacy encryption no: a record sealed to a namespace key is refused
PqPosture.pqReady ML-DSA-65 APKAM; publishes a key package and the atSign's namespace keys legacy encryption, so pre-quantum peers read it yes
PqPosture.pqActive ML-DSA-65 APKAM and an ML-DSA-65 data signing key post-quantum by default; legacy writes refused yes

What each posture switches on:

  • The nskey data path. Each namespace an atSign owns gets a key-establishment keypair, published as an APKAM-signed advertisement (public:__nskey.<namespace>@<atSign>). A writer establishes a content key to the recipient's namespace key and encrypts the record with AES-256-GCM; a reader that holds the namespace key's private half opens it. A sender follows whatever the recipient advertised, ordered by AtClientPreference.sealsToKeyAlgorithms. The X-Wing hybrid (ML-KEM-768 + X25519) is the default; pure ML-KEM-1024 is selected with keyEstablishmentAlgorithms, and every build opens both.
  • Writing in an atSign's own namespace. Each value is encrypted under a key of its own, derived from the content key and a fresh salt. A client writes in its own namespace once it holds that namespace key's private half. A write that finds it missing first fetches what was conveyed to this client, and only then throws NskeyPrivateNotHeldException; ensureReachable reports holdsPrivate.
  • Sharing. A share is sealed to the most specific namespace key the recipient advertises for the key's whole name: x.notes.todos.demos goes to the recipient's notes.todos.demos key if it has one, else its todos.demos key, else its demos key. The levels are looked up in one batch. A found key is reused for 15 minutes, and a level found missing is not asked about again for 30 seconds. PutRequestOptions.sealToNamespace, or sealToNamespace on a notification, requires one namespace instead, and CryptoRuntime(atClient).canSealTo(recipient, namespace) says whether the recipient advertises a key there, answering false with a warning when it cannot tell. When nothing fits, the share throws NamespaceKeyUnavailableException, or uses legacy encryption where allowLegacyCryptoFallback allows; it is never sealed to a broader key than a required namespace. A program serving a mixed fleet, such as daemons only some of which advertise <device>.sshnp, runs PqPosture.pqActive.copyWith(disallowLegacyEncryption: false) with allowLegacyCryptoFallback and requires each daemon's namespace. It can instead run pqReady with allowLegacyCryptoFallback, ask canSealTo, and send with sealToNamespace where the answer is true. A share with sealToNamespace and no cryptoProviderId is sealed with the namespace-key provider, symmetricAesGcmCryptoProviderId, wherever the client's config registers it, as pqReady and above do, and is refused, naming the posture or the config, where it does not.
  • A write right after opening. A client's start-up publishes its namespace keys after open returns. A write to the client's own atSign that finds no key while start-up is still to publish one for its namespace waits for it, up to 30 seconds, then throws NamespaceKeyUnavailableException naming the key, with start-up's reason when it could not publish it. A write made with no atServer reachable does not wait, and nor does a share. A write that finds the key published but holds no private for it, while start-up is yet to ask, or is asking, the atSign's other enrollments for that private, waits up to 30 seconds in all for the answer, then throws NskeyPrivateNotHeldException.
  • Who mints a namespace key. Only an enrollment carrying a key package mints one, and it conveys the private to the namespace's other enrollments before publishing it. A client on the primary enrollment mints nothing, and ensureReachable answers AtReachability.noKeyPackage.
  • Rotation. A content key is replaced once it is a year old, and a namespace key once its generation is a year old; CryptoConfig's ckRotationPolicy and nskeyRotationPolicy change that. A revocation rotates the namespace keys the revoked enrollment held, and a content key a client shares is replaced by a fresh one on its first write after it holds the new generation.
  • OpenSSL. On macOS (Homebrew) and Linux, where OpenSSL 3's libcrypto is installed, or wherever AT_CHOPS_LIBCRYPTO_PATH points, value AES-256-GCM runs through it, and X-Wing does too with OpenSSL 3.5 or later. Elsewhere both are pure Dart, and each opens what the other wrote.
  • Post-quantum enrollment. Under pqReady or pqActive, Atsign.enroll submits a request that advertises a key package, and the approving client (client.enrollments.approve) seals the atSign's secrets to it instead of wrapping them with RSA; enroll(keyExchangeMode: ...) overrides that per request. A legacy client cannot approve such a request and refuses before anything reaches the atServer.
  • ML-DSA-65 authentication. The enrollment's APKAM keypair is ML-DSA-65, filed as typed material in the keys store while the flat legacy fields are left as they were. A client whose posture asks for a stronger authentication key than its enrollment holds re-enrolls itself at its first start and comes up on the new enrollment. This needs an atServer that verifies ML-DSA signatures; a legacy client makes no such demand unless its key material raises it to a floor. The new enrollment receives no namespace keys from an approval, so it asks the atSign's other enrollments for them, and only one online at the time can answer. On an atSign that has published any namespace key, enroll a device at the posture its app runs at, pqReady or above, so that its approval conveys the namespace keys.
  • Signed advertisements and key packages. Everything a peer relies on is carried in a signed envelope and verified before use; the signing keys chain to a per-atSign signing root that only fully privileged enrollments hold.

atServer version. Minting a namespace key or a signing root needs atServer 3.16.2 or later. Each mint takes a lock that its ttl releases: two minutes for a namespace key, 15 seconds for the root. An older atServer keeps the lock until its expiry sweep, up to about ten and a half minutes, and refuses another mint or rotation of the same key until then. The client doesn't check the version.

pqActive is for a deployment that controls every client of its namespaces and has seeded them: a destination with no published namespace key is refused rather than written with the legacy provider. Set preference.namespace under it: a client whose enrollment is granted * holds a namespace key only for that namespace, so with none set it has none to write with. A device's first write in a namespace needs the atServer, which seeds or conveys that namespace's key, and a share needs it whenever the client holds no current copy of the recipient's key. Once a device has written in a namespace, it goes on writing there offline, across restarts. The design, the acceptance catalogue and the decision log are under docs/projects/pq/.

Known traps in sync

Each of these is about sync pushing this device's own queued writes to its atServer, in 4.0.x.

  • A write the atServer refuses for good leaves the queue. A refusal as unauthorized, for an immutable record, an invalid key, an illegal argument or invalid syntax, or with a code at_commons does not list, drops the write with a severe log line, so the writes behind it still push. The device keeps its local change, which the atServer does not have, until a pull brings a change to that key. Nothing else tells the app: no sync result or progress event names the write, and isInSync() can then return true.
  • The atServer, not the device's access check, has the last word. A write the device allows and the atServer refuses as unauthorized is dropped as above.
  • A write refused any other way stays queued and is sent with every batch. That is a refusal with any other code, a write the atServer leaves out of its reply, or an answer sync cannot read. Held writes that fill a whole batch at the head of the queue hold up every write behind them, round after round.
  • A queued write whose record this device cannot read back stops each round when sync reaches it, so no write behind it pushes.
  • A batch that fails as a whole ends the round. Sync tries again the next time it runs (after a write, a notification, an app's sync() or its 30-second timer), with no backoff.
  • A held write keeps sync from catching up. While it stays queued, SyncService.isInSync() returns false and SyncService.waitUntilCaughtUp() does not complete, so pass it a timeout.

Planned for 4.1.x

Planned, not promised:

  • A failure that may pass holds the write at the head of the queue, and the next push waits 5, 8, 13, 21, 34 and 55 seconds, then 60, with up to a tenth of random jitter. Pulls back off the same way, separately.
  • After a batch fails as a whole, sync sends one write per batch until one is answered, so the write at fault is named.
  • A write sync cannot send, such as one whose record this device cannot read back, leaves the queue with a severe log line.
  • Sync events an app can listen to, the write a failure holds, and a call to discard a queued write while the device keeps its copy.
  • Push batches sized in bytes, within the atServer's inbound buffer.
  • AtClient.put measuring a value in UTF-8 bytes, as sync will push it.

Further reading

Libraries

at_client
at_client_io
at_client for a native Dart program: everything in at_client.dart, plus the pieces that need dart:io — keys in a file, connections over TLS on TCP, and the atDirectory asked over TLS.
at_client_mixins
hive
The Hive-backed AtClientStorage, the durable store native apps use. A separate import, like each storage backend, so an app names the backend it chooses.
memory
The in-memory AtClientStorage, in pure Dart: nothing reaches disk, and it needs no native library. A separate import, like each storage backend.
sqlite
The SQLite-backed AtClientStorage. A separate import so package:at_client/at_client.dart carries no SQLite dependency. A SQLite database held in memory is SqliteAtClientStorage.inMemoryPath; the pure-Dart in-memory store is package:at_client/memory.dart.