Skip to main content

maktub_passkey 0.1.0-dev.7 copy "maktub_passkey: ^0.1.0-dev.7" to clipboard
maktub_passkey: ^0.1.0-dev.7 copied to clipboard

WebAuthn passkeys with the PRF (hmac-secret) extension for Flutter: derive a stable per-credential secret from a synced passkey on iOS, macOS and Android.

maktub_passkey #

pub package pub points likes CI License: MIT

Passkey (WebAuthn / P-256) create + assert for Flutter, plus the WebAuthn PRF (hmac-secret) extension — so you can derive a stable, per-credential secret from a synced passkey and reproduce it on another of the user's devices (e.g. to re-derive an encryption key after a device change). It's the PRF API the common Flutter passkey plugins don't expose.

⚠️ EXPERIMENTAL — pre-release (0.1.x-dev). The native iOS/macOS (AuthenticationServices) and Android (androidx.credentials) PRF implementations are in place and unit/simulator-tested, but the stability of the PRF output across two synced devices — the property that the same secret comes back on a second device — is not yet verified on real hardware. The plugin is fail-closed: where PRF is unavailable, device-bound, or not synced, it reports the credential as unrecoverable rather than returning a secret that can't be reproduced. Validate cross-device recovery on your own synced devices before relying on it. Because it's a pre-release, ^ version constraints won't auto-select it — opt in explicitly.

When to use this #

Reach for this when you need a stable secret derived from a passkey that reproduces across a user's synced devices — e.g. re-deriving an end-to-end encryption key on a new phone with no server-side escrow. That cross-device reproducibility is the WebAuthn PRF (hmac-secret) capability, and it's the specific gap this plugin fills.

It is not a passkey login library. If you only need authentication (register / sign-in against your server), use a full WebAuthn stack instead — this plugin deliberately exposes just the create / assert / PRF primitives needed to derive and reproduce key material, paired with a fail-closed recoverability gate.

  • Use it when you want a passkey-derived, cross-device-reproducible secret and can target iOS 18+ / Android API 28+.
  • Look elsewhere when you need passkey auth flows, device-bound (non-synced) hardware keys, or support for older OS versions.

What it does #

  • create a platform passkey with the PRF extension enabled at creation (PRF can't be retrofitted onto a credential made without it).
  • assertWithPrf — get an assertion and evaluate PRF with a caller-chosen 32-byte salt, returning the 32-byte PRF output, the credential's backup flags, and the credentialId / userHandle the platform actually used — so a discoverable assertion can learn which passkey the user picked and bind later recovery to it (since 0.1.0-dev.3).
  • probePrf — a capability check (is PRF available, is the credential backup-eligible and synced?).

The 32-byte output is uniform key material — feed it into your own KDF (HKDF, etc.) to derive whatever keys you need. The same (credential, salt) yields the same output, which is what makes the derived key reproducible on a synced device.

API #

Method Returns Errors / null
probePrf({relyingPartyId}) PrfCapability — prfSupported (backup flags read false here: they're per-credential) never throws; fails closed to unavailable
create({relyingPartyId, relyingPartyName, userName, userId, challenge}) PasskeyCreation — credentialId, attestationObject, capability throws MaktubPasskeyException
assertWithPrf({relyingPartyId, challenge, prfSalt, credentialId?}) PasskeyAssertion — prfOutput, signature, credentialId, userHandle, backupEligible, backupState, recoverable throws MaktubPasskeyException; prfOutput is null if PRF is unavailable

All byte params are Uint8List; ids and handles are base64url String. prfSalt is your fixed 32-byte salt — the same (credential, salt) always yields the same 32-byte prfOutput. Omit credentialId for a discoverable assertion (the platform lists every RP passkey and the user picks); pass it for a targeted one. Always gate on the assertion's recoverable (PRF output AND BE AND BS) before relying on a secret.

MaktubPasskeyException.code is the same on every platform: user-cancelled, auth-error (the platform ceremony failed; details in message), prf-unsupported, no-activity (nothing on screen to present on), bad-args, unexpected-credential, null-result, not-implemented.

Platform support #

Platform Min version Backing API
iOS 18.0+ AuthenticationServices (ASAuthorizationPublicKeyCredentialPRF*)
macOS 15.0+ AuthenticationServices — same API and source as iOS
Android API 28+ (Credential Manager) androidx.credentials prf extension

Below those versions, or on the simulator/emulator (no real authenticator), PRF is reported unavailable and the plugin fails closed.

Requires Flutter 3.44+ / Dart 3.12+. On Android the plugin uses built-in Kotlin (it does not apply the Kotlin Gradle Plugin): it builds in apps with android.builtInKotlin=false on Flutter 3.44+, and with android.builtInKotlin=true on Flutter 3.47+ (Flutter's own minimum for that setting).

Install #

dependencies:
  maktub_passkey: 0.1.0-dev.5   # pre-release: pin explicitly (no caret — won't auto-adopt)

Platform setup (required) #

Passkeys are bound to a domain: the platform refuses to create or assert one unless your app is verifiably associated with the relyingPartyId you pass. Skip this and calls fail (or the system sheet never appears) — it is not optional. The relyingPartyId must be a registrable HTTPS domain (e.g. example.com — no scheme, no port) and must match the files below.

iOS and macOS — Associated Domains #

  1. Add the Associated Domains capability in Xcode and list your RP id. On macOS it goes in both DebugProfile.entitlements and Release.entitlements, and the app must be signed with a provisioning profile that carries the capability — an ad-hoc signed build with this entitlement is refused at launch.

    <!-- Runner.entitlements -->
    <key>com.apple.developer.associated-domains</key>
    <array>
      <string>webcredentials:example.com</string>
    </array>
    
  2. Host an Apple App Site Association file at https://example.com/.well-known/apple-app-site-association, served as application/json with no redirect:

    { "webcredentials": { "apps": ["ABCDE12345.com.example.app"] } }
    

    ABCDE12345 is your Team ID, com.example.app your bundle id.

Host an assetlinks.json at https://example.com/.well-known/assetlinks.json declaring your app and the certificate(s) it is signed with:

[
  {
    "relation": ["delegate_permission/common.get_login_creds"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": ["AB:CD:EF:..."]
    }
  }
]

List the SHA-256 fingerprint of every signing key your users receive — your upload key and the Play App Signing key (Google re-signs the app on upload), or verification fails in production.

Usage #

import 'package:maktub_passkey/maktub_passkey.dart';

final pk = MaktubPasskey();
const rpId = 'example.com'; // your associated domain

// 1. Capability check — can this OS do PRF at all? (Whether a credential is
//    synced is per-credential: it's only known from an assertion, step 3.)
final cap = await pk.probePrf(relyingPartyId: rpId);
if (!cap.prfSupported) {
  // Fall back: no PRF on this platform/OS version.
}

// 2. Create a credential WITH PRF enabled.
final created = await pk.create(
  relyingPartyId: rpId,
  relyingPartyName: 'Example',
  userName: 'you@example.com',
  userId: userIdBytes,        // Uint8List
  challenge: challengeBytes,  // Uint8List
);

// 3. Sign AND evaluate PRF with a fixed salt → 32-byte output.
final a = await pk.assertWithPrf(
  relyingPartyId: rpId,
  challenge: challengeBytes,
  prfSalt: saltBytes,         // your fixed 32-byte salt
  credentialId: created.credentialId, // omit (null) for a discoverable assertion
);
// 4. Gate on THIS assertion: PRF output present AND the credential is synced
//    (BE ∧ BS from its authenticator data). Otherwise the secret wouldn't come
//    back on another device — don't rely on it.
if (a.recoverable) {
  final Uint8List secret = a.prfOutput!; // 32 bytes of key material
}

// For a **discoverable** assertion (credentialId: null — the platform sheet
// lists every RP passkey and the user picks one), the chosen credential is
// reported back so you can bind later recovery to it:
final String? chosenId = a.credentialId; // base64url id the platform used
final String? userHandle = a.userHandle; // base64url user handle, or null
// `userHandle` is null when the platform returns none (common for a targeted
// assertion); `credentialId` echoes the requested id for a targeted assertion.

Recipe: re-derive an encryption key on a new device #

The whole point — the same key comes back on a second synced device, with no server escrow. On both devices, run the same salt against the user's passkey:

// Same fixed, app-wide salt on every device (store it in your app, not secret).
final salt = utf8.encode('my-app/e2ee-key/v1').sublist(0, 32); // 32 bytes

Future<Uint8List?> deriveKey(MaktubPasskey pk, String rpId) async {
  final cap = await pk.probePrf(relyingPartyId: rpId);
  if (!cap.prfSupported) return null; // no PRF on this platform/OS

  final a = await pk.assertWithPrf(
    relyingPartyId: rpId,
    challenge: freshChallenge(), // 32 random bytes from your server/app
    prfSalt: salt,
    // credentialId omitted → discoverable: the user picks their synced passkey
  );
  if (!a.recoverable) return null; // fail closed — device-bound or no PRF output

  // `prfOutput` is uniform 32-byte key material; run it through HKDF.
  return hkdfSha256(ikm: a.prfOutput!, info: utf8.encode('aes-gcm-key'), length: 32);
}

Device A enrolls (create once), both A and B call deriveKey → identical output, because the passkey synced via iCloud Keychain / Google Password Manager.

Recoverability rule (and why it's fail-closed) #

A derived secret only reproduces on a new device if the credential itself syncs there. So PasskeyAssertion.recoverable requires all of:

  • a PRF output on the assertion, and
  • backupEligible (BE) — the credential can sync off-device, and
  • backupState (BS) — it is currently backed up / synced.

BE and BS are read from the assertion's authenticator data. They are per-credential, so probePrf and create can't know them and report them as false — gate on the assertion, not the probe. (PrfCapability.recoverable / .deviceBound are deprecated for exactly this reason.)

A device-bound credential (BE = 0) "works" locally but would strand the user on a new device — so the plugin treats it as not recoverable. It never returns a secret it can't promise to reproduce.

Testing #

The package ships a deterministic, test-only fake so you can exercise your logic without a device:

import 'package:maktub_passkey/testing.dart';

// Inject a deterministic PRF keyed by a `syncedSeed`; two fakes with the same
// seed model two synced devices. (Asserts !kReleaseMode; never use in app code.)
MaktubPasskeyPlatform.instance = FakeMaktubPasskey(syncedSeed: seed);

The fake demonstrates your logic; it cannot prove the platform property that real hardware returns a stable PRF across synced devices. That still requires a real two-device run.

Layout #

lib/maktub_passkey.dart   Dart facade (delegates to the platform)
lib/src/platform.dart     MaktubPasskeyPlatform + MethodChannel impl
lib/src/types.dart        PrfCapability / PasskeyCreation / PasskeyAssertion
lib/testing.dart          FakeMaktubPasskey (TEST-ONLY, release-excluded)
darwin/                   Swift plugin shared by iOS and macOS — Swift Package
                          Manager (maktub_passkey/Package.swift) + CocoaPods podspec
android/                  build.gradle + Kotlin plugin (Credential Manager)

Development #

Contributions are welcome — CONTRIBUTING.md covers what is most useful (starting with real-device cross-device PRF reports) and the path from an idea to a merged PR. After cloning, enable the secret-scanning pre-commit hook (one-time; git never auto-installs hooks):

git config core.hooksPath .githooks   # runs `gitleaks protect --staged` on commit
brew install gitleaks                 # if not already installed

About #

Maintained by BytesBrains as part of the Maktub ecosystem — a protocol for delivering end-to-end-encrypted messages on a timer, to the people they're written for. This plugin is the passkey/PRF building block that lets a Maktub account re-derive its encryption key from a synced passkey; it's published standalone because the capability is useful to any Flutter app that wants passkey-derived secrets.

License: MIT.

1
likes
160
points
38
downloads

Documentation

API reference

Publisher

verified publisherbytesbrains.com

Weekly Downloads

WebAuthn passkeys with the PRF (hmac-secret) extension for Flutter: derive a stable per-credential secret from a synced passkey on iOS, macOS and Android.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#passkey #webauthn #prf #encryption #credentials

License

MIT (license)

Dependencies

flutter

More

Packages that depend on maktub_passkey

Packages that implement maktub_passkey