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/git.py ADDED
@@ -0,0 +1,473 @@
1
+ """Git commit signature verification using native Rust verification.
2
+
3
+ Enumerates commits via ``git rev-list``, reads raw commit objects via
4
+ ``git cat-file``, and verifies SSH signatures natively through the
5
+ ``auths._native.verify_commit_native`` FFI bridge — no ``ssh-keygen``
6
+ subprocess required.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+
17
+
18
+ def generate_allowed_signers(repo_path: str = "~/.auths") -> str:
19
+ """Generate an allowed_signers file content from live Auths storage.
20
+
21
+ Reads device attestations from the Git-backed identity store and
22
+ formats them for ``gpg.ssh.allowedSignersFile``. Revoked attestations
23
+ and devices with undecodable keys are silently skipped.
24
+
25
+ Args:
26
+ repo_path: Path to the Auths identity repository.
27
+
28
+ Returns:
29
+ Formatted allowed_signers file content, or an empty string if no
30
+ attestations are found. Write this to a file or pass to
31
+ ``verify_commit_range``.
32
+
33
+ Examples:
34
+ ```python
35
+ content = generate_allowed_signers()
36
+ Path(".auths/allowed_signers").write_text(content)
37
+ ```
38
+ """
39
+ from auths._native import generate_allowed_signers_file
40
+
41
+ return generate_allowed_signers_file(repo_path)
42
+
43
+
44
+ class ErrorCode:
45
+ """Stable error codes for commit verification failures."""
46
+
47
+ UNSIGNED = "UNSIGNED"
48
+ GPG_NOT_SUPPORTED = "GPG_NOT_SUPPORTED"
49
+ UNKNOWN_SIGNER = "UNKNOWN_SIGNER"
50
+ INVALID_SIGNATURE = "INVALID_SIGNATURE"
51
+ NO_ATTESTATION_FOUND = "NO_ATTESTATION_FOUND"
52
+ DEVICE_REVOKED = "DEVICE_REVOKED"
53
+ DEVICE_EXPIRED = "DEVICE_EXPIRED"
54
+ LAYOUT_DISCOVERY_FAILED = "LAYOUT_DISCOVERY_FAILED"
55
+
56
+
57
+ @dataclass
58
+ class CommitResult:
59
+ """Result of verifying a single commit's SSH signature."""
60
+
61
+ commit_sha: str
62
+ """Git commit SHA that was verified."""
63
+ is_valid: bool
64
+ """Whether the commit's signature is valid."""
65
+ signer: str | None = None
66
+ """Hex-encoded public key of the signer, if identified."""
67
+ error: str | None = None
68
+ """Human-readable error message on failure."""
69
+ error_code: str | None = None
70
+ """Machine-readable error code (see `ErrorCode`)."""
71
+
72
+
73
+ @dataclass
74
+ class VerifyResult:
75
+ """Wrapper around commit verification results."""
76
+
77
+ commits: list[CommitResult]
78
+ """Per-commit verification results."""
79
+ passed: bool
80
+ """Overall pass/fail for the batch."""
81
+ mode: str
82
+ """Verification mode: `"enforce"` or `"warn"`."""
83
+ summary: str
84
+ """Human-readable summary (e.g. `"3/3 commits verified"`)."""
85
+
86
+
87
+ @dataclass
88
+ class LayoutInfo:
89
+ """Resolved location of Auths identity data in a repository."""
90
+
91
+ bundle: str | None = None
92
+ """Path to identity-bundle JSON file, if found."""
93
+ refs: list[str] | None = None
94
+ """Git ref names under `refs/auths/`, if found."""
95
+ source: str = ""
96
+ """How the layout was discovered: `"file"` or `"git-refs"`."""
97
+
98
+
99
+ class LayoutError(Exception):
100
+ """Raised when Auths identity data cannot be found in the repo."""
101
+
102
+ def __init__(self, code: str, message: str):
103
+ self.code = code
104
+ super().__init__(message)
105
+
106
+
107
+ def discover_layout(repo_root: str = ".") -> LayoutInfo:
108
+ """Try to find Auths identity data in the repo.
109
+
110
+ Checks ``.auths/identity-bundle.json`` then ``refs/auths/*``.
111
+ Raises :class:`LayoutError` if missing.
112
+ """
113
+ bundle_path = os.path.join(repo_root, ".auths", "identity-bundle.json")
114
+ if os.path.isfile(bundle_path):
115
+ return LayoutInfo(bundle=bundle_path, source="file")
116
+
117
+ proc = subprocess.run(
118
+ ["git", "for-each-ref", "refs/auths/", "--format=%(refname)"],
119
+ capture_output=True,
120
+ text=True,
121
+ cwd=repo_root,
122
+ )
123
+ if proc.returncode == 0 and proc.stdout.strip():
124
+ return LayoutInfo(refs=proc.stdout.strip().splitlines(), source="git-refs")
125
+
126
+ raise LayoutError(
127
+ ErrorCode.LAYOUT_DISCOVERY_FAILED,
128
+ "No .auths/identity-bundle.json or refs/auths/* found. "
129
+ "Run: auths id export-bundle --output .auths/identity-bundle.json",
130
+ )
131
+
132
+
133
+ def verify_commit_range(
134
+ commit_range: str,
135
+ identity_bundle: str | None = None,
136
+ allowed_signers: str = ".auths/allowed_signers",
137
+ mode: str = "enforce",
138
+ ) -> VerifyResult:
139
+ """Verify SSH signatures for every commit in *commit_range*.
140
+
141
+ Args:
142
+ commit_range: A git revision range (e.g. ``origin/main..HEAD``).
143
+ identity_bundle: Path to an Auths identity-bundle JSON file.
144
+ allowed_signers: Path to an ssh-keygen allowed_signers file.
145
+ mode: ``"enforce"`` or ``"warn"``.
146
+
147
+ Returns:
148
+ VerifyResult with per-commit results and a pass/fail decision.
149
+ """
150
+ if mode not in ("enforce", "warn"):
151
+ raise ValueError(f"mode must be 'enforce' or 'warn', got {mode!r}")
152
+
153
+ allowed_keys_hex: list[str] = []
154
+ attestation_lookup: dict[str, dict] | None = None
155
+
156
+ if identity_bundle is not None:
157
+ allowed_keys_hex, attestation_lookup = _allowed_signers_from_bundle(
158
+ identity_bundle
159
+ )
160
+ elif not os.path.isfile(allowed_signers):
161
+ try:
162
+ layout = discover_layout()
163
+ if layout.bundle:
164
+ allowed_keys_hex, attestation_lookup = _allowed_signers_from_bundle(
165
+ layout.bundle
166
+ )
167
+ elif layout.source == "git-refs":
168
+ result = CommitResult(
169
+ commit_sha="<layout>",
170
+ is_valid=False,
171
+ error=(
172
+ "Found refs/auths/* but git-ref-based verification "
173
+ "is not yet supported. Export a file-based bundle: "
174
+ "auths id export-bundle --output "
175
+ ".auths/identity-bundle.json"
176
+ ),
177
+ error_code=ErrorCode.LAYOUT_DISCOVERY_FAILED,
178
+ )
179
+ return VerifyResult(
180
+ commits=[result],
181
+ passed=(mode == "warn"),
182
+ mode=mode,
183
+ summary=f"Layout discovery: git-refs not yet supported ({mode} mode)",
184
+ )
185
+ except LayoutError as exc:
186
+ result = CommitResult(
187
+ commit_sha="<layout>",
188
+ is_valid=False,
189
+ error=str(exc),
190
+ error_code=ErrorCode.LAYOUT_DISCOVERY_FAILED,
191
+ )
192
+ return VerifyResult(
193
+ commits=[result],
194
+ passed=(mode == "warn"),
195
+ mode=mode,
196
+ summary=f"Layout discovery failed ({mode} mode)",
197
+ )
198
+ else:
199
+ # Legacy path: read allowed_signers file and extract hex keys
200
+ allowed_keys_hex = _hex_keys_from_allowed_signers_file(allowed_signers)
201
+
202
+ shas = list(reversed(_rev_list(commit_range)))
203
+ if not shas:
204
+ return VerifyResult(
205
+ commits=[], passed=True, mode=mode, summary="No commits to verify"
206
+ )
207
+
208
+ results: list[CommitResult] = []
209
+ for sha in shas:
210
+ results.append(_verify_one(sha, allowed_keys_hex, attestation_lookup))
211
+
212
+ total = len(results)
213
+ failures = sum(1 for r in results if not r.is_valid)
214
+
215
+ if failures == 0:
216
+ summary = f"{total}/{total} commits verified"
217
+ elif mode == "warn":
218
+ summary = f"{failures}/{total} commits failed (warn mode: not blocking)"
219
+ else:
220
+ summary = f"{failures}/{total} commits failed"
221
+
222
+ passed = (failures == 0) if mode == "enforce" else True
223
+
224
+ return VerifyResult(commits=results, passed=passed, mode=mode, summary=summary)
225
+
226
+
227
+ def verify_commits(
228
+ shas: list[str],
229
+ identity_bundle: str | None = None,
230
+ allowed_signers: str = ".auths/allowed_signers",
231
+ mode: str = "enforce",
232
+ ) -> VerifyResult:
233
+ """Verify SSH signatures for an explicit list of commit SHAs.
234
+
235
+ Args:
236
+ shas: List of commit SHA strings.
237
+ identity_bundle: Path to an Auths identity-bundle JSON file.
238
+ allowed_signers: Path to an ssh-keygen allowed_signers file.
239
+ mode: ``"enforce"`` or ``"warn"``.
240
+
241
+ Returns:
242
+ VerifyResult with per-commit results and a pass/fail decision.
243
+ """
244
+ if mode not in ("enforce", "warn"):
245
+ raise ValueError(f"mode must be 'enforce' or 'warn', got {mode!r}")
246
+
247
+ allowed_keys_hex: list[str] = []
248
+ attestation_lookup: dict[str, dict] | None = None
249
+
250
+ if identity_bundle is not None:
251
+ allowed_keys_hex, attestation_lookup = _allowed_signers_from_bundle(
252
+ identity_bundle
253
+ )
254
+ elif os.path.isfile(allowed_signers):
255
+ allowed_keys_hex = _hex_keys_from_allowed_signers_file(allowed_signers)
256
+
257
+ if not shas:
258
+ return VerifyResult(
259
+ commits=[], passed=True, mode=mode, summary="No commits to verify"
260
+ )
261
+
262
+ results: list[CommitResult] = []
263
+ for sha in shas:
264
+ results.append(_verify_one(sha, allowed_keys_hex, attestation_lookup))
265
+
266
+ total = len(results)
267
+ failures = sum(1 for r in results if not r.is_valid)
268
+
269
+ if failures == 0:
270
+ summary = f"{total}/{total} commits verified"
271
+ elif mode == "warn":
272
+ summary = f"{failures}/{total} commits failed (warn mode: not blocking)"
273
+ else:
274
+ summary = f"{failures}/{total} commits failed"
275
+
276
+ passed = (failures == 0) if mode == "enforce" else True
277
+
278
+ return VerifyResult(commits=results, passed=passed, mode=mode, summary=summary)
279
+
280
+
281
+ def _rev_list(commit_range: str) -> list[str]:
282
+ proc = subprocess.run(
283
+ ["git", "rev-list", commit_range], capture_output=True, text=True
284
+ )
285
+ if proc.returncode != 0:
286
+ raise RuntimeError(f"git rev-list failed: {proc.stderr.strip()}")
287
+ return [line for line in proc.stdout.strip().splitlines() if line]
288
+
289
+
290
+ def _get_raw_commit(sha: str) -> bytes | None:
291
+ """Read raw commit object bytes via git cat-file.
292
+
293
+ Args:
294
+ sha: Git commit SHA.
295
+
296
+ Returns:
297
+ Raw commit bytes, or None on failure.
298
+ """
299
+ proc = subprocess.run(
300
+ ["git", "cat-file", "commit", sha], capture_output=True
301
+ )
302
+ if proc.returncode != 0:
303
+ return None
304
+ return proc.stdout
305
+
306
+
307
+ def _verify_one(
308
+ sha: str,
309
+ allowed_keys_hex: list[str],
310
+ attestation_lookup: dict[str, dict] | None = None,
311
+ ) -> CommitResult:
312
+ from auths._native import verify_commit_native
313
+
314
+ commit_content = _get_raw_commit(sha)
315
+ if commit_content is None:
316
+ return CommitResult(
317
+ commit_sha=sha,
318
+ is_valid=False,
319
+ error="Failed to read commit",
320
+ error_code=ErrorCode.INVALID_SIGNATURE,
321
+ )
322
+
323
+ result = verify_commit_native(commit_content, allowed_keys_hex)
324
+
325
+ if not result.valid:
326
+ return CommitResult(
327
+ commit_sha=sha,
328
+ is_valid=False,
329
+ error=result.error or "Verification failed",
330
+ error_code=result.error_code or ErrorCode.INVALID_SIGNATURE,
331
+ )
332
+
333
+ signer = result.signer_hex
334
+
335
+ if attestation_lookup is not None and signer is not None:
336
+ status = _check_attestation_status(signer, attestation_lookup)
337
+ if status is not None:
338
+ return CommitResult(
339
+ commit_sha=sha,
340
+ is_valid=False,
341
+ signer=signer,
342
+ error=status[0],
343
+ error_code=status[1],
344
+ )
345
+
346
+ return CommitResult(commit_sha=sha, is_valid=True, signer=signer)
347
+
348
+
349
+ def _check_attestation_status(
350
+ signer_key_hex: str,
351
+ attestation_lookup: dict[str, dict],
352
+ ) -> tuple | None:
353
+ if signer_key_hex not in attestation_lookup:
354
+ return None
355
+
356
+ att = attestation_lookup[signer_key_hex]
357
+
358
+ if att.get("revoked", False):
359
+ revoked_at = att.get("timestamp", "unknown time")
360
+ return (
361
+ f"Device {signer_key_hex} was revoked (attestation timestamp: {revoked_at})",
362
+ ErrorCode.DEVICE_REVOKED,
363
+ )
364
+
365
+ expires_at = att.get("expires_at")
366
+ if expires_at is not None:
367
+ try:
368
+ exp_dt = _parse_datetime(expires_at)
369
+ if datetime.now(timezone.utc) > exp_dt:
370
+ return (
371
+ f"Device {signer_key_hex} attestation expired at {expires_at}",
372
+ ErrorCode.DEVICE_EXPIRED,
373
+ )
374
+ except (ValueError, TypeError):
375
+ pass
376
+
377
+ return None
378
+
379
+
380
+ def _parse_datetime(value: str) -> datetime:
381
+ if value.endswith("Z"):
382
+ value = value[:-1] + "+00:00"
383
+ return datetime.fromisoformat(value)
384
+
385
+
386
+ def _allowed_signers_from_bundle(
387
+ bundle_path: str,
388
+ ) -> tuple[list[str], dict[str, dict]]:
389
+ """Extract allowed Ed25519 public keys (hex) from an identity bundle.
390
+
391
+ Args:
392
+ bundle_path: Path to an Auths identity-bundle JSON file.
393
+
394
+ Returns:
395
+ Tuple of (hex_keys, attestation_lookup) where hex_keys is a list
396
+ of hex-encoded 32-byte Ed25519 public keys and attestation_lookup
397
+ maps device_public_key hex to the attestation dict.
398
+ """
399
+ with open(bundle_path) as f:
400
+ bundle = json.load(f)
401
+
402
+ pk_hex = bundle.get("public_key_hex") or bundle.get("publicKeyHex")
403
+ if not pk_hex:
404
+ raise ValueError("Identity bundle missing public_key_hex field")
405
+
406
+ pk_bytes = bytes.fromhex(pk_hex)
407
+ if len(pk_bytes) != 32:
408
+ raise ValueError(
409
+ f"Invalid Ed25519 public key length: expected 32 bytes, got {len(pk_bytes)}"
410
+ )
411
+
412
+ keys: list[str] = []
413
+ attestation_lookup: dict[str, dict] = {}
414
+
415
+ chain = bundle.get("attestation_chain", [])
416
+ for att in chain:
417
+ dev_pk_hex = att.get("device_public_key")
418
+ if not dev_pk_hex:
419
+ continue
420
+ try:
421
+ dev_pk_bytes = bytes.fromhex(dev_pk_hex)
422
+ if len(dev_pk_bytes) != 32:
423
+ continue
424
+ except (ValueError, TypeError):
425
+ continue
426
+ keys.append(dev_pk_hex)
427
+ attestation_lookup[dev_pk_hex] = att
428
+
429
+ # Identity key itself is also an allowed signer
430
+ keys.append(pk_hex)
431
+
432
+ return (keys, attestation_lookup)
433
+
434
+
435
+ def _hex_keys_from_allowed_signers_file(path: str) -> list[str]:
436
+ """Extract Ed25519 public keys as hex from an allowed_signers file.
437
+
438
+ Each line has format: ``<principal> ssh-ed25519 <base64-blob>``
439
+ The base64 blob is SSH wire format: u32-len "ssh-ed25519" + u32-len <32-byte-key>.
440
+
441
+ Args:
442
+ path: Path to an ssh-keygen allowed_signers file.
443
+
444
+ Returns:
445
+ List of hex-encoded 32-byte Ed25519 public keys.
446
+ """
447
+ import base64
448
+ import struct
449
+
450
+ keys: list[str] = []
451
+ with open(path) as f:
452
+ for line in f:
453
+ line = line.strip()
454
+ if not line or line.startswith("#"):
455
+ continue
456
+ parts = line.split()
457
+ # Format: principal key-type base64-blob [comment]
458
+ if len(parts) < 3 or parts[1] != "ssh-ed25519":
459
+ continue
460
+ try:
461
+ blob = base64.b64decode(parts[2])
462
+ # SSH wire format: u32-len + key-type-string + u32-len + key-bytes
463
+ offset = 0
464
+ type_len = struct.unpack(">I", blob[offset : offset + 4])[0]
465
+ offset += 4 + type_len
466
+ key_len = struct.unpack(">I", blob[offset : offset + 4])[0]
467
+ offset += 4
468
+ key_bytes = blob[offset : offset + key_len]
469
+ if len(key_bytes) == 32:
470
+ keys.append(key_bytes.hex())
471
+ except (ValueError, struct.error, IndexError):
472
+ continue
473
+ return keys
auths/identity.py ADDED
@@ -0,0 +1,221 @@
1
+ """Identity and agent resource services — Stripe-style API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ from auths._native import (
9
+ create_identity as _create_identity,
10
+ create_agent_identity as _create_agent_identity,
11
+ delegate_agent as _delegate_agent,
12
+ rotate_identity_ffi as _rotate_identity,
13
+ )
14
+ from auths.rotation import IdentityRotationResult
15
+
16
+ if TYPE_CHECKING:
17
+ from auths._client import Auths
18
+
19
+
20
+ @dataclass
21
+ class Identity:
22
+ """An Auths identity (represents a `did:keri:` identifier)."""
23
+
24
+ did: str
25
+ """The KERI decentralized identifier (e.g. `did:keri:EXq5...`)."""
26
+ _key_alias: str = field(repr=False)
27
+ """Internal keychain alias for the signing key."""
28
+ label: str
29
+ """Human-readable label (e.g. `"laptop"`, `"main"`)."""
30
+ repo_path: str
31
+ """Path to the Git identity repository."""
32
+ public_key: str
33
+ """Hex-encoded Ed25519 public key."""
34
+
35
+
36
+ @dataclass
37
+ class AgentIdentity:
38
+ """Standalone agent identity (`did:keri:`). Created via `identities.create_agent()`."""
39
+
40
+ did: str
41
+ """The agent's KERI decentralized identifier."""
42
+ _key_alias: str = field(repr=False)
43
+ """Internal keychain alias for the agent's signing key."""
44
+ attestation: str
45
+ """JSON-serialized attestation binding the agent to its capabilities."""
46
+ public_key: str
47
+ """Hex-encoded Ed25519 public key."""
48
+
49
+
50
+ @dataclass
51
+ class DelegatedAgent:
52
+ """Agent delegated under a parent identity (`did:key:`). Created via `identities.delegate_agent()`."""
53
+
54
+ did: str
55
+ """The delegated agent's device-level identifier (`did:key:z...`)."""
56
+ _key_alias: str = field(repr=False)
57
+ """Internal keychain alias for the delegated key."""
58
+ attestation: str
59
+ """JSON-serialized delegation attestation signed by the parent identity."""
60
+ public_key: str
61
+ """Hex-encoded Ed25519 public key."""
62
+
63
+
64
+ class IdentityService:
65
+ """Resource service for identity operations.
66
+
67
+ Examples:
68
+ ```python
69
+ auths = Auths()
70
+ identity = auths.identities.create(label="laptop")
71
+ agent = auths.identities.delegate_agent(identity.did, name="ci-bot", capabilities=["sign"])
72
+ ```
73
+ """
74
+
75
+ def __init__(self, client: Auths):
76
+ self._client = client
77
+
78
+ def create(
79
+ self,
80
+ label: str = "main",
81
+ repo_path: str | None = None,
82
+ passphrase: str | None = None,
83
+ ) -> Identity:
84
+ """Create a new identity.
85
+
86
+ Args:
87
+ label: Human-readable label for this identity (default: "main").
88
+ repo_path: Git repo path (default: client's repo_path).
89
+ passphrase: Key passphrase (default: client's passphrase or AUTHS_PASSPHRASE env var).
90
+
91
+ Returns:
92
+ Identity with the DID, public key, and key alias.
93
+
94
+ Raises:
95
+ IdentityError: If an identity with this alias already exists.
96
+ KeychainError: If the keychain is locked or inaccessible.
97
+
98
+ Examples:
99
+ ```python
100
+ identity = auths.identities.create(label="laptop")
101
+ ```
102
+ """
103
+ rp = repo_path or self._client.repo_path
104
+ pp = passphrase or self._client._passphrase
105
+ did, key_alias, public_key_hex = _create_identity(label, rp, pp)
106
+ return Identity(did=did, _key_alias=key_alias, label=label, repo_path=rp, public_key=public_key_hex)
107
+
108
+ def rotate(
109
+ self,
110
+ identity_did: str,
111
+ *,
112
+ passphrase: str | None = None,
113
+ ) -> IdentityRotationResult:
114
+ """Rotate an identity's keys using the KERI pre-rotation ceremony.
115
+
116
+ This is a single atomic operation. If any step fails, the previous key
117
+ remains active and no partial state is written.
118
+
119
+ After rotation:
120
+ - Old attestations remain valid (verified via Key Event Log history)
121
+ - New signing operations use the rotated key automatically
122
+ - Device links are unaffected (bound to DID, not key)
123
+
124
+ Args:
125
+ identity_did: The KERI DID of the identity to rotate.
126
+ passphrase: Optional passphrase for keychain access.
127
+
128
+ Returns:
129
+ IdentityRotationResult with the new key fingerprint and sequence number.
130
+
131
+ Raises:
132
+ IdentityError: If the identity does not exist or rotation fails.
133
+ KeychainError: If the keychain is locked or inaccessible.
134
+
135
+ Examples:
136
+ ```python
137
+ result = auths.identities.rotate(identity.did)
138
+ print(f"Rotated to sequence {result.sequence}")
139
+ ```
140
+ """
141
+ pp = passphrase or self._client._passphrase
142
+ native_result = _rotate_identity(self._client.repo_path, identity_did, None, pp)
143
+ return IdentityRotationResult(
144
+ controller_did=native_result.controller_did,
145
+ new_key_fingerprint=native_result.new_key_fingerprint,
146
+ previous_key_fingerprint=native_result.previous_key_fingerprint,
147
+ sequence=native_result.sequence,
148
+ )
149
+
150
+ def create_agent(
151
+ self,
152
+ name: str,
153
+ capabilities: list[str],
154
+ passphrase: str | None = None,
155
+ ) -> AgentIdentity:
156
+ """Create a standalone agent identity (`did:keri:`).
157
+
158
+ Args:
159
+ name: Human-readable agent name.
160
+ capabilities: List of capabilities (e.g., ["sign", "verify"]).
161
+ passphrase: Key passphrase override.
162
+
163
+ Returns:
164
+ AgentIdentity with the agent DID, attestation, and public key.
165
+
166
+ Raises:
167
+ IdentityError: If agent creation fails.
168
+ KeychainError: If the keychain is locked or inaccessible.
169
+
170
+ Examples:
171
+ ```python
172
+ agent = auths.identities.create_agent("ci-bot", ["sign"])
173
+ ```
174
+ """
175
+ pp = passphrase or self._client._passphrase
176
+ bundle = _create_agent_identity(
177
+ name, capabilities, self._client.repo_path, pp,
178
+ )
179
+ return AgentIdentity(
180
+ did=bundle.agent_did, _key_alias=bundle.key_alias,
181
+ attestation=bundle.attestation_json, public_key=bundle.public_key_hex,
182
+ )
183
+
184
+ def delegate_agent(
185
+ self,
186
+ identity_did: str,
187
+ name: str,
188
+ capabilities: list[str],
189
+ expires_in: int | None = None,
190
+ passphrase: str | None = None,
191
+ ) -> DelegatedAgent:
192
+ """Delegate an agent under an identity (`did:key:`).
193
+
194
+ Args:
195
+ identity_did: The parent identity's DID.
196
+ name: Human-readable agent name.
197
+ capabilities: List of capabilities (e.g., ["sign", "verify"]).
198
+ expires_in: Duration in seconds until expiration (per RFC 6749).
199
+ passphrase: Key passphrase override.
200
+
201
+ Returns:
202
+ DelegatedAgent with the agent DID, delegation attestation, and public key.
203
+
204
+ Raises:
205
+ IdentityError: If the parent identity doesn't exist or delegation fails.
206
+ KeychainError: If the keychain is locked or inaccessible.
207
+
208
+ Examples:
209
+ ```python
210
+ agent = auths.identities.delegate_agent(identity.did, "ci-bot", ["sign"])
211
+ ```
212
+ """
213
+ pp = passphrase or self._client._passphrase
214
+ bundle = _delegate_agent(
215
+ name, capabilities, self._client.repo_path, pp, expires_in,
216
+ identity_did,
217
+ )
218
+ return DelegatedAgent(
219
+ did=bundle.agent_did, _key_alias=bundle.key_alias,
220
+ attestation=bundle.attestation_json, public_key=bundle.public_key_hex,
221
+ )