use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use base64::Engine;
use hmac::{Hmac, Mac};
use rand_core::{OsRng, RngCore};
use secrecy::{ExposeSecret, SecretBox};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::DpopError;
pub trait IntoSecretBox {
fn into_secret_box(self) -> SecretBox<[u8]>;
}
impl IntoSecretBox for SecretBox<[u8]> {
fn into_secret_box(self) -> SecretBox<[u8]> {
self
}
}
impl IntoSecretBox for &SecretBox<[u8]> {
fn into_secret_box(self) -> SecretBox<[u8]> {
self.clone()
}
}
impl IntoSecretBox for &[u8] {
fn into_secret_box(self) -> SecretBox<[u8]> {
SecretBox::from(self.to_vec())
}
}
impl<const N: usize> IntoSecretBox for &[u8; N] {
fn into_secret_box(self) -> SecretBox<[u8]> {
SecretBox::from(self.to_vec())
}
}
impl IntoSecretBox for Vec<u8> {
fn into_secret_box(self) -> SecretBox<[u8]> {
SecretBox::from(self)
}
}
impl IntoSecretBox for &Vec<u8> {
fn into_secret_box(self) -> SecretBox<[u8]> {
SecretBox::from(self.clone())
}
}
type HmacSha256 = Hmac<Sha256>;
const NONCE_VERSION: u8 = 1;
const NONCE_RANDOM_LENGTH: usize = 16;
const NONCE_MAC_LENGTH: usize = 16; const NONCE_TOTAL_LENGTH: usize = 1 + 8 + 16 + 16; const NONCE_FUTURE_SKEW_SECS: i64 = 5;
pub struct NonceCtx<'a> {
pub htu: Option<&'a str>,
pub htm: Option<&'a str>,
pub jkt: Option<&'a str>,
pub client: Option<&'a str>,
}
fn ctx_bytes(ctx: &NonceCtx<'_>) -> Vec<u8> {
let mut context_bytes = Vec::new();
if let Some(htu) = ctx.htu {
context_bytes.extend_from_slice(b"HTU\0");
context_bytes.extend_from_slice(htu.as_bytes());
context_bytes.push(0);
}
if let Some(htm) = ctx.htm {
context_bytes.extend_from_slice(b"HTM\0");
context_bytes.extend_from_slice(htm.as_bytes());
context_bytes.push(0);
}
if let Some(jkt) = ctx.jkt {
context_bytes.extend_from_slice(b"JKT\0");
context_bytes.extend_from_slice(jkt.as_bytes());
context_bytes.push(0);
}
if let Some(client_id) = ctx.client {
context_bytes.extend_from_slice(b"CID\0");
context_bytes.extend_from_slice(client_id.as_bytes());
context_bytes.push(0);
}
context_bytes
}
pub fn issue_nonce<S>(secret: S, now_unix: i64, ctx: &NonceCtx<'_>) -> Result<String, DpopError>
where
S: IntoSecretBox,
{
let secret_box = secret.into_secret_box();
let version_bytes = [NONCE_VERSION];
let timestamp_bytes = now_unix.to_be_bytes();
let mut random_bytes = [0u8; NONCE_RANDOM_LENGTH];
OsRng.fill_bytes(&mut random_bytes);
let mut hmac = HmacSha256::new_from_slice(secret_box.expose_secret()).map_err(|_| DpopError::InvalidHmacConfig)?;
hmac.update(&version_bytes);
hmac.update(×tamp_bytes);
hmac.update(&random_bytes);
hmac.update(&ctx_bytes(ctx));
let mac_tag = hmac.finalize().into_bytes();
let mut nonce_bytes = Vec::with_capacity(NONCE_TOTAL_LENGTH);
nonce_bytes.extend_from_slice(&version_bytes);
nonce_bytes.extend_from_slice(×tamp_bytes);
nonce_bytes.extend_from_slice(&random_bytes);
nonce_bytes.extend_from_slice(&mac_tag[..NONCE_MAC_LENGTH]);
Ok(B64.encode(nonce_bytes))
}
pub fn verify_nonce<S>(
secret: S,
nonce_b64: &str,
now_unix: i64,
max_age_secs: i64,
ctx: &NonceCtx<'_>,
) -> Result<(), DpopError>
where
S: IntoSecretBox,
{
let secret_box = secret.into_secret_box();
let nonce_bytes = B64
.decode(nonce_b64.as_bytes())
.map_err(|_| DpopError::NonceMismatch)?;
if nonce_bytes.len() != NONCE_TOTAL_LENGTH {
return Err(DpopError::NonceMismatch);
}
let version = nonce_bytes[0];
if version != NONCE_VERSION {
return Err(DpopError::NonceMismatch);
}
let timestamp_bytes: [u8; 8] = nonce_bytes
.get(1..9)
.and_then(|slice| slice.try_into().ok())
.ok_or(DpopError::NonceMismatch)?;
let timestamp = i64::from_be_bytes(timestamp_bytes);
let random_bytes = nonce_bytes
.get(9..9 + NONCE_RANDOM_LENGTH)
.ok_or(DpopError::NonceMismatch)?;
let mac_from_nonce = nonce_bytes
.get(9 + NONCE_RANDOM_LENGTH..)
.ok_or(DpopError::NonceMismatch)?;
if now_unix - timestamp > max_age_secs {
return Err(DpopError::NonceStale);
}
if timestamp - now_unix > NONCE_FUTURE_SKEW_SECS {
return Err(DpopError::FutureSkew);
}
let mut hmac = HmacSha256::new_from_slice(secret_box.expose_secret()).map_err(|_| DpopError::InvalidHmacConfig)?;
hmac.update(&[version]);
hmac.update(×tamp.to_be_bytes());
hmac.update(random_bytes);
hmac.update(&ctx_bytes(ctx));
let computed_mac = hmac.finalize().into_bytes();
if bool::from(mac_from_nonce.ct_eq(&computed_mac[..NONCE_MAC_LENGTH])) {
Ok(())
} else {
Err(DpopError::NonceMismatch)
}
}
pub fn verify_nonce_with_any<S>(
secrets: &[S],
nonce_b64: &str,
now_unix: i64,
max_age_secs: i64,
ctx: &NonceCtx<'_>,
) -> Result<(), DpopError>
where
S: IntoSecretBox + Clone,
{
let mut last_error = DpopError::NonceMismatch;
for secret in secrets {
match verify_nonce(secret.clone(), nonce_b64, now_unix, max_age_secs, ctx) {
Ok(()) => return Ok(()),
Err(error @ DpopError::FutureSkew)
| Err(error @ DpopError::NonceStale)
| Err(error @ DpopError::NonceMismatch) => last_error = error,
Err(error) => return Err(error),
}
}
Err(last_error)
}
#[cfg(test)]
mod tests {
use super::*;
use time::OffsetDateTime;
#[test]
fn roundtrip_ok_with_binding() {
let secret = SecretBox::from(b"supersecretkey".to_vec());
let current_time = OffsetDateTime::now_utc().unix_timestamp();
let context = NonceCtx {
htu: Some("https://ex.com/a"),
htm: Some("POST"),
jkt: Some("thumb"),
client: None,
};
let nonce = issue_nonce(&secret, current_time, &context).expect("issue_nonce");
assert!(verify_nonce(&secret, &nonce, current_time, 300, &context).is_ok());
}
#[test]
fn bad_ctx_fails() {
let secret = SecretBox::from(b"k".to_vec());
let current_time = OffsetDateTime::now_utc().unix_timestamp();
let original_context = NonceCtx {
htu: Some("https://ex.com/a"),
htm: Some("GET"),
jkt: Some("t"),
client: None,
};
let nonce = issue_nonce(&secret, current_time, &original_context).expect("issue_nonce");
let different_context = NonceCtx {
htu: Some("https://ex.com/b"),
htm: Some("GET"),
jkt: Some("t"),
client: None,
};
assert!(matches!(
verify_nonce(&secret, &nonce, current_time, 300, &different_context),
Err(DpopError::NonceMismatch)
));
}
#[test]
fn stale_and_future_skew() {
let secret = SecretBox::from(b"k2".to_vec());
let current_time = OffsetDateTime::now_utc().unix_timestamp();
let empty_context = NonceCtx {
htu: None,
htm: None,
jkt: None,
client: None,
};
let future_nonce =
issue_nonce(&secret, current_time + 10, &empty_context).expect("issue_nonce");
assert!(matches!(
verify_nonce(&secret, &future_nonce, current_time, 300, &empty_context),
Err(DpopError::FutureSkew)
));
let stale_nonce =
issue_nonce(&secret, current_time - 301, &empty_context).expect("issue_nonce");
assert!(matches!(
verify_nonce(&secret, &stale_nonce, current_time, 300, &empty_context),
Err(DpopError::NonceStale)
));
}
#[test]
fn rotation_any_secret() {
let current_secret = SecretBox::from(b"current".to_vec());
let previous_secret = SecretBox::from(b"previous".to_vec());
let current_time = OffsetDateTime::now_utc().unix_timestamp();
let context = NonceCtx {
htu: Some("u"),
htm: Some("M"),
jkt: None,
client: None,
};
let nonce_from_previous =
issue_nonce(&previous_secret, current_time, &context).expect("issue_nonce");
assert!(verify_nonce_with_any(
&[¤t_secret, &previous_secret],
&nonce_from_previous,
current_time,
300,
&context
)
.is_ok());
let nonce_from_current =
issue_nonce(¤t_secret, current_time, &context).expect("issue_nonce");
assert!(verify_nonce_with_any(
&[¤t_secret, &previous_secret],
&nonce_from_current,
current_time,
300,
&context
)
.is_ok());
}
#[test]
fn accepts_non_boxed_types() {
let secret_bytes = b"test-secret";
let current_time = OffsetDateTime::now_utc().unix_timestamp();
let context = NonceCtx {
htu: Some("https://ex.com/a"),
htm: Some("GET"),
jkt: Some("thumb"),
client: None,
};
let nonce = issue_nonce(secret_bytes.as_slice(), current_time, &context).expect("issue_nonce");
assert!(verify_nonce(secret_bytes.as_slice(), &nonce, current_time, 300, &context).is_ok());
let secret_vec = b"test-secret-vec".to_vec();
let nonce2 = issue_nonce(&secret_vec, current_time, &context).expect("issue_nonce");
assert!(verify_nonce(&secret_vec, &nonce2, current_time, 300, &context).is_ok());
}
}