| examples | init | |
| src | init | |
| tests | init | |
| .gitignore | init | |
| Cargo.lock | init | |
| Cargo.toml | init | |
| LICENSE | init | |
| README.md | init | |
unimo-sdk
Rust client for Unimo / LatticeStore — a zero-knowledge, post-quantum, end-to-end-encrypted vault. Wire-compatible with the TypeScript and Java SDKs: identical key derivations, canonical-JSON signing, and storage formats, verified byte-for-byte against the shared cross-language conformance vectors and live against the gateway.
- Crypto: ML-KEM-1024 + ML-DSA-87 (FIPS 203/204, via RustCrypto), AES-256-GCM, cSHAKE256 domain-separated derivations. Everything sensitive is derived from seeds; the server never sees keys or plaintext.
- Async: tokio + reqwest + tokio-tungstenite. All I/O is
async. - Surface: accounts & devices, member management with automatic key rotation, invite flow (blind drop-box + proof-of-possession), chunked CAS blob storage, synced KV collections, Stripe billing, live WebSocket events.
Installation
Not on crates.io (yet — no license declared project-wide). Use a git dependency:
[dependencies]
unimo-sdk = { git = "ssh://git@codeberg.org/thinking_tools/unimo-sdk-rust.git" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"
Quick start
use unimo_sdk::{Client, Result};
#[tokio::main]
async fn main() -> Result<()> {
let client = Client::new("http://localhost:3000");
// ── One-time provisioning. The seeds ARE the credentials: persist the device
// seed securely; print/store the recovery seed OFFLINE. Zero-knowledge means
// losing both = losing the vault. No reset flow exists, by design.
let reg = client.register("my-account-name", "laptop", None).await?;
std::fs::write("device.seed", ®.this_device_seed).expect("persist device seed");
std::fs::write("recovery.seed", ®.recovery_seed).expect("persist recovery seed"); // move offline!
// ── Daily use: log in with the device seed. `keep_alive = true` opens the
// live WebSocket (reconnecting, auto-reauth).
let device_seed = std::fs::read("device.seed").expect("read device seed");
let account = client
.login_account("my-account-name", &device_seed, true)
.await?;
assert!(account.is_manager_member().await);
account.destroy().await; // close the WebSocket cleanly
Ok(())
}
(Real apps should use a proper secret store for seeds, not plain files.)
Collections (synced encrypted KV)
A collection is an encrypted key→value map stored as one CAS-versioned blob.
Writes accumulate locally and auto-sync after an 810 ms debounce; flush()
persists immediately. Concurrent edits reconcile remote-wins-per-key with local
pending edits replayed on top.
use serde_json::json;
let notes = account.create_new_collection("notes", "KV").await?;
notes.set("greeting", json!("hello"))?;
notes.set("count", json!(42))?;
// Either wait for auto-sync (810 ms debounce), or force it:
account.list_collections().await[0].flush().await?;
// Any logged-in device of the vault can load it by name:
let notes = account.get_collection("notes").await?.expect("exists");
assert_eq!(notes.get("greeting"), Some(json!("hello")));
// React to changes (UI bindings):
let _sub = notes.data().on_change(|map| println!("now {} keys", map.len()));
Encrypted blob storage
Raw encrypted storage under 64-hex file ids: single-chunk up to ~8 MiB, larger data is split into parallel-uploaded chunks committed under one CAS-versioned head. You own the content key.
use unimo_sdk::crypto::{aead, utils};
use unimo_sdk::shared::helpers::to_hex;
let enc_key = aead::generate_raw_aead_key(); // keep alongside your data model
let file_id = to_hex(&utils::generate_random_bytes(32)?);
// `Some(0)` CAS-asserts first write; `None` probes the current head version.
let up = account.upload(&file_id, &data, &enc_key, Some(0)).await?;
let down = account.download(&file_id, &enc_key).await?;
assert_eq!(down.data, data);
Members & key rotation
Every membership change rotates the vault key and rewraps it for each remaining member (KEM-encapsulated, role-derived). Managers (OWNER/ADMIN) hold the root key; MEMBER/VIEWER/TEMP get the collections subkey.
let slot = account
.add_member_with_seed(&their_seed, "their-name", "MEMBER", None)
.await?;
account.remove_member(slot["memberId"].as_str().unwrap()).await?; // rotates again
Invites (enroll a device you can't hand a seed to)
The server is a blind drop-box: it sees only a hashed handle and an AEAD-sealed claim — never the invite secret. The claim is signed by the invitee's new identity key (proof-of-possession), so a tampering server can't swap keys.
// Manager:
let invite = account.create_invite("MEMBER", Some(3600)).await?;
share_out_of_band(&invite.code); // QR / message — the code IS the secret
// Invitee (no account yet):
let claim = client.claim_invite(&invite.code, "new-laptop", None).await?;
// keep claim.device_seed — it becomes their login credential
// Manager (after the claim lands):
account.finalize_invite(&invite.invite_id).await?; // verify PoP + enroll + rotate
// Invitee, from now on:
let theirs = client.login_account(&claim.account_name, &claim.device_seed, true).await?;
Billing & live events
let plans = account.list_plans().await?; // public catalog
let url = account.create_checkout(&plan_id).await?; // open in a browser
let state = account.billing_state().await?; // None until subscribed
let _sub = account.on_vault_event(|ev| {
println!("vault event: {}", ev["kind"]); // manifest updates, blob puts, ...
});
Error handling
Every error is a UnimoError carrying an ErrorCode whose string form
(code().as_str()) matches the TS/Java SDKs exactly (CAS_FAILED,
INVITE_NOT_CLAIMED, NOT_MANAGER, ...):
use unimo_sdk::ErrorCode;
match account.finalize_invite(&invite_id).await {
Ok(slot) => println!("enrolled {}", slot["memberId"]),
Err(e) if e.code() == ErrorCode::InviteNotClaimed => { /* poll again later */ }
Err(e) => return Err(e),
}
Session note: the gateway keeps one active token per (vault, member) — a
second login with the same seed displaces the first session's token. A displaced
session recovers via VaultController::handle_auth_error() (signed reauth); the
WebSocket and invoke_authed paths do this automatically.
Module map
| Module | Contents |
|---|---|
client |
Client (register / login / login_account / claim_invite) |
client::account |
Account facade: storage, collections, members, invites, billing, events |
client::vault |
VaultController: manifest CAS, unlock, rotation, reauth |
client::tasker |
Chunked CAS blob transfer |
client::collections |
CollectionController, KVContent |
client::connection |
Reconnecting authenticated WebSocket |
client::invites / billing / members / reactive |
Invite crypto, Stripe surface, identity derivation, observable values |
crypto |
pq (ML-KEM/ML-DSA), aead (AES-256-GCM), utils (cSHAKE derivations) |
shared |
Canonical JSON (signing-critical), wire constants, validators, helpers |
Testing & conformance
cargo test # unit + cross-language golden vectors (offline)
docker compose --project-directory . -f docker/compose.yaml up -d # in secure.gateway.unimo
cargo test --test integration -- --ignored # live E2E (register/auth/storage/collections/invites/WS)
tests/data/vectors.json is generated by the TS SDK (tools/gen-conformance.ts)
and shared with the Java port — all three SDKs must reproduce it byte-for-byte.
The live suite registers accounts; the dev gateway rate-limits registration to
5/hour per IP (reset: delete rl:* keys in the gateway's valkey).
Status & known gaps
Feature parity with the Java SDK (itself conformance-locked to the TS original):
auth, members/rotation, storage, collections, billing, invites, WebSocket.
Deliberately deferred, matching Java: the TS task-queue (pause/resume/progress),
NetworkUtils, WS-driven automatic collection refresh (subscribe to
vault:event and call time_to_fetch_update yourself for now).
Hardening: long-lived secrets are zeroized on drop — key-pair seeds, vault /
manager / collections keys, collection content keys, member seeds, KEM shared
secrets, role keys (the RustCrypto key objects wipe themselves via their
zeroize features). The seeds returned to you (RegisterResult,
InviteClaim) are deliberately plain Vec<u8> — they're your credentials to
persist; wipe them after storing. Standard zeroize caveat: Rust moves can leave
untracked stack copies. Signing is deterministic ML-DSA where TS/Java sign
hedged — both FIPS 204-valid and verifier-compatible.