Encryption-at-rest — design & threat model
This document captures the design choices behind sortOfRemoteNG’s
encryption-at-rest layer, the threats it defends against, and what
it explicitly does not protect. Future contributors: read this
before touching any file under src-tauri/crates/sorng-encryption
or the artifact codecs.
Threat model
In scope
- Offline disk attacker. The attacker has read (or read+write)
access to the application data directory and any configured
backup destinations, but does not have access to the OS user
account or the process memory. Example: a stolen laptop with the
user logged out, or a malicious actor who copied
%APPDATA%/sortOfRemoteNG/off a workstation. - Cold-storage backup attacker. The attacker has a copy of an
exported backup
.jsonor.gzfile. Equivalent threat surface to #1 from the artifact’s perspective. - Downgrade attacker. Read+write access to disk. Attempts to trick the reader into accepting plaintext where v2 envelopes are expected.
- Tampering / swap attacker. Read+write access to disk. Attempts to swap one ciphertext chunk for another (e.g. replay the first chunk over the third) within a single recording media file.
Out of scope
- Live memory attacker. After unlock, the master DEK, every
sub-key, and every decrypted artifact’s plaintext live in process
memory. A debugger /
ptraceattacker on the running process can read everything. The auto-lock and “Lock now” affordances exist to shrink this window; they do not eliminate it. - OS keychain compromise. Vault mode trusts the OS keychain (Windows Credential Manager + DPAPI / macOS Keychain / Linux Secret Service). An attacker who has compromised the keychain service has the master DEK.
- Side-channels on shared hardware. No timing-attack hardening
beyond what
aes-gcmandargon2provide by default. - Connection passwords in transit. Wrapped by their respective protocol layers (SSH, TLS, RDP). This document covers data at rest only.
Key hierarchy
OS vault entry ┌─ MasterDek (32 random bytes, Zeroizing)
master-dek (vault mode) ─┤
wrapped-dek (pw / hybrid) ─┘ │
│ HKDF-SHA256(ikm=master, info="sorng-v1::<artifact>")
▼
SubKey (32 bytes) — AES-256-GCM key per artifact.
- MasterDek. 32 bytes of OS-RNG randomness. Generated at first
setup via
MasterDek::generate(). Stored in process memory in aZeroizingbuffer so leaks at lock / process exit clear cleanly. - HKDF labels.
sorng-v1::<artifact>— seeArtifactKind::label(). Stable on disk; renaming a variant breaks every encrypted file of that kind. To rotate, add a new variant and migrate; never rename. - Sub-key domain separation. Each artifact’s ciphertext is
decryptable only with the sub-key derived under that artifact’s
HKDF label. A settings ciphertext fed to the connections sub-key
fails GCM auth. Test:
crate::artifacts::connections::tests::settings_subkey_cannot_decrypt_connections.
Key storage modes
| Mode | Where the master DEK lives | When chosen |
|---|---|---|
| Vault | OS keychain entry. Transparent unlock on app start. | Default when the keychain is reachable. |
| Password | dek.enc — Argon2id-wrapped (OWASP 64 MiB / 3 / 4). |
Vault unreachable; user enters password at every start. |
| Vault + password | Both — keychain holds plaintext DEK, dek.enc holds Argon2id wrap. |
User wants vault transparency plus a password fallback for vault eviction. |
MasterKeyStorage is recorded in every v2 envelope’s 64-byte
preamble at offset 7 so the unlock screen knows which prompt to
render before any sub-key is derived.
On-disk artifacts
| Artifact | Codec | Filename pattern | Format |
|---|---|---|---|
| Connections | artifacts::connections |
storage.json (despite the name, v2 envelope) |
whole-file envelope, JSON object |
| Settings | artifacts::settings |
settings.enc |
whole-file envelope, JSON object |
| Recording metadata | artifacts::recording_meta |
<recordings>/recordings/<id>.json.enc |
whole-file envelope, JSON object |
| Recording media | artifacts::recording_media |
<recordings>/recordings/<id>.media.enc |
chunked stream (see below) |
| Backups | artifacts::backups |
<destination>/backup_*.json[.gz] |
whole-file envelope, opaque bytes |
| Logs | artifacts::logs |
(wired in Commit H — see deferred items) | whole-file envelope, opaque bytes |
| Macros | artifacts::macros |
<recordings>/macros/<id>.json.enc |
whole-file envelope, JSON object |
v2 whole-file envelope
64-byte unencrypted preamble + AES-256-GCM ciphertext. AEAD AAD is
the preamble itself, so a tampered preamble fails GCM verification at
decrypt-time. Full layout in src-tauri/crates/sorng-encryption/src/envelope.rs.
v2 chunked stream (recording media)
Fixed-size plaintext chunks (default 64 KiB), each independently
AES-256-GCM-encrypted. Nonce = nonce_prefix (8 random bytes per
file) || chunk_index_be (4 bytes). AAD = chunk_index_be so a
chunk swap fails GCM verification even with the same key. Full
layout in src-tauri/crates/sorng-encryption/src/artifacts/recording_media.rs.
Why two formats
Whole-file envelopes are simpler and cheaper for files the playback
path always reads end-to-end (settings, connections, backups).
Recording media files can be tens to hundreds of MiB and the player
needs random-access seek; chunked AEAD lets read_chunk(idx) decrypt
exactly one 64 KiB block without touching the rest of the file.
Defences
Downgrade resistance
- Magic byte check — v2 envelope files start with
b"SORNG\0". Readers that see a file without that prefix at the canonical path (e.g.data.encis bytes that don’t start withSORNG\0) error out as “invalid format” rather than silently treating the bytes as plaintext. The legacySORNG_ENC:text envelope was retired in the commit Z purge; any file still in that format produces a parse error on load. - Schema version field — the preamble carries a
version: u8at offset 6. A reader that sees an unknown version aborts.
Tampering / swap resistance
- Whole-file AEAD binds the preamble to the body via AAD. A tampered preamble fails verification.
- Chunked stream AAD = chunk index, defeats chunk-swap attacks on media files.
Vault eviction
The OS keychain entry can vanish: macOS keychain reset, Linux session logout that drops libsecret, migration to a new machine. The user is not stranded:
- If a
dek.encexists (password / hybrid mode), the unlock screen prompts for the password and unwraps it via Argon2id. - The unlock screen’s “Recover from portable .dek” panel
(UnlockScreen.tsx, Commit E) accepts a path + password to import
an exported
.dekfile. Pre-emptively exporting a portable.dekand storing it offline (USB key, password manager attachment) is the documented recovery flow. - If neither exists and the vault is gone, the data is unrecoverable. This is by design — encryption-at-rest without any recovery key would be no recovery at all.
Brute force / lockout
LockoutState (crate::lockout) tracks failed-password attempts
with an exponential backoff. After 3 failures the cool-down jumps
from 0 to ~30 s; after 5, ~5 min; capped at 1 h. The counter
persists to <app_data>/lockout.json so a quick process restart
doesn’t reset it. Successful unlock or rotation calls record_success()
which clears the counter.
Key rotation
encryption_rotate_master_key_full (Commit A) walks every artifact:
settings, connections, every v2 backup file across every enabled
destination, every recording metadata envelope, every media sidecar,
every macro. Each file is rewritten via the atomic temp → rename
pattern, so a crash inside one file’s rewrite leaves the canonical
path at its previous (old-key) bytes. The lossy mid-state — process
dies between artifact rewrites and vault/dek.enc update — is the
documented escape hatch for the portable .dek import.
The legacy settings-only encryption_rotate_master_key (Phase 6)
is retained but the UI no longer calls it. It exists for advanced
callers that genuinely want the receipts updated without touching
artifacts.
Audit log
<app_data>/logs/encryption-audit.log — JSON-lines append-only.
Records setup, unlock success/failure, lock (with reason metadata —
Commit C), key rotation, password change, portable export/import.
Retention: single-backup rotation at 5 MiB (Commit D); ≤ 2× cap
disk usage.
Migration
The dev branch is “we’re not in production”; commits Y and Z retired every legacy crypto format (SORNG1 backup envelope, SORNG_ENC: connections envelope). Files in those formats still on disk surface as load errors. The migrators that converted them are gone — the only path forward is to delete the legacy files and let the app recreate them in v2 on first save.
If you fork this code and add new users on a v1 release, you must write migration commands before stripping the legacy reader.
Properties NOT guaranteed
These are deliberate omissions, not bugs:
- In-memory plaintext after unlock. Connection passwords, decrypted settings, recording content — all live in plaintext in process memory once unlocked. Lock-on-blur and lock-on-idle exist to shrink the window but cannot eliminate it. A user concerned about RAM-dump attacks should keep the app locked and unlock per-session.
- Authenticated boot. The app does not verify its own binary signature at startup. An attacker who can replace the executable on disk has compromised the key path.
- Encrypted swap. OS-level encrypted swap is the user’s responsibility. On macOS it’s default; on Linux it’s optional; on Windows it depends on BitLocker.
- Forward secrecy. Compromise of the master DEK at time T decrypts every artifact ever written. There is no per-session ephemeral key.
Deferred items
- Logs encryption.
artifacts::logscodec exists; the bridge totauri-plugin-log’s file writer doesn’t. Replacing the plugin with a customlog::Logimpl that writes through the codec is Commit H. - Streaming media writer. Every encoder in
sorng-recording::encodersreturns a wholeString, sowrite_one_shotis fine. A streaming writer would matter once an encoder learns to stream incrementally.