The secret¶
The shared secret is the credential. It is held as an upper-case Base32 string, which is the encoding authenticator applications read and the one the provisioning URI carries.
A generated one¶
generate() draws it from random_bytes(). The default is 64 bytes — 512 bits, 103 Base32 characters:
A second argument sets the size in bytes:
$totp = TOTP::generate($clock, 20); // 32 characters
$hotp = HOTP::generate(20); // HOTP takes the size as its first argument
| Bytes | Base32 characters | |
|---|---|---|
| 16 | 26 | The minimum worth using |
| 20 | 32 | What RFC 4226 recommends, and what most services issue |
| 32 | 52 | |
| 64 | 103 | The default |
A size below 1 throws InvalidParameterException.
Why 20 bytes is often the better choice
The strength of the construction is capped by the hash it uses — 160 bits for sha1, 256 for
sha256 — so a secret longer than that adds nothing an attacker has to work through. What it does add
is a much denser QR code, which some cameras and some applications read badly. Twenty bytes is the
size RFC 4226 recommends, and 32 is a reasonable choice next to sha256.
Your own¶
use OTPHP\TOTP;
use ParagonIE\ConstantTime\Base32;
$secret = Base32::encodeUpper(random_bytes(20));
$totp = TOTP::createFromSecret($secret, $clock);
The value is normalised on the way in: it is upper-cased, and trailing = padding is stripped. So the
secret you read back may not be character-for-character the string you passed, while decoding to the same
bytes.
Whatever produces it must be a cryptographically secure source. random_bytes() is one; rand(),
uniqid(), a hash of the user's e-mail address and a timestamp are not.
When it is decoded¶
The Base32 string is decoded lazily, the first time a code is computed. A secret that is not valid Base32
is therefore accepted by createFromSecret() and only fails later, at now(), at() or verify(), with
a SecretDecodingException:
use OTPHP\TOTP;
use OTPHP\Exception\SecretDecodingException;
$totp = TOTP::createFromSecret('INVALID!@#$%', $clock); // accepted
try {
$totp->now(); // throws here
} catch (SecretDecodingException $e) {
// "Unable to decode the secret. Is it correctly base32 encoded?"
}
If you validate a secret a user pasted by hand, call now() on it and catch that exception; constructing
the object proves nothing.
Storing it¶
One secret per account, never shared, never reused for a second purpose. It is a credential that grants valid codes forever to whoever reads it, so encrypt it at rest with a key kept outside the database, and keep it out of logs and error reports. See Security recommendations and What to store.