core: account-derived rooms — same account → same room everywhere

Add room_for_account(account) = sha256(account)[..4] hex, exported via UniFFI so
every client derives the IDENTICAL room from a shared identity. Clients now
default their room to the account-derived id (falling back to a device id only
when signed out). With the prefilled account, all our devices land in one room
across LAN, the relay, and RTC — so "just us" works cross-network, not only on
the same Wi-Fi. The single canonical Rust impl guarantees the ids match across
Swift/Kotlin/agent (a hand-rolled per-platform hash would risk drift).

Verified: agent and the live Mac app both derive room 360309c8 from
"pecord@gmail.com"; the Mac app publishes there on the public relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 01:55:28 -05:00
parent f7bc8eed4b
commit d1750a95d6
7 changed files with 129 additions and 30 deletions

View File

@@ -51,14 +51,20 @@ fn arg(flag: &str, default: &str) -> String {
fn main() {
let server = arg("--server", "https://tether.pecord.io");
let room = arg("--room", "default");
let source = arg("--source", "agent");
let from = arg("--from", &format!("agent-{source}"));
// Friendly name for other devices' nearby lists.
let host = std::env::var("HOSTNAME").unwrap_or_else(|_| "tether-agent".into());
let name = arg("--name", &host);
// Shared identity: same-account devices pair on the LAN regardless of room.
// Shared identity: same-account devices pair on the LAN regardless of room,
// and share an account-derived room everywhere (so cross-network works too).
let account = arg("--account", "pecord@gmail.com");
let default_room = if account.is_empty() {
"default".to_string()
} else {
tethercore::room_for_account(account.clone())
};
let room = arg("--room", &default_room);
eprintln!("tether-agent → {server} room={room} source={source}");

View File

@@ -19,7 +19,11 @@ data class FeedItem(val text: String, val source: String, val ts: Long, val id:
class TetherStore(private val context: Context) {
val serverURL = mutableStateOf(prefs().getString("serverURL", "https://tether.pecord.io")!!)
val account = mutableStateOf(prefs().getString("account", "pecord@gmail.com")!!)
val room = mutableStateOf(RoomIdentity.derive(context))
// Room follows the account → all our devices share one room everywhere.
val room = mutableStateOf(
if (account.value.isEmpty()) RoomIdentity.derive(context)
else uniffi.tethercore.roomForAccount(account.value)
)
val feed = mutableStateListOf<FeedItem>()
val connected = mutableStateOf(false)

115
core/Cargo.lock generated
View File

@@ -8,7 +8,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"generic-array",
]
@@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -271,6 +271,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
@@ -404,7 +413,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -426,7 +435,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"inout",
"zeroize",
]
@@ -483,6 +492,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -492,6 +507,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
@@ -530,6 +554,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctr"
version = "0.9.2"
@@ -546,7 +579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto",
"rustc_version",
@@ -577,7 +610,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
@@ -608,12 +641,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -653,7 +697,7 @@ dependencies = [
"rustls",
"sec1",
"sha1",
"sha2",
"sha2 0.10.9",
"thiserror 1.0.69",
"tokio",
"webrtc-util",
@@ -668,7 +712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
@@ -683,7 +727,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"digest 0.10.7",
"ff",
"generic-array",
"group",
@@ -977,7 +1021,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1019,6 +1063,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.10.1"
@@ -1307,7 +1360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1487,7 +1540,7 @@ dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -1499,7 +1552,7 @@ dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -1590,7 +1643,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -1602,7 +1655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -2175,8 +2228,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
@@ -2186,8 +2239,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -2212,7 +2276,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core 0.6.4",
]
@@ -2397,6 +2461,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"uniffi",
"webrtc",
@@ -2799,7 +2864,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"subtle",
]
@@ -3004,7 +3069,7 @@ dependencies = [
"sdp",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"smol_str",
"stun",
"thiserror 1.0.69",

View File

@@ -24,3 +24,4 @@ tokio = { version = "1", features = ["rt-multi-thread", "time", "sync", "macros"
futures-util = "0.3"
webrtc = "0.17.1"
mdns-sd = "0.20.0"
sha2 = "0.11.0"

View File

@@ -74,6 +74,20 @@ pub struct Peer {
/// engine's `nearby()` (reader). Instant is last-seen for staleness pruning.
type Discovered = Arc<Mutex<HashMap<String, (Peer, Instant)>>>;
/// Canonical room id for a shared identity: sha256(account)[..4] as 8 hex chars.
/// Defined once here and exported so every client (Swift/Kotlin/agent) derives
/// the SAME room — that's what puts all of one account's devices together across
/// LAN, relay, and RTC. Empty account → empty (caller falls back to a device id).
#[uniffi::export]
pub fn room_for_account(account: String) -> String {
use sha2::{Digest, Sha256};
if account.is_empty() {
return String::new();
}
let digest = Sha256::digest(account.as_bytes());
digest[..4].iter().map(|b| format!("{b:02x}")).collect()
}
#[derive(Clone)]
struct Config {
server: String,

View File

@@ -19,7 +19,11 @@ final class MacTetherStore {
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "https://tether.pecord.io"
// Prefilled shared identity for now it's just us, so all devices pair.
var account: String = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
var room: String = RoomIdentity.derive()
// Room follows the account all our devices share one room everywhere.
var room: String = {
let a = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
return a.isEmpty ? RoomIdentity.derive() : roomForAccount(account: a)
}()
var feed: [FeedItem] = []
var nearby: [Peer] = [] // tether devices on the LAN, for the picker
var connected = false

View File

@@ -23,7 +23,12 @@ final class TetherStore {
// Prefilled shared identity for now it's just us, so all devices pair.
// Becomes the Sign-in-with-Apple `sub` once the dev program clears.
var account: String = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
var room: String = RoomIdentity.derive()
// Room follows the account, so all our devices share one room everywhere
// (LAN, relay, RTC). Falls back to a device id only when signed out.
var room: String = {
let a = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
return a.isEmpty ? RoomIdentity.derive() : roomForAccount(account: a)
}()
var feed: [FeedItem] = []
var connected = false
var lastError: String?