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}