auths-python 0.1.0__cp38-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auths/pairing.py ADDED
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+ from auths._native import (
8
+ PyPairingHandle,
9
+ complete_pairing_ffi as _complete_pairing,
10
+ create_pairing_session_ffi as _create_session,
11
+ join_pairing_session_ffi as _join_session,
12
+ )
13
+ from auths._client import _map_error
14
+ from auths._errors import PairingError
15
+
16
+
17
+ @dataclass
18
+ class PairingResponse:
19
+ """The response from a device that joined a pairing session."""
20
+
21
+ device_did: str
22
+ device_name: Optional[str]
23
+ device_public_key_hex: str
24
+ capabilities: list[str]
25
+
26
+
27
+ @dataclass
28
+ class PairingResult:
29
+ """The result of completing a pairing flow."""
30
+
31
+ device_did: str
32
+ device_name: Optional[str]
33
+ attestation_rid: Optional[str]
34
+
35
+
36
+ class PairingSession:
37
+ """A running pairing session with an active LAN server."""
38
+
39
+ def __init__(
40
+ self,
41
+ session_id: str,
42
+ short_code: str,
43
+ endpoint: str,
44
+ token: str,
45
+ controller_did: str,
46
+ handle: PyPairingHandle,
47
+ ):
48
+ self.session_id = session_id
49
+ self.short_code = short_code
50
+ self.endpoint = endpoint
51
+ self.token = token
52
+ self.controller_did = controller_did
53
+ self._handle = handle
54
+
55
+ def wait_for_response(self, timeout_secs: int = 300) -> PairingResponse:
56
+ """Block until a device submits a pairing response.
57
+
58
+ Args:
59
+ timeout_secs: Maximum seconds to wait.
60
+
61
+ Usage:
62
+ response = session.wait_for_response(timeout_secs=30)
63
+ """
64
+ try:
65
+ device_did, device_name, pk_hex, caps_json = self._handle.wait_for_response(
66
+ timeout_secs,
67
+ )
68
+ caps = json.loads(caps_json) if caps_json else []
69
+ return PairingResponse(
70
+ device_did=device_did,
71
+ device_name=device_name,
72
+ device_public_key_hex=pk_hex,
73
+ capabilities=caps,
74
+ )
75
+ except (ValueError, RuntimeError) as exc:
76
+ raise _map_error(exc, default_cls=PairingError) from exc
77
+
78
+ def stop(self) -> None:
79
+ """Stop the pairing server and release the port."""
80
+ self._handle.stop()
81
+
82
+ def __enter__(self):
83
+ return self
84
+
85
+ def __exit__(self, exc_type, exc_val, exc_tb):
86
+ self.stop()
87
+ return False
88
+
89
+ def __del__(self):
90
+ try:
91
+ self.stop()
92
+ except Exception:
93
+ pass
94
+
95
+ def __repr__(self):
96
+ return (
97
+ f"PairingSession(code={self.short_code!r}, "
98
+ f"endpoint={self.endpoint!r})"
99
+ )
100
+
101
+
102
+ class PairingService:
103
+ """Resource service for programmatic device pairing."""
104
+
105
+ def __init__(self, client):
106
+ self._client = client
107
+
108
+ def create_session(
109
+ self,
110
+ capabilities: list[str] | None = None,
111
+ timeout_secs: int = 300,
112
+ bind_address: str = "0.0.0.0",
113
+ enable_mdns: bool = True,
114
+ repo_path: str | None = None,
115
+ passphrase: str | None = None,
116
+ ) -> PairingSession:
117
+ """Start a LAN pairing server and return a session handle.
118
+
119
+ Args:
120
+ capabilities: Capabilities to grant the paired device.
121
+ timeout_secs: Session lifetime in seconds.
122
+ bind_address: IP address to bind the HTTP server to.
123
+ enable_mdns: Whether to advertise via mDNS.
124
+
125
+ Usage:
126
+ session = client.pairing.create_session(
127
+ bind_address="127.0.0.1", enable_mdns=False
128
+ )
129
+ """
130
+ rp = repo_path or self._client.repo_path
131
+ pp = passphrase or self._client._passphrase
132
+ caps_json = json.dumps(capabilities) if capabilities else None
133
+ try:
134
+ sid, code, ep, tok, did, handle = _create_session(
135
+ rp, caps_json, timeout_secs, bind_address, enable_mdns, pp,
136
+ )
137
+ return PairingSession(
138
+ session_id=sid,
139
+ short_code=code,
140
+ endpoint=ep,
141
+ token=tok,
142
+ controller_did=did,
143
+ handle=handle,
144
+ )
145
+ except (ValueError, RuntimeError) as exc:
146
+ raise _map_error(exc, default_cls=PairingError) from exc
147
+
148
+ def join(
149
+ self,
150
+ short_code: str,
151
+ endpoint: str,
152
+ token: str,
153
+ device_name: str | None = None,
154
+ repo_path: str | None = None,
155
+ passphrase: str | None = None,
156
+ ) -> PairingResult:
157
+ """Join an existing pairing session as a device.
158
+
159
+ Args:
160
+ short_code: The 6-character pairing code.
161
+ endpoint: The controller's HTTP endpoint URL.
162
+ token: The transport token from the controller's session.
163
+ device_name: Human-readable name for this device.
164
+
165
+ Usage:
166
+ result = device.pairing.join(
167
+ session.short_code,
168
+ endpoint=session.endpoint,
169
+ token=session.token,
170
+ )
171
+ """
172
+ rp = repo_path or self._client.repo_path
173
+ pp = passphrase or self._client._passphrase
174
+ try:
175
+ device_did, name = _join_session(
176
+ short_code, endpoint, token, rp, device_name, pp,
177
+ )
178
+ return PairingResult(
179
+ device_did=device_did,
180
+ device_name=name,
181
+ attestation_rid=None,
182
+ )
183
+ except (ValueError, RuntimeError) as exc:
184
+ raise _map_error(exc, default_cls=PairingError) from exc
185
+
186
+ def complete(
187
+ self,
188
+ session: PairingSession,
189
+ response: PairingResponse,
190
+ repo_path: str | None = None,
191
+ passphrase: str | None = None,
192
+ ) -> PairingResult:
193
+ """Complete pairing by creating the device attestation.
194
+
195
+ Args:
196
+ session: The active PairingSession.
197
+ response: The PairingResponse from wait_for_response().
198
+
199
+ Usage:
200
+ result = controller.pairing.complete(session, response)
201
+ """
202
+ rp = repo_path or self._client.repo_path
203
+ pp = passphrase or self._client._passphrase
204
+ caps_json = json.dumps(response.capabilities) if response.capabilities else None
205
+ try:
206
+ device_did, name, rid = _complete_pairing(
207
+ response.device_did, response.device_public_key_hex,
208
+ rp, caps_json, pp,
209
+ )
210
+ return PairingResult(
211
+ device_did=device_did,
212
+ device_name=name,
213
+ attestation_rid=rid,
214
+ )
215
+ except (ValueError, RuntimeError) as exc:
216
+ raise _map_error(exc, default_cls=PairingError) from exc
auths/policy.py ADDED
@@ -0,0 +1,382 @@
1
+ """Policy engine — compile, evaluate, enforce authorization rules.
2
+
3
+ EvalContext DID Format Requirements
4
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5
+
6
+ Both ``EvalContext.issuer`` and ``EvalContext.subject`` must be valid DID strings:
7
+
8
+ - **Identity DIDs**: ``did:keri:E...`` — for organizations and individuals.
9
+ - **Device DIDs**: ``did:key:z...`` — for device keys and signing keys.
10
+
11
+ The Rust policy engine parses both fields into ``CanonicalDid`` values. Both
12
+ ``did:keri:`` and ``did:key:`` formats are accepted. Invalid DID strings will
13
+ cause evaluation to fail with a parse error.
14
+
15
+ Example::
16
+
17
+ ctx = EvalContext(
18
+ issuer="did:keri:EOrg123", # organization identity
19
+ subject="did:key:z6MkDevice", # device key
20
+ capabilities=["sign_commit"],
21
+ )
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import enum
27
+ import json
28
+ from dataclasses import dataclass
29
+ from typing import Optional
30
+
31
+ from auths._native import (
32
+ PyCompiledPolicy,
33
+ PyDecision,
34
+ PyEvalContext,
35
+ compile_policy,
36
+ )
37
+
38
+ __all__ = [
39
+ "CompiledPolicy",
40
+ "Decision",
41
+ "EvalContext",
42
+ "Outcome",
43
+ "PolicyBuilder",
44
+ "ReasonCode",
45
+ "compile_policy",
46
+ ]
47
+
48
+ CompiledPolicy = PyCompiledPolicy
49
+ EvalContext = PyEvalContext
50
+
51
+
52
+ class Outcome(enum.Enum):
53
+ """Authorization outcome from a policy evaluation.
54
+
55
+ Values match the Rust ``Outcome`` enum in ``auths-policy/src/decision.rs``.
56
+ """
57
+
58
+ ALLOW = "Allow"
59
+ DENY = "Deny"
60
+ INDETERMINATE = "Indeterminate"
61
+ REQUIRES_APPROVAL = "RequiresApproval"
62
+ MISSING_CREDENTIAL = "MissingCredential"
63
+
64
+
65
+ class ReasonCode(enum.Enum):
66
+ """Machine-readable reason code for stable logging and alerting.
67
+
68
+ Values match the Rust ``ReasonCode`` enum in ``auths-policy/src/decision.rs``.
69
+ """
70
+
71
+ UNCONDITIONAL = "Unconditional"
72
+ ALL_CHECKS_PASSED = "AllChecksPassed"
73
+ CAPABILITY_PRESENT = "CapabilityPresent"
74
+ CAPABILITY_MISSING = "CapabilityMissing"
75
+ ISSUER_MATCH = "IssuerMatch"
76
+ ISSUER_MISMATCH = "IssuerMismatch"
77
+ REVOKED = "Revoked"
78
+ EXPIRED = "Expired"
79
+ INSUFFICIENT_TTL = "InsufficientTtl"
80
+ ISSUED_TOO_LONG_AGO = "IssuedTooLongAgo"
81
+ ROLE_MISMATCH = "RoleMismatch"
82
+ SCOPE_MISMATCH = "ScopeMismatch"
83
+ CHAIN_TOO_DEEP = "ChainTooDeep"
84
+ DELEGATION_MISMATCH = "DelegationMismatch"
85
+ ATTR_MISMATCH = "AttrMismatch"
86
+ MISSING_FIELD = "MissingField"
87
+ RECURSION_EXCEEDED = "RecursionExceeded"
88
+ SHORT_CIRCUIT = "ShortCircuit"
89
+ COMBINATOR_RESULT = "CombinatorResult"
90
+ WORKLOAD_MISMATCH = "WorkloadMismatch"
91
+ WITNESS_QUORUM_NOT_MET = "WitnessQuorumNotMet"
92
+ SIGNER_TYPE_MATCH = "SignerTypeMatch"
93
+ SIGNER_TYPE_MISMATCH = "SignerTypeMismatch"
94
+ APPROVAL_REQUIRED = "ApprovalRequired"
95
+ APPROVAL_GRANTED = "ApprovalGranted"
96
+ APPROVAL_EXPIRED = "ApprovalExpired"
97
+ APPROVAL_ALREADY_USED = "ApprovalAlreadyUsed"
98
+ APPROVAL_REQUEST_MISMATCH = "ApprovalRequestMismatch"
99
+
100
+
101
+ @dataclass
102
+ class Decision:
103
+ """Result of evaluating a policy against a context.
104
+
105
+ Supports boolean evaluation: `if decision:` is equivalent to `if decision.allowed`.
106
+ """
107
+
108
+ outcome: str
109
+ """Policy result: `"allow"` or `"deny"`."""
110
+ reason: str
111
+ """Short machine-readable reason (e.g. `"revoked"`, `"capability_missing"`)."""
112
+ message: str
113
+ """Human-readable explanation of the decision."""
114
+
115
+ @property
116
+ def outcome_enum(self) -> Outcome:
117
+ """Parse the outcome string into a typed :class:`Outcome` enum."""
118
+ return Outcome(self.outcome)
119
+
120
+ @property
121
+ def reason_enum(self) -> ReasonCode:
122
+ """Parse the reason string into a typed :class:`ReasonCode` enum."""
123
+ return ReasonCode(self.reason)
124
+
125
+ @property
126
+ def allowed(self) -> bool:
127
+ return self.outcome == "allow" or self.outcome == "Allow"
128
+
129
+ @property
130
+ def denied(self) -> bool:
131
+ return self.outcome == "deny" or self.outcome == "Deny"
132
+
133
+ def __bool__(self) -> bool:
134
+ return self.allowed
135
+
136
+ def __repr__(self) -> str:
137
+ return f"Decision(outcome='{self.outcome}', reason='{self.reason}')"
138
+
139
+
140
+ def eval_context_from_commit_result(
141
+ commit_result,
142
+ issuer: str,
143
+ capabilities: Optional[list[str]] = None,
144
+ ) -> dict:
145
+ """Build an EvalContext dict from a ``CommitResult``.
146
+
147
+ Extracts the signer hex from the commit result and converts it to a
148
+ ``did:key:`` DID for use as the ``subject`` field.
149
+
150
+ Args:
151
+ commit_result: A ``CommitResult`` from ``verify_commit_range()``.
152
+ issuer: The issuer DID (``did:keri:...``).
153
+ capabilities: Optional capability list to include.
154
+
155
+ Returns:
156
+ A dict suitable for passing to ``EvalContext`` or ``evaluatePolicy``.
157
+
158
+ Examples:
159
+ ```python
160
+ result = verify_commit_range("HEAD~1..HEAD")
161
+ for cr in result.commits:
162
+ ctx = eval_context_from_commit_result(cr, org.did, ["sign_commit"])
163
+ ```
164
+ """
165
+ from auths._native import signer_hex_to_did
166
+
167
+ subject = "unknown"
168
+ if commit_result.signer:
169
+ try:
170
+ subject = signer_hex_to_did(commit_result.signer)
171
+ except Exception:
172
+ subject = f"did:key:z{commit_result.signer}"
173
+
174
+ ctx: dict = {
175
+ "issuer": issuer,
176
+ "subject": subject,
177
+ }
178
+ if capabilities:
179
+ ctx["capabilities"] = capabilities
180
+ return ctx
181
+
182
+
183
+ class PolicyBuilder:
184
+ """Fluent builder for Auths access policies.
185
+
186
+ Examples:
187
+ ```python
188
+ policy = PolicyBuilder.standard("sign_commit").build()
189
+
190
+ policy = (PolicyBuilder()
191
+ .not_revoked()
192
+ .not_expired()
193
+ .require_capability("sign_commit")
194
+ .require_issuer("did:keri:EOrg123")
195
+ .build())
196
+ ```
197
+ """
198
+
199
+ #: All available predicate method names for discoverability.
200
+ AVAILABLE_PREDICATES: list[str] = [
201
+ "not_revoked",
202
+ "not_expired",
203
+ "expires_after",
204
+ "issued_within",
205
+ "require_capability",
206
+ "require_all_capabilities",
207
+ "require_any_capability",
208
+ "require_issuer",
209
+ "require_issuer_in",
210
+ "require_subject",
211
+ "require_delegated_by",
212
+ "require_agent",
213
+ "require_human",
214
+ "require_workload",
215
+ "require_repo",
216
+ "require_env",
217
+ "max_chain_depth",
218
+ ]
219
+
220
+ #: Built-in preset policy names.
221
+ AVAILABLE_PRESETS: list[str] = [
222
+ "standard",
223
+ ]
224
+
225
+ def __init__(self):
226
+ self._predicates: list[dict] = []
227
+
228
+ @classmethod
229
+ def standard(cls, capability: str) -> PolicyBuilder:
230
+ """The "80% policy": not revoked, not expired, requires one capability."""
231
+ return cls().not_revoked().not_expired().require_capability(capability)
232
+
233
+ @classmethod
234
+ def from_json(cls, json_str: str) -> PolicyBuilder:
235
+ """Reconstruct a PolicyBuilder from a JSON policy expression.
236
+
237
+ Args:
238
+ json_str: JSON string from ``to_json()`` or config files.
239
+
240
+ Returns:
241
+ A new PolicyBuilder with the parsed predicates.
242
+
243
+ Examples:
244
+ ```python
245
+ builder = PolicyBuilder.from_json(stored_json)
246
+ policy = builder.build()
247
+ ```
248
+ """
249
+ expr = json.loads(json_str)
250
+ result = cls()
251
+ if isinstance(expr, dict) and expr.get("op") == "And" and isinstance(expr.get("args"), list):
252
+ result._predicates = expr["args"]
253
+ else:
254
+ result._predicates = [expr]
255
+ return result
256
+
257
+ @classmethod
258
+ def available_predicates(cls) -> list[str]:
259
+ """Return the list of available predicate method names."""
260
+ return list(cls.AVAILABLE_PREDICATES)
261
+
262
+ @classmethod
263
+ def available_presets(cls) -> list[str]:
264
+ """Return the list of available preset policy names."""
265
+ return list(cls.AVAILABLE_PRESETS)
266
+
267
+ @classmethod
268
+ def any_of(cls, *builders: PolicyBuilder) -> PolicyBuilder:
269
+ """Create a policy that passes if ANY of the given policies pass."""
270
+ result = cls()
271
+ or_args = [{"op": "And", "args": b._predicates} for b in builders]
272
+ result._predicates = [{"op": "Or", "args": or_args}]
273
+ return result
274
+
275
+ def not_revoked(self) -> PolicyBuilder:
276
+ self._predicates.append({"op": "NotRevoked"})
277
+ return self
278
+
279
+ def not_expired(self) -> PolicyBuilder:
280
+ self._predicates.append({"op": "NotExpired"})
281
+ return self
282
+
283
+ def expires_after(self, seconds: int) -> PolicyBuilder:
284
+ """Require at least `seconds` of remaining validity."""
285
+ self._predicates.append({"op": "ExpiresAfter", "args": seconds})
286
+ return self
287
+
288
+ def issued_within(self, seconds: int) -> PolicyBuilder:
289
+ """Require the attestation was issued within `seconds` ago."""
290
+ self._predicates.append({"op": "IssuedWithin", "args": seconds})
291
+ return self
292
+
293
+ def require_capability(self, cap: str) -> PolicyBuilder:
294
+ self._predicates.append({"op": "HasCapability", "args": cap})
295
+ return self
296
+
297
+ def require_all_capabilities(self, caps: list[str]) -> PolicyBuilder:
298
+ for cap in caps:
299
+ self.require_capability(cap)
300
+ return self
301
+
302
+ def require_any_capability(self, caps: list[str]) -> PolicyBuilder:
303
+ or_args = [{"op": "HasCapability", "args": c} for c in caps]
304
+ self._predicates.append({"op": "Or", "args": or_args})
305
+ return self
306
+
307
+ def require_issuer(self, did: str) -> PolicyBuilder:
308
+ self._predicates.append({"op": "IssuerIs", "args": did})
309
+ return self
310
+
311
+ def require_issuer_in(self, dids: list[str]) -> PolicyBuilder:
312
+ or_args = [{"op": "IssuerIs", "args": d} for d in dids]
313
+ self._predicates.append({"op": "Or", "args": or_args})
314
+ return self
315
+
316
+ def require_subject(self, did: str) -> PolicyBuilder:
317
+ self._predicates.append({"op": "SubjectIs", "args": did})
318
+ return self
319
+
320
+ def require_delegated_by(self, did: str) -> PolicyBuilder:
321
+ self._predicates.append({"op": "DelegatedBy", "args": did})
322
+ return self
323
+
324
+ def require_agent(self) -> PolicyBuilder:
325
+ self._predicates.append({"op": "IsAgent"})
326
+ return self
327
+
328
+ def require_human(self) -> PolicyBuilder:
329
+ self._predicates.append({"op": "IsHuman"})
330
+ return self
331
+
332
+ def require_workload(self) -> PolicyBuilder:
333
+ self._predicates.append({"op": "IsWorkload"})
334
+ return self
335
+
336
+ def require_repo(self, repo: str) -> PolicyBuilder:
337
+ self._predicates.append({"op": "RepoIs", "args": repo})
338
+ return self
339
+
340
+ def require_env(self, env: str) -> PolicyBuilder:
341
+ self._predicates.append({"op": "EnvIs", "args": env})
342
+ return self
343
+
344
+ def max_chain_depth(self, depth: int) -> PolicyBuilder:
345
+ self._predicates.append({"op": "MaxChainDepth", "args": depth})
346
+ return self
347
+
348
+ def or_policy(self, other: PolicyBuilder) -> PolicyBuilder:
349
+ """Combine with another policy using OR logic."""
350
+ return PolicyBuilder.any_of(self, other)
351
+
352
+ def negate(self) -> PolicyBuilder:
353
+ """Negate the entire policy (all current predicates)."""
354
+ result = PolicyBuilder()
355
+ result._predicates = [{"op": "Not", "args": {"op": "And", "args": self._predicates}}]
356
+ return result
357
+
358
+ def build(self) -> CompiledPolicy:
359
+ """Compile the policy. Raises ValueError on invalid combinations."""
360
+ if not self._predicates:
361
+ raise ValueError(
362
+ "Cannot build an empty policy. Add at least one predicate, "
363
+ "or use PolicyBuilder.standard('capability') for the common case."
364
+ )
365
+ expr = {"op": "And", "args": self._predicates}
366
+ return compile_policy(json.dumps(expr))
367
+
368
+ def to_json(self) -> str:
369
+ """Export the policy as JSON (for storage in config files)."""
370
+ if not self._predicates:
371
+ raise ValueError("Cannot export an empty policy.")
372
+ expr = {"op": "And", "args": self._predicates}
373
+ return json.dumps(expr)
374
+
375
+ def __repr__(self) -> str:
376
+ if not self._predicates:
377
+ return "PolicyBuilder(empty)"
378
+ pred_names = [p.get("op", "?") for p in self._predicates]
379
+ return f"PolicyBuilder([{', '.join(pred_names)}])"
380
+
381
+ def __len__(self) -> int:
382
+ return len(self._predicates)
auths/py.typed ADDED
File without changes
auths/rotation.py ADDED
@@ -0,0 +1,30 @@
1
+ """Key rotation result types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class IdentityRotationResult:
10
+ """Result of a KERI key rotation ceremony.
11
+
12
+ After rotation, old attestations remain valid — verifiers walk the Key Event Log
13
+ to find the key that was active at signing time.
14
+ """
15
+
16
+ controller_did: str
17
+ """The identity's KERI DID."""
18
+ new_key_fingerprint: str
19
+ """Fingerprint of the newly rotated-in key."""
20
+ previous_key_fingerprint: str
21
+ """Fingerprint of the key that was rotated out."""
22
+ sequence: int
23
+ """KERI sequence number after rotation."""
24
+
25
+ def __repr__(self) -> str:
26
+ return (
27
+ f"IdentityRotationResult(did='{self.controller_did[:25]}...', "
28
+ f"seq={self.sequence}, "
29
+ f"new_key='{self.new_key_fingerprint[:16]}...')"
30
+ )
auths/sign.py ADDED
@@ -0,0 +1,5 @@
1
+ """Convenience re-exports for signing operations."""
2
+
3
+ from auths._native import sign_bytes, sign_action, verify_action_envelope
4
+
5
+ __all__ = ["sign_bytes", "sign_action", "verify_action_envelope"]