Skip to main content

cloud_sdk/authentication/
signing.rs

1//! Bounded canonical inputs for provider-owned request signing.
2
3use core::fmt;
4
5use cloud_sdk_sanitization::sanitize_bytes;
6
7use crate::buffer::{SnapshotEncoder, encode_snapshot_bounded, measure_snapshot_bounded};
8use crate::transport::{CanonicalHost, EndpointScheme, RequestHeader, TransportRequest};
9
10use super::ScopeValue;
11
12mod build;
13mod context;
14mod output;
15
16pub use build::SigningBuildError;
17use build::{DigestScratch, SigningBodyDigest};
18pub use context::{
19    MAX_SIGNING_ALGORITHM_BYTES, MAX_SIGNING_DIGEST_ALGORITHM_BYTES, MAX_SIGNING_KEY_ID_BYTES,
20    SigningAlgorithm, SigningContext, SigningContextValueError, SigningDigestAlgorithm,
21    SigningKeyId,
22};
23pub use output::{RequestSigner, SignedRequest, SigningOutputError};
24
25/// Maximum request-body digest bytes accepted by the canonical format.
26pub const MAX_SIGNING_BODY_DIGEST_BYTES: usize = 128;
27/// Maximum caller-provided nonce bytes accepted by the canonical format.
28pub const MAX_SIGNING_NONCE_BYTES: usize = 256;
29/// Maximum selected request headers.
30pub const MAX_SIGNING_HEADERS: usize = 32;
31/// Maximum complete canonical signing-input bytes.
32pub const MAX_CANONICAL_SIGNING_INPUT_BYTES: usize = 12_288;
33
34const SIGNING_DOMAIN: &[u8] = b"cloud-sdk-signing-v2\0";
35
36/// Bounded signing value validation failure.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum SigningValueError {
39    /// The value must not be empty.
40    Empty,
41    /// The value exceeds its type-specific byte limit.
42    TooLong,
43}
44
45impl_static_error!(SigningValueError,
46    Self::Empty => "signing value is empty",
47    Self::TooLong => "signing value exceeds the length limit",
48);
49
50/// Borrowed caller-provided nonce.
51#[derive(Clone, Copy, Eq, PartialEq)]
52pub struct SigningNonce<'a>(&'a [u8]);
53
54impl<'a> SigningNonce<'a> {
55    /// Validates a nonce without generating randomness.
56    pub fn new(value: &'a [u8]) -> Result<Self, SigningValueError> {
57        validate_bounded(value, MAX_SIGNING_NONCE_BYTES)?;
58        Ok(Self(value))
59    }
60
61    /// Returns the exact nonce bytes.
62    #[must_use]
63    pub const fn as_bytes(self) -> &'a [u8] {
64        self.0
65    }
66}
67
68impl fmt::Debug for SigningNonce<'_> {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        formatter.write_str("SigningNonce([redacted])")
71    }
72}
73
74/// Caller-observed Unix time in whole seconds.
75#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
76pub struct UnixTime(u64);
77
78impl UnixTime {
79    /// Wraps a caller-observed timestamp. Core acquires no clock.
80    #[must_use]
81    pub const fn from_seconds(seconds: u64) -> Self {
82        Self(seconds)
83    }
84
85    /// Returns whole seconds since the Unix epoch.
86    #[must_use]
87    pub const fn as_seconds(self) -> u64 {
88        self.0
89    }
90}
91
92/// Caller-owned nonce and observed time bound into one anti-replay context.
93#[derive(Clone, Copy)]
94pub struct SigningFreshness<'a> {
95    nonce: SigningNonce<'a>,
96    time: UnixTime,
97}
98
99impl<'a> SigningFreshness<'a> {
100    /// Combines caller-provided freshness values without acquiring either.
101    #[must_use]
102    pub const fn new(nonce: SigningNonce<'a>, time: UnixTime) -> Self {
103        Self { nonce, time }
104    }
105
106    const fn nonce(self) -> SigningNonce<'a> {
107        self.nonce
108    }
109
110    const fn time(self) -> UnixTime {
111        self.time
112    }
113}
114
115impl fmt::Debug for SigningFreshness<'_> {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        formatter
118            .debug_struct("SigningFreshness")
119            .field("nonce", &"[redacted]")
120            .field("time", &self.time)
121            .finish()
122    }
123}
124
125/// Canonically ordered request headers selected by provider signing policy.
126#[derive(Clone, Copy)]
127pub struct SigningHeaders<'a> {
128    entries: &'a [RequestHeader<'a>],
129}
130
131impl<'a> SigningHeaders<'a> {
132    /// Requires a strictly ascending, case-insensitive header-name order.
133    pub fn new(entries: &'a [RequestHeader<'a>]) -> Result<Self, SigningInputError> {
134        if entries.len() > MAX_SIGNING_HEADERS {
135            return Err(SigningInputError::TooManyHeaders);
136        }
137        for pair in entries.windows(2) {
138            let Some(left) = pair.first() else {
139                return Err(SigningInputError::HeaderOrder);
140            };
141            let Some(right) = pair.get(1) else {
142                return Err(SigningInputError::HeaderOrder);
143            };
144            if left.name() >= right.name() {
145                return Err(SigningInputError::HeaderOrder);
146            }
147        }
148        Ok(Self { entries })
149    }
150
151    /// Returns the selected canonical header sequence.
152    #[must_use]
153    pub const fn as_slice(self) -> &'a [RequestHeader<'a>] {
154        self.entries
155    }
156}
157
158impl fmt::Debug for SigningHeaders<'_> {
159    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160        formatter
161            .debug_struct("SigningHeaders")
162            .field("count", &self.entries.len())
163            .field("values", &"[redacted]")
164            .finish()
165    }
166}
167
168/// Canonical signing-input construction failure.
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub enum SigningInputError {
171    /// More than [`MAX_SIGNING_HEADERS`] headers were selected.
172    TooManyHeaders,
173    /// Selected headers are duplicated or not in canonical order.
174    HeaderOrder,
175    /// A selected header is absent from or differs from the request.
176    HeaderMismatch,
177    /// A field length cannot be represented by the canonical format.
178    LengthOverflow,
179    /// The canonical input exceeds its aggregate bound.
180    InputTooLarge,
181    /// Caller output cannot hold the complete canonical input.
182    OutputTooSmall,
183    /// Transactional replay did not reproduce the measured bytes.
184    SnapshotChanged,
185}
186
187impl_static_error!(SigningInputError,
188    Self::TooManyHeaders => "too many signing headers were selected",
189    Self::HeaderOrder => "signing headers are not in canonical order",
190    Self::HeaderMismatch => "signing header does not match the request",
191    Self::LengthOverflow => "signing field length cannot be represented",
192    Self::InputTooLarge => "canonical signing input exceeds the length limit",
193    Self::OutputTooSmall => "canonical signing output is too small",
194    Self::SnapshotChanged => "canonical signing snapshot changed during encoding",
195);
196
197/// Caller-provided request-body hashing implementation.
198pub trait RequestBodyHasher {
199    /// Hashing failure.
200    type Error;
201
202    /// Returns the exact digest algorithm implemented by this hasher.
203    fn digest_algorithm(&self) -> SigningDigestAlgorithm<'_>;
204
205    /// Hashes the exact request body into caller-owned output.
206    fn hash_body(&self, body: &[u8], output: &mut [u8]) -> Result<usize, Self::Error>;
207}
208
209/// Cleanup-owning canonical input that retains the exact hashed request.
210pub struct CanonicalSigningInput<'storage, 'request> {
211    request: TransportRequest<'request>,
212    context: SigningContext<'request>,
213    storage: &'storage mut [u8],
214    len: usize,
215}
216
217impl<'storage, 'request> CanonicalSigningInput<'storage, 'request> {
218    /// Hashes the exact request body and builds a security-domain-bound input.
219    pub fn new_hashed<H: RequestBodyHasher + ?Sized>(
220        request: TransportRequest<'request>,
221        context: SigningContext<'request>,
222        selected_headers: SigningHeaders<'_>,
223        freshness: SigningFreshness<'_>,
224        hasher: &H,
225        digest_storage: &mut [u8],
226        output: &'storage mut [u8],
227    ) -> Result<Self, SigningBuildError<H::Error>> {
228        let mut digest_storage = DigestScratch::new(digest_storage);
229        validate_selected_headers(request, selected_headers).map_err(SigningBuildError::Input)?;
230        if hasher.digest_algorithm() != context.digest_algorithm() {
231            return Err(SigningBuildError::DigestAlgorithmMismatch);
232        }
233        let digest_len = hasher
234            .hash_body(request.body(), digest_storage.as_mut())
235            .map_err(SigningBuildError::Hasher)?;
236        let digest_bytes = digest_storage
237            .as_slice()
238            .get(..digest_len)
239            .ok_or(SigningBuildError::Digest(SigningValueError::TooLong))?;
240        let body_digest =
241            SigningBodyDigest::new(digest_bytes).map_err(SigningBuildError::Digest)?;
242        let snapshot = SigningSnapshot {
243            request,
244            context,
245            selected_headers,
246            body_digest,
247            nonce: freshness.nonce(),
248            time: freshness.time(),
249        };
250        let required = measure_snapshot_bounded(
251            snapshot,
252            MAX_CANONICAL_SIGNING_INPUT_BYTES,
253            SigningInputError::InputTooLarge,
254            encode_signing_snapshot,
255        )
256        .map_err(SigningBuildError::Input)?;
257        if output.len() < required {
258            return Err(SigningBuildError::Input(SigningInputError::OutputTooSmall));
259        }
260        let len = encode_snapshot_bounded(
261            snapshot,
262            output,
263            MAX_CANONICAL_SIGNING_INPUT_BYTES,
264            SigningInputError::SnapshotChanged,
265            encode_signing_snapshot,
266        )
267        .map_err(SigningBuildError::Input)?;
268        Ok(Self {
269            request,
270            context,
271            storage: output,
272            len,
273        })
274    }
275
276    /// Returns the exact bytes to hash or sign.
277    #[must_use]
278    pub fn as_bytes(&self) -> &[u8] {
279        self.storage.get(..self.len).unwrap_or_default()
280    }
281
282    /// Returns the exact request retained by the canonical snapshot.
283    #[must_use]
284    pub const fn request(&self) -> TransportRequest<'request> {
285        self.request
286    }
287
288    /// Returns the exact security domain retained by the canonical snapshot.
289    #[must_use]
290    pub const fn context(&self) -> SigningContext<'request> {
291        self.context
292    }
293
294    /// Signs and returns the exact request with cleanup-owning bounded output.
295    pub fn sign_into<'signature, S: RequestSigner>(
296        self,
297        signer: &S,
298        output: &'signature mut [u8],
299    ) -> Result<SignedRequest<'signature, 'request>, SigningOutputError<S::Error>> {
300        let result =
301            SignedRequest::sign(self.request, self.context, self.as_bytes(), signer, output);
302        drop(self);
303        result
304    }
305}
306
307impl fmt::Debug for CanonicalSigningInput<'_, '_> {
308    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
309        formatter
310            .debug_struct("CanonicalSigningInput")
311            .field("len", &self.len)
312            .field("input", &"[redacted]")
313            .finish()
314    }
315}
316
317impl Drop for CanonicalSigningInput<'_, '_> {
318    fn drop(&mut self) {
319        sanitize_bytes(self.storage);
320    }
321}
322
323#[derive(Clone, Copy)]
324struct SigningSnapshot<'request, 'context, 'headers, 'digest, 'nonce> {
325    request: TransportRequest<'request>,
326    context: SigningContext<'context>,
327    selected_headers: SigningHeaders<'headers>,
328    body_digest: SigningBodyDigest<'digest>,
329    nonce: SigningNonce<'nonce>,
330    time: UnixTime,
331}
332
333fn validate_bounded(value: &[u8], maximum: usize) -> Result<(), SigningValueError> {
334    if value.is_empty() {
335        return Err(SigningValueError::Empty);
336    }
337    if value.len() > maximum {
338        return Err(SigningValueError::TooLong);
339    }
340    Ok(())
341}
342
343fn validate_selected_headers(
344    request: TransportRequest<'_>,
345    selected: SigningHeaders<'_>,
346) -> Result<(), SigningInputError> {
347    for expected in selected.as_slice() {
348        let Some(actual) = request.headers().get(expected.name().as_str()) else {
349            return Err(SigningInputError::HeaderMismatch);
350        };
351        if actual.value().as_str().as_bytes() != expected.value().as_str().as_bytes()
352            || actual.sensitivity() != expected.sensitivity()
353        {
354            return Err(SigningInputError::HeaderMismatch);
355        }
356    }
357    Ok(())
358}
359
360fn encode_signing_snapshot(
361    snapshot: SigningSnapshot<'_, '_, '_, '_, '_>,
362    encoder: &mut SnapshotEncoder<'_, SigningInputError>,
363) -> Result<(), SigningInputError> {
364    encoder.bytes(SIGNING_DOMAIN)?;
365    encode_u8_len(encoder, snapshot.context.provider().as_str().as_bytes())?;
366    encode_u8_len(encoder, snapshot.context.service().as_str().as_bytes())?;
367    let endpoint = snapshot.context.endpoint();
368    let scheme = match endpoint.scheme() {
369        EndpointScheme::Http => b"http".as_slice(),
370        EndpointScheme::Https => b"https".as_slice(),
371    };
372    encode_u8_len(encoder, scheme)?;
373    encode_canonical_host(encoder, endpoint.canonical_host())?;
374    encoder.bytes(&endpoint.effective_port().to_be_bytes())?;
375    encode_u16_len(encoder, endpoint.base_path().as_bytes())?;
376    encode_optional_scope(encoder, snapshot.context.audience())?;
377    encode_optional_scope(encoder, snapshot.context.account())?;
378    encode_optional_scope(encoder, snapshot.context.tenant())?;
379    encode_u16_len(encoder, snapshot.context.key_id().as_str().as_bytes())?;
380    encode_u16_len(
381        encoder,
382        snapshot.context.digest_algorithm().as_str().as_bytes(),
383    )?;
384    encode_u16_len(
385        encoder,
386        snapshot.context.signature_algorithm().as_str().as_bytes(),
387    )?;
388    encode_u8_len(encoder, snapshot.request.method().as_str().as_bytes())?;
389    encode_u16_len(encoder, snapshot.request.target().as_str().as_bytes())?;
390    let count = u8::try_from(snapshot.selected_headers.as_slice().len())
391        .map_err(|_| SigningInputError::LengthOverflow)?;
392    encoder.byte(count)?;
393    for header in snapshot.selected_headers.as_slice() {
394        encode_lowercase_name(encoder, header.name().as_str())?;
395        encode_u16_len(encoder, header.value().as_str().as_bytes())?;
396    }
397    encode_u8_len(encoder, snapshot.body_digest.as_bytes())?;
398    encode_u16_len(encoder, snapshot.nonce.as_bytes())?;
399    encoder.bytes(&snapshot.time.as_seconds().to_be_bytes())
400}
401
402fn encode_canonical_host(
403    encoder: &mut SnapshotEncoder<'_, SigningInputError>,
404    host: CanonicalHost<'_>,
405) -> Result<(), SigningInputError> {
406    match host {
407        CanonicalHost::Dns(value) => {
408            encoder.byte(0)?;
409            encode_u16_len(encoder, value.as_bytes())
410        }
411        CanonicalHost::Ipv4(octets) => {
412            encoder.byte(1)?;
413            encoder.bytes(&octets)
414        }
415        CanonicalHost::Ipv6(octets) => {
416            encoder.byte(2)?;
417            encoder.bytes(&octets)
418        }
419    }
420}
421
422fn encode_optional_scope(
423    encoder: &mut SnapshotEncoder<'_, SigningInputError>,
424    value: Option<ScopeValue<'_>>,
425) -> Result<(), SigningInputError> {
426    match value {
427        Some(value) => {
428            encoder.byte(1)?;
429            encode_u16_len(encoder, value.as_str().as_bytes())
430        }
431        None => encoder.byte(0),
432    }
433}
434
435fn encode_lowercase_name(
436    encoder: &mut SnapshotEncoder<'_, SigningInputError>,
437    name: &str,
438) -> Result<(), SigningInputError> {
439    let len = u8::try_from(name.len()).map_err(|_| SigningInputError::LengthOverflow)?;
440    encoder.byte(len)?;
441    for byte in name.bytes() {
442        encoder.byte(byte.to_ascii_lowercase())?;
443    }
444    Ok(())
445}
446
447fn encode_u8_len(
448    encoder: &mut SnapshotEncoder<'_, SigningInputError>,
449    value: &[u8],
450) -> Result<(), SigningInputError> {
451    let len = u8::try_from(value.len()).map_err(|_| SigningInputError::LengthOverflow)?;
452    encoder.byte(len)?;
453    encoder.bytes(value)
454}
455
456fn encode_u16_len(
457    encoder: &mut SnapshotEncoder<'_, SigningInputError>,
458    value: &[u8],
459) -> Result<(), SigningInputError> {
460    let len = u16::try_from(value.len()).map_err(|_| SigningInputError::LengthOverflow)?;
461    encoder.bytes(&len.to_be_bytes())?;
462    encoder.bytes(value)
463}
464
465#[cfg(test)]
466mod tests;