auths-sdk 0.1.2

Application services layer for Auths identity operations
Documentation
//! SDK workflow for submitting attestations to a transparency log.
//!
//! This module provides [`submit_attestation_to_log`], the async workflow
//! that takes a signed attestation and submits it to whichever transparency
//! log backend is configured. The function does NOT retry on rate limits —
//! the caller (CLI) owns retry policy.

use auths_core::ports::transparency_log::{LogError, TransparencyLog};
use auths_transparency::checkpoint::SignedCheckpoint;
use auths_transparency::proof::InclusionProof;
use thiserror::Error;

/// Result of submitting an attestation to a transparency log.
///
/// Named `LogSubmissionBundle` to avoid collision with the existing
/// `OfflineBundle` type in `auths-transparency`.
///
/// Args:
/// * `log_id` — Stable log identifier for trust config lookup.
/// * `leaf_index` — Zero-based index of the logged leaf.
/// * `inclusion_proof` — Merkle inclusion proof.
/// * `signed_checkpoint` — Signed checkpoint at submission time.
///
/// Usage:
/// ```ignore
/// let bundle = submit_attestation_to_log(&json, &pk, &sig, &log).await?;
/// println!("Logged at index {} in {}", bundle.leaf_index, bundle.log_id);
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LogSubmissionBundle {
    /// Stable log identifier for trust config lookup.
    pub log_id: String,
    /// Zero-based leaf index in the log.
    pub leaf_index: u64,
    /// Merkle inclusion proof against the checkpoint.
    pub inclusion_proof: InclusionProof,
    /// Signed checkpoint at submission time.
    pub signed_checkpoint: SignedCheckpoint,
}

/// Errors from the log submission workflow.
#[derive(Debug, Error)]
pub enum LogSubmitError {
    /// The transparency log returned an error.
    #[error("log error: {0}")]
    LogError(#[from] LogError),

    /// Post-submission verification failed (GHSA-whqx-f9j3-ch6m countermeasure).
    #[error("post-submission verification failed: {0}")]
    VerificationFailed(String),
}

/// Submit an attestation to a transparency log and verify the response.
///
/// This function:
/// 1. Submits the attestation as a leaf to the log
/// 2. Verifies the returned inclusion proof against the checkpoint (GHSA-whqx-f9j3-ch6m)
/// 3. Returns the bundle for embedding in `.auths.json`
///
/// **Does NOT retry** on `LogError::RateLimited`. The caller owns retry policy.
///
/// Args:
/// * `attestation_json` — Serialized attestation JSON bytes.
/// * `public_key` — Signer's public key (raw 32 bytes or PKIX DER).
/// * `curve` — The curve of the public key.
/// * `signature` — Signature over the attestation.
/// * `log` — The transparency log backend to submit to.
///
/// Usage:
/// ```ignore
/// let bundle = submit_attestation_to_log(
///     attestation_json.as_bytes(),
///     &public_key_bytes,
///     CurveType::P256,
///     &signature_bytes,
///     &log,
/// ).await?;
/// ```
pub async fn submit_attestation_to_log(
    attestation_json: &[u8],
    public_key: &[u8],
    curve: auths_crypto::CurveType,
    signature: &[u8],
    log: &dyn TransparencyLog,
) -> Result<LogSubmissionBundle, LogSubmitError> {
    // 1. Submit to the log
    let submission = log
        .submit(attestation_json, public_key, curve, signature)
        .await?;

    // 2. Validate the inclusion proof structure.
    //
    // We cannot recompute the leaf hash locally because the adapter may wrap
    // the attestation in a backend-specific envelope (e.g., DSSE for Rekor)
    // before submitting. The leaf Rekor hashed is the envelope, not our raw
    // attestation bytes.
    //
    // The inclusion proof is still cryptographically valid — it was produced
    // by Rekor and is self-consistent. A full GHSA-whqx-f9j3-ch6m
    // countermeasure would re-fetch the entry by index and verify the
    // payload matches, but that requires an additional HTTP round-trip.
    // For now, we trust the proof structure from the submission response.
    if submission.inclusion_proof.hashes.is_empty() && submission.inclusion_proof.size > 1 {
        return Err(LogSubmitError::VerificationFailed(
            "inclusion proof has no hashes for a non-trivial tree".into(),
        ));
    }

    let metadata = log.metadata();

    // 3. Verify the checkpoint signature against a pinned trust root, when one
    // exists for this log. Logs we do not pin (e.g. test fakes) are skipped —
    // the inclusion proof above still bounds them. For a pinned ECDSA log
    // (Sigstore Rekor) this rejects a checkpoint whose signature does not verify,
    // so a cached checkpoint is never trusted unverified.
    let trust = auths_transparency::TrustConfig::default_config();
    if let Some(root) = trust.get_log(&metadata.log_id) {
        match auths_transparency::verify_checkpoint_signature(&submission.signed_checkpoint, root) {
            auths_transparency::CheckpointStatus::Verified
            | auths_transparency::CheckpointStatus::NotProvided => {}
            other => {
                return Err(LogSubmitError::VerificationFailed(format!(
                    "checkpoint signature verification failed for log '{}': {other:?}",
                    metadata.log_id
                )));
            }
        }
    }

    Ok(LogSubmissionBundle {
        log_id: metadata.log_id,
        leaf_index: submission.leaf_index,
        inclusion_proof: submission.inclusion_proof,
        signed_checkpoint: submission.signed_checkpoint,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testing::fakes::FakeTransparencyLog;

    #[tokio::test]
    async fn submit_succeeds_with_fake() {
        let log = FakeTransparencyLog::succeeding();
        let result = submit_attestation_to_log(
            b"test attestation",
            b"public_key",
            auths_crypto::CurveType::default(),
            b"signature",
            &log,
        )
        .await;

        assert!(result.is_ok());
        let bundle = result.unwrap();
        assert_eq!(bundle.log_id, "fake-test-log");
        assert_eq!(bundle.leaf_index, 0);
    }

    #[tokio::test]
    async fn submit_propagates_rate_limit() {
        let log = FakeTransparencyLog::rate_limited(30);
        let result = submit_attestation_to_log(
            b"test",
            b"pk",
            auths_crypto::CurveType::default(),
            b"sig",
            &log,
        )
        .await;

        match result {
            Err(LogSubmitError::LogError(LogError::RateLimited { retry_after_secs })) => {
                assert_eq!(retry_after_secs, 30);
            }
            other => panic!("expected RateLimited, got: {:?}", other),
        }
    }

    #[tokio::test]
    async fn submit_propagates_network_error() {
        let log = FakeTransparencyLog::failing(LogError::NetworkError("connection refused".into()));
        let result = submit_attestation_to_log(
            b"test",
            b"pk",
            auths_crypto::CurveType::default(),
            b"sig",
            &log,
        )
        .await;

        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("connection refused")
        );
    }
}