Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
11408a6
Pull Request Initial commit
Mr-Ao-Dragon Jun 18, 2026
a8e3003
Add binary representation properties for SRPSessionBase
Mr-Ao-Dragon Jun 18, 2026
32f11fd
Enhance SRP session handling to support binary and hex inputs for pri…
Mr-Ao-Dragon Jun 18, 2026
99ce8ca
Update test_context expected values to match hex_from str return type
Mr-Ao-Dragon Jun 18, 2026
e4c4a6c
fix: wrong proof verify target
Mr-Ao-Dragon Jun 18, 2026
5adb0ec
add: all way path test for bin
Mr-Ao-Dragon Jun 18, 2026
313757d
update: README.rst for raw binary support
Mr-Ao-Dragon Jun 18, 2026
3632e5b
add: initial implementation of srptools with CLI and packaging setup
Mr-Ao-Dragon Jun 23, 2026
000310c
add: initial implementation of srptools with CLI and packaging setup
Mr-Ao-Dragon Jun 23, 2026
7c123ba
update: allow publish step to continue on error
Mr-Ao-Dragon Jun 23, 2026
a793d96
Merge remote-tracking branch 'upstream/master' into refact/publish/us…
Mr-Ao-Dragon Jun 23, 2026
d3ae1b8
强制要求发布成功,跳过上传到github registery (#4)
Mr-Ao-Dragon Jun 23, 2026
060d728
update: allow publish step to proceed even if target secret is missing
Mr-Ao-Dragon Jun 23, 2026
769cfce
update: modify publish step to include specific file patterns for upload
Mr-Ao-Dragon Jun 23, 2026
b6d6c31
小修补 (#5)
Mr-Ao-Dragon Jun 23, 2026
ce63b7e
合并拉取请求 #6
Mr-Ao-Dragon Jun 23, 2026
acf4e90
update: add permissions for write access in publish step
Mr-Ao-Dragon Jun 23, 2026
a9e37c3
update: modify publish step to use dynamic publish URL based on repos…
Mr-Ao-Dragon Jun 23, 2026
5511de9
update: modify publish step to use repository-specific publish URL an…
Mr-Ao-Dragon Jun 23, 2026
e647173
marge father commits
Mr-Ao-Dragon Jul 3, 2026
ab02e20
fix: #12 review comment N.1
Mr-Ao-Dragon Jul 7, 2026
a1b7500
fix: #12 review comment N.2
Mr-Ao-Dragon Jul 7, 2026
4a47582
fix: #12 review comment N.3
Mr-Ao-Dragon Jul 7, 2026
91b2d7f
fix: #12 review comment N.4
Mr-Ao-Dragon Jul 7, 2026
1eb2a17
fix: #12 review comment N.5
Mr-Ao-Dragon Jul 7, 2026
98be67b
fix: #12 review comment N.6
Mr-Ao-Dragon Jul 7, 2026
b9b75f7
ci: 更新依赖同步命令为 uv sync --group tests
Mr-Ao-Dragon Jul 7, 2026
e3b8d05
refactor: 优化代码风格与异常处理
Mr-Ao-Dragon Jul 7, 2026
52126b2
pr/12/fix-comment (#7)
Mr-Ao-Dragon Jul 7, 2026
afa305e
fix: pr 12 review round 2
Mr-Ao-Dragon Jul 8, 2026
f69f30e
fix: pr 12 review round 3 review issue 1
Mr-Ao-Dragon Jul 9, 2026
d31d519
fix: pr 12 review round 2 review issue 2
Mr-Ao-Dragon Jul 15, 2026
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: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ __pycache__
*.pyo
*.egg-info
docs/_build/
*.lock
Comment thread
Mr-Ao-Dragon marked this conversation as resolved.
*.lock
.venv
build
10 changes: 7 additions & 3 deletions src/srptools/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from secrets import compare_digest
from typing import TYPE_CHECKING

from .common import SRPSessionBase
Expand All @@ -12,7 +13,7 @@ class SRPClientSession(SRPSessionBase):

role = 'client'

def __init__(self, srp_context: SRPContext, *, private: str = ''):
def __init__(self, srp_context: SRPContext, *, private: str | int | bytes = ''):
super().__init__(srp_context, private)

self._password_hash: int | None = None
Expand All @@ -22,7 +23,7 @@ def __init__(self, srp_context: SRPContext, *, private: str = ''):

self._client_public = srp_context.get_client_public(client_private=self._this_private)

def init_base(self, salt: str):
def init_base(self, salt: str | bytes):
super().init_base(salt)

self._password_hash = self._context.get_common_password_hash(self._salt)
Expand All @@ -39,7 +40,10 @@ def init_session_key(self):

self._key = self._context.get_common_session_key(premaster_secret)

def verify_proof(self, key_proof: str, *, base64: bool = False) -> bool:
def verify_proof(self, key_proof: str | bytes, *, base64: bool = False) -> bool:
super().verify_proof(key_proof)

if isinstance(key_proof, bytes):
return compare_digest(key_proof, self._key_proof_hash)

return self._value_decode(key_proof, base64=base64) == self.key_proof_hash
83 changes: 62 additions & 21 deletions src/srptools/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import TYPE_CHECKING

from .exceptions import SRPException
from .utils import b64_from, hex_from, hex_from_b64, int_from_hex, value_encode
from .utils import b64_from, hex_from, hex_from_b64, int_from_bytes, int_from_hex, value_encode

if TYPE_CHECKING:
from .context import Salt, SRPContext
Expand All @@ -15,7 +15,7 @@ class SRPSessionBase:

role: str | None = None

def __init__(self, srp_context: SRPContext, private: str = '') -> None:
def __init__(self, srp_context: SRPContext, private: str | int | bytes = '') -> None:
self._context = srp_context

self._salt: Salt | None = None
Expand All @@ -30,14 +30,19 @@ def __init__(self, srp_context: SRPContext, private: str = '') -> None:
self._this_private: int | None = None

if private:
self._this_private = int_from_hex(private)
if isinstance(private, int):
self._this_private = private
elif isinstance(private, bytes):
self._this_private = int_from_bytes(private)
else:
self._this_private = int_from_hex(private)

@property
def _this_public(self) -> int:
return getattr(self, f'_{self.role}_public')

def _other_public(self, val: int) -> None:
other = ('server' if self.role == 'client' else 'client')
other = 'server' if self.role == 'client' else 'client'
setattr(self, f'_{other}_public', val)

_other_public = property(None, _other_public)
Expand All @@ -50,6 +55,10 @@ def private(self) -> str:
def private_b64(self) -> str:
return b64_from(self._this_private)

@property
def private_bin(self) -> bytes:
return self._context.pad(self._this_private)

@property
def public(self) -> str:
return hex_from(self._this_public)
Expand All @@ -58,6 +67,10 @@ def public(self) -> str:
def public_b64(self) -> str:
return b64_from(self._this_public)

@property
def public_bin(self) -> bytes:
return self._context.pad(self._this_public)

@property
def key(self) -> str:
return hex_from(self._key)
Expand All @@ -66,6 +79,10 @@ def key(self) -> str:
def key_b64(self) -> str:
return b64_from(self._key)

@property
def key_bin(self) -> bytes:
return self._key

@property
def key_proof(self) -> str:
return hex_from(self._key_proof)
Expand All @@ -74,6 +91,10 @@ def key_proof(self) -> str:
def key_proof_b64(self) -> str:
return b64_from(self._key_proof)

@property
def key_proof_bin(self) -> bytes:
return self._key_proof

@property
def key_proof_hash(self) -> str:
return hex_from(self._key_proof_hash)
Expand All @@ -82,18 +103,31 @@ def key_proof_hash(self) -> str:
def key_proof_hash_b64(self) -> str:
return b64_from(self._key_proof_hash)

@property
def key_proof_hash_bin(self) -> bytes:
return self._key_proof_hash

@classmethod
def _value_decode(cls, value: str, *, base64: bool = False) -> str:
def _value_decode(cls, value: str | bytes, *, base64: bool = False) -> str | bytes:
"""Decodes value into hex optionally from base64."""
return hex_from_b64(value) if base64 else value
if base64:
if isinstance(value, bytes):
raise SRPException('Cannot decode base64 from bytes.')
return hex_from_b64(value)
return value

def process(
self,
other_public: str | bytes = '',
salt: str | bytes = '',
*,
other_public: str,
salt: str,
base64: bool = False,
) -> tuple[str, str, str]:
if base64 and (isinstance(other_public, bytes) or isinstance(salt, bytes)):
raise SRPException(
'Cannot decode base64 from bytes. '
'If the value is bytes, it is already decoded and should not be treated as base64.'
)
salt = self._value_decode(salt, base64=base64)
other_public = self._value_decode(other_public, base64=base64)

Expand All @@ -108,18 +142,30 @@ def process(

return key, key_proof, key_proof_hash

def init_base(self, salt: str) -> None:
salt = unhexlify(salt)
self._salt = salt
def init_base(self, salt: str | bytes) -> None:
if isinstance(salt, bytes):
self._salt = salt
else:
self._salt = unhexlify(salt)

def init_session_key(self) -> None:
pass

def verify_proof(self, key_prove: str, *, base64: bool = False) -> bool:
def verify_proof(self, key_prove: str | bytes, *, base64: bool = False) -> bool:
pass

def init_common_secret(self, other_public: str) -> None:
other_public = int_from_hex(other_public)
def init_common_secret(self, other_public: str | int | bytes) -> None:
if isinstance(other_public, int):
pass
elif isinstance(other_public, bytes):
other_public = int_from_bytes(other_public)
else:
try:
other_public = int(other_public, 16)
except (ValueError, TypeError) as e:
raise SRPException(
f'Wrong public provided for {self.__class__.__name__}: cannot decode value: {e}',
) from e

if other_public % self._context._prime == 0: # A % N is zero | B % N is zero
raise SRPException(f'Wrong public provided for {self.__class__.__name__}.')
Expand All @@ -130,16 +176,11 @@ def init_common_secret(self, other_public: str) -> None:

def init_session_key_proof(self) -> None:
proof = self._context.get_common_session_key_proof(
session_key=self._key,
salt=self._salt,
server_public=self._server_public,
client_public=self._client_public
session_key=self._key, salt=self._salt, server_public=self._server_public, client_public=self._client_public
)

self._key_proof = proof

self._key_proof_hash = self._context.get_common_session_key_proof_hash(
session_key=self._key,
session_key_proof=proof,
client_public=self._client_public
session_key=self._key, session_key_proof=proof, client_public=self._client_public
)
23 changes: 21 additions & 2 deletions src/srptools/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,30 @@ def get_common_session_key_proof_hash(
"""H(A | M | K)"""
return self.hash(client_public, session_key_proof, session_key, as_bytes=True)

def get_user_data_triplet(self, *, base64: bool = False) -> tuple[str, str, str]:
"""( <_user>, <_password verifier>, <salt> )"""
def get_user_data_triplet(
Comment thread
Mr-Ao-Dragon marked this conversation as resolved.
self,
*,
base64: bool = False,
binary: bool = False,
) -> tuple[str, str | bytes, str | bytes]:
"""( <_user>, <_password verifier>, <salt> )

:param bool base64: Output verifier and salt as base64 strings.
:param bool binary: Output verifier and salt as raw bytes. Verifier
is prime-width (padded), salt is bits_salt-width (padded).
Mutually exclusive with ``base64``.
:raises SRPException: if both ``base64`` and ``binary`` are True.
"""
if binary and base64:
raise SRPException('binary and base64 are mutually exclusive')

salt = self.generate_salt()
verifier = self.get_common_password_verifier(self.get_common_password_hash(salt))

if binary:
salt_bytes = int_to_bytes(salt).rjust(self._bits_salt // 8, b'\x00')
return self._user, self.pad(verifier), salt_bytes

verifier = value_encode(verifier, base64=base64)
salt = value_encode(salt, base64=base64)

Expand Down
29 changes: 21 additions & 8 deletions src/srptools/server.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,39 @@
from __future__ import annotations

from secrets import compare_digest
from typing import TYPE_CHECKING

from .common import SRPSessionBase
from .utils import int_from_hex
from .utils import int_from_bytes, int_from_hex

if TYPE_CHECKING:
from .context import SRPContext


class SRPServerSession(SRPSessionBase):

role = 'server'

def __init__(self, srp_context: SRPContext, *, password_verifier: str, private: str = ''):
def __init__(
self,
srp_context: SRPContext,
*,
password_verifier: str | int | bytes,
private: str | int | bytes = '',
):
super().__init__(srp_context, private)

self._password_verifier = int_from_hex(password_verifier)
if isinstance(password_verifier, int):
self._password_verifier = password_verifier
elif isinstance(password_verifier, bytes):
self._password_verifier = int_from_bytes(password_verifier)
else:
self._password_verifier = int_from_hex(password_verifier)

if not private:
self._this_private = srp_context.generate_server_private()

self._server_public = srp_context.get_server_public(
password_verifier=self._password_verifier,
server_private=self._this_private
password_verifier=self._password_verifier, server_private=self._this_private
)

def init_session_key(self) -> None:
Expand All @@ -33,12 +43,15 @@ def init_session_key(self) -> None:
password_verifier=self._password_verifier,
server_private=self._this_private,
client_public=self._client_public,
common_secret=self._common_secret
common_secret=self._common_secret,
)

self._key = self._context.get_common_session_key(premaster_secret)

def verify_proof(self, key_proof: str, *, base64: bool = False) -> bool:
def verify_proof(self, key_proof: str | bytes, *, base64: bool = False) -> bool:
super().verify_proof(key_proof)

if isinstance(key_proof, bytes):
return compare_digest(key_proof, self._key_proof)

return self._value_decode(key_proof, base64=base64) == self.key_proof
9 changes: 8 additions & 1 deletion src/srptools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ def value_encode(val: int | bytes, *, base64: bool = False) -> str:
return b64_from(val) if base64 else hex_from(val)


def hex_from_b64(val: str) -> str:
def hex_from_b64(val: str | bytes) -> str:
"""Returns hex string representation for a base64 encoded value."""
if isinstance(val, bytes):
val = val.decode('ascii')
return b64decode(val).hex()


def int_from_bytes(val: bytes) -> int:
"""Returns int representation for a given bytes value (big-endian)."""
return int.from_bytes(val, 'big')


def hex_from(val: int | bytes) -> str:
"""Returns hex string representation for a given value."""
if isinstance(val, int):
Expand Down
Loading
Loading