summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py140
1 files changed, 116 insertions, 24 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 2c2eede..53238de 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1,5 +1,6 @@
"""User endpoints — /v1/users/*"""
+import base64
import uuid
from datetime import datetime, timezone, timedelta
@@ -24,7 +25,7 @@ from meshbay_hub.api.middleware import limiter
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import get_current_user, require_user_scope
router = APIRouter(prefix="/v1/users", tags=["users"])
@@ -46,10 +47,10 @@ def _refresh_ttl() -> int:
class RegisterRequest(BaseModel):
username: str
email: str
- password: str
+ password: str | None = None # deprecated — legacy native clients
+ auth_key: str | None = None # PBKDF2-derived, new clients
pk_user_ed25519: str # base64 raw 32B
pk_user_x25519: str # base64 raw 32B
- keypair_bundle: str | None = None # AES-GCM encrypted bundle (web clients)
@field_validator("username")
@classmethod
@@ -61,17 +62,11 @@ class RegisterRequest(BaseModel):
raise ValueError("username: only letters, digits, -, _, .")
return v
- @field_validator("password")
- @classmethod
- def password_strength(cls, v: str) -> str:
- if len(v) < 8:
- raise ValueError("password must be at least 8 characters")
- return v
-
class LoginRequest(BaseModel):
username: str
- password: str
+ password: str | None = None # legacy (raw password) for migration
+ auth_key: str | None = None # PBKDF2-derived auth key (new scheme)
class RefreshRequest(BaseModel):
@@ -92,18 +87,23 @@ async def register(
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Username already taken")
- pw_hash, pw_salt = hash_password(body.password)
+ credential = body.auth_key or body.password
+ if not credential:
+ raise HTTPException(status_code=400, detail="auth_key or password required")
+
+ pw_hash, pw_salt = hash_password(credential)
+ # auth_key → pw_version 3 (password split); raw password → pw_version 2 (legacy)
+ pw_ver = current_pw_version() if body.auth_key else 2
hub_id = _cfg.identity.id if _cfg else "meshbay.org"
user = User(
username=body.username,
email=encrypt_email(body.email),
pw_hash=pw_hash,
pw_salt=pw_salt,
- pw_version=current_pw_version(),
+ pw_version=pw_ver,
pk_ed25519=body.pk_user_ed25519,
pk_x25519=body.pk_user_x25519,
hub_id=hub_id,
- keypair_bundle=body.keypair_bundle,
)
db.add(user)
db.add(IPLog(
@@ -136,18 +136,52 @@ async def login(
user = result.scalar_one_or_none()
ip = _client_ip(request)
- if not user or not verify_password(
- body.password, user.pw_hash, user.pw_salt, version=user.pw_version
- ):
+
+ if not body.auth_key and not body.password:
+ raise HTTPException(status_code=401, detail="No credentials provided")
+
+ if not user:
db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
await db.commit()
raise HTTPException(status_code=401, detail="Invalid credentials")
+ if user.pw_version >= 3:
+ # New scheme: verify auth_key
+ if not body.auth_key or not verify_password(
+ body.auth_key, user.pw_hash, user.pw_salt, version=user.pw_version
+ ):
+ db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ else:
+ # Legacy scheme: need raw password
+ if not body.password:
+ raise HTTPException(status_code=401, detail="auth_upgrade_required")
+ if not verify_password(
+ body.password, user.pw_hash, user.pw_salt, version=user.pw_version
+ ):
+ db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ # Migrate to new scheme if auth_key provided alongside password
+ if body.auth_key:
+ new_hash, new_salt = hash_password(body.auth_key)
+ user.pw_hash = new_hash
+ user.pw_salt = new_salt
+ user.pw_version = current_pw_version()
+ elif user.pw_version < 2:
+ # Legacy rehash: upgrade Argon2 params within the password scheme (v1 -> v2)
+ new_hash, new_salt = hash_password(body.password)
+ user.pw_hash = new_hash
+ user.pw_salt = new_salt
+ user.pw_version = 2
+
if user.status != "active":
raise HTTPException(status_code=403, detail=f"Account {user.status}")
- if pw_needs_rehash(user.pw_version):
- new_hash, new_salt = hash_password(body.password)
+ # Rehash within the auth_key scheme if Argon2 params upgraded beyond v3
+ if user.pw_version >= 3 and pw_needs_rehash(user.pw_version):
+ new_hash, new_salt = hash_password(body.auth_key)
user.pw_hash = new_hash
user.pw_salt = new_salt
user.pw_version = current_pw_version()
@@ -168,15 +202,12 @@ async def login(
db.add(IPLog(user_id=user.id, event="login", ip_address=ip))
await db.commit()
- resp = {
+ return {
"access_token": access_token,
"refresh_token": raw_rt,
"token_type": "bearer",
"expires_in": _ttl(),
}
- if user.keypair_bundle:
- resp["keypair_bundle"] = user.keypair_bundle # encrypted, for web clients
- return resp
@router.post("/token/refresh")
@@ -248,6 +279,64 @@ async def get_current_user_info(
}
+class NodeKeyRequest(BaseModel):
+ pk_node_ed25519: str # base64 raw 32B Ed25519 public key
+
+
+@router.put("/me/node_key")
+async def register_node_key(
+ body: NodeKeyRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Link a node daemon's Ed25519 public key to the operator's account."""
+ try:
+ raw = base64.b64decode(body.pk_node_ed25519)
+ if len(raw) != 32:
+ raise ValueError
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid Ed25519 public key (need 32 bytes base64)")
+
+ current_user.pk_node_ed25519 = body.pk_node_ed25519
+ await db.commit()
+ return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519}
+
+
+class RotateKeysRequest(BaseModel):
+ pk_user_ed25519: str # base64 raw 32B
+ pk_user_x25519: str # base64 raw 32B
+
+
+@router.put("/me/keys")
+async def rotate_browser_keys(
+ body: RotateKeysRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ for field, label in [
+ (body.pk_user_ed25519, "Ed25519"),
+ (body.pk_user_x25519, "X25519"),
+ ]:
+ try:
+ raw = base64.b64decode(field)
+ if len(raw) != 32:
+ raise ValueError
+ except Exception:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid {label} public key (need 32 bytes base64)",
+ )
+
+ current_user.pk_ed25519 = body.pk_user_ed25519
+ current_user.pk_x25519 = body.pk_user_x25519
+ await db.commit()
+ return {
+ "status": "updated",
+ "pk_ed25519": body.pk_user_ed25519,
+ "pk_x25519": body.pk_user_x25519,
+ }
+
+
@router.get("/{username}/pubkeys")
async def get_user_pubkeys(
username: str,
@@ -258,12 +347,15 @@ async def get_user_pubkeys(
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
- return {
+ resp = {
"user_id": target.id,
"username": target.username,
"pk_ed25519": target.pk_ed25519,
"pk_x25519": target.pk_x25519,
}
+ if target.pk_node_ed25519:
+ resp["pk_node_ed25519"] = target.pk_node_ed25519
+ return resp
def _client_ip(request: Request) -> str: