Releases: timqi/vt
v2.0.0
v2.0.0 — envelope encryption protocol
⚠️ BREAKING wire-protocol change. The client andvt ssh agentmust upgrade in lockstep. An old client speaking to a new agent will fail withunknown variant ... expected V2 or Legacy. See Upgrade below.
Why this release
Before v2.0.0, vt ssh agent saw plaintext on every encrypt@vt call and full ciphertext on every decrypt@vt call. A compromised agent process — via a memory leak, attached debugger, supply-chain weakness, or core dump — could exfiltrate every secret that flowed through it.
v2.0.0 closes that channel by switching to envelope encryption: each record carries a 16-byte salt, the agent uses the salt to derive a one-shot DEK via HKDF over the master key, and the client encrypts/decrypts the inner ciphertext locally. The agent never sees plaintext, never sees stored ciphertext — it only releases the DEK behind a Touch ID prompt.
Highlights
Protocol
- New URL format:
vt://{type}{base64url(salt(16) ‖ ct ‖ tag(16))}— no moremac/segment.typeis0for raw,1for TOTP. Minimum URL length is actually 4 chars shorter than v1 because themac/literal is gone. - Salt doubles as AES-GCM nonce via
salt[..12]. Safe because each HKDF-derived DEK encrypts exactly once, satisfying the(key, nonce)global-uniqueness requirement automatically. - AAD =
b"vt:v2:" || type_bytebinds every ciphertext to the v2 protocol version and the secret type. Defeats type-flip attacks (e.g. flipping a stored TOTP URL to RAW so the agent emits the seed instead of a 6-digit code). - Salt is generated by the agent, never accepted from the client.
EncryptReqliterally has no salt field on the wire. This is the load-bearing invariant that prevents an attacker holdingVT_AUTHfrom extracting a salt from a stored URL and askingencrypt@vtfor its DEK to bypass Touch ID. - Touch ID prompt now distinguishes legacy vs v2 in mixed batches:
decrypt 5 items (2 legacy plaintext + 3 v2 key-release) from host to run … - Lazy migration: legacy
vt://mac/…URLs continue to be readable indefinitely. Old data keeps working without forced re-encryption. - TOTP code generation moved client-side for v2 records. Agent never decrypts the seed; client-side
TOTP::generate_current()runs in a tight scope. vt ssh agent --no-legacy-decryptretires the legacy ciphertext-on-wire path once you've migrated all stored secrets — it makes the v2 zero-knowledge property unconditional.
Memory hygiene
load_mac_ciphernow returns the raw master key inZeroizing<[u8;32]>. The decrypted passphrase is wiped on drop.- Agent response buffers (
Zeroizing<Vec<u8>>) and the in-memoryVec<EncryptResItem>/Vec<DecryptResItem>have their DEK fields explicitly zeroized after JSON serialization. - Client zeroizes received DEKs and wire response buffers after use.
Parser hardening
VtUrl::parsevalidates the type byte viaas_bytes().first()+ ASCII check before any&strslicing. This eliminates a runtime panic vector reachable through attacker-controlledDecryptInput::Legacy { url }on the agent (e.g.vt://mac/é…).- Strict prefix matching — no
url::Url, no normalization. v2 URLs with extra/, unknown type bytes, or blobs shorter than 32 bytes are rejected before any work happens.
Migration tooling
- New script
migrate-vt-urls.py— single-fileuvscript with one external dep (pexpect) that walks given files, decrypts every legacy URL in one batchedvt injectcall (single Touch ID), and re-encrypts each plaintext as v2 via apexpect-drivenvt create. - Defaults to dry-run;
--no-dry-runactually rewrites files (with.vt-migrate-backupleft next to each modified file). - Handles TOTP transparently using a one-time legacy-path trick (flipping the URL's type byte to recover the raw base32 seed). Trick works only because legacy URLs lack AAD; v2 closes this hole, so the script must run before enabling
--no-legacy-decrypt.
Upgrade
# 1. Build/install the new binary
cargo install --path . --force # or: cp target/release/vt $(which vt)
# 2. Stop the running agent
pkill -f 'vt ssh agent'
# 3. Restart the agent in a fresh shell so PATH resolves to the new binary
vt ssh agent
# 4. (Optional but recommended) Migrate stored URLs in your dotfiles / env files
uv run migrate-vt-urls.py path/to/file … # preview
uv run migrate-vt-urls.py --no-dry-run path/to/file … # apply
# 5. Once everything is migrated, retire the legacy path
pkill -f 'vt ssh agent' && vt ssh agent --no-legacy-decryptThe keychain layout is unchanged from 1.0.3 — vt secret export/import is not required for this upgrade. Only the wire protocol and URL format changed.
Test coverage
74 unit tests pass (HKDF determinism, AAD round-trip + tamper detection, no-nonce-prefix regression guard, v2/legacy parser, blob-layout known vector, salt tamper, type-flip rejection, panic regressions for non-ASCII/empty URL bodies). Linux cross-check (cargo check --target x86_64-unknown-linux-gnu) stays green.
Threat model in one line
Defends against: attacker reading the agent process's memory (debugger, crash dump, supply-chain compromise of a dependency) without VT_AUTH. They previously saw plaintext on encrypt and full ciphertext on decrypt; now they see neither — only short-lived DEKs in the unwrap moment.
Does not defend against: an attacker who already holds VT_AUTH and can talk to the agent socket — they can still trigger Touch ID prompts. That's a categorically different threat from passive memory inspection.
Full diff: v1.0.3...v2.0.0
v1.0.3
Full Changelog: v1.0.2...v1.0.3