easyssf-core 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- easyssf_core-0.7.0/.gitignore +22 -0
- easyssf_core-0.7.0/PKG-INFO +32 -0
- easyssf_core-0.7.0/README.md +11 -0
- easyssf_core-0.7.0/pyproject.toml +36 -0
- easyssf_core-0.7.0/src/easyssf/core/__init__.py +57 -0
- easyssf_core-0.7.0/src/easyssf/core/caep.py +369 -0
- easyssf_core-0.7.0/src/easyssf/core/claims.py +109 -0
- easyssf_core-0.7.0/src/easyssf/core/delivery.py +48 -0
- easyssf_core-0.7.0/src/easyssf/core/event_types.py +213 -0
- easyssf_core-0.7.0/src/easyssf/core/metadata.py +108 -0
- easyssf_core-0.7.0/src/easyssf/core/py.typed +0 -0
- easyssf_core-0.7.0/src/easyssf/core/risc.py +211 -0
- easyssf_core-0.7.0/src/easyssf/core/scim.py +304 -0
- easyssf_core-0.7.0/src/easyssf/core/signing.py +129 -0
- easyssf_core-0.7.0/src/easyssf/core/stream.py +139 -0
- easyssf_core-0.7.0/src/easyssf/core/subject.py +391 -0
- easyssf_core-0.7.0/src/easyssf/core/subjects.py +94 -0
- easyssf_core-0.7.0/src/easyssf/core/token.py +262 -0
- easyssf_core-0.7.0/tests/test_caep.py +186 -0
- easyssf_core-0.7.0/tests/test_event_types.py +85 -0
- easyssf_core-0.7.0/tests/test_risc.py +82 -0
- easyssf_core-0.7.0/tests/test_scim.py +139 -0
- easyssf_core-0.7.0/tests/test_set_builder.py +63 -0
- easyssf_core-0.7.0/tests/test_signing.py +70 -0
- easyssf_core-0.7.0/tests/test_stream_and_metadata.py +104 -0
- easyssf_core-0.7.0/tests/test_subject.py +243 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
coverage.xml
|
|
13
|
+
htmlcov/
|
|
14
|
+
|
|
15
|
+
# IDE
|
|
16
|
+
.idea/
|
|
17
|
+
.vscode/
|
|
18
|
+
|
|
19
|
+
# Local
|
|
20
|
+
scratch/
|
|
21
|
+
.DS_Store
|
|
22
|
+
conformance/certs/
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: easyssf-core
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: The vocabulary of the OpenID Shared Signals Framework: SETs, subjects, event types, the typed CAEP, RISC and SCIM events, stream configuration and transmitter metadata
|
|
5
|
+
Project-URL: Homepage, https://easyssf.org
|
|
6
|
+
Project-URL: Repository, https://github.com/easyssf/easyssf-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/easyssf/easyssf-python/blob/main/CHANGELOG.md
|
|
8
|
+
Author: the easyssf authors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: caep,openid,risc,security-events,set,ssf
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Provides-Extra: signing
|
|
19
|
+
Requires-Dist: joserfc<2,>=1.7; extra == 'signing'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# easyssf-core
|
|
23
|
+
|
|
24
|
+
The vocabulary of the [OpenID Shared Signals Framework](https://openid.net/specs/openid-sharedsignals-framework-1_0.html)
|
|
25
|
+
for Python: Security Event Tokens (`EventToken`, the `new_set` builder), subjects (RFC 9493, complex
|
|
26
|
+
subjects, the `scim` format), delivery methods, stream configuration, transmitter metadata, the
|
|
27
|
+
event types of SSF, CAEP, RISC and SCIM Events (RFC 9967) with their aliases, and the typed CAEP,
|
|
28
|
+
RISC and SCIM events. No dependencies; `easyssf-core[signing]` adds `easyssf.core.signing`, which
|
|
29
|
+
signs SETs with joserfc.
|
|
30
|
+
|
|
31
|
+
Part of [easyssf-python](https://github.com/easyssf/easyssf-python); the receiver is
|
|
32
|
+
`easyssf-receiver`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# easyssf-core
|
|
2
|
+
|
|
3
|
+
The vocabulary of the [OpenID Shared Signals Framework](https://openid.net/specs/openid-sharedsignals-framework-1_0.html)
|
|
4
|
+
for Python: Security Event Tokens (`EventToken`, the `new_set` builder), subjects (RFC 9493, complex
|
|
5
|
+
subjects, the `scim` format), delivery methods, stream configuration, transmitter metadata, the
|
|
6
|
+
event types of SSF, CAEP, RISC and SCIM Events (RFC 9967) with their aliases, and the typed CAEP,
|
|
7
|
+
RISC and SCIM events. No dependencies; `easyssf-core[signing]` adds `easyssf.core.signing`, which
|
|
8
|
+
signs SETs with joserfc.
|
|
9
|
+
|
|
10
|
+
Part of [easyssf-python](https://github.com/easyssf/easyssf-python); the receiver is
|
|
11
|
+
`easyssf-receiver`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "easyssf-core"
|
|
3
|
+
version = "0.7.0"
|
|
4
|
+
description = "The vocabulary of the OpenID Shared Signals Framework: SETs, subjects, event types, the typed CAEP, RISC and SCIM events, stream configuration and transmitter metadata"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "the easyssf authors" }]
|
|
9
|
+
keywords = ["ssf", "caep", "risc", "openid", "security-events", "set"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
+
"Topic :: Security",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = []
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
signing = ["joserfc>=1.7,<2"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://easyssf.org"
|
|
25
|
+
Repository = "https://github.com/easyssf/easyssf-python"
|
|
26
|
+
Changelog = "https://github.com/easyssf/easyssf-python/blob/main/CHANGELOG.md"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling>=1.27"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/easyssf"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = ["src", "README.md", "tests"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The vocabulary of the OpenID Shared Signals Framework: SETs, subjects, delivery methods, stream
|
|
2
|
+
configuration, transmitter metadata, event types with aliases, and the typed CAEP, RISC and SCIM
|
|
3
|
+
events. The port of ``easyssf-core`` of the Java library."""
|
|
4
|
+
|
|
5
|
+
from . import caep, event_types, risc, scim, subjects
|
|
6
|
+
from .claims import (
|
|
7
|
+
Claims,
|
|
8
|
+
as_int,
|
|
9
|
+
audiences,
|
|
10
|
+
event_timestamp,
|
|
11
|
+
object_member,
|
|
12
|
+
parse_claims,
|
|
13
|
+
string_member,
|
|
14
|
+
strings,
|
|
15
|
+
)
|
|
16
|
+
from .delivery import POLL_URI, PUSH_URI, DeliveryMethod
|
|
17
|
+
from .event_types import EventTypes
|
|
18
|
+
from .metadata import SPEC_VERSION_1_0, TransmitterMetadata
|
|
19
|
+
from .stream import StreamConfiguration, StreamStatus, StreamStatusValue
|
|
20
|
+
from .subject import ALL_SESSIONS, MEMBERS, Format, Member, Subject, SubjectIdentifier
|
|
21
|
+
from .token import EventToken, SetBuilder, SubjectCompatibilityMode, new_jti, new_set
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ALL_SESSIONS",
|
|
25
|
+
"MEMBERS",
|
|
26
|
+
"POLL_URI",
|
|
27
|
+
"PUSH_URI",
|
|
28
|
+
"SPEC_VERSION_1_0",
|
|
29
|
+
"Claims",
|
|
30
|
+
"DeliveryMethod",
|
|
31
|
+
"EventToken",
|
|
32
|
+
"EventTypes",
|
|
33
|
+
"Format",
|
|
34
|
+
"Member",
|
|
35
|
+
"SetBuilder",
|
|
36
|
+
"StreamConfiguration",
|
|
37
|
+
"StreamStatus",
|
|
38
|
+
"StreamStatusValue",
|
|
39
|
+
"Subject",
|
|
40
|
+
"SubjectCompatibilityMode",
|
|
41
|
+
"SubjectIdentifier",
|
|
42
|
+
"TransmitterMetadata",
|
|
43
|
+
"as_int",
|
|
44
|
+
"audiences",
|
|
45
|
+
"caep",
|
|
46
|
+
"event_timestamp",
|
|
47
|
+
"event_types",
|
|
48
|
+
"new_jti",
|
|
49
|
+
"new_set",
|
|
50
|
+
"object_member",
|
|
51
|
+
"parse_claims",
|
|
52
|
+
"risc",
|
|
53
|
+
"scim",
|
|
54
|
+
"string_member",
|
|
55
|
+
"strings",
|
|
56
|
+
"subjects",
|
|
57
|
+
]
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""The CAEP 1.0 events: the kinds of section 3 and typed access to their claims, the common ones
|
|
2
|
+
of section 2 (``event_timestamp``, ``initiating_entity``, ``reason_admin``, ``reason_user``) and
|
|
3
|
+
the event-specific ones (``credential_type``, ``change_type``, ``current_level``, ...). The port
|
|
4
|
+
of ``ssf/caep`` of the Go port and of ``org.easyssf.core.caep``."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Final
|
|
13
|
+
|
|
14
|
+
from . import event_types
|
|
15
|
+
from .claims import Claims, event_timestamp, object_member, string_member, strings
|
|
16
|
+
from .event_types import EventTypes
|
|
17
|
+
from .token import EventToken
|
|
18
|
+
|
|
19
|
+
__all__ = ["KNOWN", "Event", "Kind", "PayloadBuilder", "localized", "new_payload"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Kind(Enum):
|
|
23
|
+
"""One of the events of CAEP 1.0, section 3, or :attr:`OTHER` for an event type in the CAEP
|
|
24
|
+
namespace this version does not know. New kinds are added as CAEP defines events, so do not
|
|
25
|
+
match over them exhaustively; implement the receiver's CAEP handler instead."""
|
|
26
|
+
|
|
27
|
+
OTHER = "Other"
|
|
28
|
+
"""An event type in the CAEP namespace this version does not know, for example one of a later
|
|
29
|
+
CAEP version. The common claims of CAEP section 2 are still available."""
|
|
30
|
+
SESSION_REVOKED = "SessionRevoked"
|
|
31
|
+
"""The session was revoked (section 3.1)."""
|
|
32
|
+
TOKEN_CLAIMS_CHANGE = "TokenClaimsChange" # noqa: S105 # an event type name, not a credential
|
|
33
|
+
"""Claims of the token the subject identifies changed (section 3.2)."""
|
|
34
|
+
CREDENTIAL_CHANGE = "CredentialChange"
|
|
35
|
+
"""A credential was created, revoked, updated or deleted (section 3.3)."""
|
|
36
|
+
ASSURANCE_LEVEL_CHANGE = "AssuranceLevelChange"
|
|
37
|
+
"""The assurance level of the subject changed (section 3.4)."""
|
|
38
|
+
DEVICE_COMPLIANCE_CHANGE = "DeviceComplianceChange"
|
|
39
|
+
"""The compliance status of the device changed (section 3.5)."""
|
|
40
|
+
SESSION_ESTABLISHED = "SessionEstablished"
|
|
41
|
+
"""A session was established (section 3.6)."""
|
|
42
|
+
SESSION_PRESENTED = "SessionPresented"
|
|
43
|
+
"""A session was presented to the transmitter (section 3.7)."""
|
|
44
|
+
RISK_LEVEL_CHANGE = "RiskLevelChange"
|
|
45
|
+
"""The risk level of the subject changed (section 3.8)."""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def event_type(self) -> str:
|
|
49
|
+
"""The event type URI of the kind, ``""`` for :attr:`OTHER`."""
|
|
50
|
+
for kind, uri in KNOWN:
|
|
51
|
+
if kind is self:
|
|
52
|
+
return uri
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def of(cls, event_type: str) -> Kind | None:
|
|
57
|
+
"""The kind of CAEP event of an event type URI or alias: :attr:`OTHER` for an unknown
|
|
58
|
+
event type in the CAEP namespace, ``None`` if the event type is not a CAEP event."""
|
|
59
|
+
if not event_types.is_caep_event(event_type):
|
|
60
|
+
return None
|
|
61
|
+
uri = event_types.resolve(event_type)
|
|
62
|
+
for kind, known in KNOWN:
|
|
63
|
+
if known == uri:
|
|
64
|
+
return kind
|
|
65
|
+
return cls.OTHER
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
"""The name of the kind: ``SessionRevoked``, ..., ``Other``."""
|
|
69
|
+
return self.value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
KNOWN: Final[tuple[tuple[Kind, str], ...]] = (
|
|
73
|
+
(Kind.SESSION_REVOKED, EventTypes.CAEP_SESSION_REVOKED),
|
|
74
|
+
(Kind.TOKEN_CLAIMS_CHANGE, EventTypes.CAEP_TOKEN_CLAIMS_CHANGE),
|
|
75
|
+
(Kind.CREDENTIAL_CHANGE, EventTypes.CAEP_CREDENTIAL_CHANGE),
|
|
76
|
+
(Kind.ASSURANCE_LEVEL_CHANGE, EventTypes.CAEP_ASSURANCE_LEVEL_CHANGE),
|
|
77
|
+
(Kind.DEVICE_COMPLIANCE_CHANGE, EventTypes.CAEP_DEVICE_COMPLIANCE_CHANGE),
|
|
78
|
+
(Kind.SESSION_ESTABLISHED, EventTypes.CAEP_SESSION_ESTABLISHED),
|
|
79
|
+
(Kind.SESSION_PRESENTED, EventTypes.CAEP_SESSION_PRESENTED),
|
|
80
|
+
(Kind.RISK_LEVEL_CHANGE, EventTypes.CAEP_RISK_LEVEL_CHANGE),
|
|
81
|
+
)
|
|
82
|
+
"""The known kinds and their event type URIs, in the order of the specification."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Event:
|
|
86
|
+
"""A CAEP 1.0 event of a SET, with typed access to the claims every CAEP event may carry
|
|
87
|
+
(section 2) and to the event-specific claims of section 3. An accessor returns ``""`` (or an
|
|
88
|
+
empty collection, or ``None``) when the claim is absent, whether because the transmitter left
|
|
89
|
+
it out or because the :attr:`kind` does not define it. :attr:`payload` is the event as
|
|
90
|
+
received, for anything else.
|
|
91
|
+
|
|
92
|
+
The subject of the event is the ``sub_id`` of the SET, the subject of the event context in a
|
|
93
|
+
handler: CAEP does not prescribe a subject format.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
__slots__ = ("_event_type", "_kind", "_payload")
|
|
97
|
+
|
|
98
|
+
def __init__(self, event_type: str, kind: Kind, payload: Claims) -> None:
|
|
99
|
+
self._event_type = event_type
|
|
100
|
+
self._kind = kind
|
|
101
|
+
self._payload = payload
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def of(cls, event_type: str, payload: Claims | None = None) -> Event | None:
|
|
105
|
+
"""The CAEP event with the given type (URI or alias) and payload; ``None`` if the type is
|
|
106
|
+
not a CAEP event. Lenient: no payload is an empty one."""
|
|
107
|
+
kind = Kind.of(event_type)
|
|
108
|
+
if kind is None:
|
|
109
|
+
return None
|
|
110
|
+
return cls(event_types.resolve(event_type), kind, {} if payload is None else payload)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def in_token(cls, token: EventToken) -> list[Event]:
|
|
114
|
+
"""The CAEP events of a SET, in the order of their event type URIs (sorted, as the Go port
|
|
115
|
+
does, so that the order is the same whatever the transmitter sent). CAEP allows one event
|
|
116
|
+
per SET; a SET with several is read like one of each. A payload that is not a JSON object
|
|
117
|
+
is read as an empty one."""
|
|
118
|
+
events = []
|
|
119
|
+
for event_type in sorted(token.events):
|
|
120
|
+
event = cls.of(event_type, object_member(token.events, event_type))
|
|
121
|
+
if event is not None:
|
|
122
|
+
events.append(event)
|
|
123
|
+
return events
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def event_type(self) -> str:
|
|
127
|
+
"""The event type URI."""
|
|
128
|
+
return self._event_type
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def kind(self) -> Kind:
|
|
132
|
+
"""The kind of event."""
|
|
133
|
+
return self._kind
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def payload(self) -> Claims:
|
|
137
|
+
"""The event payload as received."""
|
|
138
|
+
return self._payload
|
|
139
|
+
|
|
140
|
+
# CAEP section 2: claims every event may carry
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def event_timestamp(self) -> datetime | None:
|
|
144
|
+
"""The ``event_timestamp``, the time the event occurred. CAEP defines seconds since the
|
|
145
|
+
epoch; a value that is clearly milliseconds is accepted too. ``None`` if absent."""
|
|
146
|
+
return event_timestamp(self._payload.get("event_timestamp"))
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def initiating_entity(self) -> str:
|
|
150
|
+
"""The ``initiating_entity``, who invoked the event: ``admin``, ``user``, ``policy`` or
|
|
151
|
+
``system``."""
|
|
152
|
+
return self._string("initiating_entity")
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def reason_admin(self) -> dict[str, str]:
|
|
156
|
+
"""The ``reason_admin``, the reason for administrators, by BCP 47 language tag (``en``,
|
|
157
|
+
``de-CH``, ...); empty if absent. A transmitter that sends a plain string instead of the
|
|
158
|
+
localizable object of CAEP gets the key ``""``."""
|
|
159
|
+
return localized(self._payload.get("reason_admin"))
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def reason_user(self) -> dict[str, str]:
|
|
163
|
+
"""The ``reason_user``, the reason for display to the end user, by BCP 47 language tag;
|
|
164
|
+
empty if absent, a plain string under the key ``""``."""
|
|
165
|
+
return localized(self._payload.get("reason_user"))
|
|
166
|
+
|
|
167
|
+
# CAEP section 3: event-specific claims
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def claims(self) -> Claims | None:
|
|
171
|
+
"""The ``claims`` of a :attr:`Kind.TOKEN_CLAIMS_CHANGE`, each with its new value; ``None``
|
|
172
|
+
if absent."""
|
|
173
|
+
return object_member(self._payload, "claims")
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def credential_type(self) -> str:
|
|
177
|
+
"""The ``credential_type`` of a :attr:`Kind.CREDENTIAL_CHANGE`: ``password``, ``pin``,
|
|
178
|
+
``x509``, ``fido2-platform``, ``fido2-roaming``, ``fido-u2f``, ``verifiable-credential``,
|
|
179
|
+
``phone-voice``, ``phone-sms``, ``app`` or a value agreed with the transmitter."""
|
|
180
|
+
return self._string("credential_type")
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def change_type(self) -> str:
|
|
184
|
+
"""The ``change_type`` of a :attr:`Kind.CREDENTIAL_CHANGE`: ``create``, ``revoke``,
|
|
185
|
+
``update`` or ``delete``."""
|
|
186
|
+
return self._string("change_type")
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def friendly_name(self) -> str:
|
|
190
|
+
"""The ``friendly_name`` of the credential of a :attr:`Kind.CREDENTIAL_CHANGE`."""
|
|
191
|
+
return self._string("friendly_name")
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def x509_issuer(self) -> str:
|
|
195
|
+
"""The ``x509_issuer`` of an X.509 credential."""
|
|
196
|
+
return self._string("x509_issuer")
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def x509_serial(self) -> str:
|
|
200
|
+
"""The ``x509_serial`` of an X.509 credential."""
|
|
201
|
+
return self._string("x509_serial")
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def fido2_aaguid(self) -> str:
|
|
205
|
+
"""The ``fido2_aaguid``, the FIDO2 Authenticator Attestation GUID of the credential."""
|
|
206
|
+
return self._string("fido2_aaguid")
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def namespace(self) -> str:
|
|
210
|
+
"""The ``namespace`` of the levels of an :attr:`Kind.ASSURANCE_LEVEL_CHANGE`: ``RFC8176``,
|
|
211
|
+
``RFC6711``, ``ISO-IEC-29115``, ``NIST-IAL``, ``NIST-AAL``, ``NIST-FAL`` or a value agreed
|
|
212
|
+
with the transmitter."""
|
|
213
|
+
return self._string("namespace")
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def current_level(self) -> str:
|
|
217
|
+
"""The ``current_level``: the assurance level of an :attr:`Kind.ASSURANCE_LEVEL_CHANGE` in
|
|
218
|
+
its :attr:`namespace`, or the risk level of a :attr:`Kind.RISK_LEVEL_CHANGE` (``LOW``,
|
|
219
|
+
``MEDIUM``, ``HIGH``)."""
|
|
220
|
+
return self._string("current_level")
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def previous_level(self) -> str:
|
|
224
|
+
"""The ``previous_level`` of an assurance or risk level change, ``""`` if the transmitter
|
|
225
|
+
does not know the previous level."""
|
|
226
|
+
return self._string("previous_level")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def change_direction(self) -> str:
|
|
230
|
+
"""The ``change_direction`` of an :attr:`Kind.ASSURANCE_LEVEL_CHANGE`: ``increase`` or
|
|
231
|
+
``decrease``."""
|
|
232
|
+
return self._string("change_direction")
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def current_status(self) -> str:
|
|
236
|
+
"""The ``current_status`` of a :attr:`Kind.DEVICE_COMPLIANCE_CHANGE`: ``compliant`` or
|
|
237
|
+
``not-compliant``."""
|
|
238
|
+
return self._string("current_status")
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def previous_status(self) -> str:
|
|
242
|
+
"""The ``previous_status`` of a :attr:`Kind.DEVICE_COMPLIANCE_CHANGE`."""
|
|
243
|
+
return self._string("previous_status")
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def fp_ua(self) -> str:
|
|
247
|
+
"""The ``fp_ua``, the fingerprint of the user agent of a :attr:`Kind.SESSION_ESTABLISHED`
|
|
248
|
+
or :attr:`Kind.SESSION_PRESENTED`."""
|
|
249
|
+
return self._string("fp_ua")
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def acr(self) -> str:
|
|
253
|
+
"""The ``acr``, the authentication context class reference of a
|
|
254
|
+
:attr:`Kind.SESSION_ESTABLISHED`, as in an ID Token."""
|
|
255
|
+
return self._string("acr")
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def amr(self) -> list[str]:
|
|
259
|
+
"""The ``amr``, the authentication methods references of a
|
|
260
|
+
:attr:`Kind.SESSION_ESTABLISHED`, as in an ID Token; empty if absent."""
|
|
261
|
+
return strings(self._payload.get("amr"))
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def ext_id(self) -> str:
|
|
265
|
+
"""The ``ext_id``, the external session identifier of a :attr:`Kind.SESSION_ESTABLISHED`
|
|
266
|
+
or :attr:`Kind.SESSION_PRESENTED` that correlates it with a broader session, for example a
|
|
267
|
+
SAML one."""
|
|
268
|
+
return self._string("ext_id")
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def principal(self) -> str:
|
|
272
|
+
"""The ``principal`` of a :attr:`Kind.RISK_LEVEL_CHANGE`, the entity whose risk changed:
|
|
273
|
+
``USER``, ``DEVICE``, ``SESSION``, ``TENANT``, ``ORG_UNIT``, ``GROUP`` or another entity
|
|
274
|
+
of SSF."""
|
|
275
|
+
return self._string("principal")
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def risk_reason(self) -> str:
|
|
279
|
+
"""The ``risk_reason`` of a :attr:`Kind.RISK_LEVEL_CHANGE`."""
|
|
280
|
+
return self._string("risk_reason")
|
|
281
|
+
|
|
282
|
+
def _string(self, name: str) -> str:
|
|
283
|
+
return string_member(self._payload, name)
|
|
284
|
+
|
|
285
|
+
def __repr__(self) -> str:
|
|
286
|
+
return f"Event({self._kind.value}, {self._event_type!r})"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _json_text(value: object) -> str:
|
|
290
|
+
"""A JSON value as its compact text, with the member names sorted as Go's ``json.Marshal`` does,
|
|
291
|
+
so that the five ports render the same text."""
|
|
292
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def localized(value: object) -> dict[str, str]:
|
|
296
|
+
"""Reads a localizable claim as CAEP defines it (section 2): an object of texts by BCP 47
|
|
297
|
+
language tag. A plain string is kept under the key ``""``, other values as their JSON; ``None``
|
|
298
|
+
gives an empty dict."""
|
|
299
|
+
if value is None:
|
|
300
|
+
return {}
|
|
301
|
+
if isinstance(value, str):
|
|
302
|
+
return {"": value}
|
|
303
|
+
if isinstance(value, dict):
|
|
304
|
+
reasons: dict[str, str] = {}
|
|
305
|
+
for tag, reason in value.items():
|
|
306
|
+
if reason is None:
|
|
307
|
+
continue
|
|
308
|
+
reasons[tag] = reason if isinstance(reason, str) else _json_text(reason)
|
|
309
|
+
return reasons
|
|
310
|
+
return {"": _json_text(value)}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class PayloadBuilder:
|
|
314
|
+
"""Assembles the payload of a CAEP event for a transmitter or a test: the common claims of
|
|
315
|
+
CAEP 1.0, section 2, and any event-specific ones.
|
|
316
|
+
|
|
317
|
+
Example:
|
|
318
|
+
>>> payload = (new_payload().event_timestamp(datetime.now(tz=UTC)).initiating_entity("admin")
|
|
319
|
+
... .reason_admin("en", "Policy violation").build())
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def __init__(self) -> None:
|
|
323
|
+
self._claims: Claims = {}
|
|
324
|
+
|
|
325
|
+
def event_timestamp(self, at: datetime) -> PayloadBuilder:
|
|
326
|
+
"""Sets ``event_timestamp``, in seconds since the epoch."""
|
|
327
|
+
self._claims["event_timestamp"] = int(at.timestamp())
|
|
328
|
+
return self
|
|
329
|
+
|
|
330
|
+
def initiating_entity(self, entity: str) -> PayloadBuilder:
|
|
331
|
+
"""Sets ``initiating_entity``: ``admin``, ``user``, ``policy`` or ``system``."""
|
|
332
|
+
self._claims["initiating_entity"] = entity
|
|
333
|
+
return self
|
|
334
|
+
|
|
335
|
+
def reason_admin(self, language: str, reason: str) -> PayloadBuilder:
|
|
336
|
+
"""Adds a ``reason_admin`` in the language, a BCP 47 tag such as ``en``."""
|
|
337
|
+
return self._localized("reason_admin", language, reason)
|
|
338
|
+
|
|
339
|
+
def reason_user(self, language: str, reason: str) -> PayloadBuilder:
|
|
340
|
+
"""Adds a ``reason_user`` in the language."""
|
|
341
|
+
return self._localized("reason_user", language, reason)
|
|
342
|
+
|
|
343
|
+
def _localized(self, name: str, language: str, text: str) -> PayloadBuilder:
|
|
344
|
+
# The reasons are kept as the dict of a decoded payload, so that localized() reads them
|
|
345
|
+
# back whether the payload was built or received.
|
|
346
|
+
reasons = self._claims.get(name)
|
|
347
|
+
if not isinstance(reasons, dict):
|
|
348
|
+
reasons = {}
|
|
349
|
+
self._claims[name] = reasons
|
|
350
|
+
reasons[language] = text
|
|
351
|
+
return self
|
|
352
|
+
|
|
353
|
+
def claim(self, name: str, value: Any) -> PayloadBuilder:
|
|
354
|
+
"""Sets an event-specific claim such as ``credential_type`` or ``current_level``; ``None``
|
|
355
|
+
removes it."""
|
|
356
|
+
if value is None:
|
|
357
|
+
self._claims.pop(name, None)
|
|
358
|
+
else:
|
|
359
|
+
self._claims[name] = value
|
|
360
|
+
return self
|
|
361
|
+
|
|
362
|
+
def build(self) -> Claims:
|
|
363
|
+
"""The payload as claims, a copy, to pass to :meth:`easyssf.core.SetBuilder.event`."""
|
|
364
|
+
return copy.deepcopy(self._claims)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def new_payload() -> PayloadBuilder:
|
|
368
|
+
"""Starts an empty payload."""
|
|
369
|
+
return PayloadBuilder()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""JSON claims as decoded by ``json.loads`` and the lenient accessors that read them.
|
|
2
|
+
|
|
3
|
+
The claims of a SET, an event payload, a subject identifier and a stream configuration are all
|
|
4
|
+
plain JSON objects. Nothing here raises for input that does not fit: an accessor returns ``""``,
|
|
5
|
+
``None`` or an empty list, and the verifier applies the rules that produce error codes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import Any, TypeAlias
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Claims",
|
|
17
|
+
"as_int",
|
|
18
|
+
"audiences",
|
|
19
|
+
"event_timestamp",
|
|
20
|
+
"is_object",
|
|
21
|
+
"object_member",
|
|
22
|
+
"parse_claims",
|
|
23
|
+
"string_member",
|
|
24
|
+
"strings",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
Claims: TypeAlias = dict[str, Any]
|
|
28
|
+
"""A JSON object as decoded by ``json.loads``: the claims of a SET, an event payload, a subject
|
|
29
|
+
identifier. Numbers are ``int`` or ``float``, nested objects are ``Claims``-shaped dicts."""
|
|
30
|
+
|
|
31
|
+
_MILLISECONDS_THRESHOLD = 100_000_000_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def string_member(obj: Claims | None, name: str) -> str:
|
|
35
|
+
"""The member with the given name if it is a non-blank string, ``""`` otherwise."""
|
|
36
|
+
if not obj:
|
|
37
|
+
return ""
|
|
38
|
+
value = obj.get(name)
|
|
39
|
+
if isinstance(value, str) and value.strip():
|
|
40
|
+
return value
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_object(value: object) -> bool:
|
|
45
|
+
"""Whether the value is a JSON object (a dict)."""
|
|
46
|
+
return isinstance(value, dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def object_member(obj: Claims | None, name: str) -> Claims | None:
|
|
50
|
+
"""The member with the given name if it is a JSON object, ``None`` otherwise."""
|
|
51
|
+
if not obj:
|
|
52
|
+
return None
|
|
53
|
+
value = obj.get(name)
|
|
54
|
+
return value if isinstance(value, dict) else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def as_int(value: object) -> int | None:
|
|
58
|
+
"""A JSON number as an integer, ``None`` if the value is not a finite number.
|
|
59
|
+
|
|
60
|
+
``bool`` is a number to Python but not to JSON, so ``True`` is not ``1`` here.
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(value, bool):
|
|
63
|
+
return None
|
|
64
|
+
if isinstance(value, int):
|
|
65
|
+
return value
|
|
66
|
+
if isinstance(value, float):
|
|
67
|
+
if math.isnan(value) or math.isinf(value):
|
|
68
|
+
return None
|
|
69
|
+
return int(value)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def strings(value: object) -> list[str]:
|
|
74
|
+
"""A JSON array of strings; other values give an empty list, non-string elements are skipped."""
|
|
75
|
+
if not isinstance(value, list):
|
|
76
|
+
return []
|
|
77
|
+
return [element for element in value if isinstance(element, str)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def audiences(aud: object) -> list[str]:
|
|
81
|
+
"""An ``aud`` claim as a list: a string as a list of one, an array of its strings, empty otherwise."""
|
|
82
|
+
if isinstance(aud, str):
|
|
83
|
+
return [aud]
|
|
84
|
+
if isinstance(aud, list):
|
|
85
|
+
return strings(aud)
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def event_timestamp(value: object) -> datetime | None:
|
|
90
|
+
"""An ``event_timestamp`` claim of an event payload as a point in time.
|
|
91
|
+
|
|
92
|
+
CAEP 1.0 (section 2) and RISC 1.0 (section 2.7) define it as a JSON number of seconds since
|
|
93
|
+
the epoch; a value that is clearly milliseconds is read as such, as some transmitters send
|
|
94
|
+
milliseconds. ``None`` if the value is not a number.
|
|
95
|
+
"""
|
|
96
|
+
number = as_int(value)
|
|
97
|
+
if number is None:
|
|
98
|
+
return None
|
|
99
|
+
if number > _MILLISECONDS_THRESHOLD:
|
|
100
|
+
number //= 1000
|
|
101
|
+
return datetime.fromtimestamp(number, tz=UTC)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_claims(data: bytes | str) -> Claims:
|
|
105
|
+
"""Decodes a JSON object; raises ``ValueError`` if the data is not a JSON object."""
|
|
106
|
+
decoded = json.loads(data)
|
|
107
|
+
if not isinstance(decoded, dict):
|
|
108
|
+
raise ValueError("expected a JSON object")
|
|
109
|
+
return decoded
|