Skip to content

Example: jet identity from a response-wrapped Vault token - #168

Draft
linuskendall wants to merge 4 commits into
mainfrom
claude/jet-vault-token-identity-bgrdvq
Draft

Example: jet identity from a response-wrapped Vault token#168
linuskendall wants to merge 4 commits into
mainfrom
claude/jet-vault-token-identity-bgrdvq

Conversation

@linuskendall

Copy link
Copy Markdown
Member

Sample program showing how jet could obtain its identity keypair from a single-use, response-wrapped Vault token handed to it by a Nomad job, and unwrap it itself.

This is a standalone example for discussion — it changes no jet code. The only change outside examples/ is one line adding the example to the workspace exclude list, matching how examples/jet-tpu-sender is treated.

Why response wrapping

With Vault response wrapping, the keypair never appears in the Nomad job spec, template output, or environment. The task receives only a wrapping token — a reference to a single-use cubbyhole holding the real secret:

  • Single usesys/wrapping/unwrap succeeds exactly once. If jet's own unwrap fails, either the token expired or somebody else already unwrapped it, which is a built-in interception alarm rather than a silent compromise.
  • Short-lived — the wrap_ttl bounds the exposure window.
  • Tamper-evidentsys/wrapping/lookup (which does not consume the token) exposes the token's creation_path, so jet can refuse a token that was not minted by the expected Vault endpoint before spending the single use.

What's here

examples/jet-vault-identity/src/main.rs — the whole flow is one function, load_identity_from_wrapped_token, built on the vaultrs crate, which ships the wrapping endpoints natively (vaultrs::sys::wrapping::{lookup, unwrap}):

  1. read the wrapping token from $VAULT_WRAPPED_TOKEN, where a Nomad template { env = true } stanza rendered it, so it never lands on disk;
  2. optionally verify its creation_path via sys/wrapping/lookup;
  3. unwrap via sys/wrapping/unwrap, authenticated with the wrapping token itself — jet holds no other Vault credential;
  4. parse the keypair field into a solana_keypair::Keypair that only ever lives in memory.

In jet proper this is what main would call to produce initial_identity before handing it to JetIdentitySyncGroup, in place of config.identity.keypair.unwrap_or(Keypair::new()) at apps/jet/src/bin/jet.rs:321.

The function takes no arguments; everything comes from the environment:

Variable Required Meaning
VAULT_ADDR no Vault address; read by vaultrs itself, defaults to http://127.0.0.1:8200
VAULT_WRAPPED_TOKEN yes the response-wrapped, single-use token
VAULT_EXPECTED_CREATION_PATH no when set, enables the creation-path tamper check

examples/jet-vault-identity/README.md covers the producer side: the Vault KV layout, a vault kv get -wrap-ttl=300s example, and a short Nomad template sketch (deliberately a sketch, not a full job spec).

Testing

Verified end-to-end against a mock Vault implementing the two wrapping endpoints with single-use semantics:

  • unset VAULT_WRAPPED_TOKEN → names the missing variable
  • empty VAULT_WRAPPED_TOKEN → points at the unrendered Nomad template (an unrendered template yields an empty string, which would otherwise surface as an opaque 403 from Vault)
  • VAULT_ADDR unset → falls back to 127.0.0.1:8200; set → honoured
  • wrong VAULT_EXPECTED_CREATION_PATH → refuses before consuming the token
  • happy path → recovers the expected pubkey
  • second unwrap of the same token → fails with the already-unwrapped/rotate-the-identity warning

cargo build, cargo clippy (0 warnings) and cargo +nightly fmt --check under the repo's rustfmt.toml are all clean. Note that CI does not build this example, since workspace-excluded members are outside cargo build/clippy --all-targets — same as the existing jet-tpu-sender example.

Open questions

  • Scope of the tamper check. VAULT_EXPECTED_CREATION_PATH is optional and inert unless set. Happy to drop it if the extra knob isn't wanted.
  • Keypair encoding. parse_keypair accepts the solana id.json byte array (as JSON or a stringified array) and base58, because how the keypair gets into Vault varies by operator. If we standardize on vault kv put ... keypair=@id.json, only the array arm ever fires and the rest can go.
  • Env var vs. file. The token currently comes through the environment, so it never touches disk, but it does stay readable via /proc/self/environ for the life of the process. Scrubbing it needs an unsafe block in edition 2024 and is only sound before any thread starts. A file in NOMAD_SECRETS_DIR (tmpfs) trades that for a path that can be read once and unlinked — worth a decision either way.
  • Where this lands in jet. As an example it stays out of the way; folding it into ConfigIdentity as a new identity source would be the natural follow-up if the approach looks right.

Generated by Claude Code

claude added 4 commits August 28, 2026 09:19
Sample showing how jet could ingest a single-use, response-wrapped
Vault token provided by its Nomad job and unwrap it itself to obtain
its identity keypair in memory:

- read the wrapping token from the file the Nomad template rendered
- optionally verify the token's creation_path via sys/wrapping/lookup
  (non-consuming) to detect a substituted token
- unwrap via sys/wrapping/unwrap, authenticated with the wrapping
  token itself; a failed unwrap means expiry or interception since the
  token is single use
- parse the keypair field (id.json byte array or base58) into a
  solana_keypair::Keypair

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTR2h1D8F2iRswWoGhp3FS
Replace the hand-rolled reqwest calls with the vaultrs crate, which
provides the wrapping endpoints natively:

- vaultrs::sys::wrapping::lookup for the non-consuming creation_path
  tamper check
- vaultrs::sys::wrapping::unwrap for the single-use unwrap, with the
  wrapping token as the client token (no other Vault credentials)
- ClientError::APIError code 400 detected explicitly to surface the
  already-unwrapped/interception case

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTR2h1D8F2iRswWoGhp3FS
The Nomad job exports the response-wrapped token through a
template { env = true } stanza, so the token never lands on disk:

- VaultIdentityConfig::wrapped_token_file becomes wrapped_token_env,
  carrying the name of the variable to read
- empty value is rejected explicitly, since an unrendered template
  otherwise surfaces as a confusing 403 from Vault
- note that the token stays visible in /proc/self/environ, and that
  scrubbing it needs unsafe in edition 2024 and is only sound before
  any thread starts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTR2h1D8F2iRswWoGhp3FS
load_identity_from_wrapped_token now takes no arguments and reads
everything from the environment:

- VAULT_WRAPPED_TOKEN for the single-use wrapped token
- VAULT_ADDR left to vaultrs, whose client builder already reads it and
  falls back to http://127.0.0.1:8200
- VAULT_EXPECTED_CREATION_PATH to opt into the tamper check

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTR2h1D8F2iRswWoGhp3FS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants