iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔐

Local Trust Store and Recipient Set Cache

に公開

This article explains the cryptographic processing that powers SecretEnv, a Rust-based CLI designed to protect .env files using encryption. SecretEnv is a tool that achieves confidentiality by encrypting each value in a .env file with the public keys of individual members, ensuring that only those with the corresponding private key can read them.

In a previous article, we looked at Ed25519 signatures, SSH key attestation, and GitHub online verification mechanisms to confirm the authenticity of public keys. Even if these verifications succeed, that alone is not enough to accept a file. "Cryptographically confirming that the key belongs to the person" and "recognizing this key as an authorized signer for this file" are two separate decisions.

Managing the record of those decisions is the theme of this article: the local trust store. The trust store is placed under the user's home directory and is a private cache that is not shared via Git. It maintains the history of approved keys and the history of approved recipient sets.

Intended Scenarios

Continuing from the previous article, we assume a scenario after Carol's PublicKey has been approved using member verify --approve.

After the approval is complete, Alice checks what is recorded in her trust store.

secretenv trust keys list
secretenv trust keys show carol@example.com

She also checks for changes in the recipient set every time she reads .env.enc.

secretenv get DATABASE_URL

The roles of each command are as follows:

  • trust keys list displays a list of approved keys saved in the trust store.
  • trust keys show <handle> displays the details of the approval record for a specific member.
  • get compares the file's signer and recipient set with the trust store before decryption, notifying the user if there are any changes.

If approved information remains in the cache, you will not be asked for re-approval during daily get or run operations. Explicit confirmation is only requested when an unknown key or a changed recipient set appears.

Two Lists: Workspace and Trust Store

There are two main data sources referenced for trust decisions.

The first is the list of active members within the workspace (members/active/). It represents "current members" of the team, shared via Git. It assumes that changes are monitored through PR reviews. This is a collection representing "who is currently a team member."

The second is the trust store. This is a private cache placed under the user's home directory and is not shared via Git. It contains two types of information:

  • Approved Key Cache: A record stating "I have previously recognized this key as the person" for each key ID.
  • Recipient Set Cache: A record stating "I have previously approved this recipient set" for each file.

They have different roles. By separating "current team recognition" and "my personal approval history," even if one has an anomaly (e.g., strange changes in Git history or an outdated cache), the other functions independently.

Contents of the Approved Key Cache

The "Approved Key" record in the trust store has the following structure:

{
  "kid": "K3M7N8PQ2R5S7T9V2W4X6Y8Z3B5C7D9F",
  "subject_handle": "alice@example.com",
  "approved_at": "2026-05-13T15:42:00Z",
  "approved_via": "manual-review",
  "evidence": {
    "github_account": { "id": 12345, "login": "alice" },
    "ssh_attestor_pub": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILk7xNQa... alice@laptop"
  }
}

Let's look at the role of each field.

kid is the key ID being approved. Approval is done at the key ID level, and the same key ID can only be approved once.

subject_handle is the handle confirmed at the time of approval. It is a record to look back on "what the handle was at that time."

approved_at and approved_via are the approval time and means of approval. manual-review indicates that the user confirmed and approved it on the screen.

evidence is a record of auxiliary information displayed and confirmed at the time of approval. It includes the GitHub ID, login, SSH public key, etc. This is for record-keeping and is not used for subsequent automatic judgments. It is merely information for humans to look back on "what evidence was used to approve this."

The idea of "approving by key" is similar to SSH's known_hosts. SSH records "I have previously seen this host's public key with this value." SecretEnv records "I have previously recognized this key ID as the person."

The Meaning of Key-Level Approval

By approving at the key ID level, a new approval is required if the same member generates a new key. For example, if Alice updates her key with key new, a PublicKey with a new kid is generated. Even if Alice's old key is in the cache, the new key is treated as unapproved.

This expresses trust in a narrower scope—"I trust this specific key" rather than "once I approve someone, I trust them forever." It guarantees that the user has an opportunity to re-confirm when key rotation occurs.

Recipient Set Cache

The other type of cache is the "Recipient Set Cache." This records information such as "this recipient set for this file is approved" on a per-file basis.

Records take the following form:

{
  "sid": "7b2c9e0a-345b-4d21-9f81-5c3a788b1d4f",
  "recipient_kids": [
    "K3M7N8PQ2R5S7T9V2W4X6Y8Z3B5C7D9F",
    "M5N6P7Q8R9S0T1V2W3X4Y5Z6A7B8C9D0",
    "P3Q4R5S6T7U8V9W0X1Y2Z3A4B5C6D7E8"
  ],
  "recipient_set_hash": "h7K3M8N4P2Q5R7S9T1V3W5X7Y9Z2A4B6C8D0E1F2G3",
  "approved_at": "2026-05-13T15:45:00Z",
  "approved_via": "manual-review"
}

sid is the file's session ID. It is a unique identifier for each file, and exchanges of the same .env.enc file share the same sid.

recipient_kids is a list of key IDs included in the recipient set at the time of approval.

recipient_set_hash is a deterministically hashed value of the set of key IDs. After normalizing the order, the hash is taken including usage identifiers. Since the actual comparison is performed using this hash value, a mere change in the order of recipients will not result in a cache miss.

Why Recipient Set Approval is Necessary

"Trusting individual keys" and "accepting this set of keys as recipients of an encrypted file" are different judgments.

For example, even if you have approved Bob's and Carol's individual keys as "being their keys," whether you include both Bob and Carol as recipients of your .env is a separate decision. If someone arbitrarily adds recipients in a PR, if individual keys are already trusted, individual key approval checks would just let it pass. By approving the recipient set at the hash level, you are asked for re-approval as soon as there is a change in the composition of the set.

This design creates an opportunity to verify that the recipient configuration matches the user's intent when a rewrap—which includes adding or removing recipients—is performed.

Approval Flow

Let's see what happens when you execute secretenv member verify --approve.

Here are the approval steps:

  1. Read the target PublicKey from the workspace.
  2. Perform offline verification (structural verification, self-signature verification, key attestation verification).
  3. If the PublicKey contains a GitHub association claim, perform online verification (detailed in the previous article).
  4. Display: Key ID, SSH key fingerprint, GitHub user ID, and login.
  5. The user performs the approval operation.
  6. Add a new record to the approved key cache.
  7. Re-sign the entire trust store with the user's own Ed25519 private key.

Step 5, where it says "the user performs the approval operation," is a judgment that cannot be replaced by cryptography. The user approves based on the result of verifying the displayed information through a separate channel (verbal, face-to-face, corporate chat, etc.).

TOFU: Trust On First Use Pattern

If you have used SSH's known_hosts, you may have had the experience of being asked "Do you trust the fingerprint of this host?" when connecting for the first time. If you answer "yes," that fingerprint is automatically verified for subsequent connections. This is a pattern known as TOFU (Trust On First Use).

SecretEnv's approved key cache also follows this structure. The key confirmed and approved during the initial member verify --approve is used as a cache for subsequent operations. From the second time onward, you will not be asked to re-confirm for files using the same key ID.

Strengths and Limitations of TOFU

The strength of TOFU is that it allows the confirmation of authenticity to begin without a Certificate Authority (CA) or a central server. It requires no complex infrastructure and can start from the very first confirmation.

The limitation is that it cannot guarantee whether "that first time" was secure. If a man-in-the-middle attack occurred during the initial confirmation, you would end up registering the attacker's key as the person's key. Similarly, with SSH's known_hosts, there is no way to prove after the fact that the initial connection was secure.

SecretEnv accepts this limitation but addresses it by stacking auxiliary evidence—specifically, SSH key attestation and GitHub online verification—to "increase the information available for the initial confirmation judgment." As explained in the previous article, the display during the initial approval encourages out-of-band verification.

Handling Multiple Terminals and CI

The trust store is a local file. If Alice approves Carol's key on her home laptop, it is not automatically reflected on her work PC.

When working on different terminals, you choose one of the following:

  • Execute member verify --approve individually on each terminal.
  • Manually copy and port the trust store file (an operation performed by the user, not a command provided by SecretEnv).

In a CI environment, executing secretenv run without a trust store triggers an interactive approval prompt. Since no one can answer the prompt in CI, you must either pre-place the trust store or use an option to skip CI-specific approval. However, if skipped, decryption runs without trust judgment, so you must separately manage CI-specific keys and permissions. This topic will be covered in another article.

Trust Store File Structure

The actual trust store file looks like this:

{
  "protected": {
    "format": "secretenv:format:trust-store@1",
    "owner_handle": "alice@example.com",
    "owner_kid": "K3M7N8PQ2R5S7T9V2W4X6Y8Z3B5C7D9F",
    "updated_at": "2026-05-13T15:45:00Z"
  },
  "known_keys": [
    {
      "kid": "M5N6P7Q8R9S0T1V2W3X4Y5Z6A7B8C9D0",
      "subject_handle": "carol@example.com",
      "approved_at": "2026-05-13T15:42:00Z",
      "approved_via": "manual-review",
      "evidence": {
        "github_account": { "id": 67890, "login": "carol-dev" },
        "ssh_attestor_pub": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBk8xNQb... carol@laptop"
      }
    }
  ],
  "known_recipient_sets": [
    {
      "sid": "7b2c9e0a-345b-4d21-9f81-5c3a788b1d4f",
      "recipient_kids": [
        "K3M7N8PQ2R5S7T9V2W4X6Y8Z3B5C7D9F",
        "M5N6P7Q8R9S0T1V2W3X4Y5Z6A7B8C9D0"
      ],
      "recipient_set_hash": "h7K3M8N4P2Q5R7S9T1V3W5X7Y9Z2A4B6C8D0E1F2G3",
      "approved_at": "2026-05-13T15:45:00Z",
      "approved_via": "manual-review"
    }
  ],
  "signature": "Bn7Vq2Wp5Yt7Zb4Cd6Eh8Fk1Gm5Hn3Jq7Kr9Ls2Ms3Nt4Ov5Pw6Qx7Ry8Sz9..."
}

The file is saved as <SECRETENV_HOME>/trust/<owner_handle>.json. The default SECRETENV_HOME is ~/.secretenv.

Inside protected are the owner handle of this store, the kid of the owner's key, and the last updated timestamp. known_keys is the approved key cache, and known_recipient_sets is the recipient set cache. The final signature is a signature created using the user's own Ed25519 private key across the protected object and the two caches.

This file is a plaintext JSON. It is not encrypted because the approval record is not sensitive information; only integrity (tamper detection) needs to be protected. With a signature, even if you read the file, you cannot tamper with the fact that the "signer has been verified."

Signing the Trust Store Itself

The trust store (<SECRETENV_HOME>/trust/<owner_handle>.json) is saved as a file signed by the user's own Ed25519 private key.

Why sign the store itself? Even files on a local terminal can be rewritten by other processes. If the content of the approved key cache is tampered with, another key could be falsely claimed as "previously approved." With a signature, the store's integrity can be verified at startup.

The key used here is the user's own member key (PublicKey/PrivateKey pair). The same key used to sign encrypted files is also used to protect the trust store.

A store that fails integrity verification proceeds to recovery procedures. Since this is another topic, it is enough to remember that "the store itself is also a target for tamper detection."

Acceptance Judgment Combining Verification and Trust

Finally, let's organize the entire judgment process when reading an encrypted file.

Machine-based automation handles the cryptographic parts. For trust judgment, it passes automatically if there is a cache hit, and if not, it requests explicit approval from the user.

The Significance of Three Independent Checks

The reason for the three parallel checks (I, J, and K) is as follows:

The active member set (I) represents the team's recognition shared via Git. The approved key cache (J) is the individual's approval history. The recipient set cache (K) is the approval record of "who is included as a recipient."

For example, even if a key belongs to an active member on the workspace, if you have not yet executed member verify --approve, it will not hit the approved key cache. In this case, it correctly expresses the state that while the team recognizes the member, you personally have not yet verified them.

Conversely, you can detect when a key in the approved key cache is no longer in the workspace's active member list. If you attempt to read a file after someone has merged a PR to remove a member, it flags that "this signer is no longer an active member." This allows you to notify the user of such inconsistencies before acceptance.

Daily Life with Cache Hits

Daily operations where approved information remains in the cache require nothing from the user.

In daily startups like secretenv run -- npm start, the following runs in the background:

  • Verify file structure and signature.
  • Check if signer_pub's kid is in the approved key cache.
  • Check if the current recipient set hash is in the recipient set cache.
  • Confirm consistency with the active member set.
  • If all are OK, proceed to decryption.

If all of these result in cache hits, nothing is displayed to the user, and decryption proceeds normally, launching the child process. Once approval history accumulates, daily operations become almost frictionless.

What This Design Protects

  • By separating the basis of trust into the active member set (Git operations) and personal approval history (trust store), the cryptographic operations do not become vulnerable if one side is compromised.
  • Approval at the key ID level ensures that even if the same person updates their key, a new key requires a fresh approval.
  • The recipient set cache ensures that changes in recipient composition are notified to the user.
  • Because the trust store itself is signed, store tampering can be detected.
  • Because re-approval is requested even for approved keys if the recipient set changes, you can notice unauthorized recipient additions made via PRs.

Remaining Constraints

  • Trust in the active member set depends on Git access control and PR review. In environments where these are lax, cryptographic verification becomes ineffective.
  • Backup of the trust store itself or synchronization between terminals is not handled within SecretEnv. It is a region not committed to Git and managed by the user personally.
  • Approval records represent the fact that "I approved based on evidence at that time," and do not guarantee future key security. Updating in the event of a later SSH key compromise must be handled by external operations.

What We Did Not Cover

Member removal, key rotation, and the division between what can be done with cryptography versus what must be handled by external operations will be covered in the next article.

https://github.com/ebisawa/kapsaro

Discussion