diff --git a/README.md b/README.md index a6cf22f..90d83c5 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ it intentionally has no overlap with the rest of libchat (axum + rusqlite only). `device_id` is the hex-encoded 32-byte Ed25519 verifying key of a device. -It also runs a minimal **account service**: one signed blob per **`account_id`** +It also runs a minimal **account service**: one signed blob per **`account_pub`** mapping an Account to its set of device (LocalIdentity) public keys, so clients -can invite every LocalIdentity of an account. `account_id` is the hex-encoded +can invite every LocalIdentity of an account. `account_pub` is the hex-encoded 32-byte Ed25519 AccountAddress verifying key. See [Account device-list endpoints](#account-device-list-endpoints). @@ -112,9 +112,9 @@ fails verification must be treated as not found. ## Account device-list endpoints -The account service stores **exactly one blob per `account_id`** mapping an +The account service stores **exactly one blob per `account_pub`** mapping an Account to its LocalIdentity device keys. Same trust model as keypackages: the -server verifies `signature` over `payload` under `account_id`'s key +server verifies `signature` over `payload` under `account_pub`'s key (proof-of-possession), and consumers MUST re-verify on retrieve. Clients encode a lamport-timestamped list of device public keys in `payload`; the rest of the payload stays opaque to the server. @@ -132,9 +132,9 @@ Upsert the device-list bundle for an account; replaces any previous value. ```json { - "account_id": "hex(32-byte ed25519 AccountAddress verifying key)", - "payload": "base64(opaque signed bytes: lamport-ts + device pubkeys)", - "signature": "base64(64-byte ed25519 signature over payload by the account key)" + "account_pub": "hex(32-byte ed25519 AccountAddress verifying key)", + "payload": "base64(opaque signed bytes: lamport-ts + device pubkeys)", + "signature": "base64(64-byte ed25519 signature over payload by the account key)" } ``` @@ -142,7 +142,7 @@ Returns `204` on success, `400` on malformed input or a signature that fails to verify, and `409` when the bundle's lamport is not newer than the stored one (replay / stale publish). -### `GET /v0/account/{account_id}` +### `GET /v0/account/{account_pub}` Returns the stored bundle for that account, or `404`: @@ -155,12 +155,12 @@ Returns the stored bundle for that account, or `404`: ``` `updated_at` is the server's last-upsert time in Unix ms. Consumers verify -`signature` over `payload` under `account_id`'s key, then decode the device list. +`signature` over `payload` under `account_pub`'s key, then decode the device list. ## Storage & retention Two SQLite tables: `keypackages` keyed by `device_id`, and `account_bundles` -(one row per `account_id`). A background task runs every `--prune-interval-secs`, +(one row per `account_pub`). A background task runs every `--prune-interval-secs`, dropping keypackage bundles older than `--retention-days` (keeping at most `--max-per-identity` per `device_id`) and dropping account bundles not refreshed within `--retention-days`. The schema is an internal detail and may change. diff --git a/examples/smoke_test.rs b/examples/smoke_test.rs index 9fa6246..92738e7 100644 --- a/examples/smoke_test.rs +++ b/examples/smoke_test.rs @@ -71,7 +71,7 @@ fn test_keypackage(client: &Client, base: &str) -> Result<()> { fn test_account(client: &Client, base: &str) -> Result<()> { let key = signing_key(2); - let account_id = pub_hex(&key); + let account_pub = pub_hex(&key); // The account payload is NOT arbitrary: it must start with the domain prefix, // a version byte, and an 8-byte little-endian lamport so the server can run @@ -82,7 +82,7 @@ fn test_account(client: &Client, base: &str) -> Result<()> { payload.extend_from_slice(&lamport.to_le_bytes()); let body = json!({ - "account_id": account_id, + "account_pub": account_pub, "payload": BASE64.encode(&payload), "signature": BASE64.encode(key.sign(&payload).to_bytes()), }); @@ -93,7 +93,7 @@ fn test_account(client: &Client, base: &str) -> Result<()> { .send()?; println!("POST /v0/account -> {} (expect 204)", resp.status()); - let resp = client.get(format!("{base}/v0/account/{account_id}")).send()?; + let resp = client.get(format!("{base}/v0/account/{account_pub}")).send()?; println!( "GET /v0/account/ -> {} (expect 200) {}", resp.status(), diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 1a67cde..a484841 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -8,10 +8,11 @@ CREATE TABLE keypackages ( ); -- Account device-list bundles: exactly one row per account; newer upserts --- replace the existing row (compare-and-swap on the payload's lamport). +-- replace the existing row (compare-and-swap on the payload's lamport). Keyed by +-- the hex-encoded account verifying key. CREATE TABLE account_bundles ( - account_id TEXT NOT NULL PRIMARY KEY, - updated_at INTEGER NOT NULL, - payload BLOB NOT NULL, - signature BLOB NOT NULL + account_pub TEXT NOT NULL PRIMARY KEY, + updated_at INTEGER NOT NULL, + payload BLOB NOT NULL, + signature BLOB NOT NULL ); diff --git a/src/handlers.rs b/src/handlers.rs index a1c8f75..d2e400c 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -42,7 +42,7 @@ pub fn router(store: Arc) -> Router { .route("/v0/keypackage", post(submit)) .route("/v0/keypackage/:device_id", get(fetch)) .route("/v0/account", post(submit_account)) - .route("/v0/account/:account_id", get(fetch_account)) + .route("/v0/account/:account_pub", get(fetch_account)) .with_state(store) } @@ -110,12 +110,12 @@ async fn fetch( /// to encode a lamport-timestamped list of device (LocalIdentity) Ed25519 /// public keys inside it so that consumers can detect stale bundles. The server /// only verifies that `signature` is a valid Ed25519 signature over `payload` -/// made by the key identified by `account_id`. +/// made by the key identified by `account_pub`. #[derive(Debug, Deserialize)] pub struct SubmitAccountRequest { /// Hex of the 32-byte Ed25519 account (AccountAddress) verifying key. /// Acts as both the storage key and the verification key. - pub account_id: String, + pub account_pub: String, /// base64 of the opaque signed payload (lamport-ts + device pubkeys, etc.). pub payload: String, /// base64 of the 64-byte Ed25519 signature over `payload` made by the @@ -136,16 +136,16 @@ pub struct FetchAccountResponse { /// `POST /v0/account` — upsert a signed device-list bundle for an account. /// /// The server verifies the Ed25519 signature and then stores exactly one blob -/// per `account_id`, replacing any previous value. Clients should re-publish +/// per `account_pub`, replacing any previous value. Clients should re-publish /// whenever they add or rotate LocalIdentities. async fn submit_account( State(store): State>, Json(req): Json, ) -> Result { - let account_pubkey: [u8; 32] = hex::decode(&req.account_id) + let account_pubkey: [u8; 32] = hex::decode(&req.account_pub) .ok() .and_then(|b| b.try_into().ok()) - .ok_or_else(|| ApiError::bad("account_id: must be hex of a 32-byte key"))?; + .ok_or_else(|| ApiError::bad("account_pub: must be hex of a 32-byte key"))?; let payload = BASE64 .decode(&req.payload) .map_err(|_| ApiError::bad("payload: not valid base64"))?; @@ -156,7 +156,7 @@ async fn submit_account( .ok_or_else(|| ApiError::bad("signature: must be base64 of 64 bytes"))?; let verifying_key = VerifyingKey::from_bytes(&account_pubkey) - .map_err(|_| ApiError::bad("account_id: not a valid ed25519 key"))?; + .map_err(|_| ApiError::bad("account_pub: not a valid ed25519 key"))?; verifying_key .verify_strict(&payload, &Signature::from_bytes(&signature)) .map_err(|_| ApiError::bad("signature: verification failed"))?; @@ -169,7 +169,7 @@ async fn submit_account( let applied = store .upsert_account( - &req.account_id, + &req.account_pub, lamport, &StoredAccountBundle { payload, @@ -187,20 +187,20 @@ async fn submit_account( Ok(StatusCode::NO_CONTENT) } -/// `GET /v0/account/:account_id` — fetch the device-list bundle for an account. +/// `GET /v0/account/:account_pub` — fetch the device-list bundle for an account. /// /// Returns the latest published bundle so consumers can verify the /// account signature and decode the list of LocalIdentity keys themselves. async fn fetch_account( State(store): State>, - Path(account_id): Path, + Path(account_pub): Path, ) -> Result, ApiError> { let Some(bundle) = store - .get_account(&account_id) + .get_account(&account_pub) .await .map_err(ApiError::internal)? else { - return Err(ApiError::not_found("no account bundle for account_id")); + return Err(ApiError::not_found("no account bundle for account_pub")); }; Ok(Json(FetchAccountResponse { payload: BASE64.encode(&bundle.payload), diff --git a/src/main.rs b/src/main.rs index 0086572..79d23f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ //! POST /v0/keypackage — submit a signed keypackage bundle //! GET /v0/keypackage/{device_id} — fetch the latest stored keypackage bundle //! POST /v0/account — upsert a signed account device-list bundle -//! GET /v0/account/{account_id} — fetch the account device-list bundle +//! GET /v0/account/{account_pub} — fetch the account device-list bundle mod handlers; mod store; @@ -34,7 +34,7 @@ struct Cli { #[arg(long, default_value = "chat-store.db")] db: PathBuf, - /// Maximum number of bundles retained per account_id. + /// Maximum number of keypackage bundles retained per device_id. #[arg(long, default_value_t = 100)] max_per_identity: usize, diff --git a/src/store.rs b/src/store.rs index 7cae6e4..77607c9 100644 --- a/src/store.rs +++ b/src/store.rs @@ -19,7 +19,7 @@ pub struct StoredKeyPackageBundle { } /// A signed bundle associating an account with its set of device (LocalIdentity) -/// public keys. The server stores exactly one blob per `account_id`; a newer +/// public keys. The server stores exactly one blob per `account_pub`; a newer /// bundle replaces the old one only when its lamport is strictly higher (see /// [`Store::upsert_account`]). `payload` is otherwise opaque to the server: it /// encodes a lamport-timestamped list of device pubkeys signed by the account @@ -106,7 +106,7 @@ impl Store { Ok(row) } - /// Upsert the signed device-list bundle for `account_id`. The server stores + /// Upsert the signed device-list bundle for `account_pub`. The server stores /// exactly one blob per account. /// /// Anti-replay: `lamport` is the monotonic version read from `bundle.payload` @@ -123,7 +123,7 @@ impl Store { /// `bundle` is ignored; the store stamps the row with the current time. pub async fn upsert_account( &self, - account_id: &str, + account_pub: &str, lamport: u64, bundle: &StoredAccountBundle, ) -> Result { @@ -131,9 +131,9 @@ impl Store { let mut tx = self.pool.begin().await?; let existing_lamport = sqlx::query_scalar::<_, Vec>( - "SELECT payload FROM account_bundles WHERE account_id = ?", + "SELECT payload FROM account_bundles WHERE account_pub = ?", ) - .bind(account_id) + .bind(account_pub) .fetch_optional(&mut *tx) .await? .and_then(|payload| payload_lamport(&payload)); @@ -144,14 +144,14 @@ impl Store { return Ok(false); } sqlx::query( - "INSERT INTO account_bundles (account_id, updated_at, payload, signature) + "INSERT INTO account_bundles (account_pub, updated_at, payload, signature) VALUES (?, ?, ?, ?) - ON CONFLICT(account_id) DO UPDATE SET + ON CONFLICT(account_pub) DO UPDATE SET updated_at = excluded.updated_at, payload = excluded.payload, signature = excluded.signature", ) - .bind(account_id) + .bind(account_pub) .bind(updated_at) .bind(&bundle.payload) .bind(&bundle.signature) @@ -161,13 +161,13 @@ impl Store { Ok(true) } - /// Returns the stored bundle for `account_id`, or `None` if unknown. - pub async fn get_account(&self, account_id: &str) -> Result> { + /// Returns the stored bundle for `account_pub`, or `None` if unknown. + pub async fn get_account(&self, account_pub: &str) -> Result> { let row = sqlx::query_as::<_, StoredAccountBundle>( "SELECT payload, signature, updated_at FROM account_bundles - WHERE account_id = ?", + WHERE account_pub = ?", ) - .bind(account_id) + .bind(account_pub) .fetch_optional(&self.pool) .await?; Ok(row)