Skip to content

[PM-40519] feat: Add passkey cipher storage and identity foundation to TestHarness - #2977

Open
morganzellers-bw wants to merge 1 commit into
mainfrom
pm-40519-testharness-passkeys-storage-foundation
Open

[PM-40519] feat: Add passkey cipher storage and identity foundation to TestHarness#2977
morganzellers-bw wants to merge 1 commit into
mainfrom
pm-40519-testharness-passkeys-storage-foundation

Conversation

@morganzellers-bw

@morganzellers-bw morganzellers-bw commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

PM-40519

📔 Objective

Adds the persistence and data-model foundation for passkey test scenarios in the TestHarness, split from PR #2945 for size.

  • CipherStorageService persists SDK-encrypted ciphers across app relaunches, backed by UserDefaults.
  • StoredCipher is a Codable mirror of the handful of Cipher/Fido2Credential fields these scenarios populate, since BitwardenSdk.Cipher isn't itself Codable.
  • SyntheticIdentity/PasskeyKeychainItem model the throwaway identity persisted in the Keychain so the same crypto keys survive relaunches.
  • PasskeyError and supporting fixtures round out the layer.
  • No Fido2/SDK-client orchestration yet; PR [PM-40519] feat: Add SDK-backed passkey registration and assertion services to TestHarness #2945 builds the Fido2CredentialStore/Fido2UserInterface/PasskeyService layer on top of this.
testHarness_passkeys_flow.MP4

Stack: #2977#2945#2946#2947#2948

@morganzellers-bw morganzellers-bw added app:password-manager Bitwarden Password Manager app context ai-review Request a Claude code review labels Aug 18, 2026
@morganzellers-bw
morganzellers-bw requested a review from a team as a code owner August 18, 2026 17:16
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude Code is reviewing this pull request...

If this comment does not update with results, check the Actions log.

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.51%. Comparing base (c419835) to head (f4b8cb4).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2977      +/-   ##
==========================================
- Coverage   79.53%   79.51%   -0.02%     
==========================================
  Files        1169     1169              
  Lines       75095    75095              
==========================================
- Hits        59724    59715       -9     
- Misses      15371    15380       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@morganzellers-bw
morganzellers-bw force-pushed the pm-40519-testharness-passkeys-storage-foundation branch 3 times, most recently from e3f6aa9 to 4d77e40 Compare August 20, 2026 16:47
@morganzellers-bw
morganzellers-bw force-pushed the pm-40519-testharness-passkeys-storage-foundation branch 2 times, most recently from c91e387 to 51be49b Compare August 26, 2026 19:08

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 I think we should think about moving our current CipherView fixtures from BitwardenSdk+VaultFixtures in BitwardenShared to BitwardenKit so it can be shared amongst all apps.
Same applies to Fido2CredentialAutofillView and Fido2CredentialView.
Perhaps moving the whole file there. What do you think @bitwarden/team-ios @matt-livefront ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that idea

Comment on lines +6 to +10
// sourcery: AutoMockable
/// A service that locally persists the SDK-encrypted `Cipher`s created by the SDK-backed passkey
/// scenarios, so they survive app relaunches.
///
protocol CipherStorageService: AnyObject {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ We usually put sourcery inline with the protocol definition. I'm almost certain that you should get a warning on how it's done there above the DocC.

Suggested change
// sourcery: AutoMockable
/// A service that locally persists the SDK-encrypted `Cipher`s created by the SDK-backed passkey
/// scenarios, so they survive app relaunches.
///
protocol CipherStorageService: AnyObject {
/// A service that locally persists the SDK-encrypted `Cipher`s created by the SDK-backed passkey
/// scenarios, so they survive app relaunches.
///
protocol CipherStorageService: AnyObject { // sourcery: AutoMockable

Comment on lines +34 to +35
/// The `UserDefaults` key under which the persisted ciphers are stored.
private static let storageKey = "PasskeyStoredCiphers"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I wonder if we should use some key format alike what we use now in AppSettingsStore, i.e. bwPreferencesStorage:{key}. So in here it'd would be something like thPreferencesStorage:{key}.

Comment on lines +52 to +64
func loadCiphers() -> [Cipher] {
guard let data = userDefaults.data(forKey: Self.storageKey),
let storedCiphers = try? JSONDecoder().decode([StoredCipher].self, from: data) else {
return []
}
return storedCiphers.map(\.cipher)
}

func save(ciphers: [Cipher]) {
let storedCiphers = ciphers.compactMap(StoredCipher.init(cipher:))
guard let data = try? JSONEncoder().encode(storedCiphers) else { return }
userDefaults.set(data, forKey: Self.storageKey)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I usually like to avoid try? as we wouldn't have any errors logged if one would happen. Could you add some logging here or rethrow the error in case it happens? At least on the OSLog.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ Update string keys to follow standards.

Introduces the persistence and data-model layer for the SDK-backed
passkey scenarios: a UserDefaults-backed cipher store, its Codable
mirror of the SDK's Cipher/Fido2Credential shape, the synthetic
identity model, keychain item, and supporting errors/fixtures. This
is the foundation the Fido2-driving services (registration,
assertion, credential listing) build on top of in a follow-up PR.
@morganzellers-bw
morganzellers-bw force-pushed the pm-40519-testharness-passkeys-storage-foundation branch from 51be49b to f4b8cb4 Compare August 27, 2026 16:38
@morganzellers-bw

Copy link
Copy Markdown
Contributor Author

🤖 Claude Security Code Review 🤖

PR: (#2945) - PM-40519 feat: Add SDK-backed passkey registration and assertion services to TestHarness — 2026-08-27

Date: 2026-08-27

Commits reviewed: c419835..c4f80a6 · 2 commits · TestHarnessShared/Core/Autofill/Passkey/
SHA Title PR
f4b8cb4b6 PM-40519 feat: Add passkey cipher storage and identity foundation #2977
c4f80a68f PM-40519 feat: Add SDK-backed passkey registration and assertion services #2945

Summary

Category Count
🚨 Blockers 0
⚠️ Improvements 0
📝 Notes 14
✅ Strengths 13
❌ Dismissed 5
  • This PR is entirely confined to TestHarnessShared, a non-shipping internal developer tool with a bundle ID, app group, and keychain access group fully disjoint from the production Password Manager and Authenticator apps — no production trust boundary is touched.
  • The zero-knowledge invariant holds: all four independent agents and the verifier confirmed, field-by-field against the pinned SDK, that only EncString-typed ciphertext is ever persisted to UserDefaults; key material stays exclusively in the Keychain.
  • No hardcoded secrets, no new third-party dependencies, and the SDK remains pinned to a committed full commit SHA — supply-chain posture is unchanged.
  • The two Dependabot alerts (excon, faraday) are pre-existing Ruby/fastlane CI-tooling transitives with zero reachability from this Swift diff and were dismissed by all reviewers.
  • All findings clustered around hardening of a synthetic, throwaway identity (a try? that conflates keychain errors, a self-contradictory UV flag, salt/password co-location) — none reach real user data because there is no real account, no network path, and no verifier consuming these ceremonies today.
  • The most valuable single fix, despite being rated LOW, is narrowing the try? in PasskeyService.loadOrCreateIdentity() to only treat keyNotFound as "create new" — it's cheap, removes a real failure-mode ambiguity, and is exactly the kind of pattern that could get copy-pasted into shipping code later.

📝 Notes

Expand for details on (14) notes
    • try? on the keychain identity read conflates "not found" with "read failed" (locked device, missing entitlement, decode failure), causing an unconditional overwrite of the identity and a full wipe of stored passkeys on any of those errors.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:165-190
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: Verified destructive-on-any-error behavior, but the asset destroyed is synthetic, throwaway test state in a non-shipping harness — no attacker, no vault data, no real user secret at risk.
    • isVerificationEnabled() returns false while every check-user ceremony unconditionally returns userVerified: true, so emitted authenticator data always claims UV was satisfied.
    • Location: TestHarnessShared/Core/Autofill/Passkey/DefaultFido2UserInterface.swift:14, 64, 68-70
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: Real internal contradiction, but no relying party ever consumes these assertions today (self-generated challenge, discarded clientDataJSON), so there's no live exploit path — forward-risk only, and it's documented as intentional.
    • The synthetic identity's master password is JSON-encoded into the same keychain item as the key material it unwraps, defeating the purpose of the 600k-iteration KDF for anyone who can read the item.
    • Location: TestHarnessShared/Core/Autofill/Passkey/SyntheticIdentity.swift:21-23, PasskeyService.swift:173,187, PasskeyKeychainItem.swift:14-21
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: Confirmed pattern, but the password is a per-install ~122-bit CSPRNG UUID guarding synthetic identity material with no real security value.
    • The WebAuthn challenge is self-generated and its clientDataJSON is discarded before reaching the SDK; rpId is interpolated into an origin string with no validation.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:105-114, 123, 143-144
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: clientDataJSON never leaves the process today, so nothing is forgeable against a real verifier; documented by design, but the unvalidated rpId→origin interpolation is exactly what would need hardening if this is ever extended to accept external challenges.
    • The synthetic identity's keychain item uses .shared namespacing and the app-group access group, deviating from the codebase's established .appScoped convention for private, non-cross-app secrets.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:90-94
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: Concrete, verifiable deviation with no cross-app consumer today; worth fixing because it chains with the try? finding above — a future app extension would compute a different, non-entitled group and trigger silent destruction.
    • The KDF salt is a hardcoded constant email (sdk-passkey-playground@bitwarden.com), identical across every install.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:172, 228
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: Precomputation attacks are infeasible against the paired 122-bit CSPRNG password; hygiene-only finding.
    • clientDataHash silently falls back to SHA256("") — a fixed, publicly known constant — if JSONSerialization fails.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:112
    • Severity: ⚪ INFO
    • Confidence: 🟢 HIGH
    • Rationale: Unreachable in practice since a [String: String] dictionary cannot fail to serialize, but silently substituting a predictable constant for signed input is a smell worth removing (throws instead of try? ?? Data()).
    • Duplicate registration attempts for the same rpId are unbounded (excludeList: nil), and multiple credentials for one RP later throw .ambiguousCredential during assertion.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:146, 151, DefaultFido2UserInterface.swift:72-79
    • Severity: 🔵 LOW
    • Confidence: 🟡 MEDIUM
    • Rationale: A documented disambiguation path exists via explicit credentialId / registeredCredentials(), so this is a functionality rough edge rather than a security gap.
    • BitwardenSdk.Client(tokenProvider:settings: nil) defaults to production identity.bitwarden.com / api.bitwarden.com rather than an explicit non-production endpoint.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:171, 215
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: Harmless today only because none of the called SDK methods make network requests; the client is configured to reach production and is safe purely by call-site discipline, not by structural guarantee.
    • The SDK client and its decrypted user key are cached for the full process lifetime with no lock, timeout, or backgrounding teardown.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:74, 197-209, 662-675
    • Severity: ⚪ INFO
    • Confidence: 🟢 HIGH
    • Rationale: There is no real vault or user secret behind this key, so the usual "locked vault" concern doesn't meaningfully apply; noted so the pattern isn't lifted into a shipping service.
    • StoredCipher drops the SDK's Cipher.data (blob-format) field, and init?(cipher:) silently returns nil for any unrecognized cipher shape via compactMap, with no error surfaced.
    • Location: TestHarnessShared/Core/Autofill/Passkey/StoredCipher.swift:129, 138-141, CipherStorageService.swift:61
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: The SDK's own docs indicate blob-format ciphers set name: nil; a future SDK revision to that format would make every cipher fail to reconstruct and silently empty storage on the next save.
    • The 600,000 PBKDF2 iteration count is hardcoded locally rather than sourced from a shared Constants file.
    • Location: TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift:64
    • Severity: ⚪ INFO
    • Confidence: 🟢 HIGH
    • Rationale: Partly unavoidable since TestHarnessShared doesn't link BitwardenShared (where the existing constant lives); the durable fix is promoting the shared constant into BitwardenKit.
    • CipherStorageService silently returns [] on JSONDecoder failure and silently drops the entire save on JSONEncoder failure.
    • Location: TestHarnessShared/Core/Autofill/Passkey/CipherStorageService.swift:52-64
    • Severity: 🔵 LOW
    • Confidence: 🟢 HIGH
    • Rationale: Confirmed in code — storage corruption is currently indistinguishable from "no credentials registered," which would mask a real bug during test harness use.
    • StoredCipher.cipher reconstructs security-relevant fields (reprompt, edit, viewPassword, organizationUseTotp) from hardcoded defaults rather than persisting them; a persist/reload round-trip silently resets reprompt to .none.
    • Location: TestHarnessShared/Core/Autofill/Passkey/StoredCipher.swift:71-131
    • Severity: ⚪ INFO
    • Confidence: 🟢 HIGH
    • Rationale: Unreachable today since DefaultFido2UserInterface only ever creates ciphers with reprompt: .none, but the same silent-attribute-loss class as the Cipher.data finding — worth a doc comment so a future cipher shape doesn't lose protection flags on round-trip.

✅ Strengths

Expand for details on (13) strengths
    • No hardcoded secrets across 1,431 added lines; the only credential-shaped value is UUID().uuidString, a CSPRNG-backed identifier.
    • Location: TestHarnessShared/Core/Autofill/Passkey/ (all files)
    • Rationale: Grepped for credential-shaped literals; every test value is an obvious placeholder.
    • Ciphertext-only persistence verified field-by-field against the pinned SDK checkout — Fido2Credential's keyValue, credentialId, rpId, userHandle, userName, etc. are all EncString, as is Cipher.name/Cipher.key.
    • Location: CipherStorageService.swift:31-65, StoredCipher.swift
    • Rationale: No plaintext vault-shaped data is ever written outside the Keychain; the zero-knowledge invariant holds. (Minor caveat: EncString is a naming convention, typealias EncString = String, not a compiler-enforced type.)
    • The SDK dependency remains pinned to a full commit SHA, unchanged and untouched by this PR, with a matching committed Package.resolved.
    • Location: project-common.yml (unchanged)
    • Rationale: Integrity control for the SPM git dependency is intact.
    • Zero new third-party dependencies; this PR only links the already-resolved BitwardenSdk package and an in-repo mocks target.
    • Location: project-bwth.yml:80, 178
    • Rationale: No new attack surface introduced to the dependency graph.
    • Ambiguous credential selection fails closed, throwing .ambiguousCredential rather than defaulting to the first match.
    • Location: DefaultFido2UserInterface.swift:71-79
    • Rationale: Avoids the common "fail open on uncertainty" anti-pattern; both empty and multiple-match cases are tested.
    • Signature-counter updates correctly replace the existing credential by cipher ID rather than forking divergent counter states.
    • Location: DefaultFido2CredentialStore.swift:71-76
    • Rationale: Matches correct WebAuthn semantics for credential re-registration.
    • Complete isolation from production apps — verified via project-bwth.yml, TestHarness.entitlements, and the xcconfig files: disjoint bundle ID, app group, and keychain access group from both Password Manager and Authenticator.
    • Location: TestHarness.entitlements, Configs/Common-bwth.xcconfig
    • Rationale: No path exists for this harness's synthetic key material to reach or collide with real vault data.
    • PBKDF2 at 600,000 iterations meets current OWASP guidance for PBKDF2-HMAC-SHA256.
    • Location: PasskeyService.swift:64
    • Rationale: Correct algorithm choice, even though the constant should eventually be shared (see Notes).
    • Actor-based concurrency isolation with single-flight session-task caching prevents concurrent callers from bootstrapping divergent identities.
    • Location: PasskeyService.swift:74, DefaultFido2CredentialStore.swift
    • Rationale: Identity creation is destructive, so preventing concurrent creation is a real safety property (narrowed slightly by the fact the shared CipherStorageService beneath both actors is non-Sendable — harmless today since UserDefaults is thread-safe).
    • clientData is built via JSONSerialization, not string concatenation, so rpId cannot alter the JSON structure.
    • Location: PasskeyService.swift:107-112
    • Rationale: Closes the injection variant of the origin-string finding; only the semantic (unvalidated origin) risk remains, tracked as a Note.
    • No sensitive material (identity, keys, GetAssertionResult) reaches any logging or ErrorReporter call site anywhere in the diff.
    • Location: TestHarnessShared/Core/Autofill/Passkey/ (all files)
    • Rationale: Verified stronger than initially claimed — there are no logging or ErrorReporter calls at all in the added files.
    • Exact-string rpId matching in credential lookup is the spec-correct authenticator-level WebAuthn behavior, not merely a lucky safe default.
    • Location: DefaultFido2CredentialStore.swift:59
    • Rationale: Prevents cross-relying-party credential disclosure; registrable-domain-suffix logic correctly belongs to the browser/client layer, not the authenticator.
    • Backup/restore key-mismatch is explicitly reasoned about and tested: a restored UserDefaults backup without the matching ThisDeviceOnly Keychain item correctly discards the now-undecryptable ciphers instead of wedging.
    • Location: PasskeyServiceTests.swift:854-880
    • Rationale: Confirms deliberate handling of a real state-mismatch scenario — this is the same code path as the LOW try? finding above, so narrowing that catch to keyNotFound will preserve this tested behavior while removing the over-trigger.

❌ Dismissed

Expand for details on (5) dismissed findings
    • Real WebAuthn private keys could be exposed with only device-unlock gating if the harness were used against a real account.
    • Location: CipherStorageService.swift:46, 60-64, PasskeyKeychainItem.swift:18
    • Severity: 🔵 LOW
    • Confidence: 🔵 LOW
    • Rationale: Premise unreachable — no code path in this PR imports, accepts, or targets a real account; fully subsumed by the master-password co-location Note above.
    • Non-Sendable DefaultCipherStorageService is written from two different actor isolation domains.
    • Location: PasskeyService.swift:189, DefaultFido2CredentialStore.swift:82
    • Severity: ⚪ INFO
    • Confidence: 🟡 MEDIUM
    • Rationale: UserDefaults is thread-safe; no memory-safety or security consequence.
    • Two live DefaultFido2CredentialStore instances could clobber each other's registrations (last-writer-wins).
    • Location: DefaultFido2CredentialStore.swift:43, 70-77
    • Severity: ⚪ INFO
    • Confidence: 🟡 MEDIUM
    • Rationale: Hypothetical — the single-flight sessionTask cache guarantees one live instance today.
    • No registrable-domain-suffix validation in findCredentials, only exact rpId match.
    • Location: DefaultFido2CredentialStore.swift:59-62
    • Severity: ⚪ INFO
    • Confidence: 🟢 HIGH
    • Rationale: Not a defect — exact match is the correct authenticator-level behavior; recategorized as a Strength above.
    • Dependabot alerts: excon CVE-2026-54171 (MEDIUM) and faraday CVE-2026-54297 (HIGH).
    • Location: Gemfile.lock:45-46 (fastlane transitive dependencies)
    • Severity: 🟠 HIGH
    • Confidence: 🔵 LOW
    • Rationale: Dev-only Ruby CI toolchain, never shipped in the app binary, no Ruby files touched by this PR; track separately as a fastlane/Gemfile.lock bump rather than blocking here.

Net result: 0 Blockers, 0 Improvements — nothing here should hold up the PR. The 14 Notes are all low-cost hardening opportunities around the synthetic identity's error handling, keychain namespacing, and defensive serialization; worth a follow-up cleanup pass but not urgent given the complete isolation from production trust boundaries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review app:password-manager Bitwarden Password Manager app context t:feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants