//! Sigstore bundle loading, verification, and identity extraction
//!
//! Wraps the `sigstore-verify` crate to provide bundle parsing, cryptographic
//! verification, and signer identity extraction integrated with nono's trust
//! policy types.
//!
//! # Bundle Format
//!
//! Bundles follow the Sigstore bundle v0.3 JSON format and contain:
//! - A DSSE envelope (or message signature) with the signed payload
//! - Verification material (Fulcio certificate or public key hint)
//! - Transparency log entries (Rekor inclusion proof)
//!
//! # Fulcio Certificate Extensions
//!
//! Keyless bundles contain a Fulcio-issued certificate with OIDC identity
//! claims encoded as X.509 extensions. We read v2 OIDs with v1 fallbacks:
//!
//! | OID (v2) | OID (v1) | Field | Description |
//! |----------|----------|-------|-------------|
//! | .1.1 | — | Issuer | OIDC issuer URL |
//! | .1.12 | .1.5 | Source Repository | `org/repo` (v2 URI normalized) |
//! | .1.14 | .1.6 | Source Repository Ref | Git ref at signing time |
//! | .1.18 | — | Build Config URI | Workflow path (v2 URI normalized) |
use crate::error::{NonoError, Result};
use crate::trust::types::SignerIdentity;
use std::path::Path;
// Re-export key sigstore types for downstream consumers
pub use sigstore_verify::crypto::CertificateInfo;
pub use sigstore_verify::trust_root::TrustedRoot;
pub use sigstore_verify::types::{Bundle, DerPublicKey, Sha256Hash};
pub use sigstore_verify::{VerificationPolicy, VerificationResult as SigstoreVerificationResult};
// Internal-only imports from sigstore
use sigstore_verify::crypto::parse_certificate_info;
use sigstore_verify::types::bundle::VerificationMaterialContent;
// ---------------------------------------------------------------------------
// Fulcio certificate extension OIDs
// ---------------------------------------------------------------------------
//
// Fulcio v2 extensions (1.3.6.1.4.1.57264.1.x) changed semantics from v1.
// The OIDs below are the correct v2 mappings for GitHub Actions OIDC certs.
//
// Reference: https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md
/// Fulcio OID for source repository URI: 1.3.6.1.4.1.57264.1.12
/// Value: `https://github.com/<org>/<repo>` (full URI, not just `org/repo`)
const OID_SOURCE_REPOSITORY_URI: &str = "1.3.6.1.4.1.57264.1.12";
/// Fulcio OID for source repository ref: 1.3.6.1.4.1.57264.1.14
/// Value: `refs/heads/main` (git ref at signing time)
const OID_SOURCE_REPOSITORY_REF: &str = "1.3.6.1.4.1.57264.1.14";
/// Fulcio OID for build config URI (workflow): 1.3.6.1.4.1.57264.1.18
/// Value: `https://github.com/<org>/<repo>/.github/workflows/<file>@<ref>`
const OID_BUILD_CONFIG_URI: &str = "1.3.6.1.4.1.57264.1.18";
/// Fulcio v1 OID for source repository (short form): 1.3.6.1.4.1.57264.1.5
/// Value: `<org>/<repo>` (v1 fallback, no URI prefix)
const OID_SOURCE_REPOSITORY_V1: &str = "1.3.6.1.4.1.57264.1.5";
/// Fulcio v1 OID for source repository ref: 1.3.6.1.4.1.57264.1.6
/// Value: `refs/heads/main` (same semantics as v2 .1.14)
const OID_SOURCE_REPOSITORY_REF_V1: &str = "1.3.6.1.4.1.57264.1.6";
// ---------------------------------------------------------------------------
// Bundle loading
// ---------------------------------------------------------------------------
/// Load a Sigstore bundle from a JSON file.
///
/// Bundle files are typically named `<artifact>.bundle` (e.g., `SKILLS.md.bundle`).
///
/// # Errors
///
/// Returns `NonoError::Io` if the file cannot be read, or
/// `NonoError::TrustVerification` if the JSON is malformed.
pub fn load_bundle<P: AsRef<Path>>(path: P) -> Result<Bundle> {
let path = path.as_ref();
let json = std::fs::read_to_string(path).map_err(NonoError::Io)?;
load_bundle_from_str(&json, path)
}
/// Parse a Sigstore bundle from a JSON string.
///
/// The `source_path` is used only for error messages.
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if the JSON is invalid.
pub fn load_bundle_from_str(json: &str, source_path: &Path) -> Result<Bundle> {
Bundle::from_json(json).map_err(|e| NonoError::TrustVerification {
path: source_path.display().to_string(),
reason: format!("failed to parse bundle: {e}"),
})
}
// ---------------------------------------------------------------------------
// Trust root loading
// ---------------------------------------------------------------------------
/// Load a Sigstore trusted root from a JSON file.
///
/// The trusted root contains Fulcio CA certificates, Rekor public keys,
/// and TSA certificates needed for verification.
///
/// # Errors
///
/// Returns `NonoError::TrustPolicy` if the file cannot be read or parsed.
pub fn load_trusted_root<P: AsRef<Path>>(path: P) -> Result<TrustedRoot> {
TrustedRoot::from_file(path.as_ref())
.map_err(|e| NonoError::TrustPolicy(format!("failed to load trusted root: {e}")))
}
/// Load a Sigstore trusted root from a JSON string.
///
/// # Errors
///
/// Returns `NonoError::TrustPolicy` if the JSON is invalid.
pub fn load_trusted_root_from_str(json: &str) -> Result<TrustedRoot> {
TrustedRoot::from_json(json)
.map_err(|e| NonoError::TrustPolicy(format!("failed to parse trusted root: {e}")))
}
/// Load the production Sigstore trusted root (embedded).
///
/// This uses the Sigstore public good instance trusted root that is
/// embedded in the `sigstore-trust-root` crate.
///
/// # Errors
///
/// Returns `NonoError::TrustPolicy` if the embedded root cannot be loaded.
pub fn load_production_trusted_root() -> Result<TrustedRoot> {
TrustedRoot::production()
.map_err(|e| NonoError::TrustPolicy(format!("failed to load production trusted root: {e}")))
}
// ---------------------------------------------------------------------------
// Bundle verification
// ---------------------------------------------------------------------------
/// Verify a Sigstore bundle against an artifact's content.
///
/// Performs the full Sigstore verification pipeline:
/// 1. Bundle structural validation
/// 2. Certificate chain verification (Fulcio CA -> signing cert)
/// 3. Transparency log inclusion proof (Rekor)
/// 4. Signature verification (ECDSA over DSSE PAE)
/// 5. Artifact digest match (SHA-256 in in-toto statement vs actual file)
/// 6. Policy checks (identity/issuer matching)
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if any verification step fails.
pub fn verify_bundle(
artifact_bytes: &[u8],
bundle: &Bundle,
trusted_root: &TrustedRoot,
policy: &VerificationPolicy,
artifact_path: &Path,
) -> Result<SigstoreVerificationResult> {
sigstore_verify::verify(artifact_bytes, bundle, policy, trusted_root).map_err(|e| {
NonoError::TrustVerification {
path: artifact_path.display().to_string(),
reason: format!("{e}"),
}
})
}
/// Verify a Sigstore bundle using a pre-computed SHA-256 digest.
///
/// This avoids re-reading the artifact when the digest is already known
/// (e.g., from blocklist checking earlier in the pipeline).
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if verification fails, or if
/// the digest hex string is invalid.
pub fn verify_bundle_with_digest(
digest_hex: &str,
bundle: &Bundle,
trusted_root: &TrustedRoot,
policy: &VerificationPolicy,
artifact_path: &Path,
) -> Result<SigstoreVerificationResult> {
let hash = Sha256Hash::from_hex(digest_hex).map_err(|e| NonoError::TrustVerification {
path: artifact_path.display().to_string(),
reason: format!("invalid digest hex: {e}"),
})?;
sigstore_verify::verify(hash, bundle, policy, trusted_root).map_err(|e| {
NonoError::TrustVerification {
path: artifact_path.display().to_string(),
reason: format!("{e}"),
}
})
}
/// Verify a Sigstore bundle using a provided public key (keyed signing).
///
/// Used for bundles signed with a managed key (from the system keystore)
/// rather than a Fulcio-issued certificate.
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if verification fails.
pub fn verify_bundle_keyed(
artifact_bytes: &[u8],
bundle: &Bundle,
public_key: &DerPublicKey,
trusted_root: &TrustedRoot,
artifact_path: &Path,
) -> Result<SigstoreVerificationResult> {
sigstore_verify::verify_with_key(artifact_bytes, bundle, public_key, trusted_root).map_err(
|e| NonoError::TrustVerification {
path: artifact_path.display().to_string(),
reason: format!("{e}"),
},
)
}
/// Verify a keyed bundle's ECDSA signature directly without a Sigstore TrustedRoot.
///
/// For keyed bundles (signed with a managed ECDSA P-256 key), this verifies the
/// DSSE envelope signature against the provided SPKI public key. This is the
/// standalone keyed verification path — it does not require Fulcio certificates,
/// Rekor transparency logs, or a TrustedRoot.
///
/// # Verification steps
///
/// 1. Serialize the bundle and extract the DSSE envelope
/// 2. Decode the payload and compute PAE(payloadType, payload)
/// 3. Decode the signature from the first signature entry
/// 4. Verify ECDSA P-256 SHA-256 signature over the PAE using the public key
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if any step fails (missing envelope
/// components, invalid encoding, or signature mismatch).
pub fn verify_keyed_signature(
bundle: &Bundle,
public_key_der: &[u8],
artifact_path: &Path,
) -> Result<()> {
use sigstore_verify::crypto::signing::SigningScheme;
use sigstore_verify::crypto::verification::VerificationKey;
use sigstore_verify::types::SignatureBytes;
let contents = DsseContents::from_bundle(bundle, artifact_path)?;
let sig_b64 = contents.signature_b64(artifact_path)?;
// Compute PAE over the payload
let pae_bytes = sigstore_verify::types::dsse::pae(
crate::trust::dsse::IN_TOTO_PAYLOAD_TYPE,
&contents.payload_bytes,
);
// Decode signature
let sig_bytes =
SignatureBytes::from_base64(&sig_b64).map_err(|e| NonoError::TrustVerification {
path: artifact_path.display().to_string(),
reason: format!("invalid base64 signature: {e}"),
})?;
// Verify ECDSA P-256 signature over the PAE
let pub_key = DerPublicKey::from(public_key_der.to_vec());
let vk = VerificationKey::from_spki(&pub_key, SigningScheme::EcdsaP256Sha256).map_err(|e| {
NonoError::TrustVerification {
path: artifact_path.display().to_string(),
reason: format!("invalid public key: {e}"),
}
})?;
vk.verify(&pae_bytes, &sig_bytes)
.map_err(|e| NonoError::TrustVerification {
path: artifact_path.display().to_string(),
reason: format!("ECDSA signature verification failed: {e}"),
})
}
// ---------------------------------------------------------------------------
// Pre-parsed DSSE envelope contents
// ---------------------------------------------------------------------------
/// Pre-parsed DSSE envelope contents from a Sigstore bundle.
///
/// Avoids repeated `bundle.to_json()` -> `serde_json::from_str()` round-trips
/// when multiple extraction functions need the same envelope fields.
struct DsseContents {
/// The raw bundle JSON as a `serde_json::Value` (for signature extraction)
bundle_value: serde_json::Value,
/// Decoded DSSE payload bytes
payload_bytes: Vec<u8>,
/// The in-toto statement parsed from the DSSE payload
statement: serde_json::Value,
}
impl DsseContents {
/// Parse DSSE envelope contents from a bundle.
fn from_bundle(bundle: &Bundle, context_path: &Path) -> Result<Self> {
let bundle_json = bundle.to_json().map_err(|e| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: format!("failed to serialize bundle: {e}"),
})?;
let bundle_value: serde_json::Value =
serde_json::from_str(&bundle_json).map_err(|e| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: format!("invalid bundle JSON: {e}"),
})?;
let payload_b64 = bundle_value["dsseEnvelope"]["payload"]
.as_str()
.ok_or_else(|| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: "missing DSSE payload".to_string(),
})?;
let payload_decoded = sigstore_verify::types::PayloadBytes::from_base64(payload_b64)
.map_err(|e| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: format!("invalid DSSE payload encoding: {e}"),
})?;
let payload_str = std::str::from_utf8(payload_decoded.as_bytes()).map_err(|e| {
NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: format!("DSSE payload is not UTF-8: {e}"),
}
})?;
let statement: serde_json::Value =
serde_json::from_str(payload_str).map_err(|e| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: format!("invalid statement JSON: {e}"),
})?;
Ok(Self {
bundle_value,
payload_bytes: payload_decoded.as_bytes().to_vec(),
statement,
})
}
/// Extract the `subject[0].digest.sha256` from the in-toto statement.
fn subject_digest(&self, context_path: &Path) -> Result<String> {
self.statement["subject"][0]["digest"]["sha256"]
.as_str()
.map(String::from)
.ok_or_else(|| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: "no sha256 digest in statement subject".to_string(),
})
}
/// Extract the `subject[0].name` from the in-toto statement.
fn subject_name(&self, context_path: &Path) -> Result<String> {
self.statement["subject"][0]["name"]
.as_str()
.map(String::from)
.ok_or_else(|| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: "no subject name in statement".to_string(),
})
}
/// Extract the `predicateType` from the in-toto statement.
fn predicate_type(&self, context_path: &Path) -> Result<String> {
self.statement["predicateType"]
.as_str()
.map(String::from)
.ok_or_else(|| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: "missing predicateType in statement".to_string(),
})
}
/// Extract the base64-encoded signature from the DSSE envelope.
fn signature_b64(&self, context_path: &Path) -> Result<String> {
self.bundle_value["dsseEnvelope"]["signatures"][0]["sig"]
.as_str()
.map(String::from)
.ok_or_else(|| NonoError::TrustVerification {
path: context_path.display().to_string(),
reason: "missing DSSE signature".to_string(),
})
}
}
// ---------------------------------------------------------------------------
// Bundle digest extraction
// ---------------------------------------------------------------------------
/// Extract the SHA-256 digest from a bundle's DSSE envelope payload.
///
/// Parses the bundle's DSSE envelope, decodes the base64 payload as an
/// in-toto statement, and returns the `subject[0].digest.sha256` value.
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if the bundle cannot be serialized,
/// the DSSE payload is missing or malformed, or the statement lacks a digest.
pub fn extract_bundle_digest(bundle: &Bundle, bundle_path: &Path) -> Result<String> {
let contents = DsseContents::from_bundle(bundle, bundle_path)?;
contents.subject_digest(bundle_path)
}
/// Verify that the bundle's in-toto subject name matches the expected filename.
///
/// Extracts `subject[0].name` from the DSSE payload and compares it against
/// the filename component of `expected_path`. This prevents bundle-swapping
/// attacks where a valid bundle signed for file A is used to vouch for file B.
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if the subject name cannot be extracted
/// or does not match the expected filename.
pub fn verify_bundle_subject_name(bundle: &Bundle, expected_path: &Path) -> Result<()> {
let contents = DsseContents::from_bundle(bundle, expected_path)?;
let subject_name = contents.subject_name(expected_path)?;
let expected_name = expected_path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
if subject_name != expected_name.as_ref() {
return Err(NonoError::TrustVerification {
path: expected_path.display().to_string(),
reason: format!(
"bundle subject name '{}' does not match expected filename '{}'",
subject_name, expected_name
),
});
}
Ok(())
}
// ---------------------------------------------------------------------------
// Predicate type extraction
// ---------------------------------------------------------------------------
/// Extract the `predicateType` from a bundle's DSSE envelope payload.
///
/// Returns the predicate type string (e.g., `nono.sh/attestation/instruction-file/v1`
/// or `nono.sh/attestation/trust-policy/v1`).
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if the bundle cannot be parsed or
/// the predicate type is missing.
pub fn extract_predicate_type(bundle: &Bundle, bundle_path: &Path) -> Result<String> {
let contents = DsseContents::from_bundle(bundle, bundle_path)?;
contents.predicate_type(bundle_path)
}
// ---------------------------------------------------------------------------
// Identity extraction
// ---------------------------------------------------------------------------
/// Extract the signer identity from a Sigstore bundle's verification material.
///
/// For bundles with a Fulcio certificate (keyless), extracts:
/// - OIDC issuer (OID .1.1)
/// - Source repository (OID .1.12, v1 fallback .1.5)
/// - Build config / workflow (OID .1.18)
/// - Source repository ref (OID .1.14, v1 fallback .1.6)
///
/// For bundles with a public key hint (keyed), extracts the human key ID
/// from the DSSE predicate's `signer.key_id` field. Falls back to the
/// public key hint (fingerprint) if predicate extraction fails.
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if the certificate cannot be
/// parsed or lacks required identity fields.
pub fn extract_signer_identity(bundle: &Bundle, bundle_path: &Path) -> Result<SignerIdentity> {
match &bundle.verification_material.content {
VerificationMaterialContent::PublicKey { hint } => {
// Try to read the human key_id from the DSSE predicate
match extract_keyed_identity_from_dsse(bundle, bundle_path) {
Ok(identity) => Ok(identity),
Err(_) => {
// Fall back to the public key hint (fingerprint)
Ok(SignerIdentity::Keyed {
key_id: hint.clone(),
})
}
}
}
VerificationMaterialContent::Certificate(cert_content) => {
extract_identity_from_cert(cert_content.raw_bytes.as_bytes(), bundle_path)
}
VerificationMaterialContent::X509CertificateChain { certificates } => {
let leaf = certificates
.first()
.ok_or_else(|| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: "empty certificate chain".to_string(),
})?;
extract_identity_from_cert(leaf.raw_bytes.as_bytes(), bundle_path)
}
}
}
/// Extract the human key_id from a keyed bundle's DSSE predicate.
///
/// Parses the bundle's DSSE envelope payload as an in-toto statement and
/// reads `predicate.signer.key_id`. This gives the operator-chosen key alias
/// (e.g., "default") rather than the cryptographic fingerprint in the hint.
fn extract_keyed_identity_from_dsse(bundle: &Bundle, bundle_path: &Path) -> Result<SignerIdentity> {
let contents = DsseContents::from_bundle(bundle, bundle_path)?;
let payload_str =
std::str::from_utf8(&contents.payload_bytes).map_err(|e| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: format!("DSSE payload is not UTF-8: {e}"),
})?;
let statement = crate::trust::dsse::InTotoStatement::from_json(payload_str)?;
statement.extract_signer()
}
/// Extract signer identity fields from a DER-encoded Fulcio certificate.
fn extract_identity_from_cert(cert_der: &[u8], bundle_path: &Path) -> Result<SignerIdentity> {
let cert_info = parse_certificate_info(cert_der).map_err(|e| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: format!("failed to parse signing certificate: {e}"),
})?;
let issuer = cert_info
.issuer
.ok_or_else(|| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: "signing certificate missing OIDC issuer extension".to_string(),
})?;
// Extract extended Fulcio OIDs from the raw certificate
let extensions = extract_fulcio_extensions(cert_der, bundle_path)?;
let repository = extensions
.repository
.ok_or_else(|| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: "signing certificate missing source repository extension (OID .1.12 or .1.5)"
.to_string(),
})?;
let workflow = extensions
.workflow
.ok_or_else(|| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: "signing certificate missing build config/workflow extension (OID .1.18)"
.to_string(),
})?;
let git_ref = extensions
.git_ref
.ok_or_else(|| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: "signing certificate missing source ref extension (OID .1.14 or .1.6)"
.to_string(),
})?;
Ok(SignerIdentity::Keyless {
issuer,
repository,
workflow,
git_ref,
})
}
/// Fulcio certificate extension values beyond what `sigstore-crypto` extracts.
struct FulcioExtensions {
repository: Option<String>,
workflow: Option<String>,
git_ref: Option<String>,
}
/// Extract Fulcio v2 extension values from a DER-encoded certificate.
///
/// Parses the X.509 certificate and reads the OID extensions for
/// source repository, build config (workflow), and source ref.
fn extract_fulcio_extensions(cert_der: &[u8], bundle_path: &Path) -> Result<FulcioExtensions> {
use der::Decode;
use x509_cert::Certificate;
let cert = Certificate::from_der(cert_der).map_err(|e| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: format!("failed to decode certificate DER: {e}"),
})?;
let extensions = match &cert.tbs_certificate.extensions {
Some(exts) => exts,
None => {
return Ok(FulcioExtensions {
repository: None,
workflow: None,
git_ref: None,
});
}
};
let mut repository_uri = None; // v2: full URI
let mut repository_v1 = None; // v1: org/repo
let mut workflow = None;
let mut git_ref = None;
let mut git_ref_v1 = None;
for ext in extensions.iter() {
let oid_str = ext.extn_id.to_string();
match oid_str.as_str() {
OID_SOURCE_REPOSITORY_URI => {
repository_uri = decode_utf8_extension(ext.extn_value.as_bytes());
}
OID_SOURCE_REPOSITORY_V1 => {
repository_v1 = decode_utf8_extension(ext.extn_value.as_bytes());
}
OID_BUILD_CONFIG_URI => {
workflow = decode_utf8_extension(ext.extn_value.as_bytes());
}
OID_SOURCE_REPOSITORY_REF => {
git_ref = decode_utf8_extension(ext.extn_value.as_bytes());
}
OID_SOURCE_REPOSITORY_REF_V1 => {
git_ref_v1 = decode_utf8_extension(ext.extn_value.as_bytes());
}
_ => {}
}
}
// Normalize repository URI to org/repo form for policy matching.
// v2 value: "https://github.com/org/repo" -> "org/repo"
// v1 value: "org/repo" (already correct, but normalization handles edge cases)
let repository = repository_uri
.or(repository_v1)
.map(|uri| normalize_github_uri(&uri));
// Normalize v2 build config URI to relative workflow path.
// v2 value: "https://github.com/org/repo/.github/workflows/file.yml@refs/heads/main"
// -> ".github/workflows/file.yml"
// No v1 fallback for workflow — it's a v2-only OID.
let workflow = workflow.map(|uri| normalize_workflow_uri(&uri));
Ok(FulcioExtensions {
repository,
workflow,
git_ref: git_ref.or(git_ref_v1),
})
}
/// Normalize a GitHub repository URI to `org/repo` form.
///
/// Strips the `https://github.com/` prefix if present. Falls back to the
/// original value if the prefix is absent (e.g., v1 certs or non-GitHub issuers).
fn normalize_github_uri(uri: &str) -> String {
uri.strip_prefix("https://github.com/")
.unwrap_or(uri)
.trim_end_matches('/')
.to_string()
}
/// Normalize a Fulcio v2 build config URI to a relative workflow path.
///
/// v2 value: `https://github.com/org/repo/.github/workflows/file.yml@refs/heads/main`
/// Result: `.github/workflows/file.yml`
///
/// Strips the `https://github.com/<org>/<repo>/` prefix and the `@<ref>` suffix.
/// Falls back to the original value if the format is unexpected.
fn normalize_workflow_uri(uri: &str) -> String {
// Strip the @ref suffix first (take everything before the last @)
let without_ref = if let Some(idx) = uri.rfind('@') {
&uri[..idx]
} else {
uri
};
// Strip the https://github.com/org/repo/ prefix to get relative path
if let Some(rest) = without_ref.strip_prefix("https://github.com/") {
// rest = "org/repo/.github/workflows/file.yml"
// Skip org/repo (first two path segments) to get ".github/..."
let mut segments = rest.splitn(3, '/');
let _org = segments.next();
let _repo = segments.next();
if let Some(relative) = segments.next() {
return relative.trim_end_matches('/').to_string();
}
}
// Fallback: return as-is
without_ref.trim_end_matches('/').to_string()
}
/// Decode an X.509 extension value as a UTF-8 string.
///
/// Tries DER-encoded UTF8String first, then raw bytes as UTF-8 fallback.
fn decode_utf8_extension(value_bytes: &[u8]) -> Option<String> {
// Try DER-encoded UTF8String
if let Ok(utf8_str) = <der::asn1::Utf8StringRef<'_> as der::Decode>::from_der(value_bytes) {
return Some(utf8_str.to_string());
}
// Fallback: interpret raw bytes as UTF-8
std::str::from_utf8(value_bytes).ok().map(String::from)
}
// ---------------------------------------------------------------------------
// Helper: resolve bundle path from artifact path
// ---------------------------------------------------------------------------
/// Resolve the bundle file path for a given artifact.
///
/// Follows the convention `<artifact>.bundle` (e.g., `SKILLS.md.bundle`).
#[must_use]
pub fn bundle_path_for(artifact_path: &Path) -> std::path::PathBuf {
let mut bundle = artifact_path.as_os_str().to_owned();
bundle.push(".bundle");
std::path::PathBuf::from(bundle)
}
/// Resolve the conventional path for a multi-subject trust bundle.
///
/// Multi-subject bundles are stored as `.nono-trust.bundle` in the given
/// directory (typically the project root or skill directory).
#[must_use]
pub fn multi_subject_bundle_path(dir_path: &Path) -> std::path::PathBuf {
dir_path.join(".nono-trust.bundle")
}
// ---------------------------------------------------------------------------
// Multi-subject extraction
// ---------------------------------------------------------------------------
/// Extract all subjects from a bundle's DSSE envelope.
///
/// Returns `Vec<(name, sha256_hex)>` — one entry per subject in the
/// in-toto statement. Works for both single-subject and multi-subject bundles.
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if the bundle cannot be parsed
/// or any subject is missing a SHA-256 digest.
pub fn extract_all_subjects(bundle: &Bundle, bundle_path: &Path) -> Result<Vec<(String, String)>> {
let contents = DsseContents::from_bundle(bundle, bundle_path)?;
let subjects = contents
.statement
.get("subject")
.and_then(|v| v.as_array())
.ok_or_else(|| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: "no subjects array in statement".to_string(),
})?;
let mut result = Vec::with_capacity(subjects.len());
for subject in subjects {
let name = subject
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: "subject missing name".to_string(),
})?;
let digest = subject
.get("digest")
.and_then(|v| v.get("sha256"))
.and_then(|v| v.as_str())
.ok_or_else(|| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: format!("subject '{name}' missing sha256 digest"),
})?;
result.push((name.to_string(), digest.to_string()));
}
Ok(result)
}
// ---------------------------------------------------------------------------
// Helper: extract CertificateInfo (re-export for CLI use)
// ---------------------------------------------------------------------------
/// Parse certificate info from a DER-encoded certificate.
///
/// Thin wrapper around `sigstore_crypto::parse_certificate_info` that maps
/// errors to `NonoError`.
///
/// # Errors
///
/// Returns `NonoError::TrustVerification` if the certificate cannot be parsed.
pub fn parse_cert_info(cert_der: &[u8], bundle_path: &Path) -> Result<CertificateInfo> {
parse_certificate_info(cert_der).map_err(|e| NonoError::TrustVerification {
path: bundle_path.display().to_string(),
reason: format!("failed to parse certificate: {e}"),
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
// -----------------------------------------------------------------------
// bundle_path_for
// -----------------------------------------------------------------------
#[test]
fn bundle_path_for_appends_extension() {
let path = Path::new("SKILLS.md");
assert_eq!(bundle_path_for(path), Path::new("SKILLS.md.bundle"));
}
#[test]
fn bundle_path_for_nested_path() {
let path = Path::new(".claude/commands/deploy.md");
assert_eq!(
bundle_path_for(path),
Path::new(".claude/commands/deploy.md.bundle")
);
}
#[test]
fn bundle_path_for_absolute_path() {
let path = Path::new("/home/user/project/CLAUDE.md");
assert_eq!(
bundle_path_for(path),
Path::new("/home/user/project/CLAUDE.md.bundle")
);
}
// -----------------------------------------------------------------------
// load_bundle_from_str
// -----------------------------------------------------------------------
#[test]
fn load_bundle_invalid_json() {
let result = load_bundle_from_str("not json", Path::new("test.bundle"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("failed to parse bundle"));
}
#[test]
fn load_bundle_missing_fields() {
let json = r#"{"mediaType": "test"}"#;
let result = load_bundle_from_str(json, Path::new("test.bundle"));
assert!(result.is_err());
}
// -----------------------------------------------------------------------
// load_bundle from file
// -----------------------------------------------------------------------
#[test]
fn load_bundle_nonexistent_file() {
let result = load_bundle(Path::new("/nonexistent/path/test.bundle"));
assert!(result.is_err());
}
// -----------------------------------------------------------------------
// load_trusted_root
// -----------------------------------------------------------------------
#[test]
fn load_trusted_root_invalid_json() {
let result = load_trusted_root_from_str("not json");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("failed to parse trusted root"));
}
#[test]
fn load_trusted_root_nonexistent_file() {
let result = load_trusted_root(Path::new("/nonexistent/trusted_root.json"));
assert!(result.is_err());
}
#[test]
fn load_production_trusted_root_succeeds() {
let root = load_production_trusted_root();
assert!(root.is_ok());
}
// -----------------------------------------------------------------------
// extract_signer_identity
// -----------------------------------------------------------------------
#[test]
fn extract_identity_public_key_bundle() {
let json = make_public_key_bundle_json("nono-keystore:my-key");
let bundle = Bundle::from_json(&json).unwrap();
let identity = extract_signer_identity(&bundle, Path::new("test.bundle")).unwrap();
match identity {
SignerIdentity::Keyed { key_id } => {
assert_eq!(key_id, "nono-keystore:my-key");
}
SignerIdentity::Keyless { .. } => panic!("expected keyed identity"),
}
}
#[test]
fn extract_identity_empty_cert_chain() {
let json = make_empty_cert_chain_bundle_json();
let bundle = Bundle::from_json(&json).unwrap();
let result = extract_signer_identity(&bundle, Path::new("test.bundle"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("empty certificate chain"));
}
// -----------------------------------------------------------------------
// verify_bundle_with_digest
// -----------------------------------------------------------------------
#[test]
fn verify_bundle_with_invalid_digest() {
let json = make_public_key_bundle_json("key");
let bundle = Bundle::from_json(&json).unwrap();
let root = load_production_trusted_root().unwrap();
let policy = VerificationPolicy::default();
let result =
verify_bundle_with_digest("not-hex!", &bundle, &root, &policy, Path::new("test"));
assert!(result.is_err());
}
// -----------------------------------------------------------------------
// decode_utf8_extension
// -----------------------------------------------------------------------
#[test]
fn decode_utf8_extension_raw_bytes() {
let value = b"https://github.com/org/repo";
let result = decode_utf8_extension(value);
assert_eq!(result, Some("https://github.com/org/repo".to_string()));
}
#[test]
fn decode_utf8_extension_invalid_utf8() {
let value = &[0xFF, 0xFE, 0x00];
let result = decode_utf8_extension(value);
assert!(result.is_none());
}
// -----------------------------------------------------------------------
// Helpers for constructing test bundles
// -----------------------------------------------------------------------
fn make_public_key_bundle_json(key_hint: &str) -> String {
format!(
r#"{{
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
"verificationMaterial": {{
"publicKey": {{
"hint": "{key_hint}"
}},
"tlogEntries": []
}},
"dsseEnvelope": {{
"payloadType": "application/vnd.in-toto+json",
"payload": "e30=",
"signatures": [
{{
"keyid": "",
"sig": "AAAA"
}}
]
}}
}}"#
)
}
// -----------------------------------------------------------------------
// normalize_github_uri
// -----------------------------------------------------------------------
#[test]
fn normalize_github_uri_strips_prefix() {
assert_eq!(
normalize_github_uri("https://github.com/always-further/test-sk-prov"),
"always-further/test-sk-prov"
);
}
#[test]
fn normalize_github_uri_passthrough_v1() {
assert_eq!(
normalize_github_uri("always-further/test-sk-prov"),
"always-further/test-sk-prov"
);
}
#[test]
fn normalize_github_uri_non_github() {
assert_eq!(
normalize_github_uri("https://gitlab.com/org/repo"),
"https://gitlab.com/org/repo"
);
}
// -----------------------------------------------------------------------
// normalize_workflow_uri
// -----------------------------------------------------------------------
#[test]
fn normalize_workflow_uri_full_v2() {
assert_eq!(
normalize_workflow_uri(
"https://github.com/always-further/test-sk-prov/.github/workflows/sign-skills.yml@refs/heads/main"
),
".github/workflows/sign-skills.yml"
);
}
#[test]
fn normalize_workflow_uri_no_ref_suffix() {
assert_eq!(
normalize_workflow_uri("https://github.com/org/repo/.github/workflows/ci.yml"),
".github/workflows/ci.yml"
);
}
#[test]
fn normalize_workflow_uri_relative_passthrough() {
assert_eq!(
normalize_workflow_uri(".github/workflows/sign.yml"),
".github/workflows/sign.yml"
);
}
// -----------------------------------------------------------------------
// Real Fulcio cert identity extraction (always-further/test-sk-prov)
// -----------------------------------------------------------------------
/// Base64-encoded Fulcio certificate from a real GitHub Actions keyless
/// signing run on always-further/test-sk-prov (2026-02-21).
const REAL_FULCIO_CERT_B64: &str = "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";
#[test]
fn extract_identity_from_real_fulcio_cert() {
let cert_der = crate::trust::base64::base64_decode(REAL_FULCIO_CERT_B64).unwrap();
let identity =
extract_identity_from_cert(&cert_der, Path::new("SKILLS.md.bundle")).unwrap();
match identity {
SignerIdentity::Keyless {
issuer,
repository,
workflow,
git_ref,
} => {
assert_eq!(
issuer, "https://token.actions.githubusercontent.com",
"issuer mismatch"
);
assert_eq!(
repository, "always-further/test-sk-prov",
"repository mismatch — should be normalized from full URI"
);
assert_eq!(
workflow, ".github/workflows/sign-skills.yml",
"workflow mismatch — should be normalized from full URI"
);
assert_eq!(git_ref, "refs/heads/main", "git_ref mismatch");
}
SignerIdentity::Keyed { .. } => panic!("expected keyless identity"),
}
}
#[test]
fn real_fulcio_cert_matches_trust_policy() {
let cert_der = crate::trust::base64::base64_decode(REAL_FULCIO_CERT_B64).unwrap();
let identity =
extract_identity_from_cert(&cert_der, Path::new("SKILLS.md.bundle")).unwrap();
// Simulate a trust-policy.json publisher entry
let publisher = crate::trust::types::Publisher {
name: "test-sk-prov".to_string(),
issuer: Some("https://token.actions.githubusercontent.com".to_string()),
repository: Some("always-further/test-sk-prov".to_string()),
workflow: Some(".github/workflows/sign-skills.yml".to_string()),
ref_pattern: Some("refs/heads/main".to_string()),
key_id: None,
public_key: None,
};
assert!(
publisher.matches(&identity),
"publisher should match extracted identity"
);
}
// -----------------------------------------------------------------------
// multi_subject_bundle_path
// -----------------------------------------------------------------------
#[test]
fn multi_subject_bundle_path_in_cwd() {
let path = multi_subject_bundle_path(Path::new("."));
assert_eq!(path, Path::new("./.nono-trust.bundle"));
}
#[test]
fn multi_subject_bundle_path_in_dir() {
let path = multi_subject_bundle_path(Path::new("/home/user/project"));
assert_eq!(path, Path::new("/home/user/project/.nono-trust.bundle"));
}
// -----------------------------------------------------------------------
// extract_all_subjects
// -----------------------------------------------------------------------
#[test]
fn extract_all_subjects_single() {
// Create a real signed bundle with one subject
let kp = crate::trust::signing::generate_signing_key().unwrap();
let json = crate::trust::signing::sign_bytes(b"content", "file.md", &kp, "key").unwrap();
let bundle = Bundle::from_json(&json).unwrap();
let subjects = extract_all_subjects(&bundle, Path::new("test.bundle")).unwrap();
assert_eq!(subjects.len(), 1);
assert_eq!(subjects[0].0, "file.md");
assert_eq!(subjects[0].1.len(), 64); // SHA-256 hex
}
#[test]
fn extract_all_subjects_multi() {
// Create a real signed bundle with multiple subjects
let kp = crate::trust::signing::generate_signing_key().unwrap();
let files = vec![
(
std::path::PathBuf::from("SKILL.md"),
crate::trust::digest::bytes_digest(b"skill"),
),
(
std::path::PathBuf::from("lib/helper.py"),
crate::trust::digest::bytes_digest(b"helper"),
),
(
std::path::PathBuf::from("config.json"),
crate::trust::digest::bytes_digest(b"config"),
),
];
let json = crate::trust::signing::sign_files(&files, &kp, "key").unwrap();
let bundle = Bundle::from_json(&json).unwrap();
let subjects = extract_all_subjects(&bundle, Path::new("test.bundle")).unwrap();
assert_eq!(subjects.len(), 3);
assert_eq!(subjects[0].0, "SKILL.md");
assert_eq!(subjects[1].0, "lib/helper.py");
assert_eq!(subjects[2].0, "config.json");
// Digests should match what we computed
assert_eq!(subjects[0].1, crate::trust::digest::bytes_digest(b"skill"));
assert_eq!(subjects[1].1, crate::trust::digest::bytes_digest(b"helper"));
assert_eq!(subjects[2].1, crate::trust::digest::bytes_digest(b"config"));
}
fn make_empty_cert_chain_bundle_json() -> String {
r#"{
"mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.1",
"verificationMaterial": {
"x509CertificateChain": {
"certificates": []
},
"tlogEntries": []
},
"dsseEnvelope": {
"payloadType": "application/vnd.in-toto+json",
"payload": "e30=",
"signatures": [
{
"keyid": "",
"sig": "AAAA"
}
]
}
}"#
.to_string()
}
}