mirror of
https://github.com/logos-messaging/chat-store.git
synced 2026-07-29 22:43:29 +00:00
chore: use account pub in code
This commit is contained in:
parent
5752c00463
commit
5c1fef16f4
20
README.md
20
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.
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
24
src/store.rs
24
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<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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user