Security whitepaper

A safe that needs two phones

Version 1.0October 2026Describes Tupiki v1 for iPhone (pre-release)

01Summary

Tupiki is an iPhone app that keeps photos and videos in a shared, encrypted safe that only opens when two people’s phones are side by side. The name comes from the military two-person integrity rule: some actions need two people, each holding one key.

The design rests on four commitments:

  • Two halves, one key. Each safe’s key is split 2-of-2. Each phone holds one half, sealed to its own Secure Enclave. Neither half alone reveals anything about the key.
  • Raising risk takes both; lowering risk takes one. Opening, adding to and creating a safe need both phones and both people. Destroying your half, deleting an item or moving to a new phone can be done alone.
  • Nothing leaves the two phones. There are no accounts, no servers and no analytics. The only network traffic is phone to phone.
  • Nothing plaintext touches disk. Captures are encrypted in memory before they are written, and the photo library is never used.

Everything is built on Apple’s CryptoKit, the Secure Enclave, the Keychain and iOS data protection. Tupiki uses no custom ciphers. This paper describes the design as built; if the shipping app differs, we will update this document before launch.

02Threat model

Who Tupiki protects you from

  • Your partner, acting alone. This is the core case. Someone holding only their own phone, even unlocked and with their own Face ID, cannot open the safe, view its contents, or export them.
  • Someone holding one phone. A thief, a snoop or anyone who picks up an unlocked phone gets one half of a key, which is mathematically useless on its own. Safe names are behind Face ID and a separate PIN.
  • Someone nearby on the radio. Pairing is authenticated by three words both people compare. A person in the middle is detected unless they guess a 1-in-16.7-million match on their single attempt.
  • Us, and anyone who asks us. We run no servers and hold no data, so there is nothing to breach, subpoena or sell.

Who it cannot protect you from

  • Both people choosing to open the safe together. Once it is open, whatever is on screen can be photographed with another camera.
  • A phone whose operating system is compromised (for example jailbroken with malware running) while the safe is open on it.
  • Coercion of both people at the same time.

We spell these out in full in section 16.

03Keys at a glance

KeyWhat it isWhere it lives
Safe key K32 random bytes, one per safeOnly in memory, only while both phones are connected. Zeroed on lock.
Half (share)32 bytes. A is random; B = K ⊕ AOne per phone, ECIES-sealed to that phone's Secure Enclave share key, stored in the Keychain (this device only)
Share keyP-256 key agreement keySecure Enclave. Every use needs Face ID or the device passcode.
Identity keyP-256 key agreement keySecure Enclave. Used without a prompt, only to prove “same phone as last time” during a join.
Item keyHKDF-SHA256(K, salt: item ID)Derived on demand in memory; never stored
Session keysOne ChaChaPoly key per directionMemory only; fresh for every connection
Metadata key32 random bytesKeychain, this device only. Seals safe names, item lists and tombstones.
PIN recordsPBKDF2-SHA256, 210,000 rounds, 16-byte saltKeychain, this device only. The PIN itself is never stored.

04Splitting the safe key

When two people create a safe, a 32-byte safe key is generated with the system’s secure random number generator and split:

K      = random(32)
A      = random(32)
B      = K ⊕ A
check  = HMAC-SHA256(key: K, "tupiki.kcv.v1")

Because A is uniformly random and independent of K, each half on its own is uniformly random too. This is a one-time pad: holding one half tells an attacker nothing about K, no matter how much computing power they have. It is not a matter of the key being hard to guess; every possible key remains equally likely.

Each phone keeps only its own half. The check value lets a phone detect a wrong or corrupted half before trying to decrypt anything; it is a MAC under K and reveals nothing useful about it.

Consequence: if either half is destroyed, K is gone for good. There is no third copy, no escrow and no recovery path, by design.

05Storing a half

Each phone creates two P-256 keys inside its Secure Enclave. Their private parts never leave the chip:

  • The share key is created with .privateKeyUsage and .userPresence, so every use requires Face ID or the device passcode. We use user presence rather than biometrics alone so that re-enrolling Face ID never locks your half away.
  • The identity key requires no prompt. It is used only to prove, during a join, that this is the same phone your partner paired with before.

A half is sealed to the share key with ECIES:

e        = ephemeral P-256 key
secret   = ECDH(e, shareKey.public)
wrapKey  = HKDF-SHA256(secret, …, safe ID)
blob     = e.public ‖ AES-GCM(wrapKey, half)

The blob is stored in the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly: it is unreadable while the phone is locked, it is not synced to iCloud Keychain, and it is not restored onto another device from a backup. Even with the blob, opening it needs this phone’s Secure Enclave and a fresh Face ID or passcode check. One authenticated prompt can open several halves at once, so you are not asked repeatedly.

06Photos and videos

Each item gets its own key, derived from the safe key and the item’s random ID:

itemKey    = HKDF-SHA256(ikm: K, salt: itemID, info: "tupiki.item.v1")
ciphertext = AES-256-GCM(itemKey, plaintext)
  • Capture uses AVCaptureSession directly, never the system image picker, so nothing passes through the photo library. Tupiki never requests photo library access.
  • Photos are captured to memory. Videos (up to 60 seconds) are encoded as fragmented MP4 segments delivered to memory, never to a temporary file.
  • Items are sealed before anything is written. Ciphertext files use NSFileProtectionComplete, so they are additionally encrypted by iOS whenever the phone is locked.
  • Playback decrypts into memory and streams from there. External playback (AirPlay) is disabled.
  • Both phones keep the same ciphertext set, synced whenever they are together.

07Pairing and the three words

Phones find each other with Apple’s MultipeerConnectivity (Bluetooth and peer-to-peer Wi-Fi). We treat that link as untrusted and run our own handshake on top of it, with ephemeral Curve25519 keys and a commit-then-reveal step:

initiator → responder   commit = SHA256(pkI)
responder → initiator   pkR
initiator → responder   pkI          (responder checks the commitment)

transcript = sort(pkI, pkR) ‖ commit
words      = SHA256(transcript ‖ "tupiki.sas.v1")[0..3]  → 3 words
keys       = HKDF-SHA256(X25519(sk, pk), salt: SHA256(transcript),
                         info: "tupiki.session.v1") → 64 bytes

The first three bytes of the transcript hash pick three words from a fixed list of 256, giving 24 bits. Both people check that their screens show the same words. Without the commitment, an attacker in the middle could try many keys until the words matched. Because the initiator commits to its key before seeing the responder’s, an attacker gets exactly one guess per pairing attempt: a 1 in 16,777,216 chance, and a failed attempt is visible as mismatched words.

08Known partners

After the first pairing, each phone remembers the other’s identity public key. On later joins the words are skipped, but only after both phones prove they hold the expected identity key:

static   = ECDH(myIdentityKey, theirIdentityKey)
proofKey = HKDF-SHA256(static, salt: SHA256(transcript), info: "tupiki.identity.v1")
proof    = HMAC-SHA256(proofKey, role)

The proof is bound to this session’s transcript and to each side’s role, so it cannot be replayed or reflected. If the proof fails, or the partner is unknown, the three words are required again.

09The session channel

Session keys are split into one ChaChaPoly key per direction. Every message is sealed with a 96-bit nonce built from a 64-bit sequence number that strictly increases. The receiver rejects any message whose sequence number is not greater than the last one it accepted, so replayed, reordered or reflected messages are dropped. MultipeerConnectivity’s own encryption is enabled too, but only as defence in depth. Large items travel in 128 KB parts so the heartbeat is never starved.

10Opening a safe

Opening is symmetric: both phones see the safe.

  1. The phones join and establish a session channel (above).
  2. Each person confirms with Face ID or passcode on their own phone, which unseals their half inside their Secure Enclave.
  3. Each phone sends its half to the other over the encrypted channel.
  4. Each phone computes K = A ⊕ B in memory and verifies the check value.

K and any decrypted thumbnails exist only in memory for as long as the session lasts. When a safe is first created, the phone that started it sends both halves once so the other phone can open that first session; the other phone keeps only its own half.

11Presence and locking

While a safe is open, the two phones exchange an encrypted heartbeat every 0.5 seconds. The safe locks immediately if:

  • no heartbeat arrives for 2.0 seconds,
  • the connection drops, or
  • either phone leaves the app.

Locking zeroes K and drops all decrypted content from memory (zeroing in a managed runtime is best effort).

Honest limit: in v1, “side by side” means within radio range of each other, which can be more than a few metres. Ultra Wideband distance checks (NearbyInteraction) are planned for supported iPhones and will be described here when they ship.

12Screen protection

  • Safe contents are drawn inside a secure layer that iOS leaves blank in screenshots.
  • When screen recording or mirroring is active, the safe is covered and video pauses.
  • The app switcher shows a privacy cover instead of a snapshot.
  • There is no share sheet, no export, no copy to the pasteboard and no AirPlay.

None of this can stop a second camera pointed at the screen. We say so in the app, and we say so here.

13Destroying a half

Either person can destroy their half at any time, alone, with a two-second hold (three seconds to destroy everything). The phone deletes its sealed half, that safe’s ciphertext and its metadata, and records a small tombstone (safe ID and date) under the metadata key.

With one half gone, the other half is just 32 random bytes. The safe cannot be opened again by anyone.

Because there is no server, the partner is not notified immediately. The next time the two phones join, the destroying phone answers “closed” for that safe, and the partner’s phone offers to clean up its useless half. This is intentional: there is nowhere else for a notification to come from.

14Keys PIN and duress PIN

The list of safes and the destroy controls sit behind a second check: Face ID plus a 6-digit keys PIN. The Home screen looks identical whether you have no safes or ten.

  • PINs are stored only as PBKDF2-SHA256 hashes (210,000 rounds, random 16-byte salt) in the Keychain, compared in constant time.
  • After 5 wrong attempts, entry locks out for 30 s, then 1 min, 5 min, 15 min and 1 hour.
  • An optional duress PIN looks like it works: it silently destroys every half on the phone and shows an empty list, indistinguishable from an account with no safes.

Short PINs are a usability gate, not a cryptographic barrier: the halves themselves are protected by the Secure Enclave and Face ID, not by the PIN.

15Moving to a new phone

Halves never go to iCloud, so moving phones is done directly, old phone to new:

  1. The new phone shows a QR code or a 6-digit code. Both phones run the same handshake as pairing, and each proves knowledge of the code with an HMAC bound to the session transcript ("tupiki.move.v1").
  2. The old phone opens its halves with one Face ID check and sends them, with all ciphertext, metadata, PIN hashes and tombstones, over the encrypted channel.
  3. The new phone reseals each half to its own Secure Enclave and acknowledges each safe.
  4. Only after the final acknowledgement does the old phone delete everything.

The old phone also signs a handover token for the new phone’s identity key (an HMAC under the old identity key’s shared secret with each partner). On the next join, the partner’s phone accepts the new phone, and the three words are shown once more, labelled as a new phone.

16What Tupiki does not protect against

  • A second camera. When the safe is open, anyone present can photograph the screen.
  • Both of you agreeing. If both people open the safe, Tupiki does exactly what you asked.
  • A compromised phone. Malware with control of iOS on a phone while the safe is open could read what is in that phone’s memory.
  • Coercion of both people. The duress PIN helps one person under pressure; it cannot help if both are forced to open the safe together.
  • Radio range. Until distance checks ship, two phones within radio range can keep a session open.
  • Metadata on an open phone. Safe names are sealed with a key that stays on that phone. Someone with full control of your unlocked phone and its Keychain could read safe names, never photos.

17No servers, no recovery

Tupiki v1 has no accounts, no servers, no analytics and no crash reporting. The app makes no network connections other than directly to the other phone. That means:

  • There is no copy of anything for us to lose, leak or be compelled to hand over.
  • There is no recovery. If you lose your phone without moving it, your half is gone, and so is every safe it opened.

Encrypted backup may come later. It will be opt-in, and this paper will describe it before it ships.

18Primitives

PurposePrimitiveImplementation
Key splitXOR, 2-of-2Swift, SecRandomCopyBytes
Item encryptionAES-256-GCMCryptoKit
Key derivationHKDF-SHA256CryptoKit
Session key agreementX25519 with commit-then-revealCryptoKit
Session encryptionChaCha20-Poly1305, sequence-number noncesCryptoKit
Device keysP-256 ECDHSecure Enclave via CryptoKit
Share sealingECIES: P-256 + HKDF-SHA256 + AES-GCMCryptoKit
Proofs and check valuesHMAC-SHA256CryptoKit
Words and commitmentsSHA-256CryptoKit
PIN hashingPBKDF2-HMAC-SHA256, 210,000 roundsCommonCrypto

This document will be versioned alongside the app. Found a problem with the design? We want to hear it.