Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
551 changes: 295 additions & 256 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ sha2 = "0.10.9"
base64 = "0.22.1"
serde_json = "1.0.140"
data-url = "0.3.1"
regex = "1.12.2"

# workspace dependencies
web-bot-auth = { version = "0.5.1", path = "./crates/web-bot-auth" }
25 changes: 14 additions & 11 deletions crates/http-signature-directory/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use reqwest::{
};
use serde::{Deserialize, Serialize};
use web_bot_auth::{
components::{CoveredComponent, DerivedComponent},
components::{CoveredComponent, DerivedComponent, HTTPField},
keyring::{JSONWebKeySet, KeyRing, Thumbprintable},
message_signatures::{MessageVerifier, SignedMessage},
};
Expand Down Expand Up @@ -68,18 +68,21 @@ struct SignedDirectory {
}

impl SignedMessage for SignedDirectory {
fn fetch_all_signature_headers(&self) -> Vec<String> {
self.signature.clone()
}
fn fetch_all_signature_inputs(&self) -> Vec<String> {
self.input.clone()
}
fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
match *name {
fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
match name {
CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
Some(self.authority.clone())
vec![self.authority.clone()]
}
CoveredComponent::HTTP(HTTPField { name, .. }) => {
if name == "signature" {
return self.signature.clone();
}
if name == "signature-input" {
return self.input.clone();
}
vec![]
}
_ => None,
_ => vec![],
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/web-bot-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ categories.workspace = true
[dependencies]
ed25519-dalek = { workspace = true }
indexmap = { workspace = true }
regex = { workspace = true }
sfv = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
355 changes: 225 additions & 130 deletions crates/web-bot-auth/src/lib.rs

Large diffs are not rendered by default.

104 changes: 62 additions & 42 deletions crates/web-bot-auth/src/message_signatures.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use crate::components::CoveredComponent;
use super::ImplementationError;
use crate::components::{self, CoveredComponent, HTTPField};
use crate::keyring::{Algorithm, KeyRing};
use indexmap::IndexMap;
use regex::bytes::Regex;
use sfv::SerializeValue;
use std::fmt::Write as _;
use std::sync::LazyLock;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use super::ImplementationError;
static OBSOLETE_LINE_FOLDING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\s*\r\n\s+").unwrap());

/// The component parameters associated with the signature in `Signature-Input`
#[derive(Clone, Debug)]
Expand Down Expand Up @@ -153,8 +156,26 @@ impl SignatureBaseBuilder {
self.components
.into_iter()
.map(|component| match message.lookup_component(&component) {
Some(serialized_value) => Ok((component, serialized_value)),
None => Err(ImplementationError::LookupError(component)),
v if v.len() == 1 => Ok((component, v[0].to_owned())),
v if v.len() > 1 && matches!(component, CoveredComponent::HTTP(_)) => {
let mut register: Vec<String> = vec![];

for header_value in v.into_iter() {
register.push(
// replace leading / trailing whitespace and obsolete line folding,
// per HTTP message signature spec
String::from_utf8(
OBSOLETE_LINE_FOLDING
.replace_all(header_value.as_bytes().trim_ascii(), b" ")
.into_owned(),
)
.map_err(|_| ImplementationError::NonAsciiContentFound)?,
);
}

Ok((component, register.join(", ")))
}
_ => Err(ImplementationError::LookupError(component)),
})
.collect::<Result<Vec<(CoveredComponent, String)>, ImplementationError>>()?,
),
Expand Down Expand Up @@ -215,31 +236,14 @@ impl SignatureBase {
/// Trait that messages seeking verification should implement to facilitate looking up
/// raw values from the underlying message.
pub trait SignedMessage {
/// Obtain every `Signature` header in the message. Despite the name, you can omit
/// `Signature` that are known to be invalid ahead of time. However, each `Signature-`
/// header should be unparsed and be a valid sfv::Item::Dictionary value. You should
/// separately implement looking this up in `lookup_component` as an HTTP header with
/// multiple values, although including these as signature components when signing is
/// NOT recommended. During verification, invalid values (those that cannot be
/// parsed as an sfv::Dictionary) will be skipped without raising an error.
fn fetch_all_signature_headers(&self) -> Vec<String>;
/// Obtain every `Signature-Input` header in the message. Despite the name, you
/// can omit `Signature-Input` that are known to be invalid ahead of time. However,
/// each `Signature-Input` header should be unparsed and be a valid sfv::Item::Dictionary
/// value (meaning it should be encased in double quotes). You should separately implement
/// looking this up in `lookup_component` as an HTTP header with multiple values, although
/// including these as signature components when signing is NOT recommended. During
/// verification, invalid values (those that cannot be parsed as an sfv::Dictionary) will
/// be skipped will be skipped without raising an error.
fn fetch_all_signature_inputs(&self) -> Vec<String>;
/// Obtain the serialized value of a covered component. Implementations should
/// Retrieve the raw value(s) of a covered component. Implementations should
/// respect any parameter values set on the covered component per the message
/// signature spec. Component values that cannot be found must return None.
/// signature spec. Component values that cannot be found must return an empty vector.
/// `CoveredComponent::HTTP` fields are guaranteed to have lowercase ASCII names, so
/// care should be taken to ensure HTTP field names in the message are checked in a
/// case-insensitive way. HTTP fields with multiple values should be combined into a
/// single string in the manner described in <https://www.rfc-editor.org/rfc/rfc9421#name-http-fields>.
fn lookup_component(&self, name: &CoveredComponent) -> Option<String>;
/// case-insensitive way. Only `CoveredComponent::Http` should return a vector with
/// more than one element.
fn lookup_component(&self, name: &CoveredComponent) -> Vec<String>;
}

/// Trait that messages seeking signing should implement to generate `Signature-Input`
Expand Down Expand Up @@ -386,6 +390,8 @@ impl MessageSigner {
/// of the chosen labl and its components.
#[derive(Clone, Debug)]
pub struct ParsedLabel {
/// The label that was chosen.
pub label: sfv::Key,
/// The signature obtained from the message that verifiers will verify
pub signature: Vec<u8>,
/// The signature base obtained from the message, containining both the chosen
Expand Down Expand Up @@ -424,27 +430,33 @@ impl MessageVerifier {
P: Fn(&(sfv::Key, sfv::InnerList)) -> bool,
{
let signature_input = message
.fetch_all_signature_inputs()
.lookup_component(&CoveredComponent::HTTP(HTTPField {
name: "signature-input".to_string(),
parameters: components::HTTPFieldParametersSet(vec![]),
}))
.into_iter()
.filter_map(|sig_input| sfv::Parser::new(&sig_input).parse_dictionary().ok())
.reduce(|mut acc, sig_input| {
acc.extend(sig_input);
acc
})
.ok_or(ImplementationError::ParsingError(
"No `Signature-Input` headers found".to_string(),
"No validly-formatted `Signature-Input` headers found".to_string(),
))?;

let mut signature_header = message
.fetch_all_signature_headers()
.lookup_component(&CoveredComponent::HTTP(HTTPField {
name: "signature".to_string(),
parameters: components::HTTPFieldParametersSet(vec![]),
}))
.into_iter()
.filter_map(|sig_input| sfv::Parser::new(&sig_input).parse_dictionary().ok())
.reduce(|mut acc, sig_input| {
acc.extend(sig_input);
acc
})
.ok_or(ImplementationError::ParsingError(
"No `Signature` headers found".to_string(),
"No validly-formatted `Signature` headers found".to_string(),
))?;

let (label, innerlist) = signature_input
Expand Down Expand Up @@ -483,7 +495,11 @@ impl MessageVerifier {
let base = builder.into_signature_base(message)?;

Ok(MessageVerifier {
parsed: ParsedLabel { signature, base },
parsed: ParsedLabel {
label,
signature,
base,
},
})
}

Expand Down Expand Up @@ -550,18 +566,22 @@ mod tests {
struct StandardTestVector {}

impl SignedMessage for StandardTestVector {
fn fetch_all_signature_headers(&self) -> Vec<String> {
vec!["sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()]
}
fn fetch_all_signature_inputs(&self) -> Vec<String> {
vec![r#"sig1=("@authority");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=1735693200;nonce="gubxywVx7hzbYKatLgzuKDllDAIXAkz41PydU7aOY7vT+Mb3GJNxW0qD4zJ+IOQ1NVtg+BNbTCRUMt1Ojr5BgA==";tag="web-bot-auth""#.to_owned()]
}
fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
match *name {
fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
match name {
CoveredComponent::HTTP(HTTPField { name, .. }) => {
if name == "signature" {
return vec!["sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()];
}

if name == "signature-input" {
return vec![r#"sig1=("@authority");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=1735693200;nonce="gubxywVx7hzbYKatLgzuKDllDAIXAkz41PydU7aOY7vT+Mb3GJNxW0qD4zJ+IOQ1NVtg+BNbTCRUMt1Ojr5BgA==";tag="web-bot-auth""#.to_owned()];
}
vec![]
}
CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
Some("example.com".to_string())
vec!["example.com".to_string()]
}
_ => None,
_ => vec![],
}
}
}
Expand Down
14 changes: 10 additions & 4 deletions examples/rust/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
use indexmap::IndexMap;
use std::{time::Duration, vec};
use web_bot_auth::{
components::{CoveredComponent, DerivedComponent, HTTPField, HTTPFieldParametersSet},
components::{
CoveredComponent, DerivedComponent, HTTPField, HTTPFieldParameters, HTTPFieldParametersSet,
},
keyring::Algorithm,
message_signatures::{MessageSigner, UnsignedMessage},
};
Expand All @@ -36,9 +38,11 @@ impl UnsignedMessage for MyThing {
(
CoveredComponent::HTTP(HTTPField {
name: "signature-agent".to_string(),
parameters: HTTPFieldParametersSet(vec![]),
parameters: HTTPFieldParametersSet(vec![HTTPFieldParameters::Key(
"agent1".to_string(),
)]),
}),
"\"https://myexample.com\"".to_string(),
r#"agent1="https://myexample.com""#.to_string(),
),
])
}
Expand All @@ -50,12 +54,14 @@ impl UnsignedMessage for MyThing {
}

fn main() {
// Signing a message
// Signing a message - private key pulled from https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/
// and only for example purposes.
let private_key = vec![
0x9f, 0x83, 0x62, 0xf8, 0x7a, 0x48, 0x4a, 0x95, 0x4e, 0x6e, 0x74, 0x0c, 0x5b, 0x4c, 0x0e,
0x84, 0x22, 0x91, 0x39, 0xa2, 0x0a, 0xa8, 0xab, 0x56, 0xff, 0x66, 0x58, 0x6f, 0x6a, 0x7d,
0x29, 0xc5,
];
// sample keyid pulled from https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/
let signer = MessageSigner {
keyid: "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".into(),
nonce: "ZO3/XMEZjrvSnLtAP9M7jK0WGQf3J+pbmQRUpKDhF9/jsNCWqUh2sq+TH4WTX3/GpNoSZUa8eNWMKqxWp2/c2g==".into(),
Expand Down
33 changes: 15 additions & 18 deletions examples/rust/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

use web_bot_auth::{
SignatureAgentLink, WebBotAuthSignedMessage, WebBotAuthVerifier,
SignatureAgentLink, WebBotAuthVerifier,
components::{CoveredComponent, DerivedComponent, HTTPField},
keyring::{Algorithm, KeyRing},
message_signatures::SignedMessage,
Expand All @@ -22,34 +22,30 @@ use web_bot_auth::{
struct MySignedMsg;

impl SignedMessage for MySignedMsg {
fn fetch_all_signature_headers(&self) -> Vec<String> {
vec!["sig1=:GXzHSRZ9Sf6WwLOZjxAhfE6WEUPfDMrVBJITsL2sbG8gtcZgqKe2Yn7uavk0iNQrfcPzgGq8h8Pk5osNGqdtCw==:".to_owned()]
}
fn fetch_all_signature_inputs(&self) -> Vec<String> {
vec![r#"sig1=("@authority" "signature-agent");alg="ed25519";keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";nonce="ZO3/XMEZjrvSnLtAP9M7jK0WGQf3J+pbmQRUpKDhF9/jsNCWqUh2sq+TH4WTX3/GpNoSZUa8eNWMKqxWp2/c2g==";tag="web-bot-auth";created=1749332605;expires=1749332615"#.to_owned()]
}
fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
match name {
CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
Some("example.com".to_string())
vec!["example.com".to_string()]
}
CoveredComponent::HTTP(HTTPField { name, .. }) => {
if name == "signature-agent" {
return Some(String::from("\"https://myexample.com\""));
return vec![r#"agent1="https://myexample.com""#.to_string()];
}
None

if name == "signature" {
return vec![r#"sig1=:EZZ8VJcVQ9WgiUytQWAfEvRWLLu2O+UkJ15aVI//dfLTCLnr1Vg2CDXXlrW4D+OjBB6zu/UkFtxpKzbXh2ESBg==:"#.to_string()];
}

if name == "signature-input" {
return vec![r#"sig1=("@authority" "signature-agent";key="agent1");keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";nonce="ZO3/XMEZjrvSnLtAP9M7jK0WGQf3J+pbmQRUpKDhF9/jsNCWqUh2sq+TH4WTX3/GpNoSZUa8eNWMKqxWp2/c2g==";tag="web-bot-auth";created=1761143856;expires=1761143866"#.to_string()];
}
vec![]
}
_ => None,
_ => vec![],
}
}
}

impl WebBotAuthSignedMessage for MySignedMsg {
fn fetch_all_signature_agents(&self) -> Vec<String> {
vec!["\"https://myexample.com\"".into()]
}
}

fn main() {
// Verifying a Web Bot Auth message
let public_key = [
Expand All @@ -58,6 +54,7 @@ fn main() {
0xd1, 0xbb,
];
let mut keyring = KeyRing::default();
// sample keyid pulled from https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/
keyring.import_raw(
"poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
Algorithm::Ed25519,
Expand Down
26 changes: 15 additions & 11 deletions examples/rust/verify_arbitrary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,30 @@
// limitations under the License.

use web_bot_auth::{
components::{CoveredComponent, DerivedComponent},
components::{CoveredComponent, DerivedComponent, HTTPField},
keyring::{Algorithm, KeyRing},
message_signatures::{MessageVerifier, SignedMessage},
};

struct MySignedMsg;

impl SignedMessage for MySignedMsg {
fn fetch_all_signature_headers(&self) -> Vec<String> {
vec!["sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()]
}
fn fetch_all_signature_inputs(&self) -> Vec<String> {
vec![r#"sig1=("@authority");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=1735693200;nonce="gubxywVx7hzbYKatLgzuKDllDAIXAkz41PydU7aOY7vT+Mb3GJNxW0qD4zJ+IOQ1NVtg+BNbTCRUMt1Ojr5BgA==";tag="web-bot-auth""#.to_owned()]
}
fn lookup_component(&self, name: &CoveredComponent) -> Option<String> {
match *name {
fn lookup_component(&self, name: &CoveredComponent) -> Vec<String> {
match name {
CoveredComponent::Derived(DerivedComponent::Authority { .. }) => {
Some("example.com".to_string())
vec!["example.com".to_string()]
}
CoveredComponent::HTTP(HTTPField { name, .. }) => {
if name == "signature" {
return vec!["sig1=:uz2SAv+VIemw+Oo890bhYh6Xf5qZdLUgv6/PbiQfCFXcX/vt1A8Pf7OcgL2yUDUYXFtffNpkEr5W6dldqFrkDg==:".to_owned()];
}

if name == "signature-input" {
return vec![r#"sig1=("@authority");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=1735693200;nonce="gubxywVx7hzbYKatLgzuKDllDAIXAkz41PydU7aOY7vT+Mb3GJNxW0qD4zJ+IOQ1NVtg+BNbTCRUMt1Ojr5BgA==";tag="web-bot-auth""#.to_owned()];
}
vec![]
}
_ => None,
_ => vec![],
}
}
}
Expand Down