Skip to content

Commit b375bb1

Browse files
committed
fix(security)!: do not embed TOTP secret in unseal token
CRITICAL security fix in enterprise mode. Previous alpha versions (0.1.0-alpha.{1,2,3}) embedded the operator's literal TOTP secret in the JWS payload of every minted unseal token. JWS payload is base64 JSON, NOT encrypted — anyone observing a token (CI logs, container env dumps, kubectl describe pod, stack traces in error reporters) could extract the secret and, with the master key, mint unseal tokens for FUTURE deploys indefinitely. The fix: Token payload now carries a salt-bound HMAC derivative: enterprise_epoch = HMAC-SHA256(totpSecret, salt || "epoch-v1") File commits to: epoch_commit = HMAC-SHA256(derivedKey, enterprise_epoch || "epoch-commit-v1") Verification: expected = HMAC-SHA256(derivedKey, payload.epoch || "epoch-commit-v1") assert expected == file.EPOCH-COMMIT The TOTP secret never leaves the operator's machine. A leaked token reveals only the salt-bound derivative, which is useless for minting tokens against re-sealed files (different salt → different epoch). BREAKING: - Wire format field renamed: TOTP-VERIFIER → EPOCH-COMMIT - Token payload field renamed: totp_secret → epoch - buildUnsealToken signature now requires the file's salt - Files sealed by 0.1.0-alpha.{1,2,3} are NOT readable by 0.1.0-alpha.4 Migration: re-init keys (TOTP rotation is mandatory) and re-seal all files. See CHANGELOG entry for the full migration playbook. Regression tests verify: - Serialized files do not contain TOTP-VERIFIER field - Minted tokens do not contain the literal secret in any encoding (hex, base64) or under the field name totp_secret Both Node and Java sides updated in lockstep. Cross-stack vector regenerated with the new format. Reported by an external reviewer who decoded the JWS payload of a minted token and matched the embedded value bit-for-bit against the operator's .env.local TOTP secret. Thank you.
1 parent 7db3146 commit b375bb1

30 files changed

Lines changed: 437 additions & 104 deletions

File tree

CHANGELOG.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,88 @@ files written today will remain readable forever. See [SPEC.md](./SPEC.md).
1414

1515
---
1616

17+
## [0.1.0-alpha.4] — 2026-05-07
18+
19+
> **🚨 SECURITY: this release fixes a critical issue in `enterprise` mode.
20+
> Prior versions (alpha.1, alpha.2, alpha.3) embedded the operator's TOTP
21+
> secret in the JWS payload of every unseal token. JWS payload is base64-
22+
> encoded JSON, NOT encrypted — anyone observing a token (CI logs, container
23+
> env dumps, stack traces) could extract the secret and use it (with the
24+
> master key) to mint unseal tokens for FUTURE deploys indefinitely.**
25+
>
26+
> **All `0.1.0-alpha.{1,2,3}` releases are deprecated on npm and Maven
27+
> Central. If you adopted enterprise mode in any of those versions:**
28+
>
29+
> 1. **Rotate your TOTP secret.** Re-run `sealed-env init --mode enterprise`.
30+
> 2. **Re-seal all `.env.sealed` files.** Old files use the deprecated wire
31+
> field (`TOTP-VERIFIER`) and won't decrypt with `0.1.0-alpha.4`.
32+
> 3. **Update CI / production env to use the new package version.**
33+
>
34+
> The wire format intentionally breaks compatibility — files sealed before
35+
> alpha.4 are NOT readable by alpha.4. Since the package was not yet adopted
36+
> in the wild (only the author's own dogfooding), this seemed safer than a
37+
> backward-compatible code path that would silently keep reading the
38+
> insecure field on old files.
39+
40+
### Security
41+
42+
- **CRITICAL: TOTP secret no longer appears in unseal tokens.** The token
43+
payload now carries an `enterprise_epoch`:
44+
```
45+
enterprise_epoch = HMAC-SHA256(totp_secret, salt || "epoch-v1")
46+
```
47+
This is a salt-bound HMAC derivative — knowing it does NOT let an
48+
attacker recompute it for files with a different salt. The blast radius
49+
of a leaked token is reduced from "permanent compromise of all current
50+
and future enterprise files" to "compromise of one specific file
51+
generation, until re-seal".
52+
53+
- **Wire format field renamed:** `TOTP-VERIFIER``EPOCH-COMMIT`. The new
54+
field commits to the salt-bound epoch instead of the raw TOTP secret:
55+
```
56+
epoch_commit = HMAC-SHA256(derived_key, enterprise_epoch || "epoch-commit-v1")
57+
```
58+
59+
- **Token payload field renamed:** `totp_secret``epoch`. Old field is
60+
rejected. Any code or test that referenced the old field will fail at
61+
parse time, surfacing the upgrade as a hard error rather than silent
62+
insecurity.
63+
64+
- **Regression tests added** that fail if either:
65+
- The serialized file contains `TOTP-VERIFIER`, or
66+
- A minted token contains the literal TOTP secret in any common encoding
67+
(hex or base64), or the field name `totp_secret`.
68+
69+
### Migration
70+
71+
This release is **incompatible with files sealed by `0.1.0-alpha.{1,2,3}`**.
72+
To migrate:
73+
74+
```sh
75+
# 1. Decrypt with the old version
76+
npx sealed-env@0.1.0-alpha.3 decrypt .env.sealed > /tmp/.env.plaintext
77+
78+
# 2. Upgrade
79+
npm i -D sealed-env@0.1.0-alpha.4
80+
81+
# 3. Re-init keys (TOTP secret rotation is mandatory)
82+
sealed-env init --mode enterprise
83+
84+
# 4. Re-seal with the new keys
85+
sealed-env encrypt /tmp/.env.plaintext --mode enterprise
86+
87+
# 5. Securely wipe the plaintext
88+
shred -u /tmp/.env.plaintext # or: rm -P on macOS
89+
```
90+
91+
### Credit
92+
93+
This issue was identified by an external reviewer comparing the actual JWS
94+
payload of a minted token against the operator's `.env.local` TOTP secret
95+
and confirming bit-for-bit equality. Thank you for the careful eyes.
96+
97+
---
98+
1799
## [0.1.0-alpha.3] — 2026-05-06
18100

19101
Operational ergonomics release — adds the day-to-day commands that

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ private String stripeKey;
155155
plaintext
156156

157157
158-
│ + totp_secret
158+
│ + unseal token (carries
159+
│ salt-bound TOTP epoch)
159160
│ + deploy_id
160161
```
161162

SPEC.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ are simply skipped (the order remains stable).
6868
| 2 | `KDF-PARAMS` | all | For argon2id: `t=<int>,m=<int>,p=<int>`. For scrypt: `N=<int>,r=<int>,p=<int>` |
6969
| 3 | `SALT` | all | base64, 16 bytes raw |
7070
| 4 | `NONCE` | all | base64, 12 bytes raw |
71-
| 5 | `TOTP-VERIFIER` | enterprise only | base64, 32 bytes (HMAC-SHA256 commitment) |
71+
| 5 | `EPOCH-COMMIT` | enterprise only | base64, 32 bytes (HMAC-SHA256 commitment to the salt-bound enterprise epoch — see §6) |
7272
| 6 | `CHALLENGE-BIND` | enterprise only | `enabled` or `disabled` |
7373
| 7 | `AAD-DIGEST` | all | base64, 32 bytes (SHA-256 of associated data) |
7474
| 8 | `HMAC` | team and enterprise | base64, 32 bytes |
@@ -111,7 +111,11 @@ PKCS#1 v1.5 padding, custom RNG.
111111
`aad_digest = SHA-256(aad)`.
112112
6. `ciphertext_with_tag = AES-256-GCM-encrypt(enc_key, nonce, plaintext, aad)`.
113113
7. If `mode == enterprise`:
114-
- `totp_verifier = HMAC-SHA256(derived_key, totp_secret || "verify-v1")`.
114+
- `enterprise_epoch = HMAC-SHA256(totp_secret, salt || "epoch-v1")`.
115+
- `epoch_commit = HMAC-SHA256(derived_key, enterprise_epoch || "epoch-commit-v1")`.
116+
- The file commits to `epoch_commit`. The TOTP secret itself never appears
117+
in any persisted artifact (file or token). The salt binding ensures a
118+
leaked epoch is only useful against this specific file generation.
115119
8. If `mode in (team, enterprise)`:
116120
- `mac_key = HKDF(signing_key, salt=salt, info="sealed-env:v1:mac", L=32)`.
117121
- `hmac = HMAC-SHA256(mac_key, magic_line || metadata_without_HMAC || ciphertext_with_tag)`.
@@ -143,8 +147,10 @@ Always exclude the `HMAC` line itself when computing the HMAC over metadata.
143147
- Parse the unseal token (JWT-like, see §9).
144148
- Verify token signature with `derived_key`.
145149
- Verify `token.exp > now`.
146-
- Verify `HMAC-SHA256(derived_key, token.totp_secret || "verify-v1") ==
147-
stored TOTP-VERIFIER`. Constant-time compare.
150+
- Verify `HMAC-SHA256(derived_key, token.epoch || "epoch-commit-v1") ==
151+
stored EPOCH-COMMIT`. Constant-time compare. The token carries
152+
`enterprise_epoch` (a salt-bound HMAC derivative of the operator's
153+
TOTP secret), NOT the TOTP secret itself.
148154
- If `CHALLENGE-BIND=enabled`: verify `token.deploy_id` matches the
149155
current deploy challenge (provided by CI as `SEALED_ENV_DEPLOY_ID`).
150156
6. **AAD reconstruction:** rebuild `aad` from magic + metadata (excluding HMAC),
@@ -202,12 +208,26 @@ usl_<base64url(header)>.<base64url(payload)>.<base64url(signature)>
202208
"iss": "sealed-env-cli",
203209
"iat": 1717024500,
204210
"exp": 1717024560,
205-
"totp_secret": "<base64-of-totp-seed>",
211+
"epoch": "<base64-of-32-byte-enterprise-epoch>",
206212
"deploy_id": "<sha-256-of-commit-or-null>",
207213
"ops_id": "<random-uuid-v4>"
208214
}
209215
```
210216

217+
Where:
218+
219+
```
220+
enterprise_epoch = HMAC-SHA256(totp_secret, salt || "epoch-v1")
221+
```
222+
223+
The `enterprise_epoch` is a **salt-bound HMAC derivative** of the operator's
224+
TOTP secret — never the secret itself. This is the central security property
225+
of the unseal protocol: a leaked token (e.g., from CI logs, container env
226+
dumps, or stack traces) does NOT give the attacker the TOTP secret. Without
227+
the secret, the attacker cannot recompute `enterprise_epoch` for files with
228+
a different salt (i.e., future re-sealings), so the blast radius of a
229+
leaked token is bounded to the specific file generation that produced it.
230+
211231
**Signature:**
212232
`HMAC-SHA256(derived_key, base64url(header) + "." + base64url(payload))`
213233

docs/05-enterprise-mode.md

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Enterprise mode adds two layers on top of `team`:
4848
│ │ 2. verify HMAC ✓
4949
│ │ 3. verify token sig ✓
5050
│ │ 4. verify deploy_id ✓
51-
│ │ 5. verify TOTP-VERIFIER
51+
│ │ 5. verify EPOCH-COMMIT
5252
│ │ commitment ✓
5353
│ │ 6. AES-GCM decrypt ✓
5454
│ │ │
@@ -72,27 +72,42 @@ Enterprise mode adds two layers on top of `team`:
7272
│ │ header.payload)
7373
│ │
7474
│ └──▶ payload (JSON, base64url):
75-
│ iss : sealed-env-cli
76-
│ iat : <unix-seconds>
77-
│ exp : <iat + 60>
78-
totp_secret : <base64>
79-
│ deploy_id : <commit-sha or null>
80-
│ ops_id : <random uuid v4>
75+
│ iss : sealed-env-cli
76+
│ iat : <unix-seconds>
77+
│ exp : <iat + 60>
78+
epoch : <base64-of-32-byte enterprise epoch>
79+
│ deploy_id : <commit-sha or null>
80+
│ ops_id : <random uuid v4>
8181
8282
└──▶ header (JSON, base64url):
8383
alg : HS256
8484
typ : sealed-env-unseal/v1
8585
```
8686

87+
Where the **enterprise epoch** is a salt-bound HMAC derivative of the TOTP
88+
secret:
89+
90+
```
91+
enterprise_epoch = HMAC-SHA256(totp_secret, salt || "epoch-v1")
92+
```
93+
94+
The TOTP secret itself **never appears in the token**. A captured token
95+
gives the attacker only this salt-bound derivative — useful against the
96+
specific file generation that produced it (until re-sealing rotates the
97+
salt), but useless for minting tokens against future re-sealings.
98+
8799
Key properties:
88100
- **Lifetime ≤ 600 seconds** (default 60). Short enough that a leaked token
89101
is rarely useful.
90102
- **Signed with the file's `derived_key`** (master_key + salt-derived). A
91103
token cannot be forged from the master key alone — you also need the
92104
file's salt, which means you need the file.
93-
- **`totp_secret` is committed at seal time** via the `TOTP-VERIFIER` field
94-
(an HMAC commitment). The token's TOTP secret must match what was sealed
95-
in, so an attacker cannot mint a token from a different TOTP secret.
105+
- **The TOTP secret never leaves the operator's machine.** What goes into
106+
the token is `enterprise_epoch`, a salt-bound HMAC derivative committed
107+
in the file via the `EPOCH-COMMIT` field. A leaked token cannot be used
108+
to mint tokens against re-sealed files (different salt → different
109+
epoch). This is what makes "TOTP" act as a real second factor and not a
110+
one-time secret leak.
96111

97112
## What this protects against
98113

docs/06-format-anatomy.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ verification fails.
4444
| `KDF-PARAMS` | all | Format depends on KDF |
4545
| `SALT` | all | 16 bytes, fed to KDF |
4646
| `NONCE` | all | 12 bytes, fed to AES-GCM |
47-
| `TOTP-VERIFIER` | enterprise | HMAC commitment to the TOTP secret |
47+
| `EPOCH-COMMIT` | enterprise | HMAC commitment to the salt-bound enterprise epoch (does NOT reveal the TOTP secret — see SPEC §6) |
4848
| `CHALLENGE-BIND` | enterprise | `enabled` or `disabled` |
4949
| `AAD-DIGEST` | all | SHA-256 over the bound metadata |
5050
| `HMAC` | team, enterprise | HMAC-SHA256 over magic + metadata + ciphertext |
@@ -71,7 +71,7 @@ newline.
7171
│ join with \n ┌─▶ AES-GCM
7272
SALT, NONCE ─┼──▶ ──────────▶ AAD ────┤ setAAD(...)
7373
│ │
74-
TOTP-VERIFIER, ─┤ ├─▶ SHA-256
74+
EPOCH-COMMIT, ─┤ ├─▶ SHA-256
7575
CHALLENGE-BIND ─┤ │ ─▶ AAD-DIGEST
7676
(enterprise only) │ │ (defense in
7777
│ │ depth)

docs/07-operational-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Add the starter to `pom.xml`:
185185
<dependency>
186186
<groupId>io.github.davidalmeidac</groupId>
187187
<artifactId>sealed-env-spring-boot-starter</artifactId>
188-
<version>0.1.0-alpha.3</version>
188+
<version>0.1.0-alpha.4</version>
189189
</dependency>
190190
```
191191

java/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<groupId>io.github.davidalmeidac</groupId>
99
<artifactId>sealed-env-parent</artifactId>
10-
<version>0.1.0-alpha.3</version>
10+
<version>0.1.0-alpha.4</version>
1111
<packaging>pom</packaging>
1212

1313
<name>sealed-env (parent)</name>

java/sealed-env-core/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<parent>
99
<groupId>io.github.davidalmeidac</groupId>
1010
<artifactId>sealed-env-parent</artifactId>
11-
<version>0.1.0-alpha.3</version>
11+
<version>0.1.0-alpha.4</version>
1212
</parent>
1313

1414
<artifactId>sealed-env-core</artifactId>

java/sealed-env-core/src/main/java/io/github/davidalmeidac/sealedenv/SealedEnv.java

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,12 @@ public static SealResult seal(SealOptions opts) {
8888
try {
8989
encKey = CryptoPrimitives.hkdf(derivedKey, salt, Constants.HKDF_INFO_ENC, Constants.KEY_LEN);
9090

91-
Optional<byte[]> totpVerifier = Optional.empty();
91+
// Enterprise: derive the salt-bound epoch and commit to it.
92+
// The token will carry the epoch (NOT the TOTP secret).
93+
Optional<byte[]> epochCommit = Optional.empty();
9294
Optional<ChallengeBind> challengeBind = Optional.empty();
9395
if (opts.mode == Mode.ENTERPRISE) {
94-
totpVerifier = Optional.of(buildVerifier(derivedKey, opts.totpSecret));
96+
epochCommit = Optional.of(buildEpochCommit(derivedKey, opts.totpSecret, salt));
9597
boolean cbEnabled = opts.challengeBind == null || opts.challengeBind;
9698
challengeBind = Optional.of(cbEnabled
9799
? ChallengeBind.ENABLED : ChallengeBind.DISABLED);
@@ -101,7 +103,7 @@ public static SealResult seal(SealOptions opts) {
101103
// canonically build the AAD over metadata.
102104
SealedFile draft = new SealedFile(
103105
1, opts.mode, params.algorithm(), params, salt, nonce,
104-
totpVerifier, challengeBind,
106+
epochCommit, challengeBind,
105107
new byte[32], Optional.empty(),
106108
created, Optional.empty(), new byte[0]);
107109

@@ -123,7 +125,7 @@ public static SealResult seal(SealOptions opts) {
123125

124126
SealedFile file = new SealedFile(
125127
1, opts.mode, params.algorithm(), params, salt, nonce,
126-
totpVerifier, challengeBind, aadDigest, hmac,
128+
epochCommit, challengeBind, aadDigest, hmac,
127129
created, Optional.empty(), ciphertext);
128130
return new SealResult(file, SealedFileSerializer.serialize(file));
129131
} finally {
@@ -168,7 +170,10 @@ public static byte[] unseal(UnsealOptions opts) {
168170
}
169171
}
170172

171-
// Enterprise: verify unseal token + TOTP-VERIFIER commitment
173+
// Enterprise: verify unseal token carries an epoch matching
174+
// the file's EPOCH-COMMIT. The TOTP secret never appears
175+
// in the token — the carried `enterpriseEpoch` is a salt-
176+
// bound HMAC derivative.
172177
if (file.mode() == Mode.ENTERPRISE) {
173178
UnsealToken.VerifyResult result = UnsealToken.verify(
174179
new UnsealToken.VerifyInput(
@@ -177,13 +182,13 @@ public static byte[] unseal(UnsealOptions opts) {
177182
opts.deployId,
178183
file.challengeBind().orElse(ChallengeBind.ENABLED)
179184
== ChallengeBind.ENABLED));
180-
byte[] expectedVerifier = buildVerifier(derivedKey, result.totpSecret());
181-
if (file.totpVerifier().isEmpty()
185+
byte[] expectedCommit = buildEpochCommitFromEpoch(derivedKey, result.enterpriseEpoch());
186+
if (file.epochCommit().isEmpty()
182187
|| !CryptoPrimitives.constantTimeEqual(
183-
expectedVerifier, file.totpVerifier().get())) {
188+
expectedCommit, file.epochCommit().get())) {
184189
throw SealedEnvException.decryptFailed();
185190
}
186-
CryptoPrimitives.wipe(result.totpSecret());
191+
CryptoPrimitives.wipe(result.enterpriseEpoch());
187192
}
188193

189194
// AAD digest defense in depth (GCM tag would also catch this)
@@ -281,9 +286,36 @@ public static void applyToSystemProperties(Map<String, String> env) {
281286

282287
// ── helpers ────────────────────────────────────────────────────────────
283288

284-
private static byte[] buildVerifier(byte[] derivedKey, byte[] totpSecret) {
285-
byte[] tag = Constants.TOTP_VERIFY_TAG.getBytes(StandardCharsets.UTF_8);
286-
return CryptoPrimitives.hmacSha256(derivedKey, concat(totpSecret, tag));
289+
/**
290+
* Compute the salt-bound enterprise epoch (used at seal/mint time):
291+
* {@code enterprise_epoch = HMAC(totpSecret, salt || "epoch-v1")}.
292+
* Caller is responsible for wiping the returned buffer.
293+
*/
294+
static byte[] buildEnterpriseEpoch(byte[] totpSecret, byte[] salt) {
295+
byte[] tag = Constants.EPOCH_DERIVE_TAG.getBytes(StandardCharsets.UTF_8);
296+
return CryptoPrimitives.hmacSha256(totpSecret, concat(salt, tag));
297+
}
298+
299+
/**
300+
* Compute the file-side epoch commitment:
301+
* {@code epoch_commit = HMAC(derivedKey, enterprise_epoch || "epoch-commit-v1")}.
302+
*/
303+
private static byte[] buildEpochCommitFromEpoch(byte[] derivedKey, byte[] enterpriseEpoch) {
304+
byte[] tag = Constants.EPOCH_COMMIT_TAG.getBytes(StandardCharsets.UTF_8);
305+
return CryptoPrimitives.hmacSha256(derivedKey, concat(enterpriseEpoch, tag));
306+
}
307+
308+
/**
309+
* Compose: derive epoch from totpSecret + salt, then commit it under derivedKey.
310+
* Used at seal time. Wipes the intermediate epoch.
311+
*/
312+
private static byte[] buildEpochCommit(byte[] derivedKey, byte[] totpSecret, byte[] salt) {
313+
byte[] epoch = buildEnterpriseEpoch(totpSecret, salt);
314+
try {
315+
return buildEpochCommitFromEpoch(derivedKey, epoch);
316+
} finally {
317+
CryptoPrimitives.wipe(epoch);
318+
}
287319
}
288320

289321
private static byte[] concat(byte[] a, byte[] b) {

java/sealed-env-core/src/main/java/io/github/davidalmeidac/sealedenv/core/Constants.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ private Constants() {
4242
public static final String HKDF_INFO_ENC = "sealed-env:v1:enc";
4343
public static final String HKDF_INFO_MAC = "sealed-env:v1:mac";
4444

45-
/** TOTP verifier domain-separation tag. */
46-
public static final String TOTP_VERIFY_TAG = "verify-v1";
45+
/**
46+
* Enterprise epoch derivation tag — used at seal/mint time to derive
47+
* the salt-bound enterprise epoch from the TOTP secret:
48+
*
49+
* enterprise_epoch = HMAC-SHA256(totpSecret, salt || EPOCH_DERIVE_TAG)
50+
*/
51+
public static final String EPOCH_DERIVE_TAG = "epoch-v1";
52+
53+
/**
54+
* Enterprise epoch commitment tag — used by the file to commit to a
55+
* specific epoch without revealing it:
56+
*
57+
* epoch_commit = HMAC-SHA256(derivedKey, enterprise_epoch || EPOCH_COMMIT_TAG)
58+
*
59+
* The verifier recomputes this from the token's epoch field and
60+
* compares against the file's EPOCH-COMMIT field. This commits to
61+
* the operator-side TOTP secret WITHOUT requiring the verifier to
62+
* ever see the secret itself.
63+
*/
64+
public static final String EPOCH_COMMIT_TAG = "epoch-commit-v1";
4765
}

0 commit comments

Comments
 (0)