Vaultumi

Vaultumi security review notes

Not independently audited. This is the author’s own document. Vaultumi has not been independently reviewed, audited or certified, and its hand-written scrypt and secret-sharing code still need external review. The page repeats what the project’s source documents say, with repository-internal references removed.

Historical snapshot (build 2). The app has since gained Vault Drop, Intruder log, documents/OCR, similar photos, Legacy Kit, the safety check and backup reminders, none of which this audit covers (see threat model rows 26 to 35). The test counts below are from that time. Statuses were not re-checked.

Scope: the iOS app source at build 2 (TestFlight), plus the documentation that ships with it. Two separate read-only reviews of the code were run by the project itself: an adversarial security review (crypto, auth, storage, backup, import, app lock) and a code / robustness / App Store readiness review. This file lists every finding, what was done, and the evidence. These are the project's own reviews. They are not an external audit and are not a substitute for one (see the last section).

Verification at the time of writing: 90 unit tests and 12 end-to-end UI tests pass in the iOS Simulator; an automated source scan for networking, analytics, logging and non-Apple imports passes. Nothing below has been exercised on a physical device.

Status key: Fixed (code changed and tested), Mitigated (reduced, residual risk stated), Open (not fixed, with reason), Accepted (design decision).

Summary

Counts are by the numbered rows below (the reviews' own numbering), not a tally of every sub-point.

SeverityRowsFixedMitigatedOpen / Accepted
Blocker1100
High11731
Medium2715210
Low / Infomanyseveral0the rest

The counts include one High finding about how the project's own signing material is stored. It concerns the development setup, not the app, and is not listed below.

No Critical finding (no nonce or key reuse, no AAD swap or rollback that yields plaintext, no oracle that recovers key material).

Blocker

#FindingStatusEvidence
B1A stale in-memory VaultSession.header let a later PIN change write back an old header: the previous recovery code came back and the new one stopped working; Face ID silently turned off or reappeared. A forgotten PIN after that meant permanent lockout.Fixed: every header mutator (enableBiometrics, changePIN, regenerateRecoveryCode) re-reads the header from disk and changes only its own field.(enable Face ID, new recovery code, change PIN: new code and Face ID both still work; disable Face ID then change PIN: wrap stays off).

High

#FindingStatusEvidence / note
F1Path traversal: MediaItem.filename from a backup reached appendingPathComponent, allowing overwrite or deletion of files in the sandbox (e.g. header.json).Fixed: TempSpace.sanitizedFilename / sanitizedTypeIdentifier, a parent-directory check in newFileURL, and sanitising of restored metadata.
F2The Keychain PIN "pepper" travelled in iCloud / encrypted backups, so whoever held an iCloud backup could brute-force the 10^6 PIN space offline (scrypt only).Fixed: pepper and attempt counter are AfterFirstUnlockThisDeviceOnly. Cost: a phone restored from a device backup needs the recovery code; the decoy vault cannot be reopened after migration (documented).(a device without the pepper opens only with the recovery code); docs updated. The ThisDeviceOnly Keychain class itself needs on-device confirmation.
F3Unbounded scrypt parameters from header.json or a backup allowed a memory DoS (up to 16 GiB).Fixed: Scrypt.derive refuses > 128 MiB; backup reader bounds logN <= 17, r <= 8, p <= 2.
H1Imports, backups and restores died when the screen locked; nothing cancelled them on app lock.Mitigated: the idle timer and auto-lock are held while those screens are open. Cancelling in-flight work on lock is not implemented.UI tests cover the screens; behaviour with a locking device needs a physical device.
H2A second VaultStore on the same directory could delete an in-flight ingest's staging files and orphan-clean its blobs.Fixed: per-directory active-ingest registry; load() skips cleanup while one runs.Covered by concurrency and ingest tests; the exact race is not separately reproduced.
H3The privacy cover was a SwiftUI overlay below presented sheets, so the app-switcher snapshot could show a photo in a full-screen viewer.Fixed: cover also shown in its own UIWindow above all modals.Needs on-device verification of the snapshot. Toasts are still hidden behind modals (Low).
H4When Premium lapsed, retention fell from e.g. 90 to 30 days and the next unlock purged Recently Deleted items.Fixed: retention is never shortened by a lapse.Logic change in AppModel.enter; no unit test (AppModel is not unit-tested).
H5Import fidelity: edited photos import the original (.photo) and "Delete originals" then removes the edited version; the file-provider fallback loses a Live Photo's motion yet offered to delete the original.Mitigated: the fallback no longer offers deletion; deleteOriginals returns false when no asset is found. Open: edited-photo case (needs a decision: import the edit, or warn).
H6"Backup saved" was recorded for any completed share activity (Copy, Add to Notes...), then the file was deleted.Mitigated: non-persisting activities are excluded. A document-picker export would give a definite outcome (Open).UI test reaches the share sheet.
H7Opening a backup copied the whole file on the main thread.Fixed: copy moved off the main actor. Not yet read-header-first (a wrong secret still copies).

Medium

#FindingStatus
M1Rate limiter used wall-clock time; moving the date forward bypassed it.Fixed: monotonic clock plus boot session id; a reboot restarts the delay.
M2Decoy vault: "Change PIN" revealed that another vault exists; unthrottled primary-PIN oracle; a decoy login reset the failure counter.Fixed for the three leaks (row hidden in the decoy, shared limiter, decoy login no longer resets the counter:). Residual: two scrypt runs only when a decoy exists is a timing tell (Accepted).
M3Any Keychain read error was treated as "pepper missing" and replaced the pepper.Fixed: only errSecItemNotFound creates a pepper; update-in-place; a second decoy cannot overwrite the first. Residual: a lost pepper still orphans the decoy (documented).
M4The recovery code was invalidated before it was saved; auto-lock could destroy the only copy.Mitigated: auto-lock is held on one-time-secret screens; lock() never runs during setup. The new code is still written before the person confirms (Open).
M5Export and "Delete all data" needed no PIN in an unlocked session.Fixed: both require the PIN (UI test).
M6Concurrent read-modify-write could lose updates; album list races.Fixed: recursive mutation lock; ingest commit re-checks duplicates. Locking does not yet cancel in-flight tasks (Open).
M7Weak backup passphrases (12 identical characters) allowed; no Unicode normalisation.Fixed: PassphrasePolicy, NFC normalisation, .newPassword removed.
M8Restore trusted thumbnail size and JSON sizes; duplicate album ids.Fixed: 2 MiB thumbnail, 4 MiB JSON caps, duplicate id rejection.
R-M1Auto-lock interrupted first-run setup.Fixed (lock() only from .unlocked).
R-M2Auto Face ID could fire while not active.Fixed (only on scenePhase == .active).
R-M13A lock during PIN change re-opened a session.Fixed.
R-M14Partial update(ids:) failure not shown in the UI.Fixed (refresh() in defer).
R-M17Lock screen did Keychain and disk work every second.Fixed (ticker only during a lockout).
R-M10Thumbnail cache bounded by count (about 600 MB).Fixed: 96 MiB cost limit.
R-M3Cover driven by scenePhase alone could stick.Open: needs device testing with permission alerts.
R-M4/M5Viewer index after deleting the current item; stale selection highlight.Open.
R-M6Over-limit screen stuck after buying Premium.Open.
R-M7Free limit counts only active items (trash then restore exceeds 20).Open / product decision.
R-M8/M9/M12/M16Large batch export without size checks; coarse low-storage estimates for library imports; no cancel for backup/restore; iCloud Drive placeholders.Open.
R-M11Temp files outside TempSpace.Fixed: clearAll() also clears backups, restore copies and Documents/Inbox.
R-M15Duplicate checks treat trashed items inconsistently.Open.
R-M18/M19Accessibility (live-region announcements, keypad at large text on small phones), portrait-only.Open.
R-M20The vault is in iCloud/iTunes device backups (quota; deliberate, now safe because of F2).Accepted.
R-M21Encryption export compliance (ITSAppUsesNonExemptEncryption=false).Open: a legal decision for the project (see the internal export-compliance notes).

Low and informational (selected)

Fixed: temp backup files not cleared (L1); biometric lockout reported as "biometrics changed" (L5); atomic replace of an existing backup (L7); auto-lock timing on a monotonic clock (L4); Keychain write no longer delete-then-add for the pepper; backup scrypt cost cap documented and reduced to 128 MiB; stale placeholder markers removed from the documentation.

Open: plaintext temp-file protection class not verified on device (L2); recovery code field is a plain text field (L6); restore skips the content-hash duplicate check (documented); purgeExpired uses the wall clock, so a forward clock jump purges early (L9); UserDefaults activity metadata (lastBackupAt) is shared with the decoy vault (L10); header fields are not authenticated (tampering causes DoS only after F3) (L11); the master key is not rotated when the PIN changes, so an old header copy plus the old PIN and the same device pepper still opens the vault (L12); no KDF upgrade path; purge is unlink only, with no secure erasure of flash; AirPlay output of video is not covered by the screen-capture cover; localization is English-only with some ternary strings that will not extract.

Verified sound (both reviews)

Scrypt matches RFC 7914 (three test vectors); VMF1 chunk format (random per-file key, nonce prefix + counter, final-flag AAD) defeats truncation, extension, reordering and cross-item swaps; key hierarchy separation by HKDF label and AAD; backup validate-before-restore with per-entry hashes and manifest equality, fuzzed in tests; ingest commit protocol (metadata last, rollback on error); the entitlement resolver never grants from unverified data and never locks existing media or gates export, backup, restore or recovery; the debug premium switch and the UI-test hooks are compiled out of Release; no print, NSLog or os_log calls, no networking, no third-party code (checked by the automated source scan); file sharing disabled; privacy manifest matches API use.

What this audit does not cover

  • Physical-device behaviour: Face ID and its invalidation, the private camera, Live Photo and large-video import, iCloud downloads, the app-switcher snapshot, screen recording and AirPlay, memory use on large vaults, interruption by calls or a locked screen.
  • A second-device restore from a real exported file.
  • Real StoreKit sandbox and TestFlight purchases (only the local StoreKit configuration is tested).
  • The hand-written scrypt and the overall design have not had an external cryptographic review. Get one before treating the app as release-ready.
  • Dynamic analysis: no fuzzing of the PhotoKit/AVFoundation paths, no jailbreak or forensic testing.

New features since the audit

The audit above covers the code as it was when it was written. It does not cover the features added afterwards: Vault Drop (the share extension and the sealed inbox, VMIB v1), the Intruder log, documents with OCR sidecars, similar photos with feature-print sidecars, Legacy Kit (Shamir secret sharing), and the backup safety check. They are described in security architecture sections 12 to 17 and in threat model rows 26 to 34. None of that has been independently reviewed.

Known caveats at the time of writing (2026-10-31):

  • Shamir tag. Each Legacy Kit card carries a 4-byte tag computed from the secret, so anyone holding cards can test a guess of the secret: the cards leak up to 32 bits about it. This is harmless for a random 160-bit recovery code but means the split is not information-theoretically perfect. The Shamir code is hand-written and needs external review before Legacy Kit ships.
  • Vision tests in the Simulator. The test that checks real Vision feature prints is skipped when the Simulator runtime returns degenerate prints, and the two real-Vision OCR tests skip when text recognition is unavailable. Vision behaviour must be verified on a device.
  • Similar-photos threshold. The distance threshold 0.4 is untuned; it was not tested against real photo libraries.
  • Windowed comparison. Above 1500 photos the finder compares each photo only with its 150 neighbours in two orderings (capture date; pixel count then file size), so a near-duplicate pair far apart in both can be missed.
  • Shared container. Inbox items are confidential but not authenticated, and the vault's public key in the App Group container is not pinned (threat model rows 26 and 27). The share extension works from a temporary unencrypted copy made by iOS (row 33).
  • Decoy vault. The Legacy Kit and the recovery-code test of the backup safety check are reachable from inside the decoy vault, which should not have a recovery code (security architecture section 16).
  • Not executed or device-tested. The new unit tests were counted, not run, for these documents; the camera, share sheet, App Group container, document scanner and QR scanning have not been tested on a physical device.

Sources

Primary sources behind the statements about iOS and cryptography on this page. The linked pages are in English.