Security model¶
What you store¶
A public key, or its Ethereum address. Through OIDC, a sub that stands for
one. That is the whole of it.
There is no password column, so a copy of your database is not a breach of anybody's account. There is nothing in it to reverse, crack or reuse elsewhere.
What crosses the wire¶
A signature over a challenge your server built moments earlier. Never a key, never a phrase, never a password.
The signed bytes are these six lines, joined with newlines and hashed with keccak-256, then signed with secp256k1 (low-S):
omxus-site-auth/v1
<origin>
<address, lowercase>
<nonce>
<issuedAt, unix seconds>
<expiresAt, unix seconds>
The default lifetime is 300 seconds. Change any line and the recovered address
changes, so a signature made at evil.example is worth nothing at
bank.example.
Two jobs stay with your server:
- Pass your own origin to
verifySiteChallenge(signed, expectedOrigin). Never read it back out of the challenge; that check proves nothing. - Keep a nonce store. Accept each nonce once, and only nonces you issued. The signature check does not stop replay inside the 300 seconds; the store does.
Why not a fingerprint, or typing rhythm¶
Both identify. Neither authenticates.
A browser fingerprint is observable: every site the person visits reads the same value, so anyone who gets them to load a page can replay it. It is a name badge, and badges photocopy. Your bank account number is unique to you too, and it still cannot move money.
They are good at deciding when to ask: if nothing has changed, stay quiet; if something has, ask for the finger. That is a real use and it is not the lock.
The three inputs¶
Name, date of birth and secret words make one key. Name and date of birth are public, so the secret is the words alone. The key is stretched with PBKDF2-SHA512 at 2,048 rounds, the BIP-39 count, and the public key is on the wire, so an attacker can guess offline. How identity is derived has the detail and the planned fix. Prefer passkeys or the standard door where you can.
Word strength¶
Secrets are measured in bits, not word counts. The old rule (five words, two letters each, none repeated) rejected strong secrets and passed weak ones.
validateWords() estimates bits as length times log2 of the alphabet. The
alphabet is the character classes used (26 lowercase, 26 uppercase, 10 digits,
32 for anything else, spaces included), capped at twice the number of distinct
characters. It asks for 60 bits.
| Secret | Shortest that passes |
|---|---|
| Digits only, varied | 19 characters |
| Lowercase only, all different | 13 characters |
| Repeats | longer: aaaa… scores 1 bit a character |
Mixing classes helps only with many distinct characters: 11 mixed-case letters score 49 bits, not 60.
What the check does not do
It counts characters; it has no dictionary. "my name is bob smith" scores 92 bits and passes. The words are also lowercased before the key is made, so capitals count toward the score but add nothing to the key.
Digits of a public constant are discounted
A run of 8 or more digits taken from π, e, φ or √2 costs an attacker nothing to generate. The run is charged 20 bits flat, and if the total is under 80 bits the secret is refused. 30 digits of π score 23 bits; add two words of your own and it passes.
Passwords on the standard door¶
The standard door is the ordinary account: email or phone, code, optional password. Passwords need 8 characters. The browser runs PBKDF2-SHA256 600,000 times, salted per contact, and sends only the result; the server hashes that again (PBKDF2-SHA256, 100,000 rounds, random salt per account). Workers cap WebCrypto PBKDF2 at 100,000, and Argon2id would need WebAssembly, so neither layer uses it today.
Choice¶
No signal may be a condition of entry. Turning one off costs convenience, never access; otherwise it was a toll, not a choice.