Generate time-based OTP codes from age public keys – no shared secret required.
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"
| 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 |
[dependencies]
age-otp = "0.2"use age_otp::prelude::*;
fn main() -> Result<()> {
// 1. Create a keypair (or load an existing one)
let keypair = build_keypair()?;
let public_key = &keypair.public;
// 2. Build the OTP engine from the public key
let engine = OtpEngine::from_public_key(public_key)?;
// 3. Generate a 6‑digit numeric code for the current 30‑second window
let now = now_ts();
let step = now / 30;
let code = engine.generate(6, step, 30, Charset::Numeric)?;
println!("Your OTP: {}", code); // e.g. 847291
// 4. Verify a user‑supplied code (with ±1 step clock skew)
let user_input = "847291";
let result = engine.verify_with_skew(
user_input, 6, step, 30, 30, Charset::Numeric, 1,
)?;
assert!(result.is_ok());
Ok(())
}The crate root (age_otp) re‑exports the most important items:
use age_otp::{
// Key management (from age‑setup)
PublicKey, build_keypair,
// Core engine
OtpEngine,
// Data types
OtpSeed, OtpCode, Charset,
// Utility functions
now_ts, compute_hmac, truncate, ct_eq,
// Constants
SEED_LEN, MIN_CODE_LEN, MAX_CODE_LEN,
MIN_STEP_SECS, MAX_STEP_SECS, MAX_SKEW_STEPS,
// Error types
Error, KeyError, GenerationError, VerificationError, Result,
};
// Convenience prelude
use age_otp::prelude::*;The main entry point.
| 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. |
| 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. |
| 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). |
A 32‑byte seed derived from a public key.
let pk: PublicKey = "age1...".parse()?;
let seed = OtpSeed::from_public_key(&pk)?;
seed.as_bytes(); // &[u8; 32]
seed.to_hex(); // 64‑character hex string (for debugging)
let seed2 = OtpSeed::from_bytes([0u8; 32]); // create from raw bytesDebug safety –
Debugonly prints the first 8 hex characters.
An OTP code together with its birth timestamp.
let code = OtpCode::new("123456".into(), time_step, step_secs)?;
code.as_str(); // "123456"
code.len(); // 6
code.born_at(); // time_step * step_secs (UNIX seconds)
code.is_valid_at(current_ts, ttl); // true if current_ts is within [born, born+ttl)Debug safety –
Debugmasks the code (e.g.12***),Displayshows the full code.
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 = Charset::Numeric;
assert_eq!(cs.len(), 10);
assert!(cs.validate("123456"));
assert!(!cs.validate("abc")); // wrong charsetuse age_otp::{SEED_LEN, MIN_CODE_LEN, MAX_CODE_LEN, MIN_STEP_SECS, MAX_STEP_SECS, MAX_SKEW_STEPS};
// SEED_LEN = 32
// MIN_CODE_LEN = 4, MAX_CODE_LEN = 64
// MIN_STEP_SECS = 1, MAX_STEP_SECS = 3600
// MAX_SKEW_STEPS = 10These 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. |
All fallible operations return Result<T, Error>.
Error is an enum:
pub enum Error {
Key(KeyError), // invalid public key
Generation(GenerationError), // HMAC failure, invalid parameters, overflow
Verification(VerificationError), // mismatch, expired, invalid format
}Sub‑errors:
KeyError–Empty,InvalidPrefix,Bech32Decode,InvalidDecodedLengthGenerationError–HmacFailed,TruncateFailed,InvalidLength,OverflowVerificationError–Mismatch,Expired { expired_at, current },InvalidFormat
Example:
match engine.verify_raw("123456", 6, step, 30, 30, Charset::Numeric) {
Ok(()) => println!("✓"),
Err(Error::Verification(VerificationError::Mismatch)) => println!("✗ Wrong code"),
Err(Error::Verification(VerificationError::Expired { .. })) => println!("⏰ Expired"),
Err(e) => eprintln!("Error: {}", e),
}- 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.
- 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.
- 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.
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
| 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 |
More runnable examples are in the examples/ directory.
Run them with:
cargo run --example maincargo test # run all unit & integration tests
cargo test --lib # run only unit tests (inside src/)
cargo test --test engine_tests # run integration tests onlyLicensed under either of
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
- age encryption spec
- RFC 5869 – HKDF
- BIP‑173 – Bech32
- subtle crate – constant‑time operations