chore: use account pub in code

This commit is contained in:
kaichaosun 2026-06-11 16:44:43 +08:00
parent 5752c00463
commit 5c1fef16f4
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
6 changed files with 45 additions and 44 deletions

View File

@ -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.

View File

@ -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/<id> -> {} (expect 200) {}",
resp.status(),

View File

@ -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
);

View File

@ -42,7 +42,7 @@ pub fn router(store: Arc<Store>) -> 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<Arc<Store>>,
Json(req): Json<SubmitAccountRequest>,
) -> Result<StatusCode, ApiError> {
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<Arc<Store>>,
Path(account_id): Path<String>,
Path(account_pub): Path<String>,
) -> Result<Json<FetchAccountResponse>, 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),

View File

@ -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,

View File

@ -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<bool> {
@ -131,9 +131,9 @@ impl Store {
let mut tx = self.pool.begin().await?;
let existing_lamport = sqlx::query_scalar::<_, Vec<u8>>(
"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<Option<StoredAccountBundle>> {
/// Returns the stored bundle for `account_pub`, or `None` if unknown.
pub async fn get_account(&self, account_pub: &str) -> Result<Option<StoredAccountBundle>> {
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)