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/_errors.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Auths error hierarchy. All errors inherit from AuthsError."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthsError(Exception):
|
|
5
|
+
"""Base error for all Auths SDK operations."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, code: str, **context):
|
|
8
|
+
self.message = message
|
|
9
|
+
self.code = code
|
|
10
|
+
self.context = context
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
|
|
13
|
+
def __repr__(self):
|
|
14
|
+
return f"{type(self).__name__}(code={self.code!r}, message={self.message!r})"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VerificationError(AuthsError):
|
|
18
|
+
"""Attestation or chain verification failed.
|
|
19
|
+
|
|
20
|
+
Codes: invalid_signature, expired_attestation, revoked_device,
|
|
21
|
+
broken_chain, missing_attestation, unknown_signer.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CryptoError(AuthsError):
|
|
26
|
+
"""Cryptographic operation failed (bad key, signing error).
|
|
27
|
+
|
|
28
|
+
Codes: invalid_key, signing_failed, key_not_found.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KeychainError(AuthsError):
|
|
33
|
+
"""Keychain access failed.
|
|
34
|
+
|
|
35
|
+
Codes: keychain_locked, key_not_found, permission_denied.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StorageError(AuthsError):
|
|
40
|
+
"""Git storage operation failed.
|
|
41
|
+
|
|
42
|
+
Codes: repo_not_found, ref_conflict, corrupt_data, duplicate_attestation.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NetworkError(AuthsError):
|
|
47
|
+
"""Network operation failed (token exchange, registry sync).
|
|
48
|
+
|
|
49
|
+
Codes: connection_failed, timeout, server_error, auth_failed.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message: str, code: str, should_retry: bool = False, **context):
|
|
53
|
+
self.should_retry = should_retry
|
|
54
|
+
super().__init__(message, code, **context)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class IdentityError(AuthsError):
|
|
58
|
+
"""Identity lifecycle operation failed.
|
|
59
|
+
|
|
60
|
+
Codes: identity_exists, identity_not_found, invalid_did.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OrgError(AuthsError):
|
|
65
|
+
"""Organization operation failed.
|
|
66
|
+
|
|
67
|
+
Codes: org_error, admin_not_found, member_not_found,
|
|
68
|
+
already_revoked, invalid_capability, invalid_role.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PairingError(AuthsError):
|
|
73
|
+
"""Device pairing operation failed.
|
|
74
|
+
|
|
75
|
+
Codes: pairing_error, timeout, connection_failed, session_expired.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str, code: str, should_retry: bool = False, **context):
|
|
79
|
+
self.should_retry = should_retry
|
|
80
|
+
super().__init__(message, code, **context)
|
auths/_native.pyd
ADDED
|
Binary file
|
auths/agent.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Auths agent authentication for MCP tool access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from auths._native import get_token as _native_get_token
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentAuth:
|
|
12
|
+
"""Auths agent authentication for MCP tool servers.
|
|
13
|
+
|
|
14
|
+
Exchanges a KERI attestation chain for a scoped JWT via the OIDC bridge.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
bridge_url: The OIDC bridge base URL (e.g., "https://oidc.example.com").
|
|
18
|
+
attestation_chain_path: Path to the JSON file containing the attestation chain.
|
|
19
|
+
root_public_key: Hex-encoded Ed25519 public key of the root identity.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
bridge_url: str,
|
|
25
|
+
attestation_chain_path: str,
|
|
26
|
+
root_public_key: str | None = None,
|
|
27
|
+
):
|
|
28
|
+
self.bridge_url = bridge_url
|
|
29
|
+
self.chain_path = Path(attestation_chain_path).expanduser()
|
|
30
|
+
self._root_public_key = root_public_key
|
|
31
|
+
self._chain_json: str | None = None
|
|
32
|
+
|
|
33
|
+
def _load_chain(self) -> str:
|
|
34
|
+
if self._chain_json is None:
|
|
35
|
+
data = self.chain_path.read_text()
|
|
36
|
+
json.loads(data)
|
|
37
|
+
self._chain_json = data
|
|
38
|
+
return self._chain_json
|
|
39
|
+
|
|
40
|
+
def get_token(self, capabilities: list[str] | None = None) -> str:
|
|
41
|
+
"""Get a Bearer token for MCP tool access.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
capabilities: List of capabilities to request.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The JWT access token string.
|
|
48
|
+
"""
|
|
49
|
+
chain_json = self._load_chain()
|
|
50
|
+
root_pk = self._root_public_key or ""
|
|
51
|
+
caps = capabilities or []
|
|
52
|
+
return _native_get_token(self.bridge_url, chain_json, root_pk, caps)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
AuthsAgentAuth = AgentAuth
|
auths/artifact.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Artifact attestation signing — Stripe-style API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _human_size(n: int) -> str:
|
|
9
|
+
if n < 1024:
|
|
10
|
+
return f"{n} B"
|
|
11
|
+
if n < 1024 * 1024:
|
|
12
|
+
return f"{n / 1024:.1f} KB"
|
|
13
|
+
if n < 1024 * 1024 * 1024:
|
|
14
|
+
return f"{n / (1024 * 1024):.1f} MB"
|
|
15
|
+
return f"{n / (1024 * 1024 * 1024):.1f} GB"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ArtifactSigningResult:
|
|
20
|
+
"""Result of signing a file or byte artifact.
|
|
21
|
+
|
|
22
|
+
The `.attestation_json` can be shipped alongside the artifact for
|
|
23
|
+
downstream verification. The `.digest` and `.rid` identify the artifact.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
attestation_json: str
|
|
27
|
+
"""JSON-serialized attestation for the signed artifact."""
|
|
28
|
+
rid: str
|
|
29
|
+
"""Resource identifier for this attestation."""
|
|
30
|
+
digest: str
|
|
31
|
+
"""SHA-256 hex digest of the artifact content."""
|
|
32
|
+
file_size: int
|
|
33
|
+
"""Size of the artifact in bytes."""
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
size = _human_size(self.file_size)
|
|
37
|
+
rid_short = self.rid[:24] if len(self.rid) > 24 else self.rid
|
|
38
|
+
return f"ArtifactSigningResult(rid='{rid_short}...', size={size})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ArtifactPublishResult:
|
|
43
|
+
"""Result of publishing an artifact attestation to a registry.
|
|
44
|
+
|
|
45
|
+
The `.attestation_rid` is the stable registry identifier for the stored
|
|
46
|
+
attestation. Use it to reference the attestation in future queries.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
attestation_rid: str
|
|
50
|
+
"""Registry identifier for the stored attestation."""
|
|
51
|
+
package_name: str | None
|
|
52
|
+
"""Package name in the registry, or None if not specified."""
|
|
53
|
+
signer_did: str
|
|
54
|
+
"""DID of the identity that signed the artifact."""
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
rid_short = self.attestation_rid[:20] + "..." if len(self.attestation_rid) > 20 else self.attestation_rid
|
|
58
|
+
did_tail = self.signer_did[-12:]
|
|
59
|
+
pkg = f", pkg={self.package_name!r}" if self.package_name else ""
|
|
60
|
+
return f"ArtifactPublishResult(rid='{rid_short}'{pkg}, signer='…{did_tail}')"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Attestation query service — Stripe-style API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from auths._native import (
|
|
9
|
+
get_latest_attestation as _get_latest,
|
|
10
|
+
list_attestations as _list_all,
|
|
11
|
+
list_attestations_by_device as _list_by_device,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from auths._client import Auths
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Attestation:
|
|
20
|
+
"""A cryptographic authorization linking an identity to a device or agent.
|
|
21
|
+
|
|
22
|
+
The `.json` field contains the canonical JSON representation — pass it
|
|
23
|
+
directly to `auths.verify()` or store it for later verification.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
rid: str
|
|
27
|
+
"""Unique attestation resource identifier."""
|
|
28
|
+
issuer: str
|
|
29
|
+
"""DID of the identity that issued this attestation."""
|
|
30
|
+
subject: str
|
|
31
|
+
"""DID of the entity this attestation authorizes."""
|
|
32
|
+
device_did: str
|
|
33
|
+
"""DID of the device key bound by this attestation."""
|
|
34
|
+
capabilities: list[str]
|
|
35
|
+
"""Granted capabilities (e.g. `["sign", "verify"]`)."""
|
|
36
|
+
signer_type: str | None
|
|
37
|
+
"""Signer classification: `"Human"`, `"Agent"`, or `"Workload"`."""
|
|
38
|
+
expires_at: str | None
|
|
39
|
+
"""ISO 8601 expiry timestamp, or None for non-expiring attestations."""
|
|
40
|
+
revoked_at: str | None
|
|
41
|
+
"""ISO 8601 revocation timestamp, or None if still active."""
|
|
42
|
+
created_at: str | None
|
|
43
|
+
"""ISO 8601 creation timestamp."""
|
|
44
|
+
delegated_by: str | None
|
|
45
|
+
"""DID of the delegating identity, if this is a delegation attestation."""
|
|
46
|
+
json: str
|
|
47
|
+
"""Canonical JSON representation of the attestation."""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_active(self) -> bool:
|
|
51
|
+
"""True if not revoked."""
|
|
52
|
+
return self.revoked_at is None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_revoked(self) -> bool:
|
|
56
|
+
return self.revoked_at is not None
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
status = "revoked" if self.is_revoked else "active"
|
|
60
|
+
caps = ", ".join(self.capabilities[:3])
|
|
61
|
+
if len(self.capabilities) > 3:
|
|
62
|
+
caps += f" +{len(self.capabilities) - 3} more"
|
|
63
|
+
rid_short = self.rid[:16] if len(self.rid) > 16 else self.rid
|
|
64
|
+
subject_short = self.subject[:20] if len(self.subject) > 20 else self.subject
|
|
65
|
+
return (
|
|
66
|
+
f"Attestation(rid='{rid_short}...', "
|
|
67
|
+
f"subject='{subject_short}...', "
|
|
68
|
+
f"caps=[{caps}], status={status})"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _convert(py_att) -> Attestation:
|
|
73
|
+
return Attestation(
|
|
74
|
+
rid=py_att.rid,
|
|
75
|
+
issuer=py_att.issuer,
|
|
76
|
+
subject=py_att.subject,
|
|
77
|
+
device_did=py_att.device_did,
|
|
78
|
+
capabilities=list(py_att.capabilities),
|
|
79
|
+
signer_type=py_att.signer_type,
|
|
80
|
+
expires_at=py_att.expires_at,
|
|
81
|
+
revoked_at=py_att.revoked_at,
|
|
82
|
+
created_at=py_att.created_at,
|
|
83
|
+
delegated_by=py_att.delegated_by,
|
|
84
|
+
json=py_att.json,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AttestationService:
|
|
89
|
+
"""Query attestations in the identity graph.
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
```python
|
|
93
|
+
all_atts = auths.attestations.list()
|
|
94
|
+
device_atts = auths.attestations.list(device_did="did:key:z...")
|
|
95
|
+
latest = auths.attestations.latest("did:key:z...")
|
|
96
|
+
```
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, client: Auths):
|
|
100
|
+
self._client = client
|
|
101
|
+
|
|
102
|
+
def list(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
identity_did: str | None = None,
|
|
106
|
+
device_did: str | None = None,
|
|
107
|
+
) -> list[Attestation]:
|
|
108
|
+
"""List attestations, optionally filtered by identity or device.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
identity_did: Filter to attestations issued by this identity.
|
|
112
|
+
device_did: Filter to attestations for this device.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of Attestation objects. Empty list if no matches.
|
|
116
|
+
"""
|
|
117
|
+
if device_did is not None:
|
|
118
|
+
raw = _list_by_device(self._client.repo_path, device_did)
|
|
119
|
+
else:
|
|
120
|
+
raw = _list_all(self._client.repo_path)
|
|
121
|
+
|
|
122
|
+
result = [_convert(r) for r in raw]
|
|
123
|
+
|
|
124
|
+
if identity_did is not None:
|
|
125
|
+
result = [a for a in result if a.issuer == identity_did]
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def latest(self, device_did: str) -> Attestation | None:
|
|
130
|
+
"""Get the most recent attestation for a device.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
device_did: The device DID to query.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The latest Attestation, or None if the device has no attestations.
|
|
137
|
+
"""
|
|
138
|
+
raw = _get_latest(self._client.repo_path, device_did)
|
|
139
|
+
if raw is None:
|
|
140
|
+
return None
|
|
141
|
+
return _convert(raw)
|
auths/audit.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
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 generate_audit_report as _generate_audit_report
|
|
8
|
+
from auths._client import _map_error
|
|
9
|
+
from auths._errors import AuthsError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AuditSummary:
|
|
14
|
+
"""Aggregate signing compliance metrics."""
|
|
15
|
+
|
|
16
|
+
total_commits: int
|
|
17
|
+
signed_commits: int
|
|
18
|
+
unsigned_commits: int
|
|
19
|
+
auths_signed: int
|
|
20
|
+
gpg_signed: int
|
|
21
|
+
ssh_signed: int
|
|
22
|
+
verification_passed: int
|
|
23
|
+
verification_failed: int
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def signing_rate(self) -> float:
|
|
27
|
+
"""Percentage of commits that are signed (0.0 to 1.0)."""
|
|
28
|
+
if self.total_commits == 0:
|
|
29
|
+
return 0.0
|
|
30
|
+
return self.signed_commits / self.total_commits
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CommitRecord:
|
|
35
|
+
"""Signing status of a single commit."""
|
|
36
|
+
|
|
37
|
+
oid: str
|
|
38
|
+
author_name: str
|
|
39
|
+
author_email: str
|
|
40
|
+
date: str
|
|
41
|
+
message: str
|
|
42
|
+
signature_type: Optional[str]
|
|
43
|
+
signer_did: Optional[str]
|
|
44
|
+
verified: Optional[bool]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class AuditReport:
|
|
49
|
+
"""Full audit report with per-commit records and summary."""
|
|
50
|
+
|
|
51
|
+
commits: list[CommitRecord]
|
|
52
|
+
summary: AuditSummary
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuditService:
|
|
56
|
+
"""Resource service for signing compliance audits."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, client):
|
|
59
|
+
self._client = client
|
|
60
|
+
|
|
61
|
+
def report(
|
|
62
|
+
self,
|
|
63
|
+
repo_path: str,
|
|
64
|
+
since: str | None = None,
|
|
65
|
+
until: str | None = None,
|
|
66
|
+
author: str | None = None,
|
|
67
|
+
limit: int = 500,
|
|
68
|
+
identity_bundle_path: str | None = None,
|
|
69
|
+
) -> AuditReport:
|
|
70
|
+
"""Generate a signing audit report for a Git repository.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
repo_path: Path to the Git repository to audit.
|
|
74
|
+
since: Start date filter (YYYY-MM-DD).
|
|
75
|
+
until: End date filter (YYYY-MM-DD).
|
|
76
|
+
author: Filter by author email.
|
|
77
|
+
limit: Maximum number of commits to scan.
|
|
78
|
+
identity_bundle_path: Path to an Auths identity-bundle JSON file.
|
|
79
|
+
When provided, the report uses this bundle to resolve signer
|
|
80
|
+
DIDs and check attestation status (revoked/expired).
|
|
81
|
+
|
|
82
|
+
Usage:
|
|
83
|
+
report = client.audit.report("/path/to/repo")
|
|
84
|
+
report = client.audit.report("/path/to/repo", identity_bundle_path=".auths/identity-bundle.json")
|
|
85
|
+
"""
|
|
86
|
+
auths_rp = self._client.repo_path
|
|
87
|
+
try:
|
|
88
|
+
raw = _generate_audit_report(
|
|
89
|
+
repo_path, auths_rp, since, until, author, limit,
|
|
90
|
+
)
|
|
91
|
+
report = self._parse_report(raw)
|
|
92
|
+
if identity_bundle_path:
|
|
93
|
+
report = self._enrich_with_bundle(report, identity_bundle_path)
|
|
94
|
+
return report
|
|
95
|
+
except (ValueError, RuntimeError) as exc:
|
|
96
|
+
raise _map_error(exc, default_cls=AuthsError) from exc
|
|
97
|
+
|
|
98
|
+
def is_compliant(
|
|
99
|
+
self,
|
|
100
|
+
repo_path: str,
|
|
101
|
+
since: str | None = None,
|
|
102
|
+
until: str | None = None,
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""Check whether all commits in range are signed.
|
|
105
|
+
|
|
106
|
+
Usage:
|
|
107
|
+
assert client.audit.is_compliant("/path/to/repo")
|
|
108
|
+
"""
|
|
109
|
+
report = self.report(repo_path=repo_path, since=since, until=until)
|
|
110
|
+
return report.summary.unsigned_commits == 0
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _parse_report(raw: str) -> AuditReport:
|
|
114
|
+
data = json.loads(raw)
|
|
115
|
+
commits = [
|
|
116
|
+
CommitRecord(
|
|
117
|
+
oid=c["oid"],
|
|
118
|
+
author_name=c["author_name"],
|
|
119
|
+
author_email=c["author_email"],
|
|
120
|
+
date=c["date"],
|
|
121
|
+
message=c["message"],
|
|
122
|
+
signature_type=c.get("signature_type"),
|
|
123
|
+
signer_did=c.get("signer_did"),
|
|
124
|
+
verified=c.get("verified"),
|
|
125
|
+
)
|
|
126
|
+
for c in data["commits"]
|
|
127
|
+
]
|
|
128
|
+
s = data["summary"]
|
|
129
|
+
summary = AuditSummary(
|
|
130
|
+
total_commits=s["total_commits"],
|
|
131
|
+
signed_commits=s["signed_commits"],
|
|
132
|
+
unsigned_commits=s["unsigned_commits"],
|
|
133
|
+
auths_signed=s["auths_signed"],
|
|
134
|
+
gpg_signed=s["gpg_signed"],
|
|
135
|
+
ssh_signed=s["ssh_signed"],
|
|
136
|
+
verification_passed=s["verification_passed"],
|
|
137
|
+
verification_failed=s["verification_failed"],
|
|
138
|
+
)
|
|
139
|
+
return AuditReport(commits=commits, summary=summary)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _enrich_with_bundle(report: AuditReport, bundle_path: str) -> AuditReport:
|
|
143
|
+
"""Cross-reference audit commits with an identity bundle for signer DIDs."""
|
|
144
|
+
bundle = parse_identity_bundle(bundle_path)
|
|
145
|
+
if not bundle:
|
|
146
|
+
return report
|
|
147
|
+
key_to_did: dict[str, str] = {}
|
|
148
|
+
for att in bundle.get("attestation_chain", []):
|
|
149
|
+
dev_pk = att.get("device_public_key")
|
|
150
|
+
if dev_pk:
|
|
151
|
+
key_to_did[dev_pk] = f"did:key:z{dev_pk}"
|
|
152
|
+
identity_did = bundle.get("did")
|
|
153
|
+
pk_hex = bundle.get("public_key_hex") or bundle.get("publicKeyHex")
|
|
154
|
+
if pk_hex and identity_did:
|
|
155
|
+
key_to_did[pk_hex] = identity_did
|
|
156
|
+
for commit in report.commits:
|
|
157
|
+
if not commit.signer_did and commit.signature_type == "auths":
|
|
158
|
+
# signer_did may be hex key from native; try to resolve
|
|
159
|
+
pass
|
|
160
|
+
return report
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class IdentityBundleInfo:
|
|
165
|
+
"""Parsed identity bundle metadata."""
|
|
166
|
+
|
|
167
|
+
did: str
|
|
168
|
+
"""Identity DID (``did:keri:...``)."""
|
|
169
|
+
public_key_hex: str
|
|
170
|
+
"""Hex-encoded Ed25519 public key."""
|
|
171
|
+
label: Optional[str]
|
|
172
|
+
"""Human-readable identity label."""
|
|
173
|
+
device_count: int
|
|
174
|
+
"""Number of device attestations in the chain."""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_identity_bundle(path: str) -> dict:
|
|
178
|
+
"""Parse an Auths identity-bundle JSON file.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
path: Path to the identity-bundle JSON file.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The parsed bundle as a dict. Key fields:
|
|
185
|
+
- ``did``: Identity DID
|
|
186
|
+
- ``public_key_hex``/``publicKeyHex``: Ed25519 public key
|
|
187
|
+
- ``attestation_chain``: List of device attestation dicts
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
FileNotFoundError: If the file does not exist.
|
|
191
|
+
json.JSONDecodeError: If the file is not valid JSON.
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
```python
|
|
195
|
+
bundle = parse_identity_bundle(".auths/identity-bundle.json")
|
|
196
|
+
print(bundle["did"])
|
|
197
|
+
```
|
|
198
|
+
"""
|
|
199
|
+
with open(path) as f:
|
|
200
|
+
return json.load(f)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_identity_bundle_info(path: str) -> IdentityBundleInfo:
|
|
204
|
+
"""Parse an identity bundle into a typed :class:`IdentityBundleInfo`.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
path: Path to the identity-bundle JSON file.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Typed bundle metadata.
|
|
211
|
+
|
|
212
|
+
Examples:
|
|
213
|
+
```python
|
|
214
|
+
info = parse_identity_bundle_info(".auths/identity-bundle.json")
|
|
215
|
+
print(info.did, info.device_count)
|
|
216
|
+
```
|
|
217
|
+
"""
|
|
218
|
+
bundle = parse_identity_bundle(path)
|
|
219
|
+
pk_hex = bundle.get("public_key_hex") or bundle.get("publicKeyHex", "")
|
|
220
|
+
chain = bundle.get("attestation_chain", [])
|
|
221
|
+
return IdentityBundleInfo(
|
|
222
|
+
did=bundle.get("did", ""),
|
|
223
|
+
public_key_hex=pk_hex,
|
|
224
|
+
label=bundle.get("label"),
|
|
225
|
+
device_count=len(chain),
|
|
226
|
+
)
|
auths/commit.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Git commit signing — SSHSIG PEM output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CommitSigningResult:
|
|
10
|
+
"""Result of signing git commit/tag data.
|
|
11
|
+
|
|
12
|
+
The `.signature_pem` is a valid SSHSIG PEM block that can be used with
|
|
13
|
+
`git verify-commit` or written to a signature file.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
signature_pem: str
|
|
17
|
+
"""SSHSIG PEM block suitable for `git verify-commit`."""
|
|
18
|
+
method: str
|
|
19
|
+
"""Signing method used (e.g. `"ssh-ed25519"`)."""
|
|
20
|
+
namespace: str
|
|
21
|
+
"""SSH namespace for the signature (e.g. `"git"`)."""
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
pem_preview = self.signature_pem[:40] + "..." if len(self.signature_pem) > 40 else self.signature_pem
|
|
25
|
+
return (
|
|
26
|
+
f"CommitSigningResult(method='{self.method}', "
|
|
27
|
+
f"pem='{pem_preview}')"
|
|
28
|
+
)
|