deepayment 0.1.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.
deepayment/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """Python SDK for the merchant open API.
2
+
3
+ Only business methods, DTOs, webhook verification and error types are public.
4
+ Signing, digest and body envelope internals live in _protocol / _request and
5
+ are not exported.
6
+ """
7
+
8
+ from .client import Client
9
+ from .errors import (
10
+ APIError,
11
+ ConfigError,
12
+ RequestError,
13
+ ResponseError,
14
+ ResponseTooLargeError,
15
+ SDKError,
16
+ TransportError,
17
+ WebhookError,
18
+ )
19
+ from .types import (
20
+ Balance,
21
+ CreatePaymentReq,
22
+ CreatePayoutReq,
23
+ EXTERNAL_STATUSES,
24
+ Failure,
25
+ OrderAction,
26
+ PaymentOrder,
27
+ PaymentWebhook,
28
+ PayoutOrder,
29
+ PayoutReceipt,
30
+ PayoutWebhook,
31
+ STATUS_CANCELED,
32
+ STATUS_EXPIRED,
33
+ STATUS_FAILED,
34
+ STATUS_PENDING,
35
+ STATUS_PROCESSING,
36
+ STATUS_SUCCEEDED,
37
+ USDRate,
38
+ )
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = [
43
+ "Client",
44
+ "CreatePaymentReq", "CreatePayoutReq",
45
+ "PaymentOrder", "PayoutOrder", "PayoutReceipt", "Balance", "USDRate",
46
+ "PaymentWebhook", "PayoutWebhook", "OrderAction", "Failure",
47
+ "SDKError", "ConfigError", "RequestError", "APIError",
48
+ "ResponseError", "ResponseTooLargeError", "TransportError", "WebhookError",
49
+ "STATUS_PENDING", "STATUS_PROCESSING", "STATUS_SUCCEEDED",
50
+ "STATUS_FAILED", "STATUS_EXPIRED", "STATUS_CANCELED", "EXTERNAL_STATUSES",
51
+ "__version__",
52
+ ]
@@ -0,0 +1,502 @@
1
+ """Merchant request signing and platform webhook verification.
2
+
3
+ The protocol specification (signature, body encryption, webhook) is the source
4
+ of truth; cross-language consistency is guaranteed by the shared test vectors.
5
+
6
+ Depends only on the standard library and PyNaCl.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import re
15
+ import secrets
16
+ import time
17
+ import uuid
18
+ from dataclasses import dataclass, field
19
+
20
+ import nacl.exceptions
21
+ import nacl.public
22
+ import nacl.signing
23
+
24
+ from .errors import SDKError
25
+
26
+ HEADER_MERCHANT_ACCESS_KEY = "Merchant-Access-Key"
27
+ HEADER_WEBHOOK_EVENT_ID = "Webhook-Event-Id"
28
+ HEADER_CONTENT_ENCRYPTION = "Content-Encryption"
29
+ HEADER_CONTENT_DIGEST = "Content-Digest"
30
+ HEADER_IDEMPOTENCY_KEY = "Idempotency-Key"
31
+ HEADER_SIGNATURE_INPUT = "Signature-Input"
32
+ HEADER_SIGNATURE = "Signature"
33
+
34
+ SIGNATURE_LABEL_MERCHANT = "merchant"
35
+ SIGNATURE_LABEL_PLATFORM = "platform"
36
+ SIGNATURE_ALG_ED25519 = "ed25519"
37
+ CONTENT_ENCRYPTION = "sealedbox-v1-x25519-xsalsa20poly1305"
38
+
39
+ MAX_PLAIN_BODY_BYTES = 1 << 20
40
+ MAX_WIRE_BODY_BYTES = 2 << 20
41
+ MAX_SIGNATURE_LIFETIME = 300 # seconds
42
+ X25519_PUBLIC_KEY_SIZE = 32
43
+ X25519_PRIVATE_KEY_SIZE = 32
44
+ ED25519_SIGNATURE_SIZE = 64
45
+ ED25519_PUBLIC_KEY_SIZE = 32
46
+
47
+ # Fixed by the protocol; tuple order is the signature base order
48
+ MERCHANT_WRITE_COVERED = (
49
+ "@method",
50
+ "@path",
51
+ "content-type",
52
+ "content-encryption",
53
+ "content-digest",
54
+ "idempotency-key",
55
+ "merchant-access-key",
56
+ )
57
+ MERCHANT_READ_COVERED = (
58
+ "@method",
59
+ "@path",
60
+ "@query",
61
+ "merchant-access-key",
62
+ )
63
+ PLATFORM_COVERED = (
64
+ "@method",
65
+ "@path",
66
+ "@query",
67
+ "content-type",
68
+ "content-digest",
69
+ "webhook-event-id",
70
+ )
71
+
72
+ ALLOWED_READ_QUERY_FIELDS = frozenset({
73
+ "orderNo", "merchantOrderNo", "currency", "payMethod", "status",
74
+ "startTime", "endTime", "page", "pageSize", "consentNo",
75
+ "subscriptionPaymentNo", "planNo", "merchantPlanNo", "merchantCustomerNo",
76
+ "subscriptionNo", "merchantSubscriptionNo", "invoiceNo",
77
+ })
78
+
79
+ _CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
80
+
81
+
82
+ class ProtocolError(SDKError):
83
+ """Base class for protocol-level errors."""
84
+
85
+
86
+ class InvalidHeaderError(ProtocolError):
87
+ """Signature headers, request shape or parameters are invalid."""
88
+
89
+
90
+ class InvalidEnvelopeError(ProtocolError):
91
+ """Body envelope is malformed or cannot be decrypted."""
92
+
93
+
94
+ class InvalidSignatureError(ProtocolError):
95
+ """Signature is malformed or verification failed."""
96
+
97
+
98
+ class ExpiredSignatureError(ProtocolError):
99
+ """Signature time window is invalid or has expired."""
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class SignatureParams:
104
+ """Signature parameters of the fixed RFC 9421 profile."""
105
+
106
+ label: str
107
+ covered: tuple[str, ...]
108
+ created: int
109
+ expires: int
110
+ nonce: str
111
+ alg: str = SIGNATURE_ALG_ED25519
112
+ key_id: str = "" # only platform webhooks carry keyid; merchant requests never do
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class BodyEnvelope:
117
+ """Encrypted envelope of a merchant POST wire body."""
118
+
119
+ version: int
120
+ alg: str
121
+ key_id: str
122
+ ciphertext: str
123
+
124
+ def to_json_bytes(self) -> bytes:
125
+ return json.dumps(
126
+ {
127
+ "version": self.version,
128
+ "alg": self.alg,
129
+ "keyId": self.key_id,
130
+ "ciphertext": self.ciphertext,
131
+ },
132
+ separators=(",", ":"),
133
+ ).encode()
134
+
135
+
136
+ def new_merchant_write_signature_params(nonce: str, now: int | None = None) -> SignatureParams:
137
+ created = int(time.time()) if now is None else int(now)
138
+ return SignatureParams(
139
+ label=SIGNATURE_LABEL_MERCHANT,
140
+ covered=MERCHANT_WRITE_COVERED,
141
+ created=created,
142
+ expires=created + MAX_SIGNATURE_LIFETIME,
143
+ nonce=nonce,
144
+ )
145
+
146
+
147
+ def new_merchant_read_signature_params(nonce: str, now: int | None = None) -> SignatureParams:
148
+ created = int(time.time()) if now is None else int(now)
149
+ return SignatureParams(
150
+ label=SIGNATURE_LABEL_MERCHANT,
151
+ covered=MERCHANT_READ_COVERED,
152
+ created=created,
153
+ expires=created + MAX_SIGNATURE_LIFETIME,
154
+ nonce=nonce,
155
+ )
156
+
157
+
158
+ def new_platform_signature_params(key_id: str, nonce: str, now: int | None = None) -> SignatureParams:
159
+ created = int(time.time()) if now is None else int(now)
160
+ return SignatureParams(
161
+ label=SIGNATURE_LABEL_PLATFORM,
162
+ covered=PLATFORM_COVERED,
163
+ created=created,
164
+ expires=created + MAX_SIGNATURE_LIFETIME,
165
+ nonce=nonce,
166
+ key_id=key_id,
167
+ )
168
+
169
+
170
+ def new_nonce() -> str:
171
+ return str(uuid.UUID(bytes=secrets.token_bytes(16), version=4))
172
+
173
+
174
+ def _quote(value: str) -> str:
175
+ """Protocol values are ASCII without escapes, so JSON quoting yields the RFC 9421 form."""
176
+ return json.dumps(value, ensure_ascii=False)
177
+
178
+
179
+ def signature_input_value(params: SignatureParams) -> str:
180
+ """The Signature-Input value after the label; also the @signature-params line of the base."""
181
+ components = " ".join(_quote(c) for c in params.covered)
182
+ value = (
183
+ f"({components})"
184
+ f";created={params.created}"
185
+ f";expires={params.expires}"
186
+ f";nonce={_quote(params.nonce)}"
187
+ )
188
+ if params.label == SIGNATURE_LABEL_PLATFORM:
189
+ value += f";keyid={_quote(params.key_id)}"
190
+ return value + f";alg={_quote(params.alg)}"
191
+
192
+
193
+ def signature_input_header(params: SignatureParams) -> str:
194
+ validate_signature_params(params, params.created)
195
+ return f"{params.label}={signature_input_value(params)}"
196
+
197
+
198
+ def _component_value(
199
+ component: str, *, method: str, path: str, raw_query: str, headers: dict[str, str]
200
+ ) -> str:
201
+ if component == "@method":
202
+ return method
203
+ if component == "@path":
204
+ return path or "/"
205
+ if component == "@query":
206
+ return "?" + raw_query
207
+ name = {
208
+ "content-type": "Content-Type",
209
+ "content-encryption": HEADER_CONTENT_ENCRYPTION,
210
+ "content-digest": HEADER_CONTENT_DIGEST,
211
+ "idempotency-key": HEADER_IDEMPOTENCY_KEY,
212
+ "merchant-access-key": HEADER_MERCHANT_ACCESS_KEY,
213
+ "webhook-event-id": HEADER_WEBHOOK_EVENT_ID,
214
+ }.get(component)
215
+ if name is None:
216
+ raise InvalidHeaderError(f"unknown covered component: {component}")
217
+ value = headers.get(name)
218
+ if value is None:
219
+ lowered = {k.lower(): v for k, v in headers.items()}
220
+ value = lowered.get(name.lower())
221
+ if value is None or not _valid_header_value(value.strip()):
222
+ raise InvalidHeaderError(f"missing or invalid header: {name}")
223
+ return value.strip()
224
+
225
+
226
+ def signature_base(
227
+ params: SignatureParams,
228
+ *,
229
+ method: str,
230
+ path: str,
231
+ raw_query: str = "",
232
+ headers: dict[str, str] | None = None,
233
+ ) -> bytes:
234
+ """Build the RFC 9421 signature base."""
235
+ if not params.covered:
236
+ raise InvalidHeaderError("empty covered components")
237
+ headers = headers or {}
238
+ lines = [
239
+ f"{_quote(c)}: "
240
+ + _component_value(c, method=method, path=path, raw_query=raw_query, headers=headers)
241
+ for c in params.covered
242
+ ]
243
+ lines.append(f'{_quote("@signature-params")}: {signature_input_value(params)}')
244
+ return "\n".join(lines).encode()
245
+
246
+
247
+ def _valid_header_value(value: str) -> bool:
248
+ return bool(value) and value.strip() == value and "\r" not in value and "\n" not in value
249
+
250
+
251
+ def valid_uuid_v4(value: str) -> bool:
252
+ """Lowercase UUID v4; uppercase hex digits are rejected."""
253
+ if len(value) != 36:
254
+ return False
255
+ for i, ch in enumerate(value):
256
+ if i in (8, 13, 18, 23):
257
+ if ch != "-":
258
+ return False
259
+ elif i == 14:
260
+ if ch != "4":
261
+ return False
262
+ elif i == 19:
263
+ if ch not in "89ab":
264
+ return False
265
+ elif not ("0" <= ch <= "9" or "a" <= ch <= "f"):
266
+ return False
267
+ return True
268
+
269
+
270
+ def validate_idempotency_key(value: str) -> None:
271
+ if not valid_uuid_v4(value):
272
+ raise InvalidHeaderError("Idempotency-Key must be a lowercase UUID v4")
273
+
274
+
275
+ def validate_webhook_event_id(value: str) -> None:
276
+ if not value.startswith("evt_") or len(value) != 30:
277
+ raise InvalidHeaderError("invalid Webhook-Event-Id")
278
+ if any(ch not in _CROCKFORD32 for ch in value[4:]):
279
+ raise InvalidHeaderError("invalid Webhook-Event-Id")
280
+
281
+
282
+ def validate_merchant_read_query(raw_query: str) -> None:
283
+ """A signed GET query may carry only allowed locator fields, one value per key."""
284
+ if not raw_query:
285
+ return
286
+ if re.search(r"%(?![0-9A-Fa-f]{2})", raw_query):
287
+ raise InvalidHeaderError("query contains malformed percent-encoding")
288
+ from urllib.parse import parse_qs
289
+
290
+ values = parse_qs(raw_query, keep_blank_values=True, strict_parsing=False)
291
+ for key, items in values.items():
292
+ if key not in ALLOWED_READ_QUERY_FIELDS or len(items) != 1 or not items[0].strip():
293
+ raise InvalidHeaderError(f"query field not allowed: {key}")
294
+
295
+
296
+ def validate_freshness(params: SignatureParams, now: int) -> None:
297
+ if (
298
+ params.created <= 0
299
+ or params.expires <= 0
300
+ or params.expires <= params.created
301
+ or params.expires - params.created > MAX_SIGNATURE_LIFETIME
302
+ ):
303
+ raise ExpiredSignatureError("invalid signature lifetime")
304
+ if now < params.created - MAX_SIGNATURE_LIFETIME or now > params.expires:
305
+ raise ExpiredSignatureError("signature expired")
306
+
307
+
308
+ def validate_signature_params(params: SignatureParams, now: int) -> None:
309
+ if not params.label or params.alg != SIGNATURE_ALG_ED25519 or not valid_uuid_v4(params.nonce):
310
+ raise InvalidHeaderError("invalid signature params")
311
+ if params.label == SIGNATURE_LABEL_PLATFORM:
312
+ if not _valid_header_value(params.key_id):
313
+ raise InvalidHeaderError("platform signature requires keyid")
314
+ elif params.label != SIGNATURE_LABEL_MERCHANT:
315
+ raise InvalidHeaderError("unknown signature label")
316
+ if not _covered_components_allowed(params.label, params.covered):
317
+ raise InvalidHeaderError("covered components not allowed")
318
+ validate_freshness(params, now)
319
+
320
+
321
+ def _covered_components_allowed(label: str, covered: tuple[str, ...]) -> bool:
322
+ if label == SIGNATURE_LABEL_MERCHANT:
323
+ return covered in (MERCHANT_WRITE_COVERED, MERCHANT_READ_COVERED)
324
+ if label == SIGNATURE_LABEL_PLATFORM:
325
+ return covered == PLATFORM_COVERED
326
+ return False
327
+
328
+
329
+ def parse_signature_input(value: str, label: str, covered: tuple[str, ...]) -> SignatureParams:
330
+ prefix = f"{label}=("
331
+ if not value.startswith(prefix):
332
+ raise InvalidHeaderError("bad Signature-Input label")
333
+ closing = value.find(")")
334
+ if closing < 0 or closing + 1 >= len(value) or value[closing + 1] != ";":
335
+ raise InvalidHeaderError("bad Signature-Input components")
336
+ raw_components = value[len(prefix):closing].split()
337
+ if len(raw_components) != len(covered):
338
+ raise InvalidHeaderError("covered components mismatch")
339
+ for raw, want in zip(raw_components, covered):
340
+ if json.loads(raw) != want:
341
+ raise InvalidHeaderError("covered components mismatch")
342
+
343
+ parts = value[closing + 2:].split(";")
344
+ expected = 5 if label == SIGNATURE_LABEL_PLATFORM else 4
345
+ if len(parts) != expected:
346
+ raise InvalidHeaderError("bad Signature-Input params")
347
+ got: dict[str, str] = {}
348
+ for part in parts:
349
+ key, sep, raw = part.partition("=")
350
+ if not sep or not key or key in got:
351
+ raise InvalidHeaderError("bad Signature-Input params")
352
+ got[key] = raw
353
+ if got.keys() - {"created", "expires", "nonce", "keyid", "alg"}:
354
+ raise InvalidHeaderError("unknown Signature-Input param")
355
+ if "keyid" in got and label != SIGNATURE_LABEL_PLATFORM:
356
+ raise InvalidHeaderError("merchant signature must not carry keyid")
357
+ try:
358
+ return SignatureParams(
359
+ label=label,
360
+ covered=covered,
361
+ created=int(got["created"]),
362
+ expires=int(got["expires"]),
363
+ nonce=json.loads(got["nonce"]),
364
+ alg=json.loads(got["alg"]),
365
+ key_id=json.loads(got["keyid"]) if "keyid" in got else "",
366
+ )
367
+ except (KeyError, ValueError) as exc:
368
+ raise InvalidHeaderError("bad Signature-Input params") from exc
369
+
370
+
371
+ def parse_merchant_write_signature_input(value: str) -> SignatureParams:
372
+ return parse_signature_input(value, SIGNATURE_LABEL_MERCHANT, MERCHANT_WRITE_COVERED)
373
+
374
+
375
+ def parse_merchant_read_signature_input(value: str) -> SignatureParams:
376
+ return parse_signature_input(value, SIGNATURE_LABEL_MERCHANT, MERCHANT_READ_COVERED)
377
+
378
+
379
+ def parse_platform_signature_input(value: str) -> SignatureParams:
380
+ return parse_signature_input(value, SIGNATURE_LABEL_PLATFORM, PLATFORM_COVERED)
381
+
382
+
383
+ def content_digest_sha256(body: bytes) -> str:
384
+ """RFC 9530 Content-Digest value with sha-256."""
385
+ return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
386
+
387
+
388
+ def verify_content_digest(body: bytes, header: str) -> bool:
389
+ return secrets.compare_digest(header, content_digest_sha256(body))
390
+
391
+
392
+ def _signing_key(private_key: bytes) -> nacl.signing.SigningKey:
393
+ # 64-byte keys are seed||pub; PyNaCl takes only the 32-byte seed
394
+ if len(private_key) == 64:
395
+ return nacl.signing.SigningKey(private_key[:32])
396
+ if len(private_key) == 32:
397
+ return nacl.signing.SigningKey(private_key)
398
+ raise InvalidSignatureError("ed25519 private key must be 32 or 64 bytes")
399
+
400
+
401
+ def sign_ed25519(private_key: bytes, label: str, base: bytes) -> str:
402
+ if not label or not base:
403
+ raise InvalidSignatureError("empty label or signature base")
404
+ signature = _signing_key(private_key).sign(base).signature
405
+ return signature_header(label, signature)
406
+
407
+
408
+ def verify_ed25519(public_key: bytes, base: bytes, signature: bytes) -> None:
409
+ if (
410
+ len(public_key) != ED25519_PUBLIC_KEY_SIZE
411
+ or not base
412
+ or len(signature) != ED25519_SIGNATURE_SIZE
413
+ ):
414
+ raise InvalidSignatureError("invalid ed25519 verify input")
415
+ try:
416
+ nacl.signing.VerifyKey(public_key).verify(base, signature)
417
+ except nacl.exceptions.BadSignatureError as exc:
418
+ raise InvalidSignatureError("signature verification failed") from exc
419
+
420
+
421
+ def signature_header(label: str, signature: bytes) -> str:
422
+ if not _valid_header_value(label) or len(signature) != ED25519_SIGNATURE_SIZE:
423
+ raise InvalidSignatureError("invalid signature header input")
424
+ return f"{label}=:" + base64.b64encode(signature).decode() + ":"
425
+
426
+
427
+ def parse_signature(value: str, label: str) -> bytes:
428
+ if not _valid_header_value(label):
429
+ raise InvalidSignatureError("invalid signature label")
430
+ prefix = f"{label}=:"
431
+ if not value.startswith(prefix) or not value.endswith(":"):
432
+ raise InvalidSignatureError("malformed Signature header")
433
+ try:
434
+ signature = base64.b64decode(value[len(prefix):-1], validate=True)
435
+ except (ValueError, base64.binascii.Error) as exc:
436
+ raise InvalidSignatureError("malformed Signature header") from exc
437
+ if len(signature) != ED25519_SIGNATURE_SIZE:
438
+ raise InvalidSignatureError("malformed Signature header")
439
+ return signature
440
+
441
+
442
+ def seal_body_envelope(plaintext: bytes, public_key: bytes, key_id: str) -> bytes:
443
+ """Seal a POST plaintext body to the platform X25519 public key; returns the wire body."""
444
+ if not plaintext or len(plaintext) > MAX_PLAIN_BODY_BYTES:
445
+ raise InvalidEnvelopeError("invalid plaintext size")
446
+ if len(public_key) != X25519_PUBLIC_KEY_SIZE or not _valid_header_value(key_id):
447
+ raise InvalidEnvelopeError("invalid platform body key")
448
+ ciphertext = nacl.public.SealedBox(nacl.public.PublicKey(public_key)).encrypt(plaintext)
449
+ return BodyEnvelope(
450
+ version=1,
451
+ alg=CONTENT_ENCRYPTION,
452
+ key_id=key_id,
453
+ ciphertext=base64.b64encode(ciphertext).decode(),
454
+ ).to_json_bytes()
455
+
456
+
457
+ def decode_body_envelope(wire_body: bytes) -> BodyEnvelope:
458
+ if not wire_body or len(wire_body) > MAX_WIRE_BODY_BYTES:
459
+ raise InvalidEnvelopeError("invalid envelope size")
460
+ try:
461
+ raw = json.loads(wire_body)
462
+ except ValueError as exc:
463
+ raise InvalidEnvelopeError("envelope is not valid json") from exc
464
+ if not isinstance(raw, dict) or raw.keys() != {"version", "alg", "keyId", "ciphertext"}:
465
+ raise InvalidEnvelopeError("unexpected envelope fields")
466
+ envelope = BodyEnvelope(
467
+ version=raw["version"], alg=raw["alg"], key_id=raw["keyId"], ciphertext=raw["ciphertext"]
468
+ )
469
+ if (
470
+ envelope.version != 1
471
+ or envelope.alg != CONTENT_ENCRYPTION
472
+ or not _valid_header_value(str(envelope.key_id))
473
+ or not envelope.ciphertext
474
+ ):
475
+ raise InvalidEnvelopeError("invalid envelope")
476
+ try:
477
+ base64.b64decode(envelope.ciphertext, validate=True)
478
+ except (ValueError, base64.binascii.Error) as exc:
479
+ raise InvalidEnvelopeError("invalid envelope ciphertext") from exc
480
+ return envelope
481
+
482
+
483
+ def peek_body_envelope_key_id(wire_body: bytes) -> str:
484
+ try:
485
+ return decode_body_envelope(wire_body).key_id
486
+ except InvalidEnvelopeError:
487
+ return ""
488
+
489
+
490
+ def open_body_envelope(wire_body: bytes, public_key: bytes, private_key: bytes) -> tuple[bytes, str]:
491
+ """Open an envelope with the platform X25519 key pair; returns (plaintext, keyId)."""
492
+ if len(public_key) != X25519_PUBLIC_KEY_SIZE or len(private_key) != X25519_PRIVATE_KEY_SIZE:
493
+ raise InvalidEnvelopeError("invalid platform body key pair")
494
+ envelope = decode_body_envelope(wire_body)
495
+ ciphertext = base64.b64decode(envelope.ciphertext, validate=True)
496
+ try:
497
+ plaintext = nacl.public.SealedBox(nacl.public.PrivateKey(private_key)).decrypt(ciphertext)
498
+ except nacl.exceptions.CryptoError as exc:
499
+ raise InvalidEnvelopeError("envelope decrypt failed") from exc
500
+ if not plaintext or len(plaintext) > MAX_PLAIN_BODY_BYTES:
501
+ raise InvalidEnvelopeError("invalid plaintext size")
502
+ return plaintext, envelope.key_id
deepayment/_request.py ADDED
@@ -0,0 +1,92 @@
1
+ """Signed request construction.
2
+
3
+ build_write / build_read only produce headers and the wire body and never send
4
+ anything, so they can be tested directly against the protocol vectors.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from urllib.parse import urlsplit
11
+
12
+ from . import _protocol as p
13
+ from .errors import RequestError
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BuiltRequest:
18
+ method: str
19
+ headers: dict[str, str]
20
+ body: bytes = b""
21
+
22
+
23
+ def build_write(
24
+ *,
25
+ endpoint_url: str,
26
+ access_key: str,
27
+ idempotency_key: str,
28
+ body_key_id: str,
29
+ body_public_key: bytes,
30
+ merchant_private_key: bytes,
31
+ body: bytes,
32
+ nonce: str,
33
+ now: int | None = None,
34
+ ) -> BuiltRequest:
35
+ """Signed POST; the digest covers the sealed body, not the plaintext."""
36
+ if not (endpoint_url and access_key and idempotency_key and body_key_id and body):
37
+ raise RequestError("sdk: invalid signed request")
38
+ p.validate_idempotency_key(idempotency_key)
39
+
40
+ parts = urlsplit(endpoint_url)
41
+ if parts.query:
42
+ raise RequestError("sdk: signed POST query is not allowed")
43
+
44
+ wire_body = p.seal_body_envelope(body, body_public_key, body_key_id)
45
+ params = p.new_merchant_write_signature_params(nonce, now)
46
+
47
+ headers = {
48
+ "Content-Type": "application/json",
49
+ p.HEADER_CONTENT_ENCRYPTION: p.CONTENT_ENCRYPTION,
50
+ p.HEADER_CONTENT_DIGEST: p.content_digest_sha256(wire_body),
51
+ p.HEADER_IDEMPOTENCY_KEY: idempotency_key,
52
+ p.HEADER_MERCHANT_ACCESS_KEY: access_key,
53
+ }
54
+ headers[p.HEADER_SIGNATURE_INPUT] = p.signature_input_header(params)
55
+ base = p.signature_base(
56
+ params, method="POST", path=parts.path or "/", raw_query="", headers=headers
57
+ )
58
+ headers[p.HEADER_SIGNATURE] = p.sign_ed25519(
59
+ merchant_private_key, p.SIGNATURE_LABEL_MERCHANT, base
60
+ )
61
+ return BuiltRequest(method="POST", headers=headers, body=wire_body)
62
+
63
+
64
+ def build_read(
65
+ *,
66
+ endpoint_url: str,
67
+ access_key: str,
68
+ merchant_private_key: bytes,
69
+ nonce: str,
70
+ now: int | None = None,
71
+ ) -> BuiltRequest:
72
+ """Signed GET: no body and no idempotency key; the signature covers method, path, query and access key."""
73
+ if not endpoint_url or not access_key:
74
+ raise RequestError("sdk: invalid signed request")
75
+
76
+ parts = urlsplit(endpoint_url)
77
+ p.validate_merchant_read_query(parts.query)
78
+ params = p.new_merchant_read_signature_params(nonce, now)
79
+
80
+ headers = {p.HEADER_MERCHANT_ACCESS_KEY: access_key}
81
+ headers[p.HEADER_SIGNATURE_INPUT] = p.signature_input_header(params)
82
+ base = p.signature_base(
83
+ params,
84
+ method="GET",
85
+ path=parts.path or "/",
86
+ raw_query=parts.query,
87
+ headers=headers,
88
+ )
89
+ headers[p.HEADER_SIGNATURE] = p.sign_ed25519(
90
+ merchant_private_key, p.SIGNATURE_LABEL_MERCHANT, base
91
+ )
92
+ return BuiltRequest(method="GET", headers=headers)