Project QRE Blog

Updates, Research, and Technical Deep Dives

Time-Lock Encryption in QRE Privacy Toolkit v2.7.6

QRE Privacy Toolkit v2.7.6 introduces Time-Lock Encryption — the ability to seal a file so that it cannot be decrypted until a specific date and time you choose in advance. This article is a complete technical walkthrough of how it works: the cryptographic design, the file format changes, the clock-manipulation defenses, and the honest assessment of what it can and cannot protect against.

1. Motivation and Use Cases

Standard encryption protects a file from unauthorized parties. Time-lock encryption adds a second constraint: it protects a file from premature access, even by the file's own owner. Once a file is time-locked, nobody — not the person who locked it, not someone with the master password — can decrypt it before the configured date arrives.

This is useful in a wider range of situations than it might first appear:

  • Journalists and lawyers sealing documents until an embargo or court date lifts.
  • Developers locking credentials, API keys, or signing keys until a product goes live.
  • Estate planning — instructions, passwords, or a will that should only be readable after a specific event.
  • Dead man's switch disclosures — a file that a trusted recipient can access after a deadline passes.
  • Personal commitments — locking away access to something (a trading account export, a draft document) for a cooling-off period.

Unlike cloud-based time-lock services, QRE's implementation is entirely local and offline. There is no server holding a key on your behalf, and no third party involved at any point.

2. Design Goals

We defined four non-negotiable requirements before writing a single line of code:

  1. Single-file. The entire time-lock state — the expiry timestamp, the cryptographic binding — must live inside the .qre file itself. No sidecar files, no companion databases, no external state. A file you copy to a USB drive or email to yourself must be fully self-contained.
  2. No trusted third party. No server is ever contacted during the lock or unlock process for key material. The only external contact during unlock is an optional NTP query for clock verification — and even that fails gracefully.
  3. Clock-manipulation resistance. Simply changing the system clock should not bypass the lock. We implement two independent layers of defense against this.
  4. Composable with existing security. The time-lock must integrate with the existing AES-256-GCM streaming engine and master key architecture without weakening either.

3. Cryptographic Model

The time-lock is built on top of QRE's existing envelope encryption pattern. In a normal .qre file, the actual file data is encrypted with a random File Encryption Key (FEK), and the FEK is then wrapped (encrypted) with a Key Encryption Key (KEK) derived from the master password. Decrypting the file requires unwrapping the FEK first.

For time-locked files, we add a second layer of key wrapping using a randomly generated binding key:

🔐
Key Hierarchy for Time-Locked Files

binding_key — 256-bit random key generated at lock time via OsRng (CSPRNG).
binding_key_hash = SHA-256(binding_key) — used as the effective keyfile when wrapping the FEK.
encrypted_binding_key = AES-256-GCM(base_KEK, binding_key) — stored in the file header.

The base_KEK is derived from the master password without any keyfile (using the domain separator "NO_KEYFILE"). The FEK is wrapped with a file_KEK derived from the master password and binding_key_hash as the keyfile.

The result is that two secrets are required to decrypt any time-locked file:

  1. The master password (to decrypt the binding key from the header).
  2. The binding key (to unwrap the FEK and therefore decrypt the file body).

Since the binding key is itself encrypted inside the file header, it is only retrievable after the time-lock check passes. The check happens before any key derivation — we never attempt to decrypt the binding key while the lock is active. This also means an attacker cannot use timing side-channels to check whether the master password is correct while a file is locked.

Two separate KEKs are derived to avoid key reuse between the binding key encryption and the FEK wrapping:

// Base KEK: master key only, no keyfile // Used to encrypt the binding_key for storage in the header base_KEK = SHA-256(master_key || "NO_KEYFILE") // File KEK: master key + binding_key_hash as keyfile // Used to encrypt the validation tag and wrap the FEK file_KEK = SHA-256(master_key || "KEYFILE_MIX" || SHA-256(binding_key))

4. File Format: V7

QRE's streaming file format has evolved through several versions. Time-locked files are written as Version 7. The key difference from V6 is a fixed 4 KB header region immediately after the 4-byte version prefix. This fixed region allows the application to rewrite the header in-place (to update the ratchet counter) without touching any of the encrypted data chunks that follow.

Offset Size Field
0
4 bytes
Version marker = 7 (u32, little-endian)
4
variable
StreamHeader (bincode-serialized)
vault_id · validation_nonce · encrypted_validation_tag · key_wrapping_nonce · encrypted_file_key · base_nonce · original_filename · original_hash
nested
TimeLockMeta (inside StreamHeader)
locked_until (u64, plaintext) · encrypted_binding_key · binding_key_nonce · ratchet_max_seen (u64)
4 – 4099
4096 bytes
Fixed header region (zero-padded to 4 KB)
4100+
variable
Encrypted data chunks
[chunk_len: u32][ciphertext: AES-256-GCM+zstd] × N

The locked_until field inside TimeLockMeta is stored in plaintext. This is intentional: it allows the application to display the countdown timer and check the expiry without requiring the master password. The encrypted_binding_key requires the master password to decrypt and is never revealed while the lock is active.

The ratchet_max_seen field is also stored in plaintext (inside the encrypted header region, but outside any AES wrapper). This is by design — the ratchet value needs to be readable and writable without the master key so that every unlock attempt (including failed ones with the wrong password) can update it.

⚠️
V5 and V6 compatibility
V5 files (pre-streaming) and V6 files (embedded time-lock, variable-length header) continue to be read correctly. The version byte at offset 0 tells the decryptor which deserialization path to use. New time-locked files are always written as V7. Regular (non-time-locked) encryptions continue to produce V6 files.

5. Encryption (Lock) Flow

When a user right-clicks a file and selects Time-Lock…, the following sequence executes entirely in a Rust background thread:

// Step 1 — Timestamp validation (Rust-side, authoritative) validate_unlock_at(unlock_at) // rejects past timestamps, < 1 min, > 50 years // Step 2 — Generate binding_key via OS CSPRNG let mut binding_key = Zeroizing::new([0u8; 32]); OsRng.try_fill_bytes(&mut *binding_key)?; // Step 3 — Derive the effective keyfile hash let binding_key_hash = SHA-256(binding_key); // Step 4 — Encrypt binding_key with base_KEK for header storage let base_KEK = derive_wrapping_key(master_key, None); let encrypted_binding_key = AES-256-GCM(base_KEK, binding_key, nonce); // Step 5 — Wrap the FEK with file_KEK (master + binding_key_hash) let file_KEK = derive_wrapping_key(master_key, Some(&binding_key_hash)); let encrypted_file_key = AES-256-GCM(file_KEK, FEK, nonce); // Step 6 — Build TimeLockMeta and embed in StreamHeader let meta = TimeLockMeta { locked_until: unlock_at, encrypted_binding_key, binding_key_nonce: nonce, ratchet_max_seen: 0, // starts at zero }; // Step 7 — Write V7 file: 4-byte version + 4 KB fixed header + chunks encrypt_file_stream(input, output, master_key, Some(unlock_at), ...)

The frontend-supplied unlock_at value is treated as untrusted. The Rust validate_unlock_at function enforces a minimum of 60 seconds and a maximum of 50 years from the current system time, and rejects zero and past timestamps outright. The UI pre-validates the same bounds for responsiveness, but the Rust check is the authoritative gate.

6. Decryption (Unlock) Flow

When a user attempts to unlock a .qre file, the decryption path in decrypt_file_stream executes the following sequence:

// Step 1 — Read version byte and deserialize header let version = file.read_u32_le(); let header: StreamHeader = deserialize_header(&file, version); // Step 2 — Time-lock check (before ANY key derivation) if let Some(tl) = &header.timelock { let authoritative_time = get_authoritative_time(tl.ratchet_max_seen); // → NTP if online, max(clock, ratchet) if offline if authoritative_time < tl.locked_until { update_ratchet_in_place(path, authoritative_time); // V7 only return Err("TIME_LOCKED:<unix_ts>:<human duration>"); } // Lock expired — recover binding_key let base_KEK = derive_wrapping_key(master_key, None); let binding_key = AES-256-GCM-decrypt(base_KEK, tl.encrypted_binding_key); let effective_keyfile = SHA-256(binding_key); } // Step 3 — Validate master password via the validation tag let file_KEK = derive_wrapping_key(master_key, effective_keyfile); verify_validation_tag(file_KEK, header.validation_nonce)?; // Step 4 — Unwrap FEK and decrypt chunks let FEK = AES-256-GCM-decrypt(file_KEK, header.encrypted_file_key); decrypt_and_write_chunks(FEK, output_dir);

The error returned on a locked file uses the prefix TIME_LOCKED:<unix_ts>: so the frontend can parse the exact expiry timestamp and render an accurate countdown timer, while the rest of the message is the human-readable duration for display.

7. Clock-Manipulation Hardening

The most obvious attack on any software time-lock is to simply change the system clock. QRE implements two independent layers of defense against this. Each layer catches a different class of attack. Together they cover all practical bypass attempts short of a hardware security module.

8. Layer 1 — NTP Verification

When an unlock is attempted while the device is online, QRE queries three well-known NTP servers in parallel using raw UDP sockets from the Rust standard library (no external crates required):

  • time.cloudflare.com
  • time.google.com
  • pool.ntp.org

Each server receives a minimal 48-byte NTP v3 client packet. The transmit timestamp is read from bytes 40–43 of the response (big-endian u32 seconds, NTP epoch), converted to a Unix timestamp, and collected. The median of all successful responses is used as the authoritative time.

🛡️
Why the median?
Using the median rather than the mean or minimum prevents a single rogue or misconfigured server from skewing the result. Even if one of the three servers returns a manipulated timestamp (due to a BGP hijack or DNS spoofing), the median of three values will still be a legitimate response from one of the other two servers.

If NTP is available, the system clock is ignored entirely for the time check. An attacker who winds back the system clock while remaining online will be caught immediately — the NTP response is used instead.

If all three servers are unreachable (the device is offline or NTP traffic is blocked), Layer 1 fails gracefully and Layer 2 takes over.

// timelock_clock.rs — NTP query (no external crates) fn query_ntp_server(server: &str) -> Result<u64, String> { let socket = UdpSocket::bind("0.0.0.0:0")?; socket.set_read_timeout(Duration::from_secs(3))?; socket.connect(format!("{}:123", server))?; let mut request = [0u8; 48]; request[0] = 0x1B; // LI=0, VN=3, Mode=3 (client) socket.send(&request)?; let mut response = [0u8; 48]; socket.recv(&mut response)?; // Transmit Timestamp: bytes 40-43 (seconds, big-endian) let ntp_secs = u32::from_be_bytes([ response[40], response[41], response[42], response[43] ]) as u64; Ok(ntp_secs - 2_208_988_800) // convert NTP epoch → Unix epoch }

9. Layer 2 — In-File Ratchet Mechanism

Layer 1 covers online attacks. Layer 2 covers the offline scenario: an attacker who disconnects from the internet first, then rewinds the system clock.

Every failed unlock attempt (where the time check fails, regardless of whether the master password is correct) updates the ratchet_max_seen field in the V7 file header:

// On every failed time-lock check (V7 files only): let new_ratchet = tl.ratchet_max_seen.max(authoritative_time); if new_ratchet > tl.ratchet_max_seen { update_v7_header_in_place(path, new_ratchet); // Rewrites ONLY bytes 4–4099. Ciphertext chunks are never touched. }

The ratchet value is a monotonically increasing counter. It can only go up, never down. The next time an unlock is attempted — even if the system clock has been rewound and the device is offline — the check becomes:

authoritative_time = max(system_clock, ratchet_max_seen) // If system_clock = Jan 1 (rewound) but ratchet = March 15 (from last attempt), // authoritative_time = March 15. The lock holds.

The in-place rewrite is designed to be safe even on sudden power loss: it writes exactly HEADER_RESERVED_BYTES (4096 bytes) to the file starting at byte offset 4. The encrypted data chunks begin at byte 4100 and are completely untouched. A failed or partial ratchet write degrades offline protection (the ratchet value doesn't advance) but never corrupts the file or prevents future decryption.

No sidecar files
An earlier design stored the ratchet value in a hidden companion file (.filename.qre.tl). This was replaced in v2.7.6 with the embedded approach: all time-lock state, including the ratchet, lives inside the single .qre file. Losing the file loses the lock. There is nothing else to keep track of.

10. Attack Resistance Matrix

Attack scenario Result Mechanism
Rewind system clock while online BLOCKED NTP time used instead of system clock
Block NTP traffic, rewind clock (offline) BLOCKED Ratchet records a higher time from last online access
Go offline before any prior access, rewind clock POSSIBLE Ratchet is 0 (never written); falls back to system clock
Single NTP server returns a manipulated timestamp BLOCKED Median of 3 servers; rogue value is discarded
All 3 NTP servers compromised simultaneously PARTIAL Ratchet still holds if file was accessed while online previously
Brute-force master password BLOCKED Argon2id key derivation (existing); time-lock adds no new surface
Copy file and attempt on another machine BLOCKED Ratchet travels with the file; NTP applies on the new machine too
Tamper with encrypted chunks to skip time check BLOCKED AES-256-GCM authentication tag covers each chunk; integrity verified post-decrypt

11. Honest Limitations

We believe in being transparent about what a security feature cannot do, not just what it can.

⚠️
The fresh-system, fully-offline bypass

If an attacker obtains the .qre file and the master password, and they have never previously attempted to unlock the file (so the ratchet is still 0), and they go fully offline before rewinding the clock — they can bypass the time check. This requires premeditated, multi-step effort and possession of the master password, which already represents total compromise of the user's vault.

This limitation is shared by every software-only time-lock including VeraCrypt and BitLocker. Eliminating it entirely requires hardware (a TPM, HSM, or secure enclave). QRE does not use hardware security modules.

The time-lock is best understood as a cryptographic commitment tool, not a DRM system. It is designed to:

  • Prevent accidental or impulsive early access by the file owner.
  • Enforce embargoes and legal timing requirements.
  • Resist casual and opportunistic bypass attempts.

It is not designed to withstand a sophisticated attacker who already possesses both the file and the master password, has complete control of the hardware, and is willing to perform multi-step offline manipulation. For that threat model, use a hardware HSM.

12. Summary

Time-Lock Encryption in QRE v2.7.6 is a fully local, sidecar-free implementation built on a V7 file format with a fixed 4 KB header region. The cryptographic model uses a randomly generated binding key to enforce a two-layer key dependency: both the master password and the binding key are required to decrypt, and the binding key is only revealed after the lock expires.

Clock manipulation is resisted by two independent mechanisms: NTP verification using the median of three servers (online), and a monotonically increasing ratchet counter stored in the file header and updated in-place on every failed unlock attempt (offline). No external state, no sidecar files, and no third-party servers are required at any point.

The implementation requires no external Rust crates beyond what QRE already uses — NTP queries are handled using std::net::UdpSocket from the standard library. The feature is covered by 83 automated Rust tests including the new timelock_clock module's unit tests for ratchet monotonicity, median selection, and NTP response parsing.

Download QRE v2.7.6