pgtern 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pgtern/__init__.py +46 -0
- pgtern/_binary.py +226 -0
- pgtern/_composite.py +103 -0
- pgtern/_conn.py +907 -0
- pgtern/_copy.py +186 -0
- pgtern/_cursor.py +261 -0
- pgtern/_dsn.py +182 -0
- pgtern/_protocol.py +258 -0
- pgtern/_query.py +69 -0
- pgtern/_scram.py +101 -0
- pgtern/_types.py +442 -0
- pgtern/adapters.py +85 -0
- pgtern/aio.py +1287 -0
- pgtern/errors.py +248 -0
- pgtern/hooks.py +53 -0
- pgtern/pipeline.py +202 -0
- pgtern/pool.py +309 -0
- pgtern/py.typed +0 -0
- pgtern/rows.py +31 -0
- pgtern/sql.py +200 -0
- pgtern/types/__init__.py +30 -0
- pgtern/types/json.py +5 -0
- pgtern-0.3.0.dist-info/METADATA +265 -0
- pgtern-0.3.0.dist-info/RECORD +27 -0
- pgtern-0.3.0.dist-info/WHEEL +5 -0
- pgtern-0.3.0.dist-info/licenses/LICENSE +202 -0
- pgtern-0.3.0.dist-info/top_level.txt +1 -0
pgtern/_protocol.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""PostgreSQL frontend/backend protocol 3.0, sans-I/O.
|
|
3
|
+
|
|
4
|
+
Functions here build or parse bytes and never touch a socket, so the same code
|
|
5
|
+
can later be driven by asyncio, Trio or a blocking socket.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import struct
|
|
10
|
+
from typing import NamedTuple, Sequence
|
|
11
|
+
|
|
12
|
+
from . import errors
|
|
13
|
+
|
|
14
|
+
_I32 = struct.Struct("!I")
|
|
15
|
+
_S32 = struct.Struct("!i")
|
|
16
|
+
_U16 = struct.Struct("!H")
|
|
17
|
+
_S16 = struct.Struct("!h")
|
|
18
|
+
_MAX_MESSAGE = 1 << 30
|
|
19
|
+
|
|
20
|
+
SSL_REQUEST = struct.pack("!II", 8, 80877103)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _msg(tag: bytes, payload: bytes) -> bytes:
|
|
24
|
+
return tag + _I32.pack(len(payload) + 4) + payload
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def startup_message(params: dict[str, str]) -> bytes:
|
|
28
|
+
body = _I32.pack(196608) + b"".join(
|
|
29
|
+
k.encode() + b"\0" + v.encode() + b"\0" for k, v in params.items()
|
|
30
|
+
) + b"\0"
|
|
31
|
+
return _I32.pack(len(body) + 4) + body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def query_message(sql: str) -> bytes:
|
|
35
|
+
return _msg(b"Q", _checked(sql).encode() + b"\0")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _checked(sql: str) -> str:
|
|
39
|
+
if "\x00" in sql:
|
|
40
|
+
raise errors.ProgrammingError("a query cannot contain NUL (0x00) characters")
|
|
41
|
+
return sql
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def extended_query(sql: str, oids: Sequence[int], values: Sequence[bytes | None],
|
|
45
|
+
pformats: Sequence[int] | None = None, rformats: Sequence[int] | None = None,
|
|
46
|
+
sync: bool = True) -> bytes:
|
|
47
|
+
"""Parse + Bind + Describe + Execute + Sync on the unnamed statement and portal.
|
|
48
|
+
|
|
49
|
+
Parameters travel in text format unless ``pformats`` says otherwise (one code per
|
|
50
|
+
parameter, 0 text or 1 binary); results likewise with ``rformats`` (one per column).
|
|
51
|
+
"""
|
|
52
|
+
if len(values) > 65535:
|
|
53
|
+
raise errors.ProgrammingError("PostgreSQL allows at most 65535 query parameters")
|
|
54
|
+
parse = b"\0" + _checked(sql).encode() + b"\0" + _U16.pack(len(oids)) + struct.pack(f"!{len(oids)}I", *oids)
|
|
55
|
+
pf = b"" if pformats is None else _U16.pack(len(pformats)) + b"".join(_U16.pack(f) for f in pformats)
|
|
56
|
+
parts = [b"\0\0", pf or _U16.pack(0), _U16.pack(len(values))]
|
|
57
|
+
for v in values:
|
|
58
|
+
parts.append(_S32.pack(-1) if v is None else _S32.pack(len(v)) + v)
|
|
59
|
+
rf = b"" if rformats is None else _U16.pack(len(rformats)) + b"".join(_U16.pack(f) for f in rformats)
|
|
60
|
+
parts.append(rf or _U16.pack(0))
|
|
61
|
+
return (
|
|
62
|
+
_msg(b"P", parse)
|
|
63
|
+
+ _msg(b"B", b"".join(parts))
|
|
64
|
+
+ _msg(b"D", b"P\0")
|
|
65
|
+
+ _msg(b"E", b"\0" + _S32.pack(0))
|
|
66
|
+
+ (_msg(b"S", b"") if sync else b"")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
SYNC = _msg(b"S", b"")
|
|
71
|
+
# The server buffers its replies until a Sync or a Flush: pipelines use Flush to read mid-batch.
|
|
72
|
+
FLUSH = _msg(b"H", b"")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_query(sql: str, params, translate, dump) -> bytes:
|
|
76
|
+
"""Frontend bytes for a query: simple protocol without parameters, extended with them."""
|
|
77
|
+
if params is None:
|
|
78
|
+
return query_message(sql)
|
|
79
|
+
pg_sql, values = translate(sql, params)
|
|
80
|
+
dumped = [dump(v) for v in values]
|
|
81
|
+
return extended_query(pg_sql, [oid for oid, _ in dumped], [data for _, data in dumped])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def describe_statement(sql: str) -> bytes:
|
|
85
|
+
"""Parse the unnamed statement (types inferred by the server) and describe it. Runs nothing."""
|
|
86
|
+
parse = b"\0" + _checked(sql).encode() + b"\0" + _U16.pack(0)
|
|
87
|
+
return _msg(b"P", parse) + _msg(b"D", b"S\0") + _msg(b"S", b"")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_param_description(payload: bytes) -> list[int]:
|
|
91
|
+
(count,) = _U16.unpack_from(payload, 0)
|
|
92
|
+
return list(struct.unpack_from(f"!{count}I", payload, 2))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def password_message(password: bytes) -> bytes:
|
|
96
|
+
return _msg(b"p", password + b"\0")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sasl_initial_response(mechanism: str, data: bytes) -> bytes:
|
|
100
|
+
return _msg(b"p", mechanism.encode() + b"\0" + _S32.pack(len(data)) + data)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sasl_response(data: bytes) -> bytes:
|
|
104
|
+
return _msg(b"p", data)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def copy_fail(reason: str) -> bytes:
|
|
108
|
+
return _msg(b"f", reason.encode() + b"\0")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def copy_data(data: bytes) -> bytes:
|
|
112
|
+
return _msg(b"d", data)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
COPY_DONE = _msg(b"c", b"")
|
|
116
|
+
TERMINATE = _msg(b"X", b"")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Notify(NamedTuple):
|
|
120
|
+
"""An asynchronous notification from LISTEN/NOTIFY."""
|
|
121
|
+
|
|
122
|
+
channel: str
|
|
123
|
+
payload: str
|
|
124
|
+
pid: int
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_notification(payload: bytes) -> Notify:
|
|
128
|
+
pid = _I32.unpack_from(payload, 0)[0]
|
|
129
|
+
channel, body, _ = payload[4:].split(b"\0", 2)
|
|
130
|
+
return Notify(channel.decode("utf-8", "replace"), body.decode("utf-8", "replace"), pid)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class MessageParser:
|
|
134
|
+
"""Reassembles backend messages from arbitrary chunks of bytes."""
|
|
135
|
+
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
self._buf = bytearray()
|
|
138
|
+
|
|
139
|
+
def feed(self, data: bytes) -> list[tuple[int, bytes]]:
|
|
140
|
+
buf = self._buf
|
|
141
|
+
buf += data
|
|
142
|
+
out: list[tuple[int, bytes]] = []
|
|
143
|
+
pos, n = 0, len(buf)
|
|
144
|
+
while n - pos >= 5:
|
|
145
|
+
(length,) = _I32.unpack_from(buf, pos + 1)
|
|
146
|
+
if length < 4 or length > _MAX_MESSAGE:
|
|
147
|
+
raise errors.InterfaceError(f"invalid message length {length} from the server")
|
|
148
|
+
end = pos + 1 + length
|
|
149
|
+
if end > n:
|
|
150
|
+
break
|
|
151
|
+
out.append((buf[pos], bytes(buf[pos + 5:end])))
|
|
152
|
+
pos = end
|
|
153
|
+
if pos:
|
|
154
|
+
del buf[:pos]
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_error_fields(payload: bytes) -> dict[str, str]:
|
|
159
|
+
"""ErrorResponse / NoticeResponse: ``code byte`` + ``cstring`` pairs, ended by a zero byte."""
|
|
160
|
+
fields: dict[str, str] = {}
|
|
161
|
+
i, n = 0, len(payload)
|
|
162
|
+
while i < n and payload[i] != 0:
|
|
163
|
+
code = chr(payload[i])
|
|
164
|
+
end = payload.index(b"\0", i + 1)
|
|
165
|
+
fields[code] = payload[i + 1:end].decode("utf-8", "replace")
|
|
166
|
+
i = end + 1
|
|
167
|
+
return fields
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def parse_parameter_status(payload: bytes) -> tuple[str, str]:
|
|
171
|
+
name, value, _ = payload.split(b"\0", 2)
|
|
172
|
+
return name.decode(), value.decode("utf-8", "replace")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Column(NamedTuple):
|
|
176
|
+
"""A DB-API 7-item description entry (only the first four are filled in)."""
|
|
177
|
+
|
|
178
|
+
name: str
|
|
179
|
+
type_code: int
|
|
180
|
+
display_size: int | None = None
|
|
181
|
+
internal_size: int | None = None
|
|
182
|
+
precision: int | None = None
|
|
183
|
+
scale: int | None = None
|
|
184
|
+
null_ok: bool | None = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_row_description(payload: bytes) -> list[Column]:
|
|
188
|
+
(count,) = _U16.unpack_from(payload, 0)
|
|
189
|
+
pos = 2
|
|
190
|
+
cols: list[Column] = []
|
|
191
|
+
for _ in range(count):
|
|
192
|
+
end = payload.index(b"\0", pos)
|
|
193
|
+
name = payload[pos:end].decode("utf-8", "replace")
|
|
194
|
+
_table, _attr, type_oid, size, _mod, _fmt = struct.unpack_from("!IhIhih", payload, end + 1)
|
|
195
|
+
cols.append(Column(name, type_oid, None, size if size >= 0 else None))
|
|
196
|
+
pos = end + 1 + 18
|
|
197
|
+
return cols
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_data_row(payload: bytes) -> list[bytes | None]:
|
|
201
|
+
(count,) = _U16.unpack_from(payload, 0)
|
|
202
|
+
pos = 2
|
|
203
|
+
row: list[bytes | None] = []
|
|
204
|
+
for _ in range(count):
|
|
205
|
+
(length,) = _S32.unpack_from(payload, pos)
|
|
206
|
+
pos += 4
|
|
207
|
+
if length < 0:
|
|
208
|
+
row.append(None)
|
|
209
|
+
else:
|
|
210
|
+
row.append(payload[pos:pos + length])
|
|
211
|
+
pos += length
|
|
212
|
+
return row
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _rowcount(tag: str) -> int:
|
|
216
|
+
words = tag.split()
|
|
217
|
+
if words and words[0] in {"INSERT", "SELECT", "UPDATE", "DELETE", "MERGE", "FETCH", "MOVE", "COPY"}:
|
|
218
|
+
try:
|
|
219
|
+
return int(words[-1])
|
|
220
|
+
except ValueError:
|
|
221
|
+
return -1
|
|
222
|
+
return -1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class Result:
|
|
226
|
+
__slots__ = ("columns", "rows", "tag", "rowcount", "formats")
|
|
227
|
+
|
|
228
|
+
def __init__(self, columns: list[Column] | None = None) -> None:
|
|
229
|
+
self.columns = columns
|
|
230
|
+
self.rows: list[list[bytes | None]] = []
|
|
231
|
+
self.tag = ""
|
|
232
|
+
self.rowcount = -1
|
|
233
|
+
self.formats: list[int] | None = None # per column: 1 when the rows are binary
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class QueryCollector:
|
|
237
|
+
"""Builds the list of results of one query cycle from backend messages."""
|
|
238
|
+
|
|
239
|
+
def __init__(self) -> None:
|
|
240
|
+
self.results: list[Result] = []
|
|
241
|
+
self._current: Result | None = None
|
|
242
|
+
|
|
243
|
+
def handle(self, tag: int, payload: bytes) -> None:
|
|
244
|
+
if tag == 0x54: # T RowDescription
|
|
245
|
+
self._current = Result(parse_row_description(payload))
|
|
246
|
+
elif tag == 0x44: # D DataRow
|
|
247
|
+
if self._current is None:
|
|
248
|
+
self._current = Result()
|
|
249
|
+
self._current.rows.append(parse_data_row(payload))
|
|
250
|
+
elif tag == 0x43: # C CommandComplete
|
|
251
|
+
res = self._current or Result()
|
|
252
|
+
res.tag = payload.rstrip(b"\0").decode("utf-8", "replace")
|
|
253
|
+
res.rowcount = _rowcount(res.tag)
|
|
254
|
+
self.results.append(res)
|
|
255
|
+
self._current = None
|
|
256
|
+
elif tag == 0x49: # I EmptyQueryResponse
|
|
257
|
+
self.results.append(Result())
|
|
258
|
+
# 1 ParseComplete, 2 BindComplete, 3 CloseComplete, n NoData, t ParameterDescription: nothing to keep
|
pgtern/_query.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Turn ``%s`` / ``%(name)s`` placeholders into PostgreSQL ``$n`` parameters."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import errors
|
|
10
|
+
|
|
11
|
+
_PLACEHOLDER = re.compile(r"%(?:\(([^)]*)\))?(.?)", re.S)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def translate(sql: str, params: Any, render: Any = None) -> tuple[str, list[Any]]:
|
|
15
|
+
"""Return ``(sql_with_$n, values)``.
|
|
16
|
+
|
|
17
|
+
``%s``, ``%b`` and ``%t`` are placeholders and ``%%`` is a literal percent sign.
|
|
18
|
+
The query is scanned as plain text (string literals are not
|
|
19
|
+
parsed), so a literal ``%`` inside a quoted string must be written ``%%``.
|
|
20
|
+
With ``render`` (a function value -> SQL text) values are written into the SQL
|
|
21
|
+
(client-side binding) instead of becoming ``$n``; used where PostgreSQL has no
|
|
22
|
+
parameters, such as ``COPY``.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(params, Mapping):
|
|
25
|
+
named = True
|
|
26
|
+
elif isinstance(params, Sequence) and not isinstance(params, (str, bytes, bytearray)):
|
|
27
|
+
named = False
|
|
28
|
+
else:
|
|
29
|
+
raise errors.ProgrammingError(
|
|
30
|
+
f"query parameters should be a sequence or a mapping, got {type(params).__name__}")
|
|
31
|
+
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
values: list[Any] = []
|
|
34
|
+
index_by_name: dict[str, int] = {}
|
|
35
|
+
positional = 0
|
|
36
|
+
last = 0
|
|
37
|
+
for m in _PLACEHOLDER.finditer(sql):
|
|
38
|
+
name, fmt = m.group(1), m.group(2)
|
|
39
|
+
out.append(sql[last:m.start()])
|
|
40
|
+
last = m.end()
|
|
41
|
+
if fmt == "%" and name is None:
|
|
42
|
+
out.append("%")
|
|
43
|
+
continue
|
|
44
|
+
if fmt == "":
|
|
45
|
+
raise errors.ProgrammingError("incomplete placeholder: '%' at the end of the query")
|
|
46
|
+
if fmt not in ("s", "b", "t"):
|
|
47
|
+
raise errors.ProgrammingError(
|
|
48
|
+
f"only '%s', '%b', '%t' are allowed as placeholders, got '{m.group(0)}'")
|
|
49
|
+
if name is not None:
|
|
50
|
+
if not named:
|
|
51
|
+
raise errors.ProgrammingError("named placeholders require a mapping of parameters")
|
|
52
|
+
if name not in index_by_name:
|
|
53
|
+
if name not in params:
|
|
54
|
+
raise errors.ProgrammingError(f"query parameter missing: {name}")
|
|
55
|
+
values.append(params[name])
|
|
56
|
+
index_by_name[name] = len(values)
|
|
57
|
+
out.append(f"${index_by_name[name]}" if render is None else render(params[name]))
|
|
58
|
+
else:
|
|
59
|
+
if named:
|
|
60
|
+
raise errors.ProgrammingError("positional placeholders require a sequence of parameters")
|
|
61
|
+
positional += 1
|
|
62
|
+
out.append(f"${positional}" if render is None else render(params[positional - 1]))
|
|
63
|
+
out.append(sql[last:])
|
|
64
|
+
if not named:
|
|
65
|
+
if positional != len(params):
|
|
66
|
+
raise errors.ProgrammingError(
|
|
67
|
+
f"the query has {positional} placeholders but {len(params)} parameters were passed")
|
|
68
|
+
values = list(params)
|
|
69
|
+
return "".join(out), values
|
pgtern/_scram.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""SCRAM-SHA-256 client (RFC 5802 / RFC 7677), sans-I/O: bytes in, bytes out."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import os
|
|
9
|
+
import stringprep
|
|
10
|
+
import unicodedata
|
|
11
|
+
|
|
12
|
+
from . import errors
|
|
13
|
+
|
|
14
|
+
MECHANISM = "SCRAM-SHA-256"
|
|
15
|
+
_MAX_ITERATIONS = 1 << 24 # refuse absurd server-chosen costs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def saslprep(password: str) -> str:
|
|
19
|
+
"""RFC 4013 SASLprep. Falls back to the raw password if it can't be prepared."""
|
|
20
|
+
if password.isascii():
|
|
21
|
+
return password
|
|
22
|
+
try:
|
|
23
|
+
mapped = "".join(
|
|
24
|
+
" " if stringprep.in_table_c12(ch) else ch
|
|
25
|
+
for ch in password
|
|
26
|
+
if not stringprep.in_table_b1(ch)
|
|
27
|
+
)
|
|
28
|
+
out = unicodedata.normalize("NFKC", mapped)
|
|
29
|
+
if not out:
|
|
30
|
+
return password
|
|
31
|
+
prohibited = (
|
|
32
|
+
stringprep.in_table_c12, stringprep.in_table_c21_c22, stringprep.in_table_c3,
|
|
33
|
+
stringprep.in_table_c4, stringprep.in_table_c5, stringprep.in_table_c6,
|
|
34
|
+
stringprep.in_table_c7, stringprep.in_table_c8, stringprep.in_table_c9,
|
|
35
|
+
stringprep.in_table_a1,
|
|
36
|
+
)
|
|
37
|
+
if any(check(ch) for ch in out for check in prohibited):
|
|
38
|
+
return password
|
|
39
|
+
if any(stringprep.in_table_d1(ch) for ch in out):
|
|
40
|
+
if any(stringprep.in_table_d2(ch) for ch in out):
|
|
41
|
+
return password
|
|
42
|
+
if not (stringprep.in_table_d1(out[0]) and stringprep.in_table_d1(out[-1])):
|
|
43
|
+
return password
|
|
44
|
+
return out
|
|
45
|
+
except Exception: # pragma: no cover - defensive
|
|
46
|
+
return password
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _attrs(message: str) -> dict[str, str]:
|
|
50
|
+
out: dict[str, str] = {}
|
|
51
|
+
for part in message.split(","):
|
|
52
|
+
key, sep, value = part.partition("=")
|
|
53
|
+
if sep:
|
|
54
|
+
out[key] = value
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ScramClient:
|
|
59
|
+
def __init__(self, password: str, *, username: str = "", nonce: str | None = None) -> None:
|
|
60
|
+
self._password = password
|
|
61
|
+
self._nonce = nonce or base64.b64encode(os.urandom(18)).decode()
|
|
62
|
+
self._first_bare = f"n={username},r={self._nonce}"
|
|
63
|
+
self._server_signature = b""
|
|
64
|
+
|
|
65
|
+
def client_first(self) -> bytes:
|
|
66
|
+
return ("n,," + self._first_bare).encode()
|
|
67
|
+
|
|
68
|
+
def client_final(self, server_first: bytes) -> bytes:
|
|
69
|
+
text = server_first.decode()
|
|
70
|
+
attrs = _attrs(text)
|
|
71
|
+
if "e" in attrs:
|
|
72
|
+
raise errors.OperationalError(f"SCRAM authentication failed: {attrs['e']}")
|
|
73
|
+
try:
|
|
74
|
+
server_nonce, salt, iterations = attrs["r"], base64.b64decode(attrs["s"]), int(attrs["i"])
|
|
75
|
+
except (KeyError, ValueError) as exc:
|
|
76
|
+
raise errors.OperationalError("malformed SCRAM server-first-message") from exc
|
|
77
|
+
if not server_nonce.startswith(self._nonce):
|
|
78
|
+
raise errors.OperationalError("SCRAM server nonce does not extend the client nonce")
|
|
79
|
+
if not 1 <= iterations <= _MAX_ITERATIONS:
|
|
80
|
+
raise errors.OperationalError(f"unacceptable SCRAM iteration count {iterations}")
|
|
81
|
+
salted = hashlib.pbkdf2_hmac("sha256", saslprep(self._password).encode(), salt, iterations)
|
|
82
|
+
client_key = hmac.digest(salted, b"Client Key", "sha256")
|
|
83
|
+
stored_key = hashlib.sha256(client_key).digest()
|
|
84
|
+
without_proof = f"c=biws,r={server_nonce}"
|
|
85
|
+
auth_message = f"{self._first_bare},{text},{without_proof}".encode()
|
|
86
|
+
signature = hmac.digest(stored_key, auth_message, "sha256")
|
|
87
|
+
proof = bytes(a ^ b for a, b in zip(client_key, signature))
|
|
88
|
+
server_key = hmac.digest(salted, b"Server Key", "sha256")
|
|
89
|
+
self._server_signature = hmac.digest(server_key, auth_message, "sha256")
|
|
90
|
+
return f"{without_proof},p={base64.b64encode(proof).decode()}".encode()
|
|
91
|
+
|
|
92
|
+
def verify_server_final(self, server_final: bytes) -> None:
|
|
93
|
+
attrs = _attrs(server_final.decode())
|
|
94
|
+
if "e" in attrs:
|
|
95
|
+
raise errors.OperationalError(f"SCRAM authentication failed: {attrs['e']}")
|
|
96
|
+
try:
|
|
97
|
+
received = base64.b64decode(attrs["v"])
|
|
98
|
+
except (KeyError, ValueError) as exc:
|
|
99
|
+
raise errors.OperationalError("malformed SCRAM server-final-message") from exc
|
|
100
|
+
if not hmac.compare_digest(received, self._server_signature):
|
|
101
|
+
raise errors.OperationalError("SCRAM server signature does not match: the server could not be verified")
|