Skip to content

WIP: Encrypted Video/SECV Implementation#24

Open
cwbooth5 wants to merge 127 commits into
mainfrom
video
Open

WIP: Encrypted Video/SECV Implementation#24
cwbooth5 wants to merge 127 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 15 commits June 14, 2026 14:03
Replace SwiftUI's SecureField + @focusstate with a UIViewRepresentable
(PINEntryField) backed by a UITextField subclass that owns its own
focus lifecycle. First-responder acquisition is driven by the field's
didMoveToWindow plus a bounded spaced retry, instead of a single
fire-once attempt that raced the security overlay / scene-activation
transition and silently failed ~half the time (no cursor, no keyboard).

PINVerificationView drops the obsolete @focusstate (and the lock-icon
slide animation that depended on it), computes shouldFocus from
scenePhase + isLoading, and unifies the scenePhase handler around
!= .active so all non-active transitions clear the PIN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
removeAllDecoyPhotos was wired into RemovePoisonPillUseCase, but the
matching video cleanup didn't exist — so after deleting the pill, the
decoy .secv file and its decoy thumbnail still sat in their shadow
directories. isDecoyVideo is a file-existence check, so the gallery
kept rendering the shield badge on every previously-marked decoy video.

Adds SecureImageRepository.removeAllDecoyVideos (mirrors the photo path:
delete each file in getDecoyVideoFiles, plus deleteAllDecoyVideoThumbnails)
and calls it from RemovePoisonPillUseCase alongside removeAllDecoyPhotos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec for adding an Info sheet to the video detail view that mirrors
ImageInfoView. Captures location + creation date in the .mov header
at record-time via AVCaptureMovieFileOutput.metadata (single ISO 6709
coordinate, photo-style); read back via AVAsset.commonMetadata over
EncryptedVideoDataSource. Backwards-compatible — pre-feature videos
show the technical section plus filename-derived date with "(from
filename)" hint, and location "—".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New shared utility enum providing makeCaptureItems (location/date/software),
ISO 6709 encode/parse, ISO 8601 date round-trip, codec FourCC → string, and
preferredTransform → TiffOrientation helpers. All 7 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds VideoMetaData struct with DateSource enum and display-string computed
properties (resolution, duration, codec, bitrate, frame rate, location,
orientation). All 8 formatting tests pass under SnapSafeTests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements getVideoMetaData on SecureImageRepository (actor) which reads
duration, resolution, codec, frame rate, bitrate, GPS, and capture date
from encrypted .secv files via EncryptedVideoDataSource, with filename
fallback for pre-existing videos that carry no embedded metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On cold launch the NavigationStack pushed .pinVerification while the
security overlay also showed its own PINVerificationView, mounting two
competing first-responder text fields. The coordinator's
textFieldShouldEndEditing returned false while enabled, so the winning
(hidden) field never resigned and permanently blocked the visible field
against auto-focus and taps.

- Make the security overlay the sole PIN surface; ContentView no longer
  pushes .pinVerification (root stays Color.clear while the overlay gates).
- Remove the refuse-to-resign textFieldShouldEndEditing override.
- Drive first responder from the field's window lifecycle
  (didMoveToWindow + bounded retry) instead of a single fire-once attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Older versions of these actions were pinning to an ancient version of node.
@cwbooth5 cwbooth5 requested a review from Wavesonics June 15, 2026 05:02
cwbooth5 and others added 10 commits June 15, 2026 01:49
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… filter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This is three big changes in one. Wheee
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