Skip to content

Commit 4669a9f

Browse files
committed
ENG-10670 | oraclecloud - add OCI native request signer (signing.py)
1 parent 6c5e7af commit 4669a9f

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""OCI native request signing (HTTP Signature, RSA-SHA256).
2+
3+
Signs requests for Oracle Cloud Infrastructure's Object Storage native REST API without
4+
the OCI Python SDK. OCI follows the IETF "Signing HTTP Messages" draft: a set of request
5+
headers is serialized into a signing string and signed with the customer's RSA private key
6+
(RSA-SHA256, PKCS#1 v1.5). ``date``, ``(request-target)`` and ``host`` are signed on every
7+
request; body methods also sign ``content-length``, ``content-type`` and ``x-content-sha256``.
8+
9+
See https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm
10+
"""
11+
12+
import base64
13+
import hashlib
14+
import logging
15+
from email.utils import formatdate
16+
from urllib.parse import urlsplit
17+
18+
from cryptography.exceptions import UnsupportedAlgorithm
19+
from cryptography.hazmat.primitives import hashes, serialization
20+
from cryptography.hazmat.primitives.asymmetric import padding
21+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
22+
23+
logger = logging.getLogger(__name__)
24+
25+
ALGORITHM = "rsa-sha256"
26+
SIGNATURE_VERSION = "1"
27+
DEFAULT_CONTENT_TYPE = "application/octet-stream"
28+
29+
BASE_SIGNED_HEADERS = ["date", "(request-target)", "host"]
30+
BODY_SIGNED_HEADERS = ["content-length", "content-type", "x-content-sha256"]
31+
BODY_METHODS = {"PUT", "POST", "PATCH"}
32+
33+
34+
def load_private_key(private_key_content: str | bytes) -> RSAPrivateKey:
35+
"""Load a PEM RSA private key, raising ValueError if it can't be parsed."""
36+
if isinstance(private_key_content, str):
37+
private_key_content = private_key_content.encode("ascii")
38+
try:
39+
return serialization.load_pem_private_key(private_key_content, password=None)
40+
except (ValueError, TypeError, UnsupportedAlgorithm) as exc:
41+
raise ValueError(f"Could not load OCI private key: {exc}") from exc
42+
43+
44+
def _compute_body_sha256(body: bytes) -> str:
45+
"""Base64-encoded SHA-256 of the body (OCI's x-content-sha256 value)."""
46+
return base64.b64encode(hashlib.sha256(body).digest()).decode("ascii")
47+
48+
49+
def _build_signing_string(
50+
header_names: list[str],
51+
headers: dict[str, str],
52+
method: str,
53+
path: str,
54+
host: str,
55+
) -> str:
56+
"""Render the named headers as newline-joined ``name: value`` lines.
57+
58+
``(request-target)`` and ``host`` come from the request line; everything else is
59+
looked up in *headers*. Order follows *header_names*.
60+
"""
61+
lines = []
62+
for name in header_names:
63+
if name == "(request-target)":
64+
lines.append(f"(request-target): {method.lower()} {path}")
65+
elif name == "host":
66+
lines.append(f"host: {host}")
67+
else:
68+
lines.append(f"{name}: {headers[name]}")
69+
return "\n".join(lines)
70+
71+
72+
def _rsa_sha256_sign(private_key: RSAPrivateKey, signing_string: str) -> str:
73+
"""RSA-SHA256 (PKCS#1 v1.5) signature of *signing_string*, base64-encoded."""
74+
signature = private_key.sign(
75+
signing_string.encode("ascii"), padding.PKCS1v15(), hashes.SHA256()
76+
)
77+
return base64.b64encode(signature).decode("ascii")
78+
79+
80+
class OCISigner:
81+
"""Signs OCI Object Storage native-API requests with a customer RSA API key.
82+
83+
``keyId`` is the usual ``<tenancy>/<user>/<fingerprint>`` triple; the matching PEM
84+
private key does the signing.
85+
"""
86+
87+
def __init__(
88+
self,
89+
tenancy: str,
90+
user: str,
91+
fingerprint: str,
92+
private_key_content: str | bytes,
93+
) -> None:
94+
self.api_key = f"{tenancy}/{user}/{fingerprint}"
95+
self._private_key = load_private_key(private_key_content)
96+
97+
def sign_request(
98+
self,
99+
method: str,
100+
url: str,
101+
headers: dict[str, str] | None = None,
102+
body: bytes | None = None,
103+
) -> dict[str, str]:
104+
"""Return *headers* with the OCI signing headers added.
105+
106+
:param method: HTTP verb
107+
:param url: fully-qualified request URL
108+
:param headers: extra headers to keep on the request
109+
:param body: request body for PUT/POST/PATCH; ignored for other methods
110+
"""
111+
method = method.upper()
112+
result = dict(headers) if headers else {}
113+
114+
split = urlsplit(url)
115+
host = split.netloc
116+
request_target = split.path or "/"
117+
if split.query:
118+
request_target = f"{request_target}?{split.query}"
119+
120+
date_header = formatdate(usegmt=True)
121+
signing_values = {"date": date_header}
122+
signed_headers = list(BASE_SIGNED_HEADERS)
123+
124+
if method in BODY_METHODS:
125+
body = body or b""
126+
signing_values["content-length"] = str(len(body))
127+
signing_values["content-type"] = DEFAULT_CONTENT_TYPE
128+
signing_values["x-content-sha256"] = _compute_body_sha256(body)
129+
signed_headers += BODY_SIGNED_HEADERS
130+
result.update({name: signing_values[name] for name in BODY_SIGNED_HEADERS})
131+
132+
signing_string = _build_signing_string(
133+
signed_headers, signing_values, method, request_target, host
134+
)
135+
signature = _rsa_sha256_sign(self._private_key, signing_string)
136+
137+
result["date"] = date_header
138+
result["host"] = host
139+
result["authorization"] = (
140+
f'Signature algorithm="{ALGORITHM}",'
141+
f'headers="{" ".join(signed_headers)}",'
142+
f'keyId="{self.api_key}",'
143+
f'signature="{signature}",'
144+
f'version="{SIGNATURE_VERSION}"'
145+
)
146+
return result

0 commit comments

Comments
 (0)