# TS-2026-001: Keystore AEAD construction defect

| Field             | Value                                                        |
|-------------------|--------------------------------------------------------------|
| **Identifier**    | TS-2026-001                                                  |
| **Title**         | Keystore AEAD documented as AES-256-GCM, implemented as a broken SHA-256-CTR + HMAC construction |
| **Affected**      | `treeship-core` keystore (`packages/core/src/keys`) in `treeship` `<= 0.10.2` |
| **Fixed in**      | `treeship` `0.10.3`                                          |
| **Severity**      | High                                                         |
| **CVSS 3.1**      | 7.1 — vector `AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N`           |
| **Discovered by** | Internal launch audit, 2026-05                               |
| **Public disclosure window** | None — fix shipped same cycle as discovery. No field exploitation observed. |

## Summary

The local keystore that protects every signing key Treeship issues was
documented as AES-256-GCM but was implemented as a homemade SHA-256-CTR
keystream paired with an HMAC-SHA-256 tag. The construction is not an
AEAD in any meaningful sense; the keystream is also degenerate (one
output byte of SHA-256 is reused for every byte of plaintext via
`block[i % 32]`), which means an attacker with even partial known
plaintext can recover the cipher's "key" derivative and forge new
ciphertexts that the HMAC verification will accept.

In practice this meant a local attacker — anyone with read+write access
to the file system holding `~/.treeship/keys/` — could tamper with the
contents of an encrypted key entry, swap a different attacker-controlled
ciphertext into a victim's keystore, or in some cases recover the
secret Ed25519 scalar via known-plaintext analysis of the surrounding
JSON envelope.

This is a **local** issue. The keystore never leaves the machine, the
encryption is a defense-in-depth layer for at-rest material, and the
machine-key derivation already binds ciphertexts to the host. The fix
does not change [Treeship's remote security model](./threat-model.md)
(receipts, Hub mandates, DPoP) at all.

## Affected versions

All `treeship-core` releases through and including `0.10.2`. The
keystore module (`packages/core/src/keys/mod.rs`) shipped the broken
construction continuously from the introduction of on-disk encryption.
The mislabeling was visible in the module docstrings and in
`SECURITY.md`, which both described the construction as AES-256-GCM.

## Fixed in

`treeship` `0.10.3` (this release). The keystore is rebuilt around the
RustCrypto `aes-gcm` 0.10 crate, with the framing prefix **and the
entry's identity (key id + public key)** bound into the AEAD's
Associated Authenticated Data so the format identifier and the
envelope-to-ciphertext binding are both covered by the tag.

A post-merge adversarial review of the original v2 design (which bound
only the framing prefix into AAD) surfaced an intra-keystore swap
class: a local attacker with write access to `~/.treeship/keys/` could
copy entry A's `enc_priv_key` ciphertext into entry B's JSON envelope
and the blob would decrypt cleanly (same machine key, same framing
AAD), silently un-binding `KeyInfo.public_key` from the actual scalar
in use. The framing-only AAD shipped in the first cut of this fix
closed framing-level swaps but did NOT close the intra-keystore
envelope swap. The v2 AAD layout now also length-prefixes and binds
the entry id and the public key, so any swap or rename of the
ciphertext relative to its envelope surfaces as a MAC failure.

Because v2 had not yet been released when the review landed, the AAD
layout could be tightened in place without a migration cycle. Users
upgrading from any v1 keystore land directly on the entry-binding v2
format on first decrypt.

There is no backport to `0.9.x`. Users on `0.9.x` should upgrade to
`0.10.3` to receive the fix; the migration to v2 entries runs
automatically on first signing operation after upgrade and requires no
user action. See [Migration](#migration) below.

The `treeship-vi` companion crate (`packages/vi`) is **not** part of
this fix. See [Caveat: vi keystore](#caveat-vi-keystore) below.

## CVSS 3.1 vector

- **AV:L** — local file-system access required.
- **AC:L** — no special timing, no race condition.
- **PR:L** — requires being the same Unix user, or having achieved
  file-system access to the user's `~/.treeship/keys/`.
- **UI:N** — no user interaction required once the attacker has read
  access.
- **S:U** — exploitation does not cross a security boundary;
  consequences stay within the user's own trust scope.
- **C:H** — full disclosure of any Ed25519 private key in the keystore.
- **I:H** — full integrity loss: attacker can forge signatures or
  swap in a controlled key.
- **A:N** — no impact to availability.

Numeric base score: **7.1**.

## Description

### What we documented

> AES-256-CTR + HMAC encrypted keystore, machine-bound

(from `SECURITY.md` prior to this release).

> Private keys are encrypted with AES-256-GCM (RustCrypto `aes-gcm`
> 0.10) before writing to disk.

(from the `Store` doc-comment in `packages/core/src/keys/mod.rs`).

### What we shipped

The pre-fix implementation derived two SHA-256 outputs from
(machine_key, random_nonce, "enc" | "mac") and used the first as a
"keystream" and the second as an HMAC key. The keystream generation
indexed the SHA-256 output as `block[i % 32]` for the `i`th plaintext
byte, which means:

1. Every 32 consecutive plaintext bytes XOR against the same 32-byte
   pattern, modulo recomputation per index (the per-index recomputation
   uses the byte index but not the block index, so the cipher does not
   produce a fresh block).
2. With even a few bytes of known plaintext (e.g. the leading bytes of
   an Ed25519 scalar that are statistically biased), an attacker can
   recover the per-position keystream byte and decrypt the rest of the
   message at the same position modulo 32.
3. The HMAC keys the integrity tag against the same `(machine_key,
   nonce)` derivation, so anyone in a position to extract the
   keystream can also forge MACs.

There is no AES involved anywhere in the data path. There is no GCM
mode anywhere in the data path. The construction is unrelated to the
documented primitive.

### Impact

Concretely, a local attacker with file-system access to `~/.treeship/keys/`
could:

- **Recover an Ed25519 private key** by exploiting the degenerate
  keystream against known JSON envelope bytes plus the Ed25519 scalar's
  structural constraints.
- **Forge a key file** that decrypts cleanly under the victim's machine
  key, swapping the attacker's keypair into the victim's keystore.
- **Tamper without detection** by recomputing the HMAC after a swap;
  the documented "AEAD" guarantee never existed.

Practical exploitation requires write access to the keystore file and
some known-plaintext from the surrounding JSON envelope (which is
predictable: Ed25519 secret scalars have known structural biases, and
the JSON wrapper around them has fixed leading bytes). We are not aware
of any in-the-wild exploitation.

The attacker must already be the same Unix user or have escalated to
that user's file-system access. This advisory does not describe a remote
exploit. The local-only scoping (PR:L, AV:L) is reflected in the CVSS
vector.

A remote attacker who exfiltrates a copy of `~/.treeship/keys/` (e.g.
via a leaked backup or a misconfigured cloud sync folder) and the
victim's machine identifier can also recover keys offline. The
machine-key derivation, while binding to hostname+username on macOS or
`/etc/machine-id` on Linux, is not a secret; an attacker who can read
the keystore can typically also read those identifiers.

## Migration

Treeship `0.10.3` introduces a v2 on-disk keystore format that uses
real AES-256-GCM (the RustCrypto `aes-gcm` crate) with the framing
prefix, the entry id, and the public key all bound into the AEAD's
AAD. The entry-id + public-key binding closes the intra-keystore swap
class described in [Fixed in](#fixed-in) above; the durability path
also now fsyncs the parent directory after the atomic rename so the
H1 doc-comment's promise of crash-safety matches reality on ext4/xfs.

The migration is **automatic**:

- On first `Store::signer` call against a legacy entry, the dispatcher
  detects the absence of the v2 framing prefix, routes the decrypt
  through the legacy v1 path, and (on success) re-encrypts the secret
  using `encrypt_for_disk_v2` and rewrites the entry file atomically.
- The atomic write is a tmp-then-rename so a crash mid-migration cannot
  destroy the original entry.
- A per-entry advisory lock on `<entry>.migrate.lock` serializes
  concurrent migrators across processes, so a parallel `treeship`
  invocation cannot race the rewrite.
- The migration is idempotent. A second call after migration completes
  short-circuits in the v2 path with no further I/O.

There is **no user action required**. Upgrading the `treeship` binary
is sufficient. The first signing operation after upgrade transparently
moves each key entry to v2.

If you want to verify migration completed for a given key, you can
inspect the on-disk entry: a v2 entry's `enc_priv_key` byte 0 is `0x54`
('T') and byte 1 is `0x02`. The legacy nonce field on the JSON entry
is empty for v2 entries.

## Caveat: vi keystore

The companion `treeship-vi` crate (`packages/vi`) maintains its own
keystore for the L2/L3 mandate signing flow. **The vi keystore is NOT
yet migrated** by this release and continues to use the pre-fix
construction. It will migrate on vi's own cadence in a subsequent
release.

Until vi's migration release ships:

- Treeship `core` keys (`~/.treeship/keys/`) are protected by the new
  v2 AEAD and are not affected by TS-2026-001 after upgrade.
- vi keys (`~/.treeship/vi/keys/` or wherever your vi installation
  stores them) remain on the prior construction and remain affected by
  TS-2026-001 until vi's own fix ships.

**If you use vi to sign L2 or L3 mandates, plan to rotate vi-issued
keys after vi's migration release.** The next vi release will publish
its own advisory and migration instructions; subscribe to
[security@treeship.dev](mailto:security@treeship.dev) announcements or
watch the changelog.

The two crates ship on independent cadences because vi has its own
hardware-binding semantics (Secure Enclave on macOS, TPM 2.0 on Linux
when available) whose migration path interacts with platform attestation
in ways the core keystore does not have to consider.

## Credit

Internal discovery during the v0.10.3 launch audit, May 2026. Reviewed
by the Treeship core maintainers.

No external researcher reported this; no public window was opened
because the same release cycle that surfaced the defect also shipped
the fix.

Future security findings should be reported to
[security@treeship.dev](mailto:security@treeship.dev). See
[SECURITY.md](../../SECURITY.md) for the full disclosure process.

## Disclosure timeline

The `0.10.3` release date will appear in
[`CHANGELOG.md`](../../CHANGELOG.md) when the release ships.

| Date       | Event                                                          |
|------------|----------------------------------------------------------------|
| 2026-05-12 | Defect identified during internal pre-launch audit.            |
| 2026-05-13 | Fix implemented: AES-256-GCM via `aes-gcm` 0.10 + AAD binding. |
| 2026-05-13 | Atomic write + advisory lock added to the migration path.     |
| 2026-05-14 | Audit polish: 10k-nonce RNG check, post-migration v2 decrypt assertion, `Zeroizing` of stack-allocated secret. |
| 2026-05-17 | Post-merge adversarial review: v2 AAD extended to bind entry id + public key (closes intra-keystore swap); `write_file_600` fsyncs parent dir after rename. |

## Cross-references

- Patch landing branch: `audit/lane-e-keystore-aead`.
- Source: `packages/core/src/keys/mod.rs` (`encrypt_for_disk_v2`,
  `decrypt_v2`, `decrypt_from_disk`, `migrate_entry_to_v2`,
  `write_file_600`).
- Test coverage: `keys::tests::v2_*`,
  `keys::tests::store_signer_migrates_legacy_entry_to_v2`,
  `keys::tests::concurrent_migration_serializes_correctly`,
  `keys::tests::atomic_write_leaves_original_intact_on_partial_failure`,
  `keys::tests::mode_is_600_at_creation`,
  `keys::tests::cross_entry_swap_fails_decryption`,
  `keys::tests::aad_tampered_entry_id_fails_decryption`.
