Status: Draft 1 Last updated: 2026-05-05 Authors: David Almeida
This document specifies the binary layout, cryptographic primitives, and
operational semantics of the .env.sealed file format, version 1.
Both the Node.js and Java implementations of sealed-env MUST follow this
specification exactly. A file written by the Node implementation MUST be
readable by the Java implementation, and vice versa.
- Cross-stack interoperability. Node and Java teams share the same file.
- Tamper-evident. Modification of the ciphertext or metadata is detected.
- Self-describing. The file declares its mode and parameters, no out-of-band config.
- Human-greppable header. Engineers can
catit and know the version/mode. - Forward-compatible. v2 must coexist with v1 readers (which gracefully refuse).
+-----------------------------------------------------------+
| LINE 1 Magic + version + mode |
| LINE 2-N Metadata (key=value, ASCII, one per line) |
| LINE N+1 Empty line (separator) |
| LINE N+2 Body (one base64-encoded ciphertext line) |
+-----------------------------------------------------------+
The file is plain UTF-8 text. No binary bytes. This is intentional:
git diffshows changed lines- A reviewer can spot fishy header changes
- Editors and pipes work without surprises
SEALED-ENV-V1 MODE=<mode>
Where <mode> is one of:
| Mode value | Description |
|---|---|
basic |
AES-256-GCM only. Master key = sole secret. |
team |
Adds explicit HMAC + audit log. |
enterprise |
Adds TOTP-based unseal token bound to a deploy challenge. |
Readers MUST reject the file if:
- The magic prefix is not exactly
SEALED-ENV-V1(case-sensitive) - The mode is unknown
- The line ends with characters other than the listed format
A series of KEY=VALUE lines. The order is significant for HMAC computation
(see §6). Lines MUST appear in the order listed below; missing optional fields
are simply skipped (the order remains stable).
| # | Key | Required in mode | Format |
|---|---|---|---|
| 1 | KDF |
all | One of argon2id, scrypt |
| 2 | KDF-PARAMS |
all | For argon2id: t=<int>,m=<int>,p=<int>. For scrypt: N=<int>,r=<int>,p=<int> |
| 3 | SALT |
all | base64, 16 bytes raw |
| 4 | NONCE |
all | base64, 12 bytes raw |
| 5 | EPOCH-COMMIT |
enterprise only | base64, 32 bytes (HMAC-SHA256 commitment to the salt-bound enterprise epoch — see §6) |
| 6 | CHALLENGE-BIND |
enterprise only | enabled or disabled |
| 7 | AAD-DIGEST |
all | base64, 32 bytes (SHA-256 of associated data) |
| 8 | HMAC |
team and enterprise | base64, 32 bytes |
| 9 | CREATED |
all | ISO-8601 UTC timestamp |
| 10 | ROTATED |
optional | ISO-8601 UTC timestamp; absent if never rotated |
Key names are uppercase ASCII with hyphens. Values do not contain =, \n, or
spaces. Whitespace around = is NOT allowed.
| Primitive | Choice | Parameters |
|---|---|---|
| Symmetric cipher | AES-256-GCM | 256-bit key, 96-bit nonce, 128-bit auth tag |
| Key derivation | Argon2id (preferred) or scrypt (interim) | argon2id default: t=3, m=65536, p=4 · scrypt default: N=32768, r=8, p=1 |
| Subkey derivation | HKDF-SHA256 | RFC 5869, info strings listed in §6 |
| Integrity (team mode) | HMAC-SHA256 | over concatenation defined in §6 |
| TOTP (enterprise mode) | RFC 6238 | SHA-1, 30s step, 6 digits, ±1 step skew |
| Random | OS CSPRNG | crypto.randomBytes in Node, SecureRandom.getInstanceStrong() in Java |
Forbidden: AES-CBC, PBKDF2, MD5, SHA-1 (except inside RFC 6238 TOTP), PKCS#1 v1.5 padding, custom RNG.
master_key(operator-provided, ≥32 bytes after KDF) — base materialplaintext(the contents of the original.env)mode(basic|team|enterprise)- For enterprise:
totp_secret(20 bytes random) - For team/enterprise:
signing_key(operator-provided, separate from master)
- Generate
salt = randomBytes(16). - Generate
nonce = randomBytes(12). derived_key = argon2id(master_key, salt, kdf_params)→ 32 bytes.enc_key = HKDF(derived_key, salt=salt, info="sealed-env:v1:enc", L=32).aad = utf8(magic_line || metadata_canonical_form).aad_digest = SHA-256(aad).ciphertext_with_tag = AES-256-GCM-encrypt(enc_key, nonce, plaintext, aad).- If
mode == enterprise:enterprise_epoch = HMAC-SHA256(totp_secret, salt || "epoch-v1").epoch_commit = HMAC-SHA256(derived_key, enterprise_epoch || "epoch-commit-v1").- The file commits to
epoch_commit. The TOTP secret itself never appears in any persisted artifact (file or token). The salt binding ensures a leaked epoch is only useful against this specific file generation.
- If
mode in (team, enterprise):mac_key = HKDF(signing_key, salt=salt, info="sealed-env:v1:mac", L=32).hmac = HMAC-SHA256(mac_key, magic_line || metadata_without_HMAC || ciphertext_with_tag).
- Write file in the layout of §2 with all metadata fields populated.
The "metadata canonical form" used in step 5 is the metadata lines joined by
\n (Unix newline), with NO trailing newline, in the order specified in §4.
Always exclude the HMAC line itself when computing the HMAC over metadata.
- File path
master_key(env varSEALED_ENV_KEY)- For team/enterprise:
signing_key(env varSEALED_ENV_SIGNING_KEY) - For enterprise:
unseal_token(env varSEALED_ENV_UNSEAL_TOKEN)
- Parse file, fail if magic line malformed.
- Validate
modematches expectation (or accept what file declares). - Reconstitute
derived_keyvia Argon2id over master_key + parsed salt. - HMAC verification (team+): recompute HMAC and compare with
crypto.timingSafeEqual(Node) /MessageDigest.isEqual(Java). Fail loud if mismatch. - Enterprise unseal verification:
- Parse the unseal token (JWT-like, see §9).
- Verify token signature with
derived_key. - Verify
token.exp > now. - Verify
HMAC-SHA256(derived_key, token.epoch || "epoch-commit-v1") == stored EPOCH-COMMIT. Constant-time compare. The token carriesenterprise_epoch(a salt-bound HMAC derivative of the operator's TOTP secret), NOT the TOTP secret itself. - If
CHALLENGE-BIND=enabled: verifytoken.deploy_idmatches the current deploy challenge (provided by CI asSEALED_ENV_DEPLOY_ID).
- AAD reconstruction: rebuild
aadfrom magic + metadata (excluding HMAC), compute SHA-256, compare with storedAAD-DIGEST. Fail if mismatch. enc_key = HKDF(derived_key, salt, "sealed-env:v1:enc", 32).plaintext = AES-256-GCM-decrypt(enc_key, nonce, ciphertext_with_tag, aad). GCM authentication failure → fail loud.- Parse
plaintextas a standard.envfile (KEY=value lines, comments starting with#, quoted values, escapes per dotenv conventions).
All decryption failures MUST surface a single error message: "sealed-env: file is corrupted, tampered, or wrong key". Do NOT leak which step failed —
that's a side-channel for attackers probing keys.
Format: t=<int>,m=<int>,p=<int> where:
t— iterations (Argon2time_cost)m— memory in KB (Argon2memory_cost)p— parallelism (Argon2parallelism)
Defaults (suitable for desktop hardware in 2026):
t=3, m=65536 (64 MB), p=4
For CI runners with limited memory, operators may write files with reduced
params (e.g. t=4, m=16384, p=2). Readers MUST honor whatever is in the file.
Recommended minimum: t=2, m=16384, p=1.
The unseal token is a compact, signed payload that the operator generates locally and the application verifies at startup.
usl_<base64url(header)>.<base64url(payload)>.<base64url(signature)>
Header:
{ "alg": "HS256", "typ": "sealed-env-unseal/v1" }Payload:
{
"iss": "sealed-env-cli",
"iat": 1717024500,
"exp": 1717024560,
"epoch": "<base64-of-32-byte-enterprise-epoch>",
"deploy_id": "<sha-256-of-commit-or-null>",
"ops_id": "<random-uuid-v4>"
}Where:
enterprise_epoch = HMAC-SHA256(totp_secret, salt || "epoch-v1")
The enterprise_epoch is a salt-bound HMAC derivative of the operator's
TOTP secret — never the secret itself. This is the central security property
of the unseal protocol: a leaked token (e.g., from CI logs, container env
dumps, or stack traces) does NOT give the attacker the TOTP secret. Without
the secret, the attacker cannot recompute enterprise_epoch for files with
a different salt (i.e., future re-sealings), so the blast radius of a
leaked token is bounded to the specific file generation that produced it.
Signature:
HMAC-SHA256(derived_key, base64url(header) + "." + base64url(payload))
exp - iat ≤ 600seconds (max 10 minutes lifetime)ops_idmust be unique; readers SHOULD maintain a short-term replay cache- If
CHALLENGE-BIND=enabled,deploy_idMUST be present and verified
A correctly implemented reader/writer pair guarantees:
- Confidentiality. Without
master_key, the ciphertext is indistinguishable from random under chosen-plaintext attack. - Integrity. Any modification of header, metadata, or ciphertext is detected at decryption time (GCM tag + optional HMAC + AAD digest).
- Mode-binding. A
basicfile cannot be silently parsed asenterpriseto bypass TOTP — themodeis part of the AAD. - No replay across deploys (enterprise + CHALLENGE-BIND). A captured unseal token is only valid for its bound deploy.
- Forward secrecy on rotation. After
rotate, old files cannot be decrypted with the new key (and vice versa). Old files should be deleted.
A developer adopting sealed-env lands on this page asking one question:
"I have a
.env.sealedin CI. What single value do I paste into a CI secret so my app boots?"
Prior to v1's token format, the answer involved 2-3 separate environment
variables (SEALED_ENV_KEY, SEALED_ENV_SIGNING_KEY, optionally
SEALED_ENV_TOTP_SECRET). Operators routinely pasted them in the wrong
slot, forgot one, or shipped them via different channels and lost
correlation. The credential interface MUST be one paste, like a Stripe
sk_live_* key — complexity inside, simplicity outside.
The answer for v1 is a single string: SEALED_ENV_TOKEN=sealed_env_<mode>_<checksum>_<payload>.
This section is the canonical contract for that string. Node, Java, and Rust implementations MUST produce and consume byte-identical tokens from the same inputs.
sealed_env_<mode>_<checksum>_<payload>
| Component | Bytes | Description |
|---|---|---|
sealed_env_ |
11 ASCII | Literal prefix. Never changes across format versions. Greppable in CI logs. |
<mode> |
1 ASCII | One of b, t, e, u, d. See §11.6. |
_ |
1 ASCII | Separator. |
<checksum> |
4 ASCII | Lowercase hex. See §11.4. |
_ |
1 ASCII | Separator. |
<payload> |
variable | base64url-no-padding of CBOR map. See §11.5. |
The prefix sealed_env_ is literal and version-stable. A future
format revision will live inside <mode> / payload schema, not in the
prefix. This guarantees that grep rules in CI log scrubbers written in
2026 still match tokens emitted in 2030.
<mode> |
Tier | Meaning |
|---|---|---|
b |
A · Root | basic vault — master key only |
t |
A · Root | team vault — master + signing |
e |
A · Root | enterprise vault — master + signing + TOTP secret |
u |
A · Unseal | Re-wrap of the legacy JWS unseal token (see §9) |
d |
B · Deploy | Ephemeral deploy token (see §12) |
Tier A tokens are long-lived secrets that belong in a password manager, KMS, or Studio keychain. Tier B tokens are short-lived (TTL ≤ 600s) and designed to be pasted into CI per deploy.
- Allowed bytes in the entire token string:
[a-zA-Z0-9_-]. This is the base64url alphabet plus_as a separator. The token survives shell quoting, environment variables, GitHub Actions secret rendering, and URL embedding without escaping. - Maximum total length: 512 bytes. This leaves headroom for the 0.4.0 operator-identity extension (Ed25519 pubkey + signature add ~96 bytes) and future fields. Implementations MUST reject tokens longer than 512 bytes before any parsing.
- Minimum total length:
len("sealed_env_x_xxxx_") + len(shortest payload)≈ 30 bytes. A token shorter than this is structurally invalid.
The checksum is typo detection, NOT a security control. Its only job
is to fail fast and loud when an operator pastes sealed_env_t_… with
one character corrupted by a shell, a chat client, or a copy-paste error.
The cryptographic gate that prevents forgery is the Tier B sig field
(§12.5) plus exp and optional nonce — not the checksum.
checksum_bytes = HMAC-SHA256(
key = "sealed-env:token-checksum:v1",
msg = payload_text ; see below
)[0:2] ; first 2 bytes
checksum = lowercase_hex(checksum_bytes) ; 4 ASCII chars
Where payload_text is the base64url-encoded payload STRING — the
exact characters that appear in the token between the last _ and the
end of the string. Implementations MUST NOT compute the checksum over
the raw CBOR bytes; the contract is over the wire-form payload string so
that a checksum mismatch correlates 1:1 with what the operator visually
sees.
The HMAC key "sealed-env:token-checksum:v1" is a domain-separation
literal. It is public. Its purpose is to prevent collisions with
HMACs used elsewhere in the protocol (e.g., the Tier B sig); it is not
a secret.
If checksum mismatches, the implementation MUST reject the token
before any base64url decoding or CBOR parsing. The error class is
TOKEN_INVALID with cause "checksum-mismatch". No timing-attack
discipline is required — this is a typo check.
payload_text = base64url-no-padding( CBOR( mode_specific_map ) )
CBOR encoding MUST follow RFC 8949 §4.2.1 "Core Deterministic Encoding Requirements":
- Integers use shortest-form representation (no leading zero bytes).
- Byte strings use major type 2 (no indefinite-length).
- Text strings use major type 3 (no indefinite-length).
- Maps use major type 5 with definite length (no indefinite-length).
- Map keys are sorted by bytewise lexicographic order of their canonical CBOR encoding.
- No tag wrappers unless explicitly required (none are in this version).
The deterministic ordering rule is the load-bearing one. Two implementations encoding the same map MUST produce byte-identical CBOR.
A CBOR text-string of length n < 24 is encoded as 0x60+n followed by
the UTF-8 bytes. Therefore, when all keys are short text strings:
- Shorter keys sort first. A 2-char key precedes a 3-char key
because
0x62 < 0x63. - Within the same length, sort lexicographically on the UTF-8 bytes.
Examples:
"m" (0x61 0x6d) < "s" (0x61 0x73) < "t" (0x61 0x74)
"ek" (length 2)
< "exp", "sig" (length 3, "exp" < "sig" because 'e' < 's')
< "nonce" (length 5)
< "vault_id" (length 8)
For text strings of length ≥ 24, the prefix becomes 0x78 <len>; the
"shorter first" rule still holds. This format version uses no keys with
length ≥ 24.
All keys are text strings. All cryptographic material is encoded as byte strings (major type 2), never hex or base64.
{
"m": <bstr 32> ; master_key
}
Canonical key order: m.
{
"m": <bstr 32>, ; master_key
"s": <bstr 32> ; signing_key
}
Canonical key order: m, s (both length 1; m < s).
{
"m": <bstr 32>, ; master_key
"s": <bstr 32>, ; signing_key
"t": <bstr 20> ; totp_secret (RFC 6238)
}
Canonical key order: m, s, t.
A wire-form re-wrap of the legacy JWS Compact unseal token described in
§9. Its purpose is migration, not new behavior. The CBOR map carries
the same payload fields plus the detached signature as a separate
byte-string field. Reading and writing this mode is a non-breaking
translation: a u-mode token can be losslessly serialized back to the
JWS Compact form for legacy verifiers, and vice versa. Stacks that
already accept the §9 JWS form gain support for the unified token
envelope without changing their unseal-token verification logic — they
unwrap the CBOR, reconstruct the equivalent JWS Compact form, and
delegate to existing code. New deployments SHOULD prefer mode d
(§11.6, §12.5) instead, which carries strict TTL + sig + nonce
binding from the outset.
{
"iss": <tstr>, ; "sealed-env-cli"
"iat": <uint>, ; unix seconds
"exp": <uint>, ; unix seconds
"epoch": <tstr>, ; standard-base64 of 32-byte enterprise_epoch
; (same encoding as the JWS payload field, NOT base64url)
"deploy_id": <tstr | null>,
"ops_id": <tstr>, ; UUID v4
"sig": <bstr 32> ; HMAC-SHA256(derived_key, base64url(header)+"."+base64url(payload))
; recomputed when re-wrapping
}
Canonical key order per §11.5. Lengths are 3 (iat, exp, iss,
sig), 5 (epoch), 6 (ops_id), 9 (deploy_id). Within length 3:
e (0x65) < i (0x69) < s (0x73), and iat < iss lexicographically.
Final order: exp, iat, iss, sig, epoch, ops_id, deploy_id.
Implementations MUST NOT use the JWT alg=none shortcut. The sig field
is mandatory and verified per §9.
See §12.5 for the full deploy-mode contract. The CBOR map is:
{
"ek": <bstr 32>, ; ephemeral derived key (= derived_key, see §12.5)
"exp": <uint>, ; unix seconds — token expiry
"sig": <bstr 32>, ; HMAC-SHA256 over payload-without-sig (see §12.5)
"nonce": <bstr 16>, ; replay protection (always present)
"vault_id": <bstr 32> ; SHA-256("sealed-env:vault-id:v1" || salt), see §12.4
}
Canonical key order: ek, exp, sig, nonce, vault_id.
Derivation: ek is length 2, all others ≥ 3, so ek is first. Within
length-3 keys exp < sig (e 0x65 < s 0x73). Then nonce (5) and
vault_id (8). Final order matches.
A conformant reader MUST execute the steps in this exact order. Each step has a single, named reject point. The reject point determines the error class surfaced to the caller.
INPUT: token (UTF-8 bytes)
1. len(token) ≤ 512 ............................. else TOKEN_INVALID(too-long)
2. token starts with "sealed_env_" .............. else TOKEN_INVALID(bad-prefix)
3. every byte ∈ [a-zA-Z0-9_-] ................... else TOKEN_INVALID(bad-charset)
4. split on "_" → ["sealed","env",mode,cksum,payload_text]
............................................. else TOKEN_INVALID(bad-shape)
5. mode ∈ {"b","t","e","u","d"} ................. else TOKEN_INVALID(bad-mode)
6. len(cksum)==4 ∧ cksum matches §11.4 .......... else TOKEN_INVALID(checksum-mismatch)
7. cbor_bytes = base64url-decode(payload_text) .. else TOKEN_INVALID(bad-base64)
8. map = cbor-decode(cbor_bytes), deterministic . else TOKEN_INVALID(bad-cbor)
9. validate map against mode schema (§11.6) ..... else TOKEN_INVALID(bad-payload)
10. mode == "d" → §12.7 verify procedure
mode == "u" → §9 verify procedure
mode ∈ {b,t,e} → use keys to decrypt .env.sealed per §7
The single user-facing error message remains the §7 rule:
"sealed-env: file is corrupted, tampered, or wrong key". The
fine-grained cause codes above are for telemetry and operator
debugging only — they MUST NOT be exposed in untrusted contexts.
Step 4 splits on _ from the left and stops after the first 5 fields:
the payload alphabet is base64url which uses - and never _, so the
_ separator parsing is unambiguous.
Readers MUST silently ignore unknown CBOR map keys in the payload.
A future version may add operator-identity (signer_id, signer_sig)
or workload-identity (oidc_aud) fields; existing readers parse them
into "the rest" and continue with the keys they understand.
What forward-compat does NOT cover:
- A new
<mode>character. New modes require a reader upgrade. Tokens with unknown modes are rejected at step 5 of §11.7. - A breaking change to an existing key's type or semantics. This would ship as a new mode character, not a key reinterpretation.
- The wire prefix
sealed_env_. That is permanent.
Implementations MUST continue to read vaults whose credentials are delivered via the legacy environment variables:
| Legacy env var | Vault mode | Status |
|---|---|---|
SEALED_ENV_KEY |
basic, team, enterprise | DEPRECATED — supported through 0.x |
SEALED_ENV_SIGNING_KEY |
team, enterprise | DEPRECATED — supported through 0.x |
SEALED_ENV_TOTP_SECRET |
enterprise | DEPRECATED — supported through 0.x |
SEALED_ENV_UNSEAL_TOKEN |
enterprise (unseal) | DEPRECATED — supported through 0.x |
Precedence rules:
- If
SEALED_ENV_TOKENis set, parse it per §11.7. Use its keys. Ignore any legacy variables present in the environment. - Else if any legacy variable is set, use the legacy flow. Emit a
one-time stderr warning containing the literal substring
"legacy-credential-format"plus a link to thesealed-env migrate-tokencommand. - Else fail with a clear "no credentials provided" message that
mentions
SEALED_ENV_TOKEN.
The legacy path is scheduled for removal in 1.0.0. The 0.x line honors it. No new features land in the legacy path.
The byte-identical fixtures for §11 live at
test-vectors/v1/credential-modernization-*.json. Each fixture file is
self-describing (see its schema below) and references the SPEC
subsection it exercises. The fixture suite covers every reject point in
§11.7 plus the happy path for every mode.
The full fixture roster is enumerated in §12.10.
A long-lived root credential pasted into a CI secret is a security smell.
If GitHub Actions logs leak, if a developer's laptop is compromised, or
if the CI provider itself is breached, the attacker walks away with the
ability to decrypt every .env.sealed produced from that vault — forever
or until the operator notices and rotates. Long-lived sealed_env_t_*
tokens belong in password managers and KMS, not in CI runners.
Tier B introduces a short-lived deploy token:
| Tier | Token prefix | TTL | Storage | CI? |
|---|---|---|---|---|
| A · Root | sealed_env_{b,t,e}_* |
infinite | password manager / KMS / Studio keychain | only if deploy_mode=static |
| B · Deploy | sealed_env_d_* |
60s – 600s | one-shot env var per deploy | yes — designed for this |
The operator mints a Tier B token from a Tier A unlock on their local
machine (CLI prompts for passphrase + TOTP if enterprise), pastes it into
the CI run's environment as SEALED_ENV_DEPLOY_TOKEN, the deploy runs,
the token expires. A leaked deploy token grants exactly one decrypt
window of N seconds against exactly one vault.
A new file sits next to .env.sealed. It is committable — it
contains no secret material. It records the operator's deploy policy
for this vault.
{
"deploy_mode": "ephemeral",
"deploy_ttl_max_seconds": 60,
"require_totp_on_mint": false,
"allow_long_lived_for_dev": true,
"nonce_state_backend": null
}| Field | Type | Default | Effect |
|---|---|---|---|
deploy_mode |
enum | see §12.3 | static / ephemeral / workload-identity. CLI rejects tokens of insufficient tier. |
deploy_ttl_max_seconds |
int | 60 |
Operator-chosen ceiling on --ttl. Max permitted by the format: 600. CLI MUST refuse --ttl values above this field. |
require_totp_on_mint |
bool | false (true for enterprise vaults) |
Forces 6-digit TOTP entry on every mint-deploy. |
allow_long_lived_for_dev |
bool | true |
If false, the local operator cannot decrypt with a Tier A token directly; they MUST mint a Tier B token even for local development. |
nonce_state_backend |
string | null | null |
URI of a Redis/DynamoDB backend that records seen nonces. When set, the reader enforces single-use across CI jobs. When null, only TTL bounds replay. |
- Unknown top-level fields MUST be silently ignored for forward
compatibility with 0.4.0 (which adds
require_signed_tokens,authorized_signers, and similar identity fields). deploy_ttl_max_secondsoutside[1, 600]is a config error (CONFIG_ERROR, cause"ttl-out-of-range").deploy_modenot in{"static", "ephemeral", "workload-identity"}is a config error (CONFIG_ERROR, cause"bad-deploy-mode").- For an enterprise vault,
deploy_mode == "static"is a config error (cause"enterprise-requires-ephemeral"). See §12.3. - Missing fields take the defaults in the table above.
| Vault mode | Default deploy_mode |
Rationale |
|---|---|---|
basic |
static |
Hobby / solo dev. Preserve original simplicity. Operator may opt up to ephemeral. |
team |
static |
Operator may opt up to ephemeral. |
enterprise |
ephemeral MANDATORY |
The TOTP requirement already implies human-in-the-loop per deploy; long-lived tokens annul that threat model. A .sealed-env.config.json declaring deploy_mode: "static" for an enterprise vault MUST be rejected as invalid. |
The CLI's init command MUST write a .sealed-env.config.json with the
appropriate defaults at vault creation time.
Every Tier B token names the vault it was minted against. The reader verifies the name before doing any cryptography. The vault name is deterministic from the file's public salt.
vault_id = SHA-256( "sealed-env:vault-id:v1" || salt )
"sealed-env:vault-id:v1"is a 23-byte ASCII literal (no NUL terminator). It provides domain separation so thatvault_idcannot be confused with any other SHA-256 output in the protocol.saltis the raw 16-byte salt from the.env.sealedheader (the decoded value of theSALT=line), not the base64 form.- Output is the full 32-byte SHA-256 digest.
vault_id is a scoping identifier, not a security boundary. It is:
- Publicly derivable. The salt it commits to is already visible
in cleartext in the
.env.sealedfile header. Anyone with the.env.sealedcan compute thevault_idin one SHA-256. - Designed to be visible in Tier B tokens. A deploy token names the vault it targets; that name is on the wire by design.
- Not relied on for integrity or authenticity. The cryptographic
gate that prevents forgery is the
sigfield (§12.5) combined withexp(§12.7 step 3) and the optional nonce check (§12.8). Removingvault_idfrom a forged token would not make it accepted; rewritingvault_idto point at a different vault would not bypasssigeither, becausesigis computed over the bytes that includevault_id.
Implementations MUST NOT treat vault_id as authenticating anything on
its own. Its only job is to fail fast and loud when an operator pastes
a deploy token meant for vault A into a deploy of vault B.
Recall the wire-form schema (§11.6, mode d):
{
"ek": <bstr 32>,
"exp": <uint>,
"sig": <bstr 32>,
"nonce": <bstr 16>,
"vault_id": <bstr 32>
}
| Field | Purpose |
|---|---|
ek |
The 32-byte AES-256-GCM key the verifier uses to decrypt the body of .env.sealed. See "Ephemeral key interpretation" below. |
exp |
Unsigned unix seconds. The instant past which the reader MUST reject the token. |
sig |
HMAC-SHA256 binding ek, exp, nonce, vault_id to the master key + salt. Forgery gate. |
nonce |
Per-mint random 16 bytes. Always present on the wire. Used by §12.8 if a state backend is configured; advisory otherwise. |
vault_id |
Per §12.4. Scoping identifier. |
ek = derived_key
Where derived_key is the same 32-byte output produced by §6 step 3 of
this SPEC (the KDF applied to the master key with the file's salt and
KDF params). The Tier B token wraps the derived key behind a TTL and a
signature. This is the simplest viable design and was chosen over an
HKDF-with-fresh-nonce variant for three reasons:
- No extra wire field. No
derivation_nonceto carry. - No extra computation. Verifier already computes
derived_keyfor normal decrypts. - The TTL is the freshness gate. A leaked Tier B token grants
decrypts only until
exp. Afterexp, the token is dead even thoughderived_keyis unchanged. To rotate the underlying key, the operator runssealed-env rotate, which generates a fresh salt and therefore a freshderived_key.
Compromise scope: a captured Tier B token = N seconds of decrypt
capability against one vault. Rotating the vault (new salt) invalidates
every outstanding Tier B token for that vault — they reference the old
vault_id.
The sig field binds the other four fields to the master key.
hmac_key = HKDF-SHA256(
IKM = master_key,
salt = salt,
info = "sealed-env:deploy-sig:v1",
length = 32
)
payload_without_sig = CBOR-encode-deterministic({
"ek": <ek>,
"exp": <exp>,
"nonce": <nonce>,
"vault_id": <vault_id>
}) ; key order per §11.5: ek, exp, nonce, vault_id
sig = HMAC-SHA256(hmac_key, payload_without_sig)
The wire-form payload contains all five keys (including sig) sorted
per §11.5: ek, exp, sig, nonce, vault_id. The verifier reconstructs
payload_without_sig by re-encoding the map with the sig key removed
(four keys, sorted as ek, exp, nonce, vault_id) and re-running the
HMAC. The recomputation MUST be byte-identical to the minter's encoding;
this is why deterministic CBOR per §11.5 is mandatory and not advisory.
The HKDF info string "sealed-env:deploy-sig:v1" is a public
domain-separation literal. It ensures the sig HMAC key cannot collide
with any other HMAC key derived from the same master+salt elsewhere in
the protocol.
sealed-env mint-deploy --vault <path> --ttl <duration> performs:
INPUT: vault path, requested ttl, operator credentials (Tier A unlock)
1. Read .env.sealed → extract salt, kdf_params.
2. Read .sealed-env.config.json (sibling). Validate per §12.2.
3. requested_ttl ≤ config.deploy_ttl_max_seconds .... else CONFIG_ERROR(ttl-cap)
4. If config.require_totp_on_mint, prompt for 6-digit code; verify per §9.
5. derived_key = KDF(master_key, salt, kdf_params) ; §6 step 3
6. nonce = randomBytes(16)
7. now = current unix seconds
8. exp = now + requested_ttl
9. vault_id = SHA-256("sealed-env:vault-id:v1" || salt) ; §12.4
10. ek = derived_key ; §12.5
11. payload_without_sig = cbor({ek, exp, nonce, vault_id})
12. hmac_key = HKDF(master_key, salt, "sealed-env:deploy-sig:v1", 32)
13. sig = HMAC-SHA256(hmac_key, payload_without_sig)
14. payload = cbor({ek, exp, sig, nonce, vault_id})
15. token = "sealed_env_d_" || hex(HMAC(...)[0:2]) || "_" || base64url(payload) ; §11.4
16. Emit token on stdout. Do NOT log to any file.
The mint procedure MUST run entirely in process memory. No intermediate
artifact (especially not derived_key or ek) is written to disk.
sealed-env decrypt with SEALED_ENV_DEPLOY_TOKEN set performs:
INPUT: vault path, deploy token, optional config
1. Parse token per §11.7 steps 1-9 ............. errors as defined there.
2. mode == "d" ................................. else TOKEN_INVALID(wrong-mode)
3. exp > now ................................... else TOKEN_EXPIRED
4. Read .env.sealed → salt, kdf_params, ciphertext.
5. local_vault_id = SHA-256("sealed-env:vault-id:v1" || salt)
6. constant_time_compare(token.vault_id, local_vault_id)
............................................. else TOKEN_INVALID(vault-mismatch)
7. If config.nonce_state_backend is set:
If backend.has_seen(token.nonce) ........... → TOKEN_INVALID(replay)
backend.record(token.nonce, ttl=exp-now)
(If the backend itself errors, fail closed: TOKEN_INVALID, cause
"replay-cache-unavailable". This mirrors §SEC-006 fail-closed
semantics for the unseal replay cache.)
8. hmac_key = HKDF(master_key, salt, "sealed-env:deploy-sig:v1", 32)
payload_without_sig = re-encode the CBOR map without "sig" (§11.5)
expected_sig = HMAC-SHA256(hmac_key, payload_without_sig)
constant_time_compare(token.sig, expected_sig)
............................................. else TOKEN_INVALID(sig)
9. enc_key = HKDF(token.ek, salt, "sealed-env:v1:enc", 32) ; §6 step 4
10. plaintext = AES-256-GCM-decrypt(enc_key, nonce, ciphertext, aad)
............................................. else per §7 step 8
The signature check at step 8 MUST use a constant-time comparison
(e.g. Node crypto.timingSafeEqual, Java MessageDigest.isEqual, Rust
subtle::ConstantTimeEq). The vault_id check at step 6 SHOULD also
use a constant-time comparison; it is not strictly load-bearing for
authenticity (see §12.4), but constant-time keeps the implementation
uniformly disciplined.
Note that step 8 requires the verifier to hold the master key, not
just ek. This is intentional: a Tier B verifier needs the master key
in process memory to authenticate the deploy token, then uses ek only
to derive the AES key. The master key arrives by the same path it does
today (SEALED_ENV_KEY or, post-§11, the m field of a Tier A
sealed_env_t_* companion token). When the Tier A side is itself
unavailable (workload-identity scenario), the master key arrives via the
minter service per §12.9.
The reader at this point may notice an apparent tension with the
ergonomic story: if the Tier B verifier still needs the master key in
process memory to check sig, how is the deploy token meaningfully
"smaller blast radius" than a long-lived Tier A token in CI?
This is a deliberate scoping decision for 0.3.0, recorded here so a
future maintainer or contributor does not file it as a gap. The honest
answer is a two-step migration:
0.3.0brings stricter defaults and smaller blast radius for operator-driven deploys. Enterprise vaults forcedeploy_mode: ephemeral; basic/team can opt in. The deploy token's TTL (default60s, max600s) bounds the window during which a captured token grants decrypts, even if the CI runner that holds the master is compromised concurrently. That window collapses from "hours or days until the operator notices and rotates" (today's status quo, whereSEALED_ENV_KEYis a long-lived CI secret) to "seconds, then the token is dead by wall-clock." The cryptographic improvement is real even with the master still resident in the verifier process.1.1(planned, sketched in §12.9) brings identity-aware deploys. A minter service holds the master key in cloud KMS / HSM, validates the calling identity via OIDC (GitHub Actions, OIDC-aware CI), and mints Tier B tokens scoped to the validated identity + a short TTL. In this mode, the CI runner never holds the master — only ever a fresh Tier B. The current SPEC reservesdeploy_mode: "workload-identity"as a sentinel that readers MUST reject withCONFIG_ERROR("workload-identity-not-implemented")until the minter contract is specified.
The composition matters. 0.3.0's strict defaults narrow the attack
window from days to seconds for the common operator-driven deploy. 1.1's
minter then removes the master from the CI runner entirely for teams that
operate at a scale where that step pays for itself. Shipping the former
before the latter is a cost-balanced sequencing call, not an
under-specification: the minter service requires identity infrastructure
that is out of scope for the core SPEC and belongs in a separate
release.
If you read the COORDINATION proposal that preceded this section and
expected 0.3.0 to deliver "Tier A NUNCA en CI", note that the SPEC
intentionally tightens the wording: Tier A still arrives in the CI
process for the sig check, but it arrives alongside a per-deploy
Tier B with a 60-second TTL. The CI runner's compromise window is the
Tier B TTL, not the lifetime of the Tier A token. Calling that
"smaller blast radius" is accurate; calling it "no Tier A in CI" would
not be, and we choose to not lie in the SPEC.
nonce is always on the wire. Whether replay is enforced depends
on the vault's .sealed-env.config.json:
nonce_state_backend |
Effect |
|---|---|
null (default) |
Replay bounded only by TTL. Re-using a token within [now, exp) succeeds. Suitable for short TTLs (≤ 60s) where the replay window is operationally negligible. |
<redis-uri> or <dynamodb-uri> |
The reader records every consumed nonce in the backend keyed by (vault_id, nonce) with TTL = exp - now. A second presentation of the same nonce is rejected at step 7 of §12.7. |
The state backend MUST support atomic check-and-set semantics. A non-atomic check followed by a separate write is vulnerable to a TOCTOU race in concurrent deploys.
This is opt-in for two reasons:
- Operational cost. Many deployments do not have a shared Redis/DynamoDB available, especially solo-dev setups.
- Fail-closed footprint. When the backend is configured but
unavailable, decrypt MUST fail (cause
"replay-cache-unavailable"). That tradeoff is correct for high-stakes deploys and excessive for hobby projects.
The reader MUST emit a one-time stderr warning containing the literal
substring "deploy-replay-disabled" when nonce_state_backend is
null AND deploy_mode == "ephemeral", so operators of
ephemeral-but-stateless deploys learn that they are relying on TTL
alone.
Status: non-normative for 0.3.0. Sketch only. A separate PR will spec the minter service in detail.
For CI pipelines that want neither long-lived secrets nor manual operator-in-the-loop minting, the workload-identity mode looks like:
GitHub Actions OIDC token
│
▼
Minter service (lambda / cloud function)
│ validates OIDC claims (repo, ref, env)
│ holds Tier A credentials in cloud KMS
│ mints sealed_env_d_* with short TTL
▼
CI run uses token, decrypts, deploys
Properties:
- The minter service is not shipped with
sealed-envcore. It is stack-agnostic and lives as a reference implementation + recipes. - The CI pipeline never holds Tier A material; it only ever holds a fresh Tier B token bounded by TTL.
- The minter validates OIDC claims (e.g. only the
mainbranch oforg/repocan mint a production deploy token).
This section will become normative in a future SPEC revision. For
0.3.0, deploy_mode: "workload-identity" is a reserved value: readers
that encounter it MUST refuse with CONFIG_ERROR, cause
"workload-identity-not-implemented", until the minter contract is
specified.
The byte-identical fixtures for §11 and §12 live at
test-vectors/v1/credential-modernization-*.json. Each fixture has the
schema documented in §11.10.1. The roster:
| Filename | expected.result |
Exercises |
|---|---|---|
credential-modernization-basic-valid.json |
decrypt_ok |
§11.6 mode b, §11.7 happy path |
credential-modernization-team-valid.json |
decrypt_ok |
§11.6 mode t, §11.7 happy path |
credential-modernization-enterprise-valid.json |
decrypt_ok |
§11.6 mode e, Tier A unlock |
credential-modernization-unseal-valid.json |
decrypt_ok |
§11.6 mode u, legacy-compat wrap |
credential-modernization-tier-b-deploy-valid.json |
decrypt_ok |
§12.5–§12.7 happy path |
credential-modernization-wrong-checksum.json |
reject_pre_decrypt |
§11.4 typo detection |
credential-modernization-tampered-payload.json |
reject_sig_fail |
§12.5 / §12.7 step 8 |
credential-modernization-wrong-vault-id.json |
reject_vault_mismatch |
§12.4 / §12.7 step 6 |
credential-modernization-expired.json |
reject_expired |
§12.7 step 3 |
credential-modernization-replay-after-nonce-seen.json |
reject_replay |
§12.7 step 7 / §12.8 |
credential-modernization-future-fields-ignored.json |
parse_ok_ignore_unknown |
§11.8 forward-compat |
credential-modernization-legacy-key-still-works.json |
decrypt_ok_with_warning |
§11.9 backward-compat |
All fixtures use FIXED key material so reruns are byte-stable:
| Material | Value |
|---|---|
master_key_hex |
aa × 32 (32 bytes of 0xaa) |
signing_key_hex |
bb × 32 |
totp_secret_hex |
cc × 20 |
salt_hex |
00 × 16 (matches existing enterprise-token-malformed-epoch.json) |
Tier B exp (valid) |
4102444800 (2100-01-01T00:00:00Z) |
Tier B exp (expired) |
1000000000 (2001-09-09) |
Tier B nonce |
11 × 16 |
| Tier B / u-mode kdf | scrypt |
| Tier B / u-mode kdf_params | { N: 131072, r: 8, p: 1 } (matches 0.1.1 default, SEC-002 OWASP 2024 floor) |
| u-mode iat | 1767225600 (2026-01-01T00:00:00Z) |
| u-mode exp | 4102444800 (2100-01-01T00:00:00Z, so the vector remains testable indefinitely) |
| u-mode ops_id | fixture-u-mode-ops-id-v1 |
The generator script node/scripts/gen-credential-modernization-vectors.mjs
produces all 12 fixtures deterministically. It contains its own minimal
CBOR encoder (no npm dependency) covering the major types used here and
asserts byte-equality across a double-encode pass before writing. The
generator also invokes crypto.scryptSync with the pinned params above
to derive Tier B's ek field — runtime stacks deriving ek from the
same master_key + salt + kdf_params will produce byte-identical bytes.
A reference set of test vectors lives in /test-vectors/v1/ (separate
directory in the repo). Each vector contains:
input.env— plaintextmaster.key— fixed test keyoutput.env.sealed— expected file (deterministic with mocked salt+nonce)decrypt-result.txt— expected plaintext after roundtrip
Both Node and Java implementations MUST pass all vectors before release.
This is SEALED-ENV-V1. Future versions:
V2will be incompatible. Readers seeingV2magic but only supportingV1MUST refuse with"sealed-env: file format too new, upgrade your library".V1writers MUST NOT emit fields not specified here, even if known.
- Streaming encryption (entire file is encrypted/decrypted in one pass)
- Multi-recipient encryption (use age or PGP for that use case)
- Asymmetric encryption (we use symmetric throughout)
- Hardware-backed keys (planned for v2)
- Quantum-resistant primitives (planned for v3 when standards stabilize)
- RFC 5116 — AEAD interface
- RFC 5869 — HKDF
- RFC 6238 — TOTP
- Argon2 RFC 9106 — password hashing
- NIST SP 800-38D — GCM mode
{ "name": "<filename without .json>", "purpose": "<one-line description>", "spec_section": "§11.x or §12.x", "vault": { "salt_hex": "<32 hex>", "master_key_hex": "<64 hex>", "signing_key_hex": "<64 hex, omit for basic>", "totp_secret_hex": "<40 hex, omit for non-enterprise>", "serialized": "<full .env.sealed contents or reference>" }, "token": "<sealed_env_*_*_* | null>", "config_file_contents": "<.sealed-env.config.json contents | null>", "expected": { "result": "decrypt_ok | reject_pre_decrypt | reject_sig_fail | reject_vault_mismatch | reject_expired | reject_replay | parse_ok_ignore_unknown | decrypt_ok_with_warning", "plaintext_hex": "<hex if decrypt_ok>", "error_class": "<TOKEN_INVALID | TOKEN_EXPIRED | CONFIG_ERROR | ...>", "error_cause": "<specific cause from §11.7>", "warning_substring": "<for legacy-still-works>" } }