use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD};
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::Engine;
use ed25519_dalek::{Signature, Signer};
use sha2::{Digest, Sha256};
pub use ed25519_dalek::{SigningKey, VerifyingKey};
pub const CONTENT_DIGEST_HEADER: &str = "content-digest";
pub const SIGNATURE_INPUT_HEADER: &str = "signature-input";
pub const SIGNATURE_HEADER: &str = "signature";
pub const LABEL: &str = "sig1";
pub const ALGORITHM: &str = "ed25519";
pub const COVERED_COMPONENTS: [&str; 6] = [
"@method",
"@authority",
"@path",
"@query",
CONTENT_DIGEST_HEADER,
crate::PROTOCOL_HEADER,
];
pub const WINDOW_SECONDS: u64 = 60;
pub const MAX_AHEAD_SECONDS: u64 = 5;
pub const MAX_NONCE_LEN: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SignatureError {
#[error("malformed {0} header")]
Malformed(&'static str),
#[error("no signature labelled sig1")]
NoLabel,
#[error("the signature has no {0} parameter")]
MissingParameter(&'static str),
#[error(
"the signature must cover @method, @authority, @path, @query, content-digest and recall-protocol"
)]
NotCovered,
#[error("the signature covers {0} twice")]
Duplicate(String),
#[error("the signature covers {0}, which this request does not have")]
MissingComponent(String),
#[error("unsupported signature algorithm {0:?}")]
Algorithm(String),
#[error("the nonce must be 1 to 128 characters")]
Nonce,
#[error(
"signature created {skew} seconds from the server's clock, more than the {window} allowed; check this machine's clock"
)]
Clock {
skew: u64,
window: u64,
},
#[error(
"signature created {ahead} seconds ahead of the server's clock, more than the {allowed} allowed; check this machine's clock"
)]
Ahead {
ahead: u64,
allowed: u64,
},
#[error("the signature has expired")]
Expired,
#[error("content-digest has no sha-256 value")]
NoDigest,
#[error("content-digest does not match the body")]
DigestMismatch,
#[error("the signature base is not ASCII")]
NotAscii,
#[error("the signature does not verify")]
BadSignature,
#[error("public_key must be an Ed25519 public key: 32 bytes, base64url without padding")]
PublicKey,
}
pub fn encode_public_key(key: &VerifyingKey) -> String {
URL_SAFE_NO_PAD.encode(key.as_bytes())
}
pub fn parse_public_key(text: &str) -> Result<VerifyingKey, SignatureError> {
let bytes = URL_SAFE_NO_PAD
.decode(text.trim())
.map_err(|_| SignatureError::PublicKey)?;
let raw: [u8; 32] = bytes.try_into().map_err(|_| SignatureError::PublicKey)?;
let key = VerifyingKey::from_bytes(&raw).map_err(|_| SignatureError::PublicKey)?;
if key.is_weak() {
return Err(SignatureError::PublicKey);
}
Ok(key)
}
pub fn fingerprint(key: &VerifyingKey) -> String {
format!(
"SHA256:{}",
STANDARD_NO_PAD.encode(Sha256::digest(key.as_bytes()))
)
}
pub fn content_digest(body: &[u8]) -> String {
format!("sha-256=:{}:", STANDARD.encode(Sha256::digest(body)))
}
pub fn check_content_digest(field: &str, body: &[u8]) -> Result<(), SignatureError> {
if sha256_of(field)?.as_slice() == Sha256::digest(body).as_slice() {
Ok(())
} else {
Err(SignatureError::DigestMismatch)
}
}
fn sha256_of(field: &str) -> Result<Vec<u8>, SignatureError> {
let dict = sf::dictionary(field).ok_or(SignatureError::Malformed("content-digest"))?;
dict.into_iter()
.find(|(k, _)| k == "sha-256")
.and_then(|(_, m)| match m {
sf::Member::Item(sf::Item::Bytes(b), _) => Some(b),
_ => None,
})
.ok_or(SignatureError::NoDigest)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target<'a> {
pub method: &'a str,
pub authority: &'a str,
pub path: &'a str,
pub query: Option<&'a str>,
}
impl Target<'_> {
fn derived(&self, name: &str) -> Option<String> {
match name {
"@method" => Some(self.method.to_string()),
"@authority" => Some(self.authority.to_string()),
"@path" => Some(if self.path.is_empty() {
"/".to_string()
} else {
self.path.to_string()
}),
"@query" => Some(format!("?{}", self.query.unwrap_or(""))),
_ => None,
}
}
}
pub fn normalize_authority(host: &str) -> String {
let host = host.trim().to_ascii_lowercase();
for default in [":443", ":80"] {
if let Some(stripped) = host.strip_suffix(default) {
return stripped.to_string();
}
}
host
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Param {
Integer(i64),
String(String),
Token(String),
Boolean(bool),
Bytes(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignatureInput {
pub components: Vec<String>,
pub params: Vec<(String, Param)>,
}
impl SignatureInput {
pub fn recall(created: i64, keyid: &str, nonce: &str) -> Self {
Self {
components: COVERED_COMPONENTS.iter().map(|c| c.to_string()).collect(),
params: vec![
("created".to_string(), Param::Integer(created)),
("keyid".to_string(), Param::String(keyid.to_string())),
("nonce".to_string(), Param::String(nonce.to_string())),
("alg".to_string(), Param::String(ALGORITHM.to_string())),
],
}
}
pub fn parse(field: &str, label: &str) -> Result<Self, SignatureError> {
const WHAT: &str = "signature-input";
let dict = sf::dictionary(field).ok_or(SignatureError::Malformed(WHAT))?;
let (items, params) = match dict.into_iter().find(|(k, _)| k == label) {
Some((_, sf::Member::InnerList(items, params))) => (items, params),
Some(_) => return Err(SignatureError::Malformed(WHAT)),
None => return Err(SignatureError::NoLabel),
};
let mut components = Vec::with_capacity(items.len());
for (item, item_params) in items {
match item {
sf::Item::String(name) if item_params.is_empty() => components.push(name),
_ => return Err(SignatureError::Malformed(WHAT)),
}
}
Ok(Self {
components,
params: params.into_iter().map(|(k, v)| (k, v.into())).collect(),
})
}
pub fn serialize(&self) -> String {
let mut out = String::from("(");
for (i, c) in self.components.iter().enumerate() {
if i > 0 {
out.push(' ');
}
sf::write_string(&mut out, c);
}
out.push(')');
for (key, value) in &self.params {
out.push(';');
out.push_str(key);
match value {
Param::Boolean(true) => {}
Param::Boolean(false) => out.push_str("=?0"),
Param::Integer(n) => {
out.push('=');
out.push_str(&n.to_string());
}
Param::String(s) => {
out.push('=');
sf::write_string(&mut out, s);
}
Param::Token(t) => {
out.push('=');
out.push_str(t);
}
Param::Bytes(b) => {
out.push_str("=:");
out.push_str(&STANDARD.encode(b));
out.push(':');
}
}
}
out
}
fn param(&self, name: &str) -> Option<&Param> {
self.params.iter().find(|(k, _)| k == name).map(|(_, v)| v)
}
fn string_param(&self, name: &str) -> Option<&str> {
match self.param(name)? {
Param::String(s) => Some(s),
_ => None,
}
}
fn integer_param(&self, name: &str) -> Option<i64> {
match self.param(name)? {
Param::Integer(n) => Some(*n),
_ => None,
}
}
pub fn created(&self) -> Option<i64> {
self.integer_param("created")
}
pub fn keyid(&self) -> Option<&str> {
self.string_param("keyid")
}
pub fn nonce(&self) -> Option<&str> {
self.string_param("nonce")
}
pub fn alg(&self) -> Option<&str> {
self.string_param("alg")
}
pub fn signature_base(
&self,
value: impl Fn(&str) -> Option<String>,
) -> Result<String, SignatureError> {
let mut out = String::new();
let mut seen: Vec<&str> = Vec::with_capacity(self.components.len());
for name in &self.components {
if seen.contains(&name.as_str()) {
return Err(SignatureError::Duplicate(name.clone()));
}
seen.push(name);
let v = value(name).ok_or_else(|| SignatureError::MissingComponent(name.clone()))?;
if v.contains('\n') || v.contains('\r') {
return Err(SignatureError::MissingComponent(name.clone()));
}
sf::write_string(&mut out, name);
out.push_str(": ");
out.push_str(&v);
out.push('\n');
}
out.push_str("\"@signature-params\": ");
out.push_str(&self.serialize());
if !out.is_ascii() {
return Err(SignatureError::NotAscii);
}
Ok(out)
}
pub fn check_profile(&self, now: i64, window: u64) -> Result<(), SignatureError> {
if let Some(param) = self.param("alg") {
match param {
Param::String(a) if a == ALGORITHM => {}
Param::String(a) => return Err(SignatureError::Algorithm(a.clone())),
_ => return Err(SignatureError::Algorithm(String::new())),
}
}
if self.keyid().is_none() {
return Err(SignatureError::MissingParameter("keyid"));
}
let nonce = self
.nonce()
.ok_or(SignatureError::MissingParameter("nonce"))?;
if nonce.is_empty() || nonce.len() > MAX_NONCE_LEN {
return Err(SignatureError::Nonce);
}
let created = self
.created()
.ok_or(SignatureError::MissingParameter("created"))?;
let skew = now.abs_diff(created);
if created > now && skew > MAX_AHEAD_SECONDS {
return Err(SignatureError::Ahead {
ahead: skew,
allowed: MAX_AHEAD_SECONDS,
});
}
if skew > window {
return Err(SignatureError::Clock { skew, window });
}
if let Some(expires) = self.integer_param("expires") {
if expires < now {
return Err(SignatureError::Expired);
}
}
if !COVERED_COMPONENTS
.iter()
.all(|c| self.components.iter().any(|have| have == c))
{
return Err(SignatureError::NotCovered);
}
Ok(())
}
}
impl From<sf::Item> for Param {
fn from(item: sf::Item) -> Self {
match item {
sf::Item::Integer(n) => Param::Integer(n),
sf::Item::String(s) => Param::String(s),
sf::Item::Token(t) => Param::Token(t),
sf::Item::Boolean(b) => Param::Boolean(b),
sf::Item::Bytes(b) => Param::Bytes(b),
}
}
}
pub fn parse_signature(field: &str, label: &str) -> Result<Vec<u8>, SignatureError> {
let dict = sf::dictionary(field).ok_or(SignatureError::Malformed("signature"))?;
match dict.into_iter().find(|(k, _)| k == label) {
Some((_, sf::Member::Item(sf::Item::Bytes(b), _))) => Ok(b),
Some(_) => Err(SignatureError::Malformed("signature")),
None => Err(SignatureError::NoLabel),
}
}
pub fn sign(key: &SigningKey, base: &str) -> Vec<u8> {
key.sign(base.as_bytes()).to_bytes().to_vec()
}
pub fn verify(key: &VerifyingKey, base: &str, signature: &[u8]) -> Result<(), SignatureError> {
let bytes: [u8; 64] = signature
.try_into()
.map_err(|_| SignatureError::BadSignature)?;
key.verify_strict(base.as_bytes(), &Signature::from_bytes(&bytes))
.map_err(|_| SignatureError::BadSignature)
}
fn component_value(
target: &Target<'_>,
field: &dyn Fn(&str) -> Option<String>,
name: &str,
) -> Option<String> {
if name.starts_with('@') {
target.derived(name)
} else {
field(name).map(|v| v.trim().to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedHeaders {
pub content_digest: String,
pub signature_input: String,
pub signature: String,
}
pub fn sign_request(
key: &SigningKey,
keyid: &str,
target: &Target<'_>,
protocol: &str,
body: &[u8],
created: i64,
nonce: &str,
) -> Result<SignedHeaders, SignatureError> {
let digest = content_digest(body);
let input = SignatureInput::recall(created, keyid, nonce);
let field = |name: &str| match name {
CONTENT_DIGEST_HEADER => Some(digest.clone()),
crate::PROTOCOL_HEADER => Some(protocol.to_string()),
_ => None,
};
let base = input.signature_base(|name| component_value(target, &field, name))?;
Ok(SignedHeaders {
signature_input: format!("{LABEL}={}", input.serialize()),
signature: format!("{LABEL}=:{}:", STANDARD.encode(sign(key, &base))),
content_digest: digest,
})
}
pub struct Received<'a> {
pub input: &'a SignatureInput,
pub signature: &'a [u8],
pub target: Target<'a>,
pub field: &'a dyn Fn(&str) -> Option<String>,
}
pub fn verify_headers(
req: &Received<'_>,
key: &VerifyingKey,
now: i64,
window: u64,
) -> Result<(), SignatureError> {
verify_headers_and_base(req, key, now, window).map(|_base| ())
}
pub fn verify_headers_and_base(
req: &Received<'_>,
key: &VerifyingKey,
now: i64,
window: u64,
) -> Result<String, SignatureError> {
req.input.check_profile(now, window)?;
let digest = (req.field)(CONTENT_DIGEST_HEADER)
.ok_or_else(|| SignatureError::MissingComponent(CONTENT_DIGEST_HEADER.to_string()))?;
sha256_of(&digest)?;
let base = req
.input
.signature_base(|name| component_value(&req.target, req.field, name))?;
verify(key, &base, req.signature)?;
Ok(base)
}
pub fn content_digest_base64(field: &str) -> Result<String, SignatureError> {
Ok(STANDARD.encode(sha256_of(field)?))
}
pub fn verify_request(
req: &Received<'_>,
body: &[u8],
key: &VerifyingKey,
now: i64,
window: u64,
) -> Result<(), SignatureError> {
verify_headers(req, key, now, window)?;
let digest = (req.field)(CONTENT_DIGEST_HEADER).unwrap_or_default();
check_content_digest(&digest, body)
}
mod sf {
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Item {
Integer(i64),
String(String),
Token(String),
Boolean(bool),
Bytes(Vec<u8>),
}
pub(super) type Params = Vec<(String, Item)>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Member {
Item(Item, Params),
InnerList(Vec<(Item, Params)>, Params),
}
pub(super) fn write_string(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
out.push('"');
}
struct Parser<'a> {
s: &'a [u8],
at: usize,
}
pub(super) fn dictionary(input: &str) -> Option<Vec<(String, Member)>> {
if !input.is_ascii() {
return None;
}
let mut p = Parser {
s: input.as_bytes(),
at: 0,
};
p.skip_sp();
let mut dict: Vec<(String, Member)> = Vec::new();
while !p.done() {
let key = p.key()?;
let member = if p.eat(b'=') {
p.item_or_inner_list()?
} else {
Member::Item(Item::Boolean(true), p.params()?)
};
match dict.iter_mut().find(|(k, _)| *k == key) {
Some(existing) => existing.1 = member,
None => dict.push((key, member)),
}
p.skip_ows();
if p.done() {
break;
}
if !p.eat(b',') {
return None;
}
p.skip_ows();
if p.done() {
return None;
}
}
Some(dict)
}
impl Parser<'_> {
fn done(&self) -> bool {
self.at >= self.s.len()
}
fn peek(&self) -> Option<u8> {
self.s.get(self.at).copied()
}
fn eat(&mut self, c: u8) -> bool {
if self.peek() == Some(c) {
self.at += 1;
true
} else {
false
}
}
fn skip_sp(&mut self) {
while self.peek() == Some(b' ') {
self.at += 1;
}
}
fn skip_ows(&mut self) {
while matches!(self.peek(), Some(b' ' | b'\t')) {
self.at += 1;
}
}
fn key(&mut self) -> Option<String> {
let start = self.at;
match self.peek()? {
b'a'..=b'z' | b'*' => self.at += 1,
_ => return None,
}
while matches!(
self.peek(),
Some(b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'*')
) {
self.at += 1;
}
Some(String::from_utf8_lossy(&self.s[start..self.at]).into_owned())
}
fn item_or_inner_list(&mut self) -> Option<Member> {
if self.eat(b'(') {
let mut items = Vec::new();
loop {
self.skip_sp();
if self.eat(b')') {
return Some(Member::InnerList(items, self.params()?));
}
if self.done() {
return None;
}
let item = self.bare_item()?;
items.push((item, self.params()?));
if !matches!(self.peek(), Some(b' ' | b')')) {
return None;
}
}
}
let item = self.bare_item()?;
Some(Member::Item(item, self.params()?))
}
fn params(&mut self) -> Option<Params> {
let mut params: Params = Vec::new();
while self.eat(b';') {
self.skip_sp();
let key = self.key()?;
let value = if self.eat(b'=') {
self.bare_item()?
} else {
Item::Boolean(true)
};
match params.iter_mut().find(|(k, _)| *k == key) {
Some(existing) => existing.1 = value,
None => params.push((key, value)),
}
}
Some(params)
}
fn bare_item(&mut self) -> Option<Item> {
match self.peek()? {
b'-' | b'0'..=b'9' => self.integer(),
b'"' => self.string(),
b':' => self.bytes(),
b'?' => self.boolean(),
b'A'..=b'Z' | b'a'..=b'z' | b'*' => self.token(),
_ => None,
}
}
fn integer(&mut self) -> Option<Item> {
let negative = self.eat(b'-');
let start = self.at;
while matches!(self.peek(), Some(b'0'..=b'9')) {
self.at += 1;
}
let digits = &self.s[start..self.at];
if digits.is_empty() || digits.len() > 15 || self.peek() == Some(b'.') {
return None;
}
let n: i64 = std::str::from_utf8(digits).ok()?.parse().ok()?;
Some(Item::Integer(if negative { -n } else { n }))
}
fn string(&mut self) -> Option<Item> {
self.at += 1;
let mut out = String::new();
loop {
let c = self.peek()?;
self.at += 1;
match c {
b'"' => return Some(Item::String(out)),
b'\\' => match self.peek()? {
c @ (b'"' | b'\\') => {
self.at += 1;
out.push(c as char);
}
_ => return None,
},
0x20..=0x7e => out.push(c as char),
_ => return None,
}
}
}
fn token(&mut self) -> Option<Item> {
let start = self.at;
self.at += 1;
while let Some(c) = self.peek() {
let tchar = c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~:/".contains(&c);
if !tchar {
break;
}
self.at += 1;
}
Some(Item::Token(
String::from_utf8_lossy(&self.s[start..self.at]).into_owned(),
))
}
fn bytes(&mut self) -> Option<Item> {
self.at += 1;
let start = self.at;
while self.peek()? != b':' {
let c = self.peek()?;
if !(c.is_ascii_alphanumeric() || c == b'+' || c == b'/' || c == b'=') {
return None;
}
self.at += 1;
}
let b64 = &self.s[start..self.at];
self.at += 1;
const LENIENT: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_decode_padding_mode(DecodePaddingMode::Indifferent)
.with_decode_allow_trailing_bits(true),
);
LENIENT.decode(b64).ok().map(Item::Bytes)
}
fn boolean(&mut self) -> Option<Item> {
self.at += 1;
let v = match self.peek()? {
b'0' => false,
b'1' => true,
_ => return None,
};
self.at += 1;
Some(Item::Boolean(v))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY_ED25519_D: &str = "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU";
const TEST_KEY_ED25519_X: &str = "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs";
const TEST_KEY_ED25519_PUBLIC_PEM: &str =
"MCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=";
const TEST_KEY_ED25519_PRIVATE_PEM: &str =
"MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF";
fn test_key() -> SigningKey {
let d: [u8; 32] = URL_SAFE_NO_PAD
.decode(TEST_KEY_ED25519_D)
.unwrap()
.try_into()
.unwrap();
SigningKey::from_bytes(&d)
}
#[test]
fn rfc9421_b_1_4_test_key_ed25519_is_one_key_in_both_encodings() {
let key = test_key();
assert_eq!(encode_public_key(&key.verifying_key()), TEST_KEY_ED25519_X);
let public_der = STANDARD.decode(TEST_KEY_ED25519_PUBLIC_PEM).unwrap();
assert_eq!(
&public_der[public_der.len() - 32..],
key.verifying_key().as_bytes()
);
let private_der = STANDARD.decode(TEST_KEY_ED25519_PRIVATE_PEM).unwrap();
assert_eq!(
&private_der[private_der.len() - 32..],
key.to_bytes().as_slice()
);
assert_eq!(
parse_public_key(TEST_KEY_ED25519_X).unwrap(),
key.verifying_key()
);
}
#[test]
fn rfc9421_b_2_6_signing_a_request_using_ed25519() {
let target = Target {
method: "POST",
authority: &normalize_authority("example.com"),
path: "/foo",
query: Some("param=Value&Pet=dog"),
};
let field = |name: &str| -> Option<String> {
match name {
"date" => Some("Tue, 20 Apr 2021 02:07:55 GMT".into()),
"content-type" => Some("application/json".into()),
"content-length" => Some("18".into()),
_ => None,
}
};
let signature_input = concat!(
r#"sig-b26=("date" "@method" "@path" "@authority" "#,
r#""content-type" "content-length");created=1618884473"#,
r#";keyid="test-key-ed25519""#,
);
let input = SignatureInput::parse(signature_input, "sig-b26").unwrap();
assert_eq!(format!("sig-b26={}", input.serialize()), signature_input);
let base = input
.signature_base(|name| component_value(&target, &field, name))
.unwrap();
let want_base = concat!(
"\"date\": Tue, 20 Apr 2021 02:07:55 GMT\n",
"\"@method\": POST\n",
"\"@path\": /foo\n",
"\"@authority\": example.com\n",
"\"content-type\": application/json\n",
"\"content-length\": 18\n",
r#""@signature-params": ("date" "@method" "@path" "@authority" "#,
r#""content-type" "content-length");created=1618884473"#,
r#";keyid="test-key-ed25519""#,
);
assert_eq!(base, want_base);
let signature_field = concat!(
"sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1",
"u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:",
);
let want = parse_signature(signature_field, "sig-b26").unwrap();
assert_eq!(sign(&test_key(), &base), want);
verify(&test_key().verifying_key(), &base, &want).unwrap();
let mut tampered = want.clone();
tampered[0] ^= 1;
assert_eq!(
verify(&test_key().verifying_key(), &base, &tampered),
Err(SignatureError::BadSignature)
);
}
#[test]
fn rfc9530_content_digest_examples() {
assert_eq!(
content_digest(b"{\"hello\": \"world\"}\n"),
"sha-256=:RK/0qy18MlBSVnWgjwz6lZEWjP/lF5HF9bvEF8FabDg=:"
);
assert_eq!(
content_digest(b""),
"sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"
);
}
#[test]
fn a_digest_without_sha_256_is_named_as_such() {
let field = concat!(
"sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+T",
"aPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:",
);
assert_eq!(
check_content_digest(field, b"{\"hello\": \"world\"}"),
Err(SignatureError::NoDigest)
);
}
const NOW: i64 = 1_790_000_000;
fn target(query: Option<&'static str>) -> Target<'static> {
Target {
method: "GET",
authority: "recall.example.com",
path: "/sync",
query,
}
}
fn headers(signed: &SignedHeaders) -> impl Fn(&str) -> Option<String> + '_ {
move |name: &str| match name {
CONTENT_DIGEST_HEADER => Some(signed.content_digest.clone()),
SIGNATURE_INPUT_HEADER => Some(signed.signature_input.clone()),
SIGNATURE_HEADER => Some(signed.signature.clone()),
crate::PROTOCOL_HEADER => Some("1".to_string()),
_ => None,
}
}
fn check(
signed: &SignedHeaders,
target: &Target<'_>,
body: &[u8],
now: i64,
) -> Result<(), SignatureError> {
let field = headers(signed);
let input = SignatureInput::parse(&field(SIGNATURE_INPUT_HEADER).unwrap(), LABEL)?;
let signature = parse_signature(&field(SIGNATURE_HEADER).unwrap(), LABEL)?;
let received = Received {
input: &input,
signature: &signature,
target: *target,
field: &field,
};
verify_request(
&received,
body,
&test_key().verifying_key(),
now,
WINDOW_SECONDS,
)
}
#[test]
fn a_signed_request_has_the_documented_shape_and_verifies() {
let t = target(Some("project_key=acme%2Fapp"));
let signed = sign_request(&test_key(), "dev_abc", &t, "1", b"", NOW, "n0nce").unwrap();
assert_eq!(
signed.signature_input,
concat!(
r#"sig1=("@method" "@authority" "@path" "@query" "content-digest" "recall-protocol")"#,
r#";created=1790000000;keyid="dev_abc";nonce="n0nce";alg="ed25519""#,
)
);
assert_eq!(
signed.content_digest,
"sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"
);
assert!(signed.signature.starts_with("sig1=:") && signed.signature.ends_with(':'));
check(&signed, &t, b"", NOW).unwrap();
}
#[test]
fn every_covered_part_of_the_request_is_bound() {
let body = br#"{"project_key":"acme/app"}"#;
let t = Target {
method: "POST",
authority: "recall.example.com",
path: "/sync",
query: None,
};
let signed = sign_request(&test_key(), "dev_abc", &t, "1", body, NOW, "n").unwrap();
check(&signed, &t, body, NOW).unwrap();
let other = [
Target { method: "PUT", ..t },
Target {
authority: "evil.example.com",
..t
},
Target {
path: "/admin/stats",
..t
},
Target {
query: Some("x=1"),
..t
},
];
for changed in other {
assert_eq!(
check(&signed, &changed, body, NOW),
Err(SignatureError::BadSignature),
"{changed:?}"
);
}
assert_eq!(
check(&signed, &t, b"{\"project_key\":\"evil\"}", NOW),
Err(SignatureError::DigestMismatch),
"a different body"
);
let field = |name: &str| match name {
crate::PROTOCOL_HEADER => Some("2".to_string()),
other => headers(&signed)(other),
};
let input = SignatureInput::parse(&signed.signature_input, LABEL).unwrap();
let sig = parse_signature(&signed.signature, LABEL).unwrap();
let received = Received {
input: &input,
signature: &sig,
target: t,
field: &field,
};
assert_eq!(
verify_request(
&received,
body,
&test_key().verifying_key(),
NOW,
WINDOW_SECONDS
),
Err(SignatureError::BadSignature)
);
}
#[test]
fn created_must_be_inside_the_window() {
let t = target(None);
let signed = sign_request(&test_key(), "dev_abc", &t, "1", b"", NOW, "n").unwrap();
check(&signed, &t, b"", NOW + 60).unwrap();
check(&signed, &t, b"", NOW - 5).unwrap();
assert_eq!(
check(&signed, &t, b"", NOW + 61),
Err(SignatureError::Clock {
skew: 61,
window: 60
})
);
assert_eq!(
check(&signed, &t, b"", NOW - 6),
Err(SignatureError::Ahead {
ahead: 6,
allowed: MAX_AHEAD_SECONDS
})
);
for verifier in [NOW - 60, NOW - 3600] {
assert!(matches!(
check(&signed, &t, b"", verifier),
Err(SignatureError::Ahead { .. })
));
}
assert!(matches!(
check(&signed, &t, b"", NOW + 3600),
Err(SignatureError::Clock { .. })
));
}
#[test]
fn a_signature_with_a_small_order_r_is_refused() {
use curve25519_dalek::Scalar;
use ed25519_dalek::Verifier;
use sha2::Sha512;
let key = test_key();
let public = key.verifying_key();
let base = "\"@method\": GET";
let mut r = [0u8; 32];
r[0] = 1; let k = Scalar::from_bytes_mod_order_wide(
&Sha512::new()
.chain_update(r)
.chain_update(public.as_bytes())
.chain_update(base.as_bytes())
.finalize()
.into(),
);
let s = k * key.to_scalar();
let mut forged = [0u8; 64];
forged[..32].copy_from_slice(&r);
forged[32..].copy_from_slice(s.as_bytes());
assert!(
public
.verify(base.as_bytes(), &Signature::from_bytes(&forged))
.is_ok(),
"plain verification accepts it, which is the point of the test"
);
assert_eq!(
verify(&public, base, &forged),
Err(SignatureError::BadSignature)
);
}
#[test]
fn a_signature_whose_s_is_not_reduced_is_refused() {
use curve25519_dalek::Scalar;
let l: [u8; 32] = [
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9,
0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10,
];
assert_eq!(Scalar::from_bytes_mod_order(l), Scalar::ZERO, "that is L");
let key = test_key();
let base = "\"@method\": GET";
let good = sign(&key, base);
let mut bad = good.clone();
let mut carry = 0u16;
for i in 0..32 {
let sum = u16::from(good[32 + i]) + u16::from(l[i]) + carry;
bad[32 + i] = sum as u8;
carry = sum >> 8;
}
assert_eq!(carry, 0, "S + L fits in 32 bytes");
verify(&key.verifying_key(), base, &good).unwrap();
assert_eq!(
verify(&key.verifying_key(), base, &bad),
Err(SignatureError::BadSignature)
);
}
#[test]
fn another_key_does_not_verify() {
let t = target(None);
let other = SigningKey::from_bytes(&[7; 32]);
let signed = sign_request(&other, "dev_abc", &t, "1", b"", NOW, "n").unwrap();
assert_eq!(
check(&signed, &t, b"", NOW),
Err(SignatureError::BadSignature)
);
}
#[test]
fn the_profile_requires_every_component_and_parameter() {
let full = SignatureInput::recall(NOW, "dev_abc", "n");
full.check_profile(NOW, 60).unwrap();
let mut missing = full.clone();
missing.components.retain(|c| c != "@query");
assert_eq!(
missing.check_profile(NOW, 60),
Err(SignatureError::NotCovered)
);
for (param, want) in [
("keyid", SignatureError::MissingParameter("keyid")),
("nonce", SignatureError::MissingParameter("nonce")),
("created", SignatureError::MissingParameter("created")),
] {
let mut without = full.clone();
without.params.retain(|(k, _)| k != param);
assert_eq!(without.check_profile(NOW, 60), Err(want), "{param}");
}
let mut no_alg = full.clone();
no_alg.params.retain(|(k, _)| k != "alg");
no_alg.check_profile(NOW, 60).unwrap();
let mut rsa = full.clone();
rsa.params[3].1 = Param::String("rsa-pss-sha512".into());
assert_eq!(
rsa.check_profile(NOW, 60),
Err(SignatureError::Algorithm("rsa-pss-sha512".into()))
);
let long = SignatureInput::recall(NOW, "dev_abc", &"n".repeat(MAX_NONCE_LEN + 1));
assert_eq!(long.check_profile(NOW, 60), Err(SignatureError::Nonce));
let empty = SignatureInput::recall(NOW, "dev_abc", "");
assert_eq!(empty.check_profile(NOW, 60), Err(SignatureError::Nonce));
let mut expired = full.clone();
expired
.params
.push(("expires".into(), Param::Integer(NOW - 1)));
assert_eq!(expired.check_profile(NOW, 60), Err(SignatureError::Expired));
}
#[test]
fn a_component_listed_twice_is_refused() {
let mut twice = SignatureInput::recall(NOW, "dev_abc", "n");
twice.components.push("@method".into());
let t = target(None);
assert_eq!(
twice.signature_base(|n| component_value(&t, &|_| Some("1".into()), n)),
Err(SignatureError::Duplicate("@method".into()))
);
}
#[test]
fn an_unknown_derived_component_or_absent_header_is_refused() {
let t = target(None);
let input = SignatureInput {
components: vec!["@target-uri".into()],
params: vec![],
};
assert_eq!(
input.signature_base(|n| component_value(&t, &|_| None, n)),
Err(SignatureError::MissingComponent("@target-uri".into()))
);
let input = SignatureInput {
components: vec!["recall-protocol".into()],
params: vec![],
};
assert_eq!(
input.signature_base(|n| component_value(&t, &|_| None, n)),
Err(SignatureError::MissingComponent("recall-protocol".into()))
);
}
#[test]
fn query_and_path_follow_rfc9421() {
let t = Target {
method: "GET",
authority: "h",
path: "",
query: None,
};
assert_eq!(t.derived("@path").as_deref(), Some("/"));
assert_eq!(t.derived("@query").as_deref(), Some("?"));
let t = Target {
query: Some("param=value&foo=bar&baz=bat%2Dman"),
..t
};
assert_eq!(
t.derived("@query").as_deref(),
Some("?param=value&foo=bar&baz=bat%2Dman")
);
}
#[test]
fn authority_is_lowercased_without_a_default_port() {
assert_eq!(
normalize_authority("Recall.Example.COM"),
"recall.example.com"
);
assert_eq!(
normalize_authority("recall.example.com:443"),
"recall.example.com"
);
assert_eq!(normalize_authority("127.0.0.1:80"), "127.0.0.1");
assert_eq!(normalize_authority("127.0.0.1:8787"), "127.0.0.1:8787");
assert_eq!(normalize_authority("[::1]:443"), "[::1]");
}
#[test]
fn public_keys_are_32_bytes_of_base64url_and_never_weak() {
assert!(parse_public_key(TEST_KEY_ED25519_X).is_ok());
for bad in [
"",
"not base64!",
"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs=",
"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0",
] {
assert_eq!(
parse_public_key(bad),
Err(SignatureError::PublicKey),
"{bad:?}"
);
}
let mut identity = [0u8; 32];
identity[0] = 1;
assert_eq!(
parse_public_key(&URL_SAFE_NO_PAD.encode(identity)),
Err(SignatureError::PublicKey)
);
}
#[test]
fn a_fingerprint_is_the_sha256_of_the_raw_key() {
let key = test_key().verifying_key();
let want = format!(
"SHA256:{}",
STANDARD_NO_PAD.encode(Sha256::digest(key.as_bytes()))
);
assert_eq!(fingerprint(&key), want);
assert!(!fingerprint(&key).contains('='));
}
#[test]
fn dictionaries_parse_per_rfc8941() {
let d = sf::dictionary(r#"a=1, b="x\"y", c=:AQI=:;p, d=(1 "two");q=?0, e"#).unwrap();
assert_eq!(d.len(), 5);
assert_eq!(
d[0],
("a".into(), sf::Member::Item(sf::Item::Integer(1), vec![]))
);
assert_eq!(
d[1],
(
"b".into(),
sf::Member::Item(sf::Item::String("x\"y".into()), vec![])
)
);
assert_eq!(
d[2],
(
"c".into(),
sf::Member::Item(
sf::Item::Bytes(vec![1, 2]),
vec![("p".into(), sf::Item::Boolean(true))]
)
)
);
assert_eq!(
d[4],
(
"e".into(),
sf::Member::Item(sf::Item::Boolean(true), vec![])
)
);
let d = sf::dictionary("a=1, a=2").unwrap();
assert_eq!(
d,
vec![("a".into(), sf::Member::Item(sf::Item::Integer(2), vec![]))]
);
assert_eq!(
sf::dictionary("a=:AQI:").unwrap()[0].1,
sf::Member::Item(sf::Item::Bytes(vec![1, 2]), vec![])
);
for bad in [
"a=1,",
"A=1",
"a=1.5",
"a=\"unterminated",
"a=\"bad \\x escape\"",
"a=(1 2",
"a=(1\"x\")",
"a=:not base64!:",
"a=?2",
"a=1 b=2",
"a=1234567890123456",
"a=\"é\"",
] {
assert_eq!(sf::dictionary(bad), None, "{bad:?}");
}
}
#[test]
fn strings_serialize_with_escapes_and_round_trip() {
let input = SignatureInput {
components: vec!["a\"b".into(), "c\\d".into()],
params: vec![
("keyid".into(), Param::String("k\"1".into())),
("flag".into(), Param::Boolean(true)),
("off".into(), Param::Boolean(false)),
("t".into(), Param::Token("tok/en".into())),
("b".into(), Param::Bytes(vec![0xff])),
("created".into(), Param::Integer(-5)),
],
};
let text = input.serialize();
assert_eq!(
text,
r#"("a\"b" "c\\d");keyid="k\"1";flag;off=?0;t=tok/en;b=:/w==:;created=-5"#
);
assert_eq!(
SignatureInput::parse(&format!("sig1={text}"), "sig1").unwrap(),
input
);
}
#[test]
fn the_label_must_be_sig1_and_components_may_not_carry_parameters() {
let t = format!(
"other={}",
SignatureInput::recall(NOW, "d", "n").serialize()
);
assert_eq!(
SignatureInput::parse(&t, LABEL),
Err(SignatureError::NoLabel)
);
assert_eq!(
parse_signature("other=:AA==:", LABEL),
Err(SignatureError::NoLabel)
);
assert_eq!(
SignatureInput::parse(r#"sig1=("@query-param";name="Pet")"#, LABEL),
Err(SignatureError::Malformed("signature-input"))
);
assert_eq!(
SignatureInput::parse("sig1=:AA==:", LABEL),
Err(SignatureError::Malformed("signature-input"))
);
assert_eq!(
parse_signature("sig1=(\"x\")", LABEL),
Err(SignatureError::Malformed("signature"))
);
}
}