use mkit_core::hash::{hash, to_hex};
use mkit_core::write_auth::{
AuthError, Context, ExpectedCommitment, Headers, validate_audience, verify_headers_with,
};
use crate::error::ServerError;
use crate::op::{Commitment, Procedure, VerifiedAuth};
pub const HEADER_NAMES: [&str; 10] = [
"x-envelope-version",
"x-audience",
"x-repository",
"x-public-key",
"x-signature",
"x-digest",
"x-content-commitment",
"x-created-at",
"x-expires-at",
"idempotency-key",
];
pub const CORS_ALLOW_HEADERS: &str = "x-envelope-version, x-audience, x-repository, x-content-commitment, x-expires-at, x-public-key, x-signature, x-digest, x-created-at, \
idempotency-key, x-mkit-ref, x-write-grant, content-type, connect-protocol-version";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthV2Config {
audience: String,
repository: String,
}
impl AuthV2Config {
pub fn new(
audience: impl Into<String>,
repository: impl Into<String>,
) -> Result<Self, AuthError> {
let audience = audience.into();
validate_audience(&audience)?;
Ok(Self {
audience,
repository: repository.into(),
})
}
#[must_use]
pub fn audience(&self) -> &str {
&self.audience
}
#[must_use]
pub fn repository(&self) -> &str {
&self.repository
}
fn context<'a>(&'a self, repository: &'a str) -> Context<'a> {
Context {
audience: &self.audience,
repository,
}
}
}
pub(crate) fn carries_auth_headers(get: impl Fn(&str) -> Option<String>) -> bool {
["x-envelope-version", "x-public-key", "x-signature"]
.iter()
.any(|name| get(name).is_some())
}
pub fn headers_from(get: impl Fn(&str) -> Option<String>) -> Headers {
Headers {
version: get(HEADER_NAMES[0]),
audience: get(HEADER_NAMES[1]),
repository: get(HEADER_NAMES[2]),
public_key: get(HEADER_NAMES[3]),
signature: get(HEADER_NAMES[4]),
digest: get(HEADER_NAMES[5]),
commitment: get(HEADER_NAMES[6]),
created_at: get(HEADER_NAMES[7]),
expires_at: get(HEADER_NAMES[8]),
idempotency_key: get(HEADER_NAMES[9]),
}
}
pub fn verify_unary(
cfg: &AuthV2Config,
procedure_path: &str,
body: &[u8],
now_ms: i64,
headers: &Headers,
) -> Result<VerifiedAuth, ServerError> {
verify_unary_for(cfg, cfg.repository(), procedure_path, body, now_ms, headers)
}
pub(crate) fn verify_unary_for(
cfg: &AuthV2Config,
repository: &str,
procedure_path: &str,
body: &[u8],
now_ms: i64,
headers: &Headers,
) -> Result<VerifiedAuth, ServerError> {
let commitment = format!("body:{}", to_hex(&hash(body)));
verify(
cfg,
repository,
procedure_path,
Some(&commitment),
now_ms,
headers,
)
}
pub fn verify_stream(
cfg: &AuthV2Config,
procedure_path: &str,
now_ms: i64,
headers: &Headers,
) -> Result<VerifiedAuth, ServerError> {
verify_stream_for(cfg, cfg.repository(), procedure_path, now_ms, headers)
}
pub(crate) fn verify_stream_for(
cfg: &AuthV2Config,
repository: &str,
procedure_path: &str,
now_ms: i64,
headers: &Headers,
) -> Result<VerifiedAuth, ServerError> {
verify(cfg, repository, procedure_path, None, now_ms, headers)
}
fn verify(
cfg: &AuthV2Config,
repository: &str,
procedure_path: &str,
commitment: Option<&str>,
now_ms: i64,
headers: &Headers,
) -> Result<VerifiedAuth, ServerError> {
let expected = if let Some(text) = commitment {
ExpectedCommitment::Exact(text)
} else if procedure_path == Procedure::UploadPart.connect_path() {
ExpectedCommitment::PartStream
} else {
ExpectedCommitment::PackStream
};
let authorized = verify_headers_with(
cfg.context(repository),
procedure_path,
expected,
now_ms,
headers,
)
.map_err(|e| ServerError::unauthenticated(e.0))?;
let mut auth = VerifiedAuth::try_from(&authorized)?;
auth.created_at_ms = headers
.created_at
.as_deref()
.and_then(|text| text.parse().ok())
.unwrap_or(0);
Ok(auth)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("pack header differs from signed commitment")]
pub struct PackCommitmentMismatch;
pub fn check_pack_commitment(
auth: &VerifiedAuth,
pack_id: &[u8],
total_bytes: u64,
) -> Result<(), PackCommitmentMismatch> {
match auth.commitment {
Commitment::Pack { id, len } if id.as_slice() == pack_id && len == total_bytes => Ok(()),
_ => Err(PackCommitmentMismatch),
}
}
#[cfg(test)]
mod tests {
use ed25519_dalek::{Signer, SigningKey};
use mkit_core::hash::{from_hex, to_hex_bytes};
use mkit_core::write_auth::Operation;
use super::*;
use crate::error::Code;
fn golden() -> serde_json::Value {
serde_json::from_str(include_str!("../../../tests/golden/auth-v2/unary.json")).unwrap()
}
fn field(fixture: &serde_json::Value, name: &str) -> String {
fixture[name].as_str().unwrap().to_owned()
}
fn golden_headers(fixture: &serde_json::Value) -> Headers {
let values = [
"2".to_owned(),
field(fixture, "audience"),
field(fixture, "repository"),
field(fixture, "public_key"),
field(fixture, "signature"),
field(fixture, "body_digest"),
field(fixture, "commitment"),
fixture["created_at"].as_i64().unwrap().to_string(),
fixture["expires_at"].as_i64().unwrap().to_string(),
field(fixture, "nonce"),
];
headers_from(|name| {
HEADER_NAMES
.iter()
.position(|n| *n == name)
.map(|i| values[i].clone())
})
}
fn golden_config(fixture: &serde_json::Value) -> AuthV2Config {
AuthV2Config::new(field(fixture, "audience"), field(fixture, "repository")).unwrap()
}
#[test]
fn golden_unary_verifies() {
let fixture = golden();
let created_at = fixture["created_at"].as_i64().unwrap();
let auth = verify_unary(
&golden_config(&fixture),
&field(&fixture, "procedure"),
field(&fixture, "body").as_bytes(),
created_at + 1,
&golden_headers(&fixture),
)
.unwrap();
assert_eq!(
auth.commitment,
Commitment::Body(from_hex(&field(&fixture, "body_digest")).unwrap())
);
assert_eq!(to_hex(&auth.signer), field(&fixture, "public_key"));
assert_eq!(to_hex(&auth.fingerprint), field(&fixture, "signing_digest"));
assert_eq!(auth.nonce, field(&fixture, "nonce"));
}
#[test]
fn unary_failures_are_unauthenticated() {
let fixture = golden();
let cfg = golden_config(&fixture);
let procedure = field(&fixture, "procedure");
let body = field(&fixture, "body");
let created_at = fixture["created_at"].as_i64().unwrap();
let expires_at = fixture["expires_at"].as_i64().unwrap();
let headers = golden_headers(&fixture);
let other_audience =
AuthV2Config::new("https://other.example.test", cfg.repository()).unwrap();
let other_repo = AuthV2Config::new(cfg.audience(), "room-b").unwrap();
let cases: [(&AuthV2Config, &[u8], i64, &str); 4] = [
(
&other_audience,
body.as_bytes(),
created_at + 1,
"request audience or repository mismatch",
),
(
&other_repo,
body.as_bytes(),
created_at + 1,
"request audience or repository mismatch",
),
(
&cfg,
b"tampered body",
created_at + 1,
"content commitment mismatch",
),
(
&cfg,
body.as_bytes(),
expires_at + 1,
"expired or future authorization",
),
];
for (cfg, body, now, reason) in cases {
let err = verify_unary(cfg, &procedure, body, now, &headers).unwrap_err();
assert_eq!(err.code(), Code::Unauthenticated);
assert_eq!(err.public_message(), reason);
}
}
#[test]
fn config_rejects_a_noncanonical_audience() {
for audience in ["https://API.example.test", "https://a.test/", "a.test"] {
assert!(AuthV2Config::new(audience, "room-a").is_err(), "{audience}");
}
}
const PACK_ID: [u8; 32] = [0xcd; 32];
const UPLOAD: &str = "/mkit.transport.v1.TransportService/UploadPack";
fn signed_stream(fixture: &serde_json::Value, commitment: &str) -> Headers {
signed_stream_for(fixture, UPLOAD, commitment)
}
fn signed_stream_for(
fixture: &serde_json::Value,
procedure: &str,
commitment: &str,
) -> Headers {
let (audience, repository) = (field(fixture, "audience"), field(fixture, "repository"));
let nonce = field(fixture, "nonce");
let (created_at, expires_at) = (
fixture["created_at"].as_i64().unwrap(),
fixture["expires_at"].as_i64().unwrap(),
);
let operation = Operation {
context: Context {
audience: &audience,
repository: &repository,
},
procedure,
commitment,
created_at,
expires_at,
nonce: &nonce,
};
let seed: [u8; 32] = from_hex(&field(fixture, "seed")).unwrap();
let key = SigningKey::from_bytes(&seed);
let signature = key.sign(&operation.digest().unwrap());
Headers {
version: Some("2".into()),
audience: Some(audience.clone()),
repository: Some(repository.clone()),
public_key: Some(to_hex(key.verifying_key().as_bytes())),
signature: Some(to_hex_bytes(&signature.to_bytes())),
digest: None,
commitment: Some(commitment.to_owned()),
created_at: Some(created_at.to_string()),
expires_at: Some(expires_at.to_string()),
idempotency_key: Some(nonce.clone()),
}
}
#[test]
fn stream_verifies_and_checks_the_pack_header() {
let fixture = golden();
let now = fixture["created_at"].as_i64().unwrap() + 1;
let commitment = format!("pack:{}:12", to_hex(&PACK_ID));
let headers = signed_stream(&fixture, &commitment);
let auth = verify_stream(&golden_config(&fixture), UPLOAD, now, &headers).unwrap();
assert_eq!(
auth.commitment,
Commitment::Pack {
id: PACK_ID,
len: 12
}
);
check_pack_commitment(&auth, &PACK_ID, 12).unwrap();
for (id, len) in [
(&[0xce; 32][..], 12),
(&PACK_ID[..], 13),
(&PACK_ID[..31], 12),
] {
let err = check_pack_commitment(&auth, id, len).unwrap_err();
assert_eq!(err, PackCommitmentMismatch);
assert_eq!(
err.to_string(),
"pack header differs from signed commitment"
);
}
}
#[test]
fn stream_without_a_pack_commitment_is_unauthenticated() {
let fixture = golden();
let now = fixture["created_at"].as_i64().unwrap() + 1;
let headers = signed_stream(&fixture, &field(&fixture, "commitment"));
let err = verify_stream(&golden_config(&fixture), UPLOAD, now, &headers).unwrap_err();
assert_eq!(err.code(), Code::Unauthenticated);
assert_eq!(err.public_message(), "stream requires a pack commitment");
let unary = verify_unary(
&golden_config(&fixture),
&field(&fixture, "procedure"),
field(&fixture, "body").as_bytes(),
now,
&golden_headers(&fixture),
)
.unwrap();
assert_eq!(
check_pack_commitment(&unary, &PACK_ID, 12),
Err(PackCommitmentMismatch)
);
}
#[test]
fn upload_part_selects_part_stream_commitment() {
let fixture = golden();
let now = fixture["created_at"].as_i64().unwrap() + 1;
let part = format!("part:{}:1:{}:8388608", "ab".repeat(32), "cd".repeat(32));
let path = Procedure::UploadPart.connect_path();
let headers = signed_stream_for(&fixture, path, &part);
let auth = verify_stream(&golden_config(&fixture), path, now, &headers).unwrap();
assert!(matches!(
auth.commitment,
Commitment::Part {
index: 1,
len: 8_388_608,
..
}
));
let wrong = signed_stream_for(&fixture, path, &format!("pack:{}:8388608", "cd".repeat(32)));
assert_eq!(
verify_stream(&golden_config(&fixture), path, now, &wrong)
.unwrap_err()
.public_message(),
"stream requires a part commitment"
);
}
#[test]
fn cors_allows_every_auth_header() {
for name in HEADER_NAMES {
assert!(
CORS_ALLOW_HEADERS.split(", ").any(|h| h.trim() == name),
"{name}"
);
}
}
}