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.
Contents
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:
-
Single-file. The entire time-lock state — the
expiry timestamp, the cryptographic binding — must live inside the
.qrefile 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. - 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.
- Clock-manipulation resistance. Simply changing the system clock should not bypass the lock. We implement two independent layers of defense against this.
- 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:
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:
- The master password (to decrypt the binding key from the header).
- 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:
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.
vault_id · validation_nonce · encrypted_validation_tag · key_wrapping_nonce · encrypted_file_key · base_nonce · original_filename · original_hash
locked_until (u64, plaintext) · encrypted_binding_key · binding_key_nonce · ratchet_max_seen (u64)
[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 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:
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:
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.comtime.google.compool.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.
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.
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:
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:
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.
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.
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.