Skip to main content

objectstore_types/
presign.rs

1//! Utilities for signing and verifying pre-signed URLs.
2//!
3//! A pre-signed URL lets a client that owns an Ed25519 keypair hand out a
4//! time-limited URL that authorizes a specific request.
5//!
6//! **Experimental:** pre-signed URLs are an experimental feature and this API
7//! may change in a future release.
8//!
9//! Example:
10//!
11//! ```text
12//! GET /v1/objects/<usecase>/<scopes>/<key>
13//!     ?os_timestamp=2026-04-20T13:37:00.00Z
14//!     &os_duration=3600
15//!     &os_kid=relay
16//!     &os_sig=<signature>
17//! ```
18//!
19//! The signature covers a canonical form of the request.
20//!
21//! # Canonical form
22//!
23//! The canonical form is comprised of three newline-separated components:
24//!
25//! ```text
26//! <normalized method>\n
27//! <path>\n
28//! <canonical query string>
29//! ```
30//!
31//! - normalized method: the uppercase HTTP method, with `HEAD` mapped to `GET`;
32//! - path: the request path, included verbatim as transmitted/received on the wire;
33//! - canonical query string: every query parameter except `os_sig`, with keys
34//!   lowercased, sorted lexicographically by name and value, joined with `&`.
35
36use base64::Engine as _;
37use base64::engine::general_purpose::URL_SAFE_NO_PAD;
38use ed25519_dalek::{Signature, Signer};
39use http::Method;
40
41pub use ed25519_dalek::{SigningKey as DalekSigningKey, VerifyingKey as DalekVerifyingKey};
42
43/// Query parameter carrying the time at which the request was signed.
44pub const PARAM_TIMESTAMP: &str = "os_timestamp";
45
46/// Query parameter carrying the validity duration, in seconds.
47pub const PARAM_DURATION: &str = "os_duration";
48
49/// Query parameter naming the key ID used to sign the request.
50pub const PARAM_KID: &str = "os_kid";
51
52/// Query parameter carrying the base64url-encoded signature.
53pub const PARAM_SIG: &str = "os_sig";
54
55/// Errors returned when verifying a pre-signed request signature.
56#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
57pub enum Error {
58    /// The user supplied a sequence of bytes that's not a valid Ed25519 key.
59    #[error("invalid key")]
60    InvalidKey,
61    /// The signature is not valid base64url or doesn't decode to a 64-byte
62    /// Ed25519 signature.
63    #[error("invalid signature encoding")]
64    InvalidSignatureEncoding,
65    /// The signature did not match the canonical request for the given key.
66    #[error("signature verification failed")]
67    VerificationFailed,
68}
69
70/// The canonical form of a request to be signed or verified.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct CanonicalRequest(String);
73
74/// An Ed25519 signing key.
75pub type SigningKey = [u8; 32];
76
77/// An Ed25519 verifying key.
78pub type VerifyingKey = [u8; 32];
79
80impl CanonicalRequest {
81    /// Builds the canonical form of a request.
82    ///
83    /// `path` and `query` MUST be the raw request path and query string as transmitted/received
84    /// on the wire, which means that servers should pass the raw contents of these components, and
85    /// clients should ensure that the encoding used to compute the signature matches exactly the
86    /// encoding used by their HTTP client.
87    ///
88    /// This is important to avoid any signature verification failures due to different
89    /// encoding/decoding implementations between the client and server, and is based on the
90    /// assumption that any intermediate proxies will never re-encode the URL, but always pass it
91    /// through with the original encoding.
92    pub fn new(method: &Method, path: &str, query: Option<&str>) -> Self {
93        let normalized_method = if *method == Method::HEAD {
94            String::from("GET")
95        } else {
96            method.as_str().to_uppercase()
97        };
98
99        let mut pairs: Vec<String> = query
100            .unwrap_or_default()
101            .split('&')
102            .filter(|pair| !pair.is_empty())
103            .filter_map(|pair| {
104                if let Some((key, value)) = pair.split_once('=') {
105                    let key = key.to_ascii_lowercase();
106                    (key != PARAM_SIG).then(|| format!("{key}={value}"))
107                } else {
108                    let key = pair.to_ascii_lowercase();
109                    (key != PARAM_SIG).then_some(key)
110                }
111            })
112            .collect();
113        pairs.sort_unstable();
114        let canonical_query = pairs.join("&");
115
116        Self(format!("{normalized_method}\n{path}\n{canonical_query}"))
117    }
118
119    /// Signs this canonical form with Ed25519.
120    ///
121    /// Returns the base64url-encoded signature, suitable as the value of the
122    /// [`PARAM_SIG`] query parameter.
123    pub fn sign(&self, key: &SigningKey) -> String {
124        let key = DalekSigningKey::from_bytes(key);
125        let signature = key.sign(self.0.as_bytes());
126        URL_SAFE_NO_PAD.encode(signature.to_bytes())
127    }
128
129    /// Verifies a base64url-encoded Ed25519 signature against this canonical form.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`Error::InvalidKey`] if `key` is not a valid Ed25519 verifying key,
134    /// [`Error::InvalidSignatureEncoding`] if `signature_b64` is not valid base64url
135    /// or not a 64-byte signature, and [`Error::VerificationFailed`] if the signature
136    /// does not match.
137    pub fn verify(&self, key: &VerifyingKey, signature_b64: &str) -> Result<(), Error> {
138        let key = DalekVerifyingKey::from_bytes(key).map_err(|_| Error::InvalidKey)?;
139        let bytes = URL_SAFE_NO_PAD
140            .decode(signature_b64)
141            .map_err(|_| Error::InvalidSignatureEncoding)?;
142        let signature =
143            Signature::from_slice(&bytes).map_err(|_| Error::InvalidSignatureEncoding)?;
144        key.verify_strict(self.0.as_bytes(), &signature)
145            .map_err(|_| Error::VerificationFailed)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn test_signing_key() -> DalekSigningKey {
154        DalekSigningKey::from_bytes(&[0x42; 32])
155    }
156
157    /// Raw query string as it would appear on the wire (unsorted, includes the
158    /// os_sig that must be excluded).
159    fn sample_query() -> &'static str {
160        "os_timestamp=1985-04-12T23:20:50.52Z\
161         &os_kid=relay\
162         &os_duration=3600\
163         &os_sig=should-be-excluded"
164    }
165
166    #[test]
167    fn canonical_form_is_stable() {
168        // Base case: full query. Pins path encoding (slashes too), os_sig
169        // exclusion, key lowercasing, and query sort order.
170        let canonical = CanonicalRequest::new(
171            &Method::GET,
172            "/v1/objects/testing/org=17;project=42/foo/bar",
173            Some(sample_query()),
174        );
175        assert_eq!(
176            canonical.0,
177            "GET\n\
178             /v1/objects/testing/org=17;project=42/foo/bar\n\
179             os_duration=3600&\
180             os_kid=relay&\
181             os_timestamp=1985-04-12T23:20:50.52Z"
182        );
183
184        // Empty query: the canonical query component is empty.
185        let canonical = CanonicalRequest::new(&Method::GET, "/v1/objects/testing/_/key", None);
186        assert_eq!(canonical.0, "GET\n/v1/objects/testing/_/key\n");
187
188        // Duplicate query keys are preserved (not deduplicated) and ordered by
189        // (key, value).
190        let canonical = CanonicalRequest::new(
191            &Method::GET,
192            "/v1/objects/testing/_/key",
193            Some("dup=b&dup=a&x=1"),
194        );
195        assert_eq!(
196            canonical.0,
197            "GET\n/v1/objects/testing/_/key\ndup=a&dup=b&x=1"
198        );
199
200        let canonical = CanonicalRequest::new(
201            &Method::GET,
202            "/v1/objects/testing/_/key",
203            Some(sample_query()),
204        );
205        assert_eq!(
206            canonical.0,
207            "GET\n\
208             /v1/objects/testing/_/key\n\
209             os_duration=3600&\
210             os_kid=relay&\
211             os_timestamp=1985-04-12T23:20:50.52Z"
212        );
213    }
214
215    #[test]
216    fn head_is_normalized_to_get() {
217        let path = "/v1/objects/testing/_/key";
218        assert_eq!(
219            CanonicalRequest::new(&Method::HEAD, path, Some(sample_query()),),
220            CanonicalRequest::new(&Method::GET, path, Some(sample_query()),),
221        );
222    }
223
224    #[test]
225    fn sign_and_verify_roundtrip() {
226        let sk = test_signing_key();
227        let vk = sk.verifying_key();
228
229        let canonical = CanonicalRequest::new(
230            &Method::GET,
231            "/v1/objects/testing/_/key",
232            Some(sample_query()),
233        );
234        let signature = canonical.sign(sk.as_bytes());
235
236        assert_eq!(canonical.verify(vk.as_bytes(), &signature), Ok(()));
237    }
238
239    #[test]
240    fn verify_rejects_tampered_canonical() {
241        let sk = test_signing_key();
242        let vk = sk.verifying_key();
243
244        let canonical = CanonicalRequest::new(
245            &Method::GET,
246            "/v1/objects/testing/_/key",
247            Some(sample_query()),
248        );
249        let signature = canonical.sign(sk.as_bytes());
250
251        let tampered = CanonicalRequest::new(
252            &Method::GET,
253            "/v1/objects/testing/_/other",
254            Some(sample_query()),
255        );
256        assert_eq!(
257            tampered.verify(vk.as_bytes(), &signature),
258            Err(Error::VerificationFailed)
259        );
260    }
261
262    #[test]
263    fn verify_rejects_wrong_key() {
264        let sk = test_signing_key();
265        let other_vk = DalekSigningKey::from_bytes(&[0x01; 32]).verifying_key();
266
267        let canonical = CanonicalRequest::new(
268            &Method::GET,
269            "/v1/objects/testing/_/key",
270            Some(sample_query()),
271        );
272        let signature = canonical.sign(sk.as_bytes());
273
274        assert_eq!(
275            canonical.verify(other_vk.as_bytes(), &signature),
276            Err(Error::VerificationFailed)
277        );
278    }
279
280    #[test]
281    fn verify_rejects_bad_signature_encoding() {
282        let vk = test_signing_key().verifying_key();
283        let canonical = CanonicalRequest::new(
284            &Method::GET,
285            "/v1/objects/testing/_/key",
286            Some(sample_query()),
287        );
288
289        // Not valid base64url.
290        assert_eq!(
291            canonical.verify(vk.as_bytes(), "not valid base64!!"),
292            Err(Error::InvalidSignatureEncoding)
293        );
294        // Valid base64url but not a 64-byte signature.
295        assert_eq!(
296            canonical.verify(vk.as_bytes(), &URL_SAFE_NO_PAD.encode([0u8; 10])),
297            Err(Error::InvalidSignatureEncoding)
298        );
299    }
300}