Repository: cloudflare/daphne
Pull request #619 (2024-06-14 12:56:17)

Expire task info stored in KV


Patch:
diff --git a/crates/daphne-server/src/roles/aggregator.rs b/crates/daphne-server/src/roles/aggregator.rs
index f734859..17ee7fd 100644
--- a/crates/daphne-server/src/roles/aggregator.rs
+++ b/crates/daphne-server/src/roles/aggregator.rs
@@ -14,8 +14,7 @@ use daphne::{
     metrics::DaphneMetrics,
     roles::{aggregator::MergeAggShareError, DapAggregator, DapReportInitializer},
     taskprov, DapAggregateShare, DapAggregateSpan, DapAggregationParam, DapError, DapGlobalConfig,
-    DapRequest, DapSender, DapTaskConfig, DapVersion, EarlyReportStateConsumed,
-    EarlyReportStateInitialized,
+    DapRequest, DapTaskConfig, DapVersion, EarlyReportStateConsumed, EarlyReportStateInitialized,
 };
 use daphne_service_utils::{
     auth::DaphneAuth,
@@ -169,11 +168,10 @@ impl DapAggregator<DaphneAuth> for crate::App {
             authorized = true;
         }
 
-        // If a TLS client certificate is present, verify that it is valid and that the issuer and
-        // subject are trusted.
+        // If a TLS client certificate is present verify that it is valid.
         if let Some(ref cf_tls_client_auth) = sender_auth.cf_tls_client_auth {
             // TODO(cjpatton) Add support for TLS client authentication for non-Taskprov tasks.
-            let Some(ref taskprov_config) = self.service_config.taskprov else {
+            let Some(ref _taskprov_config) = self.service_config.taskprov else {
                 return Ok(Some(
                     "TLS client authentication is currently only supported with Taskprov.".into(),
                 ));
@@ -187,35 +185,6 @@ impl DapAggregator<DaphneAuth> for crate::App {
                 )));
             }
 
-            // Resolve the trusted certificate issuers and subjects for this request.
-            let sender = req.sender();
-            let trusted_certs = if let (Some(DapSender::Leader), Some(ref trusted_certs)) =
-                (sender, &taskprov_config.leader_auth.cf_tls_client_auth)
-            {
-                trusted_certs
-            } else if let (Some(DapSender::Collector), Some(trusted_certs)) = (
-                sender,
-                taskprov_config
-                    .collector_auth
-                    .as_ref()
-                    .and_then(|auth| auth.cf_tls_client_auth.as_ref()),
-            ) {
-                trusted_certs
-            } else {
-                let unauthorized_reason =
-                    format!("TLS client authentication is not configured for sender ({sender:?}.");
-                return Ok(Some(unauthorized_reason));
-            };
-
-            if !trusted_certs.iter().any(|trusted_cert| {
-                trusted_cert.issuer == cf_tls_client_auth.issuer
-                    && trusted_cert.subject == cf_tls_client_auth.subject
-            }) {
-                return Ok(Some(format!(
-                    r#"Unexpected issuer "{}" and subject "{}"."#,
-                    cf_tls_client_auth.issuer, cf_tls_client_auth.subject,
-                )));
-            }
             authorized = true;
         }
 
@@ -288,7 +257,11 @@ impl DapAggregator<DaphneAuth> for crate::App {
             };
             if let Err(e) = self
                 .kv()
-                .put::<kv::prefix::TaskprovOptInParam>(task_id, param.clone())
+                .put_with_expiration::<kv::prefix::TaskprovOptInParam>(
+                    task_id,
+                    param.clone(),
+                    param.not_before,
+                )
                 .await
             {
                 tracing::warn!(error = ?e, "failed to store taskprov opt in param");
@@ -305,10 +278,15 @@ impl DapAggregator<DaphneAuth> for crate::App {
         task_config: DapTaskConfig,
     ) -> Result<(), DapError> {
         let task_id = req.task_id().map_err(DapError::Abort)?;
+        let expiration_time = task_config.not_after;
 
         if self.service_config.role.is_leader() || req.taskprov.is_none() {
             self.kv()
-                .put::<kv::prefix::TaskConfig>(task_id, task_config)
+                .put_with_expiration::<kv::prefix::TaskConfig>(
+                    task_id,
+                    task_config,
+                    expiration_time,
+                )
                 .await
                 .map_err(|e| fatal_error!(err = ?e))?;
         } else {
diff --git a/crates/daphne-server/src/roles/mod.rs b/crates/daphne-server/src/roles/mod.rs
index a55d594..6df0b66 100644
--- a/crates/daphne-server/src/roles/mod.rs
+++ b/crates/daphne-server/src/roles/mod.rs
@@ -213,7 +213,7 @@ mod test_utils {
 
             if self
                 .kv()
-                .put_if_not_exists::<kv::prefix::TaskConfig>(
+                .put_if_not_exists_with_expiration::<kv::prefix::TaskConfig>(
                     &cmd.task_id,
                     DapTaskConfig {
                         version,
@@ -230,6 +230,7 @@ mod test_utils {
                         method: Default::default(),
                         num_agg_span_shards: NonZeroUsize::new(4).unwrap(),
                     },
+                    cmd.task_expiration,
                 )
                 .await
                 .map_err(|e| fatal_error!(err = ?e))?
diff --git a/crates/daphne-server/src/router/mod.rs b/crates/daphne-server/src/router/mod.rs
index 5b395f0..4971979 100644
--- a/crates/daphne-server/src/router/mod.rs
+++ b/crates/daphne-server/src/router/mod.rs
@@ -266,15 +266,9 @@ where
         let sender_auth = DaphneAuth {
             bearer_token: extract_header_as_string(http_headers::DAP_AUTH_TOKEN)
                 .map(BearerToken::from),
-            cf_tls_client_auth: (|| {
-                // Whatever service ends up fronting this one and terminating mTLS must pass through
-                // these headers.
-                Some(TlsClientAuth {
-                    verified: extract_header_as_string("X-Client-Cert-Verified")?,
-                    issuer: extract_header_as_string("X-Client-Cert-Issuer-Dn-Rfc2253")?,
-                    subject: extract_header_as_string("X-Client-Cert-Subject-Dn-Rfc2253")?,
-                })
-            })(),
+
+            cf_tls_client_auth: extract_header_as_string("X-Client-Cert-Verified")
+                .map(|verified| TlsClientAuth { verified }),
         };
 
         if sender_auth.bearer_token.is_some() {
diff --git a/crates/daphne-server/src/storage_proxy_connection/kv/mod.rs b/crates/daphne-server/src/storage_proxy_connection/kv/mod.rs
index bfd9f86..4b76697 100644
--- a/crates/daphne-server/src/storage_proxy_connection/kv/mod.rs
+++ b/crates/daphne-server/src/storage_proxy_connection/kv/mod.rs
@@ -16,6 +16,8 @@ use crate::StorageProxyConfig;
 
 use super::{status_http_1_0_to_reqwest_0_11, Error};
 pub(crate) use cache::Cache;
+use daphne::messages::Time;
+use daphne_service_utils::http_headers::STORAGE_PROXY_PUT_KV_EXPIRATION;
 
 pub(crate) struct Kv<'h> {
     config: &'h StorageProxyConfig,
@@ -256,24 +258,59 @@ impl<'h> Kv<'h> {
         skip_all,
         fields(key, prefix = std::any::type_name::<P>()),
     )]
-    pub async fn put<P>(&self, key: &P::Key, value: P::Value) -> Result<(), Error>
+    pub async fn put_internal<P>(
+        &self,
+        key: &P::Key,
+        value: P::Value,
+        expiration: Option<Time>,
+    ) -> Result<(), Error>
     where
         P: KvPrefix,
         P::Key: std::fmt::Debug,
+        P::Value: Serialize,
     {
         let key = Self::to_key::<P>(key);
         tracing::debug!(key, "PUT");
-        self.http
+
+        let mut request = self
+            .http
             .post(self.config.url.join(&key).unwrap())
             .bearer_auth(&self.config.auth_token)
-            .body(serde_json::to_vec(&value).unwrap())
-            .send()
-            .await?
-            .error_for_status()?;
+            .body(serde_json::to_vec(&value).unwrap());
+
+        if let Some(expiration) = expiration {
+            request = request.header(STORAGE_PROXY_PUT_KV_EXPIRATION, expiration.to_string());
+        }
+
+        request.send().await?.error_for_status()?;
+
         self.cache.write().await.put::<P>(key, Some(value.into()));
         Ok(())
     }
 
+    pub async fn put_with_expiration<P>(
+        &self,
+        key: &P::Key,
+        value: P::Value,
+        expiration: Time,
+    ) -> Result<(), Error>
+    where
+        P: KvPrefix,
+        P::Key: std::fmt::Debug,
+        P::Value: Serialize,
+    {
+        self.put_internal::<P>(key, value, Some(expiration)).await
+    }
+
+    pub async fn put<P>(&self, key: &P::Key, value: P::Value) -> Result<(), Error>
+    where
+        P: KvPrefix,
+        P::Key: std::fmt::Debug,
+        P::Value: Serialize,
+    {
+        self.put_internal::<P>(key, value, None).await
+    }
+
     /// Stores a value in kv if it doesn't already exist.
     ///
     /// If the value already exists, returns the passed in value inside the Ok variant.
@@ -282,10 +319,11 @@ impl<'h> Kv<'h> {
         skip_all,
         fields(key, prefix = std::any::type_name::<P>()),
     )]
-    pub async fn put_if_not_exists<P>(
+    pub async fn put_if_not_exists_internal<P>(
         &self,
         key: &P::Key,
         value: P::Value,
+        expiration: Option<Time>,
     ) -> Result<Option<P::Value>, Error>
     where
         P: KvPrefix,
@@ -294,13 +332,18 @@ impl<'h> Kv<'h> {
         let key = Self::to_key::<P>(key);
 
         tracing::debug!(key, "PUT if not exists");
-        let response = self
+
+        let mut request = self
             .http
             .put(self.config.url.join(&key).unwrap())
             .bearer_auth(&self.config.auth_token)
-            .body(serde_json::to_vec(&value).unwrap())
-            .send()
-            .await?;
+            .body(serde_json::to_vec(&value).unwrap());
+
+        if let Some(expiration) = expiration {
+            request = request.header(STORAGE_PROXY_PUT_KV_EXPIRATION, expiration.to_string());
+        }
+
+        let response = request.send().await?;
 
         if response.status() == status_http_1_0_to_reqwest_0_11(StatusCode::CONFLICT) {
             Ok(Some(value))
@@ -311,6 +354,34 @@ impl<'h> Kv<'h> {
         }
     }
 
+    pub async fn put_if_not_exists_with_expiration<P>(
+        &self,
+        key: &P::Key,
+        value: P::Value,
+        expiration: Time,
+    ) -> Result<Option<P::Value>, Error>
+    where
+        P: KvPrefix,
+        P::Key: std::fmt::Debug,
+        P::Value: Serialize,
+    {
+        self.put_if_not_exists_internal::<P>(key, value, Some(expiration))
+            .await
+    }
+
+    pub async fn put_if_not_exists<P>(
+        &self,
+        key: &P::Key,
+        value: P::Value,
+    ) -> Result<Option<P::Value>, Error>
+    where
+        P: KvPrefix,
+        P::Key: std::fmt::Debug,
+        P::Value: Serialize,
+    {
+        self.put_if_not_exists_internal::<P>(key, value, None).await
+    }
+
     #[tracing::instrument(skip_all, fields(key, prefix = std::any::type_name::<P>()))]
     pub async fn only_cache_put<P>(&self, key: &P::Key, value: P::Value)
     where
diff --git a/crates/daphne-service-utils/src/auth.rs b/crates/daphne-service-utils/src/auth.rs
index 4ba66f1..4a7898a 100644
--- a/crates/daphne-service-utils/src/auth.rs
+++ b/crates/daphne-service-utils/src/auth.rs
@@ -11,8 +11,6 @@ use serde::{Deserialize, Serialize};
 #[derive(PartialEq, Eq)]
 pub struct TlsClientAuth {
     pub verified: String,
-    pub issuer: String,
-    pub subject: String,
 }
 
 /// HTTP client authorization for Daphne-Worker.
@@ -32,17 +30,13 @@ pub struct DaphneAuth {
     pub bearer_token: Option<BearerToken>,
 
     /// TLS client authentication. The client uses a certificate when establishing the TLS
-    /// connection with the expected issuer and subject. This authorization method is
-    /// Cloudflare-specific: Verifying the certificate itself is handled by the process that
-    /// invoked this Worker. The customer zone is also expected to be configured to require mutual
-    /// TLS for the route on which this Worker is listening.
+    /// connection. This authorization method is Cloudflare-specific: Verifying the certificate
+    /// itself is handled by the process that invoked this Worker. The customer zone is also
+    /// expected to be configured to require mutual TLS for the route on which this Worker is
+    /// listening.
     ///
-    /// When this authorization method is used, we verify that the following:
-    ///
-    /// * A certificate was presented and was successfully verified by the TLS server
-    ///
-    /// * The certificate details match those of one of a preconfigured set of trusted
-    /// certificates.
+    /// When this authorization method is used, we verify that a certificate was presented and was
+    /// successfully verified by the TLS server.
     ///
     /// # Caveats
     ///
@@ -96,23 +90,6 @@ pub struct DaphneWorkerAuthMethod {
     /// Expected bearer token.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub bearer_token: Option<BearerToken>,
-
-    /// Details of trusted TLS client certificates.
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub cf_tls_client_auth: Option<Vec<TlsCertInfo>>,
-}
-
-/// TLS certificate details related to authorization.
-#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
-pub struct TlsCertInfo {
-    /// Certificate issuer. Checked against the value of the "certIssuerDNRFC2253" field from the
-    /// "tlsClientAuth" object passed by the Workers runtime to the request handler.
-    /// <https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties>
-    pub issuer: String,
-
-    /// Certificate subject. Checked against the value of the "certSubjectDNRFC2253" field from the
-    /// "tlsClientAuth" object passed by the Workers runtime to the request handler.
-    pub subject: String,
 }
 
 // TODO(mendess): remove this implementation. Implementations of AsRef should never panic
@@ -130,73 +107,18 @@ impl AsRef<BearerToken> for DaphneWorkerAuthMethod {
 
 #[cfg(test)]
 mod test {
-    use super::{BearerToken, DaphneWorkerAuthMethod, TlsCertInfo};
+    use super::{BearerToken, DaphneWorkerAuthMethod};
 
     #[test]
-    fn daphne_worker_auth_method_json_serialiation() {
+    fn daphne_worker_auth_method_json_serialization() {
         let daphne_worker_auth_method: DaphneWorkerAuthMethod =
             serde_json::from_str(r#"{"bearer_token":"the bearer token"}"#).unwrap();
         assert_eq!(
             daphne_worker_auth_method.bearer_token,
             Some(BearerToken::from("the bearer token".to_string()))
         );
-        assert!(daphne_worker_auth_method.cf_tls_client_auth.is_none());
 
-        let trusted_certs = vec![
-            TlsCertInfo {
-                issuer: "CN=Steve Kille,O=Isode Limited,C=GB".into(),
-                subject: "OU=Sales+CN=J. Smith,O=Widget Inc.,C=US".into(),
-            },
-            TlsCertInfo {
-                issuer: "CN=Steve Kille,O=Isode Limited,C=GB".into(),
-                subject: "CN=L. Eagle,O=Sue\\, Grabbit and Runn,C=GB".into(),
-            },
-        ];
-
-        let daphne_worker_auth_method: DaphneWorkerAuthMethod = serde_json::from_str(
-            r#"{
-            "cf_tls_client_auth": [
-                {
-                  "issuer": "CN=Steve Kille,O=Isode Limited,C=GB",
-                  "subject": "OU=Sales+CN=J. Smith,O=Widget Inc.,C=US"
-                },
-                {
-                  "issuer": "CN=Steve Kille,O=Isode Limited,C=GB",
-                  "subject": "CN=L. Eagle,O=Sue\\, Grabbit and Runn,C=GB"
-                }
-              ]
-        }"#,
-        )
-        .unwrap();
-        assert_eq!(
-            daphne_worker_auth_method.cf_tls_client_auth,
-            Some(trusted_certs.clone()),
-        );
+        let daphne_worker_auth_method: DaphneWorkerAuthMethod = serde_json::from_str("{}").unwrap();
         assert!(daphne_worker_auth_method.bearer_token.is_none());
-
-        let daphne_worker_auth_method: DaphneWorkerAuthMethod = serde_json::from_str(
-            r#"{
-            "bearer_token": "the bearer token",
-            "cf_tls_client_auth": [
-                {
-                  "issuer": "CN=Steve Kille,O=Isode Limited,C=GB",
-                  "subject": "OU=Sales+CN=J. Smith,O=Widget Inc.,C=US"
-                },
-                {
-                  "issuer": "CN=Steve Kille,O=Isode Limited,C=GB",
-                  "subject": "CN=L. Eagle,O=Sue\\, Grabbit and Runn,C=GB"
-                }
-             ]
-        }"#,
-        )
-        .unwrap();
-        assert_eq!(
-            daphne_worker_auth_method.bearer_token,
-            Some(BearerToken::from("the bearer token".to_string()))
-        );
-        assert_eq!(
-            daphne_worker_auth_method.cf_tls_client_auth,
-            Some(trusted_certs),
-        );
     }
 }
diff --git a/crates/daphne-service-utils/src/http_headers.rs b/crates/daphne-service-utils/src/http_headers.rs
index cc29a80..ceeb33e 100644
--- a/crates/daphne-service-utils/src/http_headers.rs
+++ b/crates/daphne-service-utils/src/http_headers.rs
@@ -4,3 +4,4 @@
 pub const HPKE_SIGNATURE: &str = "x-hpke-config-signature";
 pub const DAP_AUTH_TOKEN: &str = "dap-auth-token";
 pub const DAP_TASKPROV: &str = "dap-taskprov";
+pub const STORAGE_PROXY_PUT_KV_EXPIRATION: &str = "x-daphne-storage-proxy-kv-put-expiration";
diff --git a/crates/daphne-worker/src/storage_proxy/mod.rs b/crates/daphne-worker/src/storage_proxy/mod.rs
index d0e8906..aca6cd9 100644
--- a/crates/daphne-worker/src/storage_proxy/mod.rs
+++ b/crates/daphne-worker/src/storage_proxy/mod.rs
@@ -73,17 +73,17 @@ mod metrics;
 
 use std::{sync::OnceLock, time::Duration};
 
+pub use self::metrics::Metrics;
 use daphne::auth::BearerToken;
+use daphne::messages::Time;
 use daphne_service_utils::durable_requests::{
     DurableRequest, ObjectIdFrom, DO_PATH_PREFIX, KV_PATH_PREFIX,
 };
+use daphne_service_utils::http_headers::STORAGE_PROXY_PUT_KV_EXPIRATION;
 use prometheus::Registry;
 use tracing::warn;
 use url::Url;
 use worker::{js_sys::Uint8Array, Delay, Env, Request, RequestInit, Response};
-
-pub use self::metrics::Metrics;
-
 const KV_BINDING_DAP_CONFIG: &str = "DAP_CONFIG";
 
 struct RequestContext<'e> {
@@ -197,6 +197,17 @@ async fn storage_purge(ctx: &RequestContext<'_>) -> worker::Result<Response> {
     Response::empty()
 }
 
+fn parse_expiration_header(ctx: &RequestContext) -> Result<Option<Time>, worker::Error> {
+    let expiration_header = ctx.req.headers().get(STORAGE_PROXY_PUT_KV_EXPIRATION)?;
+    expiration_header
+        .map(|expiration| {
+            expiration.parse::<Time>().map_err(|e| {
+                worker::Error::RustError(format!("Failed to parse expiration header: {e:?}"))
+            })
+        })
+        .transpose()
+}
+
 /// Handle a kv request.
 async fn handle_kv_request(ctx: &mut RequestContext<'_>, key: &str) -> worker::Result<Response> {
     match ctx.req.method() {
@@ -209,12 +220,17 @@ async fn handle_kv_request(ctx: &mut RequestContext<'_>, key: &str) -> worker::R
             }
         }
         worker::Method::Post => {
+            let expiration_unix_timestamp = parse_expiration_header(ctx)?;
+
             match ctx
                 .env
                 .kv(KV_BINDING_DAP_CONFIG)?
                 .put_bytes(key, &ctx.req.bytes().await?)
             {
-                Ok(put) => {
+                Ok(mut put) => {
+                    if let Some(expiration_unix_timestamp) = expiration_unix_timestamp {
+                        put = put.expiration(expiration_unix_timestamp);
+                    }
                     if let Err(error) = put.execute().await {
                         tracing::warn!(
                             ?error,
@@ -232,7 +248,10 @@ async fn handle_kv_request(ctx: &mut RequestContext<'_>, key: &str) -> worker::R
 
             Response::empty()
         }
+
         worker::Method::Put => {
+            let expiration_unix_timestamp = parse_expiration_header(ctx)?;
+
             let kv = ctx.env.kv(KV_BINDING_DAP_CONFIG)?;
             if kv
                 .list()
@@ -246,7 +265,10 @@ async fn handle_kv_request(ctx: &mut RequestContext<'_>, key: &str) -> worker::R
                 Response::error(String::new(), 409 /* Conflict */)
             } else {
                 match kv.put_bytes(key, &ctx.req.bytes().await?) {
-                    Ok(put) => {
+                    Ok(mut put) => {
+                        if let Some(expiration_unix_timestamp) = expiration_unix_timestamp {
+                            put = put.expiration(expiration_unix_timestamp);
+                        }
                         if let Err(error) = put.execute().await {
                             tracing::warn!(
                                 ?error,
diff --git a/crates/daphne/src/pine/flp.rs b/crates/daphne/src/pine/flp.rs
index c0ea024..a79a41a 100644
--- a/crates/daphne/src/pine/flp.rs
+++ b/crates/daphne/src/pine/flp.rs
@@ -502,7 +502,7 @@ mod tests {
     use rand::prelude::*;
     use std::iter;
 
-    use crate::pine::Pine;
+    use crate::pine::{norm_bound_f64_to_u64, Pine};
 
     use super::*;
 
@@ -510,7 +510,8 @@ mod tests {
     fn encode_gradient() {
         let dimension = 10;
         let frac_bits = 4;
-        let pine = Pine::new_128(100.0, dimension, frac_bits, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(100.0, frac_bits);
+        let pine = Pine::new_128(norm_bound, dimension, frac_bits, 4).unwrap();
 
         // We use whole numbers here so that we can test gradient decoding without losing any
         // precision.
@@ -551,7 +552,8 @@ mod tests {
     #[test]
     fn encode_wr_tests() {
         let mut rng = thread_rng();
-        let pine = Pine::new_128(10.0, 10, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(10.0, 15);
+        let pine = Pine::new_128(norm_bound, 10, 15, 4).unwrap();
         let gradient = iter::repeat_with(|| rng.gen_range(-0.1..0.1)).take(10);
 
         let (input, wr_test_results) = pine
@@ -597,28 +599,32 @@ mod tests {
     #[test]
     fn valid() {
         const DIM: usize = 1000;
-        let pine = Pine::new_128(1000.0, DIM, 15, 27).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 27).unwrap();
         pine.run_valid_test_case((0..DIM).map(|i| i as f64 * 0.01));
     }
 
     #[test]
     fn valid_small_dimension() {
         const DIM: usize = 1;
-        let pine = Pine::new_128(1.0, DIM, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 4).unwrap();
         pine.run_valid_test_case([0.75].into_iter());
     }
 
     #[test]
     fn valid_negative_values() {
         const DIM: usize = 1337;
-        let pine = Pine::new_128(1000.0, DIM, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 4).unwrap();
         pine.run_valid_test_case((0..DIM).map(|i| i as f64 * -0.01));
     }
 
     #[test]
     fn valid_random_values() {
         const DIM: usize = 1000;
-        let pine = Pine::new_128(100.0, DIM, 15, 99).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(100.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 99).unwrap();
         let mut rng = thread_rng();
         pine.run_valid_test_case(iter::repeat_with(|| rng.gen_range(-0.1..0.1)).take(DIM));
     }
@@ -626,7 +632,8 @@ mod tests {
     #[test]
     fn invalid_mutated_gradient() {
         const DIM: usize = 10;
-        let pine = Pine::new_128(100.0, DIM, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(100.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 4).unwrap();
         let (mut input, wr_test_results) = pine
             .flp
             .encode_with_wr_joint_rand([0.0; DIM].into_iter(), &Seed::generate().unwrap())
@@ -642,7 +649,8 @@ mod tests {
     #[test]
     fn invalid_mutated_sq_norm() {
         const DIM: usize = 10;
-        let pine = Pine::new_128(100.0, DIM, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(100.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 4).unwrap();
         let (mut input, wr_test_results) = pine
             .flp
             .encode_with_wr_joint_rand([0.0; DIM].into_iter(), &Seed::generate().unwrap())
@@ -662,7 +670,8 @@ mod tests {
     #[test]
     fn invalid_mutated_wr_result() {
         const DIM: usize = 10;
-        let pine = Pine::new_128(100.0, DIM, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(100.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 4).unwrap();
         let (mut input, wr_test_results) = pine
             .flp
             .encode_with_wr_joint_rand([0.0; DIM].into_iter(), &Seed::generate().unwrap())
@@ -683,7 +692,8 @@ mod tests {
     #[test]
     fn invalid_mutated_success_bit() {
         const DIM: usize = 10;
-        let pine = Pine::new_128(100.0, DIM, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(100.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 4).unwrap();
         let (mut input, wr_test_results) = pine
             .flp
             .encode_with_wr_joint_rand([0.0; DIM].into_iter(), &Seed::generate().unwrap())
@@ -705,7 +715,8 @@ mod tests {
     #[test]
     fn invalid_dot_prod_mismatch() {
         const DIM: usize = 10;
-        let pine = Pine::new_128(100.0, DIM, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(100.0, 15);
+        let pine = Pine::new_128(norm_bound, DIM, 15, 4).unwrap();
         let (mut input, wr_test_results) = pine
             .flp
             .encode_with_wr_joint_rand([0.1; DIM].into_iter(), &Seed::generate().unwrap())
diff --git a/crates/daphne/src/pine/mod.rs b/crates/daphne/src/pine/mod.rs
index cd14de1..1468ba6 100644
--- a/crates/daphne/src/pine/mod.rs
+++ b/crates/daphne/src/pine/mod.rs
@@ -48,7 +48,7 @@ pub type Pine128 = Pine<Field128>;
 impl Pine128 {
     /// Construct an instance of [`Pine128`] with the provided parameters.
     pub fn new_128(
-        norm_bound: f64,
+        norm_bound: u64,
         dimension: usize,
         frac_bits: usize,
         chunk_len: usize,
@@ -62,7 +62,7 @@ pub type Pine64 = Pine<Field64>;
 impl Pine64 {
     /// Construct an instance of [`Pine64`] with the provided parameters.
     pub fn new_64(
-        norm_bound: f64,
+        norm_bound: u64,
         dimension: usize,
         frac_bits: usize,
         chunk_len: usize,
@@ -106,7 +106,9 @@ impl<F: FftFriendlyFieldElement> Pine<F> {
     ///
     /// # Parameters
     ///
-    /// * `norm_bound`: Maximum L2-norm of each gradient
+    /// * `norm_bound`: Maximum L2-norm of each gradient, encoded as follows. Let `b: f64` denote
+    /// the desired L2-norm bound. We expect this parameter to be computed by multiplying `b` by
+    /// `2^frac_bits`, taking the floor, and converting to an integer.
     ///
     /// * `dimension`: Length of each gradient
     ///
@@ -120,13 +122,30 @@ impl<F: FftFriendlyFieldElement> Pine<F> {
     ///
     /// * `algorithm_id`: The VDAF algorithm ID.
     pub(crate) fn new(
-        norm_bound: f64,
+        norm_bound: u64,
         dimension: usize,
         frac_bits: usize,
         chunk_len: usize,
         num_proofs: u8,
         algorithm_id: u32,
     ) -> Result<Self, VdafError> {
+        if norm_bound == 0
+            || F::Integer::try_from(usize::try_from(norm_bound).map_err(|e| {
+                VdafError::Uncategorized(format!(
+                    "init failed: error while converting norm bound to usize: {e}"
+                ))
+            })?)
+            .map_err(|e| {
+                VdafError::Uncategorized(format!(
+                    "init failed: error while converting norm bound to field integer: {e}"
+                ))
+            })? > F::modulus() >> 1
+        {
+            return Err(VdafError::Uncategorized(
+                "init failed: norm bound must be positive".into(),
+            ));
+        }
+
         // 24 is the largest number of fractional bits that we know we can support.
         if frac_bits > 24 {
             return Err(VdafError::Uncategorized(
@@ -134,60 +153,45 @@ impl<F: FftFriendlyFieldElement> Pine<F> {
             ));
         }
 
-        let two_to_frac_bits = f64::from(1 << frac_bits);
-
         if dimension == 0 {
             return Err(VdafError::Uncategorized(
                 "init failed: 0-dimension inputs are invalid".into(),
             ));
         }
 
-        if norm_bound <= 0_f64 {
+        if frac_bits > 128 {
             return Err(VdafError::Uncategorized(
-                "init failed: norm bound must be positive".into(),
+                "init failed: number of fractional bits must not exceed 128".into(),
             ));
         }
 
-        if frac_bits > 128 {
+        let Some(sq_norm_bound_int) = norm_bound.checked_mul(norm_bound) else {
             return Err(VdafError::Uncategorized(
-                "init failed: number of fractional bits must not exceed 128".into(),
+                "init failed: squared norm is too large for u64".into(),
             ));
-        }
+        };
 
         let (sq_norm_bound, sq_norm_bits) = {
-            let norm_bound = f64_to_field(norm_bound, two_to_frac_bits)?;
-            let sq_norm_bound = norm_bound * norm_bound;
-
-            // Check if the square wraps around the field modulus.
-            if F::Integer::from(sq_norm_bound) < F::Integer::from(norm_bound) {
-                return Err(VdafError::Uncategorized(
-                    "init failed: squared norm bound exceeds field modulus".into(),
-                ));
-            }
-
-            let sq_norm_bound_int: u64 =
-                F::Integer::from(sq_norm_bound).try_into().map_err(|e| {
-                    VdafError::Uncategorized(format!(
-                        "init failed: failed to convert squared norm bound to u64: {e}"
-                    ))
-                })?;
             let sq_norm_bits = bits(sq_norm_bound_int);
+            let sq_norm_bound = usize::try_from(sq_norm_bound_int).map_err(|e| {
+                VdafError::Uncategorized(format!(
+                    "init failed: failed to convert squared norm bound to usize: {e}"
+                ))
+            })?;
+            let sq_norm_bound = F::Integer::try_from(sq_norm_bound).map_err(|e| {
+                VdafError::Uncategorized(format!(
+                    "init failed: failed to convert squared norm bound to field integer: {e}"
+                ))
+            })?;
+            let sq_norm_bound = F::from(sq_norm_bound);
             (sq_norm_bound, sq_norm_bits)
         };
 
         let (wr_test_bound, wr_test_bits) = {
-            let norm_bound_int: u64 = F::Integer::from(f64_to_field(norm_bound, two_to_frac_bits)?)
-                .try_into()
-                .map_err(|e| {
-                    VdafError::Uncategorized(format!(
-                        "init failed: failed to convert norm bound to u64: {e}"
-                    ))
-                })?;
-
             #[allow(clippy::cast_possible_truncation)]
             #[allow(clippy::cast_sign_loss)]
             let wr_test_bound_int =
-                (((norm_bound_int as f64) * ALPHA).ceil() as u64 + 1).next_power_of_two();
+                (((norm_bound as f64) * ALPHA).ceil() as u64 + 1).next_power_of_two();
             let wr_test_bits = bits(2 * wr_test_bound_int - 1);
             let wr_test_bound = usize::try_from(wr_test_bound_int).map_err(|e| {
                 VdafError::Uncategorized(format!(
@@ -309,6 +313,15 @@ fn chunk_count(chunk_length: usize, length: usize) -> usize {
     (length + chunk_length - 1) / chunk_length
 }
 
+#[cfg(test)]
+fn norm_bound_f64_to_u64(norm_bound: f64, frac_bits: usize) -> u64 {
+    let two_to_frac_bits = f64::from(1 << frac_bits);
+    let norm_bound = norm_bound * two_to_frac_bits;
+    let norm_bound = norm_bound.floor();
+    let norm_bound = norm_bound as u64;
+    norm_bound
+}
+
 pub mod flp;
 pub mod msg;
 #[cfg(test)]
@@ -533,7 +546,7 @@ mod tests {
             },
         ] {
             let pine = Pine::new_128(
-                t.norm_bound,
+                norm_bound_f64_to_u64(t.norm_bound, t.frac_bits),
                 10, // not used by test
                 t.frac_bits,
                 1, // not used by tests
diff --git a/crates/daphne/src/pine/vdaf.rs b/crates/daphne/src/pine/vdaf.rs
index b5970c3..39519e6 100644
--- a/crates/daphne/src/pine/vdaf.rs
+++ b/crates/daphne/src/pine/vdaf.rs
@@ -594,14 +594,15 @@ mod tests {
         },
     };
 
-    use crate::pine::{msg, vdaf::PineVec, Pine};
+    use crate::pine::{msg, norm_bound_f64_to_u64, vdaf::PineVec, Pine};
 
     use assert_matches::assert_matches;
 
     #[test]
     fn run_128() {
         let dimension = 100;
-        let pine = Pine::new_128(1000.0, dimension, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_128(norm_bound, dimension, 15, 4).unwrap();
         let result = run_vdaf(
             &pine,
             &(),
@@ -620,7 +621,8 @@ mod tests {
     #[test]
     fn run_64() {
         let dimension = 100;
-        let pine = Pine::new_64(1000.0, dimension, 15, 100).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_64(norm_bound, dimension, 15, 100).unwrap();
         let result = run_vdaf(
             &pine,
             &(),
@@ -641,7 +643,8 @@ mod tests {
     fn aggregate() {
         let dimension = 100;
         let reports = 5;
-        let pine = Pine::new_128(1000.0, dimension, 15, 100).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_128(norm_bound, dimension, 15, 100).unwrap();
 
         let mut out_shares_0 = Vec::new();
         let mut out_shares_1 = Vec::new();
@@ -674,7 +677,8 @@ mod tests {
     #[test]
     fn prep_failure_mutated_pub_share_wr_joint_rand() {
         let dimension = 100;
-        let pine = Pine::new_128(1000.0, dimension, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_128(norm_bound, dimension, 15, 4).unwrap();
 
         let nonce = [0; 16];
         let (mut public_share, input_shares) = pine.shard(&vec![1.0; dimension], &nonce).unwrap();
@@ -693,7 +697,8 @@ mod tests {
     #[test]
     fn prep_failure_mutated_pub_share_vf_joint_rand() {
         let dimension = 100;
-        let pine = Pine::new_128(1000.0, dimension, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_128(norm_bound, dimension, 15, 4).unwrap();
 
         let nonce = [0; 16];
         let (mut public_share, input_shares) = pine.shard(&vec![1.0; dimension], &nonce).unwrap();
@@ -712,7 +717,8 @@ mod tests {
     #[test]
     fn prep_failure_mutated_input_share_proof() {
         let dimension = 100;
-        let pine = Pine::new_128(1000.0, dimension, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_128(norm_bound, dimension, 15, 4).unwrap();
 
         let nonce = [0; 16];
         let (public_share, mut input_shares) = pine.shard(&vec![1.0; dimension], &nonce).unwrap();
@@ -731,7 +737,8 @@ mod tests {
     #[test]
     fn prep_failure_mutated_input_share_meas() {
         let dimension = 100;
-        let pine = Pine::new_128(1000.0, dimension, 15, 4).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(1000.0, 15);
+        let pine = Pine::new_128(norm_bound, dimension, 15, 4).unwrap();
 
         let nonce = [0; 16];
         let (public_share, mut input_shares) = pine.shard(&vec![1.0; dimension], &nonce).unwrap();
diff --git a/crates/daphne/src/vdaf/pine.rs b/crates/daphne/src/vdaf/pine.rs
index 3c24e6e..29c5371 100644
--- a/crates/daphne/src/vdaf/pine.rs
+++ b/crates/daphne/src/vdaf/pine.rs
@@ -15,56 +15,17 @@ use prio::{codec::ParameterizedDecode, field::FftFriendlyFieldElement, vdaf::Agg
 use serde::{Deserialize, Serialize};
 
 /// [Pine](crate::pine::Pine) parameters.
-#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
 #[serde(rename_all = "snake_case")]
 #[cfg_attr(any(test, feature = "test-utils"), derive(deepsize::DeepSizeOf))]
 pub struct PineConfig {
-    pub norm_bound: f64,
+    pub norm_bound: u64,
     pub dimension: usize,
     pub frac_bits: usize,
     pub chunk_len: usize,
     pub var: PineVariant,
 }
 
-impl PartialEq for PineConfig {
-    fn eq(&self, other: &Self) -> bool {
-        self.norm_bound.total_cmp(&other.norm_bound).is_eq()
-            && self.dimension == other.dimension
-            && self.frac_bits == other.frac_bits
-            && self.chunk_len == other.chunk_len
-            && self.var == other.var
-    }
-}
-
-impl Eq for PineConfig {}
-
-impl Ord for PineConfig {
-    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
-        self.norm_bound
-            .total_cmp(&other.norm_bound)
-            .then(self.dimension.cmp(&other.dimension))
-            .then(self.frac_bits.cmp(&other.frac_bits))
-            .then(self.chunk_len.cmp(&other.chunk_len))
-            .then(self.var.cmp(&self.var))
-    }
-}
-
-impl PartialOrd for PineConfig {
-    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
-        Some(self.cmp(other))
-    }
-}
-
-impl std::hash::Hash for PineConfig {
-    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
-        self.norm_bound.to_bits().hash(state);
-        self.dimension.hash(state);
-        self.frac_bits.hash(state);
-        self.chunk_len.hash(state);
-        self.var.hash(state);
-    }
-}
-
 impl std::fmt::Display for PineConfig {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         let PineConfig {
@@ -278,7 +239,7 @@ mod test {
     async fn roundtrip_128(version: DapVersion) {
         let mut t = AggregationJobTest::new(
             &VdafConfig::Pine(PineConfig {
-                norm_bound: 1.0,
+                norm_bound: 32_000,
                 dimension: 1_000,
                 frac_bits: 20,
                 chunk_len: 50,


Test patch:
diff --git a/crates/daphne/src/pine/test_vec/mod.rs b/crates/daphne/src/pine/test_vec/mod.rs
index b142c25..17c5720 100644
--- a/crates/daphne/src/pine/test_vec/mod.rs
+++ b/crates/daphne/src/pine/test_vec/mod.rs
@@ -176,14 +176,15 @@ impl<F: FftFriendlyFieldElement> Pine<F> {
 mod tests {
     use super::*;
 
-    use crate::pine::Pine;
+    use crate::pine::{norm_bound_f64_to_u64, Pine};
 
     #[test]
     fn run_64() {
         let test_vec =
             serde_json::from_str::<TestVec>(include_str!("00/Pine_Field64.json")).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(test_vec.l2_norm_bound, test_vec.num_frac_bits);
         Pine::new_64(
-            test_vec.l2_norm_bound,
+            norm_bound,
             test_vec.dimension,
             test_vec.num_frac_bits,
             test_vec.chunk_length,
@@ -196,8 +197,9 @@ mod tests {
     fn run_128() {
         let test_vec =
             serde_json::from_str::<TestVec>(include_str!("00/Pine_Field128.json")).unwrap();
+        let norm_bound = norm_bound_f64_to_u64(test_vec.l2_norm_bound, test_vec.num_frac_bits);
         Pine::new_128(
-            test_vec.l2_norm_bound,
+            norm_bound,
             test_vec.dimension,
             test_vec.num_frac_bits,
             test_vec.chunk_length,