cradlewise-client 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.
@@ -0,0 +1,57 @@
1
+ """Lightweight clients for Cradlewise local and cloud transports."""
2
+
3
+ from .certificates import (
4
+ BrokerCertificateError,
5
+ materialize_credentials,
6
+ pin_server_ca,
7
+ validate_server_chain,
8
+ )
9
+ from .cloud import (
10
+ CloudAccountClient,
11
+ CloudApiError,
12
+ CloudAuthenticationError,
13
+ CloudProvisioningError,
14
+ CradleAccount,
15
+ ProvisionedCredentials,
16
+ )
17
+ from .commands import (
18
+ CommandError,
19
+ CommandUnavailable,
20
+ CradlewiseCommandHandler,
21
+ build_desired,
22
+ shadow_payload,
23
+ )
24
+ from .local import (
25
+ LocalConnectionError,
26
+ LocalCradleClient,
27
+ LocalCradleUpdate,
28
+ LocalCredentials,
29
+ )
30
+ from .remote import REMOTE_MQTT_ENDPOINT, RemoteCradleClient
31
+ from .state import CradlewiseStateStore, normalize_device_state
32
+
33
+ __all__ = [
34
+ "CommandError",
35
+ "CommandUnavailable",
36
+ "CloudAccountClient",
37
+ "CloudApiError",
38
+ "CloudAuthenticationError",
39
+ "CloudProvisioningError",
40
+ "BrokerCertificateError",
41
+ "CradleAccount",
42
+ "CradlewiseCommandHandler",
43
+ "LocalConnectionError",
44
+ "LocalCradleClient",
45
+ "LocalCradleUpdate",
46
+ "LocalCredentials",
47
+ "REMOTE_MQTT_ENDPOINT",
48
+ "RemoteCradleClient",
49
+ "ProvisionedCredentials",
50
+ "CradlewiseStateStore",
51
+ "normalize_device_state",
52
+ "materialize_credentials",
53
+ "pin_server_ca",
54
+ "build_desired",
55
+ "shadow_payload",
56
+ "validate_server_chain",
57
+ ]
@@ -0,0 +1,214 @@
1
+ """Safe broker certificate discovery and credential materialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import socket
7
+ import ssl
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from cryptography import x509
12
+ from cryptography.exceptions import InvalidSignature
13
+ from cryptography.hazmat.primitives import serialization
14
+ from cryptography.x509.oid import NameOID
15
+
16
+ from .cloud import ProvisionedCredentials
17
+ from .local import MQTT_PORT, LocalCredentials
18
+
19
+ MQTT_BROKER_COMMON_NAME = "aws.greengrass.clientdevices.mqtt.Moquette"
20
+ MQTT_CA_COMMON_NAME = "Greengrass Core CA"
21
+
22
+
23
+ class BrokerCertificateError(RuntimeError):
24
+ """Raised when the local broker chain cannot be safely pinned."""
25
+
26
+
27
+ def fetch_server_chain(
28
+ host: str,
29
+ client_certificate_path: Path,
30
+ client_private_key_path: Path,
31
+ *,
32
+ port: int = MQTT_PORT,
33
+ ) -> list[bytes]:
34
+ """Fetch the untrusted broker chain using the provisioned client identity."""
35
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
36
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
37
+ context.check_hostname = False
38
+ context.verify_mode = ssl.CERT_NONE
39
+ context.load_cert_chain(client_certificate_path, client_private_key_path)
40
+ try:
41
+ with socket.create_connection((host, port), timeout=10) as raw_socket:
42
+ with context.wrap_socket(raw_socket, server_hostname=host) as tls_socket:
43
+ chain = _unverified_chain(tls_socket)
44
+ except BrokerCertificateError:
45
+ raise
46
+ except (OSError, ssl.SSLError) as exc:
47
+ raise BrokerCertificateError(
48
+ f"could not inspect local MQTT broker TLS: {exc}"
49
+ ) from exc
50
+ if len(chain) < 2:
51
+ raise BrokerCertificateError(
52
+ "local MQTT broker did not return a certificate chain"
53
+ )
54
+ return chain
55
+
56
+
57
+ def validate_server_chain(
58
+ certificate_data: list[bytes],
59
+ host: str,
60
+ *,
61
+ now: datetime | None = None,
62
+ ) -> x509.Certificate:
63
+ """Validate the expected Greengrass broker shape before trusting its CA."""
64
+ certificates = [_load_certificate(value) for value in certificate_data]
65
+ leaf = certificates[0]
66
+ issuers = [
67
+ certificate
68
+ for certificate in certificates[1:]
69
+ if certificate.subject == leaf.issuer
70
+ ]
71
+ if len(issuers) != 1:
72
+ raise BrokerCertificateError(
73
+ "local MQTT broker chain has no unique issuer for its leaf"
74
+ )
75
+ ca = issuers[0]
76
+ if _common_name(leaf) != MQTT_BROKER_COMMON_NAME:
77
+ raise BrokerCertificateError(
78
+ "local MQTT broker certificate has an unexpected common name"
79
+ )
80
+ if _common_name(ca) != MQTT_CA_COMMON_NAME or ca.subject != ca.issuer:
81
+ raise BrokerCertificateError(
82
+ "local MQTT broker issuer is not a self-issued Greengrass Core CA"
83
+ )
84
+ try:
85
+ constraints = ca.extensions.get_extension_for_class(x509.BasicConstraints)
86
+ except x509.ExtensionNotFound as exc:
87
+ raise BrokerCertificateError(
88
+ "Greengrass Core CA is missing basic constraints"
89
+ ) from exc
90
+ if not constraints.value.ca:
91
+ raise BrokerCertificateError(
92
+ "Greengrass Core CA certificate is not marked as a CA"
93
+ )
94
+
95
+ current = now or datetime.now(timezone.utc)
96
+ for certificate in (leaf, ca):
97
+ if not (
98
+ certificate.not_valid_before_utc
99
+ <= current
100
+ <= certificate.not_valid_after_utc
101
+ ):
102
+ raise BrokerCertificateError(
103
+ "local MQTT broker chain contains an expired or future certificate"
104
+ )
105
+ try:
106
+ leaf.verify_directly_issued_by(ca)
107
+ ca.verify_directly_issued_by(ca)
108
+ except (InvalidSignature, ValueError, TypeError) as exc:
109
+ raise BrokerCertificateError(
110
+ "local MQTT broker chain signature validation failed"
111
+ ) from exc
112
+
113
+ try:
114
+ alternative_names = leaf.extensions.get_extension_for_class(
115
+ x509.SubjectAlternativeName
116
+ ).value
117
+ except x509.ExtensionNotFound as exc:
118
+ raise BrokerCertificateError(
119
+ "local MQTT broker certificate has no subject alternative name"
120
+ ) from exc
121
+ try:
122
+ expected_ip = ipaddress.ip_address(host)
123
+ except ValueError:
124
+ valid_host = host in alternative_names.get_values_for_type(x509.DNSName)
125
+ else:
126
+ valid_host = expected_ip in alternative_names.get_values_for_type(
127
+ x509.IPAddress
128
+ )
129
+ if not valid_host:
130
+ raise BrokerCertificateError(
131
+ f"local MQTT broker certificate is not valid for {host}"
132
+ )
133
+ return ca
134
+
135
+
136
+ def pin_server_ca(
137
+ host: str,
138
+ client_certificate_path: Path,
139
+ client_private_key_path: Path,
140
+ ) -> str:
141
+ """Fetch, validate, and return the crib's current broker CA as PEM."""
142
+ chain = fetch_server_chain(
143
+ host,
144
+ client_certificate_path,
145
+ client_private_key_path,
146
+ )
147
+ ca = validate_server_chain(chain, host)
148
+ return ca.public_bytes(serialization.Encoding.PEM).decode("ascii")
149
+
150
+
151
+ def materialize_credentials(
152
+ directory: Path,
153
+ credentials: ProvisionedCredentials,
154
+ *,
155
+ server_ca_certificate: str | None = None,
156
+ ) -> LocalCredentials:
157
+ """Write one runtime-only credential bundle for Paho MQTT."""
158
+ directory.mkdir(parents=True, exist_ok=True)
159
+ directory.chmod(0o700)
160
+ values = {
161
+ "ca.pem": credentials.group_ca_certificate,
162
+ "client_cert.pem": credentials.client_certificate,
163
+ "client_key.pem": credentials.client_private_key,
164
+ "device_id": credentials.device_id,
165
+ }
166
+ if server_ca_certificate is not None:
167
+ values["server_ca.pem"] = server_ca_certificate
168
+ for name, value in values.items():
169
+ path = directory / name
170
+ path.write_text(value, encoding="utf-8")
171
+ path.chmod(0o600)
172
+ return LocalCredentials.from_directory(directory)
173
+
174
+
175
+ def _load_certificate(value: bytes) -> x509.Certificate:
176
+ if value.startswith(b"-----BEGIN"):
177
+ return x509.load_pem_x509_certificate(value)
178
+ return x509.load_der_x509_certificate(value)
179
+
180
+
181
+ def _unverified_chain(tls_socket: ssl.SSLSocket) -> list[bytes]:
182
+ """Return the peer chain across supported Python SSL API versions."""
183
+ get_chain = getattr(tls_socket, "get_unverified_chain", None)
184
+ if get_chain is None:
185
+ ssl_object = getattr(tls_socket, "_sslobj", None)
186
+ get_chain = getattr(ssl_object, "get_unverified_chain", None)
187
+ if get_chain is None:
188
+ raise BrokerCertificateError(
189
+ "Python does not support retrieving the broker chain"
190
+ )
191
+ return [_certificate_bytes(certificate) for certificate in get_chain()]
192
+
193
+
194
+ def _certificate_bytes(certificate: object) -> bytes:
195
+ if isinstance(certificate, bytes):
196
+ return certificate
197
+ public_bytes = getattr(certificate, "public_bytes", None)
198
+ if public_bytes is None:
199
+ raise BrokerCertificateError(
200
+ "Python returned an unsupported broker certificate object"
201
+ )
202
+ value = public_bytes()
203
+ if isinstance(value, str):
204
+ return value.encode("ascii")
205
+ if isinstance(value, bytes):
206
+ return value
207
+ raise BrokerCertificateError(
208
+ "Python returned an unsupported broker certificate encoding"
209
+ )
210
+
211
+
212
+ def _common_name(certificate: x509.Certificate) -> str | None:
213
+ values = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
214
+ return values[0].value if values else None
@@ -0,0 +1,421 @@
1
+ """Cradlewise account discovery and certificate provisioning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import json
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ import boto3
12
+ import requests
13
+ from botocore.auth import SigV4Auth
14
+ from botocore.awsrequest import AWSRequest
15
+ from botocore.credentials import Credentials
16
+ from botocore.exceptions import BotoCoreError, ClientError
17
+ from pycognito import Cognito
18
+
19
+ USER_POOL_ID = "us-east-1_hRGLsOxun"
20
+ CLIENT_ID = "4jnn2bbtroa3e6ra73dc8m8luh"
21
+ # Public native-app configuration embedded in the distributed Android APK.
22
+ # This is not an account credential and cannot be kept confidential by a
23
+ # mobile client.
24
+ CLIENT_SECRET = "qfmc9tv70upcacajmhpl4a9n3orehteo93icbng0fljkt6916em"
25
+ IDENTITY_POOL_ID = "us-east-1:53b70db5-7440-4ecf-8dac-d6202eb6c1d2"
26
+ REGION = "us-east-1"
27
+ API_ENDPOINT = "https://backend.cradlewise.com/prod-latest"
28
+ S3_BUCKET = "cradlewise-device-certs"
29
+ REQUEST_TIMEOUT_SECONDS = 10
30
+
31
+
32
+ class CloudAuthenticationError(RuntimeError):
33
+ """Raised when account credentials cannot be authenticated."""
34
+
35
+
36
+ class CloudApiError(RuntimeError):
37
+ """Raised when a Cradlewise cloud request fails."""
38
+
39
+
40
+ class CloudProvisioningError(CloudApiError):
41
+ """Raised when device certificates cannot be provisioned."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class CradleAccount:
46
+ """One baby profile and its associated cradle."""
47
+
48
+ baby_id: int
49
+ cradle_id: str
50
+ name: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ProvisionedCredentials:
55
+ """PEM material returned for local and AWS IoT MQTT connections."""
56
+
57
+ device_id: str
58
+ client_certificate: str
59
+ client_private_key: str
60
+ group_ca_certificate: str
61
+
62
+
63
+ def sign_request(
64
+ method: str,
65
+ url: str,
66
+ credentials: Credentials,
67
+ *,
68
+ body: str | None = None,
69
+ headers: dict[str, str] | None = None,
70
+ ) -> dict[str, str]:
71
+ """Sign one Cradlewise API request with temporary AWS credentials."""
72
+ request = AWSRequest(
73
+ method=method,
74
+ url=url,
75
+ data=body,
76
+ headers=headers or {},
77
+ )
78
+ SigV4Auth(credentials, "execute-api", REGION).add_auth(request)
79
+ return dict(request.headers)
80
+
81
+
82
+ class CloudAccountClient:
83
+ """Blocking account client intended for an executor in async consumers."""
84
+
85
+ def __init__(
86
+ self,
87
+ *,
88
+ email: str,
89
+ password: str,
90
+ session: requests.Session | None = None,
91
+ ) -> None:
92
+ if not email.strip() or not password:
93
+ raise CloudAuthenticationError("email and password are required")
94
+ self.email = email.strip()
95
+ self._password = password
96
+ self._session = session or requests.Session()
97
+ self._credentials: Credentials | None = None
98
+ self._raw_credentials: dict[str, Any] | None = None
99
+
100
+ def authenticate(self) -> None:
101
+ """Authenticate with Cognito and cache temporary AWS credentials."""
102
+ try:
103
+ cognito = Cognito(
104
+ USER_POOL_ID,
105
+ CLIENT_ID,
106
+ username=self.email,
107
+ client_secret=CLIENT_SECRET,
108
+ )
109
+ cognito.authenticate(password=self._password)
110
+ if not cognito.id_token:
111
+ raise CloudAuthenticationError(
112
+ "Cradlewise authentication returned no ID token"
113
+ )
114
+ identity = boto3.client("cognito-identity", region_name=REGION)
115
+ logins = {
116
+ f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}": (cognito.id_token)
117
+ }
118
+ identity_response = identity.get_id(
119
+ IdentityPoolId=IDENTITY_POOL_ID,
120
+ Logins=logins,
121
+ )
122
+ credentials_response = identity.get_credentials_for_identity(
123
+ IdentityId=identity_response["IdentityId"],
124
+ Logins=logins,
125
+ )
126
+ raw = credentials_response["Credentials"]
127
+ self._credentials = Credentials(
128
+ access_key=raw["AccessKeyId"],
129
+ secret_key=raw["SecretKey"],
130
+ token=raw["SessionToken"],
131
+ )
132
+ self._raw_credentials = raw
133
+ except CloudAuthenticationError:
134
+ raise
135
+ except ClientError as exc:
136
+ error = exc.response.get("Error", {})
137
+ if error.get("Code") in {
138
+ "NotAuthorizedException",
139
+ "PasswordResetRequiredException",
140
+ "UserNotConfirmedException",
141
+ "UserNotFoundException",
142
+ }:
143
+ raise CloudAuthenticationError(
144
+ "Cradlewise account authentication failed"
145
+ ) from exc
146
+ raise CloudApiError(
147
+ "Cradlewise authentication service is unavailable"
148
+ ) from exc
149
+ except (BotoCoreError, requests.RequestException, OSError) as exc:
150
+ raise CloudApiError(
151
+ "Cradlewise authentication service is unavailable"
152
+ ) from exc
153
+ except Exception as exc:
154
+ raise CloudAuthenticationError(
155
+ "Cradlewise account authentication failed"
156
+ ) from exc
157
+
158
+ def list_accounts(self) -> list[CradleAccount]:
159
+ """List paired cradle profiles for the authenticated account."""
160
+ payload = self._request_json(
161
+ "GET",
162
+ f"{API_ENDPOINT}/accounts?emailId={quote(self.email)}",
163
+ )
164
+ accounts = payload.get("accounts")
165
+ if not isinstance(accounts, list):
166
+ raise CloudApiError("Cradlewise accounts response has no account list")
167
+ result = []
168
+ for account in accounts:
169
+ if not isinstance(account, dict) or not account.get("cradle_id"):
170
+ continue
171
+ try:
172
+ baby_id = int(account["baby_id"])
173
+ except (KeyError, TypeError, ValueError):
174
+ continue
175
+ result.append(
176
+ CradleAccount(
177
+ baby_id=baby_id,
178
+ cradle_id=str(account["cradle_id"]).strip(),
179
+ name=str(account.get("name") or "Cradlewise"),
180
+ )
181
+ )
182
+ return result
183
+
184
+ def get_cradle_state(self, cradle_id: str) -> dict[str, Any]:
185
+ """Fetch the cloud shadow state through the Cradlewise REST API."""
186
+ return self._request_json(
187
+ "GET",
188
+ f"{API_ENDPOINT}/cradles/{cradle_id}/state",
189
+ )
190
+
191
+ def get_cradle_ip(self, cradle_id: str) -> str | None:
192
+ """Resolve the last reported local address, preferring onlineStatus v2."""
193
+ v2_error: CloudApiError | None = None
194
+ try:
195
+ payload = self._request_json(
196
+ "GET",
197
+ f"{API_ENDPOINT}/cradles/{cradle_id}/onlineStatus/v2",
198
+ )
199
+ state_message = payload.get("state_message")
200
+ if isinstance(state_message, str):
201
+ parsed = json.loads(state_message)
202
+ address = _local_ip(parsed)
203
+ if address:
204
+ return address
205
+ except (CloudApiError, json.JSONDecodeError, TypeError) as exc:
206
+ v2_error = (
207
+ exc
208
+ if isinstance(exc, CloudApiError)
209
+ else CloudApiError("invalid onlineStatus v2 state message")
210
+ )
211
+
212
+ try:
213
+ payload = self._request_json(
214
+ "GET",
215
+ f"{API_ENDPOINT}/cradles/{cradle_id}/onlineStatus",
216
+ )
217
+ address = payload.get("local_ip")
218
+ return (
219
+ address.strip()
220
+ if isinstance(address, str) and address.strip()
221
+ else None
222
+ )
223
+ except CloudApiError as exc:
224
+ if v2_error is not None:
225
+ raise CloudApiError(
226
+ f"Cradlewise online status failed: v2={v2_error}; v1={exc}"
227
+ ) from exc
228
+ raise
229
+
230
+ def provision_credentials(
231
+ self,
232
+ account: CradleAccount,
233
+ *,
234
+ app_version: str = "2.55.5",
235
+ timezone: str = "UTC",
236
+ country: str = "US",
237
+ ) -> ProvisionedCredentials:
238
+ """Register a Home Assistant client and download its MQTT certificate."""
239
+ body = json.dumps(
240
+ {
241
+ "email_id": self.email,
242
+ "baby_id": account.baby_id,
243
+ "fcm_token": "home_assistant",
244
+ "device": {
245
+ "registration_date": datetime.date.today().isoformat(),
246
+ "app_version": app_version,
247
+ "country": country,
248
+ "os": "android",
249
+ "device_name": "Home Assistant",
250
+ "os_version": "14",
251
+ "timezone": timezone,
252
+ "type": "phone",
253
+ "resolution": "{1440,3120}",
254
+ },
255
+ },
256
+ separators=(",", ":"),
257
+ )
258
+ payload = self._request_json(
259
+ "POST",
260
+ f"{API_ENDPOINT}/cradles/pairedUsers/v3",
261
+ body=body,
262
+ headers={"Content-Type": "application/json"},
263
+ )
264
+ if payload.get("errorType") == "API_FAILED":
265
+ raise CloudProvisioningError(
266
+ str(payload.get("message") or "certificate provisioning failed")
267
+ )
268
+ device_config = payload.get("device_config")
269
+ if not isinstance(device_config, dict):
270
+ raise CloudProvisioningError(
271
+ "Cradlewise certificate response has no device configuration"
272
+ )
273
+ group_ca = device_config.get("group_ca_cert")
274
+ object_keys = device_config.get("s3_object_keys")
275
+ if not isinstance(group_ca, str) or not group_ca.strip():
276
+ raise CloudProvisioningError(
277
+ "Cradlewise certificate response has no group CA"
278
+ )
279
+ if not isinstance(object_keys, list) or not object_keys:
280
+ raise CloudProvisioningError(
281
+ "Cradlewise certificate response has no certificate objects"
282
+ )
283
+
284
+ raw = self._require_raw_credentials()
285
+ s3 = boto3.client(
286
+ "s3",
287
+ region_name=REGION,
288
+ aws_access_key_id=raw["AccessKeyId"],
289
+ aws_secret_access_key=raw["SecretKey"],
290
+ aws_session_token=raw["SessionToken"],
291
+ )
292
+ pem_objects = [self._download_s3_text(s3, str(key)) for key in object_keys]
293
+ client_certificate = next(
294
+ (value for value in pem_objects if "BEGIN CERTIFICATE" in value),
295
+ None,
296
+ )
297
+ private_key = next(
298
+ (value for value in pem_objects if "PRIVATE KEY" in value),
299
+ None,
300
+ )
301
+ if client_certificate is None or private_key is None:
302
+ raise CloudProvisioningError(
303
+ "Cradlewise certificate objects did not contain a certificate and key"
304
+ )
305
+
306
+ device_id = device_config.get("device_id")
307
+ if not isinstance(device_id, str) or not device_id.strip():
308
+ first_key = str(object_keys[0])
309
+ device_id = first_key.rsplit("/", 1)[-1].removesuffix(".pem")
310
+ if not device_id:
311
+ raise CloudProvisioningError(
312
+ "Cradlewise certificate response has no device identity"
313
+ )
314
+ return ProvisionedCredentials(
315
+ device_id=device_id.strip(),
316
+ client_certificate=client_certificate,
317
+ client_private_key=private_key,
318
+ group_ca_certificate=group_ca,
319
+ )
320
+
321
+ def _request_json(
322
+ self,
323
+ method: str,
324
+ url: str,
325
+ *,
326
+ body: str | None = None,
327
+ headers: dict[str, str] | None = None,
328
+ ) -> dict[str, Any]:
329
+ credentials = self._require_credentials()
330
+ response = self._send(method, url, credentials, body=body, headers=headers)
331
+ if response.status_code in {401, 403}:
332
+ self.authenticate()
333
+ credentials = self._require_credentials()
334
+ response = self._send(
335
+ method,
336
+ url,
337
+ credentials,
338
+ body=body,
339
+ headers=headers,
340
+ )
341
+ try:
342
+ response.raise_for_status()
343
+ except requests.HTTPError as exc:
344
+ raise CloudApiError(
345
+ f"Cradlewise cloud request failed: HTTP {response.status_code}"
346
+ ) from exc
347
+ try:
348
+ payload = response.json()
349
+ except requests.JSONDecodeError as exc:
350
+ raise CloudApiError("Cradlewise cloud response was not JSON") from exc
351
+ if not isinstance(payload, dict):
352
+ raise CloudApiError("Cradlewise cloud response was not an object")
353
+ return payload
354
+
355
+ def _send(
356
+ self,
357
+ method: str,
358
+ url: str,
359
+ credentials: Credentials,
360
+ *,
361
+ body: str | None,
362
+ headers: dict[str, str] | None,
363
+ ) -> requests.Response:
364
+ signed_headers = sign_request(
365
+ method,
366
+ url,
367
+ credentials,
368
+ body=body,
369
+ headers=headers,
370
+ )
371
+ try:
372
+ return self._session.request(
373
+ method,
374
+ url,
375
+ headers=signed_headers,
376
+ data=body,
377
+ timeout=REQUEST_TIMEOUT_SECONDS,
378
+ )
379
+ except requests.RequestException as exc:
380
+ raise CloudApiError("Cradlewise cloud request failed") from exc
381
+
382
+ def _require_credentials(self) -> Credentials:
383
+ if self._credentials is None:
384
+ self.authenticate()
385
+ if self._credentials is None:
386
+ raise CloudAuthenticationError(
387
+ "Cradlewise authentication returned no AWS credentials"
388
+ )
389
+ return self._credentials
390
+
391
+ def _require_raw_credentials(self) -> dict[str, Any]:
392
+ self._require_credentials()
393
+ if self._raw_credentials is None:
394
+ raise CloudAuthenticationError(
395
+ "Cradlewise authentication returned no raw AWS credentials"
396
+ )
397
+ return self._raw_credentials
398
+
399
+ @staticmethod
400
+ def _download_s3_text(s3: Any, key: str) -> str:
401
+ errors = []
402
+ for candidate in (key, f"public/{key}"):
403
+ try:
404
+ response = s3.get_object(Bucket=S3_BUCKET, Key=candidate)
405
+ value = response["Body"].read().decode("utf-8")
406
+ if value.strip():
407
+ return value
408
+ except Exception as exc:
409
+ errors.append(exc)
410
+ raise CloudProvisioningError(
411
+ f"could not download provisioned certificate object {key}"
412
+ ) from errors[-1]
413
+
414
+
415
+ def _local_ip(payload: dict[str, Any]) -> str | None:
416
+ address = (
417
+ payload.get("info", {}).get("connectivity", {}).get("localIP")
418
+ if isinstance(payload.get("info"), dict)
419
+ else None
420
+ )
421
+ return address.strip() if isinstance(address, str) and address.strip() else None