use std::sync::Arc;
use auths_core::signing::StorageSigner;
use auths_core::storage::keychain::KeyAlias;
use auths_id::attestation::enriched::canonical_said;
use auths_id::keri::types::{Prefix, Said};
use auths_id::keri::{resolve_anchored_saids_via_backend, try_stage_anchor};
use auths_id::ports::registry::RegistryBackend;
use auths_id::storage::registry::backend::AtomicWriteBatch;
use serde::{Deserialize, Serialize};
use crate::context::AuthsContext;
use crate::domains::compliance::query::{ComplianceQueryError, ReleaseRecord};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ArtifactDigest(String);
impl ArtifactDigest {
pub fn parse(s: &str) -> Result<Self, ComplianceQueryError> {
let hex = s.strip_prefix("sha256:").ok_or_else(|| {
ComplianceQueryError::InvalidRelease(format!(
"artifact digest must start with 'sha256:' (got '{s}')"
))
})?;
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ComplianceQueryError::InvalidRelease(format!(
"artifact digest must be 64 hex characters after 'sha256:' (got {} chars)",
hex.len()
)));
}
Ok(Self(format!("sha256:{}", hex.to_ascii_lowercase())))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl TryFrom<String> for ArtifactDigest {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::parse(&s).map_err(|e| e.to_string())
}
}
impl From<ArtifactDigest> for String {
fn from(d: ArtifactDigest) -> Self {
d.0
}
}
impl std::fmt::Display for ArtifactDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReleaseAttestationKind {
#[serde(rename = "auths/org-release/v1")]
OrgReleaseV1,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleaseAttestation {
pub kind: ReleaseAttestationKind,
pub artifact_digest: ArtifactDigest,
pub signer: Prefix,
}
impl ReleaseAttestation {
pub fn parse(bytes: &[u8]) -> Result<Self, ComplianceQueryError> {
serde_json::from_slice(bytes)
.map_err(|e| ComplianceQueryError::InvalidRelease(e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AnchoredRelease {
pub artifact_digest: ArtifactDigest,
pub signer: Prefix,
pub signed_at: u128,
pub attestation_said: Said,
}
pub fn attest_release(
ctx: &AuthsContext,
org_prefix: &Prefix,
org_alias: &KeyAlias,
artifact_digest: ArtifactDigest,
signer: Prefix,
) -> Result<AnchoredRelease, ComplianceQueryError> {
let attestation = ReleaseAttestation {
kind: ReleaseAttestationKind::OrgReleaseV1,
artifact_digest,
signer,
};
let canonical = json_canon::to_string(&attestation)
.map_err(|e| ComplianceQueryError::Canonicalize(e.to_string()))?;
let org_signer = StorageSigner::new(Arc::clone(&ctx.key_storage));
let mut batch = AtomicWriteBatch::new();
let (attestation_said, ixn, _attachment) = try_stage_anchor(
ctx.registry.as_ref(),
&org_signer,
org_alias,
ctx.passphrase_provider.as_ref(),
org_prefix,
&attestation,
&mut batch,
)?;
batch.stage_credential(
org_prefix.clone(),
attestation_said.clone(),
canonical.into_bytes(),
);
ctx.registry
.commit_batch(&batch)
.map_err(|e| ComplianceQueryError::Registry(e.to_string()))?;
Ok(AnchoredRelease {
artifact_digest: attestation.artifact_digest,
signer: attestation.signer,
signed_at: ixn.s.value(),
attestation_said,
})
}
pub fn discover_releases(
backend: &dyn RegistryBackend,
org_prefix: &Prefix,
) -> Result<Vec<ReleaseRecord>, ComplianceQueryError> {
let anchored = resolve_anchored_saids_via_backend(backend, org_prefix, None)?;
let mut records = Vec::new();
for (seq, said) in anchored {
let Some(bytes) = backend
.load_credential(org_prefix, &said)
.map_err(|e| ComplianceQueryError::Registry(e.to_string()))?
else {
continue; };
let Ok(attestation) = ReleaseAttestation::parse(&bytes) else {
continue; };
if canonical_said(&attestation).as_ref() != Some(&said) {
return Err(ComplianceQueryError::TamperedRelease(
said.as_str().to_string(),
));
}
records.push(ReleaseRecord {
artifact_digest: attestation.artifact_digest.into_inner(),
signer_prefix: attestation.signer,
signed_at: Some(seq),
transparency: None,
});
}
Ok(records)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
const HEX64: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[test]
fn artifact_digest_parses_and_normalizes() {
let upper = format!("sha256:{}", HEX64.to_ascii_uppercase());
let d = ArtifactDigest::parse(&upper).unwrap();
assert_eq!(d.as_str(), format!("sha256:{HEX64}"));
}
#[test]
fn artifact_digest_rejects_malformed_input() {
for bad in [
"aaaa",
"sha256:",
"sha256:zz",
"sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
] {
assert!(
ArtifactDigest::parse(bad).is_err(),
"'{bad}' must be rejected"
);
}
}
#[test]
fn release_attestation_round_trips_canonically() {
let att = ReleaseAttestation {
kind: ReleaseAttestationKind::OrgReleaseV1,
artifact_digest: ArtifactDigest::parse(&format!("sha256:{HEX64}")).unwrap(),
signer: Prefix::new_unchecked("EMember".to_string()),
};
let canonical = json_canon::to_string(&att).unwrap();
let back = ReleaseAttestation::parse(canonical.as_bytes()).unwrap();
assert_eq!(att, back);
assert_eq!(canonical, json_canon::to_string(&back).unwrap());
}
#[test]
fn parse_rejects_foreign_blobs() {
let wrong_kind = format!(
r#"{{"kind":"auths/device-link/v1","artifact_digest":"sha256:{HEX64}","signer":"EMember"}}"#
);
assert!(ReleaseAttestation::parse(wrong_kind.as_bytes()).is_err());
let foreign = br#"{"subject":"did:keri:EMember","org_role":"member"}"#;
assert!(ReleaseAttestation::parse(foreign).is_err());
let extra = format!(
r#"{{"kind":"auths/org-release/v1","artifact_digest":"sha256:{HEX64}","signer":"EMember","x":1}}"#
);
assert!(ReleaseAttestation::parse(extra.as_bytes()).is_err());
}
}