first commit

This commit is contained in:
linkitapps
2026-09-14 23:22:10 +09:00
commit 9f9c8fb69b
4207 changed files with 1333187 additions and 0 deletions
@@ -0,0 +1,4 @@
__version__ = "1.7.5"
__homepage__ = "https://jose.authlib.org/en/"
__author__ = "Hsiaoming Yang <me@lepture.com>"
__license__ = "BSD-3-Clause"
+213
View File
@@ -0,0 +1,213 @@
import typing as t
import random
from ._rfc7517.types import AnyKey, KeyParameters, DictKey
from ._rfc7518.oct_key import OctKey
from ._rfc7518.rsa_key import RSAKey
from ._rfc7518.ec_key import ECKey
from ._rfc8037.okp_key import OKPKey
from .errors import (
MissingKeyError,
InvalidKeyIdError,
InvalidKeyTypeError,
MissingKeyTypeError,
)
from .util import to_bytes
__all__ = [
"OctKey",
"RSAKey",
"ECKey",
"OKPKey",
"Key",
"KeySet",
"JWKRegistry",
"KeySetSerialization",
]
Key = t.Union[OctKey, RSAKey, ECKey, OKPKey]
class JWKRegistry:
"""A registry for JWK to record ``joserfc`` supported key types.
Normally, you would use explicit key types like ``OctKey``, ``RSAKey``;
This registry provides a way to dynamically import and generate keys.
For instance:
.. code-block:: python
from joserfc.jwk import JWKRegistry
# instead of choosing which key type to use yourself,
# JWKRegistry can import it automatically
data = {"kty": "oct", "k": "..."}
key = JWKRegistry.import_key(data)
"""
key_types: dict[str, type[Key]] = {
OctKey.key_type: OctKey,
RSAKey.key_type: RSAKey,
ECKey.key_type: ECKey,
OKPKey.key_type: OKPKey,
}
@classmethod
def import_key(cls, data: AnyKey, key_type: str | None = None, parameters: KeyParameters | None = None) -> Key:
"""A class method for importing a key from bytes, string, and dict.
When ``value`` is a dict, this method can tell the key type automatically,
otherwise, developers SHOULD pass the ``key_type`` themselves.
:param data: the key data in bytes, string, or dict.
:param key_type: an optional key type in string.
:param parameters: extra key parameters
:return: OctKey, RSAKey, ECKey, or OKPKey
"""
if isinstance(data, dict) and key_type is None:
if "kty" in data:
key_type = t.cast(str, data["kty"])
else:
raise MissingKeyTypeError("Missing key type")
if key_type not in cls.key_types:
raise InvalidKeyTypeError(f"Invalid key type: '{key_type}'")
if isinstance(data, str):
data = to_bytes(data)
key_cls = cls.key_types[key_type]
return key_cls.import_key(data, parameters)
@classmethod
def generate_key(
cls,
key_type: str,
crv_or_size: str | int | None = None,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> Key:
"""A class method for generating key according to the given key type.
When ``key_type`` is "oct" and "RSA", the second parameter SHOULD be
a key size in bits. When ``key_type`` is "EC" and "OKP", the second
parameter SHOULD be a "crv" string.
.. code-block:: python
JWKRegistry.generate_key("RSA", 2048)
JWKRegistry.generate_key("EC", "P-256")
"""
if key_type not in cls.key_types:
raise InvalidKeyTypeError(f"Invalid key type: '{key_type}'")
key_cls = cls.key_types[key_type]
return key_cls.generate_key(crv_or_size, parameters, private, auto_kid) # type: ignore[arg-type]
KeySetSerialization = t.TypedDict("KeySetSerialization", {"keys": list[DictKey]})
class KeySet:
#: keys in the key set
keys: list[Key]
registry_cls: type[JWKRegistry] = JWKRegistry
algorithm_keys: t.ClassVar[dict[str, list[str]]] = {}
def __init__(self, keys: list[Key]):
for key in keys:
key.ensure_kid()
self.keys = keys
def __iter__(self) -> t.Iterator[Key]:
return iter(self.keys)
def __bool__(self) -> bool:
return bool(self.keys)
def __eq__(self, other: t.Any) -> bool:
assert isinstance(other, KeySet)
return self.keys == other.keys
def as_dict(self, private: bool = False, **params: t.Any) -> KeySetSerialization:
keys: list[DictKey] = []
for key in self.keys:
# trigger key to generate kid via thumbprint
key.ensure_kid()
keys.append(key.as_dict(private=private, **params))
return {"keys": keys}
def get_by_kid(self, kid: str | None = None, parameters: KeyParameters | None = None) -> Key:
if kid is None and len(self.keys) == 1:
return self.keys[0]
keys = [key for key in self.keys if key.kid == kid]
if parameters:
keys = list(_filter_keys_by_parameters(keys, parameters))
if keys:
return keys[0]
raise InvalidKeyIdError(f"No key for kid: '{kid}'")
def pick_random_key(self, algorithm: str | None = None, parameters: KeyParameters | None = None) -> Key | None:
key_types = self.algorithm_keys.get(algorithm) if algorithm else None
if key_types:
keys = [k for k in self.keys if k.key_type in key_types]
else:
keys = self.keys
if parameters:
keys = list(_filter_keys_by_parameters(keys, parameters))
if keys:
return random.choice(keys)
return None
@classmethod
def import_key_set(cls, value: KeySetSerialization, parameters: KeyParameters | None = None) -> "KeySet":
keys: list[Key] = []
for data in value["keys"]:
# RFC 7517, Section 5: ignore a key whose "kty" is not understood
# rather than failing the whole set (for example a post-quantum key
# published alongside classical ones).
try:
keys.append(cls.registry_cls.import_key(data, parameters=parameters))
except InvalidKeyTypeError:
continue
if not keys:
raise MissingKeyError("No keys to import")
return cls(keys)
@classmethod
def generate_key_set(
cls,
key_type: str,
crv_or_size: str | int,
parameters: KeyParameters | None = None,
private: bool = True,
count: int = 4,
) -> "KeySet":
keys: list[Key] = []
for _ in range(count):
key = cls.registry_cls.generate_key(key_type, crv_or_size, parameters, private)
keys.append(key)
return cls(keys)
def _filter_keys_by_parameters(keys: list[Key], parameters: KeyParameters) -> t.Iterator[Key]:
_use = parameters.get("use")
_alg = parameters.get("alg")
for key in keys:
designed_use = key.get("use")
if designed_use and _use and designed_use != _use:
continue
designed_alg = key.get("alg")
if designed_alg and _alg and designed_alg != _alg:
continue
yield key
@@ -0,0 +1,53 @@
from typing import Any
from .model import JWSAlgModel, CompactSignature
from ..errors import (
DecodeError,
MissingAlgorithmError,
)
from ..util import (
json_b64encode,
json_b64decode,
urlsafe_b64encode,
urlsafe_b64decode,
)
__all__ = [
"sign_compact",
"verify_compact",
"detach_compact_content",
"decode_header",
]
def sign_compact(obj: CompactSignature, alg: JWSAlgModel, key: Any) -> bytes:
header_segment = json_b64encode(obj.headers())
payload_segment = urlsafe_b64encode(obj.payload)
signing_input = header_segment + b"." + payload_segment
signature = urlsafe_b64encode(alg.sign(signing_input, key))
return signing_input + b"." + signature
def verify_compact(obj: CompactSignature, alg: JWSAlgModel, key: Any) -> bool:
signing_input = obj.segments["header"] + b"." + obj.segments["payload"]
try:
sig = urlsafe_b64decode(obj.segments["signature"])
except (TypeError, ValueError):
return False
return alg.verify(signing_input, sig, key)
def detach_compact_content(value: str) -> str:
# https://www.rfc-editor.org/rfc/rfc7515#appendix-F
parts = value.split(".")
parts[1] = ""
return ".".join(parts)
def decode_header(header_segment: bytes) -> dict[str, Any]:
try:
protected: dict[str, Any] = json_b64decode(header_segment)
if "alg" not in protected:
raise MissingAlgorithmError()
except (TypeError, ValueError):
raise DecodeError("Invalid header")
return protected
@@ -0,0 +1,166 @@
import copy
from typing import Any, Callable
from .model import (
HeaderMember,
GeneralJSONSignature,
FlattenedJSONSignature,
)
from .types import (
JSONSignatureDict,
GeneralJSONSerialization,
FlattenedJSONSerialization,
)
from .registry import JWSRegistry
from ..registry import reject_unprotected_crit_header
from ..util import (
to_bytes,
json_b64encode,
json_b64decode,
urlsafe_b64encode,
urlsafe_b64decode,
)
from ..errors import DecodeError
__all__ = [
"FindKey",
"sign_general_json",
"sign_flattened_json",
"sign_json_member",
"extract_general_json",
"verify_general_json",
"verify_flattened_json",
"detach_json_content",
]
FindKey = Callable[[HeaderMember], Any]
def sign_general_json(
members: list[HeaderMember],
payload: bytes,
registry: JWSRegistry,
find_key: FindKey,
) -> GeneralJSONSerialization:
payload_segment = urlsafe_b64encode(payload)
signatures: list[JSONSignatureDict] = [
sign_json_member(payload_segment, member, registry, find_key) for member in members
]
return {
"payload": payload_segment.decode("utf-8"),
"signatures": signatures,
}
def sign_flattened_json(
member: HeaderMember,
payload: bytes,
registry: JWSRegistry,
find_key: FindKey,
) -> FlattenedJSONSerialization:
payload_segment = urlsafe_b64encode(payload)
signature = sign_json_member(payload_segment, member, registry, find_key)
data: FlattenedJSONSerialization = {"payload": payload_segment.decode("utf-8"), **signature}
return data
def sign_json_member(
payload_segment: bytes, member: HeaderMember, registry: JWSRegistry, find_key: FindKey
) -> JSONSignatureDict:
reject_unprotected_crit_header(member.header)
headers = member.headers()
registry.check_header(headers)
alg = registry.get_alg(headers["alg"])
key = find_key(member)
alg.check_key(key)
if member.protected:
protected_segment = json_b64encode(member.protected)
else:
protected_segment = b""
signing_input = b".".join([protected_segment, payload_segment])
signature = urlsafe_b64encode(alg.sign(signing_input, key))
rv: JSONSignatureDict = {"signature": signature.decode("utf-8")}
if member.protected:
rv["protected"] = protected_segment.decode("utf-8")
if member.header:
rv["header"] = member.header
return rv
def extract_general_json(value: GeneralJSONSerialization, registry: JWSRegistry) -> GeneralJSONSignature:
payload_segment: bytes = value["payload"].encode("utf-8")
registry.validate_payload_size(payload_segment)
try:
payload = urlsafe_b64decode(payload_segment)
except (TypeError, ValueError):
raise DecodeError("Invalid payload")
signatures: list[JSONSignatureDict] = value["signatures"]
members = [__signature_to_member(sig, registry) for sig in signatures]
obj = GeneralJSONSignature(members, payload)
obj.signatures = signatures
obj.segments = {"payload": payload_segment}
return obj
def __signature_to_member(sig: JSONSignatureDict, registry: JWSRegistry) -> HeaderMember:
member = HeaderMember()
if "protected" in sig:
protected_segment = to_bytes(sig["protected"])
registry.validate_header_size(protected_segment)
member.protected = json_b64decode(protected_segment)
if "header" in sig:
member.header = sig["header"]
return member
def verify_general_json(obj: GeneralJSONSignature, registry: JWSRegistry, find_key: FindKey) -> bool:
payload_segment = obj.segments["payload"]
if not obj.signatures:
return False
for index, signature in enumerate(obj.signatures):
member = obj.members[index]
if not verify_signature(member, signature, payload_segment, registry, find_key):
return False
return True
def verify_flattened_json(obj: FlattenedJSONSignature, registry: JWSRegistry, find_key: FindKey) -> bool:
payload_segment = obj.segments["payload"]
assert obj.signature is not None
return verify_signature(obj.member, obj.signature, payload_segment, registry, find_key)
def verify_signature(
member: HeaderMember,
signature: JSONSignatureDict,
payload_segment: bytes,
registry: JWSRegistry,
find_key: FindKey,
) -> bool:
reject_unprotected_crit_header(member.header)
headers = member.headers()
registry.check_header(headers)
alg = registry.get_alg(headers["alg"])
key = find_key(member)
alg.check_key(key)
if "protected" in signature:
protected_segment = to_bytes(signature["protected"])
else:
protected_segment = b""
signature_segment = to_bytes(signature["signature"])
registry.validate_signature_size(signature_segment)
sig = urlsafe_b64decode(signature_segment)
signing_input = b".".join([protected_segment, payload_segment])
return alg.verify(signing_input, sig, key)
def detach_json_content(value: dict[str, Any]) -> dict[str, Any]:
# https://www.rfc-editor.org/rfc/rfc7515#appendix-F
rv = copy.deepcopy(value) # don't alter original value
if "payload" in rv:
del rv["payload"]
return rv
@@ -0,0 +1,141 @@
from typing import Any, ClassVar, Literal
from abc import ABCMeta, abstractmethod
from .types import SegmentsDict, JSONSignatureDict
from ..errors import InvalidKeyTypeError
from ..registry import Header
__all__ = [
"HeaderMember",
"CompactSignature",
"FlattenedJSONSignature",
"GeneralJSONSignature",
"JWSAlgModel",
]
class HeaderMember:
"""A header member of the JSON signature. It is combined with protected header,
and unprotected header.
"""
def __init__(self, protected: Header | None = None, header: Header | None = None):
#: protected header
self.protected = protected
#: unprotected header
self.header = header
def headers(self) -> Header:
rv: Header = {}
if self.header:
rv.update(self.header)
# protected header is preferred
if self.protected:
rv.update(self.protected)
return rv
def set_kid(self, kid: str) -> None:
if self.header is None:
self.header = {}
self.header["kid"] = kid
class CompactSignature:
"""JSON Web Signature object for compact mode. This object is used to
represent the JWS instance.
"""
def __init__(self, protected: Header, payload: bytes):
#: protected header
self.protected = protected
#: payload content in bytes
self.payload = payload
self.segments: SegmentsDict = {}
def headers(self) -> Header:
"""Returns protected header values in dict."""
return self.protected
def set_kid(self, kid: str) -> None:
self.protected["kid"] = kid
class FlattenedJSONSignature:
"""JSON Signature object that represents a flattened JSON serialization."""
#: mark it as flattened
flattened: ClassVar[bool] = True
def __init__(self, member: HeaderMember, payload: bytes):
#: the only header member
self.member: HeaderMember = member
#: payload content in bytes
self.payload: bytes = payload
self.signature: JSONSignatureDict | None = None
self.segments: SegmentsDict = {}
@property
def members(self) -> list[HeaderMember]:
"""A list of header members. For flattened JSON serialization, there will
be only one header member."""
return [self.member]
def headers(self) -> Header:
"""Header values in dict."""
return self.member.headers()
class GeneralJSONSignature:
"""JSON Signature object that represents a general JSON serialization."""
#: mark it as not flattened (general)
flattened: ClassVar[bool] = False
def __init__(self, members: list[HeaderMember], payload: bytes):
#: a list of header members
self.members: list[HeaderMember] = members
#: payload content in bytes
self.payload: bytes = payload
self.signatures: list[JSONSignatureDict] = []
self.segments: SegmentsDict = {}
class JWSAlgModel(metaclass=ABCMeta):
"""Interface for JWS algorithm. JWA specification (RFC7518) SHOULD
implement the algorithms for JWS with this base implementation.
"""
name: str
description: str
recommended: bool = False
security_warning: str | None = None
key_type = "oct"
algorithm_type: Literal["JWS"] = "JWS"
algorithm_location = "sig"
algorithm_security = 0
def check_key(self, key: Any) -> None:
key.check_use("sig")
if key.key_type != self.key_type:
raise InvalidKeyTypeError(f"Algorithm '{self.name}' requires '{self.key_type}' key")
key.check_alg(self.name)
@abstractmethod
def sign(self, msg: bytes, key: Any) -> bytes:
"""Sign the text msg with a private/sign key.
:param msg: message bytes to be signed
:param key: private key to sign the message
:return: bytes
"""
@abstractmethod
def verify(self, msg: bytes, sig: bytes, key: Any) -> bool:
"""Verify the signature of text msg with a public/verify key.
:param msg: message bytes to be signed
:param sig: result signature to be compared
:param key: public key to verify the signature
:return: boolean
"""
@@ -0,0 +1,181 @@
import warnings
from typing import Any
from collections.abc import Collection
from enum import Enum
from .model import JWSAlgModel
from ..errors import (
JoseError,
UnsupportedAlgorithmError,
SecurityWarning,
ExceededSizeError,
)
from ..registry import (
JWS_HEADER_REGISTRY,
Header,
HeaderRegistryDict,
check_registry_header,
check_crit_header,
check_supported_header,
)
from .._keys import KeySet
__all__ = [
"JWSRegistry",
"construct_registry",
"default_registry",
]
class JWSRegistry:
"""A registry for JSON Web Signature to keep all the supported algorithms.
An instance of ``JWSRegistry`` is usually used together with methods in
``joserfc.jws``.
:param header_registry: extra header parameters registry
:param algorithms: allowed algorithms to be used
:param strict_check_header: only allow header key in the registry to be used
"""
class Strategy(Enum):
#: find the recommended algorithm
RECOMMENDED = 1
#: find the most secure algorithm
SECURITY = 2
default_header_registry: HeaderRegistryDict = JWS_HEADER_REGISTRY
algorithms: dict[str, JWSAlgModel] = {}
recommended: list[str] = []
#: max header content's size in bytes
max_header_length: int = 512
#: max payload content's size in bytes
max_payload_length: int = 128000
#: max signature's size in bytes
max_signature_length: int = 1024
def __init__(
self,
header_registry: HeaderRegistryDict | None = None,
algorithms: Collection[str] | None = None,
strict_check_header: bool = True,
):
self.header_registry: HeaderRegistryDict = {}
self.header_registry.update(self.default_header_registry)
if header_registry is not None:
self.header_registry.update(header_registry)
self.allowed = algorithms
self.strict_check_header = strict_check_header
@classmethod
def register(cls, alg: JWSAlgModel) -> None:
"""Register a given JWS algorithm instance to the registry."""
cls.algorithms[alg.name] = alg
if alg.recommended:
cls.recommended.append(alg.name)
def get_alg(self, name: str) -> JWSAlgModel:
"""Get the allowed algorithm instance of the given name.
:param name: value of the ``alg``, e.g. ``HS256``, ``RS256``
"""
if name not in self.algorithms:
raise UnsupportedAlgorithmError(f"Algorithm of '{name}' is not supported")
if self.allowed:
if name not in self.allowed:
raise UnsupportedAlgorithmError(f"Algorithm of '{name}' is not allowed")
else:
if name not in self.recommended:
raise UnsupportedAlgorithmError(f"Algorithm of '{name}' is not recommended")
alg = self.algorithms[name]
if alg.security_warning:
warnings.warn(alg.security_warning, SecurityWarning)
return alg
def check_header(self, header: Header) -> None:
"""Check and validate the fields in header part of a JWS object."""
check_crit_header(self.header_registry, header)
check_registry_header(self.header_registry, header)
if self.strict_check_header:
check_supported_header(self.header_registry, header)
def validate_header_size(self, header: bytes) -> None:
if header and len(header) > self.max_header_length:
raise ExceededSizeError(f"Header size exceeds {self.max_header_length} bytes.")
def validate_payload_size(self, payload: bytes) -> None:
if payload and len(payload) > self.max_payload_length:
raise ExceededSizeError(f"Payload size exceeds {self.max_payload_length} bytes.")
def validate_signature_size(self, signature: bytes) -> None:
if len(signature) > self.max_signature_length:
raise ExceededSizeError(f"Signature of exceeds {self.max_signature_length} bytes.")
@classmethod
def guess_algorithm(cls, key: Any, strategy: Strategy) -> JWSAlgModel | None:
"""Guess the JWS algorithm for a given key.
:param key: key instance or a KeySet
:param strategy: the strategy for guessing the JWS algorithm
"""
if strategy == cls.Strategy.RECOMMENDED:
algorithms = cls.filter_algorithms(key, cls.recommended)
elif strategy == cls.Strategy.SECURITY:
names = list(cls.algorithms.keys())
algorithms = cls.filter_algorithms(key, names)
# sort by security level
algorithms.sort(key=lambda alg: alg.algorithm_security, reverse=True)
else:
raise NotImplementedError(f"Unknown algorithm strategy '{strategy}'")
if algorithms:
return algorithms[0]
else:
return None
@classmethod
def guess_alg(cls, key: Any, strategy: Strategy) -> str | None: # pragma: no cover
warnings.warn("Please use guess_algorithm(key, strategy)", DeprecationWarning)
alg = cls.guess_algorithm(key, strategy)
if alg:
return alg.name
return None
@classmethod
def filter_algorithms(cls, key: Any, names: list[str] | None = None) -> list[JWSAlgModel]:
"""Filter JWS algorithms based on the given algorithm names.
:param key: a key instance or a KeySet
:param names: list of algorithm names
"""
if names is None:
names = list(cls.algorithms.keys())
rv: list[JWSAlgModel] = []
if isinstance(key, KeySet):
for k in key.keys:
for alg in cls.filter_algorithms(k, names):
if alg not in rv:
rv.append(alg)
return rv
for name in names:
alg = cls.algorithms[name]
try:
alg.check_key(key)
rv.append(alg)
except JoseError:
pass
return rv
#: default JWS registry
default_registry = JWSRegistry()
def construct_registry(algorithms: Collection[str] | None = None) -> JWSRegistry:
if algorithms:
registry = JWSRegistry(algorithms=algorithms)
else:
registry = default_registry
return registry
@@ -0,0 +1,41 @@
from typing import TypedDict, final
from ..registry import Header
__all__ = [
"SegmentsDict",
"HeaderDict",
"JSONSignatureDict",
"GeneralJSONSerialization",
"FlattenedJSONSerialization",
]
class SegmentsDict(TypedDict, total=False):
header: bytes
payload: bytes
signature: bytes
class HeaderDict(TypedDict, total=False):
protected: Header
header: Header
class JSONSignatureDict(TypedDict, total=False):
protected: str
header: Header
signature: str
@final
class GeneralJSONSerialization(TypedDict):
payload: str
signatures: list[JSONSignatureDict]
@final
class FlattenedJSONSerialization(TypedDict, total=False):
payload: str
protected: str
header: Header
signature: str
@@ -0,0 +1,75 @@
from .models import CompactEncryption, Recipient
from .registry import JWERegistry
from .._keys import Key
from ..errors import (
MissingAlgorithmError,
MissingEncryptionError,
DecodeError,
)
from ..util import (
json_b64decode,
urlsafe_b64encode,
urlsafe_b64decode,
)
__all__ = [
"represent_compact",
"extract_compact",
]
def represent_compact(obj: CompactEncryption) -> bytes:
assert obj.recipient is not None
encrypted_key = obj.recipient.encrypted_key
assert encrypted_key is not None
return b".".join(
[
obj.base64_segments["aad"],
urlsafe_b64encode(encrypted_key),
obj.base64_segments["iv"],
obj.base64_segments["ciphertext"],
obj.base64_segments["tag"],
]
)
def extract_compact(value: bytes, registry: JWERegistry) -> CompactEncryption:
parts = value.split(b".")
if len(parts) != 5:
raise ValueError("Invalid JSON Web Encryption")
header_segment, ek_segment, iv_segment, ciphertext_segment, tag_segment = parts
registry.validate_protected_header_size(header_segment)
registry.validate_encrypted_key_size(ek_segment)
registry.validate_initialization_vector_size(iv_segment)
registry.validate_ciphertext_size(ciphertext_segment)
registry.validate_auth_tag_size(tag_segment)
try:
protected = json_b64decode(header_segment)
if "alg" not in protected:
raise MissingAlgorithmError()
if "enc" not in protected:
raise MissingEncryptionError()
except (TypeError, ValueError):
raise DecodeError("Invalid header")
obj = CompactEncryption(protected)
obj.base64_segments.update(
{
"aad": header_segment,
"iv": iv_segment,
"ciphertext": ciphertext_segment,
"tag": tag_segment,
}
)
obj.bytes_segments.update(
{
"iv": urlsafe_b64decode(iv_segment),
"ciphertext": urlsafe_b64decode(ciphertext_segment),
"tag": urlsafe_b64decode(tag_segment),
}
)
recipient: Recipient[Key] = Recipient(obj)
recipient.encrypted_key = urlsafe_b64decode(ek_segment)
obj.recipient = recipient
return obj
@@ -0,0 +1,142 @@
from typing import Any
from .models import (
BaseJSONEncryption,
GeneralJSONEncryption,
FlattenedJSONEncryption,
Recipient,
)
from .registry import JWERegistry
from .types import (
JSONRecipientDict,
GeneralJSONSerialization,
FlattenedJSONSerialization,
)
from ..util import (
to_bytes,
to_str,
json_b64encode,
json_b64decode,
urlsafe_b64encode,
urlsafe_b64decode,
)
from .._keys import Key
__all__ = [
"represent_general_json",
"represent_flattened_json",
"extract_general_json",
"extract_flattened_json",
]
def represent_general_json(obj: GeneralJSONEncryption) -> GeneralJSONSerialization:
data: GeneralJSONSerialization = __represent_json_serialization(obj)
recipients = []
for recipient in obj.recipients:
item: JSONRecipientDict = {}
assert recipient.header is not None
assert recipient.encrypted_key is not None
item["header"] = recipient.header
item["encrypted_key"] = to_str(urlsafe_b64encode(recipient.encrypted_key))
recipients.append(item)
data["recipients"] = recipients
return data
def represent_flattened_json(obj: FlattenedJSONEncryption) -> FlattenedJSONSerialization:
data: FlattenedJSONSerialization = __represent_json_serialization(obj)
recipient = obj.recipients[0]
assert recipient is not None
assert recipient.header is not None
assert recipient.encrypted_key is not None
data["header"] = recipient.header
data["encrypted_key"] = to_str(urlsafe_b64encode(recipient.encrypted_key))
return data
def __represent_json_serialization(obj: BaseJSONEncryption) -> Any:
data: dict[str, Any] = {
"protected": to_str(json_b64encode(obj.protected)),
"iv": to_str(obj.base64_segments["iv"]),
"ciphertext": to_str(obj.base64_segments["ciphertext"]),
"tag": to_str(obj.base64_segments["tag"]),
}
if obj.aad:
data["aad"] = to_str(urlsafe_b64encode(obj.aad))
if obj.unprotected:
data["unprotected"] = obj.unprotected
return data
def extract_general_json(data: GeneralJSONSerialization, registry: JWERegistry) -> GeneralJSONEncryption:
protected_segment = to_bytes(data["protected"])
registry.validate_protected_header_size(protected_segment)
protected = json_b64decode(protected_segment)
unprotected = data.get("unprotected")
recipients = data["recipients"]
registry.validate_recipient_count(recipients)
base64_segments, bytes_segments, aad = __extract_segments(data, registry)
obj = GeneralJSONEncryption(protected, None, unprotected, aad)
obj.base64_segments = base64_segments
obj.bytes_segments = bytes_segments
for item in recipients:
recipient = __extract_recipient(obj, item, registry)
obj.recipients.append(recipient)
return obj
def extract_flattened_json(data: FlattenedJSONSerialization, registry: JWERegistry) -> FlattenedJSONEncryption:
protected_segment = to_bytes(data["protected"])
registry.validate_protected_header_size(protected_segment)
protected = json_b64decode(protected_segment)
unprotected = data.get("unprotected")
base64_segments, bytes_segments, aad = __extract_segments(data, registry)
obj = FlattenedJSONEncryption(protected, None, unprotected, aad)
obj.base64_segments = base64_segments
obj.bytes_segments = bytes_segments
recipient = __extract_recipient(obj, data, registry)
obj.recipients.append(recipient)
return obj
def __extract_segments(
data: GeneralJSONSerialization | FlattenedJSONSerialization,
registry: JWERegistry,
) -> tuple[dict[str, bytes], dict[str, bytes], bytes | None]:
base64_segments: dict[str, bytes] = {
"iv": to_bytes(data["iv"]),
"ciphertext": to_bytes(data["ciphertext"]),
"tag": to_bytes(data["tag"]),
}
registry.validate_initialization_vector_size(base64_segments["iv"])
registry.validate_ciphertext_size(base64_segments["ciphertext"])
registry.validate_auth_tag_size(base64_segments["tag"])
bytes_segments: dict[str, bytes] = {
"iv": urlsafe_b64decode(base64_segments["iv"]),
"ciphertext": urlsafe_b64decode(base64_segments["ciphertext"]),
"tag": urlsafe_b64decode(base64_segments["tag"]),
}
if "aad" in data:
base64_segments["aad"] = to_bytes(data["aad"])
aad = urlsafe_b64decode(base64_segments["aad"])
bytes_segments["aad"] = aad
else:
aad = None
return base64_segments, bytes_segments, aad
def __extract_recipient(
obj: FlattenedJSONEncryption | GeneralJSONEncryption,
data: FlattenedJSONSerialization | JSONRecipientDict,
registry: JWERegistry,
) -> Recipient[Key]:
recipient: Recipient[Key] = Recipient(obj, data.get("header"))
if "encrypted_key" in data:
ek_segment = to_bytes(data["encrypted_key"])
registry.validate_encrypted_key_size(ek_segment)
recipient.encrypted_key = urlsafe_b64decode(ek_segment)
return recipient
@@ -0,0 +1,266 @@
import secrets
from typing import Any, Union
from .models import (
CompactEncryption,
BaseJSONEncryption,
GeneralJSONEncryption,
FlattenedJSONEncryption,
Recipient,
JWEAlgModel,
JWEEncModel,
JWEKeyAgreement,
JWEDirectEncryption,
JWEKeyEncryption,
JWEKeyWrapping,
)
from .registry import JWERegistry
from ..errors import (
JoseError,
DecodeError,
InvalidEncryptedKeyError,
InvalidExchangeKeyError,
ConflictAlgorithmError,
)
from ..util import (
json_b64encode,
urlsafe_b64encode,
)
__all__ = [
"EncryptionData",
"perform_encrypt",
"perform_decrypt",
]
EncryptionData = Union[CompactEncryption, GeneralJSONEncryption, FlattenedJSONEncryption]
def perform_encrypt(obj: EncryptionData, registry: JWERegistry) -> None:
enc = registry.get_enc(obj.protected["enc"])
cek, delayed_tasks = pre_encrypt_recipients(enc, obj.recipients, registry)
# Step 9, Generate a random JWE Initialization Vector of the correct size
# for the content encryption algorithm (if required for the algorithm);
# otherwise, let the JWE Initialization Vector be the empty octet sequence.
iv = enc.generate_iv()
# Step 10, Compute the encoded Initialization Vector value
# BASE64URL(JWE Initialization Vector).
obj.base64_segments["iv"] = urlsafe_b64encode(iv)
# Step 11, If a "zip" parameter was included, compress the plaintext using
# the specified compression algorithm and let M be the octet sequence
# representing the compressed plaintext; otherwise, let M be the octet
# sequence representing the plaintext.
assert obj.plaintext is not None
plaintext: bytes
if "zip" in obj.protected:
zip_ = registry.get_zip(obj.protected["zip"])
plaintext = zip_.compress(obj.plaintext)
else:
plaintext = obj.plaintext
# Step 13, Compute the Encoded Protected Header value BASE64URL(UTF8(JWE Protected Header)).
aad = json_b64encode(obj.protected)
# Step 14, Let the Additional Authenticated Data encryption parameter be
# ASCII(Encoded Protected Header). However, if a JWE AAD value is
# present (which can only be the case when using the JWE JSON Serialization),
# instead let the Additional Authenticated Data encryption parameter be
# ASCII(Encoded Protected Header || '.' || BASE64URL(JWE AAD)).
if isinstance(obj, BaseJSONEncryption) and obj.aad:
aad = aad + b"." + urlsafe_b64encode(obj.aad)
obj.base64_segments["aad"] = aad
# encrypting plaintext
ciphertext, tag = enc.encrypt(plaintext, cek, iv, aad)
# delay encrypting every recipient
post_encrypt_recipients(enc, delayed_tasks, cek, tag)
obj.base64_segments["ciphertext"] = urlsafe_b64encode(ciphertext)
obj.base64_segments["tag"] = urlsafe_b64encode(tag)
def perform_decrypt(obj: EncryptionData, registry: JWERegistry) -> None:
try:
_perform_decrypt(obj, registry)
except InvalidExchangeKeyError as error:
raise DecodeError(error.description)
def _perform_decrypt(obj: EncryptionData, registry: JWERegistry) -> None:
enc = registry.get_enc(obj.protected["enc"])
iv = obj.bytes_segments["iv"]
enc.check_iv(iv)
tag = obj.bytes_segments["tag"]
ciphertext = obj.bytes_segments["ciphertext"]
cek_set = set()
for recipient in obj.recipients:
try:
headers = recipient.headers()
registry.check_header(headers, True)
# Step 6, Determine the Key Management Mode employed by the algorithm
# specified by the "alg" (algorithm) Header Parameter.
alg = registry.get_alg(headers["alg"])
cek = decrypt_recipient(alg, enc, recipient, tag)
cek_set.add(cek)
except (AssertionError, JoseError) as error:
if registry.verify_all_recipients:
raise error
else:
if not registry.verify_all_recipients:
break
if not cek_set:
raise DecodeError("Invalid recipients")
if len(cek_set) > 1: # pragma: no cover
raise DecodeError("Multiple 'cek' found")
cek = cek_set.pop()
if len(cek) * 8 != enc.cek_size: # pragma: no cover
cek = secrets.token_bytes(enc.cek_size // 8)
if isinstance(obj, BaseJSONEncryption):
aad = json_b64encode(obj.protected)
if obj.aad:
aad = aad + b"." + obj.base64_segments["aad"]
else:
aad = obj.base64_segments["aad"]
msg = enc.decrypt(ciphertext, tag, cek, iv, aad)
if "zip" in obj.protected:
zip_ = registry.get_zip(obj.protected["zip"])
obj.plaintext = zip_.decompress(msg)
else:
obj.plaintext = msg
def pre_encrypt_recipients(
enc: JWEEncModel, recipients: list[Recipient[Any]], registry: JWERegistry
) -> tuple[bytes, list[tuple[JWEKeyAgreement, Recipient[Any]]]]:
cek: bytes = b""
delayed_tasks: list[tuple[JWEKeyAgreement, Recipient[Any]]] = []
for recipient in recipients:
alg = __prepare_recipient_algorithm(recipient, registry)
if alg.direct_mode:
if len(recipients) > 1:
raise ConflictAlgorithmError(f"Algorithm {alg.name} SHOULD have 1 recipient only")
cek = __pre_encrypt_direct_mode(alg, enc, recipient)
else:
if not cek:
# 2. When Key Wrapping, Key Encryption, or Key Agreement with Key
# Wrapping are employed, generate a random CEK value. See RFC
# 4086 [RFC4086] for considerations on generating random values.
# The CEK MUST have a length equal to that required for the
# content encryption algorithm.
cek = enc.generate_cek()
if isinstance(alg, JWEKeyAgreement):
delayed_tasks.append((alg, recipient))
else:
# 4. When Key Wrapping, or Key Encryption are employed, encrypt the CEK
# to the recipient and let the result be the JWE Encrypted Key.
assert isinstance(alg, (JWEKeyWrapping, JWEKeyEncryption))
recipient.encrypted_key = alg.encrypt_cek(cek, recipient)
return cek, delayed_tasks
def __prepare_recipient_algorithm(recipient: Recipient[Any], registry: JWERegistry) -> JWEAlgModel:
headers = recipient.headers()
registry.check_header(headers)
# 1. Determine the Key Management Mode employed by the algorithm used
# to determine the Content Encryption Key value. (This is the
# algorithm recorded in the "alg" (algorithm) Header Parameter of
# the resulting JWE.)
alg = registry.get_alg(headers["alg"])
if isinstance(alg, JWEKeyAgreement):
alg.prepare_ephemeral_key(recipient)
return alg
def __pre_encrypt_direct_mode(alg: JWEAlgModel, enc: JWEEncModel, recipient: Recipient[Any]) -> bytes:
cek: bytes
if isinstance(alg, JWEKeyAgreement):
# 3. When Direct Key Agreement is employed,
# let the CEK be the agreed upon key.
cek = alg.encrypt_agreed_upon_key(enc, recipient)
if len(cek) * 8 != enc.cek_size: # pragma: no cover
cek = secrets.token_bytes(enc.cek_size // 8)
else:
# 6. When Direct Encryption is employed, let the CEK be the shared
# symmetric key.
assert isinstance(alg, JWEDirectEncryption)
cek = alg.compute_cek(enc.cek_size, recipient)
# 5. When Direct Key Agreement or Direct Encryption are employed, let
# the JWE Encrypted Key be the empty octet sequence.
recipient.encrypted_key = b""
return cek
def post_encrypt_recipients(
enc: JWEEncModel, tasks: list[tuple[JWEKeyAgreement, Recipient[Any]]], cek: bytes, tag: bytes
) -> None:
for alg, recipient in tasks:
if alg.tag_aware:
agreed_upon_key = alg.encrypt_agreed_upon_key_with_tag(enc, recipient, tag)
else:
agreed_upon_key = alg.encrypt_agreed_upon_key(enc, recipient)
# 4. When Key Agreement with Key Wrapping is employed, encrypt the CEK
# to the recipient and let the result be the JWE Encrypted Key.
recipient.encrypted_key = alg.wrap_cek_with_auk(cek, agreed_upon_key)
def decrypt_recipient(alg: JWEAlgModel, enc: JWEEncModel, recipient: Recipient[Any], tag: bytes) -> bytes:
cek: bytes
if alg.direct_mode:
# 10. When Direct Key Agreement or Direct Encryption are employed,
# verify that the JWE Encrypted Key value is an empty octet
# sequence.
if recipient.encrypted_key: # pragma: no cover
raise InvalidEncryptedKeyError()
if isinstance(alg, JWEKeyAgreement):
# 8. When Direct Key Agreement is employed, let the CEK be the agreed upon key.
cek = alg.decrypt_agreed_upon_key(enc, recipient)
else:
# 11. When Direct Encryption is employed, let the CEK be the shared
# symmetric key.
assert isinstance(alg, JWEDirectEncryption)
cek = alg.compute_cek(enc.cek_size, recipient)
elif isinstance(alg, JWEKeyAgreement):
agreed_upon_key: bytes
if alg.tag_aware:
agreed_upon_key = alg.decrypt_agreed_upon_key_with_tag(enc, recipient, tag)
else:
agreed_upon_key = alg.decrypt_agreed_upon_key(enc, recipient)
# 8. When Key Agreement with Key Wrapping is employed, the agreed upon key
# will be used to decrypt the JWE Encrypted Key.
if recipient.encrypted_key is None: # pragma: no cover
raise DecodeError("Invalid encrypted key")
cek = alg.unwrap_cek_with_auk(recipient.encrypted_key, agreed_upon_key)
else:
# 9. When Key Wrapping, Key Encryption, or Key Agreement with Key
# Wrapping are employed, decrypt the JWE Encrypted Key to produce
# the CEK. The CEK MUST have a length equal to that required for
# the content encryption algorithm. Note that when there are
# multiple recipients, each recipient will only be able to decrypt
# JWE Encrypted Key values that were encrypted to a key in that
# recipient's possession. It is therefore normal to only be able
# to decrypt one of the per-recipient JWE Encrypted Key values to
# obtain the CEK value.
assert isinstance(alg, (JWEKeyWrapping, JWEKeyEncryption))
if recipient.encrypted_key is None:
raise DecodeError("Invalid encrypted key")
cek = alg.decrypt_cek(recipient)
return cek
@@ -0,0 +1,345 @@
import typing as t
import secrets
from abc import ABCMeta, abstractmethod
from ..registry import Header, HeaderRegistryDict
from ..errors import InvalidKeyTypeError, InvalidKeyLengthError
from .._keys import Key, ECKey, OctKey
__all__ = [
"Recipient",
"CompactEncryption",
"BaseJSONEncryption",
"GeneralJSONEncryption",
"FlattenedJSONEncryption",
"JWEEncModel",
"JWEZipModel",
"KeyManagement",
"JWEDirectEncryption",
"JWEKeyEncryption",
"JWEKeyWrapping",
"JWEKeyAgreement",
"JWEAlgModel",
]
KeyType = t.TypeVar("KeyType")
class Recipient(t.Generic[KeyType]):
def __init__(
self,
parent: t.Union["CompactEncryption", "GeneralJSONEncryption", "FlattenedJSONEncryption"],
header: Header | None = None,
recipient_key: KeyType | None = None,
):
self.__parent = parent
self.header = header
self.recipient_key = recipient_key
self.sender_key: KeyType | None = None
self.encrypted_key: bytes | None = None
self.ephemeral_key: KeyType | None = None
def headers(self) -> Header:
rv: Header = {}
if isinstance(self.__parent, BaseJSONEncryption) and self.__parent.unprotected:
rv.update(self.__parent.unprotected)
if self.header:
rv.update(self.header)
rv.update(self.__parent.protected)
return rv
def add_header(self, k: str, v: t.Any) -> None:
if isinstance(self.__parent, CompactEncryption):
self.__parent.protected.update({k: v})
elif self.header:
self.header.update({k: v})
else:
self.header = {k: v}
def set_kid(self, kid: str) -> None:
self.add_header("kid", kid)
class CompactEncryption:
"""An object to represent the JWE Compact Serialization. It is usually returned by
``decrypt_compact`` method.
"""
def __init__(self, protected: Header, plaintext: bytes | None = None):
#: protected header in dict
self.protected = protected
#: the plaintext in bytes
self.plaintext = plaintext
self.recipient: Recipient[t.Any] | None = None
self.bytes_segments: dict[str, bytes] = {} # store the decoded segments
self.base64_segments: dict[str, bytes] = {} # store the encoded segments
def headers(self) -> Header:
"""Returns the protected header values in dict."""
return self.protected
def attach_recipient(self, key: Key, header: Header | None = None) -> None:
"""Add a recipient to the JWE Compact Serialization. Please add a key that
comply with the given "alg" value.
:param key: an instance of a key, e.g. (OctKey, RSAKey, ECKey, and etc)
:param header: extra header in dict
"""
recipient = Recipient(self, None, key)
if header:
self.protected.update(header)
self.recipient = recipient
@property
def recipients(self) -> list[Recipient[t.Any]]:
if self.recipient is not None:
return [self.recipient]
return []
class BaseJSONEncryption(metaclass=ABCMeta):
#: represents if the object is in flatten syntax
flattened: t.ClassVar[bool]
#: protected header in dict
protected: Header
#: the plaintext in bytes
plaintext: t.Optional[bytes]
#: unprotected header in dict
unprotected: t.Optional[Header]
#: an optional additional authenticated data
aad: t.Optional[bytes]
#: a list of recipients
recipients: list[Recipient[t.Any]]
def __init__(
self,
protected: Header,
plaintext: bytes | None = None,
unprotected: Header | None = None,
aad: bytes | None = None,
):
self.protected = protected
self.plaintext = plaintext
self.unprotected = unprotected
self.aad = aad
self.recipients = []
self.bytes_segments: dict[str, bytes] = {} # store the decoded segments
self.base64_segments: dict[str, bytes] = {} # store the encoded segments
@abstractmethod
def add_recipient(self, header: Header | None = None, key: Key | None = None) -> None:
"""Add a recipient to the JWE JSON Serialization. Please add a key that
comply with the "alg" to this recipient.
:param header: recipient's own (unprotected) header
:param key: an instance of a key, e.g. (OctKey, RSAKey, ECKey, and etc)
"""
class GeneralJSONEncryption(BaseJSONEncryption):
"""An object to represent the JWE General JSON Serialization. It is used by
``encrypt_json``, and it is usually returned by ``decrypt_json`` method.
To construct an object of ``GeneralJSONEncryption``:
.. code-block:: python
protected = {"enc": "A128CBC-HS256"}
plaintext = b"hello world"
obj = GeneralJSONEncryption(protected, plaintext)
# then add each recipient
obj.add_recipient({"alg": "A128KW"})
"""
flattened = False
def add_recipient(self, header: Header | None = None, key: Key | None = None) -> None:
recipient = Recipient(self, header, key)
if key and key.kid:
recipient.set_kid(key.kid)
self.recipients.append(recipient)
class FlattenedJSONEncryption(BaseJSONEncryption):
"""An object to represent the JWE Flattened JSON Serialization. It is used by
``encrypt_json``, and it is usually returned by ``decrypt_json`` method.
To construct an object of ``FlattenedJSONEncryption``:
.. code-block:: python
protected = {"enc": "A128CBC-HS256"}
plaintext = b"hello world"
obj = FlattenedJSONEncryption(protected, plaintext)
# then add each recipient
obj.add_recipient({"alg": "A128KW"})
"""
flattened = True
def add_recipient(self, header: Header | None = None, key: Key | None = None) -> None:
recipient = Recipient(self, header, key)
if key and key.kid:
recipient.set_kid(key.kid)
self.recipients = [recipient]
class JWEEncModel(metaclass=ABCMeta):
name: str
description: str
recommended: bool = False
algorithm_type: t.Literal["JWE"] = "JWE"
algorithm_location: t.Literal["enc"] = "enc"
iv_size: int
cek_size: int
def generate_cek(self) -> bytes:
return secrets.token_bytes(self.cek_size // 8)
def generate_iv(self) -> bytes:
return secrets.token_bytes(self.iv_size // 8)
def check_iv(self, iv: bytes) -> bytes:
if len(iv) * 8 != self.iv_size: # pragma: no cover
raise ValueError("Invalid 'iv' size")
return iv
@abstractmethod
def encrypt(self, plaintext: bytes, cek: bytes, iv: bytes, aad: bytes) -> tuple[bytes, bytes]:
pass
@abstractmethod
def decrypt(self, ciphertext: bytes, tag: bytes, cek: bytes, iv: bytes, aad: bytes) -> bytes:
pass
class JWEZipModel(metaclass=ABCMeta):
name: str
description: str
recommended: bool = True
algorithm_type: t.Literal["JWE"] = "JWE"
algorithm_location: t.Literal["zip"] = "zip"
@abstractmethod
def compress(self, s: bytes) -> bytes:
pass
@abstractmethod
def decompress(self, s: bytes) -> bytes:
pass
class KeyManagement:
name: str
description: str
recommended: bool = False
key_size: int | None = None
key_types: list[str]
security_warning: str | None = None
algorithm_type: t.Literal["JWE"] = "JWE"
algorithm_location: t.Literal["alg"] = "alg"
more_header_registry: HeaderRegistryDict = {}
@property
def direct_mode(self) -> bool:
return self.key_size is None
def check_key_type(self, key: Key) -> None:
if key.key_type not in self.key_types:
raise InvalidKeyTypeError()
def prepare_recipient_header(self, recipient: Recipient[t.Any]) -> None:
raise NotImplementedError()
class JWEDirectEncryption(KeyManagement, metaclass=ABCMeta):
key_types = ["oct"]
@abstractmethod
def compute_cek(self, size: int, recipient: Recipient[OctKey]) -> bytes:
pass
class JWEKeyEncryption(KeyManagement, metaclass=ABCMeta):
@property
def direct_mode(self) -> bool:
return False
@abstractmethod
def encrypt_cek(self, cek: bytes, recipient: Recipient[t.Any]) -> bytes:
pass
@abstractmethod
def decrypt_cek(self, recipient: Recipient[t.Any]) -> bytes:
pass
class JWEKeyWrapping(KeyManagement, metaclass=ABCMeta):
key_size: int
key_types = ["oct"]
@property
def direct_mode(self) -> bool:
return False
def check_op_key(self, op_key: bytes) -> None:
if len(op_key) * 8 != self.key_size:
raise InvalidKeyLengthError(f"A key of size {self.key_size} bits MUST be used")
@abstractmethod
def wrap_cek(self, cek: bytes, key: bytes) -> bytes:
pass
@abstractmethod
def unwrap_cek(self, ek: bytes, key: bytes) -> bytes:
pass
@abstractmethod
def encrypt_cek(self, cek: bytes, recipient: Recipient[OctKey]) -> bytes:
pass
@abstractmethod
def decrypt_cek(self, recipient: Recipient[OctKey]) -> bytes:
pass
class JWEKeyAgreement(KeyManagement, metaclass=ABCMeta):
key_types = ["EC", "OKP"]
tag_aware: bool = False
key_wrapping: t.Optional[JWEKeyWrapping]
def prepare_ephemeral_key(self, recipient: Recipient[ECKey]) -> None:
recipient_key = recipient.recipient_key
assert recipient_key is not None
self.check_key_type(recipient_key)
if recipient.ephemeral_key is None:
ephemeral_key = recipient_key.generate_key(recipient_key.curve_name, private=True)
recipient.ephemeral_key = ephemeral_key
recipient.add_header("epk", recipient.ephemeral_key.as_dict(private=False))
@abstractmethod
def encrypt_agreed_upon_key(self, enc: JWEEncModel, recipient: Recipient[ECKey]) -> bytes:
pass
@abstractmethod
def decrypt_agreed_upon_key(self, enc: JWEEncModel, recipient: Recipient[ECKey]) -> bytes:
pass
def wrap_cek_with_auk(self, cek: bytes, key: bytes) -> bytes:
assert self.key_wrapping is not None
return self.key_wrapping.wrap_cek(cek, key)
def unwrap_cek_with_auk(self, ek: bytes, key: bytes) -> bytes:
assert self.key_wrapping is not None
return self.key_wrapping.unwrap_cek(ek, key)
def encrypt_agreed_upon_key_with_tag(self, enc: JWEEncModel, recipient: Recipient[ECKey], tag: bytes) -> bytes:
raise NotImplementedError()
def decrypt_agreed_upon_key_with_tag(self, enc: JWEEncModel, recipient: Recipient[ECKey], tag: bytes) -> bytes:
raise NotImplementedError()
JWEAlgModel = t.Union[JWEKeyEncryption, JWEKeyWrapping, JWEKeyAgreement, JWEDirectEncryption]
@@ -0,0 +1,177 @@
import warnings
import typing as t
from collections.abc import Collection
from .models import JWEAlgModel, JWEEncModel, JWEZipModel
from ..errors import (
UnsupportedAlgorithmError,
SecurityWarning,
ExceededSizeError,
)
from ..registry import (
Header,
HeaderRegistryDict,
JWE_HEADER_REGISTRY,
check_supported_header,
check_registry_header,
check_crit_header,
)
__all__ = [
"JWEAlgorithm",
"JWERegistry",
"default_registry",
]
JWEAlgorithm = t.Union[JWEAlgModel, JWEEncModel, JWEZipModel]
AlgorithmsDict = t.TypedDict(
"AlgorithmsDict",
{
"alg": dict[str, JWEAlgModel],
"enc": dict[str, JWEEncModel],
"zip": dict[str, JWEZipModel],
},
)
class JWERegistry:
"""A registry for JSON Web Encryption to keep all the supported algorithms.
An instance of ``JWERegistry`` is usually used together with methods in
``joserfc.jwe``.
:param header_registry: extra header parameters registry
:param algorithms: allowed algorithms to be used
:param verify_all_recipients: validating all recipients in a JSON serialization
:param strict_check_header: only allow header key in the registry to be used
:param max_recipients: max number of recipients in JWE General JSON Serialization
"""
algorithms: AlgorithmsDict = {
"alg": {},
"enc": {},
"zip": {},
}
recommended: t.ClassVar[list[str]] = []
#: max protected header content's size in bytes
max_protected_header_length: int = 1024
#: max encrypted key's size in bytes
max_encrypted_key_length: int = 1024
#: max initialization vector's size in bytes
max_initialization_vector_length: int = 64
#: max ciphertext's size in bytes
max_ciphertext_length: int = 65536 # 64KB
#: max auth tag's size in bytes
max_auth_tag_length: int = 64
#: max number of recipients in JWE General JSON Serialization
max_recipients: int = 16
def __init__(
self,
header_registry: HeaderRegistryDict | None = None,
algorithms: Collection[str] | None = None,
verify_all_recipients: bool = True,
strict_check_header: bool = True,
max_recipients: int | None = None,
):
self.header_registry: HeaderRegistryDict = {}
self.header_registry.update(JWE_HEADER_REGISTRY)
if header_registry is not None:
self.header_registry.update(header_registry)
self.allowed = algorithms
self.verify_all_recipients = verify_all_recipients
self.strict_check_header = strict_check_header
if max_recipients is not None:
self.max_recipients = max_recipients
@classmethod
def register(cls, model: JWEAlgorithm) -> None:
cls.algorithms[model.algorithm_location][model.name] = model # type: ignore
if model.recommended:
cls.recommended.append(model.name)
def check_header(self, header: Header, check_more: bool = False) -> None:
"""Check and validate the fields in header part of a JWS object."""
check_crit_header(self.header_registry, header)
check_registry_header(self.header_registry, header)
alg = self.get_alg(header["alg"])
if alg.more_header_registry:
check_registry_header(alg.more_header_registry, header, check_more)
if self.strict_check_header:
allowed_registry = self.header_registry.copy()
allowed_registry.update(alg.more_header_registry)
check_supported_header(allowed_registry, header)
elif self.strict_check_header:
check_supported_header(self.header_registry, header)
def validate_protected_header_size(self, header: bytes) -> None:
if header and len(header) > self.max_protected_header_length:
raise ExceededSizeError(f"Header size exceeds {self.max_protected_header_length} bytes.")
def validate_encrypted_key_size(self, ek: bytes) -> None:
if ek and len(ek) > self.max_encrypted_key_length:
raise ExceededSizeError(f"Encrypted key size exceeds {self.max_encrypted_key_length} bytes.")
def validate_initialization_vector_size(self, iv: bytes) -> None:
if iv and len(iv) > self.max_initialization_vector_length:
raise ExceededSizeError(
f"Initialization vector size exceeds {self.max_initialization_vector_length} bytes."
)
def validate_ciphertext_size(self, ciphertext: bytes) -> None:
if ciphertext and len(ciphertext) > self.max_ciphertext_length:
raise ExceededSizeError(f"Ciphertext size exceeds {self.max_ciphertext_length} bytes.")
def validate_auth_tag_size(self, tag: bytes) -> None:
if tag and len(tag) > self.max_auth_tag_length:
raise ExceededSizeError(f"Auth tag size exceeds {self.max_auth_tag_length} bytes.")
def validate_recipient_count(self, recipients: Collection[t.Any]) -> None:
if len(recipients) > self.max_recipients:
raise ExceededSizeError(f"Recipient count exceeds {self.max_recipients}.")
def get_alg(self, name: str) -> JWEAlgModel:
"""Get the allowed ("alg") algorithm instance of the given name.
:param name: value of the ``alg``, e.g. ``ECDH-ES``, ``A128KW``
"""
registry = self.algorithms["alg"]
self._check_algorithm(name, registry)
alg: JWEAlgModel = registry[name]
if alg.security_warning:
warnings.warn(alg.security_warning, SecurityWarning)
return alg
def get_enc(self, name: str) -> JWEEncModel:
"""Get the allowed ("enc") algorithm instance of the given name.
:param name: value of the ``enc``, e.g. ``A128CBC-HS256``, ``A128GCM``
"""
registry = self.algorithms["enc"]
self._check_algorithm(name, registry)
return registry[name]
def get_zip(self, name: str) -> JWEZipModel:
"""Get the allowed ("zip") algorithm instance of the given name.
:param name: value of the ``zip``, e.g. ``DEF``
"""
registry = self.algorithms["zip"]
self._check_algorithm(name, registry)
return registry[name]
def _check_algorithm(self, name: str, registry: dict[str, t.Any]) -> None:
if name not in registry:
raise UnsupportedAlgorithmError(f"Algorithm of '{name}' is not supported")
if self.allowed:
if name not in self.allowed:
raise UnsupportedAlgorithmError(f"Algorithm of '{name}' is not allowed")
else:
if name not in self.recommended:
raise UnsupportedAlgorithmError(f"Algorithm of '{name}' is not recommended")
default_registry = JWERegistry()
@@ -0,0 +1,33 @@
from typing import Any, TypedDict
__all__ = [
"JSONRecipientDict",
"FlattenedJSONSerialization",
"GeneralJSONSerialization",
]
class JSONRecipientDict(TypedDict, total=False):
header: dict[str, Any]
encrypted_key: str
class GeneralJSONSerialization(TypedDict, total=False):
protected: str
unprotected: dict[str, Any]
iv: str
aad: str
ciphertext: str
tag: str
recipients: list[JSONRecipientDict]
class FlattenedJSONSerialization(TypedDict, total=False):
protected: str
unprotected: dict[str, Any]
iv: str
aad: str
ciphertext: str
tag: str
header: dict[str, Any]
encrypted_key: str
@@ -0,0 +1,345 @@
import typing as t
from collections.abc import KeysView
from abc import ABCMeta, abstractmethod
from .types import DictKey, AnyKey, KeyParameters
from .._rfc7638 import calculate_thumbprint
from .._rfc9278 import concat_thumbprint_uri
from ..registry import (
KeyParameterRegistryDict,
JWK_PARAMETER_REGISTRY,
KeyOperationRegistryDict,
JWK_OPERATION_REGISTRY,
)
from ..util import to_bytes
from ..errors import (
KeyParameterError,
UnsupportedKeyUseError,
UnsupportedKeyAlgorithmError,
UnsupportedKeyOperationError,
)
NativePrivateKey = t.TypeVar("NativePrivateKey")
NativePublicKey = t.TypeVar("NativePublicKey")
GenericKey = t.TypeVar("GenericKey", bound="BaseKey[t.Any, t.Any]")
class NativeKeyBinding(metaclass=ABCMeta):
use_key_ops_registry: t.ClassVar[dict[str, list[str]]] = {
"sig": ["sign", "verify"],
"enc": ["encrypt", "decrypt", "wrapKey", "unwrapKey", "deriveKey", "deriveBits"],
}
@classmethod
@abstractmethod
def convert_raw_key_to_dict(cls, raw_key: t.Any, private: bool) -> DictKey:
pass
@classmethod
@abstractmethod
def import_from_dict(cls, value: DictKey) -> t.Any:
pass
@classmethod
@abstractmethod
def import_from_bytes(cls, value: bytes, password: t.Any = None) -> t.Any:
pass
@staticmethod
def as_bytes(
key: GenericKey,
encoding: t.Literal["PEM", "DER"] | None = None,
private: bool = False,
password: str | None = None,
) -> bytes:
raise NotImplementedError()
@classmethod
def validate_dict_key_registry(cls, dict_key: DictKey, registry: KeyParameterRegistryDict) -> None:
for k in registry:
if registry[k].required and k not in dict_key:
raise KeyParameterError(f"'{k}' is required")
if k in dict_key:
try:
registry[k].validate(dict_key[k])
except ValueError as error:
raise KeyParameterError(f"'{k}' {error}")
@classmethod
def validate_dict_key_use_operations(cls, dict_key: DictKey) -> None:
if "use" in dict_key and "key_ops" in dict_key:
_use: str = dict_key["use"] # type: ignore
operations = cls.use_key_ops_registry[_use]
for op in dict_key["key_ops"]:
if op not in operations:
raise KeyParameterError("'use' and 'key_ops' does not match")
class BaseKey(t.Generic[NativePrivateKey, NativePublicKey], metaclass=ABCMeta):
key_type: t.ClassVar[str]
binding: t.ClassVar[type[NativeKeyBinding]]
value_registry: t.ClassVar[KeyParameterRegistryDict]
param_registry: t.ClassVar[KeyParameterRegistryDict] = JWK_PARAMETER_REGISTRY
operation_registry: t.ClassVar[KeyOperationRegistryDict] = JWK_OPERATION_REGISTRY
thumbprint_digest_method: t.Literal["sha256", "sha384", "sha512"] = "sha256"
def __init__(
self,
raw_value: NativePrivateKey | NativePublicKey,
original_value: t.Any,
parameters: KeyParameters | None = None,
):
self._raw_value = raw_value
self.original_value = original_value
self.extra_parameters = parameters
self._dict_value: DictKey = {}
if isinstance(original_value, dict):
if parameters is not None:
data = {**original_value, **parameters, "kty": self.key_type}
else:
data = {**original_value, "kty": self.key_type}
self.validate_dict_key(data)
self._dict_value = data
def __eq__(self, other: t.Any) -> bool:
if not isinstance(other, self.__class__):
return False
return self.dict_value == other.dict_value
def keys(self) -> KeysView[str]:
return self.dict_value.keys()
def __getitem__(self, k: str) -> str | list[str]:
return self.dict_value[k]
def get(self, k: str, default: str | list[str] | None = None) -> str | list[str] | None:
return self.dict_value.get(k, default)
def ensure_kid(self) -> None:
"""Ensure this key has a ``kid``. If ``kid`` is not provided by default,
it will generate the kid with ``.thumbprint`` method, which is defined
by RFC7638."""
if "kid" not in self.dict_value:
self._dict_value["kid"] = self.thumbprint()
@property
def kid(self) -> str | None:
"""The "kid" value of the JSON Web Key."""
return t.cast(t.Optional[str], self.get("kid"))
@property
def alg(self) -> str | None:
"""The "alg" value of the JSON Web Key."""
return t.cast(t.Optional[str], self.get("alg"))
@property
def raw_value(self) -> t.Any:
raise NotImplementedError()
@property
def is_private(self) -> bool:
raise NotImplementedError()
@property
def dict_value(self) -> DictKey:
"""Property of the Key in Dict (JSON)."""
if self._dict_value:
return self._dict_value
data = self.binding.convert_raw_key_to_dict(self.raw_value, self.is_private)
if self.extra_parameters is not None:
data.update(self.extra_parameters) # type: ignore
data["kty"] = self.key_type
self.validate_dict_key(data)
self._dict_value = data
return data
@property
def public_key(self) -> NativePublicKey:
raise NotImplementedError()
@property
def private_key(self) -> NativePrivateKey | None:
raise NotImplementedError()
def thumbprint(self) -> str:
"""Call this method will generate the thumbprint with algorithm
defined in RFC7638."""
fields = [k for k in self.value_registry if self.value_registry[k].required]
fields.append("kty")
data = {key: self.dict_value[key] for key in fields}
return calculate_thumbprint(data, self.thumbprint_digest_method)
def thumbprint_uri(self) -> str:
"""Call this method will generate the thumbprint URI
defined in RFC9278."""
value = self.thumbprint()
return concat_thumbprint_uri(value, self.thumbprint_digest_method)
def as_dict(self, private: bool = False, **params: t.Any) -> DictKey:
"""Output this key to a JWK format (in dict). By default, it will return
the ``dict_value`` of this key.
:param private: determine whether this method should output private key or not
:param params: other parameters added into this key
:raise: ValueError
"""
# check private conflicts
if private and not self.is_private:
raise ValueError("This key is not a private key.")
data = self.dict_value.copy()
if private:
data.update(params)
return data
# clear private fields
for k in self.dict_value:
if k in self.value_registry and self.value_registry[k].private:
del data[k]
data.update(params)
return data
def check_use(self, use: str) -> None:
"""Check if this key supports the given "use".
Values defined by this specification are:
- "sig" (signature)
- "enc" (encryption)
Other values MAY be used. The "use" value is a case-sensitive
string. Use of the "use" member is OPTIONAL, unless the application
requires its presence.
:param use: this key is used for, e.g. "sig", "enc"
:raise UnsupportedKeyUseError: if this key is not designed for the given use
"""
designed_use = self.get("use")
if designed_use and designed_use != use:
raise UnsupportedKeyUseError(f"This key is designed to be used for '{designed_use}'")
def check_alg(self, alg: str) -> None:
"""Check if this key supports the given "alg".
:param alg: the algorithm this key is intended to be used, e.g. "HS256", "ECDH-EC"
:raise UnsupportedKeyAlgorithmError: if this key is not designed for the given algorithm
"""
designed_alg = self.get("alg")
if designed_alg and designed_alg != alg:
raise UnsupportedKeyAlgorithmError(f"This key is designed for algorithm '{designed_alg}'")
def check_key_op(self, operation: str) -> None:
"""Check if the given key_op is supported by this key.
:param operation: key operation value, such as "sign", "encrypt".
:raise UnsupportedKeyOperationError: if the operation is not supported by this key.
"""
key_ops = self.get("key_ops")
if key_ops is not None and operation not in key_ops:
raise UnsupportedKeyOperationError(f"Unsupported key_op '{operation}'")
assert operation in self.operation_registry
reg = self.operation_registry[operation]
if reg.private and not self.is_private:
raise UnsupportedKeyOperationError(f"Invalid key_op '{operation}' for public key")
@t.overload
def get_op_key(self, operation: t.Literal["verify", "encrypt", "wrapKey", "deriveKey"]) -> NativePublicKey: ...
@t.overload
def get_op_key(self, operation: t.Literal["sign", "decrypt", "unwrapKey"]) -> NativePrivateKey: ...
def get_op_key(self, operation: str) -> NativePublicKey | NativePrivateKey:
self.check_key_op(operation)
reg = self.operation_registry[operation]
if reg.private:
assert self.private_key is not None
return self.private_key
return self.public_key
@classmethod
def validate_dict_key(cls, data: DictKey) -> None:
cls.binding.validate_dict_key_registry(data, cls.param_registry)
cls.binding.validate_dict_key_registry(data, cls.value_registry)
cls.binding.validate_dict_key_use_operations(data)
@classmethod
def import_key(
cls: type[GenericKey],
value: AnyKey,
parameters: KeyParameters | None = None,
password: t.Any = None,
) -> GenericKey:
if isinstance(value, dict):
cls.validate_dict_key(value)
raw_key = cls.binding.import_from_dict(value)
return cls(raw_key, value, parameters)
raw_key = cls.binding.import_from_bytes(to_bytes(value), password)
return cls(raw_key, value, parameters)
@classmethod
def generate_key(
cls: type[GenericKey],
*,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> GenericKey:
raise NotImplementedError()
class SymmetricKey(BaseKey[bytes, bytes], metaclass=ABCMeta):
@property
def raw_value(self) -> bytes:
"""The raw key in bytes."""
return self._raw_value
@property
def is_private(self) -> bool:
"""A symmetric key will always be private."""
return True
@property
def public_key(self) -> bytes:
"""Returns the ``raw_value`` as the public key."""
return self.raw_value
@property
def private_key(self) -> bytes:
"""Returns the ``raw_value`` as the private key."""
return self.raw_value
class AsymmetricKey(BaseKey[NativePrivateKey, NativePublicKey], metaclass=ABCMeta):
@property
def raw_value(self) -> t.Union[NativePublicKey, NativePrivateKey]:
return self._raw_value
def as_bytes(
self,
encoding: t.Literal["PEM", "DER"] | None = None,
private: bool = False,
password: str | None = None,
) -> bytes:
return self.binding.as_bytes(self, encoding, private, password)
def as_pem(self, private: bool = False, password: str | None = None) -> bytes:
return self.as_bytes(private=private, password=password)
def as_der(self, private: bool = False, password: str | None = None) -> bytes:
return self.as_bytes(encoding="DER", private=private, password=password)
class CurveKey(AsymmetricKey[NativePrivateKey, NativePublicKey]):
@property
@abstractmethod
def curve_name(self) -> str:
pass
@abstractmethod
def exchange_derive_key(self, key: t.Any) -> bytes:
pass
@@ -0,0 +1,163 @@
from typing import Any, Literal, cast
from abc import ABCMeta, abstractmethod
from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
load_pem_public_key,
load_ssh_public_key,
load_ssh_private_key,
load_der_private_key,
load_der_public_key,
Encoding,
PrivateFormat,
PublicFormat,
KeySerializationEncryption,
BestAvailableEncryption,
NoEncryption,
)
from .models import NativeKeyBinding, GenericKey
from .types import DictKey
from ..errors import InvalidKeyTypeError
from ..util import to_bytes
def import_from_ssh_key(raw: bytes) -> Any:
return load_ssh_public_key(raw)
def import_from_pem_key(raw: bytes, password: bytes | None = None) -> Any:
key: Any
if b"OPENSSH PRIVATE" in raw:
key = load_ssh_private_key(raw, password=password)
elif b"PUBLIC" in raw:
key = load_pem_public_key(raw)
elif b"PRIVATE" in raw:
key = load_pem_private_key(raw, password=password)
elif b"CERTIFICATE" in raw:
cert = load_pem_x509_certificate(raw)
return cert.public_key()
else:
try:
key = load_der_private_key(raw, password=password)
except ValueError:
key = load_der_public_key(raw)
return key
def dump_pem_key(
key: Any,
encoding: Literal["PEM", "DER"] | None = None,
private: bool | None = False,
password: Any | None = None,
) -> bytes:
"""Export key into PEM/DER format bytes.
:param key: native cryptography key
:param encoding: "PEM" or "DER"
:param private: export private key or public key
:param password: encrypt private key with password
:return: bytes
"""
if encoding is None or encoding == "PEM":
encoding_enum = Encoding.PEM
elif encoding == "DER":
encoding_enum = Encoding.DER
else: # pragma: no cover
raise ValueError(f"Invalid encoding: {encoding}")
if private:
encryption_algorithm: KeySerializationEncryption
if password is None:
encryption_algorithm = NoEncryption()
else:
encryption_algorithm = BestAvailableEncryption(to_bytes(password))
value = key.private_bytes(
encoding=encoding_enum,
format=PrivateFormat.PKCS8,
encryption_algorithm=encryption_algorithm,
)
else:
value = key.public_bytes(
encoding=encoding_enum,
format=PublicFormat.SubjectPublicKeyInfo,
)
return cast(bytes, value)
class CryptographyBinding(NativeKeyBinding, metaclass=ABCMeta):
key_type: str
ssh_type: bytes
_cryptography_key_types: Any
@classmethod
def check_ssh_type(cls, value: bytes) -> bool:
return value.startswith(cls.ssh_type)
@classmethod
def check_cryptography_key(cls, native_key: Any) -> bool:
return isinstance(native_key, cls._cryptography_key_types)
@classmethod
def convert_raw_key_to_dict(cls, raw_key: Any, private: bool) -> DictKey:
if private:
value = cls.export_private_key(raw_key)
else:
value = cls.export_public_key(raw_key)
return cast(DictKey, value)
@classmethod
def import_from_dict(cls, value: DictKey) -> Any:
if "d" in value:
return cls.import_private_key(value)
return cls.import_public_key(value)
@classmethod
def import_from_bytes(cls, value: bytes, password: Any | None = None) -> Any:
if cls.check_ssh_type(value):
return import_from_ssh_key(value)
if password is not None:
password = to_bytes(password)
key = import_from_pem_key(value, password)
if not cls.check_cryptography_key(key):
raise InvalidKeyTypeError(f"Not a key of: '{cls.key_type}'")
return key
@staticmethod
def as_bytes(
key: GenericKey,
encoding: Literal["PEM", "DER"] | None = None,
private: bool = False,
password: Any | None = None,
) -> bytes:
if private:
return dump_pem_key(key.private_key, encoding, private, password)
else:
return dump_pem_key(key.public_key, encoding, private, password)
@classmethod
@abstractmethod
def import_private_key(cls, obj: Any) -> Any:
pass
@classmethod
@abstractmethod
def import_public_key(cls, obj: Any) -> Any:
pass
@classmethod
@abstractmethod
def export_private_key(cls, key: Any) -> Any:
pass
@classmethod
@abstractmethod
def export_public_key(cls, key: Any) -> Any:
pass
@@ -0,0 +1,25 @@
import typing as t
__all__ = ["DictKey", "AnyKey", "KeyParameters"]
#: JSON Web Key in dict
DictKey = dict[str, t.Union[str, list[str]]]
#: Key in str, bytes and dict
AnyKey = t.Union[str, bytes, DictKey]
#: extra key parameters for JWK
KeyParameters = t.TypedDict(
"KeyParameters",
{
"use": str,
"key_ops": list[str],
"alg": str,
"kid": str,
"x5u": str,
"x5c": list[str],
"x5t": str,
"x5t#S256": str,
},
total=False,
)
@@ -0,0 +1,53 @@
import struct
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.concatkdf import ConcatKDFHash
from ..registry import Header
from ..util import to_bytes, urlsafe_b64decode
__all__ = [
"derive_key_for_concat_kdf",
"u32be_len_input",
]
def derive_key_for_concat_kdf(
shared_key: bytes, header: Header, cek_size: int, key_size: int | None, tag: bytes | None = None
) -> bytes:
# PartyUInfo
apu_info = u32be_len_input(header.get("apu"), True)
# PartyVInfo
apv_info = u32be_len_input(header.get("apv"), True)
# SuppPubInfo
if key_size:
alg_id = u32be_len_input(header["alg"])
bit_size = key_size
else:
alg_id = u32be_len_input(header["enc"])
bit_size = cek_size
pub_info = struct.pack(">I", bit_size)
fixed_info = alg_id + apu_info + apv_info + pub_info
if tag:
cctag = u32be_len_input(tag)
fixed_info += cctag
ckdf = ConcatKDFHash(
algorithm=hashes.SHA256(),
length=bit_size // 8,
otherinfo=fixed_info,
)
return ckdf.derive(shared_key)
def u32be_len_input(s: bytes | str | None, use_base64: bool = False) -> bytes:
if not s:
return b"\x00\x00\x00\x00"
sb: bytes
if use_base64:
sb = urlsafe_b64decode(to_bytes(s))
else:
sb = to_bytes(s)
return struct.pack(">I", len(sb)) + sb
@@ -0,0 +1,266 @@
import typing as t
from functools import cached_property
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key,
derive_private_key,
ECDH,
EllipticCurvePublicKey,
EllipticCurvePrivateKey,
EllipticCurvePrivateNumbers,
EllipticCurvePublicNumbers,
EllipticCurve,
SECP256R1,
SECP384R1,
SECP521R1,
)
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from ..errors import InvalidExchangeKeyError, InvalidKeyCurveError
from .._rfc7517.models import CurveKey
from .._rfc7517.pem import CryptographyBinding
from .._rfc7517.types import KeyParameters, AnyKey
from ..util import base64_to_int, int_to_base64, to_bytes
from ..registry import KeyParameter
__all__ = ["ECKey"]
ECDictKey = t.TypedDict(
"ECDictKey",
{
"crv": str,
"x": str,
"y": str,
"d": str, # optional
},
total=False,
)
class ECBinding(CryptographyBinding):
key_type = "EC"
ssh_type = b"ecdsa-sha2-"
_cryptography_key_types = (EllipticCurvePrivateKey, EllipticCurvePublicKey)
_dss_curves: dict[str, type[EllipticCurve]] = {}
_curves_dss: dict[str, str] = {}
@classmethod
def register_curve(cls, name: str, curve: type[EllipticCurve]) -> None:
cls._dss_curves[name] = curve
cls._curves_dss[str(curve.name)] = name
@classmethod
def generate_private_key(cls, name: str) -> EllipticCurvePrivateKey:
if name not in cls._dss_curves:
raise InvalidKeyCurveError(f"Invalid crv value: '{name}'")
curve = cls._dss_curves[name]()
return generate_private_key(curve=curve)
@classmethod
def import_private_key(cls, obj: ECDictKey) -> EllipticCurvePrivateKey:
curve = cls._dss_curves[obj["crv"]]()
public_numbers = EllipticCurvePublicNumbers(
base64_to_int(obj["x"]),
base64_to_int(obj["y"]),
curve,
)
d = base64_to_int(obj["d"])
private_numbers = EllipticCurvePrivateNumbers(d, public_numbers)
return private_numbers.private_key()
@classmethod
def export_private_key(cls, key: EllipticCurvePrivateKey) -> ECDictKey:
numbers = key.private_numbers()
byte_count = (key.key_size + 7) // 8
return {
"crv": cls._curves_dss[key.curve.name],
"x": int_to_base64(numbers.public_numbers.x, byte_count),
"y": int_to_base64(numbers.public_numbers.y, byte_count),
"d": int_to_base64(numbers.private_value, byte_count),
}
@classmethod
def import_public_key(cls, obj: ECDictKey) -> EllipticCurvePublicKey:
curve = cls._dss_curves[obj["crv"]]()
public_numbers = EllipticCurvePublicNumbers(
base64_to_int(obj["x"]),
base64_to_int(obj["y"]),
curve,
)
return public_numbers.public_key()
@classmethod
def export_public_key(cls, key: EllipticCurvePublicKey) -> ECDictKey:
numbers = key.public_numbers()
byte_count = (key.key_size + 7) // 8
return {
"crv": cls._curves_dss[numbers.curve.name],
"x": int_to_base64(numbers.x, byte_count),
"y": int_to_base64(numbers.y, byte_count),
}
# register default curves with their DSS (Digital Signature Standard) names
# https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-4.pdf
ECBinding.register_curve("P-256", SECP256R1)
ECBinding.register_curve("P-384", SECP384R1)
ECBinding.register_curve("P-521", SECP521R1)
class ECKey(CurveKey[EllipticCurvePrivateKey, EllipticCurvePublicKey]):
key_type = "EC"
#: Registry definition for EC Key
#: https://www.rfc-editor.org/rfc/rfc7518#section-6.2
value_registry = {
"crv": KeyParameter("Curve", "str", private=False, required=True),
"x": KeyParameter("X Coordinate", "str", private=False, required=True),
"y": KeyParameter("Y Coordinate", "str", private=False, required=True),
"d": KeyParameter("EC Private Key", "str", private=True, required=False),
}
binding = ECBinding
@property
def is_private(self) -> bool:
return isinstance(self.raw_value, EllipticCurvePrivateKey)
@cached_property
def public_key(self) -> EllipticCurvePublicKey:
if isinstance(self.raw_value, EllipticCurvePrivateKey):
return self.raw_value.public_key()
return self.raw_value
@property
def private_key(self) -> EllipticCurvePrivateKey | None:
if isinstance(self.raw_value, EllipticCurvePrivateKey):
return self.raw_value
return None
def exchange_derive_key(self, key: "ECKey") -> bytes:
pubkey = key.get_op_key("deriveKey")
if self.private_key and self.curve_name == key.curve_name:
return self.private_key.exchange(ECDH(), pubkey)
raise InvalidExchangeKeyError()
@property
def curve_name(self) -> str:
return self.binding._curves_dss[self.raw_value.curve.name]
@property
def curve_key_size(self) -> int:
return self.raw_value.curve.key_size
@classmethod
def import_key(
cls: t.Any,
value: AnyKey | EllipticCurvePrivateKey | EllipticCurvePublicKey,
parameters: KeyParameters | None = None,
password: t.Any = None,
) -> "ECKey":
key: ECKey
if isinstance(value, (EllipticCurvePrivateKey, EllipticCurvePublicKey)):
key = cls(value, value, parameters)
else:
key = super(ECKey, cls).import_key(value, parameters, password)
return key
@classmethod
def generate_key(
cls: type["ECKey"],
crv: str | None = "P-256",
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> "ECKey":
"""Generate a ``ECKey`` with the given "crv" value.
:param crv: ECKey curve name
:param parameters: extra parameter in JWK
:param private: generate a private key or public key
:param auto_kid: add ``kid`` automatically
"""
if crv is None:
crv = "P-256"
raw_key = cls.binding.generate_private_key(crv)
return _wrap_key(cls, raw_key, private, auto_kid, parameters)
@classmethod
def derive_key(
cls: type["ECKey"],
secret: bytes | str,
crv: str = "P-256",
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
kdf_name: t.Literal["HKDF", "PBKDF2"] = "HKDF",
kdf_options: dict[str, t.Any] | None = None,
) -> "ECKey":
"""
Generate an elliptic curve cryptographic key derived from a secret input using a key
derivation function (KDF). This allows the creation of deterministic elliptic curve
keys based on given input data, curve specification, and KDF options.
:param secret: The input secret used for key derivation
:param crv: ECKey curve name
:param parameters: extra parameter in JWK
:param private: generate a private key or public key
:param auto_kid: add ``kid`` automatically
:param kdf_name: Key derivation function name
:param kdf_options: Additional options for the KDF
"""
try:
curve_class = cls.binding._dss_curves[crv]
except KeyError:
raise InvalidKeyCurveError(f"Invalid crv value: '{crv}'")
curve = curve_class()
length = (curve.group_order.bit_length() + 7) // 8 * 2
if kdf_options is None:
kdf_options = {}
algorithm = kdf_options.pop("algorithm", None)
if algorithm is None:
algorithm = hashes.SHA256()
kdf_options.setdefault("salt", to_bytes(f"joserfc:EC:{kdf_name}:{crv}"))
if kdf_name == "HKDF":
kdf_options.setdefault("info", b"")
hkdf = HKDF(
algorithm=algorithm,
length=length,
**kdf_options,
)
seed = hkdf.derive(to_bytes(secret))
elif kdf_name == "PBKDF2":
kdf_options.setdefault("iterations", 100000)
pbkdf2 = PBKDF2HMAC(
algorithm=algorithm,
length=length,
**kdf_options,
)
seed = pbkdf2.derive(to_bytes(secret))
else:
raise ValueError(f"Invalid kdf value: '{kdf_name}'")
d = int.from_bytes(seed, "big") % curve.group_order
raw_key = derive_private_key(d, curve)
return _wrap_key(cls, raw_key, private, auto_kid, parameters)
def _wrap_key(
cls: type["ECKey"],
raw_key: EllipticCurvePrivateKey,
private: bool,
auto_kid: bool,
parameters: KeyParameters | None = None,
) -> ECKey:
if private:
key = cls(raw_key, raw_key, parameters)
else:
pub_key = raw_key.public_key()
key = cls(pub_key, pub_key, parameters)
if auto_kid:
key.ensure_kid()
return key
@@ -0,0 +1,351 @@
import secrets
import warnings
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.keywrap import (
aes_key_wrap,
aes_key_unwrap,
InvalidUnwrap,
)
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.primitives.ciphers.modes import GCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.exceptions import InvalidTag
from .derive_key import derive_key_for_concat_kdf
from .oct_key import OctKey
from .rsa_key import RSAKey
from .ec_key import ECKey
from .._rfc7516.models import (
JWEAlgModel,
JWEDirectEncryption,
JWEKeyEncryption,
JWEKeyWrapping,
JWEKeyAgreement,
JWEEncModel,
Recipient,
)
from ..util import to_bytes, urlsafe_b64encode, urlsafe_b64decode
from ..registry import HeaderParameter
from ..errors import (
InvalidKeyLengthError,
DecodeError,
SecurityWarning,
)
class DirectAlgEncryption(JWEDirectEncryption):
name = "dir"
description = "Direct use of a shared symmetric key"
recommended = True
def compute_cek(self, size: int, recipient: Recipient[OctKey]) -> bytes:
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
cek = key.raw_value
if len(cek) * 8 != size:
raise InvalidKeyLengthError(f"A key of size {size} bits MUST be used")
return cek
class RSAAlgKeyEncryption(JWEKeyEncryption):
#: A key of size 2048 bits or larger MUST be used with these algorithms
#: RSA1_5, RSA-OAEP, RSA-OAEP-256
key_size = 2048
key_types = ["RSA"]
def __init__(self, name: str, description: str, pad_fn: padding.AsymmetricPadding, recommended: bool = False):
self.name = name
self.description = description
self.padding = pad_fn
self.recommended = recommended
def encrypt_cek(self, cek: bytes, recipient: Recipient[RSAKey]) -> bytes:
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
op_key = key.get_op_key("encrypt")
if op_key.key_size < self.key_size:
raise InvalidKeyLengthError(f"A key of size {self.key_size} bits or larger MUST be used")
return op_key.encrypt(cek, self.padding)
def decrypt_cek(self, recipient: Recipient[RSAKey]) -> bytes:
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
op_key = key.get_op_key("decrypt")
try:
assert recipient.encrypted_key is not None
cek = op_key.decrypt(recipient.encrypted_key, self.padding)
except ValueError as error:
raise DecodeError(str(error))
return cek
class AESAlgKeyWrapping(JWEKeyWrapping):
def __init__(self, key_size: int, recommended: bool = False):
self.name = f"A{key_size}KW"
self.description = f"AES Key Wrap using {key_size}-bit key"
self.key_size = key_size
self.recommended = recommended
def wrap_cek(self, cek: bytes, key: bytes) -> bytes:
self.check_op_key(key)
return aes_key_wrap(key, cek)
def unwrap_cek(self, ek: bytes, key: bytes) -> bytes:
self.check_op_key(key)
try:
cek = aes_key_unwrap(key, ek)
except InvalidUnwrap:
raise DecodeError("Unwrap AES key failed")
return cek
def encrypt_cek(self, cek: bytes, recipient: Recipient[OctKey]) -> bytes:
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
op_key = key.get_op_key("wrapKey")
return self.wrap_cek(cek, op_key)
def decrypt_cek(self, recipient: Recipient[OctKey]) -> bytes:
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
op_key = key.get_op_key("unwrapKey")
assert recipient.encrypted_key is not None
return self.unwrap_cek(recipient.encrypted_key, op_key)
class AESGCMAlgKeyWrapping(JWEKeyWrapping):
more_header_registry = {
"iv": HeaderParameter("Initialization vector", "str", True),
"tag": HeaderParameter("Authentication tag", "str", True),
}
def __init__(self, key_size: int):
self.name = f"A{key_size}GCMKW"
self.description = f"Key wrapping with AES GCM using {key_size}-bit key"
self.key_size = key_size
def wrap_cek(self, cek: bytes, key: bytes) -> bytes: # pragma: no cover
raise RuntimeError(f"{self.name} can not be used together with Key Agreement")
def unwrap_cek(self, ek: bytes, key: bytes) -> bytes: # pragma: no cover
raise RuntimeError(f"{self.name} can not be used together with Key Agreement")
def encrypt_cek(self, cek: bytes, recipient: Recipient[OctKey]) -> bytes:
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
op_key = key.get_op_key("wrapKey")
self.check_op_key(op_key)
#: https://tools.ietf.org/html/rfc7518#section-4.7.1.1
#: The "iv" (initialization vector) Header Parameter value is the
#: base64url-encoded representation of the 96-bit IV value
iv_size = 96
iv = secrets.token_bytes(iv_size // 8)
cipher = Cipher(AES(op_key), GCM(iv))
enc = cipher.encryptor()
encrypted_key = enc.update(cek) + enc.finalize()
recipient.add_header("iv", urlsafe_b64encode(iv).decode("ascii"))
recipient.add_header("tag", urlsafe_b64encode(enc.tag).decode("ascii"))
return encrypted_key
def decrypt_cek(self, recipient: Recipient[OctKey]) -> bytes:
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
op_key = key.get_op_key("unwrapKey")
self.check_op_key(op_key)
headers = recipient.headers()
assert "iv" in headers
assert "tag" in headers
iv = urlsafe_b64decode(to_bytes(headers["iv"]))
tag = urlsafe_b64decode(to_bytes(headers["tag"]))
cipher = Cipher(AES(op_key), GCM(iv, tag))
d = cipher.decryptor()
try:
assert recipient.encrypted_key is not None
cek = d.update(recipient.encrypted_key) + d.finalize()
except InvalidTag as error:
raise DecodeError(str(error))
return cek
class ECDHESAlgKeyAgreement(JWEKeyAgreement):
key_types = ["EC", "OKP"]
more_header_registry = {
"epk": HeaderParameter("Ephemeral Public Key", "jwk", True),
"apu": HeaderParameter("Agreement PartyUInfo", "str"),
"apv": HeaderParameter("Agreement PartyVInfo", "str"),
}
# https://tools.ietf.org/html/rfc7518#section-4.6
def __init__(self, key_wrapping: JWEKeyWrapping | None = None):
if key_wrapping is None:
self.name = "ECDH-ES"
self.description = "ECDH-ES in the Direct Key Agreement mode"
self.key_size = None
self.recommended = True
else:
self.name = f"ECDH-ES+{key_wrapping.name}"
self.description = f"ECDH-ES using Concat KDF and CEK wrapped with {key_wrapping.name}"
self.key_size = key_wrapping.key_size
self.recommended = key_wrapping.recommended
self.key_wrapping = key_wrapping
def encrypt_agreed_upon_key(self, enc: JWEEncModel, recipient: Recipient[ECKey]) -> bytes:
recipient_key = recipient.recipient_key
assert recipient_key is not None
ephemeral_key = recipient.ephemeral_key
assert ephemeral_key is not None
shared_key = ephemeral_key.exchange_derive_key(recipient_key)
headers = recipient.headers()
return derive_key_for_concat_kdf(shared_key, headers, enc.cek_size, self.key_size)
def decrypt_agreed_upon_key(self, enc: JWEEncModel, recipient: Recipient[ECKey]) -> bytes:
headers = recipient.headers()
assert "epk" in headers
recipient_key = recipient.recipient_key
assert recipient_key is not None
self.check_key_type(recipient_key)
ephemeral_key = recipient_key.import_key(headers["epk"])
shared_key = recipient_key.exchange_derive_key(ephemeral_key)
return derive_key_for_concat_kdf(shared_key, headers, enc.cek_size, self.key_size)
def validate_p2c(value: int) -> None:
if not isinstance(value, int):
raise ValueError("must be an int")
# A minimum iteration count of 1000 is RECOMMENDED.
if value < 1000:
warnings.warn("A minimum iteration count of 1000 is RECOMMENDED", SecurityWarning)
max_value = 300000
if value > max_value:
raise ValueError(f"must be less than {max_value}")
class PBES2HSAlgKeyEncryption(JWEKeyEncryption):
# https://www.rfc-editor.org/rfc/rfc7518#section-4.8
key_size: int
more_header_registry = {
"p2s": HeaderParameter("PBES2 Salt Input", "str", True),
"p2c": HeaderParameter("PBES2 Count", validate_p2c, True),
}
key_types = ["oct"]
DEFAULT_P2C = 2048
def __init__(self, hash_size: int, key_wrapping: JWEKeyWrapping):
self.name = f"PBES2-HS{hash_size}+{key_wrapping.name}"
self.description = f"PBES2 with HMAC SHA-{hash_size} and {key_wrapping.name} wrapping"
self.key_size = key_wrapping.key_size
self.key_wrapping = key_wrapping
self.hash_alg = getattr(hashes, f"SHA{hash_size}")()
def compute_derived_key(self, key: bytes, p2s: bytes, p2c: int) -> bytes:
# The salt value used is (UTF8(Alg) || 0x00 || Salt Input)
salt = to_bytes(self.name) + b"\x00" + p2s
kdf = PBKDF2HMAC(
algorithm=self.hash_alg,
length=self.key_size // 8,
salt=salt,
iterations=p2c,
)
return kdf.derive(key)
def encrypt_cek(self, cek: bytes, recipient: Recipient[OctKey]) -> bytes:
headers = recipient.headers()
if "p2s" not in headers:
p2s = secrets.token_bytes(16)
recipient.add_header("p2s", urlsafe_b64encode(p2s).decode("ascii"))
else:
p2s = urlsafe_b64decode(to_bytes(headers["p2s"]))
if "p2c" not in headers:
# A minimum iteration count of 1000 is RECOMMENDED.
p2c = self.DEFAULT_P2C
recipient.add_header("p2c", p2c)
else:
p2c = headers["p2c"]
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
kek = self.compute_derived_key(key.get_op_key("deriveKey"), p2s, p2c)
return self.key_wrapping.wrap_cek(cek, kek)
def decrypt_cek(self, recipient: Recipient[OctKey]) -> bytes:
headers = recipient.headers()
assert "p2s" in headers
assert "p2c" in headers
p2s = urlsafe_b64decode(to_bytes(headers["p2s"]))
p2c = headers["p2c"]
key = recipient.recipient_key
assert key is not None
self.check_key_type(key)
kek = self.compute_derived_key(key.get_op_key("deriveKey"), p2s, p2c)
assert recipient.encrypted_key is not None
return self.key_wrapping.unwrap_cek(recipient.encrypted_key, kek)
RSA1_5 = RSAAlgKeyEncryption("RSA1_5", "RSAES-PKCS1-v1_5", padding.PKCS1v15())
RSA1_5.security_warning = 'JWE algorithm "RSA1_5" is deprecated, via draft-ietf-jose-deprecate-none-rsa15-02'
A128KW = AESAlgKeyWrapping(128, True) # A128KW, Recommended
A192KW = AESAlgKeyWrapping(192) # A192KW
A256KW = AESAlgKeyWrapping(256, True) # A256KW, Recommended
#: https://www.rfc-editor.org/rfc/rfc7518#section-4.1
JWE_ALG_MODELS: list[JWEAlgModel] = [
RSA1_5,
RSAAlgKeyEncryption(
"RSA-OAEP",
"RSAES OAEP using default parameters",
padding.OAEP(padding.MGF1(hashes.SHA1()), hashes.SHA1(), None),
True,
), # Recommended+
RSAAlgKeyEncryption(
"RSA-OAEP-256",
"RSAES OAEP using SHA-256 and MGF1 with SHA-256",
padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), None),
),
A128KW,
A192KW,
A256KW,
DirectAlgEncryption(), # dir, Recommended
ECDHESAlgKeyAgreement(None), # ECDH-ES, Recommended+
ECDHESAlgKeyAgreement(A128KW), # ECDH-ES+A128KW, Recommended
ECDHESAlgKeyAgreement(A192KW), # ECDH-ES+A192KW
ECDHESAlgKeyAgreement(A256KW), # ECDH-ES+A256KW, Recommended
AESGCMAlgKeyWrapping(128), # A128GCMKW
AESGCMAlgKeyWrapping(192), # A192GCMKW
AESGCMAlgKeyWrapping(256), # A256GCMKW
PBES2HSAlgKeyEncryption(256, A128KW), # PBES2-HS256+A128KW
PBES2HSAlgKeyEncryption(384, A192KW), # PBES2-HS384+A192KW
PBES2HSAlgKeyEncryption(512, A256KW), # PBES2-HS512+A256KW
]
# compatible alias
DirectAlgModel = DirectAlgEncryption
AESAlgModel = AESAlgKeyWrapping
ECDHESAlgModel = ECDHESAlgKeyAgreement
AESGCMAlgModel = AESGCMAlgKeyWrapping
PBES2HSAlgModel = PBES2HSAlgKeyEncryption
@@ -0,0 +1,115 @@
"""
joserfc._rfc7518
~~~~~~~~~~~~~~~~
Cryptographic Models for Cryptographic Models for Content
Encryption per `Section 5`_.
.. _`Section 5`: https://tools.ietf.org/html/rfc7518#section-5
"""
import hmac
import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.primitives.ciphers.modes import GCM, CBC
from cryptography.hazmat.primitives.padding import PKCS7
from cryptography.exceptions import InvalidTag
from .._rfc7516.models import JWEEncModel
from ..errors import DecodeError
from .util import encode_int
class CBCHS2EncModel(JWEEncModel):
# The IV used is a 128-bit value generated randomly or
# pseudo-randomly for use in the cipher.
iv_size = 128
recommended = True
def __init__(self, key_size: int, hash_type: int):
self.name = f"A{key_size}CBC-HS{hash_type}"
self.description = f"AES_{key_size}_CBC_HMAC_SHA_{hash_type} authenticated encryption algorithm"
# key size in bit
self.key_size = key_size
# key size in byte
self.key_len = key_size // 8
self.cek_size = key_size * 2
self.hash_alg = getattr(hashlib, f"sha{hash_type}")
def _hmac(self, ciphertext: bytes, aad: bytes, iv: bytes, key: bytes) -> bytes:
al = encode_int(len(aad) * 8, 64)
msg = aad + iv + ciphertext + al
d = hmac.new(key, msg, self.hash_alg).digest()
return d[: self.key_len]
def encrypt(self, plaintext: bytes, cek: bytes, iv: bytes, aad: bytes) -> tuple[bytes, bytes]:
"""Key Encryption with AES_CBC_HMAC_SHA2."""
hkey = cek[: self.key_len]
ekey = cek[self.key_len :]
pad = PKCS7(AES.block_size).padder()
padded_data = pad.update(plaintext) + pad.finalize()
cipher = Cipher(AES(ekey), CBC(iv))
enc = cipher.encryptor()
ciphertext = enc.update(padded_data) + enc.finalize()
tag = self._hmac(ciphertext, aad, iv, hkey)
return ciphertext, tag
def decrypt(self, ciphertext: bytes, tag: bytes, cek: bytes, iv: bytes, aad: bytes) -> bytes:
"""Key Decryption with AES AES_CBC_HMAC_SHA2."""
hkey = cek[: self.key_len]
dkey = cek[self.key_len :]
ctag = self._hmac(ciphertext, aad, iv, hkey)
if not hmac.compare_digest(ctag, tag):
raise DecodeError("tag does not match")
cipher = Cipher(AES(dkey), CBC(iv))
d = cipher.decryptor()
data = d.update(ciphertext) + d.finalize()
unpad = PKCS7(AES.block_size).unpadder()
return unpad.update(data) + unpad.finalize()
class GCMEncModel(JWEEncModel):
# Use of an IV of size 96 bits is REQUIRED with this algorithm.
# https://tools.ietf.org/html/rfc7518#section-5.3
iv_size = 96
recommended = True
def __init__(self, key_size: int):
self.name = f"A{key_size}GCM"
self.description = f"AES GCM using {key_size}-bit key"
self.key_size = key_size
self.cek_size = key_size
def encrypt(self, plaintext: bytes, cek: bytes, iv: bytes, aad: bytes) -> tuple[bytes, bytes]:
"""Key Encryption with AES GCM"""
cipher = Cipher(AES(cek), GCM(iv))
enc = cipher.encryptor()
enc.authenticate_additional_data(aad)
ciphertext = enc.update(plaintext) + enc.finalize()
return ciphertext, enc.tag
def decrypt(self, ciphertext: bytes, tag: bytes, cek: bytes, iv: bytes, aad: bytes) -> bytes:
"""Key Decryption with AES GCM"""
cipher = Cipher(AES(cek), GCM(iv, tag))
d = cipher.decryptor()
d.authenticate_additional_data(aad)
try:
return d.update(ciphertext) + d.finalize()
except InvalidTag as error:
raise DecodeError(str(error))
JWE_ENC_MODELS: list[JWEEncModel] = [
CBCHS2EncModel(128, 256), # A128CBC-HS256
CBCHS2EncModel(192, 384), # A192CBC-HS384
CBCHS2EncModel(256, 512), # A256CBC-HS512
GCMEncModel(128), # A128GCM
GCMEncModel(192), # A192GCM
GCMEncModel(256), # A256GCM
]
@@ -0,0 +1,32 @@
import zlib
from .._rfc7516.models import JWEZipModel
from ..errors import ExceededSizeError
class DeflateZipModel(JWEZipModel):
name = "DEF"
description = "DEFLATE"
GZIP_HEAD = bytes([120, 156])
MAX_SIZE = 250 * 1024
def compress(self, s: bytes) -> bytes:
"""Compress bytes data with DEFLATE algorithm."""
data = zlib.compress(s)
# https://datatracker.ietf.org/doc/html/rfc1951
# since DEF is always gzip, we can drop gzip headers and tail
return data[2:-4]
def decompress(self, s: bytes) -> bytes:
"""Decompress DEFLATE bytes data."""
if s.startswith(self.GZIP_HEAD):
decompressor = zlib.decompressobj()
else:
decompressor = zlib.decompressobj(-zlib.MAX_WBITS)
value = decompressor.decompress(s, self.MAX_SIZE)
if decompressor.unconsumed_tail:
raise ExceededSizeError(f"Decompressed string exceeds {self.MAX_SIZE} bytes")
return value
JWE_ZIP_MODELS: list[JWEZipModel] = [DeflateZipModel()]
@@ -0,0 +1,211 @@
"""
joserfc._rfc7518
~~~~~~~~~~~~~~~~
Originally designed in ``authlib.jose.rfc7518``.
"alg" (Algorithm) Header Parameter Values for JWS per `Section 3`_.
.. _`Section 3`: https://tools.ietf.org/html/rfc7518#section-3
"""
import hmac
import hashlib
import typing as t
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature,
encode_dss_signature,
)
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
from .._rfc7515.model import JWSAlgModel
from ..errors import InvalidKeyCurveError
from .oct_key import OctKey
from .rsa_key import RSAKey
from .ec_key import ECKey
from .util import encode_int, decode_int
class NoneAlgorithm(JWSAlgModel):
name = "none"
description = "No digital signature or MAC performed"
security_warning = 'JWS algorithm "none" is deprecated, via draft-ietf-jose-deprecate-none-rsa15-02'
def sign(self, msg: bytes, key: t.Any) -> bytes:
return b""
def verify(self, msg: bytes, sig: bytes, key: t.Any) -> bool:
return sig == b""
class HMACAlgorithm(JWSAlgModel):
"""HMAC using SHA algorithms for JWS. Available algorithms:
- HS256: HMAC using SHA-256
- HS384: HMAC using SHA-384
- HS512: HMAC using SHA-512
"""
SHA256 = hashlib.sha256
SHA384 = hashlib.sha384
SHA512 = hashlib.sha512
def __init__(self, sha_type: t.Literal[256, 384, 512], recommended: bool = False):
self.name = f"HS{sha_type}"
self.description = f"HMAC using SHA-{sha_type}"
self.recommended = recommended
self.hash_alg = getattr(self, f"SHA{sha_type}")
self.algorithm_security = sha_type
def sign(self, msg: bytes, key: OctKey) -> bytes:
# it is faster than the one in cryptography
op_key = key.get_op_key("sign")
if not op_key:
# Defence-in-depth: OctKey.import_key rejects empty input, but
# an OctKey can also be constructed directly through other
# internal paths. An empty HMAC key produces a forgeable digest.
raise ValueError("HMAC key must not be empty")
return hmac.new(op_key, msg, self.hash_alg).digest()
def verify(self, msg: bytes, sig: bytes, key: OctKey) -> bool:
op_key = key.get_op_key("verify")
if not op_key:
raise ValueError("HMAC key must not be empty")
v_sig = hmac.new(op_key, msg, self.hash_alg).digest()
return hmac.compare_digest(sig, v_sig)
class _RSAAlgModel(JWSAlgModel):
key_type = "RSA"
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
padding: padding.AsymmetricPadding
def __init__(self, sha_type: t.Literal[256, 384, 512], recommended: bool = False):
self.recommended = recommended
self.hash_alg = getattr(self, f"SHA{sha_type}")
self.algorithm_security = sha_type
def sign(self, msg: bytes, key: RSAKey) -> bytes:
op_key = key.get_op_key("sign")
return op_key.sign(msg, self.padding, self.hash_alg())
def verify(self, msg: bytes, sig: bytes, key: RSAKey) -> bool:
op_key = key.get_op_key("verify")
try:
op_key.verify(sig, msg, self.padding, self.hash_alg())
return True
except InvalidSignature:
return False
class RSAAlgorithm(_RSAAlgModel):
"""RSA using SHA algorithms for JWS. Available algorithms:
- RS256: RSASSA-PKCS1-v1_5 using SHA-256
- RS384: RSASSA-PKCS1-v1_5 using SHA-384
- RS512: RSASSA-PKCS1-v1_5 using SHA-512
"""
padding = padding.PKCS1v15()
def __init__(self, sha_type: t.Literal[256, 384, 512], recommended: bool = False):
super().__init__(sha_type, recommended)
self.name = f"RS{sha_type}"
self.description = f"RSASSA-PKCS1-v1_5 using SHA-{sha_type}"
class ESAlgorithm(JWSAlgModel):
"""ECDSA using SHA algorithms for JWS. Available algorithms:
- ES256: ECDSA using P-256 and SHA-256
- ES384: ECDSA using P-384 and SHA-384
- ES512: ECDSA using P-521 and SHA-512
"""
key_type = "EC"
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
def __init__(self, name: str, curve: str, sha_type: t.Literal[256, 384, 512], recommended: bool = False):
self.name = name
self.curve = curve
self.description = f"ECDSA using {self.curve} and SHA-{sha_type}"
self.recommended = recommended
self.hash_alg = getattr(self, f"SHA{sha_type}")
self.algorithm_security = sha_type
def check_key(self, key: ECKey) -> None:
super().check_key(key)
if key.curve_name != self.curve:
raise InvalidKeyCurveError(f"Key for '{self.name}' not supported, only '{self.curve}' allowed")
def sign(self, msg: bytes, key: ECKey) -> bytes:
op_key = key.get_op_key("sign")
der_sig = op_key.sign(msg, ECDSA(self.hash_alg()))
r, s = decode_dss_signature(der_sig)
size = key.curve_key_size
return encode_int(r, size) + encode_int(s, size)
def verify(self, msg: bytes, sig: bytes, key: ECKey) -> bool:
key_size = key.curve_key_size
length = (key_size + 7) // 8
if len(sig) != 2 * length:
return False
r = decode_int(sig[:length])
s = decode_int(sig[length:])
der_sig = encode_dss_signature(r, s)
try:
op_key = key.get_op_key("verify")
op_key.verify(der_sig, msg, ECDSA(self.hash_alg()))
return True
except InvalidSignature:
return False
class RSAPSSAlgorithm(_RSAAlgModel):
"""RSASSA-PSS using SHA algorithms for JWS. Available algorithms:
- PS256: RSASSA-PSS using SHA-256 and MGF1 with SHA-256
- PS384: RSASSA-PSS using SHA-384 and MGF1 with SHA-384
- PS512: RSASSA-PSS using SHA-512 and MGF1 with SHA-512
"""
def __init__(self, sha_type: t.Literal[256, 384, 512]):
super().__init__(sha_type, False)
self.name = f"PS{sha_type}"
self.description = f"RSASSA-PSS using SHA-{sha_type} and MGF1 with SHA-{sha_type}"
self.padding = padding.PSS(mgf=padding.MGF1(self.hash_alg()), salt_length=self.hash_alg.digest_size)
JWS_ALGORITHMS: list[JWSAlgModel] = [
NoneAlgorithm(), # none
HMACAlgorithm(256, True), # HS256
HMACAlgorithm(384), # HS384
HMACAlgorithm(512), # HS512
RSAAlgorithm(256, True), # RS256
RSAAlgorithm(384), # RS384
RSAAlgorithm(512), # RS512
ESAlgorithm("ES256", "P-256", 256, True),
ESAlgorithm("ES384", "P-384", 384),
ESAlgorithm("ES512", "P-521", 512),
RSAPSSAlgorithm(256), # PS256
RSAPSSAlgorithm(384), # PS384
RSAPSSAlgorithm(512), # PS512
]
# compatible
NoneAlgModel = NoneAlgorithm
HMACAlgModel = HMACAlgorithm
RSAAlgModel = RSAAlgorithm
ECAlgModel = ESAlgorithm
RSAPSSAlgModel = RSAPSSAlgorithm
@@ -0,0 +1,107 @@
import typing as t
from typing import Any
import secrets
import warnings
from ..errors import SecurityWarning
from ..util import (
to_bytes,
urlsafe_b64decode,
urlsafe_b64encode,
)
from ..registry import KeyParameter
from .._rfc7517.models import SymmetricKey, NativeKeyBinding
from .._rfc7517.types import KeyParameters, DictKey, AnyKey
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
class OctBinding(NativeKeyBinding):
@classmethod
def convert_raw_key_to_dict(cls, raw_key: bytes, private: bool) -> DictKey:
k = urlsafe_b64encode(raw_key).decode("utf-8")
return {"k": k}
@classmethod
def import_from_dict(cls, value: DictKey) -> bytes:
return urlsafe_b64decode(to_bytes(value["k"]))
@classmethod
def import_from_bytes(cls, value: bytes, password: Any | None = None) -> bytes:
# security check
if value.startswith(POSSIBLE_UNSAFE_KEYS):
warnings.warn("This key should not be used as an oct key", SecurityWarning)
return value
class OctKey(SymmetricKey):
"""OctKey is a symmetric key, defined by RFC7518 Section 6.4."""
key_type = "oct"
binding = OctBinding
#: https://www.rfc-editor.org/rfc/rfc7518#section-6.4
value_registry = {"k": KeyParameter("Key Value", "str", True, True)}
@classmethod
def import_key(
cls: Any,
value: AnyKey,
parameters: KeyParameters | None = None,
password: Any = None,
) -> "OctKey":
key: OctKey = super(OctKey, cls).import_key(value, parameters, password)
if not key.raw_value:
# An empty oct key material produces a deterministic HMAC digest
# that any party can reproduce, allowing trivial signature forgery
# in JWS HS256/HS384/HS512 verification. Reject outright rather
# than emitting a SecurityWarning that callers commonly suppress.
raise ValueError("oct key material must not be empty")
if len(key.raw_value) < 14:
# https://csrc.nist.gov/publications/detail/sp/800-131a/rev-2/final
warnings.warn("Key size should be >= 112 bits", SecurityWarning)
return key
@classmethod
def generate_key(
cls,
key_size: int | None = 256,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> "OctKey":
"""Generate a ``OctKey`` with the given bit size (not bytes).
:param key_size: size in bit
:param parameters: extra parameter in JWK
:param private: must be True
:param auto_kid: add ``kid`` automatically
"""
if not private:
raise ValueError("oct key can not be generated as public")
if key_size is None:
key_size = 256
if key_size % 8 != 0:
raise ValueError("Invalid bit size for oct key")
if key_size < 112:
# https://csrc.nist.gov/publications/detail/sp/800-131a/rev-2/final
warnings.warn("Key size should be >= 112 bits", SecurityWarning)
raw_key = secrets.token_bytes(key_size // 8)
key: OctKey = cls(raw_key, raw_key, parameters)
if auto_kid:
key.ensure_kid()
return key
def as_dict(self, private: bool = False, **params: t.Any) -> DictKey:
return super().as_dict(private=True, **params)
@@ -0,0 +1,201 @@
import warnings
import typing as t
from functools import cached_property
from cryptography.hazmat.primitives.asymmetric.rsa import (
generate_private_key,
RSAPublicKey,
RSAPrivateKey,
RSAPrivateNumbers,
RSAPublicNumbers,
rsa_recover_prime_factors,
rsa_crt_dmp1,
rsa_crt_dmq1,
rsa_crt_iqmp,
)
from ..registry import KeyParameter
from ..errors import SecurityWarning, KeyParameterError
from .._rfc7517.models import AsymmetricKey
from .._rfc7517.pem import CryptographyBinding
from .._rfc7517.types import KeyParameters, AnyKey
from ..util import int_to_base64, base64_to_int
RSADictKey = t.TypedDict(
"RSADictKey",
{
"n": str,
"e": str,
"d": str,
"p": str,
"q": str,
"dp": str,
"dq": str,
"qi": str,
},
total=False,
)
class RSABinding(CryptographyBinding):
key_type = "RSA"
ssh_type = b"ssh-rsa"
_cryptography_key_types = (RSAPrivateKey, RSAPublicKey)
@staticmethod
def generate_private_key(size: int) -> RSAPrivateKey:
return generate_private_key(public_exponent=65537, key_size=size)
@classmethod
def import_private_key(cls, obj: RSADictKey) -> RSAPrivateKey:
if "oth" in obj: # pragma: no cover
# https://tools.ietf.org/html/rfc7518#section-6.3.2.7
raise ValueError('"oth" is not supported yet')
public_numbers = RSAPublicNumbers(base64_to_int(obj["e"]), base64_to_int(obj["n"]))
if has_all_prime_factors(obj):
numbers = RSAPrivateNumbers(
d=base64_to_int(obj["d"]),
p=base64_to_int(obj["p"]),
q=base64_to_int(obj["q"]),
dmp1=base64_to_int(obj["dp"]),
dmq1=base64_to_int(obj["dq"]),
iqmp=base64_to_int(obj["qi"]),
public_numbers=public_numbers,
)
else:
d = base64_to_int(obj["d"])
p, q = rsa_recover_prime_factors(public_numbers.n, d, public_numbers.e)
numbers = RSAPrivateNumbers(
d=d,
p=p,
q=q,
dmp1=rsa_crt_dmp1(d, p),
dmq1=rsa_crt_dmq1(d, q),
iqmp=rsa_crt_iqmp(p, q),
public_numbers=public_numbers,
)
return numbers.private_key()
@classmethod
def export_private_key(cls, key: RSAPrivateKey) -> RSADictKey:
numbers = key.private_numbers()
return {
"n": int_to_base64(numbers.public_numbers.n),
"e": int_to_base64(numbers.public_numbers.e),
"d": int_to_base64(numbers.d),
"p": int_to_base64(numbers.p),
"q": int_to_base64(numbers.q),
"dp": int_to_base64(numbers.dmp1),
"dq": int_to_base64(numbers.dmq1),
"qi": int_to_base64(numbers.iqmp),
}
@classmethod
def import_public_key(cls, obj: RSADictKey) -> RSAPublicKey:
numbers = RSAPublicNumbers(base64_to_int(obj["e"]), base64_to_int(obj["n"]))
return numbers.public_key()
@classmethod
def export_public_key(cls, key: RSAPublicKey) -> dict[str, str]:
numbers = key.public_numbers()
return {"n": int_to_base64(numbers.n), "e": int_to_base64(numbers.e)}
class RSAKey(AsymmetricKey[RSAPrivateKey, RSAPublicKey]):
key_type = "RSA"
#: Registry definition for RSA Key
#: https://www.rfc-editor.org/rfc/rfc7518#section-6.3
value_registry = {
"n": KeyParameter("Modulus", "str", private=False, required=True),
"e": KeyParameter("Exponent", "str", private=False, required=True),
"d": KeyParameter("Private Exponent", "str", private=True, required=False),
"p": KeyParameter("First Prime Factor", "str", private=True, required=False),
"q": KeyParameter("Second Prime Factor", "str", private=True, required=False),
"dp": KeyParameter("First Factor CRT Exponent", "str", private=True, required=False),
"dq": KeyParameter("Second Factor CRT Exponent", "str", private=True, required=False),
"qi": KeyParameter("First CRT Coefficient", "str", private=True, required=False),
"oth": KeyParameter("Other Primes Info", "none", private=True, required=False),
}
binding = RSABinding
@property
def is_private(self) -> bool:
return isinstance(self.raw_value, RSAPrivateKey)
@cached_property
def public_key(self) -> RSAPublicKey:
if isinstance(self.raw_value, RSAPrivateKey):
return self.raw_value.public_key()
return self.raw_value
@property
def private_key(self) -> RSAPrivateKey | None:
if isinstance(self.raw_value, RSAPrivateKey):
return self.raw_value
return None
@classmethod
def import_key(
cls: t.Any,
value: AnyKey | RSAPrivateKey | RSAPublicKey,
parameters: KeyParameters | None = None,
password: t.Any = None,
) -> "RSAKey":
key: RSAKey
if isinstance(value, (RSAPrivateKey, RSAPublicKey)):
key = cls(value, value, parameters)
else:
key = super(RSAKey, cls).import_key(value, parameters, password)
if key.raw_value.key_size < 2048:
# https://csrc.nist.gov/publications/detail/sp/800-131a/rev-2/final
warnings.warn("Key size should be >= 2048 bits", SecurityWarning)
return key
@classmethod
def generate_key(
cls: type["RSAKey"],
key_size: int | None = 2048,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> "RSAKey":
"""Generate a ``RSAKey`` with the given bit size (not bytes).
:param key_size: size in bit
:param parameters: extra parameter in JWK
:param private: generate a private key or public key
:param auto_kid: add ``kid`` automatically
"""
if key_size is None:
key_size = 2048
if key_size % 8 != 0:
raise ValueError("A bit size must be a multiple of 8")
if key_size < 2048:
# https://csrc.nist.gov/publications/detail/sp/800-131a/rev-2/final
warnings.warn("Key size should be >= 2048 bits", SecurityWarning)
raw_key = cls.binding.generate_private_key(key_size)
if private:
key = cls(raw_key, raw_key, parameters)
else:
pub_key = raw_key.public_key()
key = cls(pub_key, pub_key, parameters)
if auto_kid:
key.ensure_kid()
return key
def has_all_prime_factors(obj: RSADictKey) -> bool:
props = ["p", "q", "dp", "dq", "qi"]
props_found = [prop in obj for prop in props]
if all(props_found):
return True
if any(props_found):
raise KeyParameterError("RSA key must include all parameters if any are present besides d")
return False
@@ -0,0 +1,12 @@
import binascii
def encode_int(num: int, bits: int) -> bytes:
length = ((bits + 7) // 8) * 2
padded_hex = "%0*x" % (length, num)
big_endian = binascii.a2b_hex(padded_hex.encode("ascii"))
return big_endian
def decode_int(s: bytes) -> int:
return int(binascii.b2a_hex(s), 16)
@@ -0,0 +1,218 @@
import time
import json
import datetime
import calendar
from json import JSONEncoder
from typing import TypedDict, Any, Callable
from ..util import to_bytes
from ..errors import (
MissingClaimError,
InvalidClaimError,
ExpiredTokenError,
)
Claims = dict[str, Any]
def convert_claims(claims: Claims, encoder_cls: type[JSONEncoder] | None = None) -> bytes:
"""Turn claims into bytes payload."""
for k in ["exp", "iat", "nbf"]:
claim = claims.get(k)
if isinstance(claim, datetime.datetime):
claims[k] = calendar.timegm(claim.utctimetuple())
content = json.dumps(claims, ensure_ascii=False, separators=(",", ":"), cls=encoder_cls)
return to_bytes(content)
#: http://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests
class ClaimsOption(TypedDict, total=False):
essential: bool
allow_blank: bool | None
value: str | int | bool
values: list[str | int | bool] | list[str] | list[int] | list[bool]
class BaseClaimsRegistry:
"""Requesting "claims" for JWT with the given conditions."""
def __init__(self, **kwargs: ClaimsOption):
self.options = kwargs
@property
def essential_keys(self) -> set[str]:
"""Returns the essential claim names."""
return {key for key in self.options if self.options[key].get("essential")}
def check_value(self, claim_name: str, value: Any) -> None:
"""
Validates a given claim value based on predefined options.
:param claim_name: The name of the claim to validate.
:param value: The value of the claim to be validated.
:raises InvalidClaimError: If the value does not meet the claim's validation requirements.
"""
option = self.options.get(claim_name)
if not option:
return
allow_blank = option.get("allow_blank")
if not allow_blank and value in (None, "", [], {}):
raise InvalidClaimError(claim_name)
option_values = option.get("values")
if option_values is None:
option_value = option.get("value")
if option_value is not None:
option_values = [option_value]
if not option_values:
return
if isinstance(value, list):
if not any(v in value for v in option_values):
raise InvalidClaimError(claim_name)
else:
if value not in option_values:
raise InvalidClaimError(claim_name)
def validate(self, claims: dict[str, Any]) -> None:
"""
Validates the provided claims against specified requirements and checks.
:param claims: A dictionary containing claims to validate.
:raises InvalidClaimError: Raised if any claim fails validation.
:raises MissingClaimError: Raised if one or more essential keys are missing.
"""
missed_keys = {key for key in self.essential_keys if claims.get(key) is None}
if missed_keys:
raise MissingClaimError(",".join(sorted(missed_keys)))
for key in claims:
value = claims[key]
func = getattr(self, "validate_" + key, None)
if func:
func(value)
elif key in self.options:
self.check_value(key, value)
class JWTClaimsRegistry(BaseClaimsRegistry):
"""A claims registry for validating JWT claims.
:param now: timestamp of "now" time
:param leeway: leeway time in seconds
:param kwargs: claims options
"""
def __init__(self, now: int | Callable[[], int] | None = None, leeway: int = 0, **kwargs: ClaimsOption) -> None:
if now is None:
now = _generate_now
self._now = now
self.leeway = leeway
super().__init__(**kwargs)
@property
def now(self) -> int:
"""Returns the current timestamp."""
if callable(self._now):
return self._now()
return self._now
def validate_iss(self, value: str) -> None:
"""The "iss" (issuer) claim identifies the principal that issued the
JWT. The processing of this claim is generally application specific.
The "iss" value is a case-sensitive string containing a StringOrURI
value. Use of this claim is OPTIONAL.
"""
if not isinstance(value, str):
raise InvalidClaimError("str", "Claim 'str' must be a StringOrURI value")
self.check_value("iss", value)
def validate_sub(self, value: str) -> None:
"""The "sub" (subject) claim identifies the principal that is the
subject of the JWT. The claims in a JWT are normally statements
about the subject. The subject value MUST either be scoped to be
locally unique in the context of the issuer or be globally unique.
The processing of this claim is generally application specific. The
"sub" value is a case-sensitive string containing a StringOrURI
value. Use of this claim is OPTIONAL.
"""
if not isinstance(value, str):
raise InvalidClaimError("sub", "Claim 'sub' must be a StringOrURI value")
self.check_value("sub", value)
def validate_aud(self, value: str | list[str]) -> None:
"""The "aud" (audience) claim identifies the recipients that the JWT is
intended for. Each principal intended to process the JWT MUST
identify itself with a value in the audience claim. If the principal
processing the claim does not identify itself with a value in the
"aud" claim when this claim is present, then the JWT MUST be
rejected. In the general case, the "aud" value is an array of case-
sensitive strings, each containing a StringOrURI value. In the
special case when the JWT has one audience, the "aud" value MAY be a
single case-sensitive string containing a StringOrURI value. The
interpretation of audience values is generally application specific.
Use of this claim is OPTIONAL.
"""
if isinstance(value, str) or _validate_list_of_strings(value):
self.check_value("aud", value)
else:
raise InvalidClaimError(
"aud", "Claim 'aud' must be an array of StringOrURI value or a single StringOrURI value"
)
def validate_exp(self, value: int) -> None:
"""The "exp" (expiration time) claim identifies the expiration time on
or after which the JWT MUST NOT be accepted for processing. The
processing of the "exp" claim requires that the current date/time
MUST be before the expiration date/time listed in the "exp" claim.
Implementers MAY provide for some small leeway, usually no more than
a few minutes, to account for clock skew. Its value MUST be a number
containing a NumericDate value. Use of this claim is OPTIONAL.
"""
if not _validate_numeric_time(value):
raise InvalidClaimError("exp", "Claim 'exp' must be a NumericDate value")
if value < (self.now - self.leeway):
raise ExpiredTokenError("exp")
self.check_value("exp", value)
def validate_nbf(self, value: int) -> None:
"""The "nbf" (not before) claim identifies the time before which the JWT
MUST NOT be accepted for processing. The processing of the "nbf"
claim requires that the current date/time MUST be after or equal to
the not-before date/time listed in the "nbf" claim. Implementers MAY
provide for some small leeway, usually no more than a few minutes, to
account for clock skew. Its value MUST be a number containing a
NumericDate value. Use of this claim is OPTIONAL.
"""
if not _validate_numeric_time(value):
raise InvalidClaimError("nbf", "Claim 'nbf' must be a NumericDate value")
if value > (self.now + self.leeway):
raise InvalidClaimError("nbf", "The token is not yet valid")
self.check_value("nbf", value)
def validate_iat(self, value: int) -> None:
"""The "iat" (issued at) claim identifies the time at which the JWT was
issued. This claim can be used to determine the age of the JWT. Its
value MUST be a number containing a NumericDate value. Use of this
claim is OPTIONAL.
"""
if not _validate_numeric_time(value):
raise InvalidClaimError("iat", "Claim 'iat' must be a NumericDate value")
if value > (self.now + self.leeway):
raise InvalidClaimError("iat", "The token was issued in the future")
self.check_value("iat", value)
def _validate_numeric_time(s: int) -> bool:
return isinstance(s, (int, float))
def _validate_list_of_strings(s: list[str]) -> bool:
return isinstance(s, list) and all(isinstance(v, str) for v in s)
def _generate_now() -> int:
return int(time.time())
@@ -0,0 +1,38 @@
import re
from typing import Any
from ..errors import InsecureClaimError
SENSITIVE_NAMES = ("password", "token", "secret", "secret_key", "api_key")
SENSITIVE_VALUES = re.compile(
r"|".join(
[
# http://www.richardsramblings.com/regex/credit-card-numbers/
r"\b(?:3[47]\d|(?:4\d|5[1-5]|65)\d{2}|6011)\d{12}\b",
# various private keys
r"-----BEGIN[A-Z ]+PRIVATE KEY-----.+-----END[A-Z ]+PRIVATE KEY-----",
# social security numbers (US)
r"^\b(?!(000|666|9))\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b",
]
),
re.DOTALL,
)
def check_sensitive_data(claims: dict[str, Any]) -> None:
"""
Checks for sensitive data within a dictionary of claims and raises an
error if any sensitive names or values are detected.
:param claims: JWT claims to check for sensitive data
:raises InsecureClaimError: if any sensitive names or values are detected
"""
for k in claims:
# check claims key name
if k in SENSITIVE_NAMES:
raise InsecureClaimError(k)
# check claims values
v = claims[k]
if isinstance(v, str) and SENSITIVE_VALUES.search(v):
raise InsecureClaimError(k)
@@ -0,0 +1,31 @@
import typing as t
import json
import hashlib
from collections import OrderedDict
from ..util import to_bytes, urlsafe_b64encode
def calculate_thumbprint(
value: dict[str, t.Any],
digest_method: t.Literal["sha256", "sha384", "sha512"] = "sha256",
) -> str:
"""Calculate the thumbprint value of a Key, per RFC 7638.
.. code-block:: python
from joserfc import jwk
jwk.thumbprint({
'kty': 'oct',
'k': 'sTBpI_oCHSyW-n0exSwhzNHwU9FGRioPauxWA84bnRU',
})
# 'DCdRGGDKvhAJgmVlCp6tosc2T9ELtd30S_15vn8bhrI'
"""
sorted_fields = sorted(value.keys())
data = OrderedDict()
for k in sorted_fields:
data[k] = value[k]
json_data = json.dumps(data, ensure_ascii=True, separators=(",", ":"))
hash_value = hashlib.new(digest_method, to_bytes(json_data))
digest_data = hash_value.digest()
return urlsafe_b64encode(digest_data).decode("utf-8")
@@ -0,0 +1,90 @@
import re
from typing import Any
from ..util import (
to_bytes,
to_str,
json_b64encode,
urlsafe_b64encode,
urlsafe_b64decode,
)
from .._rfc7515.model import JWSAlgModel, CompactSignature
from .._rfc7515.compact import decode_header
from .._rfc7515.registry import JWSRegistry, default_registry
from ..errors import DecodeError
from .util import is_rfc7797_enabled
def sign_rfc7515_compact(obj: CompactSignature, alg: JWSAlgModel, key: Any) -> bytes:
header_segment = json_b64encode(obj.headers())
signing_input = header_segment + b"." + obj.payload
signature = urlsafe_b64encode(alg.sign(signing_input, key))
# if need to detach payload
if __is_urlsafe_characters(obj.payload):
out = signing_input + b"." + signature
else:
out = header_segment + b".." + signature
return out
def extract_rfc7515_compact(
value: bytes, payload: bytes | str | None = None, registry: JWSRegistry | None = None
) -> CompactSignature:
"""Extract the JWS Compact Serialization from bytes to object.
:param value: JWS in bytes
:param payload: optional payload, required with detached content
:param registry: optional JWSRegistry instance
:raise DecodeError: when decoding fails
"""
parts = value.split(b".")
if len(parts) != 3:
raise DecodeError("Invalid JSON Web Signature")
if registry is None:
registry = default_registry
header_segment, payload_segment, signature_segment = parts
registry.validate_header_size(header_segment)
registry.validate_signature_size(signature_segment)
if payload_segment:
registry.validate_payload_size(payload_segment)
protected = decode_header(header_segment)
if is_rfc7797_enabled(protected):
if not payload_segment and payload:
payload_segment = to_bytes(payload)
registry.validate_payload_size(payload_segment)
payload = payload_segment
else:
if not payload_segment and payload:
payload = to_bytes(payload)
payload_segment = urlsafe_b64encode(payload)
else:
try:
payload = urlsafe_b64decode(payload_segment)
except (TypeError, ValueError):
raise DecodeError("Invalid payload")
obj = CompactSignature(protected, payload)
obj.segments.update(
{
"header": header_segment,
"payload": payload_segment,
"signature": signature_segment,
}
)
return obj
# https://datatracker.ietf.org/doc/html/rfc7797#section-5.2
# the application MUST ensure that the payload contains only the URL-safe
# characters 'a'-'z', 'A'-'Z', '0'-'9', dash ('-'), underscore ('_'),
# and tilde ('~')
_re_urlsafe = re.compile("^[a-zA-Z0-9-_~]+$")
def __is_urlsafe_characters(s: bytes | str) -> bool:
return bool(_re_urlsafe.match(to_str(s)))
@@ -0,0 +1,51 @@
from .._rfc7515.types import FlattenedJSONSerialization, JSONSignatureDict
from .._rfc7515.model import HeaderMember, FlattenedJSONSignature
from .._rfc7515.registry import JWSRegistry
from .._rfc7515.json import sign_json_member, FindKey
from ..util import to_bytes, json_b64decode, urlsafe_b64decode
from ..errors import DecodeError
from .util import is_rfc7797_enabled
def sign_rfc7797_json(
member: HeaderMember,
payload: bytes,
registry: JWSRegistry,
find_key: FindKey,
) -> FlattenedJSONSerialization:
signature = sign_json_member(payload, member, registry, find_key)
data: FlattenedJSONSerialization = {"payload": payload.decode("utf-8"), **signature}
return data
def extract_rfc7797_json(value: FlattenedJSONSerialization, registry: JWSRegistry) -> FlattenedJSONSignature:
if "protected" in value:
protected_segment = to_bytes(value["protected"])
registry.validate_header_size(protected_segment)
protected = json_b64decode(protected_segment)
else:
protected = None
header = value.get("header")
member = HeaderMember(protected, header)
payload_segment: bytes = value["payload"].encode("utf-8")
registry.validate_payload_size(payload_segment)
if is_rfc7797_enabled(member.headers()):
payload = payload_segment
else:
try:
payload = urlsafe_b64decode(payload_segment)
except (TypeError, ValueError):
raise DecodeError("Invalid payload")
obj = FlattenedJSONSignature(member, payload)
_sig: JSONSignatureDict = {"signature": value["signature"]}
if "protected" in value:
_sig["protected"] = value["protected"]
if "header" in value:
_sig["header"] = value["header"]
obj.signature = _sig
obj.segments = {"payload": payload_segment}
return obj
@@ -0,0 +1,17 @@
from ..registry import Header
from ..errors import MissingCritHeaderError
def is_rfc7797_enabled(header: Header) -> bool:
if "b64" not in header:
return False
if header["b64"] is True:
return False
# https://datatracker.ietf.org/doc/html/rfc7797#section-6
crit = header.get("crit")
if isinstance(crit, list) and "b64" in crit:
return True
raise MissingCritHeaderError("b64")
@@ -0,0 +1,35 @@
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey, Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PublicKey, Ed448PrivateKey
from ..errors import InvalidKeyTypeError
from .._rfc7515.model import JWSAlgModel
from .okp_key import OKPKey
class EdDSAAlgorithm(JWSAlgModel):
name = "EdDSA"
description = "Edwards-curve Digital Signature Algorithm for JWS"
key_type = "OKP"
security_warning = "EdDSA is deprecated via RFC 9864"
def sign(self, msg: bytes, key: OKPKey) -> bytes:
op_key = key.get_op_key("sign")
if not isinstance(op_key, (Ed25519PrivateKey, Ed448PrivateKey)):
raise InvalidKeyTypeError(f"Algorithm '{self.name}' requires 'Ed25519' or 'Ed448' OKP key")
return op_key.sign(msg)
def verify(self, msg: bytes, sig: bytes, key: OKPKey) -> bool:
op_key = key.get_op_key("verify")
if not isinstance(op_key, (Ed25519PublicKey, Ed448PublicKey)):
raise InvalidKeyTypeError(f"Algorithm '{self.name}' requires 'Ed25519' or 'Ed448' OKP key")
try:
op_key.verify(sig, msg)
return True
except InvalidSignature:
return False
EdDSA = EdDSAAlgorithm()
# compatible
EdDSAAlgModel = EdDSAAlgorithm
@@ -0,0 +1,326 @@
import typing as t
from functools import cached_property
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey, Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PublicKey, Ed448PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey, X25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x448 import X448PublicKey, X448PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
PrivateFormat,
NoEncryption,
)
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from .._rfc7517.models import CurveKey
from .._rfc7517.types import KeyParameters, AnyKey
from .._rfc7517.pem import CryptographyBinding
from ..errors import InvalidExchangeKeyError, InvalidKeyCurveError
from ..util import to_bytes, urlsafe_b64decode, urlsafe_b64encode
from ..registry import KeyParameter
LiteralCurves = t.Literal["Ed25519", "Ed448", "X25519", "X448"]
PublicOKPKey = t.Union[Ed25519PublicKey, Ed448PublicKey, X25519PublicKey, X448PublicKey]
PrivateOKPKey = t.Union[Ed25519PrivateKey, Ed448PrivateKey, X25519PrivateKey, X448PrivateKey]
OKPDictKey = t.TypedDict(
"OKPDictKey",
{
"crv": LiteralCurves,
"x": str,
"d": str,
},
total=False,
)
PUBLIC_KEYS_MAP: dict[str, type[PublicOKPKey]] = {
"Ed25519": Ed25519PublicKey,
"Ed448": Ed448PublicKey,
"X25519": X25519PublicKey,
"X448": X448PublicKey,
}
PRIVATE_KEYS_MAP: dict[str, type[PrivateOKPKey]] = {
"Ed25519": Ed25519PrivateKey,
"Ed448": Ed448PrivateKey,
"X25519": X25519PrivateKey,
"X448": X448PrivateKey,
}
OKP_SEED_SIZES: dict[LiteralCurves, int] = {
"Ed25519": 32,
"Ed448": 57,
"X25519": 32,
"X448": 56,
}
PrivateKeyTypes = (Ed25519PrivateKey, Ed448PrivateKey, X25519PrivateKey, X448PrivateKey)
class OKPBinding(CryptographyBinding):
key_type = "OKP"
ssh_type = b"ssh-ed25519"
_cryptography_key_types = (
Ed25519PublicKey,
Ed25519PrivateKey,
Ed448PublicKey,
Ed448PrivateKey,
X25519PublicKey,
X25519PrivateKey,
X448PublicKey,
X448PrivateKey,
)
@staticmethod
def generate_private_key(crv: LiteralCurves) -> PrivateOKPKey:
if crv not in PRIVATE_KEYS_MAP:
raise InvalidKeyCurveError(f"Invalid curve value: '{crv}'")
crv_key: type[PrivateOKPKey] = PRIVATE_KEYS_MAP[crv]
return crv_key.generate()
@staticmethod
def from_private_bytes(crv: LiteralCurves, data: bytes) -> PrivateOKPKey:
crv_key: type[PrivateOKPKey] = PRIVATE_KEYS_MAP[crv]
return crv_key.from_private_bytes(data)
@staticmethod
def from_public_bytes(crv: LiteralCurves, data: bytes) -> PublicOKPKey:
crv_key: type[PublicOKPKey] = PUBLIC_KEYS_MAP[crv]
return crv_key.from_public_bytes(data)
@classmethod
def import_private_key(cls, obj: OKPDictKey) -> PrivateOKPKey:
d = urlsafe_b64decode(to_bytes(obj["d"]))
return cls.from_private_bytes(obj["crv"], d)
@classmethod
def import_public_key(cls, obj: OKPDictKey) -> PublicOKPKey:
x = urlsafe_b64decode(to_bytes(obj["x"]))
return cls.from_public_bytes(obj["crv"], x)
@classmethod
def export_private_key(cls, key: PrivateOKPKey) -> dict[str, str]:
obj = cls.export_public_key(key.public_key())
d_bytes = key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
obj["d"] = urlsafe_b64encode(d_bytes).decode("utf-8")
return obj
@classmethod
def export_public_key(cls, key: PublicOKPKey) -> dict[str, str]:
x_bytes = key.public_bytes(Encoding.Raw, PublicFormat.Raw)
return {
"crv": get_key_curve(key),
"x": urlsafe_b64encode(x_bytes).decode("utf-8"),
}
class OKPKey(CurveKey[PrivateOKPKey, PublicOKPKey]):
"""Key class of the ``OKP`` key type."""
key_type = "OKP"
#: Registry definition for OKP Key
#: https://www.rfc-editor.org/rfc/rfc8037#section-2
value_registry = {
"crv": KeyParameter("Curve", "str", private=False, required=True),
"x": KeyParameter("X Coordinate", "str", private=False, required=True),
"d": KeyParameter("OKP Private Key", "str", private=True, required=False),
}
binding = OKPBinding
def exchange_derive_key(self, key: "OKPKey") -> bytes:
# used in ECDH-ES Algorithms
pubkey = t.cast(t.Union[X25519PublicKey, X448PublicKey], key.get_op_key("deriveKey"))
# this if else logic is used for type hints
if isinstance(self.private_key, X25519PrivateKey) and isinstance(pubkey, X25519PublicKey):
return self.private_key.exchange(pubkey)
elif isinstance(self.private_key, X448PrivateKey) and isinstance(pubkey, X448PublicKey):
return self.private_key.exchange(pubkey)
raise InvalidExchangeKeyError()
@property
def is_private(self) -> bool:
return isinstance(self.raw_value, PrivateKeyTypes)
@cached_property
def public_key(self) -> PublicOKPKey:
if isinstance(self.raw_value, PrivateKeyTypes):
return self.raw_value.public_key()
return self.raw_value
@property
def private_key(self) -> PrivateOKPKey | None:
if isinstance(self.raw_value, PrivateKeyTypes):
return self.raw_value
return None
@property
def curve_name(self) -> LiteralCurves:
return get_key_curve(self.raw_value)
@classmethod
def import_key(
cls: t.Any,
value: AnyKey | PrivateOKPKey | PublicOKPKey,
parameters: KeyParameters | None = None,
password: t.Any = None,
) -> "OKPKey":
key: OKPKey
if isinstance(
value,
(
Ed25519PrivateKey,
Ed448PrivateKey,
X25519PrivateKey,
X448PrivateKey,
Ed25519PublicKey,
Ed448PublicKey,
X25519PublicKey,
X448PublicKey,
),
):
key = cls(value, value, parameters)
else:
key = super(OKPKey, cls).import_key(value, parameters, password)
return key
@classmethod
def generate_key(
cls: type["OKPKey"],
crv: LiteralCurves | None = "Ed25519",
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> "OKPKey":
"""Generate a ``OKPKey`` with the given "crv" value.
:param crv: OKPKey curve name
:param parameters: extra parameter in JWK
:param private: generate a private key or public key
:param auto_kid: add ``kid`` automatically
"""
if crv is None:
raw_key = cls.binding.generate_private_key("Ed25519")
else:
raw_key = cls.binding.generate_private_key(crv)
return _wrap_key(cls, raw_key, private, auto_kid, parameters)
@classmethod
def derive_key(
cls: type["OKPKey"],
secret: bytes | str,
crv: LiteralCurves = "Ed25519",
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
kdf_name: t.Literal["HKDF", "PBKDF2"] = "HKDF",
kdf_options: dict[str, t.Any] | None = None,
) -> "OKPKey":
"""
Derives a key from a given input secret using a specified key derivation function
(KDF) and elliptic curve algorithm.
To derive a key using **HKDF**, the ``kdf_options`` may contain the ``algorithm``,
``salt`` and ``info`` values:
.. code-block:: python
from cryptography.hazmat.primitives import hashes
from joserfc.jwk import OKPKey
# default kdf_name is HKDF, algorithm is SHA256
OKPKey.derive_key("secret")
# equivalent to
OKPKey.derive_key(
"secret", "Ed25519",
kdf_name="HKDF",
kdf_options={
"algorithm": hashes.SHA256(),
"salt": b"joserfc:OKP:HKDF:Ed25519",
"info": b"",
}
)
To derive a key using **PBKDF2**, the ``kdf_options`` may contain the ``algorithm``,
``salt`` and ``iterations`` values:
.. code-block:: python
from cryptography.hazmat.primitives import hashes
from joserfc.jwk import OKPKey
OKPKey.derive_key("secret", kdf_name="PBKDF2")
# equivalent to
OKPKey.derive_key(
"secret", "Ed25519",
kdf_name="PBKDF2",
kdf_options={
"algorithm": hashes.SHA256(),
"salt": b"joserfc:OKP:PBKDF2:Ed25519",
"iterations": 100000,
}
)
:param secret: The input secret used for key derivation
:param crv: OKPKey curve name
:param parameters: extra parameter in JWK
:param private: generate a private key or public key
:param auto_kid: add ``kid`` automatically
:param kdf_name: Key derivation function name
:param kdf_options: Additional options for the KDF
"""
if kdf_options is None:
kdf_options = {}
algorithm = kdf_options.pop("algorithm", None)
if algorithm is None:
algorithm = hashes.SHA256()
kdf_options.setdefault("salt", to_bytes(f"joserfc:OKP:{kdf_name}:{crv}"))
if kdf_name == "HKDF":
kdf_options.setdefault("info", b"")
hkdf = HKDF(
algorithm=algorithm,
length=OKP_SEED_SIZES[crv],
**kdf_options,
)
seed = hkdf.derive(to_bytes(secret))
elif kdf_name == "PBKDF2":
kdf_options.setdefault("iterations", 100000)
pbkdf2 = PBKDF2HMAC(
algorithm=algorithm,
length=OKP_SEED_SIZES[crv],
**kdf_options,
)
seed = pbkdf2.derive(to_bytes(secret))
else:
raise ValueError(f"Invalid kdf value: '{kdf_name}'")
raw_key = cls.binding.from_private_bytes(crv, seed)
return _wrap_key(cls, raw_key, private, auto_kid, parameters)
def get_key_curve(key: t.Union[PublicOKPKey, PrivateOKPKey]) -> LiteralCurves:
if isinstance(key, (Ed25519PublicKey, Ed25519PrivateKey)):
return "Ed25519"
elif isinstance(key, (Ed448PublicKey, Ed448PrivateKey)):
return "Ed448"
elif isinstance(key, (X25519PublicKey, X25519PrivateKey)):
return "X25519"
elif isinstance(key, (X448PublicKey, X448PrivateKey)):
return "X448"
raise ValueError("Invalid key") # pragma: no cover
def _wrap_key(
cls: type[OKPKey],
raw_key: PrivateOKPKey,
private: bool,
auto_kid: bool,
parameters: KeyParameters | None = None,
) -> OKPKey:
if private:
key = cls(raw_key, raw_key, parameters)
else:
pub_key = raw_key.public_key()
key = cls(pub_key, pub_key, parameters)
if auto_kid:
key.ensure_kid()
return key
@@ -0,0 +1,13 @@
from cryptography.hazmat.primitives.asymmetric.ec import SECP256K1
from .._rfc7518.ec_key import ECKey
from .._rfc7518.jws_algs import ESAlgorithm
ES256K = ESAlgorithm("ES256K", "secp256k1", 256)
def register_secp256k1() -> None:
# https://tools.ietf.org/html/rfc8812#section-3.1
ECKey.binding.register_curve("secp256k1", SECP256K1)
__all__ = ["ES256K", "register_secp256k1"]
@@ -0,0 +1,29 @@
import typing as t
from .._rfc7638 import calculate_thumbprint
JWK_THUMBPRINT_URN = "urn:ietf:params:oauth:jwk-thumbprint"
def calculate_thumbprint_uri(
value: dict[str, t.Any],
digest_method: t.Literal["sha256", "sha384", "sha512"] = "sha256",
) -> str:
"""Calculate JWK thumbprint URI, defined by RFC9278.
.. code-block:: python
from joserfc import jwk
jwk.thumbprint({
'kty': 'oct',
'k': 'sTBpI_oCHSyW-n0exSwhzNHwU9FGRioPauxWA84bnRU',
})
# 'urn:ietf:params:oauth:jwk-thumbprint:sha-256:DCdRGGDKvhAJgmVlCp6tosc2T9ELtd30S_15vn8bhrI'
"""
thumbprint = calculate_thumbprint(value, digest_method=digest_method)
return concat_thumbprint_uri(thumbprint, digest_method=digest_method)
def concat_thumbprint_uri(value: str, digest_method: t.Literal["sha256", "sha384", "sha512"]) -> str:
method = digest_method.replace("sha", "sha-")
return f"{JWK_THUMBPRINT_URN}:{method}:{value}"
@@ -0,0 +1,8 @@
from .jws_eddsa import JWS_ALGORITHMS, Ed25519, Ed448
__all__ = [
"JWS_ALGORITHMS",
"Ed25519",
"Ed448",
]
@@ -0,0 +1,39 @@
import typing as t
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey, Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PublicKey, Ed448PrivateKey
from ..errors import InvalidKeyCurveError
from .._rfc7515.model import JWSAlgModel
from .._rfc8037.okp_key import OKPKey
class EdDSAAlgorithm(JWSAlgModel):
key_type = "OKP"
def __init__(self, curve: t.Literal["Ed25519", "Ed448"]):
self.name = curve
self.curve = curve
self.description = f"EdDSA using the {curve} parameter set"
def check_key(self, key: OKPKey) -> None:
super().check_key(key)
if key.curve_name != self.curve:
raise InvalidKeyCurveError(f"Key for '{self.name}' not supported, only '{self.curve}' allowed")
def sign(self, msg: bytes, key: OKPKey) -> bytes:
op_key = t.cast(t.Union[Ed25519PrivateKey, Ed448PrivateKey], key.get_op_key("sign"))
return op_key.sign(msg)
def verify(self, msg: bytes, sig: bytes, key: OKPKey) -> bool:
op_key = t.cast(t.Union[Ed25519PublicKey, Ed448PublicKey], key.get_op_key("verify"))
try:
op_key.verify(sig, msg)
return True
except InvalidSignature:
return False
Ed25519 = EdDSAAlgorithm("Ed25519")
Ed448 = EdDSAAlgorithm("Ed448")
JWS_ALGORITHMS: list[EdDSAAlgorithm] = [Ed25519, Ed448]
@@ -0,0 +1,40 @@
from Crypto.Cipher import ChaCha20_Poly1305
from .._rfc7516.registry import JWERegistry
from .._rfc7516.models import JWEEncModel
__all__ = ["ChaCha20EncModel", "JWE_ENC_MODELS", "register_chacha20_poly1305"]
class ChaCha20EncModel(JWEEncModel):
# https://datatracker.ietf.org/doc/html/draft-amringer-jose-chacha-02#section-4
cek_size = 256
recommended = False
def __init__(self, name: str, description: str, iv_size: int):
self.name = name
self.description = description
self.iv_size = iv_size
def encrypt(self, plaintext: bytes, cek: bytes, iv: bytes, aad: bytes) -> tuple[bytes, bytes]:
"""Key Encryption with AEAD_CHACHA20_POLY1305"""
chacha = ChaCha20_Poly1305.new(key=cek, nonce=iv)
chacha.update(aad)
ciphertext, tag = chacha.encrypt_and_digest(plaintext)
return ciphertext, tag
def decrypt(self, ciphertext: bytes, tag: bytes, cek: bytes, iv: bytes, aad: bytes) -> bytes:
"""Key Decryption with AEAD_CHACHA20_POLY1305."""
chacha = ChaCha20_Poly1305.new(key=cek, nonce=iv)
chacha.update(aad)
return chacha.decrypt_and_verify(ciphertext, tag)
C20P = ChaCha20EncModel("C20P", "ChaCha20-Poly1305", 96)
XC20P = ChaCha20EncModel("XC20P", "XChaCha20-Poly1305", 192)
JWE_ENC_MODELS = [C20P, XC20P]
def register_chacha20_poly1305() -> None:
for model in JWE_ENC_MODELS:
JWERegistry.register(model)
@@ -0,0 +1,117 @@
from .._rfc7516.models import Recipient, JWEKeyAgreement, JWEKeyWrapping, JWEEncModel
from .._rfc7518.jwe_algs import (
A128KW,
A192KW,
A256KW,
)
from .._rfc7518.ec_key import ECKey
from .._rfc7518.derive_key import (
derive_key_for_concat_kdf,
)
from .._rfc7518.jwe_encs import CBCHS2EncModel
from ..registry import HeaderParameter
from ..errors import InvalidEncryptionAlgorithmError
__all__ = ["ECDH1PUAlgModel", "register_ecdh_1pu", "JWE_ALG_MODELS"]
class ECDH1PUAlgModel(JWEKeyAgreement):
"""Key Agreement with Elliptic Curve Diffie-Hellman One-Pass Unified Model (ECDH-1PU)
https://datatracker.ietf.org/doc/html/draft-madden-jose-ecdh-1pu-04
"""
more_header_registry = {
"epk": HeaderParameter("Ephemeral Public Key", "jwk", True),
"apu": HeaderParameter("Agreement PartyUInfo", "str"),
"apv": HeaderParameter("Agreement PartyVInfo", "str"),
"skid": HeaderParameter("Sender Key ID", "str"),
}
key_types = ["EC", "OKP"]
tag_aware = True
def __init__(self, key_wrapping: JWEKeyWrapping | None):
if key_wrapping is None:
self.name = "ECDH-1PU"
self.description = "ECDH-1PU using one-pass KDF and CEK in the Direct Key Agreement mode"
self.key_size = None
else:
self.name = f"ECDH-1PU+{key_wrapping.name}"
self.description = f"ECDH-1PU using one-pass KDF and CEK wrapped with {key_wrapping.name}"
self.key_size = key_wrapping.key_size
self.key_wrapping = key_wrapping
def _check_enc(self, enc: JWEEncModel) -> None:
# https://datatracker.ietf.org/doc/html/draft-madden-jose-ecdh-1pu-04#section-2.1
# The AES_CBC_HMAC_SHA2 algorithms described in section 5.2 of [RFC7518] are compactly
# committing and can be used with ECDH-1PU in Key Agreement with Key Wrapping mode.
# Other content encryption algorithms MUST be rejected. In Direct Key Agreement
# mode, any JWE content encryption algorithm MAY be used.
if self.key_wrapping and not isinstance(enc, CBCHS2EncModel):
description = (
"In key agreement with key wrapping mode ECDH-1PU algorithm "
"only supports AES_CBC_HMAC_SHA2 family encryption algorithms"
)
raise InvalidEncryptionAlgorithmError(description)
def encrypt_agreed_upon_key(self, enc: JWEEncModel, recipient: Recipient[ECKey]) -> bytes:
self._check_enc(enc)
return self.__encrypt_agreed_upon_key(enc, recipient, None)
def encrypt_agreed_upon_key_with_tag(self, enc: JWEEncModel, recipient: Recipient[ECKey], tag: bytes) -> bytes:
self._check_enc(enc)
return self.__encrypt_agreed_upon_key(enc, recipient, tag)
def decrypt_agreed_upon_key(self, enc: JWEEncModel, recipient: Recipient[ECKey]) -> bytes:
return self.__decrypt_agreed_upon_key(enc, recipient, None)
def decrypt_agreed_upon_key_with_tag(self, enc: JWEEncModel, recipient: Recipient[ECKey], tag: bytes) -> bytes:
return self.__decrypt_agreed_upon_key(enc, recipient, tag)
def __encrypt_agreed_upon_key(self, enc: JWEEncModel, recipient: Recipient[ECKey], tag: bytes | None) -> bytes:
sender_key = recipient.sender_key
recipient_key = recipient.recipient_key
ephemeral_key = recipient.ephemeral_key
assert sender_key is not None
assert recipient_key is not None
assert ephemeral_key is not None
sender_shared_key = sender_key.exchange_derive_key(recipient_key)
ephemeral_shared_key = ephemeral_key.exchange_derive_key(recipient_key)
shared_key = ephemeral_shared_key + sender_shared_key
headers = recipient.headers()
return derive_key_for_concat_kdf(shared_key, headers, enc.cek_size, self.key_size, tag)
def __decrypt_agreed_upon_key(self, enc: JWEEncModel, recipient: Recipient[ECKey], tag: bytes | None) -> bytes:
self._check_enc(enc)
headers = recipient.headers()
assert "epk" in headers
sender_key = recipient.sender_key
recipient_key = recipient.recipient_key
assert sender_key is not None
assert recipient_key is not None
ephemeral_key = recipient_key.import_key(headers["epk"])
sender_shared_key = recipient_key.exchange_derive_key(sender_key)
ephemeral_shared_key = recipient_key.exchange_derive_key(ephemeral_key)
shared_key = ephemeral_shared_key + sender_shared_key
return derive_key_for_concat_kdf(shared_key, headers, enc.cek_size, self.key_size, tag)
JWE_ALG_MODELS = [
ECDH1PUAlgModel(None), # ECDH-1PU
ECDH1PUAlgModel(A128KW), # ECDH-1PU+A128KW
ECDH1PUAlgModel(A192KW), # ECDH-1PU+A192KW
ECDH1PUAlgModel(A256KW), # ECDH-1PU+A256KW
]
def register_ecdh_1pu() -> None:
from ..jwe import JWERegistry
from ..jwk import KeySet
for model in JWE_ALG_MODELS:
JWERegistry.register(model)
KeySet.algorithm_keys[model.name] = model.key_types
+238
View File
@@ -0,0 +1,238 @@
class SecurityWarning(UserWarning):
"""Base class for warnings of security issues."""
pass
class JoseError(Exception):
"""Base Exception for all errors in joserfc."""
#: short-string error code
error: str = ""
#: long-string to describe this error
description: str = ""
def __init__(self, description: str | None = None):
if description is not None:
self.description = description
message = "{}: {}".format(self.error, self.description)
super(JoseError, self).__init__(message)
# --- Key related errors --- #
class KeyParameterError(JoseError):
error = "key_parameter"
class MissingKeyError(JoseError):
error = "missing_key"
class UnsupportedKeyUseError(KeyParameterError):
error = "unsupported_key_use"
class UnsupportedKeyAlgorithmError(KeyParameterError):
error = "unsupported_key_alg"
class UnsupportedKeyOperationError(KeyParameterError):
error = "unsupported_key_operation"
class MissingKeyTypeError(KeyParameterError):
error = "missing_key_type"
class InvalidKeyTypeError(KeyParameterError):
error = "invalid_key_type"
class InvalidKeyIdError(JoseError):
"""This error is designed for Key Set. It is raised when a key
can not be found with the given key ID."""
error = "invalid_key_id"
class InvalidExchangeKeyError(JoseError):
"""This error is designed for EC and OKP keys. It is raised when
exchanging derive key failed."""
error = "invalid_exchange_key"
description = "Invalid key for exchanging shared key"
# --- JWS & JWE related errors --- #
class DecodeError(JoseError):
"""This error is designed for both JWS and JWE. It is raised when deserialization
and decryption fails.
"""
error = "decode_error"
class MissingAlgorithmError(JoseError):
"""Raised when an algorithm ("alg") is missing."""
error = "missing_algorithm"
description = "Missing 'alg' value in header"
class ConflictAlgorithmError(JoseError):
error = "conflict_algorithm"
class UnsupportedAlgorithmError(JoseError):
"""This error is designed for both JWS and JWE. It is raised when the
given algorithm is not supported in the registry.
"""
error = "unsupported_algorithm"
class InvalidHeaderValueError(JoseError):
"""Raised when the given header's value is invalid."""
error = "invalid_header_value"
class UnsupportedHeaderError(JoseError):
"""Raised when an unsupported header is encountered."""
error = "unsupported_header"
class MissingHeaderError(JoseError):
"""This error happens when the required header does not exist."""
error = "missing_header"
def __init__(self, key: str):
description = f"Missing '{key}' value in header"
super(MissingHeaderError, self).__init__(description=description)
class MissingCritHeaderError(JoseError):
"""This error happens when the critical header does not exist."""
error = "missing_crit_header"
def __init__(self, key: str):
description = f"Missing critical '{key}' value in header"
super(MissingCritHeaderError, self).__init__(description=description)
class MissingEncryptionError(JoseError):
"""This error is designed for JWE. It is raised when the 'enc' value
in header is missing."""
error = "missing_encryption"
description = "Missing 'enc' value in header"
class InvalidKeyCurveError(JoseError):
"""This error is designed for JWS. It is raised when key's
curve name does not match with the given algorithm.
"""
error = "invalid_key_curve"
class InvalidKeyLengthError(JoseError):
"""This error is designed for JWE. It is raised when key's
length does not align with the given algorithm.
"""
error = "invalid_key_length"
class BadSignatureError(JoseError):
"""This error is designed for JWS. It is raised when signature
does not match.
"""
error = "bad_signature"
class ExceededSizeError(JoseError):
"""This error is designed for validating the token's content size.
It raised when the data exceeds the maximum allowed length."""
error = "exceeded_size"
class InvalidEncryptionAlgorithmError(JoseError):
"""This error is designed for JWE. It is raised when "enc" value
does not work together with "alg" value.
"""
error = "invalid_encryption_algorithm"
class InvalidEncryptedKeyError(JoseError):
error = "invalid_encrypted_key"
description = "JWE Encrypted Key value SHOULD be an empty octet sequence"
# --- JWT related errors --- #
class ClaimError(JoseError):
"""This a base error for JWT claims validation."""
claim: str
description = "Error claim: '{}'"
def __init__(self, claim: str, description: str | None = None):
self.claim = claim
if description is None:
description = self.description.format(claim)
super(ClaimError, self).__init__(description=description)
class InvalidClaimError(ClaimError):
"""This error is designed for JWT. It raised when the claim contains
invalid values or types."""
error = "invalid_claim"
description = "Invalid claim: '{}'"
class MissingClaimError(ClaimError):
"""This error is designed for JWT. It raised when the required
claims are missing."""
error = "missing_claim"
description = "Missing claim: '{}'"
class InsecureClaimError(ClaimError):
"""This error is designed for JWT. It raised when the claim
contains sensitive information."""
error = "insecure_claim"
description = "Insecure claim: '{}'"
class ExpiredTokenError(ClaimError):
"""This error is designed for JWT. It raised when the token is expired."""
error = "expired_token"
description = "The token is expired"
class InvalidPayloadError(JoseError):
"""This error is designed for JWT. It raised when the payload is
not a valid JSON object."""
error = "invalid_payload"
# compatibility
InvalidTokenError = InvalidClaimError
+100
View File
@@ -0,0 +1,100 @@
from ._rfc7515.registry import JWSRegistry
from ._rfc7515.model import JWSAlgModel
from ._rfc7516.registry import JWERegistry
from ._rfc7516.models import (
JWEDirectEncryption,
JWEKeyEncryption,
JWEKeyWrapping,
JWEKeyAgreement,
JWEAlgModel,
JWEEncModel,
JWEZipModel,
)
from ._rfc7518.jws_algs import (
NoneAlgorithm,
HMACAlgorithm,
RSAAlgorithm,
ESAlgorithm,
RSAPSSAlgorithm,
JWS_ALGORITHMS as RFC7518_JWS_ALGORITHMS,
)
from ._rfc7518.jwe_algs import (
DirectAlgEncryption,
AESAlgKeyWrapping,
ECDHESAlgKeyAgreement,
AESGCMAlgKeyWrapping,
PBES2HSAlgKeyEncryption,
JWE_ALG_MODELS,
)
from ._rfc7518.jwe_encs import (
CBCHS2EncModel,
GCMEncModel,
JWE_ENC_MODELS,
)
from ._rfc7518.jwe_zips import (
DeflateZipModel,
JWE_ZIP_MODELS,
)
from ._rfc8037.jws_eddsa import EdDSA, EdDSAAlgorithm
from ._rfc8812 import ES256K
from ._rfc9864 import JWS_ALGORITHMS as RFC9864_JWS_ALGORITHMS
from ._keys import KeySet
__all__ = [
# JWS algorithms
"JWS_ALGORITHMS",
"JWSAlgModel",
"NoneAlgorithm",
"HMACAlgorithm",
"RSAAlgorithm",
"ESAlgorithm",
"RSAPSSAlgorithm",
"EdDSAAlgorithm",
# JWE algorithms
"JWE_ALG_MODELS",
"JWE_ENC_MODELS",
"JWE_ZIP_MODELS",
"JWEAlgModel",
"JWEDirectEncryption",
"JWEKeyEncryption",
"JWEKeyWrapping",
"JWEKeyAgreement",
"DirectAlgEncryption",
"AESAlgKeyWrapping",
"ECDHESAlgKeyAgreement",
"AESGCMAlgKeyWrapping",
"PBES2HSAlgKeyEncryption",
"JWEEncModel",
"CBCHS2EncModel",
"GCMEncModel",
"JWEZipModel",
"DeflateZipModel",
# setup methods
"setup_jws_algorithms",
"setup_jwe_algorithms",
]
JWS_ALGORITHMS = [
*RFC7518_JWS_ALGORITHMS,
EdDSA,
ES256K,
*RFC9864_JWS_ALGORITHMS,
]
def setup_jws_algorithms() -> None:
for _alg in JWS_ALGORITHMS:
JWSRegistry.register(_alg)
KeySet.algorithm_keys[_alg.name] = [_alg.key_type]
def setup_jwe_algorithms() -> None:
for _alg in JWE_ALG_MODELS:
KeySet.algorithm_keys[_alg.name] = _alg.key_types
JWERegistry.register(_alg)
for _enc in JWE_ENC_MODELS:
JWERegistry.register(_enc)
for _zip in JWE_ZIP_MODELS:
JWERegistry.register(_zip)
+277
View File
@@ -0,0 +1,277 @@
from typing import cast, overload, Union
from collections.abc import Collection
from ._rfc7516.types import (
GeneralJSONSerialization,
FlattenedJSONSerialization,
)
from ._rfc7516.models import (
Recipient,
CompactEncryption,
GeneralJSONEncryption,
FlattenedJSONEncryption,
)
from ._rfc7516.registry import (
JWERegistry,
default_registry,
)
from ._rfc7516.message import perform_encrypt, perform_decrypt
from ._rfc7516.compact import represent_compact, extract_compact
from ._rfc7516.json import (
represent_general_json,
represent_flattened_json,
extract_general_json,
extract_flattened_json,
)
from .jwa import setup_jwe_algorithms
from .jwk import Key, KeySet, ECKey, OKPKey, KeyFlexible, guess_key
from .util import to_bytes
from .registry import Header, reject_unprotected_crit_header
__all__ = [
# types
"GeneralJSONSerialization",
"FlattenedJSONSerialization",
# modules
"JWERegistry",
"Recipient",
"CompactEncryption",
"GeneralJSONEncryption",
"FlattenedJSONEncryption",
# methods
"encrypt_compact",
"decrypt_compact",
"encrypt_json",
"decrypt_json",
# consts
"default_registry",
]
setup_jwe_algorithms()
def encrypt_compact(
protected: Header,
plaintext: bytes | str,
public_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWERegistry | None = None,
sender_key: ECKey | OKPKey | KeySet | None = None,
) -> str:
"""Generate a JWE Compact Serialization. The JWE Compact Serialization represents
encrypted content as a compact, URL-safe string. This string is::
BASE64URL(UTF8(JWE Protected Header)) || '.' ||
BASE64URL(JWE Encrypted Key) || '.' ||
BASE64URL(JWE Initialization Vector) || '.' ||
BASE64URL(JWE Ciphertext) || '.' ||
BASE64URL(JWE Authentication Tag)
:param protected: protected header part of the JWE, in dict
:param plaintext: the content (message) to be encrypted
:param public_key: a public key used to encrypt the CEK
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a JWERegistry to use
:param sender_key: only required when using ECDH-1PU
:return: JWE Compact Serialization in bytes
"""
if algorithms:
registry = JWERegistry(algorithms=algorithms)
elif registry is None:
registry = default_registry
obj = CompactEncryption(protected, to_bytes(plaintext))
recipient: Recipient[Key] = Recipient(obj)
key = guess_key(public_key, recipient, True, use="enc")
key.check_use("enc")
recipient.recipient_key = key
if sender_key:
recipient.sender_key = _guess_sender_key(recipient, sender_key, True)
obj.recipient = recipient
perform_encrypt(obj, registry)
out = represent_compact(obj)
return out.decode("utf-8")
def decrypt_compact(
value: bytes | str,
private_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWERegistry | None = None,
sender_key: ECKey | OKPKey | KeySet | None = None,
) -> CompactEncryption:
"""Extract and validate the JWE Compact Serialization (in string, or bytes)
with the given key. An JWE Compact Serialization looks like:
.. code-block:: text
:caption: line breaks for display purposes only
OKOawDo13gRp2ojaHV7LFpZcgV7T6DVZKTyKOMTYUmKoTCVJRgckCL9kiMT03JGe
ipsEdY3mx_etLbbWSrFr05kLzcSr4qKAq7YN7e9jwQRb23nfa6c9d-StnImGyFDb
Sv04uVuxIp5Zms1gNxKKK2Da14B8S4rzVRltdYwam_lDp5XnZAYpQdb76FdIKLaV
mqgfwX7XWRxv2322i-vDxRfqNzo_tETKzpVLzfiwQyeyPGLBIO56YJ7eObdv0je8
1860ppamavo35UgoRdbYaBcoh9QcfylQr66oc6vFWXRcZ_ZT2LawVCWTIy3brGPi
6UklfCpIMfIjf7iGdXKHzg
:param value: a string (or bytes) of the JWE Compact Serialization
:param private_key: a flexible private key to decrypt the serialization
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a JWERegistry to use
:param sender_key: only required when using ECDH-1PU
:return: object of the ``CompactEncryption``
"""
if algorithms:
registry = JWERegistry(algorithms=algorithms)
elif registry is None:
registry = default_registry
obj = extract_compact(to_bytes(value), registry)
recipient = obj.recipient
assert recipient is not None
key = guess_key(private_key, recipient, use="enc")
key.check_use("enc")
recipient.recipient_key = key
if sender_key:
recipient.sender_key = _guess_sender_key(recipient, sender_key)
perform_decrypt(obj, registry)
return obj
@overload
def encrypt_json(
obj: GeneralJSONEncryption,
public_key: KeyFlexible | None,
algorithms: Collection[str] | None = None,
registry: JWERegistry | None = None,
sender_key: ECKey | OKPKey | KeySet | None = None,
) -> GeneralJSONSerialization: ...
@overload
def encrypt_json(
obj: FlattenedJSONEncryption,
public_key: KeyFlexible | None,
algorithms: Collection[str] | None = None,
registry: JWERegistry | None = None,
sender_key: ECKey | OKPKey | KeySet | None = None,
) -> FlattenedJSONSerialization: ...
def encrypt_json(
obj: GeneralJSONEncryption | FlattenedJSONEncryption,
public_key: KeyFlexible | None,
algorithms: Collection[str] | None = None,
registry: JWERegistry | None = None,
sender_key: ECKey | OKPKey | KeySet | None = None,
) -> GeneralJSONSerialization | FlattenedJSONSerialization:
"""Generate a JWE JSON Serialization (in dict). The JWE JSON Serialization
represents encrypted content as a JSON object. This representation is neither
optimized for compactness nor URL safe.
When calling this method, developers MUST construct an instance of a
``GeneralJSONEncryption`` or ``FlattenedJSONEncryption`` object. Here
is an example::
from joserfc.jwe import GeneralJSONEncryption
protected = {"enc": "A128CBC-HS256"}
plaintext = b"hello world"
header = {"jku": "https://server.example.com/keys.jwks"} # optional shared header
obj = GeneralJSONEncryption(protected, plaintext, header)
# add the recipients
obj.add_recipient({"kid": "alice", "alg": "RSA1_5"}) # not configured a key
bob_key = OctKey.import_key("bob secret")
obj.add_recipient({"kid": "bob", "alg": "A128KW"}, bob_key)
:param obj: an instance of ``GeneralJSONEncryption`` or ``FlattenedJSONEncryption``
:param public_key: a public key used to encrypt the CEK
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a JWERegistry to use
:param sender_key: only required when using ECDH-1PU
:return: JWE JSON Serialization in dict
"""
if algorithms:
registry = JWERegistry(algorithms=algorithms)
elif registry is None:
registry = default_registry
reject_unprotected_crit_header(obj.unprotected)
for recipient in obj.recipients:
if sender_key and not recipient.sender_key:
recipient.sender_key = _guess_sender_key(recipient, sender_key, True)
if not recipient.recipient_key:
assert public_key is not None
key = guess_key(public_key, recipient, True, use="enc")
key.check_use("enc")
recipient.recipient_key = key
perform_encrypt(obj, registry)
if isinstance(obj, GeneralJSONEncryption):
return represent_general_json(obj)
return represent_flattened_json(obj)
def decrypt_json(
data: GeneralJSONSerialization | FlattenedJSONSerialization,
private_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWERegistry | None = None,
sender_key: ECKey | OKPKey | KeySet | None = None,
) -> GeneralJSONEncryption | FlattenedJSONEncryption:
"""Decrypt the JWE JSON Serialization (in dict) to a
``GeneralJSONEncryption`` or ``FlattenedJSONEncryption`` object.
:param data: JWE JSON Serialization in dict
:param private_key: a flexible private key to decrypt the CEK
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a JWERegistry to use
:param sender_key: only required when using ECDH-1PU
:return: an instance of ``GeneralJSONEncryption`` or ``FlattenedJSONEncryption``
"""
if algorithms:
registry = JWERegistry(algorithms=algorithms)
elif registry is None:
registry = default_registry
reject_unprotected_crit_header(data.get("unprotected"))
if "recipients" in data:
general_obj = extract_general_json(cast(GeneralJSONSerialization, data), registry)
_attach_recipient_keys(general_obj.recipients, private_key, sender_key)
perform_decrypt(general_obj, registry)
return general_obj
else:
flattened_obj = extract_flattened_json(cast(FlattenedJSONSerialization, data), registry)
_attach_recipient_keys(flattened_obj.recipients, private_key, sender_key)
perform_decrypt(flattened_obj, registry)
return flattened_obj
def _attach_recipient_keys(
recipients: list[Recipient[Key]], private_key: KeyFlexible, sender_key: ECKey | OKPKey | KeySet | None = None
) -> None:
for recipient in recipients:
key = guess_key(private_key, recipient, use="enc")
key.check_use("enc")
recipient.recipient_key = key
if sender_key:
recipient.sender_key = _guess_sender_key(recipient, sender_key)
def _guess_sender_key(
recipient: Recipient[Key], key: ECKey | OKPKey | KeySet, use_random: bool = False
) -> ECKey | OKPKey:
if not isinstance(key, KeySet):
return key
headers = recipient.headers()
skid = headers.get("skid")
if skid:
skey = cast(Union[ECKey, OKPKey], key.get_by_kid(skid))
return skey
if use_random:
skey = cast(Union[ECKey, OKPKey], key.pick_random_key(headers["alg"]))
if skey is not None:
recipient.add_header("skid", skey.kid)
return skey
raise ValueError("Invalid key")
+216
View File
@@ -0,0 +1,216 @@
import warnings
from typing import cast, overload, Literal, Union, Callable, Protocol
from ._keys import (
JWKRegistry,
KeySet,
Key,
KeySetSerialization,
)
from ._rfc7517.pem import import_from_pem_key, import_from_ssh_key
from ._rfc7517.types import AnyKey, DictKey, KeyParameters
from ._rfc7518.oct_key import OctKey
from ._rfc7518.rsa_key import RSAKey
from ._rfc7518.ec_key import ECKey
from ._rfc8037.okp_key import OKPKey
from ._rfc8812 import register_secp256k1
from ._rfc7638 import calculate_thumbprint as thumbprint
from ._rfc9278 import calculate_thumbprint_uri as thumbprint_uri
from .errors import SecurityWarning, InvalidKeyTypeError
from .registry import Header
from .util import to_bytes
__all__ = [
# types
"Key",
"DictKey",
"KeyParameters",
"KeyCallable",
"KeyFlexible",
"KeySetSerialization",
"KeyBase",
"GuestProtocol",
# modules
"JWKRegistry",
"OctKey",
"RSAKey",
"ECKey",
"OKPKey",
"KeySet",
# methods
"guess_key",
"import_key",
"generate_key",
"thumbprint",
"thumbprint_uri",
]
register_secp256k1()
class GuestProtocol(Protocol): # pragma: no cover
def headers(self) -> Header: ...
def set_kid(self, kid: str) -> None: ...
KeyBase = Union[Key, KeySet]
KeyCallable = Callable[[GuestProtocol], KeyBase]
KeyFlexible = Union[KeyBase, KeyCallable]
def guess_key(
key: KeyFlexible,
obj: GuestProtocol,
random: bool = False,
use: Literal["sig", "enc"] | None = None,
) -> Key:
"""Guess key from a various sources.
:param key: a very flexible key
:param obj: a protocol that has ``headers`` and ``set_kid`` methods
:param random: pick a random key from key set
:param use: optional "use" value
"""
resolved_key: KeyBase
if callable(key):
resolved_key = key(obj)
else:
resolved_key = key
if isinstance(resolved_key, (OctKey, RSAKey, ECKey, OKPKey)):
return resolved_key
elif isinstance(resolved_key, KeySet):
headers = obj.headers()
kid: str | None = headers.get("kid")
parameters: KeyParameters = {"alg": headers["alg"]}
if use:
parameters["use"] = use
if not kid and random:
# choose one key by random
return_key = resolved_key.pick_random_key(headers["alg"], parameters)
if return_key is None:
raise ValueError("Invalid key")
return_key.ensure_kid()
obj.set_kid(cast(str, return_key.kid))
else:
return_key = resolved_key.get_by_kid(kid, parameters)
return return_key
else:
raise ValueError("Invalid key")
@overload
def import_key(data: AnyKey, key_type: Literal["oct"], parameters: KeyParameters | None = None) -> OctKey: ...
@overload
def import_key(data: AnyKey, key_type: Literal["RSA"], parameters: KeyParameters | None = None) -> RSAKey: ...
@overload
def import_key(data: AnyKey, key_type: Literal["EC"], parameters: KeyParameters | None = None) -> ECKey: ...
@overload
def import_key(data: AnyKey, key_type: Literal["OKP"], parameters: KeyParameters | None = None) -> OKPKey: ...
@overload
def import_key(data: AnyKey, key_type: None = None, parameters: KeyParameters | None = None) -> Key: ...
def import_key(
data: AnyKey,
key_type: Literal["oct", "RSA", "EC", "OKP"] | None = None,
parameters: KeyParameters | None = None,
) -> Key:
"""Importing a key from bytes, string, and dict. When ``value`` is a dict,
this method can tell the key type automatically, otherwise, developers
SHOULD pass the ``key_type`` themselves.
:param data: the key data in bytes, string, or dict.
:param key_type: an optional key type in string.
:param parameters: extra key parameters
:return: OctKey, RSAKey, ECKey, or OKPKey
"""
if isinstance(data, (str, bytes)) and key_type is None:
warnings.warn("Using implicit key type is not recommended.", SecurityWarning)
value = to_bytes(data)
ssh_types = tuple(
cls.binding.ssh_type for cls in JWKRegistry.key_types.values() if hasattr(cls.binding, "ssh_type")
)
if value.startswith(ssh_types):
try:
raw_key = import_from_ssh_key(value)
except ValueError:
return OctKey.import_key(value, parameters)
else:
try:
raw_key = import_from_pem_key(value)
except ValueError:
return OctKey.import_key(value, parameters)
for cls in JWKRegistry.key_types.values():
if hasattr(cls.binding, "check_cryptography_key") and cls.binding.check_cryptography_key(raw_key):
return cls(raw_key, data, parameters)
raise InvalidKeyTypeError("Not a key of any supported type") # pragma: no cover
return JWKRegistry.import_key(data, key_type, parameters)
@overload
def generate_key(
key_type: Literal["oct"],
crv_or_size: int | None = None,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> OctKey: ...
@overload
def generate_key(
key_type: Literal["RSA"],
crv_or_size: int | None = None,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> RSAKey: ...
@overload
def generate_key(
key_type: Literal["EC"],
crv_or_size: Literal["P-256", "P-384", "P-521", "secp256k1"] | None = None,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> ECKey: ...
@overload
def generate_key(
key_type: Literal["OKP"],
crv_or_size: Literal["Ed25519", "Ed448", "X25519", "X448"] | None = None,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> OKPKey: ...
def generate_key(
key_type: Literal["oct", "RSA", "EC", "OKP"],
crv_or_size: str | int | None = None,
parameters: KeyParameters | None = None,
private: bool = True,
auto_kid: bool = False,
) -> Key:
"""Generating key according to the given key type. When ``key_type`` is
"oct" and "RSA", the second parameter SHOULD be a key size in bits.
When ``key_type`` is "EC" and "OKP", the second
parameter SHOULD be a "crv" string.
"""
return JWKRegistry.generate_key(key_type, crv_or_size, parameters, private, auto_kid)
+353
View File
@@ -0,0 +1,353 @@
from typing import overload, Any
from collections.abc import Collection
from ._rfc7515.model import (
JWSAlgModel,
HeaderMember,
CompactSignature,
GeneralJSONSignature,
FlattenedJSONSignature,
)
from ._rfc7515.registry import (
JWSRegistry,
construct_registry,
default_registry,
)
from ._rfc7515.compact import (
sign_compact,
verify_compact,
detach_compact_content,
)
from ._rfc7515.json import (
sign_general_json,
sign_flattened_json,
verify_general_json,
verify_flattened_json,
extract_general_json,
detach_json_content,
)
from ._rfc7515.types import (
HeaderDict,
GeneralJSONSerialization,
FlattenedJSONSerialization,
)
from ._rfc7797.util import is_rfc7797_enabled
from ._rfc7797.compact import (
sign_rfc7515_compact,
extract_rfc7515_compact as extract_compact,
)
from ._rfc7797.json import (
sign_rfc7797_json,
extract_rfc7797_json as extract_flattened_json,
)
from .errors import BadSignatureError, MissingKeyError
from .jwk import Key, KeyFlexible, guess_key
from .jwa import setup_jws_algorithms
from .util import to_bytes
from .registry import Header
__all__ = [
# types
"HeaderDict",
"GeneralJSONSerialization",
"FlattenedJSONSerialization",
# modules
"JWSRegistry",
"HeaderMember",
"CompactSignature",
"GeneralJSONSignature",
"FlattenedJSONSignature",
# methods
"serialize_compact",
"deserialize_compact",
"extract_compact",
"validate_compact",
"serialize_json",
"deserialize_json",
"detach_content",
# consts
"default_registry",
]
setup_jws_algorithms()
def serialize_compact(
protected: Header,
payload: bytes | str,
private_key: KeyFlexible | None,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
) -> str:
"""Generate a JWS Compact Serialization. The JWS Compact Serialization
represents digitally signed or MACed content as a compact, URL-safe
string, per Section 7.1.
.. code-block:: text
BASE64URL(UTF8(JWS Protected Header)) || '.' ||
BASE64URL(JWS Payload) || '.' ||
BASE64URL(JWS Signature)
:param protected: protected header part of the JWS, in dict
:param payload: payload data of the JWS, in bytes
:param private_key: a flexible private key to sign the signature
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a JWSRegistry to use
:return: JWS in str
"""
if registry is None:
registry = construct_registry(algorithms)
registry.check_header(protected)
is_rfc7797 = is_rfc7797_enabled(protected)
obj = CompactSignature(protected, to_bytes(payload))
alg: JWSAlgModel = registry.get_alg(protected["alg"])
# "none" algorithm requires no key
key: Key | None = None
if alg.name != "none":
if private_key is None:
raise MissingKeyError()
key = guess_key(private_key, obj, True, use="sig")
alg.check_key(key)
if is_rfc7797:
out = sign_rfc7515_compact(obj, alg, key)
else:
out = sign_compact(obj, alg, key)
return out.decode("utf-8")
def validate_compact(
obj: CompactSignature,
public_key: KeyFlexible | None,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
) -> bool:
"""Validate the JWS Compact Serialization with the given key.
This method is usually used together with ``extract_compact``.
:param obj: object of the JWS Compact Serialization
:param public_key: a flexible public key to verify the signature
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a JWSRegistry to use
"""
if registry is None:
registry = construct_registry(algorithms)
headers = obj.headers()
registry.check_header(headers)
alg: JWSAlgModel = registry.get_alg(headers["alg"])
# "none" algorithm requires no key
if headers["alg"] == "none":
return verify_compact(obj, alg, None)
if public_key is None:
raise MissingKeyError()
key: Key = guess_key(public_key, obj, use="sig")
alg.check_key(key)
return verify_compact(obj, alg, key)
def deserialize_compact(
value: bytes | str,
public_key: KeyFlexible | None,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
payload: bytes | str | None = None,
) -> CompactSignature:
"""Extract and validate the JWS Compact Serialization (in string, or bytes)
with the given key. An JWE Compact Serialization looks like:
.. code-block:: text
:caption: line breaks for display purposes only
eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9
.
eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt
cGxlLmNvbS9pc19yb290Ijp0cnVlfQ
.
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
:param value: a string (or bytes) of the JWS Compact Serialization
:param public_key: a flexible public key to verify the signature
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a JWSRegistry to use
:param payload: optional payload, required with detached content
:raises BadSignatureError: when signature verification fails
:return: object of the CompactSignature
"""
obj = extract_compact(to_bytes(value), payload, registry)
if not validate_compact(obj, public_key, algorithms, registry):
raise BadSignatureError()
return obj
@overload
def serialize_json(
members: list[HeaderDict],
payload: bytes | str,
private_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
) -> GeneralJSONSerialization: ...
@overload
def serialize_json(
members: HeaderDict,
payload: bytes | str,
private_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
) -> FlattenedJSONSerialization: ...
def serialize_json(
members: HeaderDict | list[HeaderDict],
payload: bytes | str,
private_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
) -> GeneralJSONSerialization | FlattenedJSONSerialization:
"""Generate a JWS JSON Serialization (in dict). The JWS JSON Serialization
represents digitally signed or MACed content as a JSON object. This representation
is neither optimized for compactness nor URL-safe.
A general JWS JSON Serialization contains:
payload
The "payload" member MUST be present and contain the value
BASE64URL(JWS Payload).
signatures
The "signatures" member value MUST be an array of JSON objects.
Each object represents a signature or MAC over the JWS Payload and
the JWS Protected Header.
A flatten JWS JSON Serialization looks like:
.. code-block:: text
{
"payload":"<payload contents>",
"protected":"<integrity-protected header contents>",
"header":<non-integrity-protected header contents>,
"signature":"<signature contents>"
}
"""
if registry is None:
registry = construct_registry(algorithms)
def find_key(obj: HeaderMember) -> Key:
return guess_key(private_key, obj, True, use="sig")
_payload = to_bytes(payload)
if isinstance(members, list):
_members = [HeaderMember(**member) for member in members]
return sign_general_json(_members, _payload, registry, find_key)
else:
member = HeaderMember(**members)
if is_rfc7797_enabled(member.headers()):
return sign_rfc7797_json(member, _payload, registry, find_key)
return sign_flattened_json(member, _payload, registry, find_key)
@overload
def deserialize_json(
value: GeneralJSONSerialization,
public_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
) -> GeneralJSONSignature: ...
@overload
def deserialize_json(
value: FlattenedJSONSerialization,
public_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
) -> FlattenedJSONSignature: ...
def deserialize_json(
value: GeneralJSONSerialization | FlattenedJSONSerialization,
public_key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | None = None,
) -> GeneralJSONSignature | FlattenedJSONSignature:
"""Extract and validate the JWS (in string) with the given key.
:param value: a dict of the JSON signature
:param public_key: a flexible public key to verify the signature
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a JWSRegistry to use
:return: object of GeneralJSONSignature or FlattenedJSONSignature
:raises BadSignatureError: when signature verification fails
"""
if registry is None:
registry = construct_registry(algorithms)
def find_key(obj: HeaderMember) -> Key:
return guess_key(public_key, obj, use="sig")
if "signatures" in value:
general_obj = extract_general_json(value, registry)
if not verify_general_json(general_obj, registry, find_key):
raise BadSignatureError()
return general_obj
else:
flattened_obj = extract_flattened_json(value, registry)
if not verify_flattened_json(flattened_obj, registry, find_key):
raise BadSignatureError()
return flattened_obj
@overload
def detach_content(value: str) -> str: ...
@overload
def detach_content(value: GeneralJSONSerialization) -> dict[str, Any]: ...
@overload
def detach_content(value: FlattenedJSONSerialization) -> dict[str, Any]: ...
def detach_content(value: Any) -> Any:
"""In some contexts, it is useful to integrity-protect content that is
not itself contained in a JWS. This method is an implementation of
https://www.rfc-editor.org/rfc/rfc7515#appendix-F
It is used to detach the content of the compact and JSON serialization.
.. code-block:: python
>>> from joserfc import jws
>>> from joserfc.jwk import OctKey
>>> key = OctKey.import_key("secret")
>>> encoded_text = jws.serialize_compact({"alg": "HS256"}, b"hello", key)
>>> jws.detach_content(encoded_text)
'eyJhbGciOiJIUzI1NiJ9..UYmO_lPAY5V0Wf4KZsfhiYs1SxqXPhxvjuYqellDV5A'
You can also detach the JSON serialization:
.. code-block:: python
>>> obj = jws.serialize_json({"protected": {"alg": "HS256"}}, b"hello", key)
>>> jws.detach_content(obj)
{
'payload': '',
'signature': 'UYmO_lPAY5V0Wf4KZsfhiYs1SxqXPhxvjuYqellDV5A',
'protected': 'eyJhbGciOiJIUzI1NiJ9'
}
"""
if isinstance(value, str):
return detach_compact_content(value)
return detach_json_content(value)
+133
View File
@@ -0,0 +1,133 @@
import json
from json import JSONEncoder, JSONDecoder
from collections.abc import Collection
from ._rfc7519.claims import (
convert_claims,
Claims,
ClaimsOption,
BaseClaimsRegistry,
JWTClaimsRegistry,
)
from ._rfc7519.security import check_sensitive_data
from .jws import (
JWSRegistry,
serialize_compact,
deserialize_compact,
)
from .jwe import (
JWERegistry,
encrypt_compact,
decrypt_compact,
)
from .jwk import KeyFlexible
from .errors import InvalidPayloadError
from .util import to_bytes
from .registry import Header
__all__ = [
# types
"Claims",
"ClaimsOption",
# modules
"BaseClaimsRegistry",
"JWTClaimsRegistry",
"Token",
# methods
"encode",
"decode",
"check_sensitive_data",
]
class Token:
"""The extracted token object, which contains ``header`` and ``claims``.
:param header: the header part of the JWT
:param claims: the payload part of the JWT
"""
def __init__(self, header: Header, claims: Claims):
#: header in dict
self.header = header
#: payload claims in dict
self.claims = claims
def encode(
header: Header,
claims: Claims,
key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | JWERegistry | None = None,
encoder_cls: type[JSONEncoder] | None = None,
default_type: str | None = "JWT",
) -> str:
"""Encode a JSON Web Token with the given header, and claims.
:param header: A dict of the JWT header
:param claims: A dict of the JWT claims to be encoded
:param key: key used to sign the signature
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a ``JWSRegistry`` or ``JWERegistry`` to use
:param encoder_cls: A JSONEncoder subclass to use
:param default_type: default value of the ``typ`` header parameter
"""
if default_type is not None:
_header = {"typ": default_type, **header}
else:
_header = {**header}
payload = convert_claims(claims, encoder_cls)
if isinstance(registry, JWERegistry):
return encrypt_compact(_header, payload, key, algorithms, registry)
else:
return serialize_compact(_header, payload, key, algorithms, registry)
def decode(
value: bytes | str,
key: KeyFlexible,
algorithms: Collection[str] | None = None,
registry: JWSRegistry | JWERegistry | None = None,
decoder_cls: type[JSONDecoder] | None = None,
) -> Token:
"""Decode the JSON Web Token string with the given key, and validate
it with the claims requests.
:param value: text of the JWT
:param key: key used to verify the signature
:param algorithms: a collection (list, tuple, or set) of allowed algorithms
:param registry: a ``JWSRegistry`` or ``JWERegistry`` to use
:param decoder_cls: A JSONDecoder subclass to use
:raise BadSignatureError: when signature verification fails
:raise InvalidPayloadError: when payload is not a valid JSON object
"""
_value = to_bytes(value)
header: Header
payload: bytes
if isinstance(registry, JWERegistry):
header, payload = _decode_jwe(_value, key, algorithms, registry)
else:
header, payload = _decode_jws(_value, key, algorithms, registry)
try:
claims: Claims = json.loads(payload, cls=decoder_cls)
except (TypeError, ValueError):
raise InvalidPayloadError()
return Token(header, claims)
def _decode_jwe(
value: bytes, key: KeyFlexible, algorithms: Collection[str] | None = None, registry: JWERegistry | None = None
) -> tuple[Header, bytes]:
jwe_obj = decrypt_compact(value, key, algorithms, registry)
assert jwe_obj.plaintext is not None
return jwe_obj.headers(), jwe_obj.plaintext
def _decode_jws(
value: bytes, key: KeyFlexible, algorithms: Collection[str] | None = None, registry: JWSRegistry | None = None
) -> tuple[Header, bytes]:
jws_obj = deserialize_compact(value, key, algorithms, registry)
assert jws_obj.payload is not None
return jws_obj.headers(), jws_obj.payload
+217
View File
@@ -0,0 +1,217 @@
from typing import Any, Callable, Union
from .errors import (
MissingHeaderError,
MissingCritHeaderError,
UnsupportedHeaderError,
InvalidHeaderValueError,
)
Header = dict[str, Any]
def is_str(value: Any) -> None:
if not isinstance(value, str):
raise ValueError("must be a str")
def is_url(value: str) -> None:
is_str(value)
if not value.startswith(("http://", "https://")):
raise ValueError("must be a URL")
def is_int(value: int) -> None:
if not isinstance(value, int):
raise ValueError("must be an int")
def is_bool(value: bool) -> None:
if not isinstance(value, bool):
raise ValueError("must be an bool")
def is_list_str(values: list[str]) -> None:
if not isinstance(values, list):
raise ValueError("must be a list[str]")
if not all(isinstance(value, str) for value in values):
raise ValueError("must be a list[str]")
def is_jwk(value: dict[str, Any]) -> None:
if not isinstance(value, dict):
raise ValueError("must be a JWK")
def in_choices(choices: list[str]) -> Callable[[Union[str, list[str]]], None]:
def _is_one_of(value: str | list[str]) -> None:
if isinstance(value, list):
if not all(v in choices for v in value):
raise ValueError(f"must be one of {choices}")
elif value not in choices:
raise ValueError(f"must be one of {choices}")
return _is_one_of
def not_support(_: Any) -> None:
raise ValueError("is not supported")
Validate = Callable[[Any], None]
_value_validators: dict[str, Validate] = {
"str": is_str,
"list[str]": is_list_str,
"int": is_int,
"bool": is_bool,
"url": is_url,
"jwk": is_jwk,
"none": not_support,
}
class HeaderParameter:
"""Define the header parameter for JWS and JWE."""
def __init__(self, description: str, validate: str | Validate, required: bool = False):
#: a short description of the header parameter
self.description = description
#: a function for validating the header parameter's value
self.validate = _value_validators[validate] if isinstance(validate, str) else validate
#: if this header parameter is required
self.required = required
#: Define header parameters for JWS and JWE
HeaderRegistryDict = dict[str, HeaderParameter]
class KeyParameter:
"""Define the key parameter for JWK."""
def __init__(self, description: str, validate: str | Validate, private: bool | None = None, required: bool = False):
#: a short description of the key parameter
self.description: str = description
#: a function for validating the key parameter's value
self.validate = _value_validators[validate] if isinstance(validate, str) else validate
#: if this key parameter for private key only
self.private = private
#: if this key parameter is required
self.required = required
class KeyOperation:
def __init__(self, description: str, use: str, private: bool | None):
self.description = description
self.use = use
self.private = private
#: Define parameters for JWK
KeyParameterRegistryDict = dict[str, KeyParameter]
KeyOperationRegistryDict = dict[str, KeyOperation]
#: Basic JWS header registry
JWS_HEADER_REGISTRY: HeaderRegistryDict = {
"alg": HeaderParameter("Algorithm", is_str, True),
"jku": HeaderParameter("JWK Set URL", is_url),
"jwk": HeaderParameter("JSON Web Key", is_jwk),
"kid": HeaderParameter("Key ID", is_str),
"x5u": HeaderParameter("X.509 URL", is_url),
"x5c": HeaderParameter("X.509 Certificate Chain", is_list_str),
"x5t": HeaderParameter("X.509 Certificate SHA-1 Thumbprint", is_str),
"x5t#S256": HeaderParameter("X.509 Certificate SHA-256 Thumbprint", is_str),
"typ": HeaderParameter("Type", is_str),
"cty": HeaderParameter("Content Type", is_str),
"crit": HeaderParameter("Critical", is_list_str),
# Enable RFC7797 by default.
"b64": HeaderParameter("JWS Signing Input Formula", is_bool),
}
#: Basic JWE header registry
JWE_HEADER_REGISTRY = {
"enc": HeaderParameter("Encryption Algorithm", is_str, True),
"zip": HeaderParameter("Compression Algorithm", is_str),
**JWS_HEADER_REGISTRY,
}
#: Basic JWK parameter registry
JWK_PARAMETER_REGISTRY = {
"kty": KeyParameter("Key Type", is_str, required=True), # This member MUST be present in a JWK.
"use": KeyParameter("Public Key Use", in_choices(["sig", "enc"])),
"key_ops": KeyParameter(
"Key Operations",
in_choices(
[
"sign",
"verify",
"encrypt",
"decrypt",
"wrapKey",
"unwrapKey",
"deriveKey",
"deriveBits",
]
),
),
"alg": KeyParameter("Algorithm", is_str),
"kid": KeyParameter("Key ID", is_str),
"x5u": KeyParameter("X.509 URL", is_url),
"x5c": KeyParameter("X.509 Certificate Chain", is_list_str),
"x5t": KeyParameter("X.509 Certificate SHA-1 Thumbprint", is_str),
"x5t#S256": KeyParameter("X.509 Certificate SHA-256 Thumbprint", is_str),
}
#: Common JWK operations
#: https://www.rfc-editor.org/rfc/rfc7517#section-4.3
JWK_OPERATION_REGISTRY = {
"sign": KeyOperation("compute digital signature or MAC", "sig", True),
"verify": KeyOperation("verify digital signature or MAC", "sig", False),
"encrypt": KeyOperation("encrypt content", "enc", False),
"decrypt": KeyOperation("decrypt content and validate decryption, if applicable", "enc", True),
"wrapKey": KeyOperation("encrypt key", "enc", False),
"unwrapKey": KeyOperation("decrypt key and validate decryption, if applicable", "enc", True),
"deriveKey": KeyOperation("derive key", "enc", False),
"deriveBits": KeyOperation("derive bits not to be used as a key", "enc", None),
}
def check_supported_header(registry: HeaderRegistryDict, header: Header) -> None:
allowed_keys = set(registry.keys())
unsupported_keys = set(header.keys()) - allowed_keys
if unsupported_keys:
raise UnsupportedHeaderError(f"Unsupported {unsupported_keys} in header")
def check_registry_header(registry: HeaderRegistryDict, header: Header, check_required: bool = True) -> None:
for key, reg in registry.items():
if check_required and reg.required and key not in header:
raise MissingHeaderError(key)
if key in header:
try:
reg.validate(header[key])
except ValueError as error:
raise InvalidHeaderValueError(f"'{key}' in header {error}")
def check_crit_header(registry: HeaderRegistryDict, header: Header) -> None:
# check `crit` header
missing_crit_headers = []
unsupported_crit_headers = []
if "crit" in header:
for k in header["crit"]:
if k not in header:
missing_crit_headers.append(k)
elif k not in registry:
unsupported_crit_headers.append(k)
if missing_crit_headers:
raise MissingCritHeaderError(",".join(missing_crit_headers))
elif unsupported_crit_headers:
raise UnsupportedHeaderError(f"Unsupported {unsupported_crit_headers} in header")
def reject_unprotected_crit_header(unprotected: Header | None) -> None:
if unprotected and "crit" in unprotected:
raise UnsupportedHeaderError("'crit' header MUST be protected header")
+69
View File
@@ -0,0 +1,69 @@
from typing import Any
import base64
import struct
import binascii
import json
def to_bytes(x: Any, charset: str = "utf-8", errors: str = "strict") -> bytes:
if isinstance(x, bytes):
return x
if isinstance(x, str):
return x.encode(charset, errors)
if isinstance(x, (int, float)):
return str(x).encode(charset, errors)
return bytes(x)
def to_str(x: bytes | str, charset: str = "utf-8") -> str:
if isinstance(x, bytes):
return x.decode(charset)
return x
def urlsafe_b64decode(s: bytes) -> bytes:
if b"+" in s or b"/" in s or b"=" in s:
raise binascii.Error
pad = -len(s) % 4
if pad == 3:
raise binascii.Error
safe_ending = (b"AEIMQUYcgkosw048", b"AQgw")
if pad and s[-1] not in safe_ending[pad - 1]:
raise binascii.Error
s += b"=" * pad
return base64.b64decode(s, b"-_", validate=True)
def urlsafe_b64encode(s: bytes) -> bytes:
return base64.urlsafe_b64encode(s).rstrip(b"=")
def base64_to_int(s: str) -> int:
data = urlsafe_b64decode(to_bytes(s))
buf = struct.unpack("%sB" % len(data), data)
return int("".join(["%02x" % byte for byte in buf]), 16)
def int_to_base64(num: int, byte_count: int | None = None) -> str:
if num < 0:
raise ValueError("Must be a positive integer")
if byte_count is None:
byte_count = (num.bit_length() + 7) // 8
elif num.bit_length() > byte_count * 8:
raise ValueError("Number too large for byte count")
s = num.to_bytes(byte_count, "big", signed=False)
return urlsafe_b64encode(s).decode("utf-8", "strict")
def json_b64encode(data: dict[str, Any]) -> bytes:
text = json.dumps(data, ensure_ascii=True, separators=(",", ":"))
return urlsafe_b64encode(to_bytes(text, "ascii"))
def json_b64decode(text: bytes) -> Any:
return json.loads(urlsafe_b64decode(text))