Skip to content

Releases: timqi/vt

v2.0.0

27 Apr 10:48

Choose a tag to compare

v2.0.0 — envelope encryption protocol

⚠️ BREAKING wire-protocol change. The client and vt ssh agent must upgrade in lockstep. An old client speaking to a new agent will fail with unknown 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 more mac/ segment. type is 0 for raw, 1 for TOTP. Minimum URL length is actually 4 chars shorter than v1 because the mac/ 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_byte binds 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. EncryptReq literally has no salt field on the wire. This is the load-bearing invariant that prevents an attacker holding VT_AUTH from extracting a salt from a stored URL and asking encrypt@vt for 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-decrypt retires 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_cipher now returns the raw master key in Zeroizing<[u8;32]>. The decrypted passphrase is wiped on drop.
  • Agent response buffers (Zeroizing<Vec<u8>>) and the in-memory Vec<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::parse validates the type byte via as_bytes().first() + ASCII check before any &str slicing. This eliminates a runtime panic vector reachable through attacker-controlled DecryptInput::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-file uv script with one external dep (pexpect) that walks given files, decrypts every legacy URL in one batched vt inject call (single Touch ID), and re-encrypts each plaintext as v2 via a pexpect-driven vt create.
  • Defaults to dry-run; --no-dry-run actually rewrites files (with .vt-migrate-backup left 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-decrypt

The 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

26 Feb 07:07

Choose a tag to compare

Full Changelog: v1.0.2...v1.0.3