Vaultumi security architecture
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.
Written from the app's source as of 2026-10-30 (app version 1.0, iOS 17 and later). Sections 12 to 17 were added on 2026-10-31 for features written after the first version of this document (Vault Drop, Intruder log, documents and text search, similar photos, Legacy Kit, backup safety check). This is a description of what the code does, not a certification. It has not had an independent review (see the threat model, "What requires external review"). The project's own review of the earlier code, published as the security review notes, does not cover the newer features.
1. Design goals
- Everything the user stores is encrypted on the device before it is written to app storage.
- No server, no account, no analytics. There is nothing to breach remotely.
- A forgotten PIN is recoverable (recovery code), but nobody without a secret can open the vault.
- Failure must never silently lose data: an item either exists completely or not at all, and no number of wrong PINs deletes anything.
- Only Apple-provided cryptographic primitives (CryptoKit, CommonCrypto, Security). No third-party code.
2. Key hierarchy
PIN (6 digits) Recovery code (160-bit, Crockford base32) Face ID
| | |
scrypt N=2^15 r=8 p=1 scrypt N=2^16 r=8 p=1 random 32-byte key K_bio
salt = 16 random bytes (header) salt = 16 random bytes (header) stored in Keychain:
| | .biometryCurrentSet,
HKDF-SHA256 HKDF-SHA256 WhenPasscodeSetThisDeviceOnly
salt = pepper (32 B, Keychain) salt = empty |
info = "vaultumi.v1.pin-kek" info = "vaultumi.v1.recovery-kek" |
| | |
PIN KEK (256-bit) Recovery KEK (256-bit) K_bio (256-bit)
| AES-256-GCM wrap | AES-256-GCM wrap | AES-256-GCM wrap
| AAD "master|pin|<vaultID>" | AAD "master|recovery|<vaultID>" | AAD "master|bio|<vaultID>"
+--------------------+-----------------------+---------------------------------------+
v
MASTER KEY (256-bit random, one per vault; stored ONLY wrapped, in header.json)
|
HKDF-SHA256(salt "vaultumi.v1", info = label)
+------------------+---------------------+---------------------+
| | | |
wrap key meta key thumb key (per-file DEKs are NOT
"vaultumi.v1.wrap" "vaultumi.v1.meta" "vaultumi.v1.thumb" derived: they are random)
| | |
wraps each DEK seals item metadata seals thumbnails
AAD "dek|<id>|<role>" AAD "meta|<id>" AAD "thumb|<id>"
and album list
AAD "albums"
|
per-file DEK (256-bit random, one for "main", one for "comp" of a Live Photo)
|
VMF1 chunked AES-256-GCM of the media, 1 MiB chunks, AAD = header || "main|<id>" or "comp|<id>" || final-flag
<id> is the item UUID in upper-case string form (UUID.uuidString). <vaultID> is the UUID in header.json.
Consequences:
- Changing the PIN re-wraps only the master key. Media is untouched. A fresh salt is generated each time.
- Compromise of one DEK exposes only that file.
- Turning Face ID on or off never changes the PIN wrapping.
- The wrap AAD includes the item id and the role (
mainorcomp), so a wrapped DEK cannot be moved to another item or role.
2.1 Keys added after the first version of this document
Three more keys are derived from the master key in exactly the same way as wrap, meta and thumb (HKDF-SHA256, salt "vaultumi.v1", info = label, 32 bytes):
| Label | Seals | AAD |
|---|---|---|
vaultumi.v1.ocr | items/<id>.ocr, the recognized text of a document | the text ocr, a vertical bar, the item id |
vaultumi.v1.fp | items/<id>.fp, a cached image feature print | the text fp, a vertical bar, the item id |
vaultumi.v1.inbox | inbox.key.sealed, the private half of the inbox key pair | inbox.key.v1 (constant, one file per vault) |
The inbox key pair itself is not derived: it is a random X25519 key pair created on first use (section 12). Its public half is not secret.
3. Algorithms and parameters
| Purpose | Algorithm | Parameters |
|---|---|---|
| PIN stretching | scrypt (RFC 7914), own implementation of ROMix/BlockMix/Salsa20/8 on CommonCrypto PBKDF2-HMAC-SHA256 | N=2^15, r=8, p=1 (32 MiB), 16-byte random salt. Scrypt refuses any parameters needing more than 128 MiB |
| Recovery code / backup secret stretching | scrypt | N=2^16, r=8, p=1 (64 MiB), 16-byte random salt |
| Key derivation / separation | HKDF-SHA256 (CryptoKit) | labels listed in section 2 |
| Small sealed blobs (wrapped keys, metadata, thumbnails, album list) | AES-256-GCM, random 96-bit nonce, layout nonce(12) ‖ ciphertext ‖ tag(16) | AAD per section 2 |
| Media files | AES-256-GCM in 1 MiB chunks ("VMF1"); nonce = 8 random bytes prefix + 32-bit chunk counter | per-file random 256-bit key |
| Integrity of imported content | SHA-256 of plaintext, recorded in sealed metadata | |
| Randomness | SecRandomCopyBytes | |
| Recovery code | 20 random bytes encoded as 32 Crockford base32 characters (8 groups of 4) | 160 bits |
| Backup key | scrypt(NFC(secret), salt) then HKDF-SHA256 info vaultumi.backup.v1 | scrypt N=2^16 by default; reader accepts logN <= 17, r <= 8, p <= 2 (at most 128 MiB) and rejects anything larger |
| Inbox items (Vault Drop, Intruder log) | Curve25519 ECDH (CryptoKit) with a fresh ephemeral key per item, then HKDF-SHA256 (salt = ephemeral public key) for a stream key and a metadata key; AES-256-GCM | VMIB v1, section 12.4 |
| Legacy Kit | Shamir secret sharing over GF(2^8) (polynomial 0x11B), own implementation; 4-byte HMAC-SHA256 tag; 4-byte SHA-256 checksum | 2 to 5 cards needed, 2 to 7 cards made (the library allows up to 10 shares), 20-byte secret |
Notes and honest caveats:
- scrypt is our own implementation because Apple ships PBKDF2 but no memory-hard KDF and the app allows no third-party code. It is checked against the three RFC 7914 test vectors, but it is custom code and still requires external review.
- The pepper is the HKDF salt for the PIN KEK, not the scrypt salt. HKDF-Extract is HMAC keyed with the salt, so without the pepper an attacker cannot verify a PIN guess. The pepper is acting as a secret key.
- A 6-digit PIN has only 10^6 possibilities. Protection comes from (a) the pepper, which is not in the vault files and, being device-only, is in no backup, (b) scrypt cost, (c) the attempt limiter. Anyone who obtains both the vault files and the pepper (that is, who extracts the pepper from the device itself: a compromised or jailbroken device, or a forensic extraction) can brute-force the PIN offline; at 32 MiB scrypt this costs about a quarter of a second per guess on an iPhone-class CPU and less on dedicated hardware. A PIN is a convenience lock, not a strong secret. The recovery code (160 bits) and a strong backup passphrase are the strong secrets.
- Backup passphrases: the secret is normalised to NFC (
precomposedStringWithCanonicalMapping) before derivation. A generated 160-bit secret is preferred. A passphrase the person chooses needs at least 12 characters and must passPassphrasePolicy(no heavy repetition, nothing trivially common). PINPolicyrejects obviously guessable PINs (all same digit, ascending or descending runs, a small list of patterns).- Swift
SymmetricKeymemory cannot be reliably zeroised. The code wipes theDatacopies it makes of key material (Data.wipe,defer), but keys held asSymmetricKeylive until the object is released. See section 8.
4. Storage layout
Inside the app sandbox, under Application Support/Vaultumi/:
<base>/main/ primary vault (directory names are deliberately generic: "main", "alt")
<base>/alt/ decoy vault, only when one has been created
header.json key wrapping (plain JSON; every secret in it is sealed)
albums.vlt sealed album list
items/<UUID>.main VMF1 file: the original
items/<UUID>.comp VMF1 file: Live Photo video (only for Live Photos)
items/<UUID>.thumb sealed JPEG thumbnail (512 px longest side)
items/<UUID>.meta JSON envelope {v, id, wrappedDEK, wrappedCompanionDEK?, sealedMeta} <- the commit point
items/<UUID>.ocr sealed OCR text of a document (sidecar, optional; section 14)
items/<UUID>.fp sealed image feature print (sidecar, optional; section 15)
inbox.key.sealed the vault's inbox private key, sealed under the inbox key (primary vault only; section 12)
intruder/<UUID>.vmib Intruder log photos in the VMIB format (primary vault only; section 13)
staging/<UUID>/ in-flight imports, wiped at every launch
tmp/VaultumiTemp/<UUID>/ short-lived decrypted files (viewing a video, sharing, export)
<App Group container>/VaultumiInbox/ shared with the Vault Drop extension (section 12.3)
vault.pub 32 bytes, the vault's X25519 public key (not secret)
slots ASCII integer, free-plan slots left (-1 = unlimited)
items/<UUID>.vmib sealed items waiting to be imported (and <UUID>.vmib.partial while being written)
header.jsonholds:version,vaultID,createdAt,pin(kdf name, scrypt params, salt, PIN length, wrapped master), optionalrecovery(params, salt, wrapped master), optionalbiometric(wrapped master). The PIN length (6) and KDF parameters are visible; nothing in it opens the vault on its own.- Files and directories are created with iOS file protection
completeUnlessOpen; atomic writes use.completeFileProtectionUnlessOpen. The shared inbox directory in the App Group container is different: it usescompleteUntilFirstUserAuthentication, because the share extension and the lock screen must be able to add items while the phone is locked (section 12.3). UIFileSharingEnabledandLSSupportsOpeningDocumentsInPlacearefalse: the vault is not visible in the Files app or iTunes file sharing.- Vault files are in device backups, the pepper is not. Vault files are not excluded from iCloud/iTunes device backups: a restored phone still has the encrypted files. The pepper and the attempt counter are
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: they never leave the device and are in no backup, encrypted or not. A phone restored from a device backup (or set up on a new device) therefore has the vault files but no pepper, the PIN cannot open it, and only the recovery code (UnlockError.needsRecovery) or a manual.vaultumibackupdoes. A holder of a device backup without the recovery code gets ciphertext plus a header they cannot attack offline without the pepper. The decoy vault has no recovery code, so it cannot be reopened after a device migration.
What an attacker with a copy of the vault files learns without any key: the number of items, approximate size of each (ciphertext length), file modification times, whether an item has a companion file, the existence of an alt directory, KDF parameters and the PIN length. Filenames, dates, albums, media type, dimensions, hashes and thumbnails are all inside sealed blobs.
The sidecars add two more things such an attacker can see: a .ocr file exists only for documents, and its size grows with the amount of recognized text; a .fp file exists for photos that the Similar photos scan has processed. The presence of inbox.key.sealed shows that the vault has been opened with a version that has Vault Drop, and an intruder/ directory shows that the Intruder log has received at least one photo. Their contents are sealed.
5. Item lifecycle and crash safety
An item exists if and only if its .meta file exists.
Ingest (VaultStore.ingest):
- Check free space (needed = estimated size + 64 MiB margin) before writing anything.
- Reject if the same photo library asset id or the same content hash is already stored (
VaultError.duplicate). - Encrypt into
staging/<id>/mainwith a fresh random DEK, streaming; photo-library resources are streamed into the encryptor so no plaintext file is written for them. - Verify: re-open the staged file, authenticate every chunk, decrypt in memory and compare length and SHA-256 with what went in. Failure throws
verificationFailed. The same is done for the Live Photo companion. - Generate the thumbnail. This decrypts the staged file to a temporary file in
tmp/VaultumiTemp(needed by ImageIO/AVFoundation), which is deleted immediately afterwards. - Seal the thumbnail; move main/comp/thumb from staging into
items/(renames on the same volume). - Write
<id>.metalast, atomically. This is the commit. - On any error or cancellation: remove moved files and the staging directory; nothing partial remains.
Recovery in VaultStore.load(), which runs at every unlock and setup, not only at launch: delete staging/ wholesale; read every .meta; any items/ file whose id has no .meta is an orphan and is deleted; unreadable metadata is reported as an integrity issue but does not stop loading the other items. While an ingest of the same vault is running, load() skips the staging and orphan cleanup, so it cannot delete files of an import in flight. All metadata and album read-modify-write sequences and the ingest commit are serialised by a recursive mutation lock, and duplicates (asset id, content hash) are re-checked at commit.
Delete: moving to Recently Deleted only sets deletedAt in sealed metadata (reversible). Purge removes .meta first, then the blobs; a crash in between leaves orphans that the next load() removes. This removes the app's encrypted files; it does not claim secure erasure of flash storage. Because every file is encrypted under its own key, destroying the metadata that holds the wrapped key is the practical erasure.
Integrity check (verifyAll(deep:)): existence, size, and optionally full authentication plus SHA-256 against the recorded hash.
6. Lock and unlock flows
PIN unlock (VaultManager.unlock(pin:)):
- Return
lockedOut(retryIn:)if the limiter says wait. - Load the primary header, and the decoy header if one exists.
- Read the pepper from the Keychain. If absent:
needsRecovery. - Derive the PIN KEK and try to open the primary master key. If a decoy exists, the decoy derivation also always runs, so timing does not distinguish which vault a PIN belongs to (the derivation for a non-matching vault still costs one scrypt run). Acknowledged limitation: when no decoy exists only one scrypt run happens, so the extra time of a failed attempt can reveal that a decoy exists.
- Success: reset the attempt counter, open the matching vault (primary or decoy). Failure: record it and throw
wrongPIN(failures:retryIn:).
Rate limiter (RateLimiter): 1-4 failures no delay; 5: 30 s; 6: 60 s; 7: 5 min; 8: 15 min; 9 and more: 1 h. It never deletes anything. Time is measured with the monotonic clock (CLOCK_MONOTONIC_RAW, which keeps counting during sleep and ignores the date and time settings) together with the boot session id (kern.bootsessionuuid): changing the date or time does not shorten a lockout, and after a reboot the current delay restarts in full. The counter is stored in the Keychain (pin.attempts.v1, AfterFirstUnlockThisDeviceOnly) so it survives app reinstall. A successful primary-vault unlock resets it; a decoy-vault login does not, otherwise the holder of the decoy PIN could interleave decoy logins with guesses at the main PIN. The PIN re-check before sensitive actions (change PIN, new recovery code, decoy creation, creating a backup, delete all data) goes through verifyCounted, which shares the same limiter and reports a lockout instead of a wrong PIN. The limiter is a speed bump against someone using the app UI; it does not slow an attacker who runs the KDF offline (see section 3).
Face ID (unlockWithBiometrics): reads K_bio from the Keychain behind the system Face ID prompt and unwraps the master key. It unlocks only the primary vault, never the decoy. If the enrolled biometrics change, or the device passcode is removed, the Keychain item becomes unusable: the app reports biometricsInvalidated, the PIN keeps working, and Face ID can be enabled again. A cancelled prompt is not counted as a failed attempt. A failed or locked-out biometric match is reported as "try again or use your PIN" (biometricsCancelled), not as "biometrics changed"; only a missing or invalidated Keychain item reports biometricsInvalidated.
Recovery (recover(code:newPIN:)): the canonicalised code (upper-case, separators removed, O to 0, I and L to 1) is run through scrypt and tries to open the recovery wrap; on success the PIN is re-wrapped under the new PIN (creating a new pepper if the Keychain has none). Recovery attempts are not rate limited because 160 random bits cannot be guessed; each attempt costs one 64 MiB scrypt run. The decoy vault has no recovery code.
Regenerating the recovery code replaces the wrap; the old code stops working.
Auto-lock and privacy cover: the app locks when it moves to the background (dropping the VaultSession and clearing TempSpace), immediately or after the chosen delay. The delay is timed on the monotonic clock (DeviceClock.uptime()), so changing the date or time does not extend it. Auto-lock is held while a screen showing a one-time secret (recovery code, backup secret) is visible, so the person can switch to another app to copy it. An opaque cover hides the app switcher snapshot, and a cover is shown while the screen is recorded or mirrored. iOS cannot block screenshots, and AirPlay output of video is not covered by the screen-capture cover (see threat model). The Change PIN row is hidden inside the decoy vault.
Full erase (eraseEverything): deletes every Keychain item the app created (pepper, attempts, biometric keys), removes the whole Vaultumi base directory and clears temp files.
7. Keychain use
Service com.vaultumi.secrets, class kSecClassGenericPassword.
| Account | Content | Protection |
|---|---|---|
pin.pepper.v1 | 32 random bytes | kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (never leaves the device, in no backup; updated in place so a crash cannot lose it) |
pin.attempts.v1 | failure count, last failure time, monotonic uptime and boot session id (JSON) | AfterFirstUnlockThisDeviceOnly |
bio.<vaultID> | 32 random bytes K_bio | kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly with SecAccessControl flag .biometryCurrentSet |
exists never prompts (LAContext.interactionNotAllowed). The real Keychain code path is not exercised by unit tests.
8. Memory and temporary files
- Keys exist in memory only while a
VaultSessionis open. Raw keyDatacopies are zeroed after use (wipe).SymmetricKeyvalues are released but cannot be guaranteed zeroed; a memory dump of an unlocked, compromised device can reveal keys (out of scope). - Large files are processed one chunk (1 MiB) at a time. The backup writer and restore never load a whole file.
- Decrypted bytes touch disk only in
tmp/VaultumiTemp/<UUID>/(viewing videos, share/export sheets, thumbnail generation during import). Directories are created withcompleteUnlessOpen. They are removed when the viewer or share sheet finishes, when the vault locks and at launch (TempSpace.clearAll), which is called on lock and at launch.clearAllalso removestmp/VaultumiBackups,tmp/VaultumiRestore-*andDocuments/Inbox. - The sealed thumbnail is decrypted to memory only.
- Documents: a PDF of up to 48 MiB is decrypted into memory for viewing and OCR (
PDFLoader); a larger one goes throughtmp/VaultumiTemplike a video. Scanning builds the PDF in memory and writes it to a temporary file for the duration of the encrypted import. Saving an image as a document wraps it in a temporary PDF file the same way. Recognized text lives in the in-memoryOCRIndexwhile the vault is unlocked and is cleared on lock and on "Delete all data". - Vault Drop: the share extension never writes plaintext itself, but the system hands it a temporary copy of the shared file, outside Vaultumi's control (section 12.6). Legacy Kit: "Save cards as PDF" writes the cards in the clear to
tmpand hands the file to the share sheet or printer; the app removes its copy when the screen closes, copies saved elsewhere are the person's to delete. - If the app is killed while a temp file exists, the file remains until the next launch cleanup. iOS may also snapshot or cache things we do not control (for example the share sheet or a system video player); see threat model.
- UserDefaults is used only for non-secret preferences and a cached copy of the last verified entitlement (UI convenience, never the source of truth; see the internal purchase notes).
9. Backup (summary)
Manual encrypted backup .vaultumibackup (format "VMBK" v1) is independent of device, Keychain and PIN: key = HKDF(scrypt(NFC(secret), salt)). Everything is encrypted and authenticated; the whole archive is validated before any restore; restore merges and never deletes. On restore, file names and type identifiers from the backup are sanitised, record sizes are capped and duplicate ids are rejected (details in the internal backup-format notes). Full byte layout and algorithms are in the internal backup-format notes. Documents travel in a backup as ordinary items. OCR sidecars, feature prints, the inbox key, waiting inbox items and Intruder log photos are not in a backup (the internal backup-format notes section 3.5).
10. Dependency and network audit
Audit method: read import statements and searched the source for URLSession, URLRequest, NWConnection, WKWebView and http (as of 2026-10-30 the only match for http is the Apple DTD URL in Info.plist). Re-run before every release (see the internal release checklist).
Frameworks imported: Foundation, SwiftUI, Observation, CryptoKit, CommonCrypto, Security, LocalAuthentication, StoreKit, AVFoundation, ImageIO, UniformTypeIdentifiers. UI-layer frameworks (re-verify against the source before release): PhotosUI/Photos (import, optional delete of originals, save on export), AVFoundation capture (private camera, and the front camera still for the Intruder log), UIKit bridging where needed. Added with the newer features: Vision (on-device text recognition and image feature prints), VisionKit (document camera, QR scanning of Legacy Kit cards), PDFKit (document viewing and PDF building). All are Apple frameworks and all work on the device; the allowed-imports list in includes them. The Vault Drop extension imports only UIKit, UniformTypeIdentifiers, Foundation and CryptoKit and compiles five source files (section 12.5).
Swift Package Manager / CocoaPods / Carthage dependencies: none (the project declares no packages).
Conclusion: the code reviewed makes no network requests of its own. The only network traffic is performed by Apple's StoreKit and the App Store (product lookup, purchase, entitlement refresh) on the app's behalf. There are no analytics, advertising, crash-reporting or other SDKs, no backend, no accounts. Anything the UI adds later (for example an "open support page" link opening Safari) must be reviewed for this claim.
Not covered by this audit: network behaviour of iOS itself (iCloud device backup if the user has it on, share sheet destinations the user chooses, PhotoKit iCloud Photos download of originals when importing an asset that is stored in iCloud).
11. Known limitations (summary)
- No protection against a jailbroken/compromised device, malware, or someone who watches the screen.
- The PIN space is 10^6. It rests on scrypt, the device-only pepper and rate limiting, and is weak against an offline attack by someone who has both the files and the pepper.
- A phone restored from a device backup cannot be opened with the PIN (the pepper is not in backups); it needs the recovery code or a manual backup.
- The decoy vault has no recovery code and cannot be reopened after a device migration.
- Decoy vault is not forensic-proof; an
altdirectory and header exist, and behaviour may differ (for example storage use). Timing reveals that a decoy exists: a failed attempt runs two scrypt runs only when a decoy exists. - The master key is not rotated when the PIN changes: an old header copy, the old PIN and the same device pepper still open the vault.
- Purge is unlink-only; there is no secure erase.
- "Delete originals" removes the original
.photoresource, so an edited photo's edit is not what was imported. - AirPlay output of video is not covered by the screen-capture cover.
- Metadata leakage listed in section 4.
- The hand-written scrypt and the custom file formats have not been externally reviewed; external review is still required.
- Deleted originals in the Photos library go to Recently Deleted for about 30 days and may exist in iCloud or other synchronised copies; Vaultumi does not claim secure erasure.
- Vault Drop: the shared file exists unencrypted for a moment in system temporary storage; sharing from Photos leaves the original in Photos; the waiting items reveal their number, sizes, times and kind to anyone with the device files; the shared container is writable by anything with the same access (section 12.7).
- Intruder log: it can photograph someone other than the owner; it needs Premium, the camera permission and an explicit consent step; it works only on the main vault's lock screen (section 13).
- Documents: OCR text is recognized by Vision and kept in a sealed sidecar that is not in backups; older app versions cannot read documents or restore backups that contain them (section 14).
- Legacy Kit: there is no timer or release mechanism; any k cards open the vault together with a copy of the vault files; the Shamir code is hand-written and unreviewed (section 16).
- None of the features in sections 12 to 17 is covered by the security audit in security review notes.
See threat model for the full analysis.
Features added after the first version of this document
Sections 12 to 17 describe, from the source, six features added after the project's own review in security review notes. None of this has been independently reviewed.
12. Vault Drop and the sealed inbox
Purpose: let a person share a photo, video or PDF into Vaultumi from the iOS share sheet, and let the lock screen store an Intruder log photo, without any vault key being available. Both run while the vault is locked, so they cannot use the vault's keys. Instead they encrypt to a public key and only the unlocked vault can decrypt.
12.1 Inbox key pair
- Each primary vault has a random X25519 key pair (
InboxKeys). The private key (32 bytes) is sealed asSealedunderinboxKeywith AADinbox.key.v1and stored as<vault>/inbox.key.sealed. It exists in the clear only in memory while the vault is unlocked. VaultManager.prepareInboxruns after every primary-vault unlock. It loads or creates the pair and publishes the public key to the inbox location (vault.pub), rewriting the file only if it differs. A damaged or unauthenticatedinbox.key.sealedis replaced with a new pair so that Vault Drop keeps working; items sealed to the old key can then no longer be opened (they are reported as unreadable and can be discarded).- The decoy vault never gets an inbox key, never publishes a key and never drains the inbox.
12.2 Where the inbox lives
InboxLocation.shared() uses the App Group container named in the Info.plist key VaultumiAppGroup (Release: group.com.vaultumi; Debug: group.com.vaultumi.debug; set in the project settings), directory VaultumiInbox. Where no group container exists (unsigned Simulator builds, unit tests) it falls back to Application Support/VaultumiInbox; tests and UI tests always inject their own location.
12.3 App Group container contents
VaultumiInbox/
vault.pub 32 raw bytes: the vault's X25519 public key. Not secret.
slots ASCII integer: how many more items the free plan can hold, or -1 for unlimited. Written by the app.
items/<UUID>.vmib complete sealed items
items/<UUID>.vmib.partial an item being written; renamed to .vmib when complete
The items/ directory is created with file protection completeUntilFirstUserAuthentication, as are vault.pub and slots. That class is deliberate: the extension must be able to write while the phone is locked after its first unlock since boot, which a stronger class would prevent. The container holds no key that opens anything, only ciphertext, a public key and a counter.
12.4 VMIB v1: the item file
All integers are big-endian. ‖ is concatenation.
offset size field
0 4 magic "VMIB"
4 1 version = 1
5 1 kind: 1 = shared file, 2 = intruder photo
6 32 ephemeral X25519 public key (raw)
38 16 item id (the 16 bytes of a UUID) ---- bytes 0..53 are the "fixed header" (54 bytes)
54 2 sealed metadata length m (u16; the reader accepts 28 <= m <= 4096)
56 m sealed metadata: nonce(12) ‖ ciphertext ‖ tag(16), AES-256-GCM, AAD = the 54-byte fixed header
56+m ... a VMF1 stream (the internal backup-format notes, section 2) of the payload
Keys:
shared = X25519(ephemeral private, vault public) (the reader computes X25519(vault private, ephemeral public))
stream = HKDF-SHA256(IKM = shared, salt = ephemeral public key (32 bytes), info = "vaultumi.v1.inbox.item", L = 32)
meta = HKDF-SHA256(IKM = shared, salt = ephemeral public key (32 bytes), info = "vaultumi.v1.inbox.item.meta", L = 32)
- The sealed metadata is JSON
{filename, type, createdAt (ISO-8601), attempts?}. The sealer truncatesfilenameto 200 characters andtypeto 100.attemptsis set for intruder photos (how many wrong PINs). - The VMF1 stream uses
streamas its key and the context stringinbox|<kind>|<UUID>where<kind>is the number 1 or 2 and<UUID>is the upper-case UUID string. The default 1 MiB chunk size is used. The stream starts at offset56 + m. - The file name must be
<UUID>.vmibwith the same UUID as in the header; a file renamed to another item's name is rejected by the reader. - The ephemeral private key is discarded after sealing, so the sealer (the extension) cannot reopen what it wrote. Each item has its own shared secret.
- Anyone who has the public key can create a valid item. Items are confidential to the vault but not authenticated as coming from the person (section 12.7).
- Not encrypted, and readable without any key: magic, version, kind, ephemeral public key, item id, the length of the sealed metadata (which roughly tracks the file name length), the stream's VMF1 header and its ciphertext length (the payload size), the file name and the file times.
- The reader rejects, without crashing: bad magic or version, unknown kind, short or oversized metadata, tag failures, a stream whose size arithmetic is impossible, truncated or appended data, and an item swapped under another item's file name.
12.5 What the share extension can and cannot do
The extension is the target VaultumiDrop (bundle id com.vaultumi.drop; Debug com.vaultumi.debug.drop), embedded in the app (iPhone and iPad, TARGETED_DEVICE_FAMILY: "1,2"). It compiles only a handful of source files: the share screen, the inbox format and sealer, and the shared crypto and chunk-cipher code. No vault, Keychain, PIN, backup or UI code from the app is in the extension. Its privacy manifest declares no tracking, no collected data and no required-reason APIs.
It can:
- Read
vault.pubandslots, and refuse early (notEnabled,noFreeSlots,inboxFull) before reading any shared file. - Accept photos, videos and PDFs (types that conform to
public.image,public.movieorcom.adobe.pdf), up to 50 attachments per share. Everything else is refused with a message ("Vaultumi can keep photos, videos and PDFs"). - Stream each file from the temporary URL that the system gives it through AES-GCM into
<UUID>.vmib.partial, one 1 MiB chunk at a time, then rename it. The sealed metadata carries the file name and type. - Add at most 256 files and 2 GiB in total to the inbox (including
.partialfiles), and, for shared files, no more waiting files than theslotsvalue allows. - Show a small status card. It has no access to vault contents and shows none.
It cannot:
- Open any item, including the ones it just wrote.
- Read or write vault files, the Keychain items, the pepper, the PIN or any key.
- Tell whether the vault is locked, or import anything into the vault. Import happens only in the app after an unlock.
- Reach the network (no networking code is compiled in).
- Delete or change the original. When the file comes from Photos, the original stays in Photos.
12.6 Draining the inbox (app side)
InboxDrain.run runs for the unlocked primary vault only, automatically after each unlock and when the person taps "Import now".
- Each file is opened with the private key. Files that cannot be opened (damaged, tampered, sealed to another key) stay in the inbox and are counted as unreadable; the person can discard them.
- A shared file is imported through the normal
VaultStore.ingest, with the inbox item's UUID as the vault item id. The inbox file is removed only after the vault committed the item, or already had it. A retry after an interruption therefore yields exactly one item. A content duplicate is consumed. When the free plan is full, the file waits. - An intruder photo is moved into
<vault>/intruder/(section 13). - A
.partialfile older than one hour is deleted. The app publishes the free-plan slot count toslotswhen it changes (-1for Premium). - "Delete all data" removes the whole inbox directory, including
vault.pub.
12.7 Plaintext and trust boundaries
- Temporary copy.
NSItemProvider.loadFileRepresentationgives the extension a temporary file, which for a photo or video is an unencrypted copy of the original. It exists in the extension's temporary storage until iOS removes it after the completion handler returns. Vaultumi cannot encrypt, shorten or wipe it, does not choose its file protection class, and cannot clean it up if iOS kills the extension first. Not testable in the Simulator; see the internal release checklist. - The source file (in Photos, Files or another app) is untouched.
- Writable container. Anything that can write to the App Group container can add forged items (they will be imported if they are valid and the plan allows), fill the inbox so the extension and the Intruder log are refused, or replace
vault.pubwith its own key so that later shares are sealed to that key. The app rewrites the correctvault.pubat the next unlock; items sealed to the wrong key then show as unreadable. Processes that can write there are apps signed by the same team with the same App Group, and anything with device-level access. - Metadata visible without a key: see 12.4, plus the
slotsvalue, which says whether the plan is Premium and, on the free plan, how many items are left.
13. Intruder log
- Off by default, Premium only, and turned on only through
IntruderCamera.enable(), which first needs the person's confirmation on a consent sheet and then the camera permission at that moment. Preferences are two plainUserDefaultsvalues (intruder.enabled,intruder.threshold); the photo is never stored there. - Trigger: after N consecutive wrong PINs on the primary vault's lock screen (N is 2 to 5, default 3; the count is the rate limiter's consecutive failure count), the app takes one photo per streak. A correct primary PIN ends the streak. A PIN that opens the decoy vault takes no photo and does not reset the streak. A rate-limit lockout is not an attempt.
- Capture:
AVCaptureSessionwith the front camera, no preview, about 0.8 s for exposure to settle, 6 s timeout, JPEG. It runs only in the foreground, on the app's own lock screen. The iOS camera indicator is on while it runs. It fails harmlessly where there is no camera. - Storage: the JPEG is sealed with
InboxSealer.seal(data:)using only the public key, as kind 2, with metadataintruder-<time>.jpg,public.jpeg, the time and the attempt count. It waits in the inbox. At the next primary unlock it is moved to<vault>/intruder/<UUID>.vmib. - These photos are deliberately not vault items: they are not in the gallery, do not count against the free plan, are not in backups, and are deleted by "Delete all data". They can be opened only with the inbox private key, so only while the vault is unlocked. The person can view, export (plaintext copy via the share sheet) and delete them.
- A photo may show someone other than the owner. See threat model.
14. Documents and OCR
- A document is an ordinary vault item with
kind = document(a PDF). It has.main(VMF1),.thumband.metalike any item, plus the optionalpageCountfield in its sealed metadata. Sources: a scan with the VisionKit document camera (Premium), a PDF from Files, a PDF shared through Vault Drop, or an image saved as a one-page PDF. The main file is encrypted and verified by the normal ingest path. - Recognized text is stored in
items/<id>.ocr: JSON{version: 1, pages: [{lines, words with boxes}], truncated}, sealed withocrKey, AADocr|<id>. It is made on the device by Vision (VNRecognizeTextRequest, accurate level, language correction on, language detection on). Each page is rendered in memory at up to 2000 px on the longer side and released; at most 150 pages are recognized (truncatedrecords more). - The sidecar is not part of
.metaand not in backups. A document without a readable sidecar is recognized again the next time Search is used or the document is opened. A tampered sidecar fails authentication and is replaced. Purge removes it, andVaultStore.load()removes orphans. - Search:
OCRIndexdecrypts one sidecar at a time into an in-memory index (case, diacritics and width folded). It is cleared when the vault locks and on "Delete all data". Nothing is written to disk by search. - Version compatibility:
documentis a newMediaKindvalue. A build that does not know it cannot read such an item (and cannot restore a backup that contains one). shows the vault itself still loads. See the internal backup-format notes section 3.5.
15. Similar photos
- The scan compares photos and GIFs that are not in Recently Deleted. Videos, Live Photos and documents are never compared.
- For each item, the stored thumbnail (512 px, JPEG) is decrypted in memory and passed to Vision (
VNGenerateImageFeaturePrintRequest). The archived observation is sealed withfpKey, AADfp|<id>, and stored asitems/<id>.fp. Nothing new in the clear touches disk. Each print is written atomically, so a cancelled scan leaves only complete prints and the next run resumes. A damaged or swapped.fpfile fails authentication and is recomputed. - Clustering: two photos are similar when the Vision distance is below 0.4 (a value that has not been tuned on real libraries). Groups are built with union-find. Up to 1500 photos are compared all against all. Above that the comparison is windowed: each photo is compared with its 150 neighbours in two orderings (capture date; pixel count then file size), so a pair that is far apart in both can be missed.
- The suggested "best" photo is the one with the most pixels, then the largest file, then the earliest. The scan changes nothing. Moving the others to Recently Deleted is Premium and uses the normal trash (restorable). Feature prints are not in backups and are removed on purge.
16. Legacy Kit
- What is split. A new recovery code (20 random bytes, as in section 3) is created, which replaces the existing one: the old code and any printed copy stop working (
regenerateRecoveryCode), after a counted PIN re-check. The 20 bytes are split with Shamir secret sharing. Combining k cards gives the 20 bytes back and the same code text, which opens the same recovery wrap inheader.json. No new key, wrap or file format is added to the vault. - Math. Per secret byte, a random polynomial of degree k-1 over GF(2^8) (AES polynomial 0x11B) whose constant term is that byte; the share with index x holds the polynomial's value at x, x = 1..n. Rebuilding uses Lagrange interpolation at 0. Coefficients come from
SecRandomCopyBytes. The field multiplication has no data-dependent branches or table lookups, but Swift gives no constant-time guarantee. - Limits. The kit makes 2 to 7 cards and needs 2 to 5 of them (the library itself allows up to 10 shares and a secret of up to 64 bytes).
- Card format ("VLK1").
version (1) ‖ k ‖ n ‖ index (1..n) ‖ set id (4 random bytes) ‖ secret tag (4) ‖ y bytes (20) ‖ checksum (4), 36 bytes. The checksum is the first 4 bytes of SHA-256 of everything before it, so a typing mistake is reported as such before any maths. The tag is the first 4 bytes of HMAC-SHA256 (key = set id, message ="vaultumi.legacy.v1" ‖ k ‖ n ‖ secret), identical in every card of a set; after interpolation the tag proves the rebuilt secret is the right one, so too few cards, or a wrong card that passed its checksum, never yield a wrong code silently. The text form is Crockford base32 in groups of 4 (58 characters); the QR payload isVLK1:plus the same characters without dashes. - Known weakness. Because the tag is a function of the secret and is on every card, anyone holding cards can test a guess of the secret against it, so the cards leak up to 32 bits about the secret. For a random 160-bit secret this is not exploitable in practice. It also means the scheme is not information-theoretically perfect.
- What the cards show. The card index, k, n and the set id are printed in the clear (k and n tell a holder how many cards are needed). A card is not stored by the app. After the cards are shown the person confirms they have saved them; leaving the screen clears them from memory. "Save cards as PDF" and "Print" create a PDF with the cards in the clear; the person must delete it.
- Combining. The Combine cards screen in Settings accepts typed, pasted or QR-scanned cards (QR scanning uses VisionKit and the camera, devices only), reports specific errors, and can test the result against the current vault's recovery wrap (
verifyRecoveryCode, which changes nothing). It is reached from Settings, which means from an unlocked vault; the rebuilt code is then entered on the Forgot PIN screen of the vault it belongs to. - Decoy vault: the Legacy Kit, the recovery-code test and recovery-code creation are unavailable inside the decoy vault (
makeRecoveryWrapandregenerateRecoveryCodethrow for it), so the decoy never gets a recovery wrap. - Not provided. Nothing here releases the cards after a delay or after the owner's death; there is no server and no timer. Cards are only as safe as their holders. If more than n-k cards are lost, this path is gone.
- Needs external review. The implementation is hand-written (no Apple framework offers secret sharing) and has not been reviewed by a cryptographer.
17. Backup safety check
- The person picks a
.vaultumibackupfile and types its secret (and, optionally, a recovery code). The file is copied totmp/VaultumiRestore-<UUID>.vaultumibackup, opened and fully validated byBackupReader.validate(every chunk and every hash, the internal backup-format notes section 4), then the copy is deleted. Nothing is written to the vault. BackupCoverage.comparematches the backup's manifest to the vault by item id and main-file SHA-256: items covered, items in the vault but not in the backup (with their size), items only in the backup, items with the same id but different content, and items a restore would add a second time because the same content is already in the vault under another id. Items in Recently Deleted count on both sides.- The optional recovery code is checked with
verifyRecoveryCode(one scrypt run against the recovery wrap), kept only for the duration of the call, and the field is cleared when the screen closes. It changes nothing. - It also runs
verifyAll(deep: false)(files exist, sizes match) and records the time of the last check in settings. - It proves that this file opens with this secret and matches the vault today. It does not prove a restore on another device or iOS version, or that the secret has been stored safely.
Sources
Primary sources behind the statements about iOS and cryptography on this page. The linked pages are in English.
- RFC 7914: The scrypt Password-Based Key Derivation Function
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF)
- RFC 7748: Elliptic Curves for Security (X25519)
- NIST SP 800-38D: Galois/Counter Mode (GCM) and GMAC
- Apple Developer: CryptoKit
- Apple Developer: AES.GCM
- Apple Developer: Keychain services
- Apple Developer: Local Authentication
- Apple Platform Security Guide
- Apple Platform Security: Keychain data protection
- Apple Platform Security: The Secure Enclave
- Apple Platform Security: Biometric security