use semver::Version;
use serde::Deserialize;
use serde::Serialize;
use crate::CodePushError;
use crate::manifest::BundleManifest;
use crate::signing::ReleasePublicKey;
use crate::signing::ReleaseSigningKey;
use crate::signing::TrustedKeys;
use crate::signing::decode_hex;
pub const APPROVAL_FORMAT_VERSION: u32 = 1;
pub const APPROVAL_KIND: &str = "leptoskit-codepush-source-approval";
pub const APPROVALS_FORMAT_VERSION: u32 = 1;
pub const MAX_STATEMENT_BYTES: usize = 4096;
pub const MAX_SIGNED_APPROVAL_BYTES: u64 = 16 * 1024;
pub const MAX_APPROVALS: usize = 64;
pub const MAX_APPROVALS_BYTES: u64 = 1024 * 1024;
const APPROVAL_CONTEXT: &[u8] = b"leptoskit-codepush-source-approval-v1:";
fn signed_message(statement: &str) -> Vec<u8> {
let mut message = Vec::with_capacity(APPROVAL_CONTEXT.len() + statement.len());
message.extend_from_slice(APPROVAL_CONTEXT);
message.extend_from_slice(statement.as_bytes());
message
}
fn invalid(message: impl Into<String>) -> CodePushError {
CodePushError::InvalidApproval(message.into())
}
pub fn validate_repository(repository: &str) -> Result<(), CodePushError> {
let segment = |segment: &str| {
!segment.is_empty()
&& segment != "."
&& segment != ".."
&& segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
};
let valid = repository
.strip_prefix("https://github.com/")
.and_then(|path| path.split_once('/'))
.is_some_and(|(owner, name)| segment(owner) && segment(name) && !is_git_suffixed(name));
if valid {
Ok(())
} else {
Err(invalid(
"the repository must be a GitHub HTTPS URL, https://github.com/<owner>/<repo>, \
without credentials, a query, a fragment, a trailing slash, or .git",
))
}
}
fn is_git_suffixed(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("git"))
}
pub fn validate_commit(commit: &str) -> Result<(), CodePushError> {
if is_lower_hex(commit, &[40, 64]) {
Ok(())
} else {
Err(invalid(
"the commit must be a full Git commit id: 40 or 64 lowercase hex characters",
))
}
}
fn is_lower_hex(value: &str, lengths: &[usize]) -> bool {
lengths.contains(&value.len())
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn same_repository(left: &str, right: &str) -> bool {
left.eq_ignore_ascii_case(right)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceApproval {
pub format_version: u32,
pub kind: String,
pub app: String,
pub channel: String,
pub version: Version,
pub build: u64,
pub bundle_hash: String,
pub repository: String,
pub commit: String,
}
impl SourceApproval {
pub fn new(
manifest: &BundleManifest,
repository: &str,
commit: &str,
) -> Result<Self, CodePushError> {
let statement = Self {
format_version: APPROVAL_FORMAT_VERSION,
kind: APPROVAL_KIND.to_owned(),
app: manifest.app.clone(),
channel: manifest.channel.clone(),
version: manifest.version.clone(),
build: manifest.build,
bundle_hash: manifest.bundle_hash(),
repository: repository.to_owned(),
commit: commit.to_owned(),
};
statement.validate()?;
Ok(statement)
}
pub fn validate(&self) -> Result<(), CodePushError> {
if self.format_version != APPROVAL_FORMAT_VERSION {
return Err(invalid(format!(
"unsupported approval format version {}",
self.format_version
)));
}
if self.kind != APPROVAL_KIND {
return Err(invalid(format!("the statement is not a {APPROVAL_KIND}")));
}
if !is_lower_hex(&self.bundle_hash, &[64]) {
return Err(invalid("the bundle hash must be a lowercase hex SHA-256"));
}
validate_repository(&self.repository)?;
validate_commit(&self.commit)
}
fn covers(&self, manifest: &BundleManifest, bundle_hash: &str) -> bool {
self.app == manifest.app
&& self.channel == manifest.channel
&& self.version == manifest.version
&& self.build == manifest.build
&& self.bundle_hash == bundle_hash
}
pub fn release(&self) -> String {
format!(
"{}/{}/{}+{}",
self.app, self.channel, self.version, self.build
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedApproval {
pub statement: String,
pub public_key: String,
pub signature: String,
}
impl SignedApproval {
pub fn sign(
statement: &SourceApproval,
approver: &ReleaseSigningKey,
) -> Result<Self, CodePushError> {
statement.validate()?;
let text = serde_json::to_string(statement)?;
if text.len() > MAX_STATEMENT_BYTES {
return Err(invalid(format!(
"the statement exceeds {MAX_STATEMENT_BYTES} bytes"
)));
}
let signature = approver.sign_message(&signed_message(&text));
Ok(Self {
statement: text,
public_key: approver.public_key().to_hex(),
signature: hex::encode(signature),
})
}
pub fn from_slice(bytes: &[u8]) -> Result<Self, CodePushError> {
if bytes.len() as u64 > MAX_SIGNED_APPROVAL_BYTES {
return Err(invalid(format!(
"an approval exceeds {MAX_SIGNED_APPROVAL_BYTES} bytes"
)));
}
Ok(serde_json::from_slice(bytes)?)
}
pub fn open(&self) -> Result<(ReleasePublicKey, SourceApproval), CodePushError> {
let key = ReleasePublicKey::from_hex(&self.public_key)?;
let statement = self.verify_with(&key)?;
Ok((key, statement))
}
fn verify_with(&self, key: &ReleasePublicKey) -> Result<SourceApproval, CodePushError> {
if self.statement.len() > MAX_STATEMENT_BYTES {
return Err(invalid(format!(
"the statement exceeds {MAX_STATEMENT_BYTES} bytes"
)));
}
let signature = decode_hex::<64>("an approval signature", &self.signature)
.map_err(|_| invalid("the approval signature must be 128 hex characters"))?;
if !key.verifies(&signed_message(&self.statement), &signature) {
return Err(invalid(format!(
"the approval signature does not verify for key {}",
key.to_hex()
)));
}
let statement: SourceApproval = serde_json::from_str(&self.statement)?;
statement.validate()?;
Ok(statement)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleaseApprovals {
pub format_version: u32,
pub approvals: Vec<SignedApproval>,
}
impl ReleaseApprovals {
pub fn empty() -> Self {
Self {
format_version: APPROVALS_FORMAT_VERSION,
approvals: Vec::new(),
}
}
pub fn from_slice(bytes: &[u8]) -> Result<Self, CodePushError> {
if bytes.len() as u64 > MAX_APPROVALS_BYTES {
return Err(invalid(format!(
"the approvals file exceeds {MAX_APPROVALS_BYTES} bytes"
)));
}
let approvals: Self = serde_json::from_slice(bytes)?;
if approvals.format_version != APPROVALS_FORMAT_VERSION {
return Err(invalid(format!(
"unsupported approvals format version {}",
approvals.format_version
)));
}
if approvals.approvals.len() > MAX_APPROVALS {
return Err(invalid(format!(
"the approvals file holds more than {MAX_APPROVALS} approvals"
)));
}
Ok(approvals)
}
pub fn to_vec(&self) -> Result<Vec<u8>, CodePushError> {
let bytes = serde_json::to_vec_pretty(self)?;
Self::from_slice(&bytes)?;
Ok(bytes)
}
pub fn merge(
&mut self,
approvals: impl IntoIterator<Item = SignedApproval>,
) -> Result<usize, CodePushError> {
let mut merged = self.approvals.clone();
for approval in approvals {
if !merged.contains(&approval) {
merged.push(approval);
}
}
if merged.len() > MAX_APPROVALS {
return Err(invalid(format!(
"a release holds at most {MAX_APPROVALS} approvals"
)));
}
let added = merged.len() - self.approvals.len();
self.approvals = merged;
Ok(added)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProvenanceConfiguration {
pub repository: String,
pub approvers: Vec<String>,
pub threshold: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvenancePolicy {
repository: String,
approvers: TrustedKeys,
threshold: usize,
}
impl ProvenancePolicy {
pub fn new(
repository: impl Into<String>,
approvers: TrustedKeys,
threshold: usize,
) -> Result<Self, CodePushError> {
let repository = repository.into();
validate_repository(&repository)
.map_err(|error| CodePushError::InvalidProvenance(error.to_string()))?;
let keys: Vec<_> = approvers.iter().collect();
if let Some(duplicate) = keys
.iter()
.enumerate()
.find_map(|(index, key)| keys[..index].contains(key).then_some(key))
{
return Err(CodePushError::InvalidProvenance(format!(
"approver {} is listed more than once",
duplicate.to_hex()
)));
}
if threshold == 0 || threshold > keys.len() {
return Err(CodePushError::InvalidProvenance(format!(
"the threshold must be between 1 and the {} approvers",
keys.len()
)));
}
Ok(Self {
repository,
approvers,
threshold,
})
}
pub fn from_configuration(
configuration: &ProvenanceConfiguration,
) -> Result<Self, CodePushError> {
let approvers = TrustedKeys::from_hex(&configuration.approvers)
.map_err(|error| CodePushError::InvalidProvenance(format!("approvers: {error}")))?;
Self::new(
configuration.repository.clone(),
approvers,
configuration.threshold,
)
}
pub fn repository(&self) -> &str {
&self.repository
}
pub fn approvers(&self) -> &TrustedKeys {
&self.approvers
}
pub fn threshold(&self) -> usize {
self.threshold
}
pub fn verify(
&self,
manifest: &BundleManifest,
approvals: &[SignedApproval],
) -> Result<VerifiedSource, CodePushError> {
let release = || {
format!(
"{}/{}/{}+{}",
manifest.app, manifest.channel, manifest.version, manifest.build
)
};
if approvals.len() > MAX_APPROVALS {
return Err(invalid(format!(
"a release holds at most {MAX_APPROVALS} approvals"
)));
}
let bundle_hash = manifest.bundle_hash();
let mut counted: Vec<ReleasePublicKey> = Vec::new();
let mut commit: Option<String> = None;
for approval in approvals {
let Some(key) = ReleasePublicKey::from_hex(&approval.public_key)
.ok()
.filter(|key| self.approvers.contains(key))
else {
continue;
};
let Ok(statement) = approval.verify_with(&key) else {
continue;
};
if !statement.covers(manifest, &bundle_hash)
|| !same_repository(&statement.repository, &self.repository)
{
continue;
}
match &commit {
Some(named) if *named != statement.commit => {
return Err(CodePushError::ConflictingApprovals {
release: release(),
first: named.clone(),
second: statement.commit,
});
}
Some(_) => {}
None => commit = Some(statement.commit),
}
if !counted.contains(&key) {
counted.push(key);
}
}
let (Some(commit), true) = (commit, counted.len() >= self.threshold) else {
return Err(CodePushError::Unapproved {
release: release(),
approvals: counted.len(),
threshold: self.threshold,
});
};
Ok(VerifiedSource {
repository: self.repository.clone(),
commit,
approvers: counted.iter().map(ReleasePublicKey::to_hex).collect(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedSource {
pub repository: String,
pub commit: String,
pub approvers: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::MANIFEST_FORMAT_VERSION;
const REPOSITORY: &str = "https://github.com/example/app";
const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
const OTHER_COMMIT: &str = "89abcdef0123456789abcdef0123456789abcdef";
fn manifest() -> BundleManifest {
BundleManifest {
format_version: MANIFEST_FORMAT_VERSION,
app: "app".to_owned(),
channel: "production".to_owned(),
version: Version::new(1, 0, 0),
build: 2,
entry: "app.wasm".to_owned(),
files: Vec::new(),
created_unix: 0,
mandatory: false,
}
}
fn key(seed: u8) -> ReleaseSigningKey {
ReleaseSigningKey::from_seed(&[seed; 32])
}
fn policy(threshold: usize) -> ProvenancePolicy {
let approvers = TrustedKeys::new([key(1).public_key(), key(2).public_key()]).expect("keys");
ProvenancePolicy::new(REPOSITORY, approvers, threshold).expect("policy")
}
fn approve(seed: u8, manifest: &BundleManifest, commit: &str) -> SignedApproval {
let statement = SourceApproval::new(manifest, REPOSITORY, commit).expect("statement");
SignedApproval::sign(&statement, &key(seed)).expect("sign")
}
#[test]
fn repositories_must_be_plain_github_https_urls() {
for valid in [
REPOSITORY,
"https://github.com/Owner-1/repo_name.rs",
"https://github.com/o/r.s",
] {
validate_repository(valid).unwrap_or_else(|error| panic!("{valid}: {error}"));
}
for invalid in [
"",
"http://github.com/example/app",
"https://gitlab.com/example/app",
"https://github.com/example",
"https://github.com/example/app/",
"https://github.com/example/app.git",
"https://github.com/example/app.GIT",
"https://github.com/example/app/tree/main",
"https://github.com/example/app?tab=readme",
"https://github.com/example/app#readme",
"https://token@github.com/example/app",
"https://github.com:443/example/app",
"https://github.com/../app",
"https://github.com/example/a pp",
"https://github.com//app",
] {
let error = validate_repository(invalid).expect_err(invalid);
assert!(
invalid.is_empty() || !error.to_string().contains(invalid),
"the error never quotes the value: {error}"
);
}
}
#[test]
fn commits_must_be_full_lowercase_object_ids() {
validate_commit(COMMIT).expect("sha-1");
validate_commit(&"a".repeat(64)).expect("sha-256");
for invalid in ["", &COMMIT[..39], &COMMIT.to_uppercase(), &"g".repeat(40)] {
assert!(validate_commit(invalid).is_err(), "{invalid:?}");
}
}
#[test]
fn verifies_a_threshold_of_distinct_approvers_and_returns_the_commit() {
let manifest = manifest();
let approvals = [approve(1, &manifest, COMMIT), approve(2, &manifest, COMMIT)];
let source = policy(2).verify(&manifest, &approvals).expect("approved");
assert_eq!(source.commit, COMMIT);
assert_eq!(source.repository, REPOSITORY);
assert_eq!(
source.approvers,
[key(1).public_key().to_hex(), key(2).public_key().to_hex()]
);
}
#[test]
fn duplicate_approvals_from_one_key_count_once() {
let manifest = manifest();
let approval = approve(1, &manifest, COMMIT);
let approvals = [approval.clone(), approval, approve(1, &manifest, COMMIT)];
assert!(matches!(
policy(2).verify(&manifest, &approvals),
Err(CodePushError::Unapproved {
approvals: 1,
threshold: 2,
..
})
));
}
#[test]
fn approvers_naming_different_commits_are_an_error_not_a_vote() {
let manifest = manifest();
let approvals = [
approve(1, &manifest, COMMIT),
approve(2, &manifest, OTHER_COMMIT),
];
assert!(matches!(
policy(1).verify(&manifest, &approvals),
Err(CodePushError::ConflictingApprovals { first, second, .. })
if first == COMMIT && second == OTHER_COMMIT
));
}
#[test]
fn keys_outside_the_approver_set_count_for_nothing() {
let manifest = manifest();
let approvals = [approve(9, &manifest, COMMIT)];
assert!(matches!(
policy(1).verify(&manifest, &approvals),
Err(CodePushError::Unapproved { approvals: 0, .. })
));
let approvals = [
approve(9, &manifest, OTHER_COMMIT),
approve(1, &manifest, COMMIT),
];
assert_eq!(
policy(1).verify(&manifest, &approvals).expect("one").commit,
COMMIT
);
}
#[test]
fn approvals_of_another_release_or_repository_count_for_nothing() {
let manifest = manifest();
let mut other_build = manifest.clone();
other_build.build = 3;
let mut other_version = manifest.clone();
other_version.version = Version::new(1, 0, 1);
let mut other_files = manifest.clone();
other_files.mandatory = true;
let mut other_channel = manifest.clone();
other_channel.channel = "beta".to_owned();
for other in [other_build, other_version, other_files, other_channel] {
let approvals = [approve(1, &other, COMMIT)];
assert!(
matches!(
policy(1).verify(&manifest, &approvals),
Err(CodePushError::Unapproved { approvals: 0, .. })
),
"{other:?}"
);
}
let statement = SourceApproval::new(&manifest, "https://github.com/example/fork", COMMIT)
.expect("statement");
let approvals = [SignedApproval::sign(&statement, &key(1)).expect("sign")];
assert!(matches!(
policy(1).verify(&manifest, &approvals),
Err(CodePushError::Unapproved { approvals: 0, .. })
));
}
#[test]
fn repository_names_compare_without_case() {
let manifest = manifest();
let statement = SourceApproval::new(&manifest, "https://github.com/Example/App", COMMIT)
.expect("statement");
let approvals = [SignedApproval::sign(&statement, &key(1)).expect("sign")];
policy(1)
.verify(&manifest, &approvals)
.expect("same repository");
}
#[test]
fn tampered_statements_and_signatures_do_not_verify() {
let manifest = manifest();
let approval = approve(1, &manifest, COMMIT);
let mut changed = approval.clone();
changed.statement = changed.statement.replace(COMMIT, OTHER_COMMIT);
assert!(changed.open().is_err());
let mut reformatted = approval.clone();
reformatted.statement = format!(" {}", reformatted.statement);
assert!(reformatted.open().is_err());
let mut signature = approval.clone();
let flipped = if signature.signature.starts_with('0') {
"1"
} else {
"0"
};
signature.signature.replace_range(..1, flipped);
assert!(signature.open().is_err());
let mut short = approval.clone();
short.signature.truncate(64);
assert!(short.open().is_err());
for tampered in [changed, reformatted, signature, short] {
assert!(matches!(
policy(1).verify(&manifest, &[tampered]),
Err(CodePushError::Unapproved { approvals: 0, .. })
));
}
let (signer, statement) = approval.open().expect("intact");
assert_eq!(signer, key(1).public_key());
assert_eq!(statement.commit, COMMIT);
}
#[test]
fn a_release_signature_cannot_be_replayed_as_an_approval() {
let manifest = manifest();
let approver = key(1);
let release = approver.sign(&manifest);
for statement in [
manifest.bundle_hash(),
serde_json::to_string(&SourceApproval::new(&manifest, REPOSITORY, COMMIT).unwrap())
.unwrap(),
] {
let replayed = SignedApproval {
statement,
public_key: release.public_key.clone(),
signature: release.signature.clone(),
};
assert!(replayed.open().is_err());
assert!(policy(1).verify(&manifest, &[replayed]).is_err());
}
let approval = approve(1, &manifest, COMMIT);
let trusted = TrustedKeys::new([approver.public_key()]).expect("keys");
let forged = crate::signing::ReleaseSignature {
public_key: approval.public_key,
signature: approval.signature,
};
assert!(matches!(
trusted.verify(&manifest, Some(&forged)),
Err(CodePushError::BadSignature(_))
));
}
#[test]
fn statements_reject_unknown_fields_and_other_kinds() {
let manifest = manifest();
let statement = SourceApproval::new(&manifest, REPOSITORY, COMMIT).expect("statement");
let mut value = serde_json::to_value(&statement).expect("value");
value["note"] = "extra".into();
assert!(serde_json::from_value::<SourceApproval>(value).is_err());
let mut other = statement.clone();
other.kind = "something-else".to_owned();
assert!(SignedApproval::sign(&other, &key(1)).is_err());
let mut future = statement;
future.format_version = 2;
assert!(SignedApproval::sign(&future, &key(1)).is_err());
}
#[test]
fn oversized_inputs_are_refused_before_parsing() {
let mut approval = approve(1, &manifest(), COMMIT);
approval.statement = " ".repeat(MAX_STATEMENT_BYTES + 1);
assert!(approval.open().is_err());
assert!(
SignedApproval::from_slice(&vec![b' '; MAX_SIGNED_APPROVAL_BYTES as usize + 1])
.is_err()
);
assert!(
ReleaseApprovals::from_slice(&vec![b' '; MAX_APPROVALS_BYTES as usize + 1]).is_err()
);
let mut many = ReleaseApprovals::empty();
let approval = approve(1, &manifest(), COMMIT);
many.approvals = vec![approval; MAX_APPROVALS + 1];
let bytes = serde_json::to_vec(&many).expect("bytes");
assert!(ReleaseApprovals::from_slice(&bytes).is_err());
assert!(policy(1).verify(&manifest(), &many.approvals).is_err());
}
#[test]
fn merging_skips_approvals_already_attached() {
let manifest = manifest();
let mut approvals = ReleaseApprovals::empty();
let first = approve(1, &manifest, COMMIT);
let second = approve(2, &manifest, COMMIT);
assert_eq!(approvals.merge([first.clone()]).expect("merge"), 1);
assert_eq!(approvals.merge([first, second]).expect("merge"), 1);
assert_eq!(approvals.approvals.len(), 2);
let parsed =
ReleaseApprovals::from_slice(&approvals.to_vec().expect("bytes")).expect("parse");
assert_eq!(parsed, approvals);
}
#[test]
fn policies_need_a_reachable_threshold_and_distinct_approvers() {
let one = TrustedKeys::new([key(1).public_key()]).expect("keys");
assert!(ProvenancePolicy::new(REPOSITORY, one.clone(), 0).is_err());
assert!(ProvenancePolicy::new(REPOSITORY, one.clone(), 2).is_err());
assert!(ProvenancePolicy::new("https://example.com/a/b", one.clone(), 1).is_err());
let twice = TrustedKeys::new([key(1).public_key(), key(1).public_key()]).expect("keys");
assert!(matches!(
ProvenancePolicy::new(REPOSITORY, twice, 1),
Err(CodePushError::InvalidProvenance(_))
));
let configured = ProvenancePolicy::from_configuration(&ProvenanceConfiguration {
repository: REPOSITORY.to_owned(),
approvers: vec![key(1).public_key().to_hex()],
threshold: 1,
})
.expect("policy");
assert_eq!(
configured,
ProvenancePolicy::new(REPOSITORY, one, 1).unwrap()
);
}
}