Dart and Flutter SDK

Browse documentation

Keybay gives each host application one encrypted local store. Requires Dart 3.11+; Flutter is an optional native host, not an SDK dependency.

Security · Architecture · Passkey construction · External review

Format compatibility: V1 stores are not read, migrated or deleted. 0.3.0 also rejects retired V2 singleton-passphrase packages and 0.2.0 vaults with a passphrase or passkey (methods policy 2), without changing them. To keep such a vault, remove its methods with 0.2.0, upgrade, then add them again. Platform-only vaults are unaffected. See the changelog before upgrading.

Open, use, close

import 'package:keybay/keybay.dart';

final session = await Keybay.open();
try {
  await session.set('service/api-token', 's3cr3t');
  final token = await session.get('service/api-token');
  final present = await session.contains('service/api-token');
  final names = await session.listKeys();
  await session.delete('service/api-token');
} finally {
  await session.close();
}
  • open() initializes fully absent state with platform protection.
  • open(credential: ...) authenticates an existing store; it never enrolls. With no live file or staging state, it returns storeNotFound before platform or credential access. Enroll through session.auth.add before writing values that should require the added protection.
  • A retained platform root without a complete file returns storeStateConflict on initialization. Do not automatically reset; see recovery.
  • close() rejects new work, drains accepted operations and clears the owned store-key buffer. Keep sessions open only as long as needed.

Open, auth changes and reset may invoke trusted provider UI. Record operations and auth.list() never prompt or acquire a provider, even on failure.

Operation Behavior
get / set String values.
getBytes / setBytes Binary values; returned buffers belong to the caller.
getManyBytes Exact requested records from one authenticated generation.
listKeys Authenticated names without decrypting record values.
clearAll Remove records, keep protection.
Keybay.reset() Delete current store, staging and deletable provider state; retain coordination locks.

Reset does not initialize a replacement. The next open creates a fresh store key. Flatpak retains its portal secret; restoring an old complete snapshot can restore access under that snapshot’s policy.

Application identity

The API has no application-ID, path, provider or store selector. iOS, Android and entitled macOS use platform-authenticated identity. Flatpak uses sandbox facts from /.flatpak-info. Ordinary Dart executables on Linux and unentitled macOS declare a stable namespace in the pubspec owning their entrypoint:

keybay:
  application_id: com.example.my_app

This prevents accidental collisions, not access by another same-user program. Additional passphrase or hardware protection makes platform/file access alone insufficient.

dart run resolves the owning pubspec. Embed the declaration for AOT builds:

dart run keybay:keybay_compile bin/app.dart -o build/app

Use --aot-snapshot before the entrypoint for a separate AOT module. Hardened macOS distribution signs that module and a dedicated dartaotruntime with the same team; see macOS packaging. Recognized dart install bundles retain their declaration, checked against any embedded value.

The generated desktop package:test entrypoint has no resolvable application identity and fails with applicationIdentityUnavailable. Use a declared dart run integration program or native Flutter runner; there is no test override. Flutter hosts also need platform integration, including KeybayApplicationIdentifier in the processed iOS Info.plist.

Add passphrase protection

Protection is platform AND (passphrase OR passkey A OR passkey B ...). A store allows one passphrase and eight total methods. Adding a second passphrase fails with authMethodAlreadyConfigured.

final session = await Keybay.open();
try {
  final phrase = await readPassphraseBytes(); // Your app's input UI.
  final adding = session.auth.add(PassphraseCredential(phrase: phrase));
  phrase.fillRange(0, phrase.length, 0);
  final method = await adding;
} finally {
  await session.close();
}

Operations synchronously copy caller-owned passphrase/PIN bytes and clear that copy when they settle. Clear your input buffers after submitting the call. Prefer bytes from your input layer: immutable Dart strings and VM/OS copies cannot be reliably erased.

After enrollment, credential-free open returns authRequired. To reopen:

final phrase = await readPassphraseBytes();
final opening = Keybay.open(credential: PassphraseCredential(phrase: phrase));
phrase.fillRange(0, phrase.length, 0);
final session = await opening;
try {
  final token = await session.get('service/api-token');
} finally {
  await session.close();
}

To change a passphrase, remove the old method and add a new one:

final methods = await session.auth.list();
await session.auth.remove(methods.whereType<PassphraseMethod>().single);
await session.auth.add(PassphraseCredential(phrase: newPhraseBytes));

These are separate commits. Removing the last method returns to platform-only protection, which persists if adding fails. There is no update/replace. Auth changes rotate the store key and re-encrypt records; they do not revoke older snapshots, issuer tokens or external backups. To fully revoke a compromised credential on Linux or unentitled macOS, remove every method and add back the trusted ones; see security levels.

Add passkey protection

Keybay owns the Keypass client, temporary secret, derivation and authenticated records. Your app supplies credential data and UI; constructing a credential does not start a ceremony.

const systemPasskey = PasskeyCredential.system(rpId: 'vault.example.com');
final session = await Keybay.open();
try {
  await session.auth.add(systemPasskey, label: 'Personal vault');
  await session.set('service/api-token', 's3cr3t');
} finally {
  await session.close();
}

For an already protected store, open with its existing credential first. Later, Keybay.open(credential: systemPasskey) authenticates the saved method; opening never creates a passkey.

For a physical FIDO2 key:

final pin = await readHardwarePinBytes();
final adding = session.auth.add(
  PasskeyCredential.hardware(rpId: 'dev.example.vault', pin: pin),
  label: 'Backup key',
);
pin.fillRange(0, pin.length, 0);
final backup = await adding;
Credential field Meaning
rpId Required scope: associated domain for system passkeys, stable DNS-shaped namespace for direct hardware.
displayName Optional provider text; defaults to the RP ID.
pin Hardware-only UTF-8 bytes for this attempt; optional, never persisted.

The RP ID is not an endpoint, unique credential ID or Keybay application ID. Direct hardware needs no website and does not authenticate the calling executable. Matching RP IDs do not make separate enrollments interchangeable.

A supplied PIN must be 4–63 UTF-8 bytes with no NUL; the key also applies its own policy. Omit it if verification happens on the key/platform. When a PIN is needed, Keybay returns passkeyOperationFailed with passkeyCode == pinRequired. Ask for input and retry deliberately; rejected PINs are never retried automatically.

Credentials have no callbacks. A sole hardware connection is selected; multiple candidates return deviceSelectionRequired. Disconnect other keys or readers before retrying. Keypass itself supports richer connection/event UI.

Select and manage methods

Keybay.open(credential: credential, methodId: selectedId);
session.auth.add(credential, label: 'Everyday key');
session.auth.list();
session.auth.remove(method);

Every add creates a fresh method ID. Optional labels default to Passphrase or Keybay vault. auth.list() returns immutable, authenticated descriptors with id and label; PasskeyMethod also has rpId and route. Descriptors contain no credential material and can be removed after reopening that same vault:

final methods = await session.auth.list();
final selected = await chooseMethod(methods); // Your app's selection UI.
await session.auth.remove(selected);

Removal checks vault identity and method ID, not object identity. Foreign, reset-vault and already removed descriptors return authMethodNotConfigured. Removing the last method leaves platform-only protection.

For passkey unlock, selection happens before prompting:

Request Result
Explicit ID matches kind, RP ID and route Unlock exactly that method.
Explicit ID is absent authMethodNotConfigured.
Explicit ID has another kind, RP ID or route protectionMismatch.
No ID; one matching method Unlock it.
No ID; no matching method protectionMismatch.
No ID; several matching methods authMethodSelectionRequired with compatible hints.
final session = await Keybay.open(
  credential: PasskeyCredential.hardware(
    rpId: 'dev.example.vault',
    pin: enteredPinBytes,
  ),
  methodId: backup.id,
);

Keybay requests the selected record’s exact credential. It does not try every passkey, and labels do not select methods. Keep your expected RP configuration in the application.

For replacement keys, add before removing when capacity allows:

await session.auth.add(
  PasskeyCredential.hardware(rpId: 'dev.example.vault', pin: newKeyPinBytes),
  label: 'New everyday key',
);
await session.auth.remove(backup);

Each auth change commits independently. An I/O error after file replacement may mean it committed: reopen and inspect before retrying. Registration can leave an orphan provider credential if local persistence fails. Removal does not delete a provider credential or revoke older snapshots.

Authentication errors and hints

Catch KeybayException and branch on code, not message text. Credential-free open of a protected store returns authRequired without a passkey ceremony, though platform-root access may invoke OS UI.

authRequired and authMethodSelectionRequired can carry authMethods hints. These are authenticated by the platform package, not yet by the store’s credential-protected manifest. Use them for display/selection only, never to grant access, change policy or replace expected RP configuration. After opening, auth.list() supplies the authenticated list.

Passkey failures use passkeyOperationFailed and a redacted passkeyCode, including cancelled, missing host/PRF/credential, PIN errors and verification failure. They do not expose raw provider data or secrets. Failure does not trigger fallback; offer another enrolled method for the user to choose.

Cancellation and native setup

open and auth.add accept an optional PasskeyCancellation:

final cancellation = PasskeyCancellation();
final opening = Keybay.open(
  credential: const PasskeyCredential.system(rpId: 'vault.example.com'),
  cancellation: cancellation,
);
// Connect a Cancel control to cancellation.cancel().
final session = await opening;
try {
  // Use the session, or close it if the initiating screen has gone.
} finally {
  await session.close();
}

Cancel, await settlement and close any successful session even if its screen has gone. Future.timeout alone does not cancel native work. Cancellation cannot undo an already committed auth change. Keybay clears its temporary Keypass/derived material; your input copies and returned values remain yours.

Desktop Dart builds use Keypass build hooks. Install their native build prerequisites even for passphrase-only apps. Build on the target OS/architecture and distribute the complete bundle and notices. The hook does not qualify Flutter desktop packaging.

Keypass native setup is additional to Keybay’s platform setup:

  • System: Apple associated domain/AASA and native host; Android Digital Asset Links and Activity integration. The OS presents the provider dialog.
  • Hardware: desktop USB adapter or phone NFC/USB adapter and permissions. No website, association file or Keypass API key.

Support depends on the native host, transport and provider’s PRF capability. A Dart dependency alone cannot configure signing, libraries or permissions. Passkeys do not replace the platform root or make vaults portable by themselves.

Errors and limits

Error Response
storeNotFound Credential-based open found no live file or staging state. No provider/root access or enrollment occurred; a root may still exist. Offer explicit first-use setup.
storeStateConflict Files/provider state cannot safely open or initialize. Investigate; reset only when deliberate data loss is acceptable.
staleSession / storeAuthenticationFailed Close and attempt authenticated reopen. Failure is not permission to erase state.
storeBusy Retry with bounded backoff within the application’s deadline.
sessionClosed Open a new session.

A retained root after interrupted initialization or an Apple reinstall/restore can cause storeStateConflict; the error does not prove its cause. Deliberate recovery is Keybay.reset() followed by open. Never reset automatically on conflict or authentication failure. Physical restore qualification remains incomplete.

Local invalidation reports staleSession. Cross-process rotation, an operation already running during rotation, or damaged/replaced data may instead report storeAuthenticationFailed. Locks have a one-second acquisition deadline; provider UI or Argon2 may hold them longer.

Keys are 1–120 ASCII characters in slash-separated segments matching [A-Za-z0-9][A-Za-z0-9._-]*; empty, . and .. segments are invalid. Slashes organize names, not security domains.

Platform summary

Host Application boundary File Platform root
iOS signed application group app-private Application Support, excluded from backup Data Protection Keychain, WhenUnlockedThisDeviceOnly, non-syncing
Android 12+ package/UID sandbox app-private noBackupFilesDir one non-exportable Android Keystore AES-GCM key
entitled macOS signed application group; file isolation depends on App Sandbox app container or derived Application Support Data Protection Keychain, exact signed group
unentitled macOS / CLI declared namespace only derived Application Support directory one item in the explicit login Keychain
ordinary Linux desktop declared namespace only derived XDG data directory one item in unlocked Secret Service

See iOS, Android, macOS and Linux for setup. The Flatpak candidate uses sandbox identity and XDG Secret Portal; it never falls back to Secret Service. Snap, Windows and unsupported providers fail closed. The qualification report records observed configurations and remaining work.