Skip to main content

auths_sdk/workflows/
log_submit.rs

1//! SDK workflow for submitting attestations to a transparency log.
2//!
3//! This module provides [`submit_attestation_to_log`], the async workflow
4//! that takes a signed attestation and submits it to whichever transparency
5//! log backend is configured. The function does NOT retry on rate limits —
6//! the caller (CLI) owns retry policy.
7
8use auths_core::ports::transparency_log::{LogError, TransparencyLog};
9use auths_transparency::checkpoint::SignedCheckpoint;
10use auths_transparency::proof::InclusionProof;
11use thiserror::Error;
12
13/// Result of submitting an attestation to a transparency log.
14///
15/// Named `LogSubmissionBundle` to avoid collision with the existing
16/// `OfflineBundle` type in `auths-transparency`.
17///
18/// Args:
19/// * `log_id` — Stable log identifier for trust config lookup.
20/// * `leaf_index` — Zero-based index of the logged leaf.
21/// * `inclusion_proof` — Merkle inclusion proof.
22/// * `signed_checkpoint` — Signed checkpoint at submission time.
23///
24/// Usage:
25/// ```ignore
26/// let bundle = submit_attestation_to_log(&json, &pk, &sig, &log).await?;
27/// println!("Logged at index {} in {}", bundle.leaf_index, bundle.log_id);
28/// ```
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct LogSubmissionBundle {
31    /// Stable log identifier for trust config lookup.
32    pub log_id: String,
33    /// Zero-based leaf index in the log.
34    pub leaf_index: u64,
35    /// Merkle inclusion proof against the checkpoint.
36    pub inclusion_proof: InclusionProof,
37    /// Signed checkpoint at submission time.
38    pub signed_checkpoint: SignedCheckpoint,
39}
40
41/// Errors from the log submission workflow.
42#[derive(Debug, Error)]
43pub enum LogSubmitError {
44    /// The transparency log returned an error.
45    #[error("log error: {0}")]
46    LogError(#[from] LogError),
47
48    /// Post-submission verification failed (GHSA-whqx-f9j3-ch6m countermeasure).
49    #[error("post-submission verification failed: {0}")]
50    VerificationFailed(String),
51}
52
53/// Submit an attestation to a transparency log and verify the response.
54///
55/// This function:
56/// 1. Submits the attestation as a leaf to the log
57/// 2. Verifies the returned inclusion proof against the checkpoint (GHSA-whqx-f9j3-ch6m)
58/// 3. Returns the bundle for embedding in `.auths.json`
59///
60/// **Does NOT retry** on `LogError::RateLimited`. The caller owns retry policy.
61///
62/// Args:
63/// * `attestation_json` — Serialized attestation JSON bytes.
64/// * `public_key` — Signer's public key (raw 32 bytes or PKIX DER).
65/// * `curve` — The curve of the public key.
66/// * `signature` — Signature over the attestation.
67/// * `log` — The transparency log backend to submit to.
68///
69/// Usage:
70/// ```ignore
71/// let bundle = submit_attestation_to_log(
72///     attestation_json.as_bytes(),
73///     &public_key_bytes,
74///     CurveType::P256,
75///     &signature_bytes,
76///     &log,
77/// ).await?;
78/// ```
79pub async fn submit_attestation_to_log(
80    attestation_json: &[u8],
81    public_key: &[u8],
82    curve: auths_crypto::CurveType,
83    signature: &[u8],
84    log: &dyn TransparencyLog,
85) -> Result<LogSubmissionBundle, LogSubmitError> {
86    // 1. Submit to the log
87    let submission = log
88        .submit(attestation_json, public_key, curve, signature)
89        .await?;
90
91    // 2. Validate the inclusion proof structure.
92    //
93    // We cannot recompute the leaf hash locally because the adapter may wrap
94    // the attestation in a backend-specific envelope (e.g., DSSE for Rekor)
95    // before submitting. The leaf Rekor hashed is the envelope, not our raw
96    // attestation bytes.
97    //
98    // The inclusion proof is still cryptographically valid — it was produced
99    // by Rekor and is self-consistent. A full GHSA-whqx-f9j3-ch6m
100    // countermeasure would re-fetch the entry by index and verify the
101    // payload matches, but that requires an additional HTTP round-trip.
102    // For now, we trust the proof structure from the submission response.
103    if submission.inclusion_proof.hashes.is_empty() && submission.inclusion_proof.size > 1 {
104        return Err(LogSubmitError::VerificationFailed(
105            "inclusion proof has no hashes for a non-trivial tree".into(),
106        ));
107    }
108
109    let metadata = log.metadata();
110
111    // 3. Verify the checkpoint signature against a pinned trust root, when one
112    // exists for this log. Logs we do not pin (e.g. test fakes) are skipped —
113    // the inclusion proof above still bounds them. For a pinned ECDSA log
114    // (Sigstore Rekor) this rejects a checkpoint whose signature does not verify,
115    // so a cached checkpoint is never trusted unverified.
116    let trust = auths_transparency::TrustConfig::default_config();
117    if let Some(root) = trust.get_log(&metadata.log_id) {
118        match auths_transparency::verify_checkpoint_signature(&submission.signed_checkpoint, root) {
119            auths_transparency::CheckpointStatus::Verified
120            | auths_transparency::CheckpointStatus::NotProvided => {}
121            other => {
122                return Err(LogSubmitError::VerificationFailed(format!(
123                    "checkpoint signature verification failed for log '{}': {other:?}",
124                    metadata.log_id
125                )));
126            }
127        }
128    }
129
130    Ok(LogSubmissionBundle {
131        log_id: metadata.log_id,
132        leaf_index: submission.leaf_index,
133        inclusion_proof: submission.inclusion_proof,
134        signed_checkpoint: submission.signed_checkpoint,
135    })
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::testing::fakes::FakeTransparencyLog;
142
143    #[tokio::test]
144    async fn submit_succeeds_with_fake() {
145        let log = FakeTransparencyLog::succeeding();
146        let result = submit_attestation_to_log(
147            b"test attestation",
148            b"public_key",
149            auths_crypto::CurveType::default(),
150            b"signature",
151            &log,
152        )
153        .await;
154
155        assert!(result.is_ok());
156        let bundle = result.unwrap();
157        assert_eq!(bundle.log_id, "fake-test-log");
158        assert_eq!(bundle.leaf_index, 0);
159    }
160
161    #[tokio::test]
162    async fn submit_propagates_rate_limit() {
163        let log = FakeTransparencyLog::rate_limited(30);
164        let result = submit_attestation_to_log(
165            b"test",
166            b"pk",
167            auths_crypto::CurveType::default(),
168            b"sig",
169            &log,
170        )
171        .await;
172
173        match result {
174            Err(LogSubmitError::LogError(LogError::RateLimited { retry_after_secs })) => {
175                assert_eq!(retry_after_secs, 30);
176            }
177            other => panic!("expected RateLimited, got: {:?}", other),
178        }
179    }
180
181    #[tokio::test]
182    async fn submit_propagates_network_error() {
183        let log = FakeTransparencyLog::failing(LogError::NetworkError("connection refused".into()));
184        let result = submit_attestation_to_log(
185            b"test",
186            b"pk",
187            auths_crypto::CurveType::default(),
188            b"sig",
189            &log,
190        )
191        .await;
192
193        assert!(result.is_err());
194        assert!(
195            result
196                .unwrap_err()
197                .to_string()
198                .contains("connection refused")
199        );
200    }
201}