use base64::Engine as _;
use crypto::{Ed25519Signer, Signer as _};
use sha2::{Digest, Sha256};
use tonic::Request;
use tonic::metadata::{Ascii, BinaryMetadataValue, MetadataValue};
use wire::ProtocolError;
const DOMAIN_PREFIX: &str = "weft-req-sig-v1:";
pub(super) const HDR_SIG_ALG: &str = "x-weft-sig-alg";
pub(super) const HDR_SIG_BIN: &str = "x-weft-sig-bin";
pub(super) const HDR_SIG_TS: &str = "x-weft-sig-ts";
pub(super) const HDR_SIG_NONCE_BIN: &str = "x-weft-sig-nonce-bin";
pub(super) const HDR_SIG_KEY_BIN: &str = "x-weft-sig-key-bin";
pub(super) const HDR_SIG_WEBAUTHN_CLIENT_DATA_BIN: &str = "x-weft-sig-webauthn-client-data-bin";
pub(super) const HDR_SIG_WEBAUTHN_AUTH_DATA_BIN: &str = "x-weft-sig-webauthn-auth-data-bin";
pub(super) const HDR_SIG_WEBAUTHN_USER_HANDLE_BIN: &str = "x-weft-sig-webauthn-user-handle-bin";
pub(super) const HDR_SIG_REQUIRED: &str = "x-weft-sig-required";
pub(super) const HDR_SIG_ACTION_URL: &str = "x-weft-sig-action-url";
const ALG_ED25519: &str = "ed25519";
const ALG_WEBAUTHN: &str = "webauthn";
const NONCE_LEN: usize = 16;
pub fn canonical_bytes(path: &str, ts_millis: i64, nonce: &[u8], body: &[u8]) -> Vec<u8> {
let body_hash = Sha256::digest(body);
let mut out = String::with_capacity(
DOMAIN_PREFIX.len() + path.len() + 1 + 20 + 1 + nonce.len() * 2 + 1 + 64,
);
out.push_str(DOMAIN_PREFIX);
out.push_str(path);
out.push(':');
out.push_str(&ts_millis.to_string());
out.push(':');
out.push_str(&hex::encode(nonce));
out.push(':');
out.push_str(&hex::encode(body_hash));
out.into_bytes()
}
pub fn human_challenge(canonical: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(canonical))
}
pub fn grpc_framed_body(message_bytes: &[u8]) -> Vec<u8> {
let len = message_bytes.len() as u32;
let mut framed = Vec::with_capacity(5 + message_bytes.len());
framed.push(0u8); framed.extend_from_slice(&len.to_be_bytes());
framed.extend_from_slice(message_bytes);
framed
}
fn fresh_nonce() -> [u8; NONCE_LEN] {
let mut nonce = [0u8; NONCE_LEN];
rand::fill(&mut nonce);
nonce
}
fn now_millis() -> Result<i64, ProtocolError> {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
i64::try_from(dur.as_millis())
.map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))
}
fn ascii(value: impl AsRef<str>) -> Result<MetadataValue<Ascii>, ProtocolError> {
MetadataValue::try_from(value.as_ref())
.map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))
}
pub(super) struct SignedRequestContext {
pub canonical: Vec<u8>,
pub ts_millis: i64,
pub nonce: [u8; NONCE_LEN],
}
pub(super) fn attach_pop<T>(
request: &mut Request<T>,
signer: &Ed25519Signer,
path: &str,
message_bytes: &[u8],
) -> Result<SignedRequestContext, ProtocolError> {
let ts_millis = now_millis()?;
let nonce = fresh_nonce();
let framed = grpc_framed_body(message_bytes);
let canonical = canonical_bytes(path, ts_millis, &nonce, &framed);
let signature = signer
.sign(&canonical)
.map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
let md = request.metadata_mut();
md.insert(HDR_SIG_ALG, ascii(ALG_ED25519)?);
md.insert(HDR_SIG_TS, ascii(ts_millis.to_string())?);
md.insert_bin(HDR_SIG_BIN, BinaryMetadataValue::from_bytes(&signature));
md.insert_bin(HDR_SIG_NONCE_BIN, BinaryMetadataValue::from_bytes(&nonce));
md.insert_bin(
HDR_SIG_KEY_BIN,
BinaryMetadataValue::from_bytes(signer.public_key()),
);
Ok(SignedRequestContext {
canonical,
ts_millis,
nonce,
})
}
#[derive(Clone, Debug)]
pub struct WebAuthnAssertion {
pub credential_id: Vec<u8>,
pub signature: Vec<u8>,
pub client_data_json: Vec<u8>,
pub authenticator_data: Vec<u8>,
pub user_handle: Option<Vec<u8>>,
}
#[derive(Clone, Debug)]
pub struct HumanSignatureRequest {
pub method_path: String,
pub action_summary: String,
pub challenge: String,
pub canonical: Vec<u8>,
pub action_url: Option<String>,
}
pub type HumanSignatureCallback = std::sync::Arc<
dyn Fn(HumanSignatureRequest) -> Result<WebAuthnAssertion, ProtocolError> + Send + Sync,
>;
pub(super) fn attach_human<T>(
request: &mut Request<T>,
ctx: &SignedRequestContext,
assertion: &WebAuthnAssertion,
) -> Result<(), ProtocolError> {
let md = request.metadata_mut();
md.insert(HDR_SIG_ALG, ascii(ALG_WEBAUTHN)?);
md.insert(HDR_SIG_TS, ascii(ctx.ts_millis.to_string())?);
md.insert_bin(
HDR_SIG_NONCE_BIN,
BinaryMetadataValue::from_bytes(&ctx.nonce),
);
md.insert_bin(
HDR_SIG_KEY_BIN,
BinaryMetadataValue::from_bytes(&assertion.credential_id),
);
md.insert_bin(
HDR_SIG_BIN,
BinaryMetadataValue::from_bytes(&assertion.signature),
);
md.insert_bin(
HDR_SIG_WEBAUTHN_CLIENT_DATA_BIN,
BinaryMetadataValue::from_bytes(&assertion.client_data_json),
);
md.insert_bin(
HDR_SIG_WEBAUTHN_AUTH_DATA_BIN,
BinaryMetadataValue::from_bytes(&assertion.authenticator_data),
);
if let Some(user_handle) = &assertion.user_handle {
md.insert_bin(
HDR_SIG_WEBAUTHN_USER_HANDLE_BIN,
BinaryMetadataValue::from_bytes(user_handle),
);
}
Ok(())
}
pub(super) fn requires_human_signature(status: &tonic::Status) -> bool {
if status.code() != tonic::Code::Unauthenticated {
return false;
}
status
.metadata()
.get(HDR_SIG_REQUIRED)
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("human"))
.unwrap_or(false)
}
pub(super) fn action_url_from_status(status: &tonic::Status) -> Option<String> {
status
.metadata()
.get(HDR_SIG_ACTION_URL)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const PINNED_PATH: &str = "/heddle.v1.ExampleService/DoThing";
const PINNED_TS: i64 = 1_700_000_000_000;
const PINNED_NONCE: [u8; 16] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10,
];
const PINNED_CANONICAL: &str = "weft-req-sig-v1:/heddle.v1.ExampleService/DoThing:1700000000000:0102030405060708090a0b0c0d0e0f10:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[test]
fn canonical_bytes_matches_weft341_pinned_vector() {
let got = canonical_bytes(PINNED_PATH, PINNED_TS, &PINNED_NONCE, b"");
assert_eq!(got, PINNED_CANONICAL.as_bytes());
}
#[test]
fn canonical_bytes_change_any_field_changes_output() {
let base = canonical_bytes(PINNED_PATH, PINNED_TS, &PINNED_NONCE, b"");
assert_ne!(canonical_bytes("/other", PINNED_TS, &PINNED_NONCE, b""), base);
assert_ne!(
canonical_bytes(PINNED_PATH, PINNED_TS + 1, &PINNED_NONCE, b""),
base
);
assert_ne!(
canonical_bytes(PINNED_PATH, PINNED_TS, &[0xff; 16], b""),
base
);
assert_ne!(
canonical_bytes(PINNED_PATH, PINNED_TS, &PINNED_NONCE, b"x"),
base
);
}
#[test]
fn grpc_framing_is_flag_len_body() {
assert_eq!(grpc_framed_body(b""), vec![0, 0, 0, 0, 0]);
assert_eq!(grpc_framed_body(b"ab"), vec![0, 0, 0, 0, 2, b'a', b'b']);
}
#[test]
fn attach_pop_produces_headers_verifiable_against_device_pubkey() {
let signer = Ed25519Signer::generate().expect("gen device key");
let pubkey = signer.public_key().to_vec();
let path = "/heddle.v1.SpoolService/DeleteSpool";
let message_bytes = b"\x0a\x03abc";
let mut request = Request::new(());
let ctx = attach_pop(&mut request, &signer, path, message_bytes).expect("attach pop");
let md = request.metadata();
assert_eq!(
md.get(HDR_SIG_ALG).and_then(|v| v.to_str().ok()),
Some("ed25519")
);
assert_eq!(
md.get(HDR_SIG_TS).and_then(|v| v.to_str().ok()),
Some(ctx.ts_millis.to_string().as_str())
);
let framed = grpc_framed_body(message_bytes);
let canonical = canonical_bytes(path, ctx.ts_millis, &ctx.nonce, &framed);
assert_eq!(canonical, ctx.canonical);
let sig_b64 = md
.get_bin(HDR_SIG_BIN)
.expect("sig header present")
.to_bytes()
.expect("sig decodes");
Ed25519Signer::verify_with_public_key(&canonical, &pubkey, &sig_b64)
.expect("attached signature verifies against the device pubkey");
let key_bytes = md
.get_bin(HDR_SIG_KEY_BIN)
.expect("key header present")
.to_bytes()
.expect("key decodes");
assert_eq!(key_bytes.as_ref(), pubkey.as_slice());
let nonce_bytes = md
.get_bin(HDR_SIG_NONCE_BIN)
.expect("nonce header present")
.to_bytes()
.expect("nonce decodes");
assert!(nonce_bytes.len() >= 16);
}
#[test]
fn human_challenge_is_client_derived_sha256_of_canonical() {
let canonical = canonical_bytes(PINNED_PATH, PINNED_TS, &PINNED_NONCE, b"body");
let challenge = human_challenge(&canonical);
let expected =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(&canonical));
assert_eq!(challenge, expected);
}
#[test]
fn requires_human_signature_detects_trailer() {
let mut md = tonic::metadata::MetadataMap::new();
md.insert(HDR_SIG_REQUIRED, "human".parse().unwrap());
let status =
tonic::Status::with_metadata(tonic::Code::Unauthenticated, "needs human", md.clone());
assert!(requires_human_signature(&status));
let mut md_pop = tonic::metadata::MetadataMap::new();
md_pop.insert(HDR_SIG_REQUIRED, "pop".parse().unwrap());
let status_pop =
tonic::Status::with_metadata(tonic::Code::Unauthenticated, "needs pop", md_pop);
assert!(!requires_human_signature(&status_pop));
let status_wrong_code =
tonic::Status::with_metadata(tonic::Code::PermissionDenied, "denied", md);
assert!(!requires_human_signature(&status_wrong_code));
}
#[test]
fn attach_human_sets_webauthn_headers() {
let signer = Ed25519Signer::generate().expect("gen");
let mut request = Request::new(());
let ctx = attach_pop(&mut request, &signer, "/x/Y", b"body").expect("pop");
let assertion = WebAuthnAssertion {
credential_id: vec![1, 2, 3],
signature: vec![4, 5, 6],
client_data_json: b"{}".to_vec(),
authenticator_data: vec![7; 37],
user_handle: Some(vec![9]),
};
attach_human(&mut request, &ctx, &assertion).expect("attach human");
let md = request.metadata();
assert_eq!(
md.get(HDR_SIG_ALG).and_then(|v| v.to_str().ok()),
Some("webauthn")
);
assert_eq!(
md.get_bin(HDR_SIG_KEY_BIN).unwrap().to_bytes().unwrap().as_ref(),
&[1, 2, 3]
);
assert!(md.get_bin(HDR_SIG_WEBAUTHN_CLIENT_DATA_BIN).is_some());
assert!(md.get_bin(HDR_SIG_WEBAUTHN_AUTH_DATA_BIN).is_some());
assert!(md.get_bin(HDR_SIG_WEBAUTHN_USER_HANDLE_BIN).is_some());
}
}