at_client 4.0.0
at_client: ^4.0.0 copied to clipboard
The at_client library is the non-platform specific Client SDK which provides the essential methods for building an app using the atProtocol.
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 aFuture<AtCollection<T>>.fromJsonandtypeTagtravel 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 atServerput(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'senableRequestMutexuses 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.mdwalks through every program inexample/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 ofAtCollection<T>— every common collection pattern (typedAtCollection, 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 totodos,dockerstatsis 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-applyingAtCollection<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:
- 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.)
- Onboard the atSign exactly once: CRAM-authenticate, generate the
master keypairs, and write them to disk (
.atKeysfile 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. - 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):
example/bin/collections_primitives.dartexample/bin/collections_domain_objects.dartexample/bin/collections_generic.dart(polymorphic types)example/bin/collections_binary.dart(Uint8List)example/bin/collections_subcollections.dart(parent + sub with cascade)example/bin/collections_todos.dart(full interactive TUI)
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
nskeydata 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 byAtClientPreference.sealsToKeyAlgorithms. The X-Wing hybrid (ML-KEM-768 + X25519) is the default; pure ML-KEM-1024 is selected withkeyEstablishmentAlgorithms, 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;ensureReachablereportsholdsPrivate. - Sharing. A share is sealed to the most specific namespace key the
recipient advertises for the key's whole name:
x.notes.todos.demosgoes to the recipient'snotes.todos.demoskey if it has one, else itstodos.demoskey, else itsdemoskey. 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, orsealToNamespaceon a notification, requires one namespace instead, andCryptoRuntime(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 throwsNamespaceKeyUnavailableException, or uses legacy encryption whereallowLegacyCryptoFallbackallows; 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, runsPqPosture.pqActive.copyWith(disallowLegacyEncryption: false)withallowLegacyCryptoFallbackand requires each daemon's namespace. It can instead runpqReadywithallowLegacyCryptoFallback, askcanSealTo, and send withsealToNamespacewhere the answer is true. A share withsealToNamespaceand nocryptoProviderIdis sealed with the namespace-key provider,symmetricAesGcmCryptoProviderId, wherever the client's config registers it, aspqReadyand 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
openreturns. 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 throwsNamespaceKeyUnavailableExceptionnaming 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 throwsNskeyPrivateNotHeldException. - 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
ensureReachableanswersAtReachability.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'sckRotationPolicyandnskeyRotationPolicychange 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_PATHpoints, 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
pqReadyorpqActive,Atsign.enrollsubmits 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. Alegacyclient 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
legacyclient 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,pqReadyor 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 andSyncService.waitUntilCaughtUp()does not complete, so pass it atimeout.
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.putmeasuring a value in UTF-8 bytes, as sync will push it.