openlinktoken 2.0.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.
Files changed (72) hide show
  1. openlinktoken/__init__.py +15 -0
  2. openlinktoken/attributes/__init__.py +17 -0
  3. openlinktoken/attributes/attribute.py +26 -0
  4. openlinktoken/attributes/attribute_expression.py +232 -0
  5. openlinktoken/attributes/attribute_loader.py +48 -0
  6. openlinktoken/attributes/base_attribute.py +31 -0
  7. openlinktoken/attributes/combined_attribute.py +69 -0
  8. openlinktoken/attributes/general/__init__.py +17 -0
  9. openlinktoken/attributes/general/date_attribute.py +117 -0
  10. openlinktoken/attributes/general/decimal_attribute.py +86 -0
  11. openlinktoken/attributes/general/integer_attribute.py +78 -0
  12. openlinktoken/attributes/general/record_id_attribute.py +41 -0
  13. openlinktoken/attributes/general/string_attribute.py +55 -0
  14. openlinktoken/attributes/general/year_attribute.py +78 -0
  15. openlinktoken/attributes/person/__init__.py +25 -0
  16. openlinktoken/attributes/person/age_attribute.py +41 -0
  17. openlinktoken/attributes/person/birth_date_attribute.py +40 -0
  18. openlinktoken/attributes/person/birth_year_attribute.py +39 -0
  19. openlinktoken/attributes/person/canadian_postal_code_attribute.py +165 -0
  20. openlinktoken/attributes/person/first_name_attribute.py +112 -0
  21. openlinktoken/attributes/person/last_name_attribute.py +146 -0
  22. openlinktoken/attributes/person/postal_code_attribute.py +42 -0
  23. openlinktoken/attributes/person/sex_attribute.py +48 -0
  24. openlinktoken/attributes/person/social_security_number_attribute.py +119 -0
  25. openlinktoken/attributes/person/us_postal_code_attribute.py +104 -0
  26. openlinktoken/attributes/serializable_attribute.py +7 -0
  27. openlinktoken/attributes/utilities/__init__.py +7 -0
  28. openlinktoken/attributes/utilities/attribute_utilities.py +224 -0
  29. openlinktoken/attributes/validation/__init__.py +23 -0
  30. openlinktoken/attributes/validation/age_range_validator.py +41 -0
  31. openlinktoken/attributes/validation/attribute_validator.py +22 -0
  32. openlinktoken/attributes/validation/date_range_validator.py +111 -0
  33. openlinktoken/attributes/validation/not_in_validator.py +48 -0
  34. openlinktoken/attributes/validation/not_null_or_empty_validator.py +21 -0
  35. openlinktoken/attributes/validation/not_starts_with_validator.py +48 -0
  36. openlinktoken/attributes/validation/regex_validator.py +44 -0
  37. openlinktoken/attributes/validation/serializable_attribute_validator.py +26 -0
  38. openlinktoken/attributes/validation/year_range_validator.py +48 -0
  39. openlinktoken/ec_key_utils.py +207 -0
  40. openlinktoken/exchange_config.py +305 -0
  41. openlinktoken/exchange_jwe.py +107 -0
  42. openlinktoken/metadata.py +104 -0
  43. openlinktoken/tokens/__init__.py +19 -0
  44. openlinktoken/tokens/base_token_definition.py +48 -0
  45. openlinktoken/tokens/definitions/t1_token.py +39 -0
  46. openlinktoken/tokens/definitions/t2_token.py +39 -0
  47. openlinktoken/tokens/definitions/t3_token.py +39 -0
  48. openlinktoken/tokens/definitions/t4_token.py +37 -0
  49. openlinktoken/tokens/definitions/t5_token.py +37 -0
  50. openlinktoken/tokens/token.py +38 -0
  51. openlinktoken/tokens/token_definition.py +46 -0
  52. openlinktoken/tokens/token_generation_exception.py +16 -0
  53. openlinktoken/tokens/token_generator.py +201 -0
  54. openlinktoken/tokens/token_generator_result.py +33 -0
  55. openlinktoken/tokens/token_registry.py +46 -0
  56. openlinktoken/tokens/tokenizer/__init__.py +11 -0
  57. openlinktoken/tokens/tokenizer/passthrough_tokenizer.py +66 -0
  58. openlinktoken/tokens/tokenizer/sha256_tokenizer.py +72 -0
  59. openlinktoken/tokens/tokenizer/tokenizer.py +31 -0
  60. openlinktoken/tokentransformer/__init__.py +43 -0
  61. openlinktoken/tokentransformer/decrypt_token_transformer.py +83 -0
  62. openlinktoken/tokentransformer/encrypt_token_transformer.py +85 -0
  63. openlinktoken/tokentransformer/encryption_constants.py +23 -0
  64. openlinktoken/tokentransformer/hash_token_transformer.py +88 -0
  65. openlinktoken/tokentransformer/jwe_match_token_formatter.py +128 -0
  66. openlinktoken/tokentransformer/match_token_constants.py +57 -0
  67. openlinktoken/tokentransformer/no_operation_token_transformer.py +24 -0
  68. openlinktoken/tokentransformer/token_transformer.py +22 -0
  69. openlinktoken-2.0.0.dist-info/METADATA +21 -0
  70. openlinktoken-2.0.0.dist-info/RECORD +72 -0
  71. openlinktoken-2.0.0.dist-info/WHEEL +5 -0
  72. openlinktoken-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,207 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Shared utilities for ECDH key management used by initiate-exchange and
4
+ exchange-config consumers.
5
+
6
+ Note: The exchange-config workflow is Python-CLI only. The Java counterpart
7
+ (``EcKeyUtils.java``) is a placeholder stub that references this module.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ import stat
13
+ from datetime import date
14
+ from pathlib import Path, PureWindowsPath
15
+ from typing import Optional, Tuple
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ SUPPORTED_CURVES = ["P-256", "P-384", "P-521"]
20
+
21
+
22
+ def get_curve_class(curve: str):
23
+ """Return the cryptography EC curve class for the named curve.
24
+
25
+ Args:
26
+ curve: One of ``P-256``, ``P-384``, or ``P-521``.
27
+
28
+ Returns:
29
+ An EC curve instance from ``cryptography.hazmat.primitives.asymmetric.ec``.
30
+
31
+ Raises:
32
+ ValueError: If the curve name is not supported.
33
+ """
34
+ from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1, SECP384R1, SECP521R1
35
+
36
+ curve_map = {
37
+ "P-256": SECP256R1,
38
+ "P-384": SECP384R1,
39
+ "P-521": SECP521R1,
40
+ }
41
+
42
+ if curve not in curve_map:
43
+ raise ValueError(f"Unsupported curve '{curve}'. Valid options are: {', '.join(SUPPORTED_CURVES)}")
44
+
45
+ return curve_map[curve]()
46
+
47
+
48
+ def resolve_key_name(name: Optional[str]) -> str:
49
+ """Resolve and validate the requested key basename.
50
+
51
+ Args:
52
+ name: Caller-supplied name, or ``None`` / empty to use the default.
53
+
54
+ Returns:
55
+ A validated simple file basename.
56
+
57
+ Raises:
58
+ ValueError: If the supplied name contains path separators, traversal components, or drive prefixes.
59
+ """
60
+ if not name or not name.strip():
61
+ return f"openlinktoken-{date.today().isoformat()}"
62
+
63
+ candidate = name.strip()
64
+ if (
65
+ candidate in {".", ".."}
66
+ or "/" in candidate
67
+ or "\\" in candidate
68
+ or ":" in candidate
69
+ or candidate != Path(candidate).name
70
+ or PureWindowsPath(candidate).drive
71
+ ):
72
+ raise ValueError(
73
+ "Key name must be a simple file basename without path separators, traversal, or drive prefixes."
74
+ )
75
+
76
+ return candidate
77
+
78
+
79
+ def generate_key_pair(curve: str) -> Tuple[bytes, bytes]:
80
+ """Generate an EC key pair for the specified curve.
81
+
82
+ Args:
83
+ curve: One of ``P-256``, ``P-384``, or ``P-521``.
84
+
85
+ Returns:
86
+ A tuple of ``(private_pem, public_pem)`` as bytes.
87
+
88
+ Raises:
89
+ ValueError: If the curve name is unsupported.
90
+ """
91
+ from cryptography.hazmat.primitives.asymmetric.ec import generate_private_key
92
+ from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
93
+
94
+ curve_instance = get_curve_class(curve)
95
+ private_key = generate_private_key(curve_instance)
96
+
97
+ private_pem = private_key.private_bytes(
98
+ encoding=Encoding.PEM,
99
+ format=PrivateFormat.PKCS8,
100
+ encryption_algorithm=NoEncryption(),
101
+ )
102
+ public_pem = private_key.public_key().public_bytes(
103
+ encoding=Encoding.PEM,
104
+ format=PublicFormat.SubjectPublicKeyInfo,
105
+ )
106
+
107
+ return private_pem, public_pem
108
+
109
+
110
+ def _curve_name_from_private_key(private_key) -> str:
111
+ """Map a cryptography EC private key's curve to an Open Link Token curve name."""
112
+ curve_name_map = {
113
+ "secp256r1": "P-256",
114
+ "secp384r1": "P-384",
115
+ "secp521r1": "P-521",
116
+ }
117
+ curve_name = curve_name_map.get(private_key.curve.name)
118
+ if curve_name is None:
119
+ raise ValueError(f"Unsupported EC private key curve '{private_key.curve.name}'.")
120
+ return curve_name
121
+
122
+
123
+ def derive_public_key_from_private_pem(private_pem: bytes) -> Tuple[bytes, str]:
124
+ """Load an EC private key PEM and return its public key PEM plus Open Link Token curve name."""
125
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key
126
+
127
+ private_key = load_pem_private_key(private_pem, password=None)
128
+ public_pem = private_key.public_key().public_bytes(
129
+ encoding=Encoding.PEM,
130
+ format=PublicFormat.SubjectPublicKeyInfo,
131
+ )
132
+ return public_pem, _curve_name_from_private_key(private_key)
133
+
134
+
135
+ def ensure_directory(directory: Path) -> None:
136
+ """Ensure the directory exists, is not a symlink, and has 700 permissions.
137
+
138
+ Args:
139
+ directory: The directory path to create or validate.
140
+
141
+ Raises:
142
+ OSError: If the path is a symbolic link.
143
+ NotADirectoryError: If the path exists but is not a directory.
144
+ """
145
+ if not directory.exists():
146
+ directory.mkdir(parents=True, exist_ok=True)
147
+
148
+ if directory.is_symlink():
149
+ raise OSError(f"Key directory {directory} must not be a symbolic link.")
150
+
151
+ if not directory.is_dir():
152
+ raise NotADirectoryError(f"Key directory path {directory} exists and is not a directory.")
153
+
154
+ try:
155
+ os.chmod(directory, stat.S_IRWXU)
156
+ except (NotImplementedError, PermissionError) as error:
157
+ logger.warning("Could not set directory permissions on %s: %s", directory, error)
158
+
159
+
160
+ def write_key(path: Path, pem_bytes: bytes, mode: int, overwrite: bool = True) -> None:
161
+ """Write PEM bytes to a file using secure creation flags and set the specified permissions.
162
+
163
+ Args:
164
+ path: The target file path.
165
+ pem_bytes: The PEM-encoded key bytes.
166
+ mode: Octal file permission mode (for example, ``0o600``).
167
+ overwrite: When false, require exclusive creation instead of truncating an existing file.
168
+
169
+ Raises:
170
+ OSError: If the path is a symbolic link.
171
+ """
172
+ if path.is_symlink():
173
+ raise OSError(f"Key file path {path} must not be a symbolic link.")
174
+
175
+ flags = os.O_WRONLY | os.O_CREAT
176
+ flags |= os.O_TRUNC if overwrite else os.O_EXCL
177
+ if hasattr(os, "O_NOFOLLOW"):
178
+ flags |= os.O_NOFOLLOW
179
+
180
+ file_descriptor = os.open(path, flags, mode)
181
+ try:
182
+ with os.fdopen(file_descriptor, "wb") as file_handle:
183
+ file_handle.write(pem_bytes)
184
+ os.chmod(path, mode)
185
+ except (NotImplementedError, PermissionError) as error:
186
+ logger.warning("Could not set file permissions on %s: %s", path, error)
187
+
188
+
189
+ def public_key_fingerprint(public_pem: bytes) -> str:
190
+ """Compute a SHA-256 fingerprint of a SubjectPublicKeyInfo PEM public key."""
191
+ import hashlib
192
+
193
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_public_key
194
+
195
+ key = load_pem_public_key(public_pem)
196
+ der = key.public_bytes(encoding=Encoding.DER, format=PublicFormat.SubjectPublicKeyInfo)
197
+ digest = hashlib.sha256(der).hexdigest().upper()
198
+ return ":".join(digest[index : index + 2] for index in range(0, len(digest), 2))
199
+
200
+
201
+ def fingerprint_to_kid(fingerprint: str) -> str:
202
+ """Convert a SHA-256 public-key fingerprint into the portable JWE recipient id."""
203
+ normalized = fingerprint.strip()
204
+ if not normalized:
205
+ raise ValueError("Fingerprint must not be empty.")
206
+
207
+ return f"sha256:{normalized.lower().replace(':', '-')}"
@@ -0,0 +1,305 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Shared helpers for loading and consuming initiate-exchange config files.
4
+
5
+ Note: The exchange-config workflow is Python-CLI only. The Java counterpart
6
+ (``ExchangeConfig.java``) is a placeholder stub that references this module.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass
13
+ from datetime import date
14
+ from pathlib import Path
15
+ from typing import Any, Mapping
16
+
17
+ from cryptography.hazmat.primitives import hashes, serialization
18
+ from cryptography.hazmat.primitives.asymmetric import ec
19
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
20
+
21
+ from openlinktoken.ec_key_utils import derive_public_key_from_private_pem, public_key_fingerprint
22
+ from openlinktoken.exchange_jwe import decrypt_exchange_envelope, resolve_private_key_by_kid
23
+
24
+ SUPPORTED_EXCHANGE_CONFIG_VERSIONS = {1}
25
+ TRANSPORT_KEY_INFO = b"openlinktoken:token-encryption:v1"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LoadedExchangeConfig:
30
+ """Validated exchange-config envelope loaded from disk."""
31
+
32
+ path: Path
33
+ version: int
34
+ config: Mapping[str, Any]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ResolvedExchangeConfig:
39
+ """Resolved exchange-config inputs for consumer commands."""
40
+
41
+ path: Path
42
+ version: int
43
+ config: Mapping[str, Any]
44
+ payload: Mapping[str, Any]
45
+ private_key_pem: bytes
46
+ private_key_role: str
47
+ hashing_secret: bytes
48
+
49
+
50
+ def default_exchange_config_path() -> Path:
51
+ """Return the default date-based exchange-config path."""
52
+ return Path(f"./openlinktoken-{date.today().isoformat()}.exchange.json")
53
+
54
+
55
+ def load_exchange_config(
56
+ exchange_config_path: str | Path | None = None,
57
+ exchange_config_value: str | bytes | Mapping[str, Any] | None = None,
58
+ ) -> LoadedExchangeConfig:
59
+ """Load and validate an exchange-config envelope from disk or an in-memory value."""
60
+ if exchange_config_path and exchange_config_value is not None:
61
+ raise ValueError("Cannot combine an exchange config path and a direct exchange config value.")
62
+
63
+ if exchange_config_value is not None:
64
+ config_path = Path("<provided exchange config>")
65
+ config = _parse_exchange_config_value(exchange_config_value)
66
+ else:
67
+ config_path = Path(exchange_config_path) if exchange_config_path else default_exchange_config_path()
68
+ if not config_path.exists():
69
+ raise FileNotFoundError(
70
+ f"Exchange config '{config_path}' was not found. "
71
+ "Provide --exchange-config or run 'olt initiate-exchange' to create it."
72
+ )
73
+ if not config_path.is_file():
74
+ raise OSError(f"Exchange config path '{config_path}' is not a readable file.")
75
+
76
+ try:
77
+ config = json.loads(config_path.read_text(encoding="utf-8"))
78
+ except json.JSONDecodeError as error:
79
+ raise ValueError(f"Exchange config '{config_path}' is not valid JSON: {error}") from error
80
+
81
+ version = config.get("version")
82
+ if version not in SUPPORTED_EXCHANGE_CONFIG_VERSIONS:
83
+ supported_versions = ", ".join(str(item) for item in sorted(SUPPORTED_EXCHANGE_CONFIG_VERSIONS))
84
+ raise ValueError(f"Unsupported exchange config version '{version}'. Supported versions: {supported_versions}.")
85
+
86
+ return LoadedExchangeConfig(path=config_path, version=version, config=config)
87
+
88
+
89
+ def resolve_exchange_config(
90
+ exchange_config_path: str | Path | None,
91
+ private_key_pem: bytes,
92
+ ) -> ResolvedExchangeConfig:
93
+ """Load, validate, and decrypt an exchange config using the provided private key PEM."""
94
+ return resolve_loaded_exchange_config(load_exchange_config(exchange_config_path), private_key_pem)
95
+
96
+
97
+ def resolve_exchange_config_inputs(
98
+ exchange_config_path: str | Path | None = None,
99
+ private_key_path: str | Path | None = None,
100
+ private_key_env: str | None = None,
101
+ exchange_config_value: str | bytes | Mapping[str, Any] | None = None,
102
+ private_key_value: str | bytes | None = None,
103
+ ) -> ResolvedExchangeConfig:
104
+ """Resolve exchange-config inputs into decrypted exchange state."""
105
+ loaded_exchange = load_exchange_config(
106
+ exchange_config_path=exchange_config_path,
107
+ exchange_config_value=exchange_config_value,
108
+ )
109
+ private_key_pem = resolve_exchange_config_private_key(
110
+ loaded_exchange,
111
+ private_key_path=private_key_path,
112
+ private_key_env=private_key_env,
113
+ private_key_value=private_key_value,
114
+ )
115
+ return resolve_loaded_exchange_config(loaded_exchange, private_key_pem)
116
+
117
+
118
+ def resolve_exchange_config_private_key(
119
+ exchange_config: LoadedExchangeConfig,
120
+ private_key_path: str | Path | None = None,
121
+ private_key_env: str | None = None,
122
+ private_key_value: str | bytes | None = None,
123
+ openlinktoken_dir: Path | None = None,
124
+ environment: Mapping[str, str] | None = None,
125
+ ) -> bytes:
126
+ """Resolve private-key PEM bytes for a loaded exchange config."""
127
+ provided_private_key_inputs = [
128
+ private_key_path is not None,
129
+ private_key_env is not None,
130
+ private_key_value is not None,
131
+ ]
132
+ if sum(provided_private_key_inputs) > 1:
133
+ raise ValueError("Cannot combine private key path, environment variable, and direct private key value inputs.")
134
+
135
+ if private_key_path:
136
+ return _read_private_key_path(private_key_path)
137
+
138
+ if private_key_env:
139
+ return _read_private_key_env(private_key_env, environment=environment)
140
+
141
+ if private_key_value is not None:
142
+ return _read_private_key_value(private_key_value)
143
+
144
+ resolved_openlinktoken_dir = openlinktoken_dir if openlinktoken_dir else Path.home() / ".openlinktoken"
145
+ for kid in _recipient_kids(exchange_config.config):
146
+ try:
147
+ return resolve_private_key_by_kid(resolved_openlinktoken_dir, kid)
148
+ except FileNotFoundError:
149
+ continue
150
+
151
+ raise FileNotFoundError(
152
+ f"No private key matching this exchange config was found in {resolved_openlinktoken_dir}. "
153
+ "Provide a private key path, environment variable, or direct value."
154
+ )
155
+
156
+
157
+ def resolve_loaded_exchange_config(
158
+ exchange_config: LoadedExchangeConfig, private_key_pem: bytes
159
+ ) -> ResolvedExchangeConfig:
160
+ """Decrypt a validated exchange-config envelope using the provided private key PEM."""
161
+ try:
162
+ payload = json.loads(decrypt_exchange_envelope(exchange_config.config, private_key_pem))
163
+ except Exception as error:
164
+ raise ValueError(f"Failed to decrypt exchange config '{exchange_config.path}': {error}") from error
165
+
166
+ if not isinstance(payload, dict):
167
+ raise ValueError(f"Exchange config '{exchange_config.path}' decrypted to an invalid payload.")
168
+
169
+ return ResolvedExchangeConfig(
170
+ path=exchange_config.path,
171
+ version=exchange_config.version,
172
+ config=exchange_config.config,
173
+ payload=payload,
174
+ private_key_pem=private_key_pem,
175
+ private_key_role=_resolve_private_key_role(private_key_pem, payload),
176
+ hashing_secret=_decode_hashing_secret(payload),
177
+ )
178
+
179
+
180
+ def derive_transport_encryption_key(exchange: ResolvedExchangeConfig) -> bytes:
181
+ """Derive the shared 32-byte transport key defined by the exchange config contract."""
182
+ sender_public_key = exchange.payload.get("senderPublicKey")
183
+ recipient_public_key = exchange.payload.get("recipientPublicKey")
184
+ exchange_id = exchange.payload.get("exchangeId")
185
+ if not sender_public_key or not recipient_public_key:
186
+ raise ValueError(
187
+ "This exchange config does not include the public keys required for token encryption/decryption. "
188
+ "Regenerate it with 'olt initiate-exchange'."
189
+ )
190
+ if not exchange_id:
191
+ raise ValueError("Exchange config payload is missing exchangeId required for key derivation.")
192
+
193
+ other_public_key_pem = recipient_public_key if exchange.private_key_role == "sender" else sender_public_key
194
+
195
+ try:
196
+ private_key = serialization.load_pem_private_key(exchange.private_key_pem, password=None)
197
+ public_key = serialization.load_pem_public_key(other_public_key_pem.encode("utf-8"))
198
+ shared_secret = private_key.exchange(ec.ECDH(), public_key)
199
+ return HKDF(
200
+ algorithm=hashes.SHA256(),
201
+ length=32,
202
+ salt=exchange_id.encode("utf-8"),
203
+ info=TRANSPORT_KEY_INFO,
204
+ ).derive(shared_secret)
205
+ except Exception as error:
206
+ raise ValueError(f"Failed to derive the transport encryption key: {error}") from error
207
+
208
+
209
+ def _read_private_key_path(private_key_path: str | Path) -> bytes:
210
+ """Read private-key PEM bytes from disk."""
211
+ path = Path(private_key_path)
212
+ if not path.exists():
213
+ raise FileNotFoundError(f"Private key file not found: {path}")
214
+ if not path.is_file():
215
+ raise OSError(f"Private key path '{path}' is not a readable file.")
216
+ return path.read_bytes()
217
+
218
+
219
+ def _read_private_key_env(
220
+ private_key_env: str,
221
+ environment: Mapping[str, str] | None = None,
222
+ ) -> bytes:
223
+ """Read private-key PEM bytes from a named environment variable."""
224
+ resolved_environment = os.environ if environment is None else environment
225
+ value = resolved_environment.get(private_key_env)
226
+ if value is None or not value.strip():
227
+ raise ValueError(f"Environment variable {private_key_env} does not contain non-empty private key data.")
228
+ return value.encode("utf-8")
229
+
230
+
231
+ def _read_private_key_value(private_key_value: str | bytes) -> bytes:
232
+ """Read private-key PEM bytes from a direct in-memory value."""
233
+ if isinstance(private_key_value, bytes):
234
+ if not private_key_value.strip():
235
+ raise ValueError("Direct private key value does not contain non-empty private key data.")
236
+ return private_key_value
237
+
238
+ if not private_key_value.strip():
239
+ raise ValueError("Direct private key value does not contain non-empty private key data.")
240
+ return private_key_value.encode("utf-8")
241
+
242
+
243
+ def _parse_exchange_config_value(exchange_config_value: str | bytes | Mapping[str, Any]) -> Mapping[str, Any]:
244
+ """Parse an in-memory exchange-config payload."""
245
+ if isinstance(exchange_config_value, Mapping):
246
+ return dict(exchange_config_value)
247
+
248
+ try:
249
+ raw_value = (
250
+ exchange_config_value.decode("utf-8") if isinstance(exchange_config_value, bytes) else exchange_config_value
251
+ )
252
+ parsed_value = json.loads(raw_value)
253
+ except UnicodeDecodeError as error:
254
+ raise ValueError(f"Provided exchange config value is not valid UTF-8 JSON: {error}") from error
255
+ except json.JSONDecodeError as error:
256
+ raise ValueError(f"Provided exchange config value is not valid JSON: {error}") from error
257
+
258
+ if not isinstance(parsed_value, dict):
259
+ raise ValueError("Provided exchange config value must decode to a JSON object.")
260
+
261
+ return parsed_value
262
+
263
+
264
+ def _recipient_kids(exchange_config: Mapping[str, Any]) -> list[str]:
265
+ """Extract recipient key identifiers from an exchange-config envelope."""
266
+ recipients = exchange_config.get("recipients")
267
+ if not isinstance(recipients, list) or not recipients:
268
+ raise ValueError("Exchange config is missing recipient entries needed for private-key resolution.")
269
+
270
+ kids: list[str] = []
271
+ for recipient in recipients:
272
+ if not isinstance(recipient, dict):
273
+ continue
274
+ header = recipient.get("header")
275
+ if isinstance(header, dict) and header.get("kid"):
276
+ kids.append(header["kid"])
277
+
278
+ if not kids:
279
+ raise ValueError("Exchange config recipients do not include key identifiers for private-key resolution.")
280
+ return kids
281
+
282
+
283
+ def _resolve_private_key_role(private_pem: bytes, payload: Mapping[str, Any]) -> str:
284
+ public_pem, _ = derive_public_key_from_private_pem(private_pem)
285
+ fingerprint = public_key_fingerprint(public_pem)
286
+ if fingerprint == payload.get("senderKeyFingerprint"):
287
+ return "sender"
288
+ if fingerprint == payload.get("recipientKeyFingerprint"):
289
+ return "recipient"
290
+ raise ValueError("Resolved private key does not match the sender or recipient fingerprint in the exchange config.")
291
+
292
+
293
+ def _decode_hashing_secret(payload: Mapping[str, Any]) -> bytes:
294
+ encoding = payload.get("hashingSecretEncoding")
295
+ value = payload.get("hashingSecret")
296
+ if encoding != "base64url":
297
+ raise ValueError(f"Unsupported hashingSecretEncoding '{encoding}'.")
298
+ if not isinstance(value, str) or not value:
299
+ raise ValueError("Exchange config payload is missing hashingSecret.")
300
+
301
+ padding = "=" * (-len(value) % 4)
302
+ try:
303
+ return base64.urlsafe_b64decode(value + padding)
304
+ except Exception as error:
305
+ raise ValueError(f"hashingSecret is not valid base64url data: {error}") from error
@@ -0,0 +1,107 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Shared helpers for building and decrypting exchange-config JWE envelopes.
4
+
5
+ Note: The exchange-config workflow is Python-CLI only. The Java counterpart
6
+ (``ExchangeJwe.java``) is a placeholder stub that references this module.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any, Mapping
13
+
14
+ from jwcrypto import jwe, jwk
15
+
16
+ from openlinktoken.ec_key_utils import fingerprint_to_kid, public_key_fingerprint
17
+
18
+ EXCHANGE_JWE_VERSION = 1
19
+ EXCHANGE_JWE_TYPE = "openlinktoken-exchange+jwe"
20
+ EXCHANGE_JWE_CONTENT_TYPE = "application/openlinktoken-exchange+json"
21
+ EXCHANGE_JWE_ENCRYPTION = "A256GCM"
22
+ EXCHANGE_JWE_RECIPIENT_ALGORITHM = "ECDH-ES+A256KW"
23
+
24
+
25
+ def build_exchange_envelope(
26
+ exchange_name: str,
27
+ hashing_secret: bytes,
28
+ sender_public_pem: bytes,
29
+ recipient_public_pem: bytes,
30
+ curve: str,
31
+ created_at: str,
32
+ exchange_id: str,
33
+ ) -> dict[str, Any]:
34
+ """Build a multi-recipient JWE exchange envelope."""
35
+ payload = {
36
+ "exchangeName": exchange_name,
37
+ "hashingSecret": _base64url_encode(hashing_secret),
38
+ "hashingSecretEncoding": "base64url",
39
+ "senderKeyFingerprint": public_key_fingerprint(sender_public_pem),
40
+ "recipientKeyFingerprint": public_key_fingerprint(recipient_public_pem),
41
+ "senderPublicKey": sender_public_pem.decode("utf-8"),
42
+ "recipientPublicKey": recipient_public_pem.decode("utf-8"),
43
+ "curve": curve,
44
+ "createdAt": created_at,
45
+ "exchangeId": exchange_id,
46
+ }
47
+ protected_header = {
48
+ "typ": EXCHANGE_JWE_TYPE,
49
+ "cty": EXCHANGE_JWE_CONTENT_TYPE,
50
+ "enc": EXCHANGE_JWE_ENCRYPTION,
51
+ }
52
+
53
+ envelope = jwe.JWE(
54
+ json.dumps(payload, separators=(",", ":")).encode("utf-8"),
55
+ protected=json.dumps(protected_header, separators=(",", ":")),
56
+ )
57
+ envelope.add_recipient(
58
+ jwk.JWK.from_pem(sender_public_pem),
59
+ header=json.dumps(_recipient_header(sender_public_pem), separators=(",", ":")),
60
+ )
61
+ envelope.add_recipient(
62
+ jwk.JWK.from_pem(recipient_public_pem),
63
+ header=json.dumps(_recipient_header(recipient_public_pem), separators=(",", ":")),
64
+ )
65
+
66
+ serialized = json.loads(envelope.serialize(compact=False))
67
+ serialized["version"] = EXCHANGE_JWE_VERSION
68
+ return serialized
69
+
70
+
71
+ def decrypt_exchange_envelope(exchange_config: Mapping[str, Any], private_pem: bytes) -> bytes:
72
+ """Decrypt an exchange JWE envelope with a matching private key PEM."""
73
+ envelope = jwe.JWE()
74
+ envelope.deserialize(json.dumps(dict(exchange_config)))
75
+ envelope.decrypt(jwk.JWK.from_pem(private_pem))
76
+ return bytes(envelope.payload)
77
+
78
+
79
+ def resolve_private_key_by_kid(openlinktoken_dir: Path, kid: str) -> bytes:
80
+ """Resolve a private key by matching a fingerprint-derived recipient ``kid``."""
81
+ for public_key_path in sorted(openlinktoken_dir.glob("*.public.pem")):
82
+ public_pem = public_key_path.read_bytes()
83
+ if fingerprint_to_kid(public_key_fingerprint(public_pem)) != kid:
84
+ continue
85
+
86
+ basename = public_key_path.name[: -len(".public.pem")]
87
+ private_key_path = public_key_path.with_name(f"{basename}.private.pem")
88
+ if not private_key_path.exists():
89
+ raise FileNotFoundError(
90
+ f"Resolved recipient kid '{kid}' to {public_key_path}, but {private_key_path} does not exist."
91
+ )
92
+ return private_key_path.read_bytes()
93
+
94
+ raise FileNotFoundError(f"No private key found for recipient kid '{kid}' in {openlinktoken_dir}.")
95
+
96
+
97
+ def _base64url_encode(value: bytes) -> str:
98
+ """Encode bytes as unpadded base64url text."""
99
+ return base64.urlsafe_b64encode(value).decode("utf-8").rstrip("=")
100
+
101
+
102
+ def _recipient_header(public_pem: bytes) -> dict[str, str]:
103
+ """Build the per-recipient JOSE header for the provided public key."""
104
+ return {
105
+ "alg": EXCHANGE_JWE_RECIPIENT_ALGORITHM,
106
+ "kid": fingerprint_to_kid(public_key_fingerprint(public_pem)),
107
+ }