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/__init__.py +147 -0
- auths/__init__.pyi +486 -0
- auths/_client.py +713 -0
- auths/_errors.py +80 -0
- auths/_native.pyd +0 -0
- auths/agent.py +55 -0
- auths/artifact.py +60 -0
- auths/attestation_query.py +141 -0
- auths/audit.py +226 -0
- auths/commit.py +28 -0
- auths/devices.py +162 -0
- auths/doctor.py +109 -0
- auths/git.py +473 -0
- auths/identity.py +221 -0
- auths/jwt.py +253 -0
- auths/org.py +310 -0
- auths/pairing.py +216 -0
- auths/policy.py +382 -0
- auths/py.typed +0 -0
- auths/rotation.py +30 -0
- auths/sign.py +5 -0
- auths/trust.py +169 -0
- auths/verify.py +111 -0
- auths/witness.py +91 -0
- auths_python-0.1.0.dist-info/METADATA +152 -0
- auths_python-0.1.0.dist-info/RECORD +28 -0
- auths_python-0.1.0.dist-info/WHEEL +4 -0
- auths_python-0.1.0.dist-info/sboms/auths-python.cyclonedx.json +14418 -0
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
|
+
)
|