Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: read

jobs:
build:
if: |
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
tags:
- "v*.*.*"

permissions:
contents: write
id-token: write

jobs:
build:
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: read

jobs:
build:
if: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ venv.bak/
dmypy.json

.DS_Store
.vibe/
data
storage
config/
Expand Down
13 changes: 13 additions & 0 deletions supernote/cli/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,24 @@
import getpass
import hashlib
import sys
import warnings

from supernote.client.admin import AdminClient
from supernote.client.exceptions import ApiException

from .client import create_session


def _warn_md5_usage() -> None:
"""Warn about MD5 usage for security awareness."""
warnings.warn(
"MD5 is used for Supernote protocol compatibility but is cryptographically weak. "
"This is required for device compatibility, not a security best practice.",
UserWarning,
stacklevel=3,
)


async def add_user_async(
url: str, email: str, password: str, display_name: str | None = None
) -> None:
Expand All @@ -24,6 +35,7 @@ async def add_user_async(
admin_client = AdminClient(session.client)

# Hash password
_warn_md5_usage()
password_md5 = hashlib.md5(password.encode()).hexdigest()

# Try Public Registration
Expand Down Expand Up @@ -110,6 +122,7 @@ async def reset_password_async(url: str, email: str, password: str) -> None:
print(f"Attempting to reset password for '{email}' on {url}...")
admin_client = AdminClient(session.client)

_warn_md5_usage()
password_md5 = hashlib.md5(password.encode()).hexdigest()

try:
Expand Down
26 changes: 24 additions & 2 deletions supernote/client/hashing.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
"""Module for hashing utilities."""
"""Module for hashing utilities.

Security Note:
This module implements the Supernote protocol's password hashing scheme which uses MD5.
MD5 is considered cryptographically broken and unsuitable for security purposes.
However, it is required for compatibility with official Supernote devices.
The protocol uses: MD5(password) + SHA256(MD5(password) + salt)
"""

import hashlib
import logging
import warnings

logger = logging.getLogger(__name__)


def _warn_md5_usage() -> None:
"""Warn about MD5 usage for security awareness."""
warnings.warn(
"MD5 is used for Supernote protocol compatibility but is cryptographically weak. "
"This is required for device compatibility, not a security best practice.",
UserWarning,
stacklevel=3,
)


def _sha256_string(s: str) -> str:
"""Return SHA256 hex digest of a string."""
return hashlib.sha256(s.encode("utf-8")).hexdigest()


def _md5_string(s: str) -> str:
"""Return MD5 hex digest of a string."""
"""Return MD5 hex digest of a string.

Security Warning: MD5 is cryptographically broken but required for Supernote protocol compatibility.
"""
_warn_md5_usage()
return hashlib.md5(s.encode("utf-8")).hexdigest()


Expand Down
4 changes: 2 additions & 2 deletions supernote/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ def load(

if gemini_api_key := os.getenv("SUPERNOTE_GEMINI_API_KEY"):
config.gemini_api_key = gemini_api_key
logger.info("Using SUPERNOTE_GEMINI_API_KEY")
logger.info("Using SUPERNOTE_GEMINI_API_KEY: ***")

if gemini_ocr_model := os.getenv("SUPERNOTE_GEMINI_OCR_MODEL"):
config.gemini_ocr_model = gemini_ocr_model
Expand Down Expand Up @@ -367,7 +367,7 @@ def load(

if mistral_api_key := os.getenv("SUPERNOTE_MISTRAL_API_KEY"):
config.mistral_api_key = mistral_api_key
logger.info("Using SUPERNOTE_MISTRAL_API_KEY")
logger.info("Using SUPERNOTE_MISTRAL_API_KEY: ***")

if mistral_ocr_model := os.getenv("SUPERNOTE_MISTRAL_OCR_MODEL"):
config.mistral_ocr_model = mistral_ocr_model
Expand Down
3 changes: 2 additions & 1 deletion supernote/server/services/mistral.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ async def generate_json(self, prompt: str, schema: dict[str, Any]) -> str:
),
response_format={"type": "json_object"},
)
content = response.choices[0].message.content if response.choices else ""
message = response.choices[0].message if response.choices else None
content = message.content if message else ""
return (
content
if isinstance(content, str)
Expand Down
2 changes: 1 addition & 1 deletion supernote/server/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
if (dark) document.documentElement.classList.add('dark');
})();
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js" integrity="sha512-KRb/FlKLQJpM3bYfo5VxX3jwG1wUrk9VQeIJGxKQl2Y1sOXY0d64S3wzOZb9bOQTaK1s3s22KsV1m5ZkZzJFQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
Expand Down
150 changes: 149 additions & 1 deletion tests/cli/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch

from supernote.cli.admin import list_users_async
from supernote.cli.admin import (
add_user_async,
list_users_async,
reset_password_async,
)


async def test_list_users_async_calls_get_json() -> None:
Expand All @@ -28,3 +32,147 @@ async def mock_create_session(
await list_users_async()

mock_client.get_json.assert_called_once()


async def test_list_users_async_successful_listing() -> None:
"""list_users_async successfully lists users when API call succeeds."""
# Mock user data
mock_users = [
{"email": "user1@example.com", "userName": "User One", "totalCapacity": "100"},
{"email": "user2@example.com", "userName": "User Two", "totalCapacity": "200"},
]

mock_client = AsyncMock()
mock_client.get_json.side_effect = Exception("blocked") # First call fails
mock_resp = AsyncMock()
mock_resp.json = AsyncMock(return_value=mock_users)
mock_client.get = AsyncMock(return_value=mock_resp)

mock_session = MagicMock()
mock_session.client = mock_client

@asynccontextmanager
async def mock_create_session(
*args: object, **kwargs: object
) -> AsyncGenerator[MagicMock, None]:
yield mock_session

with patch("supernote.cli.admin.create_session", mock_create_session):
# Capture print output
import io
from contextlib import redirect_stdout

f = io.StringIO()
with redirect_stdout(f):
await list_users_async()

output = f.getvalue()

# Verify the output contains user data
assert "Total Users: 2" in output
assert "user1@example.com" in output
assert "user2@example.com" in output
assert "User One" in output
assert "User Two" in output


async def test_add_user_async_success() -> None:
"""Test add_user_async successful user creation."""
# Mock the AdminClient methods directly
with patch("supernote.cli.admin.AdminClient") as mock_admin_client_class:
mock_admin_instance = AsyncMock()
mock_admin_instance.register = AsyncMock() # Public registration succeeds
mock_admin_client_class.return_value = mock_admin_instance

mock_session = MagicMock()
mock_session.client = MagicMock()

@asynccontextmanager
async def mock_create_session(
*args: object, **kwargs: object
) -> AsyncGenerator[MagicMock, None]:
yield mock_session

with patch("supernote.cli.admin.create_session", mock_create_session):
# Capture print output
import io
from contextlib import redirect_stdout

f = io.StringIO()
with redirect_stdout(f):
await add_user_async(
"http://test.com", "test@example.com", "password123", "Test User"
)

output = f.getvalue()
assert "Success! User created (Public Registration)" in output


async def test_add_user_async_admin_fallback() -> None:
"""Test add_user_async falls back to admin creation when public registration fails."""
# Mock the AdminClient methods directly
from supernote.client.exceptions import ApiException

with patch("supernote.cli.admin.AdminClient") as mock_admin_client_class:
mock_admin_instance = AsyncMock()
mock_admin_instance.register = AsyncMock(
side_effect=ApiException("Public registration disabled")
)
mock_admin_instance.admin_create_user = AsyncMock() # Admin creation succeeds
mock_admin_client_class.return_value = mock_admin_instance

mock_session = MagicMock()
mock_session.client = MagicMock()

@asynccontextmanager
async def mock_create_session(
*args: object, **kwargs: object
) -> AsyncGenerator[MagicMock, None]:
yield mock_session

with patch("supernote.cli.admin.create_session", mock_create_session):
# Capture print output
import io
from contextlib import redirect_stdout

f = io.StringIO()
with redirect_stdout(f):
await add_user_async(
"http://test.com", "test@example.com", "password123", "Test User"
)

output = f.getvalue()
assert "Public registration failed or disabled" in output
assert "Success! User created (Admin API)" in output


async def test_reset_password_async_success() -> None:
"""Test reset_password_async successful password reset."""
# Mock the AdminClient methods directly
with patch("supernote.cli.admin.AdminClient") as mock_admin_client_class:
mock_admin_instance = AsyncMock()
mock_admin_instance.admin_reset_password = AsyncMock()
mock_admin_client_class.return_value = mock_admin_instance

mock_session = MagicMock()
mock_session.client = MagicMock()

@asynccontextmanager
async def mock_create_session(
*args: object, **kwargs: object
) -> AsyncGenerator[MagicMock, None]:
yield mock_session

with patch("supernote.cli.admin.create_session", mock_create_session):
# Capture print output
import io
from contextlib import redirect_stdout

f = io.StringIO()
with redirect_stdout(f):
await reset_password_async(
"http://test.com", "test@example.com", "newpassword123"
)

output = f.getvalue()
assert "Success! Password Reset" in output
10 changes: 10 additions & 0 deletions tests/client/test_login.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the login flow."""

import hashlib
import warnings
from typing import Awaitable, Callable

import aiohttp
Expand All @@ -13,6 +14,15 @@
from supernote.models.auth import LoginVO, RandomCodeVO


def _warn_md5_usage() -> None:
"""Warn about MD5 usage for security awareness."""
warnings.warn(
"MD5 is used for Supernote protocol compatibility but is cryptographically weak.",
UserWarning,
stacklevel=2,
)


# Mock SHA-256 implementation matching the JS one
def sha256(data: str) -> str:
return hashlib.sha256(data.encode()).hexdigest()
Expand Down
14 changes: 13 additions & 1 deletion tests/server/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import logging
import socket
import warnings
from collections.abc import AsyncGenerator, Generator
from pathlib import Path
from unittest.mock import AsyncMock, patch
Expand Down Expand Up @@ -35,6 +36,16 @@
)
from supernote.server.services.user import JWT_ALGORITHM, UserService


def _warn_md5_usage() -> None:
"""Warn about MD5 usage for security awareness."""
warnings.warn(
"MD5 is used for Supernote protocol compatibility but is cryptographically weak.",
UserWarning,
stacklevel=2,
)


TEST_USERNAME = "test@example.com"
TEST_PASSWORD = "testpassword"

Expand Down Expand Up @@ -72,7 +83,7 @@ def proxy_mode() -> str | None:
def pick_port() -> int:
"""Find a free port on the host."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])


Expand Down Expand Up @@ -152,6 +163,7 @@ async def create_test_user(
if await user_service.check_user_exists(test_user):
await user_service.unregister(test_user)

_warn_md5_usage()
result = await user_service.create_user(
UserRegisterDTO(
email=test_user,
Expand Down
Loading
Loading