Skip to content
9 changes: 9 additions & 0 deletions src/include/connection_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
)
from include.handlers.debugging.throw import RequestThrowExceptionHandler
from include.handlers.search import RequestSearchHandler
from include.handlers.protection import (
RequestEnablePasswordProtectionHandler,
RequestRemovePasswordProtectionHandler,
RequestVerifyPasswordHandler,
)

from include.constants import CORE_VERSION, PROTOCOL_VERSION
from include.shared import connected_listeners, lockdown_enabled
Expand Down Expand Up @@ -193,6 +198,10 @@ def handle_request(websocket: websockets.sync.server.ServerConnection, message:
# 系统类
"lockdown": RequestLockdownHandler,
"view_audit_logs": RequestViewAuditLogsHandler,
# 密码保护类
"enable_password_protection": RequestEnablePasswordProtectionHandler,
"remove_password_protection": RequestRemovePasswordProtectionHandler,
"verify_password": RequestVerifyPasswordHandler,
}

# Debugging
Expand Down
4 changes: 4 additions & 0 deletions src/include/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"FILE_TRANSFER_MAX_CHUNK_SIZE",
"FILE_TRANSFER_MIN_CHUNK_SIZE",
"FILE_TASK_DEFAULT_DURATION_SECONDS",
"TARGET_TYPE_MAPPING",
]

CORE_VERSION = Version("0.1.0.251226_alpha")
Expand All @@ -19,6 +20,9 @@
AVAILABLE_ACCESS_TYPES = ["read", "write", "move", "manage"]
AVAILABLE_BLOCK_TYPES: set = {"read", "write", "move"}

# Target type mapping for database table names to API types
TARGET_TYPE_MAPPING = {"folders": "directory", "documents": "document"}

# Authentication and Security Constants
DEFAULT_TOKEN_EXPIRY_SECONDS = 3600 # 1 hour
FAILED_LOGIN_DELAY_SECONDS = 3 # Delay after failed login attempt
Expand Down
8 changes: 3 additions & 5 deletions src/include/database/models/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import secrets
from sqlalchemy import VARCHAR, Float, ForeignKey, Integer
from include.classes.exceptions import NoActiveRevisionsError
from include.constants import AVAILABLE_ACCESS_TYPES, AVAILABLE_BLOCK_TYPES
from include.constants import AVAILABLE_ACCESS_TYPES, AVAILABLE_BLOCK_TYPES, TARGET_TYPE_MAPPING
from include.database.handler import Base
from include.classes.access_rule import AccessRuleBase
from sqlalchemy.orm import Mapped
Expand Down Expand Up @@ -43,8 +43,6 @@ def check_access_requirements(self, user: User, access_type: str = "read") -> bo
- If no access rules are defined, access is granted by default.
"""

_TARGET_TYPE_MAPPING = {"folders": "directory", "documents": "document"}

def match_rights(sub_rights_group):
if not sub_rights_group:
return True
Expand Down Expand Up @@ -164,7 +162,7 @@ def match_primary_sub_group(per_match_group):
ObjectAccessEntry.entity_type == "user",
ObjectAccessEntry.entity_identifier == user.username,
ObjectAccessEntry.target_type
== _TARGET_TYPE_MAPPING[self.__tablename__],
== TARGET_TYPE_MAPPING[self.__tablename__],
ObjectAccessEntry.target_identifier == self.id,
ObjectAccessEntry.access_type == access_type,
ObjectAccessEntry.start_time <= now,
Expand All @@ -185,7 +183,7 @@ def match_primary_sub_group(per_match_group):
ObjectAccessEntry.entity_type == "group",
ObjectAccessEntry.entity_identifier == group.group_name,
ObjectAccessEntry.target_type
== _TARGET_TYPE_MAPPING[self.__tablename__],
== TARGET_TYPE_MAPPING[self.__tablename__],
ObjectAccessEntry.target_identifier == self.id,
ObjectAccessEntry.access_type == access_type,
ObjectAccessEntry.start_time <= now,
Expand Down
120 changes: 120 additions & 0 deletions src/include/database/models/protection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
Database models for object protection (passwords, encryption, biometric, etc.)

This module provides a unified protection system that can handle multiple
protection types in a single table.
"""

import hashlib
import json
import secrets
from typing import Optional

from sqlalchemy import VARCHAR, Integer, Text
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

from include.database.handler import Base


class ObjectProtection(Base):
"""
Unified model for all types of protection on documents and directories.

This single table handles all protection types (password, encryption, biometric, etc.)
distinguished by the protection_type column.
"""
__tablename__ = "object_protections"

id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

# Target object identification
target_type: Mapped[str] = mapped_column(
VARCHAR(64), nullable=False, comment="Type: 'document' or 'directory'"
)
target_id: Mapped[str] = mapped_column(
VARCHAR(255), nullable=False, comment="ID of the protected object"
)

# Protection type identifier
protection_type: Mapped[str] = mapped_column(
VARCHAR(64), nullable=False, default="password",
comment="Protection type: 'password', 'encryption', 'biometric', etc."
)

# Protection data (type-specific, stored as JSON or text)
# For password: contains password_hash and salt
# For other types: contains type-specific data
protection_data: Mapped[str] = mapped_column(
Text, nullable=False,
comment="Protection-specific data (JSON format for flexibility)"
)

# Additional metadata (reserved for future use)
protection_metadata: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="JSON metadata for future extensions"
)
Comment on lines +29 to +57

Copilot AI Dec 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database model is missing a unique constraint to prevent duplicate protection entries for the same target. Without a unique constraint on (target_type, target_id, protection_type), multiple password protection entries could be created for the same document or directory, leading to undefined behavior during verification. Consider adding a unique constraint or composite index on these three columns.

Copilot uses AI. Check for mistakes.

def set_password(self, plain_password: str) -> None:
"""
Set password protection data for this entry.

Uses PBKDF2-HMAC-SHA256 with 600,000 iterations for secure password hashing,
following OWASP recommendations for password storage.

Args:
plain_password: The plain text password to hash and store
"""
self.protection_type = "password"

# Generate salt and hash
salt = secrets.token_hex(16)
password_bytes = plain_password.encode('utf-8')
salt_bytes = salt.encode('utf-8')
key = hashlib.pbkdf2_hmac('sha256', password_bytes, salt_bytes, 600000)
password_hash = key.hex()

# Store as JSON
self.protection_data = json.dumps({
"password_hash": password_hash,
"salt": salt
})

def verify_password(self, plain_password: str) -> bool:
"""
Verify a password against the stored hash.

Uses constant-time comparison to prevent timing attacks.
Only works if protection_type is "password".

Args:
plain_password: The plain text password to verify

Returns:
True if the password matches, False otherwise
"""
if self.protection_type != "password":
return False

try:
data = json.loads(self.protection_data)
password_hash = data["password_hash"]
salt = data["salt"]
except (json.JSONDecodeError, KeyError):
return False

password_bytes = plain_password.encode('utf-8')
salt_bytes = salt.encode('utf-8')
key = hashlib.pbkdf2_hmac('sha256', password_bytes, salt_bytes, 600000)
computed_hash = key.hex()

# Use constant-time comparison to prevent timing attacks
return secrets.compare_digest(computed_hash, password_hash)

def __repr__(self) -> str:
return (
f"ObjectProtection(id={self.id!r}, "
f"target_type={self.target_type!r}, target_id={self.target_id!r}, "
f"protection_type={self.protection_type!r})"
)
32 changes: 28 additions & 4 deletions src/include/handlers/directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from include.database.handler import Session
from include.database.models.classic import User
from include.database.models.entity import Folder, Document, FolderAccessRule
from include.handlers.protection import check_password_protection
from include.util.audit import log_audit
from include.util.rule.applying import apply_access_rules
import include.system.messages as smsg
Expand All @@ -21,15 +22,19 @@ class RequestListDirectoryHandler(RequestHandler):
handler (ConnectionHandler): The connection handler containing request data and methods for responding.
Response Codes:
200 - Directory listing successful, returns a list of files and directories in the response data.
202 - Password required for access.
400 - Invalid request.
403 - Invalid user or token.
403 - Invalid user or token, or incorrect password.
404 - Directory not found.
500 - Internal server error, with the exception message.
"""

data_schema = {
"type": "object",
"properties": {"folder_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}},
"properties": {
"folder_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"password": {"type": "string"}
},
"required": ["folder_id"],
"additionalProperties": False,
}
Expand All @@ -40,6 +45,7 @@ def handle(self, handler: ConnectionHandler):

# Parse the directory listing request
folder_id: Optional[str] = handler.data.get("folder_id")
password = handler.data.get("password")

with Session() as session:
this_user = session.get(User, handler.username)
Expand Down Expand Up @@ -72,6 +78,13 @@ def handle(self, handler: ConnectionHandler):
**{"code": 403, "message": "Access denied", "data": {}}
)
return 403, folder_id, handler.username

# Check password protection
protection_code, protection_msg = check_password_protection(folder, password, session)
if protection_code != 0:
handler.conclude_request(protection_code, {}, protection_msg)
return protection_code, folder_id, handler.username

parent = folder.parent
children = folder.children
documents = folder.documents
Expand Down Expand Up @@ -129,22 +142,27 @@ class RequestGetDirectoryInfoHandler(RequestHandler):
handler (ConnectionHandler): The connection handler containing request data and methods for responding.
Response Codes:
200 - Directory info successful, returns directory info in the response data.
202 - Password required for access.
400 - Invalid request.
403 - Invalid user or token.
403 - Invalid user or token, or incorrect password.
404 - Directory not found.
500 - Internal server error, with the exception message.
"""

data_schema = {
"type": "object",
"properties": {"directory_id": {"type": "string", "minLength": 1}},
"properties": {
"directory_id": {"type": "string", "minLength": 1},
"password": {"type": "string"}
},
"required": ["directory_id"],
"additionalProperties": False,
}

def handle(self, handler: ConnectionHandler):

Copilot AI Dec 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequestGetDirectoryInfoHandler.handle returns tuple of size 2 and tuple of size 3.
RequestGetDirectoryInfoHandler.handle returns tuple of size 2 and tuple of size 3.
RequestGetDirectoryInfoHandler.handle returns tuple of size 2 and tuple of size 3.
RequestGetDirectoryInfoHandler.handle returns tuple of size 2 and tuple of size 3.
RequestGetDirectoryInfoHandler.handle returns tuple of size 2 and tuple of size 3.
RequestGetDirectoryInfoHandler.handle returns tuple of size 2 and tuple of size 3.
RequestGetDirectoryInfoHandler.handle returns tuple of size 2 and tuple of size 3.
RequestGetDirectoryInfoHandler.handle returns tuple of size 2 and tuple of size 3.

Copilot uses AI. Check for mistakes.

directory_id: str = handler.data["directory_id"]
password = handler.data.get("password")

if not directory_id:
handler.conclude_request(400, {}, "Directory ID is required")
Expand All @@ -171,6 +189,12 @@ def handle(self, handler: ConnectionHandler):
if not directory.check_access_requirements(user, access_type="read"):
handler.conclude_request(403, {}, "Permission denied")
return 403, directory_id, handler.username

# Check password protection
protection_code, protection_msg = check_password_protection(directory, password, session)
if protection_code != 0:
handler.conclude_request(protection_code, {}, protection_msg)
return protection_code, directory_id, handler.username

info_code = 0
### generate access_rules text
Expand Down
25 changes: 23 additions & 2 deletions src/include/handlers/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
NoActiveRevisionsError,
)
from include.database.models.file import File, FileTask
from include.handlers.protection import check_password_protection
from include.util.rule.applying import apply_access_rules
import include.system.messages as smsg

Expand Down Expand Up @@ -79,7 +80,10 @@ class RequestGetDocumentInfoHandler(RequestHandler):

data_schema = {
"type": "object",
"properties": {"document_id": {"type": "string", "minLength": 1}},
"properties": {
"document_id": {"type": "string", "minLength": 1},
"password": {"type": "string"}
},
"required": ["document_id"],
}

Expand All @@ -88,6 +92,7 @@ class RequestGetDocumentInfoHandler(RequestHandler):
def handle(self, handler: ConnectionHandler):

document_id = handler.data.get("document_id")
password = handler.data.get("password")

if not document_id:
handler.conclude_request(400, {}, "Document ID is required")
Expand All @@ -114,6 +119,12 @@ def handle(self, handler: ConnectionHandler):
if not document.check_access_requirements(user, access_type="read"):
handler.conclude_request(403, {}, "Permission denied")
return 403, document_id, handler.username

# Check password protection
protection_code, protection_msg = check_password_protection(document, password, session)
if protection_code != 0:
handler.conclude_request(protection_code, {}, protection_msg)
return protection_code, document_id, handler.username

info_code = 0
### generate access_rules text
Expand Down Expand Up @@ -198,7 +209,10 @@ class RequestGetDocumentHandler(RequestHandler):

data_schema = {
"type": "object",
"properties": {"document_id": {"type": "string", "minLength": 1}},
"properties": {
"document_id": {"type": "string", "minLength": 1},
"password": {"type": "string"}
},
"required": ["document_id"],
"additionalProperties": False,
}
Expand All @@ -207,6 +221,7 @@ class RequestGetDocumentHandler(RequestHandler):

def handle(self, handler: ConnectionHandler):
document_id: str = handler.data["document_id"]
password = handler.data.get("password")

with Session() as session:
user = session.get(User, handler.username)
Expand All @@ -220,6 +235,12 @@ def handle(self, handler: ConnectionHandler):
if not document.check_access_requirements(user):
handler.conclude_request(403, {}, "Access denied to the document")
return 403, document_id, handler.username

# Check password protection
protection_code, protection_msg = check_password_protection(document, password, session)
if protection_code != 0:
handler.conclude_request(protection_code, {}, protection_msg)
return protection_code, document_id, handler.username

try:
latest_revision = document.get_latest_revision()
Expand Down
Loading