Skip to content

Commit c7051b4

Browse files
committed
feat: add endpoint for verifying Ed25519 signatures and returning user info
1 parent a3ae092 commit c7051b4

18 files changed

Lines changed: 1090 additions & 50 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ repos:
3636
sqlalchemy,
3737
alembic,
3838
types-sqlalchemy,
39+
types-markdown,
3940
]

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ requires-python = ">=3.9"
2121
dependencies = [
2222
"alembic>=1.14.0",
2323
"argon2-cffi>=25.1.0",
24+
"cryptography>=43.0.0",
2425
"email-validator>=2.3.0",
2526
"fastapi>=0.121.1",
2627
"httpx>=0.28.1",

src/syfthub/api/endpoints/users.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
fake_users_db,
1313
get_current_active_user,
1414
)
15-
from syfthub.schemas.auth import UserRole
15+
from syfthub.auth.security import verify_ed25519_signature
16+
from syfthub.schemas.auth import (
17+
SignatureVerificationRequest,
18+
SignatureVerificationResponse,
19+
UserRole,
20+
)
1621
from syfthub.schemas.user import User, UserResponse, UserUpdate
1722

1823
router = APIRouter()
@@ -170,3 +175,52 @@ async def delete_user(
170175

171176
# Delete user
172177
del fake_users_db[user_id]
178+
179+
180+
@router.post("/verify-signature", response_model=SignatureVerificationResponse)
181+
async def verify_signature(
182+
request: SignatureVerificationRequest,
183+
) -> SignatureVerificationResponse:
184+
"""Verify an Ed25519 signature and return user information if valid.
185+
186+
This endpoint allows other services to verify that a signature was created
187+
by a valid Syfthub user, enabling Syfthub to act as an identity provider.
188+
"""
189+
# Convert message to bytes for verification
190+
message_bytes = request.message.encode("utf-8")
191+
192+
# Find user by public key
193+
user_owner = None
194+
for user in fake_users_db.values():
195+
if user.public_key == request.public_key and user.is_active:
196+
user_owner = user
197+
break
198+
199+
if not user_owner:
200+
return SignatureVerificationResponse(
201+
verified=False,
202+
user_info=None,
203+
message="Public key not found or user inactive",
204+
)
205+
206+
# Verify the signature
207+
is_valid = verify_ed25519_signature(
208+
message_bytes, request.signature, request.public_key
209+
)
210+
211+
if not is_valid:
212+
return SignatureVerificationResponse(
213+
verified=False, user_info=None, message="Invalid signature"
214+
)
215+
216+
# Return user information (minimal data for privacy)
217+
user_info: dict[str, str | int | None] = {
218+
"id": user_owner.id,
219+
"username": user_owner.username,
220+
"full_name": user_owner.full_name,
221+
"key_created_at": user_owner.key_created_at.isoformat(),
222+
}
223+
224+
return SignatureVerificationResponse(
225+
verified=True, user_info=user_info, message="Signature verified successfully"
226+
)

src/syfthub/auth/router.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@
2323
blacklist_token,
2424
create_access_token,
2525
create_refresh_token,
26+
generate_ed25519_key_pair,
2627
hash_password,
2728
verify_password,
2829
verify_token,
2930
)
3031
from syfthub.schemas.auth import (
31-
AuthResponse,
32+
Ed25519KeyPair,
33+
KeyRegenerationResponse,
3234
PasswordChange,
3335
RefreshTokenRequest,
36+
RegistrationResponse,
3437
Token,
3538
UserRegister,
3639
UserRole,
@@ -44,9 +47,11 @@
4447

4548

4649
@router.post(
47-
"/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED
50+
"/register",
51+
response_model=RegistrationResponse,
52+
status_code=status.HTTP_201_CREATED,
4853
)
49-
async def register_user(user_data: UserRegister) -> AuthResponse:
54+
async def register_user(user_data: UserRegister) -> RegistrationResponse:
5055
"""Register a new user."""
5156
global user_id_counter
5257

@@ -67,6 +72,9 @@ async def register_user(user_data: UserRegister) -> AuthResponse:
6772
# Create new user
6873
password_hash = hash_password(user_data.password)
6974

75+
# Generate Ed25519 key pair for the user
76+
key_pair = generate_ed25519_key_pair()
77+
7078
user = User(
7179
id=user_id_counter,
7280
username=user_data.username,
@@ -75,9 +83,11 @@ async def register_user(user_data: UserRegister) -> AuthResponse:
7583
age=user_data.age,
7684
role=UserRole.USER, # Default role for new users
7785
password_hash=password_hash,
86+
public_key=key_pair.public_key,
7887
is_active=True,
7988
created_at=datetime.now(timezone.utc),
8089
updated_at=datetime.now(timezone.utc),
90+
key_created_at=datetime.now(timezone.utc),
8191
)
8292

8393
# Store user in database
@@ -103,11 +113,18 @@ async def register_user(user_data: UserRegister) -> AuthResponse:
103113
"is_active": user.is_active,
104114
}
105115

106-
return AuthResponse(
116+
# Create key pair response
117+
keys = Ed25519KeyPair(
118+
private_key=key_pair.private_key,
119+
public_key=key_pair.public_key,
120+
)
121+
122+
return RegistrationResponse(
107123
user=user_dict,
108124
access_token=access_token,
109125
refresh_token=refresh_token,
110126
token_type="bearer",
127+
keys=keys,
111128
)
112129

113130

@@ -225,6 +242,31 @@ async def change_password(
225242
fake_users_db[current_user.id] = current_user
226243

227244

245+
@router.post("/regenerate-keys", response_model=KeyRegenerationResponse)
246+
async def regenerate_user_keys(
247+
current_user: Annotated[User, Depends(get_current_active_user)],
248+
) -> KeyRegenerationResponse:
249+
"""Regenerate Ed25519 key pair for the current user."""
250+
# Generate new key pair
251+
key_pair = generate_ed25519_key_pair()
252+
253+
# Update user's public key in database
254+
current_user.public_key = key_pair.public_key
255+
current_user.key_created_at = datetime.now(timezone.utc)
256+
current_user.updated_at = datetime.now(timezone.utc)
257+
258+
# Save to database
259+
fake_users_db[current_user.id] = current_user
260+
261+
# Create key pair response
262+
keys = Ed25519KeyPair(
263+
private_key=key_pair.private_key,
264+
public_key=key_pair.public_key,
265+
)
266+
267+
return KeyRegenerationResponse(keys=keys)
268+
269+
228270
def authenticate_user(username: str, password: str) -> User | None:
229271
"""Authenticate a user by username/email and password."""
230272
# Try to find user by username or email

src/syfthub/auth/security.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,21 @@
22

33
from __future__ import annotations
44

5+
import base64
56
from datetime import datetime, timedelta, timezone
67
from typing import Any
78

89
import jwt # type: ignore[import-not-found]
10+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
11+
Ed25519PrivateKey,
12+
Ed25519PublicKey,
13+
)
14+
from cryptography.hazmat.primitives.serialization import (
15+
Encoding,
16+
NoEncryption,
17+
PrivateFormat,
18+
PublicFormat,
19+
)
920
from passlib.context import CryptContext # type: ignore[import-untyped]
1021

1122
from syfthub.core.config import settings
@@ -114,3 +125,93 @@ def get_token_from_header(authorization: str) -> str | None:
114125
if not authorization or not authorization.startswith("Bearer "):
115126
return None
116127
return authorization.replace("Bearer ", "")
128+
129+
130+
class Ed25519KeyPair:
131+
"""Container for Ed25519 key pair."""
132+
133+
def __init__(self, private_key: str, public_key: str) -> None:
134+
"""Initialize key pair with base64 encoded keys."""
135+
self.private_key = private_key
136+
self.public_key = public_key
137+
138+
139+
def generate_ed25519_key_pair() -> Ed25519KeyPair:
140+
"""Generate a new Ed25519 key pair.
141+
142+
Returns:
143+
Ed25519KeyPair with base64 encoded private and public keys.
144+
"""
145+
# Generate private key
146+
private_key = Ed25519PrivateKey.generate()
147+
148+
# Get public key from private key
149+
public_key = private_key.public_key()
150+
151+
# Serialize private key to bytes
152+
private_key_bytes = private_key.private_bytes(
153+
encoding=Encoding.Raw,
154+
format=PrivateFormat.Raw,
155+
encryption_algorithm=NoEncryption(),
156+
)
157+
158+
# Serialize public key to bytes
159+
public_key_bytes = public_key.public_bytes(
160+
encoding=Encoding.Raw, format=PublicFormat.Raw
161+
)
162+
163+
# Encode to base64 for storage/transmission
164+
private_key_b64 = base64.b64encode(private_key_bytes).decode("utf-8")
165+
public_key_b64 = base64.b64encode(public_key_bytes).decode("utf-8")
166+
167+
return Ed25519KeyPair(private_key_b64, public_key_b64)
168+
169+
170+
def verify_ed25519_signature(message: bytes, signature: str, public_key: str) -> bool:
171+
"""Verify an Ed25519 signature.
172+
173+
Args:
174+
message: The original message that was signed (as bytes)
175+
signature: Base64 encoded signature
176+
public_key: Base64 encoded public key
177+
178+
Returns:
179+
True if signature is valid, False otherwise.
180+
"""
181+
try:
182+
# Decode base64 inputs
183+
signature_bytes = base64.b64decode(signature)
184+
public_key_bytes = base64.b64decode(public_key)
185+
186+
# Reconstruct public key object
187+
public_key_obj = Ed25519PublicKey.from_public_bytes(public_key_bytes)
188+
189+
# Verify signature
190+
public_key_obj.verify(signature_bytes, message)
191+
return True
192+
except Exception:
193+
# Any exception means verification failed
194+
return False
195+
196+
197+
def sign_message_ed25519(message: bytes, private_key: str) -> str:
198+
"""Sign a message with Ed25519 private key.
199+
200+
Args:
201+
message: The message to sign (as bytes)
202+
private_key: Base64 encoded private key
203+
204+
Returns:
205+
Base64 encoded signature.
206+
"""
207+
# Decode private key
208+
private_key_bytes = base64.b64decode(private_key)
209+
210+
# Reconstruct private key object
211+
private_key_obj = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
212+
213+
# Sign message
214+
signature_bytes = private_key_obj.sign(message)
215+
216+
# Return base64 encoded signature
217+
return base64.b64encode(signature_bytes).decode("utf-8")

src/syfthub/database/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class UserModel(Base):
3434
age: Mapped[int | None] = mapped_column(Integer, nullable=True)
3535
role: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
3636
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
37+
public_key: Mapped[str] = mapped_column(
38+
String(88), nullable=False
39+
) # Base64 encoded Ed25519 public key
3740
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
3841
created_at: Mapped[datetime] = mapped_column(
3942
DateTime(timezone=True),
@@ -46,6 +49,11 @@ class UserModel(Base):
4649
default=lambda: datetime.now(timezone.utc),
4750
onupdate=lambda: datetime.now(timezone.utc),
4851
)
52+
key_created_at: Mapped[datetime] = mapped_column(
53+
DateTime(timezone=True),
54+
nullable=False,
55+
default=lambda: datetime.now(timezone.utc),
56+
)
4957

5058
# Relationships
5159
datasites: Mapped[list[DatasiteModel]] = relationship(
@@ -56,6 +64,7 @@ class UserModel(Base):
5664
__table_args__ = (
5765
Index("idx_users_username", "username"),
5866
Index("idx_users_email", "email"),
67+
Index("idx_users_public_key", "public_key", unique=True),
5968
Index("idx_users_role", "role"),
6069
Index("idx_users_is_active", "is_active"),
6170
)

src/syfthub/database/repositories.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,11 @@ def _model_to_schema(user_model: UserModel) -> User:
110110
age=user_model.age,
111111
role=UserRole(user_model.role),
112112
password_hash=user_model.password_hash,
113+
public_key=user_model.public_key,
113114
is_active=user_model.is_active,
114115
created_at=user_model.created_at,
115116
updated_at=user_model.updated_at,
117+
key_created_at=user_model.key_created_at,
116118
)
117119

118120

src/syfthub/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88
from typing import TYPE_CHECKING
99

10-
import markdown # type: ignore
10+
import markdown
1111
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
1212
from fastapi.middleware.cors import CORSMiddleware
1313
from fastapi.templating import Jinja2Templates
@@ -250,7 +250,7 @@ async def list_owner_public_datasites(
250250
return [DatasitePublicResponse.model_validate(ds) for ds in accessible_datasites]
251251

252252

253-
@app.get("/{owner_slug}/{datasite_slug}")
253+
@app.get("/{owner_slug}/{datasite_slug}", response_model=None)
254254
async def get_owner_datasite(
255255
request: Request,
256256
owner_slug: str,

0 commit comments

Comments
 (0)