Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,17 @@ with idioms is [docs/idiomatic_pith.md](docs/idiomatic_pith.md).

## the standard library, briefly

81 modules, ~37,000 lines, all pith. the areas: io and filesystems
83 modules, ~38,000 lines, all pith. the areas: io and filesystems
(fs, glob, path, process), networking (tcp, dns, url, http, http2,
websocket, tls, sse), databases (sql, postgres, mysql, redis — pure-pith
wire protocols with tls, prepared statements, and pooling — plus db, a
pooled high-level layer over a connection url, see [docs/db.md](docs/db.md)),
data (json,
toml, yaml, csv, config, table, see [docs/yaml.md](docs/yaml.md) for the
yaml subset), bytes and crypto (hash, checksum, encoding,
crypto, bits, binary), compression and archives (gzip/zlib, tar, zip),
crypto, bits, binary — including argon2id password hashing and json web
tokens, see [docs/auth.md](docs/auth.md)),
compression and archives (gzip/zlib, tar, zip),
text (regex, scanner, fmt), app plumbing (log, metrics, cli, env, testing,
diagnostic, time, datetime, rand, uuid, math), observability (trace,
prometheus, otlp, obs — opentelemetry traces and metrics, see
Expand Down
190 changes: 190 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# passwords and tokens

authentication in pith is two modules. `std.crypto.password` handles the
password at rest, and `std.crypto.jwt` handles the session that follows.
`examples/auth.pith` runs the whole flow end to end.

## hashing a password

`hash` takes a password and returns a phc string. `verify` takes the password
and the stored string and says whether they match.

```pith
import std.crypto.password as password

stored := password.hash("correct horse battery staple")!
# $argon2id$v=19$m=19456,t=2,p=1$hMDYzMFA5NHRoZQ$Zk5MPFY7...

password.verify("correct horse battery staple", stored) # true
password.verify("something else", stored) # false
```

the salt is fresh os randomness on every call, so hashing the same password
twice gives two different strings. the comparison at the end of `verify` goes
through `std.crypto.subtle`, so it looks at every byte whether or not the first
one matched — a comparison that returns early leaks how much of the tag was
right, one request at a time.

`verify` returns a `Bool` rather than a result. a hash it cannot parse is a
failed verification, which is the only safe reading: an api that returned an
error there would sooner or later be called by something that treated
"unparseable" as "fine".

## the parameters, and why these ones

the defaults are owasp's argon2id parameters: 19 mib of memory, two passes, one
lane, a 16 byte salt and a 32 byte tag. that measures around 30 ms per hash on
a modest core.

| | memory | passes | lanes | measured |
| --- | --- | --- | --- | --- |
| the default | 19 mib | 2 | 1 | ~30 ms |
| rfc 9106 first option | 2 gib | 1 | 4 | over the runtime's 1 gib cap |
| rfc 9106 second option | 64 mib | 3 | 4 | ~230 ms |

30 ms is a deliberate middle. it is expensive enough that an attacker who
steals the database is buying time by the cpu-year, and cheap enough that a
login endpoint under load is not attacking itself — an auth path that spends a
quarter of a second per attempt hands anyone with a script a way to saturate
it. the runtime derives single-threaded, so raising `lanes` costs wall clock
rather than saving it.

to move off the defaults, build a `Params` and use `hash_with`:

```pith
strict := password.params(65536, 3, 1) # memory kib, passes, lanes
stored := password.hash_with(secret, strict)!
```

`params_with_lengths` also sets the salt and tag sizes. everything is range
checked before any derivation happens: at least 8 kib of memory per lane, a
salt of at least 8 bytes, a tag of at least 16, and no more than 1 gib of
memory, which is the cap the runtime enforces.

## raising the parameters later

the cost parameters are written into the phc string, so a hash stored under old
parameters stays verifiable after you raise them. `needs_rehash` is what tells
you which ones to replace:

```pith
wanted := password.params(65536, 3, 1)

if password.verify(attempt, stored):
if password.needs_rehash(stored, wanted):
stored = password.hash_with(attempt, wanted)!
save(user, stored)
```

the rehash happens inside the successful login, because that is the only moment
the plaintext password is in hand. a hash that cannot be parsed at all also
reports as needing a rehash, which quietly migrates anything left over from an
older scheme the first time its owner signs in.

## json web tokens

`std.crypto.jwt` reads and writes the jws compact serialization. the api is
deliberately asymmetric, and the asymmetry is visible in the function names:

| algorithm | sign | verify |
| --- | --- | --- |
| HS256, HS384, HS512 | yes | yes |
| PS256 | yes | yes |
| RS256 | no | yes |
| ES256 | no | yes |
| EdDSA | no | yes |

the runtime's only signing primitives are hmac and rsa-pss. it can verify
ed25519, ecdsa and rsa pkcs#1 signatures but cannot produce them, so a pith
service can validate tokens from an outside issuer using any of those and can
only issue HS\* or PS256 itself. there is no `sign_rs256` to discover at
runtime; the function does not exist.

```pith
import std.bytes as bytes
import std.crypto.jwt as jwt

secret := bytes.from_string_utf8(env("SESSION_SECRET"))
claims := "{{\"iss\":\"chat\",\"sub\":\"u-1024\",\"exp\":1750003600}}"

token := jwt.sign_hs256(claims, secret)!
session := jwt.verify_hs256(token, secret, jwt.default_options())!
print(session.claims)
```

json in a pith string literal is written with doubled braces, since a single
`{` starts an interpolation.

`verify_*` hands back a `Verified` holding the header and claims as the json
text they arrived as. parse them with `std.json` — `json.decode_text[T]` if you
have a struct for your claims, `json.parse` if you would rather have handles.

## the two things jwt libraries get wrong

### algorithm confusion

every verifier names the algorithm it accepts. `verify_hs256` verifies HS256
and nothing else; the token's own `alg` header is only ever compared against
the name the caller passed, and no code path selects a verifier from it.

the attack this closes is the classic one. take a token meant for an rsa
verifier, re-sign it as HS256 using the rsa *public* key as the hmac secret,
and hand it to a verifier that trusts the header. against `verify_rs256` that
token is refused on its `alg` before its signature is looked at.

### alg: none

refused outright, with no option to turn it back on, spelled in any case, with
or without a signature field bolted on the end. an unsecured jws is a valid
thing for the spec to describe and never a valid thing for a verifier to
accept.

### and the smaller ones

a `crit` header parameter is rejected per rfc 7515 section 4.1.11, since a
verifier that ignores an extension the issuer marked critical is ignoring the
thing the issuer said not to ignore. signature comparison goes through
`std.crypto.subtle`. the token length, header parameter count, claim count and
audience list are all bounded.

## checking the claims

`Options` decides which registered claims get checked. the defaults check
`exp`, `nbf` and `iat` with a minute of clock skew, and compare nothing the
caller has not named:

```pith
expected := jwt.with_audience(jwt.with_issuer(jwt.default_options(), "chat"), "web")
session := jwt.verify_hs256(token, secret, expected)!
```

the builders compose, each returning a new `Options`:

- `with_issuer`, `with_audience`, `with_subject` — compare `iss`, `sub`, and
`aud`, which may be a string or an array of them
- `with_leeway` — how much clock skew the time comparisons tolerate
- `requiring_exp` — refuse a token that carries no `exp` at all
- `at_time` — validate against a fixed unix timestamp rather than the clock
- `without_time_checks` — turn off `exp`, `nbf` and `iat`, for replaying a
fixed vector and not for traffic

## interoperability

the tests verify the rfc 7515 appendix a.1 HS256 token and the appendix a.3
ES256 token against the exact serializations the rfc prints, and
`tests/cases/test_jwt_asymmetric.pith` verifies an RS256 and an EdDSA token
signed with openssl. a token that only round trips against the module that made
it says nothing about talking to anyone else.

ES256 needs a small translation on the way in: jws carries the ecdsa signature
as r and s glued together at a fixed width, and the runtime's verifier reads
the asn.1 der encoding, so `verify_es256` re-wraps it.

key formats, which are the usual place to get stuck:

- `verify_eddsa` takes the raw 32 byte ed25519 public key
- `verify_es256` takes the uncompressed p-256 point: `0x04`, then x, then y
- `verify_rs256` and `verify_ps256` take the pkcs#1 rsapublickey der, which is
what `std.crypto.x509` returns as a certificate's `subject_public_key`
- `sign_ps256` takes a pkcs#8 private key, which is what
`encoding.pem_decode` gives you from a `BEGIN PRIVATE KEY` file
64 changes: 64 additions & 0 deletions examples/auth.pith
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# signing a user up, logging them in, and handing them a session token.
#
# the two halves of an auth story: argon2id for the password at rest, and a
# signed json web token for the session that follows. json in string literals
# is written with doubled braces, which is pith's escape for a literal brace.

import std.bytes as bytes
import std.crypto.jwt as jwt
import std.crypto.password as password

# a fixed instant so the example prints the same thing every run. real code
# leaves `now` alone and lets the verifier read the clock.
NOW := 1_750_000_000

fn signing_secret() -> Bytes:
return bytes.from_string_utf8("keep this in the environment, not the source")

fn register(secret: String) -> String!:
# the defaults are owasp's argon2id parameters, around 30 ms per hash.
stored := password.hash(secret)!
print("stored hash starts: " + stored.substring(0, 30))
return stored

fn log_in(attempt: String, stored: String) -> Bool:
return password.verify(attempt, stored)

fn issue(subject: String) -> String!:
claims := "{{\"iss\":\"pith-chat\",\"sub\":\"" + subject + "\",\"aud\":\"chat\",\"iat\":1750000000,\"exp\":1750003600}}"
return jwt.sign_hs256(claims, signing_secret())!

fn main() -> Int!:
print("=== registering ===")
stored := register("correct horse battery staple")!
print("right password: " + log_in("correct horse battery staple", stored).to_string())
print("wrong password: " + log_in("correct horse battery stapler", stored).to_string())

print("")
print("=== raising the cost later ===")
# a year on, the same hash checked against parameters that have moved.
stronger := password.params(65536, 3, 4)
print("needs rehashing: " + password.needs_rehash(stored, stronger).to_string())
print("still fine today: " + password.needs_rehash(stored, password.default_params()).to_string())

print("")
print("=== issuing a session token ===")
token := issue("u-1024")!
print("token fields: " + token.split(".").len().to_string())

# the verifier names its algorithm and what it expects to find inside. it
# never reads the algorithm off the token, which is what stops an attacker
# re-signing one and choosing the verification path for you.
expected := jwt.with_audience(jwt.with_issuer(jwt.at_time(jwt.default_options(), NOW), "pith-chat"), "chat")
session := jwt.verify_hs256(token, signing_secret(), expected)!
print("claims: " + session.claims)

print("")
print("=== what gets turned away ===")
print("wrong secret: " + jwt.verify_hs256(token, bytes.from_string_utf8("guess"), expected).is_err.to_string())
print("wrong audience: " + jwt.verify_hs256(token, signing_secret(), jwt.with_audience(jwt.at_time(jwt.default_options(), NOW), "billing")).is_err.to_string())
print("expired an hour later: " + jwt.verify_hs256(token, signing_secret(), jwt.at_time(jwt.default_options(), NOW + 7200)).is_err.to_string())
# pith cannot issue RS256 at all, and it will not verify an HS256 token
# through the rs256 path either.
print("hs256 token, rs256 verifier: " + jwt.verify_rs256(token, signing_secret(), expected).is_err.to_string())
return 0
18 changes: 18 additions & 0 deletions examples/expected/auth.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
=== registering ===
stored hash starts: $argon2id$v=19$m=19456,t=2,p=1
right password: true
wrong password: false

=== raising the cost later ===
needs rehashing: true
still fine today: false

=== issuing a session token ===
token fields: 3
claims: {"iss":"pith-chat","sub":"u-1024","aud":"chat","iat":1750000000,"exp":1750003600}

=== what gets turned away ===
wrong secret: true
wrong audience: true
expired an hour later: true
hs256 token, rs256 verifier: true
Loading
Loading