add `Secret` enum & `gen_secret` feature

- Make the distinction between encoded/non-encoded secret clear,
and allows for easy transformation betwen the two formats
- add `gen_secret` feature to allow easy generation of CSPRNG secret,
also add function to generate rfc recommended length secret
This commit is contained in:
Steven Salaun 2022-08-06 23:04:50 +02:00
parent 3bdb91fad7
commit f65a2e840a
5 changed files with 250 additions and 2 deletions

View File

@ -19,6 +19,7 @@ default = []
otpauth = ["url", "urlencoding"]
qr = ["qrcodegen", "image", "base64", "otpauth"]
serde_support = ["serde"]
gen_secret = ["rand"]
[dependencies]
serde = { version = "1.0", features = ["derive"], optional = true }
@ -27,8 +28,9 @@ sha-1 = "~0.10.0"
hmac = "~0.12.1"
base32 = "~0.4"
urlencoding = { version = "^2.1.0", optional = true}
url = { version = "^2.2.2", optional = true }
url = { version = "^2.2.2", optional = true }
constant_time_eq = "~0.2.1"
qrcodegen = { version = "~1.8", optional = true }
image = { version = "~0.24.2", features = ["png"], optional = true, default-features = false}
base64 = { version = "~0.13", optional = true }
base64 = { version = "~0.13", optional = true }
rand = { version = "~0.8.5", optional = true }

26
examples/gen_secret.rs Normal file
View File

@ -0,0 +1,26 @@
#[cfg(not(feature = "gen_secret"))]
compile_error!("requires feature gen_secret");
use totp_rs::{Secret, TOTP, Algorithm};
fn main () {
let secret = Secret::generate_rfc_secret();
let totp = TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret.as_bytes().unwrap(),
None,
"account".to_string(),
).unwrap();
println!(
"secret plain: {} ; secret base32 {} ; code: {}",
secret,
secret.as_base32(),
totp.generate_current().unwrap()
)
}

33
examples/secret.rs Normal file
View File

@ -0,0 +1,33 @@
use totp_rs::{Secret, TOTP, Algorithm};
fn main () {
// create TOTP from base32 secret
let secret_b32 = Secret::Base32(String::from("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG"));
let totp_b32 = TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret_b32.as_bytes().unwrap(),
None,
"account".to_string(),
).unwrap();
println!("base32 {} ; plain {}", secret_b32, secret_b32.as_plain().unwrap());
println!("code from base32:\t{}", totp_b32.generate_current().unwrap());
// create TOTP from plain text secret
let secret_plain = Secret::Plain(String::from("plain-string-secret-123"));
let totp_plain = TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret_plain.as_bytes().unwrap(),
None,
"account".to_string(),
).unwrap();
println!("plain {} ; base32 {}", secret_plain, secret_plain.as_base32());
println!("code from plain text:\t{}", totp_plain.generate_current().unwrap());
}

View File

@ -45,6 +45,8 @@
//! # }
//! ```
mod secret;
pub use secret::{Secret, SecretParseError};
pub use base32;
use constant_time_eq::constant_time_eq;

185
src/secret.rs Normal file
View File

@ -0,0 +1,185 @@
use std::string::FromUtf8Error;
use base32::{self, Alphabet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretParseError {
ParseBase32,
Utf8Error(FromUtf8Error),
}
/// Representation of a secret either in "plain text" or "base 32" encoded
///
/// # Examples
///
/// - Create a TOTP from a "plain text" secret
/// ```
/// use totp_rs::{Secret, TOTP, Algorithm};
///
/// let secret = Secret::Plain(String::from("my-secret"));
/// let totp_plain = TOTP::new(
/// Algorithm::SHA1,
/// 6,
/// 1,
/// 30,
/// secret.as_bytes().unwrap(),
/// None,
/// "account".to_string(),
/// ).unwrap();
///
/// println!("code from plain text:\t{}", totp_plain.generate_current().unwrap());
/// ```
///
/// - Create a TOTP from a base32 encoded secret
/// ```
/// use totp_rs::{Secret, TOTP, Algorithm};
///
/// let secret = Secret::Base32(String::from("NV4S243FMNZGK5A"));
/// let totp_base32 = TOTP::new(
/// Algorithm::SHA1,
/// 6,
/// 1,
/// 30,
/// secret.as_bytes().unwrap(),
/// None,
/// "account".to_string(),
/// ).unwrap();
///
/// println!("code from base32:\t{}", totp_base32.generate_current().unwrap());
///
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Secret {
/// represent a non-encoded "plain text" secret
Plain(String),
/// represent a base32 encoded secret
Base32(String),
}
impl Secret {
/// Get the inner String value of the enum variant
pub fn inner(&self) -> &String {
match self {
Secret::Plain(s) => s,
Secret::Base32(s) => s,
}
}
/// Get the inner String value as a Vec of bytes
pub fn as_bytes(&self) -> Result<Vec<u8>, SecretParseError> {
match self {
Secret::Plain(s) => Ok(s.as_bytes().to_vec()),
Secret::Base32(s) => match base32::decode(Alphabet::RFC4648 { padding: false }, s) {
Some(bytes) => Ok(bytes),
None => Err(SecretParseError::ParseBase32),
},
}
}
/// Transforms a `Secret::Base32` into a `Secret::Plain`
pub fn as_plain(&self) -> Result<Self, SecretParseError> {
match self {
Secret::Plain(_) => Ok(self.clone()),
Secret::Base32(s) => match base32::decode(Alphabet::RFC4648 { padding: false }, s) {
Some(buf) => match String::from_utf8(buf) {
Ok(str) => Ok(Secret::Plain(str)),
Err(e) => Err(SecretParseError::Utf8Error(e)),
},
None => Err(SecretParseError::ParseBase32),
},
}
}
/// Transforms a `Secret::Plain` into a `Secret::Base32`
pub fn as_base32(&self) -> Self {
match self {
Secret::Plain(s) => Secret::Base32(base32::encode(
Alphabet::RFC4648 { padding: false },
s.as_ref(),
)),
Secret::Base32(_) => self.clone(),
}
}
/// ⚠️ requires feature `gen_secret`
///
/// Generate a CSPRNG alpha-numeric string of length `size`
#[cfg(feature = "gen_secret")]
pub fn generate_secret(size: usize) -> Secret {
use rand::distributions::{Alphanumeric, DistString};
Secret::Plain(Alphanumeric.sample_string(&mut rand::thread_rng(), size))
}
/// ⚠️ requires feature `gen_secret`
///
/// Generate a CSPRNG alpha-numeric string of length 20,
/// the recomended size from [rfc-4226](https://tools.ietf.org/html/rfc4226)
///
/// > The length of the shared secret MUST be at least 128 bits.
/// > This document RECOMMENDs a shared secret length of 160 bits.
#[cfg(feature = "gen_secret")]
pub fn generate_rfc_secret() -> Secret {
Secret::generate_secret(20)
}
}
impl std::fmt::Display for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Secret::Plain(s) => write!(f, "{}", s),
Secret::Base32(s) => write!(f, "{}", s),
}
}
}
#[cfg(test)]
mod tests {
use super::Secret;
const PLAIN: &str = "plain-string-secret-123";
const BASE32: &str = "OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG";
const BYTES: [u8; 23] = [
0x70, 0x6c, 0x61, 0x69, 0x6e, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x73, 0x65,
0x63, 0x72, 0x65, 0x74, 0x2d, 0x31, 0x32, 0x33,
];
#[test]
fn secret_convert_base32_plain() {
let plain_str = String::from(PLAIN);
let base32_str = String::from(BASE32);
let secret_plain = Secret::Plain(plain_str.clone());
let secret_base32 = Secret::Base32(base32_str.clone());
assert_eq!(&secret_plain.as_base32(), &secret_base32);
assert_eq!(&secret_plain.as_plain().unwrap(), &secret_plain);
assert_eq!(&secret_base32.as_plain().unwrap(), &secret_plain);
assert_eq!(&secret_base32.as_base32(), &secret_base32);
}
#[test]
fn secret_as_bytes() {
let plain_str = String::from(PLAIN);
let base32_str = String::from(BASE32);
assert_eq!(Secret::Plain(plain_str).as_bytes().unwrap(), BYTES.to_vec());
assert_eq!(Secret::Base32(base32_str).as_bytes().unwrap(), BYTES.to_vec());
}
#[test]
#[cfg(feature = "gen_secret")]
fn secret_gen_secret() {
match Secret::generate_secret(10) {
Secret::Plain(secret) => assert_eq!(secret.len(), 10),
Secret::Base32(_) => panic!("should be plain"),
}
}
#[test]
#[cfg(feature = "gen_secret")]
fn secret_gen_rfc_secret() {
match Secret::generate_rfc_secret() {
Secret::Plain(secret) => assert_eq!(secret.len(), 20),
Secret::Base32(_) => panic!("should be plain"),
}
}
}