aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/db
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
commit768e07046368819b8a8f15c8b21e5a8bbfcdf282 (patch)
treefba4fa5f85e3963b2281004b503be05f552aff2c /packages/meshbay-hub/src/meshbay_hub/db
parente9d5e979fdab9a1cc3c729d602e6f27207b9480c (diff)
downloadmeshbay-768e07046368819b8a8f15c8b21e5a8bbfcdf282.tar.gz
feat: device linking, and signing in to the hub with a device key
Stage C. Identity keys are per node, so a browser and a desktop client are two keys on one account there — and the node refused the second where it accepted the first. Without this, an account created natively could never be opened in a browser without an operator code per node, and "a native client must not prevent web use" would have been dead on arrival. Device linking (node) --------------------- `identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The old shape did `INSERT OR REPLACE`, so a second device overwrote the first silently; SQLite cannot change a primary key in place, so the table is rebuilt. Existing pins are carried over — verified against a live roster with 10 of them, nobody re-pairs. A new device files a request bound by `sha256(code ‖ its own keys)`, and a key the node **already pinned** countersigns it. The hub cannot: it has stored no user keys since 2026-08-14, which is what makes this safe to do without an operator in the loop. **The code never reaches the node.** It lists this account's pending requests with their stored hashes; the approver recomputes and keeps the match. A node offering fabricated keys would have to produce a hash over a code it has never seen. Nothing rests on a human comparing digits — that ritual was dropped in 12.1 as "correct, unusable as the default" and must not return by the back door. The design document had the approver look a request up *by* its hash, which is circular: computing it needs the keys being asked about. Corrected in both. Revocation marks rather than deletes, because a deleted row is a key the node would happily pin again — which is the laptop somebody just reported lost. Your last device cannot be revoked: coming back would need an operator's code. Hub — the only change in the whole plan --------------------------------------- `POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as `/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration, because `create_all()` is not one. This is **not** the key directory that was H3, and the tests say so: nothing reads it but the hub, no group key is ever wrapped for one, and it is a different key from the per-node identities. What it does cost is metadata — the hub now knows how many devices an account has and when each last signed in. Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an installed client meets a newer hub the day the interface ships in a package, and that is cheap now and awkward to retrofit. Browser ------- The `key_changed` refusal becomes `unknown_device` and offers a linking code instead of telling someone to find their operator. The Members panel lists this account's devices here, approves one by code, and retires one. 773 tests pass. `e2e.py` gained a step that links a device end to end against the live deployment — file, list, recompute, countersign, then open the group with the new keys and no code — and it also gained `recv_type`, because a step that assumes the next message is its own answer reads an ack left by the step before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/db')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py35
2 files changed, 82 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py
new file mode 100644
index 0000000..ddada15
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py
@@ -0,0 +1,47 @@
+"""user device auth keys
+
+Device Ed25519 authentication to the hub, so a client signs in without
+re-deriving a key from the passphrase every time.
+
+Deliberately **not** the key directory that was H3: nothing reads this but the
+hub itself, nobody wraps a group key for it, and it is a different key from the
+per-node identity keys, which never leave the device-node relationship. See
+`docs/desktop-client-v1.md` §5.
+
+`create_all()` is not a migration — it creates missing tables and never a
+missing column, so a schema change without a file here reaches the tests (fresh
+DB every run) and never reaches the deployed hub.
+
+Revision ID: e5a2b7d31f88
+Revises: 3dc91cd4ea52
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "e5a2b7d31f88"
+down_revision: Union[str, Sequence[str], None] = "3dc91cd4ea52"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "user_devices",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"),
+ nullable=False),
+ sa.Column("pk_auth_ed25519", sa.String(64), nullable=False, unique=True),
+ sa.Column("label", sa.String(64), nullable=False, server_default=""),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
+ server_default=sa.func.now()),
+ sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.create_index("ix_user_devices_user", "user_devices", ["user_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_user_devices_user", table_name="user_devices")
+ op.drop_table("user_devices")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index a220a64..12507a6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -178,6 +178,41 @@ class FederatedGroup(Base):
)
+class UserDevice(Base):
+ """
+ A device's key for authenticating **to the hub**, and nothing else.
+
+ This is not a reintroduction of the key directory that was H3, and the
+ distinction is worth being precise about because it looks like one:
+
+ * **Nobody reads this but the hub.** No endpoint publishes it, nothing
+ wraps a group key for it, and no node ever asks for it. H3 was a
+ directory *others* read from, where a substituted key was handed the
+ GEK by an honest member.
+ * **It is not a node identity key.** Those are generated per node, pinned
+ there, and never leave that relationship (`docs/per-node-identity-v1.md`).
+ A device holds one of these *plus* a different key per node, so nothing
+ here correlates a person across operators.
+
+ What it does cost, stated plainly: the hub now knows how many devices an
+ account has and when each one last signed in. That is new metadata, and it
+ is the price of not deriving a key from the passphrase on every sign-in.
+ """
+
+ __tablename__ = "user_devices"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
+ # base64 raw Ed25519, unique so one device key belongs to one account
+ pk_auth_ed25519: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+ label: Mapped[str] = mapped_column(String(64), default="")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ last_seen: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (Index("ix_user_devices_user", "user_id"),)
+
+
class SwarmSource(Base):
"""
Tracks which nodes can serve a given content hash (public swarm).