Skip to content

WIP: Encrypted Video/SECV Implementation#24

Open
cwbooth5 wants to merge 42 commits into
mainfrom
video
Open

WIP: Encrypted Video/SECV Implementation#24
cwbooth5 wants to merge 42 commits into
mainfrom
video

Conversation

@cwbooth5
Copy link
Copy Markdown
Collaborator

This is just about done, checking it out here.

cwbooth5 and others added 30 commits February 2, 2026 00:13
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…() modifier

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
SecureGalleryView called both onDismiss?() and the environment dismiss()
at every dismissal site. When the gallery is a pushed nav destination
(Camera -> Gallery -> Select for Decoys), onDismiss is nav.navigateBack(),
so Save popped the stack twice -- removing .gallery then .camera -- landing
on the empty Color.clear root (black screen).

Call exactly one mechanism: the injected onDismiss when present, otherwise
the environment dismiss(). Fixes empty-gallery, decoy Back, and decoy Save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Videos are stored in a separate "videos" directory, but activatePoisonPill()
only wiped the photo gallery and thumbnails via deleteNonDecoyImages(). All
videos therefore survived the poison pill -- a serious data-leak that defeats
the feature's purpose.

Add deleteNonDecoyVideos(), invoked before deleteNonDecoyImages() (which removes
the decoy directory used for the decoy check). A video is preserved only if a
file with the same name exists in the decoy directory; since decoy selection is
photo-only today, every video is destroyed -- while remaining forward-compatible
if video decoys are added later.

Tests: PoisonPillVideoDeletionTests covers both the destroy and decoy-preserve
paths. To make them run, the previously orphaned FakeEncryptionScheme /
FakeThumbnailCache test helpers were added to the SnapSafeTests target (and
FakeEncryptionScheme updated to match the current EncryptionScheme protocol).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A number of test files existed on disk but were never members of the
SnapSafeTests target, so they silently never compiled or ran.

Removed (obsolete/superseded - tested types that no longer exist):
- SecureFileManagerTests, EditedPhotoTrackingTests (SecureFileManager removed)
- LocationManagerTests (replaced by LocationRepository)
- PINManagerTests (replaced by PinRepository/PinCrypto)
- SecurePhotoTests (SecurePhoto model removed)
- CameraModelTests (CameraModel renamed to CameraViewModel)
- CameraLifecycleTests (CameraViewModel.isSessionActive removed)
- FaceDetectorTests (MaskMode reduced to .pixelate; blurFaces removed)
- PhotoDetailViewModelTests (built on removed SecurePhoto + showFaceDetection)
- SnapSafeTests (empty Xcode template stub)

Folded into the target (valid tests of current code, with minor fixes):
- VerifyPinUseCaseTests - updated to current AuthorizePinUseCase /
  VerifyPinUseCase initializers; import FactoryKit; @mainactor.
- SECVFileFormatTests - UInt32 conversions; fileLength: label.
- SecureImageRepositoryTests - drop removed getPhotoByName tests; saveImage
  now takes CLLocation; add getVideosDirectory override for isolation.
- Added the previously-orphaned FakeEncryptionScheme / FakeThumbnailCache
  helpers to the target.

Full unit suite now compiles and runs: 92 passed, 0 failed (was 58).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously the poison pill destroyed ALL videos because videos could never be
marked as decoys: saveDecoySelections() skipped video items and there was no
video-decoy storage. Even preserving a video as-is would be broken, since
activatePoisonPill deletes the real key's DEK, making real-key content
undecryptable.

Mirror the photo decoy model for videos:
- VideoEncryptionService: add encryptVideoForDecoy(...) async (awaitable encrypt).
- SecureImageRepository: inject VideoEncryptionService; add isDecoyVideo,
  addDecoyVideoWithKey (decrypt with current key -> re-encrypt with poison-pill
  key into the decoy dir), removeDecoyVideo; count videos toward the shared
  decoy limit. deleteNonDecoyVideos now destroys non-decoy videos AND moves each
  decoy's poison-pill-key copy into the videos dir, replacing the real-key
  original (runs before deleteNonDecoyImages, which removes the decoy dir).
- AddDecoyVideoUseCase + DI wiring (and pass VideoEncryptionService into the
  SecureImageRepository factory).
- MixedMediaGalleryViewModel: saveDecoySelections add/remove decoy videos;
  decoy count, pre-selection, and strings now include videos.

Result: decoy videos are re-encrypted with the poison-pill key and remain
playable under the duress PIN; all non-decoy videos are destroyed.

Tests (PoisonPillVideoDeletionTests, + FakeVideoEncryptionService):
- non-decoy videos destroyed (decoy photo preserved)
- addDecoyVideoWithKey re-encrypts and marks the video a decoy
- end-to-end: mark decoy video -> poison pill -> decoy file replaced by the
  poison-pill-key copy, non-decoy video destroyed
Full unit suite: 93 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Marking a video as a decoy re-encrypts it with the poison-pill key, which can
take a while for large videos. Previously the decoy gallery dismissed
immediately and the work ran detached, giving the user no feedback.

- saveDecoySelections() is now async and drives isSavingDecoys + a
  completed/total counter (only items whose decoy state changes are processed).
- SecureGalleryView awaits the save and dismisses only when it completes,
  showing a dimmed spinner overlay ("Saving decoy media…" with an N-of-M count
  for multiple items). Save/Back are disabled while saving.

Note: the SECV re-encryption still runs on the main actor (the
VideoEncryptionService is @mainactor). The indeterminate ProgressView animates
on the render server so the spinner stays alive, but the rest of the UI is
blocked during the crypto. Moving the crypto off the main actor is a follow-up;
the recording path already consumes its progress via receive(on: .main), so it
should be feasible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Video cells showed a generic film-icon placeholder. Now each video has a real
thumbnail. Because videos are encrypted SECV (unreadable by
AVAssetImageGenerator), the thumbnail is generated once at record time from the
plaintext .mov, before it is deleted.

- SecureImageRepository: durable, encrypted video-thumbnail storage in
  Application Support (videoThumbnails/, excluded from backup) — generate from
  the plaintext .mov via AVAssetImageGenerator.image(at:), store encrypted with
  the current key, read+decrypt with an in-memory cache, delete one / delete all.
- Security: thumbnails are derived from real frames, so deleteAllVideoThumbnails
  runs on poison-pill activation and security reset; per-video thumbnail (and any
  decoy copy) is removed when a video is deleted.
- ThumbnailCache: video-name-keyed get/put/evict (prefixed).
- CameraViewModel.encryptRecordedVideo generates+stores the thumbnail before the
  .mov is deleted.
- VideoCellView loads the decrypted thumbnail via .task (mirrors PhotoCell),
  with a play badge and decoy shield; falls back to the icon placeholder.

Scope: record-time only — videos recorded before this change and decoy videos
(after the pill) show the placeholder.

Tests (VideoThumbnailTests): store writes an encrypted file; read returns the
image; delete removes it; poison pill wipes the whole thumbnails dir. Full unit
suite: 97 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SecureGalleryViewModel was unreferenced (the live gallery uses
MixedMediaGalleryViewModel). Removed the 22KB class and file. The file also
declared the shared SelectionMode enum, which the live view model uses, so that
enum was moved into MixedMediaGalleryViewModel.swift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
~13 test files had silently never been added to the SnapSafeTests target, so
they never compiled and their tests never ran (the bundle reported success while
executing nothing). Add scripts/check_test_target_membership.rb, which fails if
any .swift under SnapSafeTests/ is not compiled by the target, and run it first
in the fastlane build/test lanes (so CI's `fastlane test` and local runs both
enforce it). Verified it fails on an orphan file and passes when clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rship guard

Add a "Building, Testing & Releasing" section covering the fastlane lanes, the
test-target membership guard (scripts/check_test_target_membership.rb) and how
it's enforced, the CI workflows, and the tag-driven release process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l deleted ALL videos

addDecoyVideoWithKey decrypted the original to a temp file and re-encrypted it
with the poison-pill key, but never created those output files. The video
encryption service opens its output with FileHandle(forWritingTo:), which
requires the file to already exist (the camera and the sharing path both
pre-create it). So every decrypt/encrypt threw, addDecoyVideoWithKey caught it
and returned false, and NO video was ever marked as a decoy.

Consequences, both reported by the user:
- Entering the poison PIN destroyed ALL videos, including ones the user tried to
  mark as decoys (deleteNonDecoyVideos found zero decoy files).
- The decoy shield badge never appeared on video cells (isDecoyVideo was always
  false).

Fix: pre-create the temp plaintext file and the decoy file before calling the
encryption service, matching the camera/sharing pattern.

Why tests missed it: FakeVideoEncryptionService wrote output via Data.write(to:),
which creates the file, masking the missing precondition. The fake now models
the real precondition (throws if the output file does not exist), so this class
of bug is caught. With the faithful fake the two decoy tests went RED, then GREEN
after the fix. Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…haring + decoy marking)

decryptVideoFile read `upToCount: trailer.chunkSize` (the full 1 MB) for EVERY
chunk, but the encoder writes a partial final chunk (min(chunkSize, remaining)).
On the last chunk that over-read swallowed the auth tag (and index/trailer), so
the subsequent tag read returned nothing and decryption threw
SECVError.fileIOError -- for essentially any video (almost all have a partial
final chunk).

This silently broke every bulk-decrypt caller, decryptVideoForSharing:
- Marking a video as a decoy (addDecoyVideoWithKey decrypts then re-encrypts)
  always failed, so no video ever became a decoy. Hence the poison pill deleted
  ALL videos and the decoy shield badge never appeared.
- Video sharing (also decryptVideoForSharing) was broken the same way.
Playback was unaffected -- it streams via a custom AVAssetResourceLoader
(makeEncryptedVideoAsset), a different path.

Fix: read each chunk's actual size,
min(chunkSize, originalSize - chunkIndex*chunkSize); AES-GCM ciphertext length
equals the plaintext length, so this reads exactly the ciphertext and leaves the
tag intact.

This was the real root cause behind the decoy-video badge / "all videos deleted"
reports (the earlier saveDecoySelections and pre-create fixes were necessary but
not sufficient).

Tests (DecoyVideoIntegrationTests): real-service encrypt/decrypt round-trips for
single-chunk and multi-chunk-with-partial-last inputs (assert exact bytes), plus
an end-to-end "mark video as decoy -> isDecoyVideo true" using the real
VideoEncryptionService. Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On poison-pill activation, deleteAllVideoThumbnails() destroys every video
thumbnail (they are derived from real frames and were encrypted with the
now-deleted real key). But decoy videos survive the pill, so they were left
with no thumbnail -> the gallery showed the placeholder icon.

Mirror the decoy-video mechanism for thumbnails: when a video is marked as a
decoy, re-encrypt its thumbnail with the poison-pill key into a separate
decoyVideoThumbnails/ directory. On the pill, after wiping the real-key
thumbnails, restore the decoy thumbnails into videoThumbnails/. They are
encrypted with the poison key (the active key after the pill), so
readVideoThumbnail decrypts them normally; clearAllThumbnails() flushes the
in-memory cache so no real thumbnail leaks.

Cleanup: removeDecoyVideo drops the decoy thumbnail copy; securityFailureReset
wipes the decoy thumbnail directory too.

Test (VideoThumbnailTests): marking a video as a decoy stores a poison-key
thumbnail; after the poison pill the decoy video's thumbnail is restored while a
non-decoy video's thumbnail is destroyed. Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Import picker only accepted images. Allow videos too: a picked video is
copied to a temp file (ImportedMovie transferable), encrypted to SECV with the
current key in the videos directory, and given a thumbnail -- mirroring the
camera record path. Imported videos use the camera's "video_yyyyMMdd_HHmmss"
naming (bumping the second on collision) so they stay unique and sort correctly.

Tests (VideoImportTests + DecoyVideoIntegrationTests): import creates an
encrypted .secv with the video_ prefix, the name is date-parseable/sortable,
repeated imports get distinct names, a non-decodable input still encrypts but
skips the thumbnail, and a real-service round-trip recovers the original bytes.
Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat black translucent backgrounds on the camera controls
(switch/flash/gallery/settings buttons, recording indicator, zoom capsule) with
a glassControlBackground modifier: Apple Liquid Glass (glassEffect) on iOS 26+,
with an .ultraThinMaterial fallback for the iOS 18.5 deployment floor. The
shutter keeps its dedicated design.

The glass is intentionally non-interactive: these backgrounds sit inside Buttons
and tap gestures, and interactive glass installs its own touch handling that
swallowed the button taps (regression: the flash toggle could not be changed).
The enclosing control owns the interaction; the modifier is purely visual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cwbooth5 and others added 2 commits May 30, 2026 21:29
Tapping any gallery item opened photos in a swipe-only sequence; videos went
to a separate full-screen player with no surrounding context and no way to
swipe back to adjacent media. The detail route carried [PhotoDef] so videos
were stripped before the pager started.

Fix: AppDestination.photoDetail now carries [GalleryMediaItem] (the full
mixed-media list). PhotoPageViewController creates a PhotoDetailHostingController
for photos or an InlineVideoHostingController for videos, using the encryption
key already on each GalleryMediaItem. All gallery taps route to .photoDetail
with viewModel.mediaItems so swiping moves seamlessly between photos and videos
in gallery order.

InlineVideoPageView wraps VideoPlayerViewModel + AVKit VideoPlayer. Requires
.onAppear { viewModel.setupPlayback() } — without it isLoading stays true
forever and the spinner never resolves.

EnhancedPhotoDetailViewModel works with [GalleryMediaItem]: currentPhotoDef /
currentIsVideo gate photo-specific operations; preloading skips video items.
Photo toolbar hides when the current page is a video (AVKit provides controls).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ntrols

- New InlineVideoPlayerView replaces AVKit's chrome with a bare
  AVPlayerLayer surface (VideoSurfaceView), a glass transport bar
  (play/pause, scrubber, time), and the shared MediaDetailToolbar
  (Share / Decoy / Delete). Photo and video pages now use one
  toolbar component.
- Auto-hide rewritten: replaced the global Timer.publish with a
  per-show cancellable Task that resets on every interaction
  (play/pause, scrub start/end, tap-to-show) and waits 5s before
  fading. Scrubbing cancels the timer so the bar can't disappear
  mid-drag.
- Play/pause hit area: added contentShape(Rectangle()) so the full
  44x44 frame is reliably tappable instead of falling through to
  the surface tap that toggles control visibility.
- Counter chip ("3 of 10") now fades in lockstep with the video
  controls via a callback plumbed through PhotoPageViewController
  to EnhancedPhotoDetailViewModel; photo pages remain unaffected.
- Off-screen audio fix: the asset load now runs in a tracked Task
  that cleanup() cancels, and the completion bails out before
  player.play() when the task is cancelled -- so a slow decrypt
  can't auto-start audio on a page that's been swiped away.
- Haptics: light sensory feedback on play/pause (keyed to
  isPlaying) and on every MediaToolbarButton tap, matching the
  camera/PIN vocabulary already in the app. The standard SwiftUI
  Slider continues to provide scrub haptics for free.
- Project, strings catalog, and fastlane README updated for the
  new files and lanes. TODO.md adds a scratch list of unrelated
  observations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 31, 2026

Not up to standards ⛔

🔴 Issues 1 critical

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
ErrorProne 1 critical

View in Codacy

🟢 Metrics 589 complexity · 34 duplication

Metric Results
Complexity 589
Duplication 34

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 31, 2026

iOS Test Results

131 tests  +131   124 ✅ +124   54s ⏱️ +54s
 17 suites + 17     7 💤 +  7 
  1 files   +  1     0 ❌ ±  0 

Results for commit e4516c4. ± Comparison against base commit d71bc39.

♻️ This comment has been updated with latest results.

cwbooth5 and others added 10 commits May 31, 2026 17:25
DEK files were written without `.completeFileProtection`, making them readable
from first device unlock until reboot—even while locked. Wrapped DEKs are now
encrypted at rest with the `.complete` file-protection class:

- Line 286: DEK file writes use `.completeFileProtection` + `.atomic` options
  so files are atomic and unreadable when device is locked
- Line 537: Keys directory marked with FileProtectionType.complete to protect
  all contained key material

Fixes H1: DEK files written without `.completeFileProtection`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`VerifyPinUseCase` used `try!` around `deriveAndCacheKey`, so any I/O
error reading the wrapped DEK, transient hardware-key failure (e.g.
`errSecInteractionNotAllowed` when the device locks mid-flow), or
PBKDF2 failure crashed the process. An attacker who can race the
device-lock state could turn that into a DoS.

- Introduce `PinVerificationResult` (`success` / `invalidPin` /
  `failure(Error)`) and return it from `verifyPin` instead of `Bool`.
- Catch derivation errors and surface them as `.failure`.
- Update `PINVerificationViewModel` to treat `.failure` as a
  retryable error that does NOT increment failed-attempts (otherwise
  the same race could force a security reset).
- Show the new retryable-error message in `PINVerificationView`.

Test (red→green): `test_verifyPin_returnsRetryableFailure_whenKeyDerivationThrows`
stubs `deriveAndCacheKey` to throw and asserts the use case returns
`.failure` without crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was needed to address the new xcode26 compiler issues we didn't see on the earlier version.
`securityFailureReset` left the Secure Enclave EC keys (`snapsafe_kek`,
`pin_key`) intact in the keychain, so they survived reset and re-onboarding
and could decrypt any DEK that ever leaked via backup or extraction.
`evictKey()` also launched a detached `Task` and returned synchronously,
so the in-memory key could still be cached during/after the reset.

- `securityFailureReset` now `SecItemDelete`s every EC hardware key the
  app owns (`kSecClassKey` + `kSecAttrKeyTypeECSECPrimeRandom`) and
  awaits `evictKey()` before returning.
- `EncryptionScheme.evictKey()` and its implementations are now `async`.
  Ripples to `SecureImageRepository.evictKey/securityFailureReset/
  activatePoisonPill` and `InvalidateSessionUseCase.invalidateSession`
  (now `async`); call sites updated to `await`.

Tests (red-first, in `HardwareEncryptionSchemeSecurityResetTests`):
- `test_securityFailureReset_deletesHardwareKeys` creates the KEK and
  `pin_key` via `encryptWithKeyAlias`, asserts they exist in the keychain,
  runs reset, and asserts both are gone.
- `test_securityFailureReset_evictsCachedKeyBeforeReturning` derives a
  key, runs reset, and asserts `getDerivedKey()` throws `.keyNotDerived`
  on return — proving the cache was cleared before reset returned.

Both tests failed before this change and pass after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wall-clock elapsed-time checks let an attacker bypass the session
timeout (move clock backward) or zero out the PIN backoff (move clock
forward). Switch elapsed-time decisions to CLOCK_UPTIME_RAW via a new
Clock.monotonicNow accessor; keep wall clock only for display and
restart-fallback persistence with backward deltas clamped to 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
verifyPoisonPillPin was awaited on every PIN attempt even when no
poison pill is configured. That ran a second Argon2 verification per
attempt and gave an attacker a timing oracle for poison-pill presence.
Gate the call behind hasPoisonPillPin via && short-circuit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UserDefaultsSettingsDataSource carried a hardcoded "stub-cipher-key"
constant that was seeded into UserDefaults and persisted in the
file-based settings JSON. Audit confirmed no consumer ever reads
getCipherKey() — the protocol method, both implementations, the
Defaults constant, the PrefKeys case, and the SettingsData field
were dead. Removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hardware-backed DEK could be unwrapped without the SnapSafe PIN once the
device was unlocked. The DEK was derived from the PIN at create time
(PBKDF2(PIN ‖ dSalt ‖ deviceID)) but only Secure-Enclave-wrapped on disk, so
the load path reproduced the DEK from the SE alone — `deriveWrappedKey` never
used its `plainPin` argument. The Argon2 PIN check was a Swift-level gate, not
a cryptographic dependency. Anything reaching `SecKeyCreateDecryptedData` on an
unlocked device (jailbreak, lldb attach, patched binary) recovered all content
without the PIN.

Fix: add a PIN-derived AES-GCM wrap layer *under* the SE wrap.

    create:  DEK     = random(32)                       // independent of PIN
             pinKey  = PBKDF2("snapsafe-pinwrap-v1:" ‖ PIN ‖ deviceID, salt)
             stored  = SE_wrap( AES-GCM(DEK, key: pinKey) )
    derive:  payload = SE_unwrap(stored)
             DEK     = AES-GCM-open(payload, key: pinKey)   // wrong PIN -> .wrongPin

Recovering the DEK now requires the user to actively type the PIN. Chosen over
`.userPresence` / biometric ACLs deliberately: biometrics and the device
passcode are coercible (sleeping/forced face, border demand), the PIN is not —
SnapSafe's threat model is compelled-access resistance. See
design/2026-05-31-c1-pin-binding-analysis.

Details:
- New `PinDEKWrapper` (CryptoKit + PBKDF2, keychain-free → unit-testable on CI):
  derivePinKey / wrap / unwrap / isLegacyRawDEK.
- `CryptoError.wrongPin` (+ Equatable) for clean, non-leaky wrong-PIN failures.
- One-shot transparent migration: a legacy 32-byte raw-DEK payload is preserved
  (existing content depends on its value) and re-wrapped under the PIN key on
  next valid unlock. 32-byte raw vs 60-byte wrapped is the discriminator.
- Removed now-dead PIN→DEK PBKDF2 path (`derivePBKDF2Key`, `dSaltSize`).
- UX unchanged: same `deriveAndCacheKey` signature, same session model.

Tests (TDD): 10 PinDEKWrapper unit tests (determinism, wrong-PIN rejection,
no-plaintext-leak, nonce freshness, payload length, migration discriminator)
run on CI; 3 scheme-level integration tests (round trip, wrong-PIN, stored
payload is PIN-wrapped) run on device, skip on simulator (Secure Enclave).
Full suite: TEST SUCCEEDED, 0 failures.

Addresses C1 from the 2026-05-31 security review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The failed-attempt counter had two writers. On an invalid PIN,
VerifyPinUseCase called incrementFailedAttempts() — which authoritatively
bumps the persisted count AND records the last-failed timestamp + monotonic
backoff baseline. Then PINVerificationViewModel ALSO wrote the counter via
setCurrentFailedAttempts(failedAttempts + 1), derived from its stale local
@published value and without touching the backoff timestamp/baseline.

The two writes happened to align today, but the design was racy (the VM's
local value updates asynchronously in a Task) and could desync the count from
the backoff window — a value the lockout/security-reset logic depends on.

Fix: make the repository the single writer; the view model only observes.
- PinVerificationResult.invalidPin now carries `failedAttempts: Int`, the
  authoritative post-increment count returned by the repository's single
  increment.
- VerifyPinUseCase returns that count; the view model reflects it into its
  @published property and uses it for the MAX-attempts check — it no longer
  writes the counter.
- On success the VM just sets failedAttempts = 0 locally (AuthorizePinUseCase
  already reset the persisted counter); the redundant write is gone.
- Removed the now-dead setCurrentFailedAttempts(_:) helper.

Tests (TDD): added test_verifyPin_onInvalidPin_incrementsCounterExactlyOnce_
andReturnsNewCount — asserts the persisted counter goes 0 -> 1 on one invalid
attempt and the result carries count 1. Full suite: TEST SUCCEEDED, 0 failures.

Addresses M1 from the 2026-05-31 security review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tial)

The settings file (AppSettings.json) persists the reversible poison-pill PIN
ciphertext (poisonPillPlain) alongside the PIN hashes, and was written with
`.atomic` only — excluded from backup but readable while the device is locked.

Apply `.completeFileProtection` to the write so the file is unreadable when the
device is locked (defense-in-depth at rest).

This is Option D from the H2 risk discussion: a hardening pass, not a full
remediation. The reversible PP PIN still exists because decoy creation needs to
reproduce the poison-pill encryption key while the user is in their normal
session (AddDecoyPhoto/VideoUseCase). The deeper fix (wrap the poison-pill DEK
under the primary PIN key, à la C1, and drop the reversible PIN) is deferred —
the residual exposure (recover the PP PIN value via instrumented access on an
unlocked device) was assessed as mostly theoretical. See
decisions/2026-06-01-poison-pill-pin-accepted-risk.

Test (device-only; skips on simulator where file protection isn't enforced):
asserts the settings file has `.complete` protection after a PP PIN is stored.
Full suite: TEST SUCCEEDED, 0 failures.

Addresses H2 (partial / accepted residual risk) from the 2026-05-31 security review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant