feat(opaque): add login opaque exchange

This commit is contained in:
Edouard Vanbelle
2026-07-26 23:01:07 +02:00
parent 0e395ae15f
commit 033146a6c9
10 changed files with 1180 additions and 0 deletions
@@ -13,6 +13,7 @@ mod favorites_pg_repository;
pub mod file_metadata_repository;
mod magic_link_token_pg_repository;
mod nextcloud_object_id_repository;
mod opaque_pg_repository;
pub mod playlist_pg_repository;
mod recent_items_pg_repository;
mod session_pg_repository;
@@ -46,6 +47,7 @@ pub use file_metadata_repository::FileMetadataRepository;
pub use folder_db_repository::FolderDbRepository;
pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository;
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
pub use opaque_pg_repository::OpaquePgRepository;
pub use playlist_pg_repository::{
AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository,
};
@@ -0,0 +1,460 @@
//! PostgreSQL repository for OPAQUE aPAKE envelopes.
//!
//! Backs [`OpaqueRepositoryPort`] against the three OPAQUE columns on
//! `auth.users` introduced by migration `20260926000000_auth_opaque.sql`:
//!
//! * `opaque_envelope BYTEA` — the serialised registration blob,
//! * `opaque_ciphersuite_version SMALLINT` — the bound suite version,
//! * `opaque_registered_at TIMESTAMPTZ` — first-registration timestamp,
//!
//! plus the co-located `force_password_change_at_next_login BOOLEAN`
//! toggled by [`clear_registration`].
//!
//! No caching. OPAQUE reads happen at most once per login (the
//! server hands the envelope to `ServerLogin::start` and that's it),
//! so the added cache-invalidation complexity would earn nothing. If
//! that ever changes (batch-endpoint use), the moka pattern in
//! `login_lockout_service` is the shape to reach for.
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use crate::application::ports::opaque_ports::{OpaqueRepositoryPort, StoredEnvelope};
use crate::common::errors::{DomainError, Result};
pub struct OpaquePgRepository {
pool: Arc<PgPool>,
}
impl OpaquePgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
fn pool(&self) -> &PgPool {
&self.pool
}
}
#[async_trait]
impl OpaqueRepositoryPort for OpaquePgRepository {
async fn write_registration(
&self,
user_id: Uuid,
envelope: &[u8],
ciphersuite_version: i16,
) -> Result<()> {
// COALESCE preserves the first-registration timestamp across
// re-registrations (password change → new envelope, same
// registered_at). The alternative — always stamping `NOW()`
// — would erase the operational signal "when did this user
// first join OPAQUE," which the migration dashboard reads.
let res = sqlx::query(
r#"
UPDATE auth.users
SET opaque_envelope = $2,
opaque_ciphersuite_version = $3,
opaque_registered_at = COALESCE(opaque_registered_at, NOW())
WHERE id = $1
"#,
)
.bind(user_id)
.bind(envelope)
.bind(ciphersuite_version)
.execute(self.pool())
.await
.map_err(|e| DomainError::internal_error("OpaquePg", format!("write_registration: {e}")))?;
if res.rows_affected() == 0 {
// No matching user id — caller expected the user to exist.
// We surface this as NotFound rather than swallowing so the
// handler layer can 404 anti-enum consistently.
return Err(DomainError::not_found("User", user_id.to_string()));
}
Ok(())
}
async fn read_registration(&self, user_id: Uuid) -> Result<Option<StoredEnvelope>> {
// `try_get` on the envelope column returns None when the row
// exists but the column is NULL (the Phase 0 default for every
// pre-migration account). A missing row propagates as NotFound
// via the same anti-enum path as `write_registration`.
let row = sqlx::query_as::<_, EnvelopeRow>(
r#"
SELECT opaque_envelope,
opaque_ciphersuite_version,
opaque_registered_at
FROM auth.users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("OpaquePg", format!("read_registration: {e}")))?;
let Some(row) = row else {
return Err(DomainError::not_found("User", user_id.to_string()));
};
// All three columns are NULL together (they're set atomically by
// `write_registration`). Any partial-NULL is a schema-drift
// symptom — return None with a warn so ops can catch it.
match (row.envelope, row.ciphersuite_version, row.registered_at) {
(Some(env), Some(ver), Some(at)) => Ok(Some(StoredEnvelope {
envelope: env,
ciphersuite_version: ver,
registered_at: at,
})),
(None, None, None) => Ok(None),
(env, ver, at) => {
tracing::warn!(
target: "oxicloud::opaque",
user_id = %user_id,
envelope_set = env.is_some(),
version_set = ver.is_some(),
registered_at_set = at.is_some(),
"OPAQUE columns partial-NULL — treating as unregistered. \
This shouldn't happen; check for a broken migration."
);
Ok(None)
}
}
}
async fn clear_registration(&self, user_id: Uuid) -> Result<()> {
// One UPDATE writes both the envelope invalidation AND the
// force-change flag — matches the atomicity we promise in the
// port doc, avoids drift between two separate writes.
let res = sqlx::query(
r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL,
force_password_change_at_next_login = TRUE
WHERE id = $1
"#,
)
.bind(user_id)
.execute(self.pool())
.await
.map_err(|e| DomainError::internal_error("OpaquePg", format!("clear_registration: {e}")))?;
if res.rows_affected() == 0 {
return Err(DomainError::not_found("User", user_id.to_string()));
}
Ok(())
}
}
#[derive(sqlx::FromRow)]
struct EnvelopeRow {
#[sqlx(rename = "opaque_envelope")]
envelope: Option<Vec<u8>>,
#[sqlx(rename = "opaque_ciphersuite_version")]
ciphersuite_version: Option<i16>,
#[sqlx(rename = "opaque_registered_at")]
registered_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[cfg(integration_tests)]
#[allow(dead_code)]
mod integration_tests {
use super::*;
use crate::integration_test_support::{ensure_clean_test_db, test_db_url};
use sqlx::postgres::PgPoolOptions;
async fn test_repo() -> OpaquePgRepository {
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&test_db_url())
.await
.expect("connect to integration-test PostgreSQL");
ensure_clean_test_db(&pool).await;
OpaquePgRepository::new(Arc::new(pool))
}
/// Hermetic per-test user seed. `project_test_fixture_self_seeding`
/// memo: don't couple to `init-test-schema.sh`'s implicit admin —
/// mint a fresh user with a unique email so parallel tests don't
/// stomp each other. Returns the new user's id.
async fn seed_user(repo: &OpaquePgRepository, email: &str) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
r#"
INSERT INTO auth.users (
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, active
) VALUES (
$1, NULL, $2, NULL, 'user'::auth.userrole,
0, 0, NOW(), NOW(), TRUE
)
"#,
)
.bind(id)
.bind(email)
.execute(repo.pool())
.await
.expect("seed test user");
id
}
#[tokio::test]
async fn write_then_read_round_trips_envelope_and_version() {
let repo = test_repo().await;
let user = seed_user(
&repo,
&format!("opaque-rt-{}@example.invalid", Uuid::new_v4()),
)
.await;
assert!(
repo.read_registration(user)
.await
.expect("read pre")
.is_none(),
"seed user must start with no envelope"
);
let payload = b"envelope-v1-bytes".to_vec();
repo.write_registration(user, &payload, 1)
.await
.expect("write");
let stored = repo
.read_registration(user)
.await
.expect("read post")
.expect("envelope now present");
assert_eq!(stored.envelope, payload);
assert_eq!(stored.ciphersuite_version, 1);
}
#[tokio::test]
async fn re_registration_preserves_registered_at_but_swaps_envelope() {
// Password change / silent-migration re-mint: fresh envelope
// bytes + potentially a new suite version, but the
// first-registration timestamp must stay stable (ops dashboard
// reads it to know when the user joined OPAQUE).
let repo = test_repo().await;
let user = seed_user(
&repo,
&format!("opaque-rereg-{}@example.invalid", Uuid::new_v4()),
)
.await;
repo.write_registration(user, b"first-envelope", 1)
.await
.expect("first write");
let first = repo.read_registration(user).await.unwrap().unwrap();
// Tiny sleep so a bug that overwrites registered_at with NOW()
// would produce a measurably different timestamp.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
repo.write_registration(user, b"second-envelope", 1)
.await
.expect("second write");
let second = repo.read_registration(user).await.unwrap().unwrap();
assert_eq!(second.envelope, b"second-envelope");
assert_eq!(
second.registered_at, first.registered_at,
"registered_at must be preserved across re-registration (COALESCE guard)"
);
}
#[tokio::test]
async fn clear_registration_nulls_columns_and_sets_force_change_flag() {
let repo = test_repo().await;
let user = seed_user(
&repo,
&format!("opaque-clr-{}@example.invalid", Uuid::new_v4()),
)
.await;
repo.write_registration(user, b"envelope", 1)
.await
.expect("prime with envelope");
repo.clear_registration(user)
.await
.expect("clear registration");
// Envelope columns are back to NULL.
assert!(
repo.read_registration(user).await.unwrap().is_none(),
"clear must NULL the envelope"
);
// Force-change flag is TRUE — this is the load-bearing behaviour
// that keeps admin-set passwords temporary.
let flag: (bool,) = sqlx::query_as(
"SELECT force_password_change_at_next_login FROM auth.users WHERE id = $1",
)
.bind(user)
.fetch_one(repo.pool())
.await
.expect("read force-change flag");
assert!(flag.0, "clear must set force_password_change_at_next_login");
}
/// End-to-end proof: run the FULL OPAQUE register handshake
/// client-side against the real ciphersuite, persist the resulting
/// envelope through this repository, read it back on a fresh
/// connection, and use those bytes to complete a real login
/// handshake. This is the load-bearing test for Phase 1 Step 3 —
/// it proves the shape the register endpoints will land on:
///
/// client_register.start
/// → ServerRegistration::start (server-side, produces response)
/// client_register.finish
/// → ServerRegistration::finish → serialize → repo.write
/// repo.read
/// → ServerRegistration::deserialize
/// → ServerLogin::start (with the stored password_file)
/// client_login.finish → ServerLogin::finish
/// → session_key matches
///
/// If any of these steps drift (envelope shape change, ciphersuite
/// mismatch, serialisation format regression), this test catches
/// it — without needing to spin up an HTTP server.
#[tokio::test]
async fn envelope_persists_across_register_and_serves_a_matching_login() {
use crate::infrastructure::services::opaque_service::OxiCloudSuite;
use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, ServerLogin, ServerLoginStartParameters,
ServerRegistration, ServerSetup,
};
use rand_core::OsRng;
let repo = test_repo().await;
let user = seed_user(
&repo,
&format!("opaque-e2e-{}@example.invalid", Uuid::new_v4()),
)
.await;
// Fresh server setup for this test only — mirrors what the
// DI factory would load from OXICLOUD_OPAQUE_SERVER_SETUP.
let mut server_rng = OsRng;
let server_setup = ServerSetup::<OxiCloudSuite>::new(&mut server_rng);
// Fast KSF so the test finishes in ms rather than seconds.
let ksf = argon2::Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::new(8, 1, 1, None).unwrap(),
);
let mut client_rng = OsRng;
let user_bytes = user.as_bytes();
let passphrase = b"correct horse battery staple";
// ── REGISTER — same shape the /register/{start,finish} handlers run ─
let client_reg = ClientRegistration::<OxiCloudSuite>::start(&mut client_rng, passphrase)
.expect("client_register.start");
let server_reg = ServerRegistration::<OxiCloudSuite>::start(
&server_setup,
client_reg.message,
user_bytes,
)
.expect("server_register.start");
let client_reg_finish = client_reg
.state
.finish(
&mut client_rng,
passphrase,
server_reg.message,
ClientRegistrationFinishParameters::new(
opaque_ke::Identifiers::default(),
Some(&ksf),
),
)
.expect("client_register.finish");
let password_file = ServerRegistration::<OxiCloudSuite>::finish(client_reg_finish.message);
// Persist through the repo — this is exactly what `register/finish`
// will do at the handler layer. `.serialize()` returns a
// `GenericArray`; convert to `Vec<u8>` at the boundary so
// downstream comparisons stay simple.
let envelope_bytes: Vec<u8> = password_file.serialize().to_vec();
repo.write_registration(user, &envelope_bytes, 1)
.await
.expect("persist envelope");
// ── LOGIN — reads the envelope back the way `login/ke1` will ─────
let stored = repo
.read_registration(user)
.await
.expect("read")
.expect("envelope present after write");
assert_eq!(stored.ciphersuite_version, 1);
assert_eq!(
stored.envelope, envelope_bytes,
"stored bytes must round-trip verbatim"
);
let password_file_back = ServerRegistration::<OxiCloudSuite>::deserialize(&stored.envelope)
.expect("deserialize stored envelope");
let client_login = ClientLogin::<OxiCloudSuite>::start(&mut client_rng, passphrase)
.expect("client_login.start");
let server_login = ServerLogin::start(
&mut server_rng,
&server_setup,
Some(password_file_back),
client_login.message,
user_bytes,
ServerLoginStartParameters::default(),
)
.expect("server_login.start (with stored envelope)");
let client_login_finish = client_login
.state
.finish(
passphrase,
server_login.message,
ClientLoginFinishParameters::new(
None,
opaque_ke::Identifiers::default(),
Some(&ksf),
),
)
.expect("client_login.finish");
let server_login_finish = server_login
.state
.finish(client_login_finish.message)
.expect("server_login.finish");
assert_eq!(
client_login_finish.session_key.as_slice(),
server_login_finish.session_key.as_slice(),
"session keys must match after a full register → persist → login round trip"
);
}
#[tokio::test]
async fn missing_user_surfaces_notfound_on_write_and_read_and_clear() {
let repo = test_repo().await;
let ghost = Uuid::new_v4();
for err in [
repo.write_registration(ghost, b"whatever", 1)
.await
.unwrap_err(),
repo.read_registration(ghost).await.unwrap_err(),
repo.clear_registration(ghost).await.unwrap_err(),
] {
assert_eq!(
err.kind,
crate::common::errors::ErrorKind::NotFound,
"missing-user path must surface as NotFound (anti-enum)"
);
}
}
}
+1
View File
@@ -35,6 +35,7 @@ pub mod noop_face_analyzer;
pub mod oidc_service;
#[cfg(feature = "faces-onnx")]
pub mod onnx_face_analyzer;
pub mod opaque_login_exchange;
pub mod opaque_service;
pub mod password_hasher;
pub mod path_resolver_service;
@@ -0,0 +1,252 @@
//! In-memory login-exchange cache for OPAQUE aPAKE (Phase 1).
//!
//! OPAQUE login is a two-round exchange. KE1 arrives from the client
//! and produces a [`ServerLogin`] state that must survive until the
//! matching KE3 arrives (usually within a few hundred milliseconds).
//! This module holds that state between the two round-trips, keyed by
//! a random `exchange_id: Uuid` handed back to the client on KE1.
//!
//! ## Why in-memory
//!
//! Single-instance deployments (the current OxiCloud shape) can use a
//! process-local cache without correctness issues — KE1 and KE3 always
//! hit the same server. Multi-instance deployments (Phase 5+ if we go
//! there) would need to swap the backing to Redis or session-affinity
//! at the load balancer; the shape of this module (`store`/`take`)
//! stays identical, so the swap would be local.
//!
//! ## Why NOT put the state in a cookie
//!
//! The naive alternative — "cookie the ServerLogin state client-side" —
//! would leak the server's ephemeral private key material (the KE1
//! response bakes it in). Even if AEAD-wrapped with a server secret,
//! that AEAD key becomes another crown jewel to rotate. Server-side
//! storage keyed by a random opaque handle is simpler and correct.
//!
//! ## Single-use semantics
//!
//! An `exchange_id` MUST be consumed at most once. Two concurrent KE3s
//! with the same id would be either an accidental double-submit or a
//! replay attempt; either way, the second must fail. We use moka's
//! atomic [`Cache::remove`] (get-and-invalidate in one call — verified
//! in moka 0.12+ source) so there's no race window between "state
//! exists" and "state consumed".
//!
//! ## TTL
//!
//! 60s is the ceiling for a normal OPAQUE login round-trip (KE1
//! response → user typed nothing new → client computes KE3 → sends).
//! Beyond that, the state is stale — the client would have to start
//! over anyway. Bounded capacity (default 10k concurrent exchanges,
//! LRU-evicted) caps memory even under a burst / abuse pattern.
use std::sync::Arc;
use std::time::Duration;
use moka::sync::Cache;
use opaque_ke::ServerLogin;
use uuid::Uuid;
use crate::infrastructure::services::opaque_service::OxiCloudSuite;
/// Default lifetime of a login exchange between KE1 and KE3.
///
/// Chosen empirically to cover slow client CPUs (Argon2id on mobile
/// can take ~1s with production KSF params) plus a network buffer.
/// Extending beyond 60s is a security taste question; shortening
/// under ~15s starts failing legitimate slow clients.
pub const DEFAULT_TTL_SECS: u64 = 60;
/// Default maximum concurrent in-flight exchanges. Bounds memory in
/// case of an attack or bug that spams KE1 without ever sending KE3.
/// Each entry is roughly the size of a `ServerLogin<OxiCloudSuite>`
/// (~200 bytes with the Ristretto255 keypair + AKE state), so 10k
/// entries is a couple of MB total — well below "worry" territory.
pub const DEFAULT_MAX_INFLIGHT: u64 = 10_000;
/// Handle passed to the client on KE1 that the client must echo back
/// on KE3. Opaque random UUID — nothing about the server state is
/// derivable from it, so leaking it only enables a race that STILL
/// requires a valid KE3 payload to succeed (which requires the
/// correct passphrase).
pub type ExchangeId = Uuid;
/// In-memory cache holding server-side login state between KE1 and KE3.
#[derive(Clone)]
pub struct OpaqueLoginExchange {
inner: Arc<Cache<ExchangeId, ServerLogin<OxiCloudSuite>>>,
}
impl OpaqueLoginExchange {
/// Build a cache with production defaults ([`DEFAULT_TTL_SECS`],
/// [`DEFAULT_MAX_INFLIGHT`]). Callers wanting tighter TTL for
/// tests should use [`Self::with_params`].
pub fn new() -> Self {
Self::with_params(Duration::from_secs(DEFAULT_TTL_SECS), DEFAULT_MAX_INFLIGHT)
}
/// Build with explicit TTL + capacity. Used by tests to shrink the
/// TTL so expiry paths can be exercised in milliseconds.
pub fn with_params(ttl: Duration, max_capacity: u64) -> Self {
let inner = Cache::builder()
.time_to_live(ttl)
.max_capacity(max_capacity)
.build();
Self {
inner: Arc::new(inner),
}
}
/// Stash a fresh `ServerLogin` state and return the handle to
/// hand back to the client. The exchange_id is generated here so
/// callers can't accidentally reuse one — every KE1 gets its own.
pub fn store(&self, state: ServerLogin<OxiCloudSuite>) -> ExchangeId {
let id = Uuid::new_v4();
self.inner.insert(id, state);
id
}
/// Atomically consume the state for `exchange_id`. Returns `None`
/// if the id is unknown, already consumed, or expired. Callers
/// must treat those three cases identically (anti-enum): a KE3
/// with a bad id, a replay, and a timeout should all surface as
/// the same `InvalidCredentials` shape to the client.
///
/// Uses moka's atomic `remove` (verified single get-and-invalidate
/// in moka 0.12+, no race window between the two operations).
pub fn take(&self, exchange_id: ExchangeId) -> Option<ServerLogin<OxiCloudSuite>> {
self.inner.remove(&exchange_id)
}
/// Force runtime maintenance (LRU eviction + TTL sweep). Moka runs
/// these opportunistically on `insert`/`get` too; test code calls
/// this after fast-forwarding time so expiry assertions are
/// deterministic without waiting on background threads.
#[cfg(test)]
fn run_pending_tasks(&self) {
self.inner.run_pending_tasks();
}
}
impl Default for OpaqueLoginExchange {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::config::OpaqueConfig;
use crate::infrastructure::services::opaque_service::{
OpaqueMode, OpaqueService, OxiCloudSuite,
};
use opaque_ke::{
ClientLogin, ClientRegistration, ClientRegistrationFinishParameters,
ServerLoginStartParameters, ServerRegistration,
};
use rand_core::OsRng;
/// Build a real `ServerLogin<OxiCloudSuite>` to stash — the type
/// is generic and can't be `Default`ed, so we drive a mini
/// registration + KE1 to produce one. Slower than a fake, but
/// this exercises the full crate type-plumb.
fn build_server_login_state() -> ServerLogin<OxiCloudSuite> {
let svc = OpaqueService::from_config(OpaqueConfig {
mode: OpaqueMode::Migrate,
server_setup_b64: Some(OpaqueService::generate_server_setup_b64()),
..OpaqueConfig::default()
})
.expect("build service");
let ksf = argon2::Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::new(8, 1, 1, None).unwrap(),
);
let mut rng = OsRng;
let user = b"alice@example.com";
let pass = b"pw";
// Registration to prime the password file.
let client_reg = ClientRegistration::<OxiCloudSuite>::start(&mut rng, pass).unwrap();
let server_reg =
ServerRegistration::<OxiCloudSuite>::start(svc.setup(), client_reg.message, user)
.unwrap();
let client_reg_finish = client_reg
.state
.finish(
&mut rng,
pass,
server_reg.message,
ClientRegistrationFinishParameters::new(
opaque_ke::Identifiers::default(),
Some(&ksf),
),
)
.unwrap();
let password_file = ServerRegistration::<OxiCloudSuite>::finish(client_reg_finish.message);
// KE1 to produce the ServerLogin state we want to stash.
let client_login = ClientLogin::<OxiCloudSuite>::start(&mut rng, pass).unwrap();
opaque_ke::ServerLogin::start(
&mut rng,
svc.setup(),
Some(password_file),
client_login.message,
user,
ServerLoginStartParameters::default(),
)
.unwrap()
.state
}
#[test]
fn store_and_take_round_trip_returns_the_same_state_once() {
let cache = OpaqueLoginExchange::with_params(Duration::from_secs(60), 100);
let state = build_server_login_state();
let id = cache.store(state);
// Second call after take must miss — single-use semantic.
assert!(cache.take(id).is_some(), "first take retrieves the state");
assert!(
cache.take(id).is_none(),
"second take must miss — exchange_id is single-use"
);
}
#[test]
fn unknown_id_returns_none() {
let cache = OpaqueLoginExchange::new();
assert!(cache.take(Uuid::new_v4()).is_none());
}
#[test]
fn expired_state_is_evicted_and_take_returns_none() {
// 100ms TTL so the test finishes fast without wall-clock sleep
// beyond that. Moka's TTL is not perfectly wall-clock precise
// (it runs pending tasks lazily), so `run_pending_tasks`
// forces a deterministic sweep.
let cache = OpaqueLoginExchange::with_params(Duration::from_millis(100), 100);
let id = cache.store(build_server_login_state());
std::thread::sleep(Duration::from_millis(150));
cache.run_pending_tasks();
assert!(
cache.take(id).is_none(),
"state must be evicted after TTL — replay attempts past 60s must fail"
);
}
#[test]
fn store_yields_distinct_exchange_ids_per_call() {
// Two KE1s from the same user MUST get different exchange_ids
// — reusing one would enable a KE3 to consume the wrong
// exchange's state.
let cache = OpaqueLoginExchange::new();
let a = cache.store(build_server_login_state());
let b = cache.store(build_server_login_state());
assert_ne!(a, b, "each store() must mint a fresh UUID");
}
}