Skip to content

Commit 9a0293e

Browse files
author
Your Name
committed
feat: Implement SRP-6a client for email/password login authentication
1 parent 70acbbe commit 9a0293e

1 file changed

Lines changed: 228 additions & 0 deletions

File tree

src/gmgnapi/srp.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""
2+
SRP-6a client used by GMGN's email/password login.
3+
4+
GMGN's web app authenticates with SRP-6a (RFC 5054) via the JavaScript
5+
``secure-remote-password`` package. The password itself never leaves the
6+
machine; only the SRP proof does.
7+
8+
This module is a byte-exact port of that package, which matters because the
9+
proof is a hash over fixed-width hex encodings: every integer carries the hex
10+
width it was created with and is re-encoded at that width when hashed, so a
11+
value that merely compares equal is not enough (``SRPInteger``).
12+
13+
The JavaScript package does its arithmetic with ``jsbn``, whose ``mod`` and
14+
``modPow`` normalise into ``[0, m)``, matching Python's ``%`` and ``pow``. That
15+
normalisation is load-bearing: SRP's ``B - k*g^x`` is routinely negative, and
16+
only the sign of the reduced result makes the proof agree with the server.
17+
18+
Test vectors generated from the JavaScript package live in ``tests/test_srp.py``.
19+
"""
20+
21+
import hashlib
22+
import secrets
23+
from typing import Optional, Union
24+
25+
__all__ = [
26+
"SRPInteger",
27+
"Ephemeral",
28+
"Session",
29+
"generate_salt",
30+
"generate_ephemeral",
31+
"derive_private_key",
32+
"derive_verifier",
33+
"derive_session",
34+
"verify_session",
35+
]
36+
37+
38+
def _js_hex(value: int) -> str:
39+
"""Render an integer the way ``jsbn``'s ``toString(16)`` does."""
40+
return f"-{abs(value):x}" if value < 0 else f"{value:x}"
41+
42+
43+
class SRPInteger:
44+
"""An integer that remembers the hex width it should be encoded at."""
45+
46+
__slots__ = ("value", "hex_length")
47+
48+
ZERO: "SRPInteger"
49+
50+
def __init__(self, value: int, hex_length: Optional[int]) -> None:
51+
self.value = value
52+
self.hex_length = hex_length
53+
54+
@classmethod
55+
def from_hex(cls, value: str) -> "SRPInteger":
56+
return cls(int(value, 16), len(value))
57+
58+
@classmethod
59+
def random_integer(cls, num_bytes: int) -> "SRPInteger":
60+
return cls.from_hex(secrets.token_bytes(num_bytes).hex())
61+
62+
def to_hex(self) -> str:
63+
if self.hex_length is None:
64+
raise ValueError("This SRPInteger has no specified length")
65+
return _js_hex(self.value).rjust(self.hex_length, "0")
66+
67+
def add(self, other: "SRPInteger") -> "SRPInteger":
68+
return SRPInteger(self.value + other.value, None)
69+
70+
def subtract(self, other: "SRPInteger") -> "SRPInteger":
71+
return SRPInteger(self.value - other.value, self.hex_length)
72+
73+
def multiply(self, other: "SRPInteger") -> "SRPInteger":
74+
return SRPInteger(self.value * other.value, None)
75+
76+
def xor(self, other: "SRPInteger") -> "SRPInteger":
77+
return SRPInteger(self.value ^ other.value, self.hex_length)
78+
79+
def mod(self, modulus: "SRPInteger") -> "SRPInteger":
80+
return SRPInteger(self.value % modulus.value, modulus.hex_length)
81+
82+
def mod_pow(self, exponent: "SRPInteger", modulus: "SRPInteger") -> "SRPInteger":
83+
return SRPInteger(
84+
pow(self.value, exponent.value, modulus.value), modulus.hex_length
85+
)
86+
87+
def equals(self, other: "SRPInteger") -> bool:
88+
return self.value == other.value
89+
90+
def __repr__(self) -> str: # pragma: no cover - debugging aid
91+
text = _js_hex(self.value)
92+
suffix = "..." if len(text) > 16 else ""
93+
return f"<SRPInteger {text[:16]}{suffix}>"
94+
95+
96+
SRPInteger.ZERO = SRPInteger(0, None)
97+
98+
99+
# RFC 5054 2048-bit group, SHA-256 — the parameters GMGN's web client uses.
100+
_LARGE_SAFE_PRIME = (
101+
"AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050"
102+
"A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50"
103+
"E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8"
104+
"55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B"
105+
"CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748"
106+
"544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6"
107+
"AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6"
108+
"94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73"
109+
)
110+
_GENERATOR_MODULO = "02"
111+
HASH_OUTPUT_BYTES = 32
112+
113+
N = SRPInteger.from_hex(_LARGE_SAFE_PRIME)
114+
g = SRPInteger.from_hex(_GENERATOR_MODULO)
115+
116+
117+
def H(*args: Union[SRPInteger, str]) -> SRPInteger:
118+
"""SHA-256 over the concatenation of the arguments.
119+
120+
``SRPInteger`` arguments contribute their fixed-width hex encoding decoded
121+
to bytes; ``str`` arguments contribute their UTF-8 bytes.
122+
"""
123+
buffer = bytearray()
124+
for arg in args:
125+
if isinstance(arg, SRPInteger):
126+
buffer.extend(bytes.fromhex(arg.to_hex()))
127+
elif isinstance(arg, str):
128+
buffer.extend(arg.encode("utf-8"))
129+
else:
130+
raise TypeError("Expected string or SRPInteger")
131+
return SRPInteger.from_hex(hashlib.sha256(bytes(buffer)).hexdigest())
132+
133+
134+
k = H(N, g)
135+
136+
137+
class Ephemeral:
138+
"""A client's ephemeral key pair, as hex strings."""
139+
140+
__slots__ = ("secret", "public")
141+
142+
def __init__(self, secret: str, public: str) -> None:
143+
self.secret = secret
144+
self.public = public
145+
146+
def __repr__(self) -> str: # pragma: no cover - debugging aid
147+
return f"<Ephemeral public={self.public[:16]}...>"
148+
149+
150+
class Session:
151+
"""A derived SRP session: the shared ``key`` and the client ``proof``."""
152+
153+
__slots__ = ("key", "proof")
154+
155+
def __init__(self, key: str, proof: str) -> None:
156+
self.key = key
157+
self.proof = proof
158+
159+
def __repr__(self) -> str: # pragma: no cover - debugging aid
160+
return f"<Session proof={self.proof[:16]}...>"
161+
162+
163+
def generate_salt() -> str:
164+
"""Generate a random salt as a hex string."""
165+
return SRPInteger.random_integer(HASH_OUTPUT_BYTES).to_hex()
166+
167+
168+
def generate_ephemeral() -> Ephemeral:
169+
"""Generate the client's ephemeral key pair (``a`` and ``A = g^a mod N``)."""
170+
secret = SRPInteger.random_integer(HASH_OUTPUT_BYTES)
171+
public = g.mod_pow(secret, N)
172+
return Ephemeral(secret=secret.to_hex(), public=public.to_hex())
173+
174+
175+
def derive_private_key(salt: str, username: str, password: str) -> str:
176+
"""Derive the SRP private key ``x = H(salt, H(username:password))``."""
177+
return H(SRPInteger.from_hex(salt), H(f"{username}:{password}")).to_hex()
178+
179+
180+
def derive_verifier(private_key: str) -> str:
181+
"""Derive the SRP verifier ``v = g^x mod N``."""
182+
return g.mod_pow(SRPInteger.from_hex(private_key), N).to_hex()
183+
184+
185+
def derive_session(
186+
client_secret: str,
187+
server_public: str,
188+
salt: str,
189+
username: str,
190+
private_key: str,
191+
) -> Session:
192+
"""Derive the shared session key and the client proof ``M1``.
193+
194+
Raises:
195+
ValueError: If the server's ephemeral public value is invalid.
196+
"""
197+
a = SRPInteger.from_hex(client_secret)
198+
B = SRPInteger.from_hex(server_public)
199+
s = SRPInteger.from_hex(salt)
200+
I = str(username)
201+
x = SRPInteger.from_hex(private_key)
202+
203+
A = g.mod_pow(a, N)
204+
205+
if B.mod(N).equals(SRPInteger.ZERO):
206+
raise ValueError("The server sent an invalid public ephemeral")
207+
208+
u = H(A, B)
209+
S = B.subtract(k.multiply(g.mod_pow(x, N))).mod_pow(a.add(u.multiply(x)), N)
210+
K = H(S)
211+
M = H(H(N).xor(H(g)), H(I), s, A, B, K)
212+
213+
return Session(key=K.to_hex(), proof=M.to_hex())
214+
215+
216+
def verify_session(client_public: str, session: Session, server_proof: str) -> None:
217+
"""Verify the server's proof ``M2``.
218+
219+
Raises:
220+
ValueError: If the server's proof does not match.
221+
"""
222+
expected = H(
223+
SRPInteger.from_hex(client_public),
224+
SRPInteger.from_hex(session.proof),
225+
SRPInteger.from_hex(session.key),
226+
)
227+
if not SRPInteger.from_hex(server_proof).equals(expected):
228+
raise ValueError("Server provided session proof is invalid")

0 commit comments

Comments
 (0)