Parsing 1y6mo and generating secrets you can't guess
Two small jobs come up in any tool that handles credentials: writing down when a key should be rotated, and making a new key that nobody can guess. Both look like ten lines of code. Both hide a trap: an ambiguous unit in the first, a skewed random number in the second.
This post walks through how envsec handles both: the duration parser behind add --expires and audit --within, and the secret command that generates random values. All the code shown is from the repository, trimmed for length.
Durations a human would type
When I store an API key that expires, I want to type the expiry the way I would say it: 90d, 6mo, or 1y6mo for a key that is valid a year and a half. envsec accepts compact strings made of one or more segments, each one a whole number followed by a unit:
| Unit | Meaning | Fixed length |
|---|---|---|
| m | minutes | 60 s |
| h | hours | 60 min |
| d | days | 24 h |
| w | weeks | 7 d |
| mo | months | 30 d |
| y | years | 365 d |
$envsec -c myapp.dev add stripe.key --expires 1y6mo$◆ Enter secret value: ************$✔ Secret "stripe.key" stored in context "myapp.dev"$ ◔ expires: 2028-04-06 14:17:31The parser lives in packages/core/src/domain/duration.ts. It lowercases and trims the input, then eats it one segment at a time with an anchored regular expression, summing each piece into an Effect Duration:
const UNIT_TO_DURATION: Record<string, (n: number) => Duration.Duration> = {
d: (n) => Duration.days(n),
h: (n) => Duration.hours(n),
m: (n) => Duration.minutes(n),
mo: (n) => Duration.days(n * 30),
w: (n) => Duration.weeks(n),
y: (n) => Duration.days(n * 365),
};
const SEGMENT_PATTERN = /^(?<amount>\d+)(?<unit>mo|[mhdwy])(?<rest>.*)$/u;
// inside parseDuration, after trim() and toLowerCase():
while (remaining.length > 0) {
const { amount, unit, rest } =
SEGMENT_PATTERN.exec(remaining)?.groups ?? {};
if (!(amount && unit)) {
return yield* new InvalidDurationError({ input, message: "…" });
}
total = Duration.sum(total, UNIT_TO_DURATION[unit](Number(amount)));
if (Duration.isGreaterThan(total, MAX_DURATION)) {
// MAX_DURATION is 1000 years
return yield* new InvalidDurationError({ input, message: "…" });
}
remaining = rest ?? "";
}m or mo?
Minutes and months share a first letter, so the regex has to decide what 1mo means. JavaScript tries the branches of an alternation from left to right, and the unit group is mo|[mhdwy]: the two-letter mo is tried first. If the order were reversed, 1mo would match as one minute and the parser would then fail on the leftover o.
The flip side is case. Because the input is lowercased before matching, 1M is one minute, not one month. If you come from a world where a capital M means month, that will surprise you. Use mo and the ambiguity never comes up.
What it accepts and what it rejects
Segments can come in any order and can repeat; they are added up. Anything that is not digits followed by a known unit is an InvalidDurationError, which the CLI prints as a single line on stderr with exit code 1:
| Input | Result |
|---|---|
| 1y6mo | 545 days |
| 6mo1y | 545 days (order is free) |
| 2w3d | 17 days |
| 1d12h | 1 day 12 hours |
| 1h1h | 2 hours (repeats add up) |
| 7D | 7 days (lowercased first) |
| 1M | 1 minute, not 1 month |
| 0d | zero: expires immediately |
| 1.5h | error |
| 1 d | error |
| -1d | error |
| 30 | error (no unit) |
| 1001y | error (more than 1000y) |
No fractions, no spaces between number and unit, no negative values. 0d is accepted, and it is useful: audit --within 0d means "only what has already expired".
There is also an upper limit of 1000y, added in envsec 1.1.3. Before it, --expires 99999999999999y parsed without complaint and then crashed with RangeError: Invalid time value when envsec computed the expiry date: a JavaScript Date ends in the year 275760, and that duration goes far past it. A thousand years is generous, and it keeps every expiry a four-digit year, which the text comparison in the next section relies on.
A month is 30 days
The table above says it plainly: mo is 30 days and y is 365 days. The parser builds a fixed Duration, and DateTime.addDuration adds it as a number of milliseconds. Nothing in this path knows about calendars.
So 1y6mo is 545 days. Added on 9 October 2026, it lands on 6 April 2028, three days before the calendar answer of 9 April. Leap days and 31-day months also shift the result by a day or so.
I am fine with that trade-off. An expiry in envsec is a reminder to rotate, not a deadline enforced to the second. Fixed lengths make every duration comparable to every other, which is what audit --within needs, and they avoid the classic calendar question of what "one month after 31 January" means. If you need an exact date, use days: --expires 548d means 548 days, no interpretation involved.
Storing and showing the date
The expiry is computed once, when you run add or secret, and stored in the SQLite metadata database (never in the keychain, which only holds the value):
export const expiresAtFromNow = (duration: Duration.Duration): string => {
const future = DateTime.addDuration(DateTime.nowUnsafe(), duration);
return DateTime.formatIso(future)
.replace("T", " ")
.replace("Z", "")
.slice(0, 19);
};The stored string is UTC in the form YYYY-MM-DD HH:mm:ss, with no time-zone suffix. That shape sorts correctly as plain text, so finding what expires within a window is a string comparison against a cutoff computed the same way:
SELECT key, created_at, updated_at, expires_at
FROM secrets
WHERE env = ? AND expires_at IS NOT NULL AND expires_at <= ?
ORDER BY expires_atUTC is right for storage and wrong for people. Early versions printed the raw value with a UTC label, which meant doing time-zone arithmetic in your head. Now add and secret pass it through formatLocalDateTime, which converts it to the system time zone. The 2028-04-06 14:17:31 in the example above is Central European Summer Time; the database holds 12:17:31. The printed time carries no zone label, so it is only unambiguous on the machine that printed it.
How audit reports it
envsec audit lists secrets that have expired or will expire within a window. The window defaults to 30d and goes through the same parser, so --within 1y6mo works too. Without a context (no --context and no ENVSEC_CONTEXT), it checks every context.
$envsec -c myapp.dev audit --within 1y6mo$◎ Secrets expiring within 1y6mo in "myapp.dev":$ ✖ old.token expired 0m ago$ ◔ session.token expires in 16d$ ◔ api.key expires in 89d$ ◔ stripe.key expires in 544d$▪ 1 expired, 3 expiring soon (4 total)The relative times come from formatTimeDistance, which shows the largest whole unit (days, hours or minutes) and rounds down. That is why a key stored with --expires 90d shows in 89d a moment later: it is 89 days and 23-something hours away. --json gives you the raw UTC timestamp and an expired boolean instead.
Expiry is advisory. envsec never deletes or blocks an expired secret. envsec get still prints the value, and adds a warning on stderr when the secret has expired or expires within 24 hours. list shows the same status inline. Rotating the key is up to you.
Generating a secret you can't guess
The other half is envsec secret. It generates a random string and, when you give it both a context and a key, stores it like add would. The flags:
--length,-l: number of random characters, default 32, allowed range 1 to 4096--prefix,-p: a fixed string prepended to the value, such assk_--expires,-e: the same durations as above--alphanumeric,-a:[a-zA-Z0-9], which is already the default--special,-s: alphanumeric plus!@#$%^&*--all-chars,-A: a 93-character set of printable ASCII
$# Standalone: print a value and store nothing$envsec secret$uB1aUIMDgS2ybVPGAQQC8UT7XmdAOOrM$envsec secret --special --length 24$uLU%d2rV9&v#EABrR@mbm$SI$# Pipe it straight to the clipboard on macOS$envsec secret --length 48 | pbcopy$# Store mode: needs both a context and a key$envsec -c myapp.dev secret api.key --prefix demo_ -l 24 --expires 90d$⬡ Generated 24-char secret (alphanumeric)$ › prefix: demo_$✔ Secret "api.key" stored in context "myapp.dev"$ ◔ expires: 2027-01-07 13:17:31$ ◆ demo_5kLliTjcHt6h96ZYBXPnC7XhIf you pass more than one character-set flag, the largest set wins: --all-chars, then --special. In store mode the generated value is also printed on the last line, so it ends up in your terminal scrollback. If only one of context and key is present, the command prints the value and stores nothing.
Where the randomness comes from
The bytes come from randomBytes in node:crypto, which the Node.js documentation describes as cryptographically strong pseudorandom data. Not Math.random(), which makes no such promise. The npm package runs on Node and the standalone binary on Bun, which implements the same module.
Random bytes are only half the job, though. A byte has 256 values and the default character set has 62. Turning one into the other is where hand-rolled generators can go wrong.
Modulo bias, and the loop that avoids it
The obvious mapping is charset[byte % 62]. The problem is that 256 = 4 × 62 + 8. The byte values 0 to 247 cover each of the 62 characters exactly four times. The last eight values, 248 to 255, wrap around and land on the first eight characters again. Those eight characters, a to h, would each come up with probability 5/256 instead of 4/256: 25% more often than the rest.
In entropy terms the damage is small. The skewed distribution carries about 5.950 bits per character instead of 5.954, roughly 0.14 bits lost over 32 characters. The reason to fix it anyway is that the fix costs almost nothing, and with it the output is exactly uniform, so the entropy numbers below are exact instead of approximately right. envsec uses rejection sampling: it computes the largest multiple of the charset size that fits in a byte and throws away any byte at or above it.
const generateSecret = (length: number, charset: string): string => {
const maxValid = 256 - (256 % charset.length);
const result: string[] = [];
while (result.length < length) {
const bytes = randomBytes(length * 2);
for (const byte of bytes) {
if (result.length >= length) {
break;
}
if (byte < maxValid) {
result.push(charset[byte % charset.length] as string);
}
}
}
return result.join("");
};For the default set, maxValid is 256 − (256 mod 62) = 248. Bytes 248 to 255 are dropped, each character keeps exactly four byte values, and the distribution is uniform. Each round draws twice as many bytes as needed. At the default length of 32, even --all-chars, which discards the most, needs a second round about 3 times in 100,000:
| Character set | Size | Accepted bytes | Discarded | Bias without rejection |
|---|---|---|---|---|
| default (alphanumeric) | 62 | 0–247 | 3.1% | 8 chars at 5/256 vs 4/256 |
| --special | 70 | 0–209 | 18.0% | 46 chars at 4/256 vs 3/256 |
| --all-chars | 93 | 0–185 | 27.3% | 70 chars at 3/256 vs 2/256 |
The bias grows with the set size. With --all-chars and a plain modulo, 70 of the 93 characters would be 50% more likely than the other 23, costing about 0.6 bits over 32 characters. Rejection sampling removes it in every case.
How many bits is the default?
With a uniform, independent choice per character, the entropy of a secret is the length times log₂ of the charset size. For the defaults:
log2(62) ≈ 5.954 bits per character
5.954 × 32 ≈ 190.5 bits| Settings | Bits per char | Length | Entropy |
|---|---|---|---|
| default (62 chars) | 5.954 | 32 | 190.5 bits |
| --special (70 chars) | 6.129 | 32 | 196.1 bits |
| --all-chars (93 chars) | 6.539 | 32 | 209.3 bits |
| --special -l 64 | 6.129 | 64 | 392.3 bits |
| --all-chars -l 128 | 6.539 | 128 | 837.0 bits |
190 bits is well past the 128 bits usually quoted as the target for symmetric keys. Two details matter when you read these numbers:
- The prefix adds nothing.
sk_is the same in every value, so a 3-character prefix on 32 random characters is still 190.5 bits. - "All printable" is 93, not 95. The
--all-charsset leaves out the space and the backslash. It still includes quotes and the backtick, so it needs care when you paste it into a shell.
Going from the default set to --all-chars adds less than 0.6 bits per character. If you need more entropy, adding length buys more than adding symbols, and an alphanumeric secret survives being pasted into YAML, URLs and shell commands without escaping.
In short
- Durations are compact segments (
30m,2w3d,1y6mo).mobeatsmbecause the regex tries it first; input is lowercased, so1Mis a minute. - Months are 30 days and years 365. Expiry is stored in UTC, printed in local time by
add, and never enforced. envsec secretusesnode:cryptorandom bytes with rejection sampling, so there is no modulo bias. The default is 32 alphanumeric characters, about 190 bits.
Every flag is listed in the documentation. If you are curious how the commands themselves are wired together, I wrote about that in Building a CLI with Effect 4.