age-otp
Generate time-based OTP codes from age public keys – no shared secret required.
Overview
age-otp derives deterministic one‑time passwords solely from an age public key (format age1...).
Unlike standard TOTP (RFC 6238), there is no shared secret – the verifier only needs the user’s public key.
age1ysxuae... ──Bech32 decode──► [32‑byte X25519 key]
│
▼
HKDF‑SHA256 ("age-otp-v1")
│
▼
[32‑byte seed]
│
▼
HMAC‑SHA256(time_step)
│
▼
Dynamic truncation
│
▼
"123456"
Why this matters
| Standard TOTP | age‑otp |
|---|---|
| Requires shared secret | Uses only public key |
| Both parties must protect the secret | Verifier never sees a secret |
| Key rotation is painful | Just rotate the age keypair |
| Works with authenticator apps | Custom – integrate into your own auth service |
Installation
[]
= "0.2"
Quick Start
use *;
API Reference
Public re‑exports
The crate root (age_otp) re‑exports the most important items:
use ;
// Convenience prelude
use *;
OtpEngine
The main entry point.
Construction
| Method | Description |
|---|---|
OtpEngine::from_public_key(pk: &PublicKey) -> Result<Self> |
Derives a seed from an age public key using HKDF‑SHA256. |
OtpEngine::from_seed(seed: OtpSeed) -> Self |
Builds the engine directly from a pre‑derived seed (skips HKDF). |
engine.seed() |
Returns a reference to the internal OtpSeed. |
Generation
| Method | Description |
|---|---|
engine.generate(len, time_step, step_secs, charset) -> Result<OtpCode> |
Generates an OTP code of given length for a specific time window. |
engine.generate_default(len, time_step) -> Result<OtpCode> |
Shortcut for numeric, 30‑second step. |
engine.generate_now(len) -> Result<OtpCode> |
Generates a code for the current 30‑second window. |
Verification
| Method | Description |
|---|---|
engine.verify(code: &OtpCode, time_step, ttl, step_secs, charset) -> Result<()> |
Verifies an OtpCode object. |
engine.verify_default(code: &OtpCode, time_step, ttl) -> Result<()> |
Shortcut for numeric, 30‑second step. |
engine.verify_raw(raw: &str, len, time_step, ttl, step_secs, charset) -> Result<()> |
Verifies a raw string. |
engine.verify_with_skew(raw: &str, len, time_step, ttl, step_secs, charset, skew_steps) -> Result<()> |
Verifies a raw string with clock skew tolerance (±skew_steps steps). |
OtpSeed
A 32‑byte seed derived from a public key.
let pk: PublicKey = "age1...".parse?;
let seed = from_public_key?;
seed.as_bytes; // &[u8; 32]
seed.to_hex; // 64‑character hex string (for debugging)
let seed2 = from_bytes; // create from raw bytes
Debug safety –
Debugonly prints the first 8 hex characters.
OtpCode
An OTP code together with its birth timestamp.
let code = new?;
code.as_str; // "123456"
code.len; // 6
code.born_at; // time_step * step_secs (UNIX seconds)
code.is_valid_at; // true if current_ts is within [born, born+ttl)
Debug safety –
Debugmasks the code (e.g.12***),Displayshows the full code.
Charset
Supported character sets for OTP codes.
| Variant | Characters | Base |
|---|---|---|
Charset::Numeric |
0-9 |
10 |
Charset::AlphanumericUpper |
0-9A-Z |
36 |
Charset::HexLower |
0-9a-f |
16 |
let cs = Numeric;
assert_eq!;
assert!;
assert!; // wrong charset
Constants
use ;
// SEED_LEN = 32
// MIN_CODE_LEN = 4, MAX_CODE_LEN = 64
// MIN_STEP_SECS = 1, MAX_STEP_SECS = 3600
// MAX_SKEW_STEPS = 10
Utility functions
These are re‑exported at the crate root, but are also available under age_otp::types.
| Function | Signature | Purpose |
|---|---|---|
now_ts() |
-> u64 |
Current UNIX timestamp in seconds. |
compute_hmac |
(seed: &[u8;32], step: u64) -> Result<[u8;32]> |
HMAC‑SHA256 of the step value. |
truncate |
(hash: &[u8;32], charset: Charset, len: usize) -> Result<String> |
Dynamic truncation (HOTP‑style). |
ct_eq |
(a: &[u8], b: &[u8]) -> bool |
Constant‑time slice comparison. |
validate_code_len |
(len: usize) -> Result<()> |
Checks len is within MIN_CODE_LEN..=MAX_CODE_LEN. |
validate_step_secs |
(secs: u64) -> Result<()> |
Checks step seconds are within bounds. |
validate_skew_steps |
(skew: u64) -> Result<()> |
Checks skew steps ≤ MAX_SKEW_STEPS. |
Error handling
All fallible operations return Result<T, Error>.
Error is an enum:
Sub‑errors:
KeyError–Empty,InvalidPrefix,Bech32Decode,InvalidDecodedLengthGenerationError–HmacFailed,TruncateFailed,InvalidLength,OverflowVerificationError–Mismatch,Expired { expired_at, current },InvalidFormat
Example:
match engine.verify_raw
Security
✅ What this library provides
- No shared secrets – OTP codes are derived from the public key only.
- Proper key derivation – The age public key is Bech32‑decoded first, then fed into HKDF‑SHA256. The original Bech32 string is never used as the HMAC key directly.
- Constant‑time comparison – All code comparisons use the
subtlecrate to prevent timing attacks. - Overflow safety – All arithmetic uses checked operations (
checked_mul,saturating_add). - Bounded parameters – Code length, step seconds, and skew steps have hard limits to prevent abuse.
- Debug protection –
OtpSeedshows only a short hex prefix;OtpCodemasks the code. - Zeroization – Secret keys (via
age‑setup) are zeroized on drop.
⚠️ Deployment checklist
- Use HTTPS – OTP codes must be transmitted over encrypted channels.
- Short TTL – 30–60 seconds is recommended.
- Rate limiting – Throttle verification attempts to prevent brute force (library does not implement rate limiting).
- Store the seed securely – If you cache
OtpSeed, treat it as sensitive (it can generate valid codes). - Do not log full OTP codes – Use the
Debugrepresentation (masked) for logging.
🚫 Known limitations
- Not TOTP compatible – Does not follow RFC 6238; cannot be used with standard authenticator apps.
- No replay protection – A code remains valid for the entire TTL window. The application must enforce one‑time use if desired.
- Single charset per code – Characters cannot be mixed.
Architecture
src/
├── lib.rs # Crate root, re‑exports
├── engine.rs # OtpEngine (generation & verification)
├── types.rs # OtpSeed, OtpCode, Charset, constants, utility functions
└── error.rs # Error and Result types
Dependencies
| Crate | Version | Purpose |
|---|---|---|
| age‑setup | 0.1 | Key pair generation, PublicKey type |
| bech32 | 0.11 | Bech32 decoding with checksum |
| hkdf | 0.12 | HKDF‑SHA256 key derivation |
| hmac | 0.12 | HMAC‑SHA256 for code generation |
| sha2 | 0.10 | SHA‑256 hash function |
| thiserror | 1.0 | Ergonomic error types |
| subtle | 2.5 | Constant‑time comparison |
Examples
More runnable examples are in the examples/ directory.
Run them with:
Testing
License
Licensed under either of
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
References
- age encryption spec
- RFC 5869 – HKDF
- BIP‑173 – Bech32
- subtle crate – constant‑time operations