Secret values
On this page
A secret value is a password, key, token or code that your app receives
and checks, but never sends, logs, stores or shows. It arrives in a
request's Input, or through text.parse or json.read, and the
compiler keeps it from going any further. There are seven:
| Type | What it holds |
|---|---|
Password |
8 to 256 characters with no control character; any other Unicode and spaces are allowed. 8 is NIST SP 800-63B's minimum. Raise it with min_length:; NIST asks for 15 where a password is the only factor |
ApiKey |
1 to 512 printable ASCII characters with no spaces, whatever prefix the provider uses |
BearerToken |
RFC 6750's b64token, at most 8,192 characters |
Jwt |
A JSON Web Token, which crypto.verify_jwt checks. Its text is RFC 6750's b64token, at most 8,192 characters |
TotpSecret |
RFC 4648 Base32 in upper case without padding, in its one spelling, holding 16 to 64 bytes (at least RFC 4226's 128 bits) |
SecretText |
1 to 8,192 characters of anything, line breaks included, so it has room for a PEM key |
OneTimeCode |
The code a person types: six to eight ASCII digits (at least RFC 4226's six, at most RFC 6238's eight), a leading zero kept |
None of them is ordered: your code can't compare one with < or sort
by it. Sets and maps still keep secrets in an order of their own, which
Comparing secrets describes.
Here a sign-up form takes a password, which goes no further than its hash, and a partner's API key is recognised by what is kept of it:
type MemberHandle = Text { max_length: 40}type ChosenPassword = Password { min_length: 15}/// What the sign-up form sends: the password arrives, and goes no further/// than its hash.type SignUpForm = Input { handle: MemberHandle chosen_password: ChosenPassword}type KeyVerifier = Bytes {}type Recognised = Bool {}/// The hash a new member's password is kept as.action password_to_keep(sign_up: SignUpForm) -> PasswordHash { return crypto.hash_password(sign_up.chosen_password)}/// What is kept of a partner's API key: its SHA-256, which signs no one/// in.function key_verifier(partner_key: ApiKey) -> KeyVerifier { return KeyVerifier(hash.sha256(partner_key))}/// Whether a key a partner sends is the one it was issued.function recognised(offered_key: ApiKey, kept_verifier: KeyVerifier) -> Recognised { return Recognised(key_verifier(offered_key) == kept_verifier)}
test "a password is kept as its hash" { var sign_up: SignUpForm = SignUpForm { handle: "ada" chosen_password: "correct horse battery staple" } var kept_hash: PasswordHash = password_to_keep(sign_up) assert crypto.verify_password(sign_up.chosen_password, kept_hash) == PasswordCheck.correct}test "a partner's key is recognised by its verifier" { var kept_verifier: KeyVerifier = key_verifier("example_key_0123456789abcdef") assert recognised("example_key_0123456789abcdef", kept_verifier) assert not recognised("example_key_0123456789abcdeX", kept_verifier)}
Where a secret can go
A secret stays on the server. An Input, an Object, a variable and a
parameter may hold one, and json.read reads one, since none of them
leaves it.
Nothing that leaves the server may hold one, however deeply it is
nested in lists, maps, options, objects, Inputs or a variant's data.
The compiler refuses a secret in each of these with
type.secret_exposed:
- an
Output; - a failure's
clientorinternalfields. A failure's fields are logged, so this keeps secrets out of the logs too; - an event, which is kept in the outbox and handed on;
- an email or a message.
json.write can't write one either (type.mismatch). A PasswordHash
has a rule of its own, type.password_hash_exposed, because a hash,
unlike a secret, is stored.
How a secret is kept
A secret is never stored as itself. Each kind is kept as something that does the same job:
- A password is kept as its
PasswordHash. - An API key or a bearer token is kept as
hash.sha256of it, as Identity keeps a token's verifier. - A
Jwtis checked when it arrives and never kept. - A one-time code is kept only as the time it was used.
- A
TotpSecret, which checking a code needs back, and aSecretTextare stored sealed with an encrypted field's key (Encrypted fields).
An entity's field that holds any other secret is type.secret_exposed,
and the error's next: line names what to keep instead. Keeping the
password itself is refused:
type MemberHandle = Text { max_length: 40}entity Member { id: Uuid handle: MemberHandle { unique: true } chosen_password: Password}
error: `Member.chosen_password` keeps a `Password` --> main.jadpo:10:22 |10 | chosen_password: Password | ^^^^^^^^ a secret, stored reason: A `Password` is never stored as itself, so a copy of the database holds none. A password is hashed with `crypto.hash_password` and checked with `crypto.verify_password`. next: Store `crypto.hash_password(...)` of it in a `PasswordHash` field in `chosen_password`'s place (agent) rule: type.secret_exposed help: https://jadpo.dev/docs/errors/type.secret_exposedcheck failed: 1 error, 1 file
What may read a secret
Only the operation made for a secret reads it:
crypto.hash_passwordandcrypto.verify_passwordtake aPassword.crypto.verify_jwttakes aJwt.hash.sha256takes anApiKeyor aBearerToken. It never takes aPassword, whose only hash is Argon2id's, nor aOneTimeCode, since the digest of a six-digit code is found in a million tries.
Everything else that would read a secret is type.secret_exposed, so no
length, part, text or format for people can carry one out:
- any other operation, one over a collection of secrets included, so
text.joincan't join them; - any operator but
==and!=, since an order ormatcheswould answer questions about the text; text.fromandtext.format;- building any type except one built on the same secret.
Building a type that is built on it is allowed, as in
StaffPassword(password).
Comparing secrets
== and != on secrets take a time that depends on the two values'
lengths alone, so how long a comparison takes says nothing of where two
values differ. Every byte up to the longer value's length is compared,
and the answer is decided only once all of them have been, so the
comparison never stops early. This is how
Identity compares its verifiers, and a
secret compared with text written in source is compared the same way.
A set or a map orders secrets by a comparison that also looks at every byte.
A secret value equals its text wherever the two meet, in time that
depends on the lengths alone: inside a list, a set, a map's keys and
values, an object and an enum's data, however deep
([p] == ["hunter2hunter2"]). In the order sets and maps keep, a
secret value sits among text values, ordered by its text. So
set.contains finds a secret by its text, and finds text by an equal
secret, and that order always agrees with ==.
Reports show a secret redacted
Wherever Jadpo shows a value, it shows a secret as <redacted ApiKey>,
naming its type: a failed test's values, jadpo test's output and its
--json, a failure's report, a fault, and anything a debugger or
jadpo watch shows. Every one of them writes a value the same way.
A test that fails over a secret shows it redacted wherever it shows a value, and its diff still says which field differs:
type PartnerName = Text { max_length: 100}type PartnerLogin = Object { name: PartnerName partner_key: ApiKey}/// A partner, signed in with the key it sent.function signed_in(name: PartnerName, offered_key: ApiKey) -> PartnerLogin { return PartnerLogin { name: name partner_key: offered_key }}
test "a partner keeps the key it signed in with" { var issued_key: ApiKey = "example_key_0123456789abcdef" assert signed_in("Northwind", issued_key) == PartnerLogin { name: "Northwind" partner_key: "example_key_9876543210fedcba" }}
FAIL a partner keeps the key it signed in with --> main.test.jadpo:3 assert signed_in("Northwind", issued_key) == PartnerLogin { ... | | | | | PartnerLogin { name: "Northwind", partner_key: <redacted ApiKey> } | <redacted ApiKey> PartnerLogin { name: "Northwind", partner_key: <redacted ApiKey> } expected: PartnerLogin { name: "Northwind", partner_key: <redacted ApiKey> } actual: PartnerLogin { name: "Northwind", partner_key: <redacted ApiKey> } diff: ~ partner_key: expected <redacted ApiKey>, actual <redacted ApiKey> rule: test.assertion_failed rerun: jadpo test main.test.jadpo:3 --seed 1test failed: 1 failed, 1 pure test
A setting that holds a secret
A setting whose type is a secret value follows its secret: flag, as
every setting does (Secrets).
With secret: true, it goes only to places the compiler owns. Without
it, your code reads it as a secret value, which the rules above keep in
and show redacted.
Recordings for replay
A recording of a request, kept so it can be replayed
(What the graph and production evidence add),
holds no credential the runtime made. The bytes the runtime makes a
session, an API token, a service key or a CSRF token from are never kept
on a recording's tape, nor taken from a replayed one, so a replay makes
fresh ones. The program's own random draws still replay
in order.
No crypto.encrypt nonce is kept either. It is drawn as a credential's
bytes are, so a replay never seals with a nonce that a production run
used under the same key. A password's salt isn't secret and stays on the
tape, so a replayed hash_password gives the hash the recorded run made.
OpenAPI
OpenAPI describes a secret value as a writeOnly string, and a
Password with format: password as well, so generated clients hide it
as it is typed.
Not built yet: storing a
TotpSecretor aSecretTextneeds keys held as data (Language gaps this page depends on). Until that is built, storing one istoolchain.not_built_yetrather than refused. No operation reads aOneTimeCodeyet:crypto.verify_totpwaits for keys held as data.jadpo replay, which the recordings above are for, isn't built; when it is, it keeps out the draws a secret value was made from.
Markdown for agents: /docs/secret-values.md.Built from Jadpo main at a707efb.