keybay on macOS

Browse documentation

macOS has two schemes. The library picks one automatically — once per process, deterministically, and never by silently degrading.

How the choice is made

On first use the library derives the first authorized access group from the signed process entitlements, then verifies it with a tiny explicitly scoped write to the Data Protection keychain:

  • Success → the app is signed and carries the Keychain Sharing entitlement → native items.
  • errSecMissingEntitlement (−34018) → the normal result for a plain CLI or dart run → the encrypted file.
  • Any other error → it throws. A misconfigured entitlement is surfaced loudly, never quietly downgraded.

Entitlements are baked into the code signature, so the outcome is fixed per binary and cached for the process. The probe writes to a dedicated internal service (outside the appId grammar) in that exact group, so it can never collide with or delete a caller secret.

Gaining the entitlement between versions moves the store. Switching from a CLI/unentitled build to an entitled one changes the resolved scheme from the encrypted file to native items — physically different places. The encrypted file leaves its own on-disk trace (the container), so rather than silently present an empty store and strand those secrets, an entitled resolve that finds a pre-existing ~/Library/Application Support/<appId>/secrets.enc throws a typed MigrationRequired (from: encryptedFile, to: nativeItems). Migrate the secrets across, then remove that file (or the directory) to proceed.

Losing or changing the entitlement is also loud. Before the first native mutation, Keybay records a private, non-secret .scheme marker beside the possible file-store location. It contains only the native scheme and encoded access-group identity. A later unentitled resolver throws MigrationRequired instead of opening a fresh file store; a different access group throws KeychainAccessGroupChanged. The marker is retained after deletion so a signing change cannot silently revive an abandoned namespace. Removing it is therefore an explicit migration/reset action, not automatic cleanup.

The marker protects native use observed by marker-aware Keybay versions. An older entitled installation should run one marker-aware build before its entitlement or access group changes; changing both at the upgrade boundary leaves no information an unentitled process can use to discover the older OS-walled items and requires an explicit application migration decision.

Signed apps (entitled)

Each secret is a native item in the Data Protection Keychain. There is no Keybay secret container or separate Keybay store key on this path; only the non-secret scheme marker above. Every operation includes the one derived access group explicitly. Items use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and are non-synchronizing; updates reassert both attributes rather than inheriting a colliding pre-existing item’s policy.

What this policy means. The item does not migrate to a different device. AfterFirstUnlock remains compatible with background work: after the first unlock following a reboot, it remains accessible when the machine relocks. Keybay does not add biometric or current-unlock gating.

describe().scheme reports nativeItems. describe().level is null: Keybay applies the documented Data Protection Keychain policy but does not infer or attest a hardware-backing level for the stored items.

Validation. The refusal path (−34018 → the file scheme, with nothing written as a fallback) is CI-tested on every push. The success path needs a signed, provisioned bundle CI can’t produce; it is validated end-to-end by the example_flutter/ host app (Keychain Sharing + a development team → the resolver picks native items and completes a round trip). That leg is local — the repeatable recipe is tool/dp_keychain_verification.md. Its security properties, lifecycle extensions, and report contract are tracked as the macOS device-suite scenarios.

Command-line and unentitled

Every secret lives in one authenticated encrypted file at ~/Library/Application Support/<appId>/secrets.enc (mode 0600, written atomically). The file is sealed with XChaCha20-Poly1305 under an HKDF-SHA256-derived key with a key-commitment header (a wrong key fails closed before decryption, distinct from tampering). The 32-byte file key is stored in the login Keychain via the SecItem API. Keybay writes no plaintext copy of that key beside the container; the operating system owns how the credential store persists it.

First creation is insert-only: Keybay uses atomic SecItemAdd, and a racing writer adopts the already-created Keychain value instead of overwriting it. This matters when the same appId is reached from sandboxed and unsandboxed processes whose container roots differ; both containers remain sealed under the one winning Keychain key.

What this resists. The file key sits in the login Keychain under a login-password-derived key: safe from other local users and casual theft. Against a stolen disk it is only as strong as the login password. The authenticated container adds tamper detection and separates the portable data file from its key; it does not turn a login-bound key into hardware protection.

Validation. Real login-Keychain round-trips run in CI on every push; the file scheme is additionally exercised inside a real sandboxed .app by the example_flutter/ harness. Destructive and lifecycle qualification remains separate in the macOS device-suite scenarios.

Know your trust unit

Keychain ACLs bind to the acting binary. Under dart run that binary is the shared Dart VM, so one “Always Allow” authorizes every Dart program you run to read the item silently. For production, dart compile exe and sign with a stable Developer ID — the ACL then binds to your app and survives upgrades. A locked keychain (SSH, CI) surfaces as a typed error rather than hanging on a GUI unlock prompt.