oneid 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oneid-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: oneid
3
+ Version: 0.1.0
4
+ Summary: Hardware-anchored identity SDK for AI agents -- 1id.com
5
+ Author-email: Christopher Drake <chris@1id.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://1id.com
8
+ Project-URL: Documentation, https://1id.com/enroll.md
9
+ Project-URL: Repository, https://github.com/AuraFriday/oneid-sdk
10
+ Project-URL: Issues, https://github.com/AuraFriday/oneid-sdk/issues
11
+ Keywords: identity,ai,agent,tpm,hardware,oidc,oauth2,sybil
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Security :: Cryptography
22
+ Classifier: Topic :: System :: Hardware
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: httpx>=0.24.0
26
+ Requires-Dist: cryptography>=41.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
31
+ Requires-Dist: mypy>=1.0; extra == "dev"
32
+
33
+ # oneid-sdk
34
+
35
+ Python SDK for [1id.com](https://1id.com) -- hardware-anchored identity for AI agents.
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ import oneid
41
+
42
+ # Enroll at declared tier (no HSM needed, always works)
43
+ identity = oneid.enroll(request_tier="declared")
44
+ print(f"Enrolled: {identity.handle}")
45
+
46
+ # Get an OAuth2 token for API access
47
+ token = oneid.get_token()
48
+ headers = {"Authorization": token.authorization_header_value}
49
+
50
+ # Check identity
51
+ me = oneid.whoami()
52
+ print(f"I am {me.handle}, trust tier: {me.trust_tier.value}")
53
+ ```
54
+
55
+ ## Trust tiers
56
+
57
+ | Tier | Hardware | Sybil resistance |
58
+ |------|----------|-----------------|
59
+ | `sovereign` | TPM (discrete/firmware) | Highest -- manufacturer-attested |
60
+ | `sovereign-portable` | YubiKey/Nitrokey | High -- manufacturer-attested |
61
+ | `declared` | None (software keys) | Lowest -- self-asserted |
62
+
63
+ `request_tier` is a **requirement**, not a preference. You get exactly what you ask for, or an exception. No silent fallbacks.
64
+
65
+ ## Key algorithms
66
+
67
+ Like SSH, agents can choose their preferred key algorithm for declared-tier enrollment:
68
+
69
+ ```python
70
+ identity = oneid.enroll(request_tier="declared", key_algorithm="ed25519") # default, strongest
71
+ identity = oneid.enroll(request_tier="declared", key_algorithm="ecdsa-p384") # NIST P-384
72
+ identity = oneid.enroll(request_tier="declared", key_algorithm="rsa-4096") # legacy compat
73
+ ```
74
+
75
+ ## Installation
76
+
77
+ ```bash
78
+ pip install oneid-sdk
79
+ ```
80
+
81
+ Requires Python 3.10+.
82
+
83
+ ## License
84
+
85
+ Apache-2.0
oneid-0.1.0/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # oneid-sdk
2
+
3
+ Python SDK for [1id.com](https://1id.com) -- hardware-anchored identity for AI agents.
4
+
5
+ ## Quick start
6
+
7
+ ```python
8
+ import oneid
9
+
10
+ # Enroll at declared tier (no HSM needed, always works)
11
+ identity = oneid.enroll(request_tier="declared")
12
+ print(f"Enrolled: {identity.handle}")
13
+
14
+ # Get an OAuth2 token for API access
15
+ token = oneid.get_token()
16
+ headers = {"Authorization": token.authorization_header_value}
17
+
18
+ # Check identity
19
+ me = oneid.whoami()
20
+ print(f"I am {me.handle}, trust tier: {me.trust_tier.value}")
21
+ ```
22
+
23
+ ## Trust tiers
24
+
25
+ | Tier | Hardware | Sybil resistance |
26
+ |------|----------|-----------------|
27
+ | `sovereign` | TPM (discrete/firmware) | Highest -- manufacturer-attested |
28
+ | `sovereign-portable` | YubiKey/Nitrokey | High -- manufacturer-attested |
29
+ | `declared` | None (software keys) | Lowest -- self-asserted |
30
+
31
+ `request_tier` is a **requirement**, not a preference. You get exactly what you ask for, or an exception. No silent fallbacks.
32
+
33
+ ## Key algorithms
34
+
35
+ Like SSH, agents can choose their preferred key algorithm for declared-tier enrollment:
36
+
37
+ ```python
38
+ identity = oneid.enroll(request_tier="declared", key_algorithm="ed25519") # default, strongest
39
+ identity = oneid.enroll(request_tier="declared", key_algorithm="ecdsa-p384") # NIST P-384
40
+ identity = oneid.enroll(request_tier="declared", key_algorithm="rsa-4096") # legacy compat
41
+ ```
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install oneid-sdk
47
+ ```
48
+
49
+ Requires Python 3.10+.
50
+
51
+ ## License
52
+
53
+ Apache-2.0
@@ -0,0 +1,154 @@
1
+ """
2
+ 1id.com SDK -- Hardware-anchored identity for AI agents.
3
+
4
+ Quick start:
5
+
6
+ import oneid
7
+
8
+ # Enroll at declared tier (no HSM, always works)
9
+ identity = oneid.enroll(request_tier="declared")
10
+ print(f"Enrolled as {identity.handle}")
11
+
12
+ # Get an OAuth2 token for authentication
13
+ token = oneid.get_token()
14
+ print(f"Bearer {token.access_token}")
15
+
16
+ # Check current identity
17
+ identity = oneid.whoami()
18
+
19
+ Trust tiers (request_tier parameter):
20
+ 'sovereign' -- TPM hardware, manufacturer-attested
21
+ 'sovereign-portable' -- YubiKey/Nitrokey, manufacturer-attested
22
+ 'declared' -- Software keys, no hardware proof
23
+
24
+ CRITICAL: request_tier is a REQUIREMENT, not a preference.
25
+ You get exactly what you ask for, or an exception. No fallbacks.
26
+ """
27
+
28
+ from .auth import clear_cached_token, get_token
29
+ from .credentials import credentials_exist, load_credentials
30
+ from .enroll import enroll
31
+ from .exceptions import (
32
+ AlreadyEnrolledError,
33
+ AuthenticationError,
34
+ BinaryNotFoundError,
35
+ EnrollmentError,
36
+ HandleInvalidError,
37
+ HandleRetiredError,
38
+ HandleTakenError,
39
+ HSMAccessError,
40
+ NetworkError,
41
+ NoHSMError,
42
+ NotEnrolledError,
43
+ OneIDError,
44
+ UACDeniedError,
45
+ )
46
+ from .identity import (
47
+ DEFAULT_KEY_ALGORITHM,
48
+ HSMType,
49
+ Identity,
50
+ KeyAlgorithm,
51
+ Token,
52
+ TrustTier,
53
+ )
54
+ from .keys import sign_challenge_with_private_key
55
+ from ._version import __version__
56
+
57
+
58
+ def whoami() -> Identity:
59
+ """Check the current enrolled identity.
60
+
61
+ Reads the local credentials file and returns the identity information
62
+ stored during enrollment. Does NOT make a network request.
63
+
64
+ For a network-verified identity check, use the server API directly.
65
+
66
+ Returns:
67
+ Identity: The enrolled identity.
68
+
69
+ Raises:
70
+ NotEnrolledError: If no credentials exist (call enroll() first).
71
+ """
72
+ from datetime import datetime, timezone
73
+
74
+ creds = load_credentials()
75
+
76
+ try:
77
+ trust_tier = TrustTier(creds.trust_tier)
78
+ except ValueError:
79
+ trust_tier = TrustTier.DECLARED
80
+
81
+ try:
82
+ key_algorithm = KeyAlgorithm(creds.key_algorithm)
83
+ except ValueError:
84
+ key_algorithm = DEFAULT_KEY_ALGORITHM
85
+
86
+ try:
87
+ enrolled_at = datetime.fromisoformat(creds.enrolled_at.replace("Z", "+00:00")) if creds.enrolled_at else datetime.now(timezone.utc)
88
+ except (ValueError, AttributeError):
89
+ enrolled_at = datetime.now(timezone.utc)
90
+
91
+ internal_id = creds.client_id
92
+ handle = f"@{internal_id}" if not internal_id.startswith("@") else internal_id
93
+
94
+ # Determine HSM type from credentials
95
+ hsm_type: HSMType | None = None
96
+ if creds.private_key_pem is not None:
97
+ hsm_type = HSMType.SOFTWARE
98
+ elif creds.hsm_key_reference is not None:
99
+ hsm_type = HSMType.TPM # Could also be YubiKey, but we'd need more info
100
+
101
+ return Identity(
102
+ internal_id=internal_id,
103
+ handle=handle,
104
+ trust_tier=trust_tier,
105
+ hsm_type=hsm_type,
106
+ hsm_manufacturer=None,
107
+ enrolled_at=enrolled_at,
108
+ device_count=1 if creds.hsm_key_reference else 0,
109
+ key_algorithm=key_algorithm,
110
+ )
111
+
112
+
113
+ def refresh() -> None:
114
+ """Force-refresh the cached OAuth2 token.
115
+
116
+ Discards the in-memory cached token and fetches a new one
117
+ on the next get_token() call.
118
+ """
119
+ clear_cached_token()
120
+
121
+
122
+ # -- Public API --
123
+ __all__ = [
124
+ # Core functions
125
+ "enroll",
126
+ "get_token",
127
+ "whoami",
128
+ "refresh",
129
+ "credentials_exist",
130
+ "sign_challenge_with_private_key",
131
+ # Data types
132
+ "Identity",
133
+ "Token",
134
+ "TrustTier",
135
+ "KeyAlgorithm",
136
+ "HSMType",
137
+ "DEFAULT_KEY_ALGORITHM",
138
+ # Exceptions (all importable from oneid directly)
139
+ "OneIDError",
140
+ "EnrollmentError",
141
+ "NoHSMError",
142
+ "UACDeniedError",
143
+ "HSMAccessError",
144
+ "AlreadyEnrolledError",
145
+ "HandleTakenError",
146
+ "HandleInvalidError",
147
+ "HandleRetiredError",
148
+ "AuthenticationError",
149
+ "NetworkError",
150
+ "NotEnrolledError",
151
+ "BinaryNotFoundError",
152
+ # Version
153
+ "__version__",
154
+ ]
@@ -0,0 +1,3 @@
1
+ """Version string for the oneid-sdk package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,331 @@
1
+ """
2
+ OAuth2 token management for the 1id.com SDK.
3
+
4
+ After enrollment, agents authenticate using standard OAuth2
5
+ client_credentials grant. No TPM operations needed for daily use.
6
+
7
+ This module handles:
8
+ - Token acquisition (client_credentials grant)
9
+ - Token caching (in-memory, with expiry awareness)
10
+ - Token refresh
11
+ - Authorization header formatting
12
+
13
+ The token endpoint is Keycloak:
14
+ POST https://1id.com/realms/agents/protocol/openid-connect/token
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import time
21
+ from datetime import datetime, timedelta, timezone
22
+
23
+ import httpx
24
+
25
+ from .credentials import StoredCredentials, load_credentials
26
+ from .exceptions import AuthenticationError, NetworkError, NotEnrolledError
27
+ from .identity import Token
28
+
29
+ logger = logging.getLogger("oneid.auth")
30
+
31
+ # -- Configuration --
32
+ TOKEN_REFRESH_MARGIN_SECONDS = 60 # Refresh tokens this many seconds before expiry
33
+ TOKEN_REQUEST_TIMEOUT_SECONDS = 15.0
34
+
35
+ # -- Module-level token cache --
36
+ _cached_token: Token | None = None
37
+
38
+
39
+ def get_token(
40
+ force_refresh: bool = False,
41
+ credentials: StoredCredentials | None = None,
42
+ ) -> Token:
43
+ """Get a valid OAuth2 access token, refreshing if needed.
44
+
45
+ This is the primary authentication method for daily use. It uses
46
+ the OAuth2 client_credentials grant with the credentials stored
47
+ during enrollment.
48
+
49
+ Tokens are cached in memory and automatically refreshed when they
50
+ are within TOKEN_REFRESH_MARGIN_SECONDS of expiry.
51
+
52
+ Args:
53
+ force_refresh: If True, always fetch a new token even if the
54
+ cached one is still valid.
55
+ credentials: Optional pre-loaded credentials. If None, loads
56
+ from the credentials file.
57
+
58
+ Returns:
59
+ A valid Token object.
60
+
61
+ Raises:
62
+ NotEnrolledError: If no credentials file exists.
63
+ AuthenticationError: If the token request fails.
64
+ NetworkError: If the token endpoint cannot be reached.
65
+ """
66
+ global _cached_token
67
+
68
+ # Check if cached token is still valid (with margin)
69
+ if not force_refresh and _cached_token is not None:
70
+ margin = timedelta(seconds=TOKEN_REFRESH_MARGIN_SECONDS)
71
+ if datetime.now(timezone.utc) + margin < _cached_token.expires_at:
72
+ return _cached_token
73
+
74
+ # Load credentials
75
+ if credentials is None:
76
+ credentials = load_credentials()
77
+
78
+ # Request a new token
79
+ token = _request_token_from_keycloak(credentials)
80
+ _cached_token = token
81
+
82
+ return token
83
+
84
+
85
+ def _request_token_from_keycloak(credentials: StoredCredentials) -> Token:
86
+ """Request a new access token from Keycloak using client_credentials grant.
87
+
88
+ Args:
89
+ credentials: The stored enrollment credentials.
90
+
91
+ Returns:
92
+ A new Token object.
93
+
94
+ Raises:
95
+ AuthenticationError: If the token request fails (401, 403, etc.).
96
+ NetworkError: If the token endpoint cannot be reached.
97
+ """
98
+ token_endpoint = credentials.token_endpoint
99
+
100
+ request_body = {
101
+ "grant_type": "client_credentials",
102
+ "client_id": credentials.client_id,
103
+ "client_secret": credentials.client_secret,
104
+ }
105
+
106
+ try:
107
+ with httpx.Client(timeout=TOKEN_REQUEST_TIMEOUT_SECONDS) as http_client:
108
+ response = http_client.post(
109
+ token_endpoint,
110
+ data=request_body,
111
+ headers={
112
+ "Content-Type": "application/x-www-form-urlencoded",
113
+ "User-Agent": "oneid-sdk-python/0.1.0",
114
+ },
115
+ )
116
+ except httpx.ConnectError as connection_error:
117
+ raise NetworkError(
118
+ f"Could not connect to token endpoint {token_endpoint}: {connection_error}"
119
+ ) from connection_error
120
+ except httpx.TimeoutException as timeout_error:
121
+ raise NetworkError(
122
+ f"Token request to {token_endpoint} timed out: {timeout_error}"
123
+ ) from timeout_error
124
+ except httpx.HTTPError as http_error:
125
+ raise NetworkError(
126
+ f"HTTP error requesting token from {token_endpoint}: {http_error}"
127
+ ) from http_error
128
+
129
+ if response.status_code != 200:
130
+ # Keycloak returns error details in JSON
131
+ try:
132
+ error_body = response.json()
133
+ error_description = error_body.get("error_description", error_body.get("error", "Unknown error"))
134
+ except Exception:
135
+ error_description = f"HTTP {response.status_code}: {response.text[:200]}"
136
+
137
+ raise AuthenticationError(
138
+ f"Token request failed: {error_description}"
139
+ )
140
+
141
+ try:
142
+ token_response = response.json()
143
+ except Exception as json_error:
144
+ raise AuthenticationError(
145
+ f"Invalid JSON in token response: {json_error}"
146
+ ) from json_error
147
+
148
+ # Parse the standard OAuth2 token response
149
+ access_token = token_response.get("access_token")
150
+ if not access_token:
151
+ raise AuthenticationError("Token response missing 'access_token' field")
152
+
153
+ expires_in_seconds = token_response.get("expires_in", 3600)
154
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)
155
+
156
+ return Token(
157
+ access_token=access_token,
158
+ token_type=token_response.get("token_type", "Bearer"),
159
+ expires_at=expires_at,
160
+ refresh_token=token_response.get("refresh_token"),
161
+ )
162
+
163
+
164
+ def clear_cached_token() -> None:
165
+ """Clear the in-memory cached token.
166
+
167
+ Useful for testing or when credentials have changed.
168
+ """
169
+ global _cached_token
170
+ _cached_token = None
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # TPM-backed passwordless authentication (sovereign/virtual tier)
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def authenticate_with_tpm(
178
+ identity_id: str | None = None,
179
+ ak_handle: str | None = None,
180
+ api_base_url: str | None = None,
181
+ credentials: StoredCredentials | None = None,
182
+ ) -> Token:
183
+ """Authenticate using the TPM -- passwordless, zero-elevation sign-in.
184
+
185
+ This is the "OAuth for agents" flow:
186
+ 1. Requests a challenge nonce from the server
187
+ 2. Signs it with the TPM AK (no elevation needed)
188
+ 3. Sends the signature back to the server
189
+ 4. Server verifies and issues a JWT
190
+
191
+ No passwords, no client_secret transmitted, no UAC prompt.
192
+ The AK private key never leaves the TPM chip.
193
+
194
+ Args:
195
+ identity_id: The 1id internal ID. If None, loaded from credentials.
196
+ ak_handle: The AK persistent handle (hex). If None, loaded from credentials.
197
+ api_base_url: Base URL for the 1id API. If None, loaded from credentials.
198
+ credentials: Pre-loaded credentials. If None, loaded from file.
199
+
200
+ Returns:
201
+ A valid Token object.
202
+
203
+ Raises:
204
+ NotEnrolledError: If no credentials file exists.
205
+ AuthenticationError: If the challenge-response fails.
206
+ NetworkError: If the server cannot be reached.
207
+ """
208
+ global _cached_token
209
+
210
+ # Load credentials if not provided
211
+ if credentials is None:
212
+ credentials = load_credentials()
213
+
214
+ if identity_id is None:
215
+ identity_id = credentials.client_id # client_id IS the identity ID
216
+
217
+ if ak_handle is None:
218
+ ak_handle = credentials.hsm_key_reference
219
+ if not ak_handle:
220
+ raise AuthenticationError(
221
+ "No AK handle found in credentials. TPM authentication requires "
222
+ "a sovereign or virtual tier enrollment with a TPM."
223
+ )
224
+
225
+ if api_base_url is None:
226
+ api_base_url = credentials.api_base_url
227
+
228
+ # Step 1: Request a challenge nonce from the server
229
+ challenge_url = f"{api_base_url}/api/v1/auth/challenge"
230
+
231
+ try:
232
+ with httpx.Client(timeout=TOKEN_REQUEST_TIMEOUT_SECONDS) as http_client:
233
+ challenge_response = http_client.post(
234
+ challenge_url,
235
+ json={"identity_id": identity_id},
236
+ headers={"User-Agent": "oneid-sdk-python/0.1.0"},
237
+ )
238
+ except httpx.ConnectError as connection_error:
239
+ raise NetworkError(
240
+ f"Could not connect to {challenge_url}: {connection_error}"
241
+ ) from connection_error
242
+ except httpx.TimeoutException as timeout_error:
243
+ raise NetworkError(
244
+ f"Challenge request to {challenge_url} timed out: {timeout_error}"
245
+ ) from timeout_error
246
+
247
+ if challenge_response.status_code != 200:
248
+ try:
249
+ error_body = challenge_response.json()
250
+ error_msg = error_body.get("error", {}).get("message", f"HTTP {challenge_response.status_code}")
251
+ except Exception:
252
+ error_msg = f"HTTP {challenge_response.status_code}"
253
+ raise AuthenticationError(f"Challenge request failed: {error_msg}")
254
+
255
+ challenge_data = challenge_response.json().get("data", {})
256
+ challenge_id = challenge_data.get("challenge_id")
257
+ nonce_b64 = challenge_data.get("nonce_b64")
258
+
259
+ if not challenge_id or not nonce_b64:
260
+ raise AuthenticationError("Server returned incomplete challenge response")
261
+
262
+ logger.debug("Received auth challenge: %s", challenge_id)
263
+
264
+ # Step 2: Sign the nonce with the TPM AK (NO elevation needed)
265
+ from .helper import sign_challenge_with_tpm
266
+
267
+ sign_result = sign_challenge_with_tpm(nonce_b64=nonce_b64, ak_handle=ak_handle)
268
+ signature_b64 = sign_result.get("signature_b64", "")
269
+
270
+ if not signature_b64:
271
+ raise AuthenticationError("TPM signing returned empty signature")
272
+
273
+ logger.debug("Nonce signed successfully, verifying with server...")
274
+
275
+ # Step 3: Send the signature to the server for verification
276
+ verify_url = f"{api_base_url}/api/v1/auth/verify"
277
+
278
+ try:
279
+ with httpx.Client(timeout=TOKEN_REQUEST_TIMEOUT_SECONDS) as http_client:
280
+ verify_response = http_client.post(
281
+ verify_url,
282
+ json={
283
+ "challenge_id": challenge_id,
284
+ "signature_b64": signature_b64,
285
+ },
286
+ headers={"User-Agent": "oneid-sdk-python/0.1.0"},
287
+ )
288
+ except httpx.ConnectError as connection_error:
289
+ raise NetworkError(
290
+ f"Could not connect to {verify_url}: {connection_error}"
291
+ ) from connection_error
292
+ except httpx.TimeoutException as timeout_error:
293
+ raise NetworkError(
294
+ f"Verify request to {verify_url} timed out: {timeout_error}"
295
+ ) from timeout_error
296
+
297
+ if verify_response.status_code != 200:
298
+ try:
299
+ error_body = verify_response.json()
300
+ error_msg = error_body.get("error", {}).get("message", f"HTTP {verify_response.status_code}")
301
+ except Exception:
302
+ error_msg = f"HTTP {verify_response.status_code}"
303
+ raise AuthenticationError(f"TPM authentication failed: {error_msg}")
304
+
305
+ verify_data = verify_response.json().get("data", {})
306
+
307
+ if not verify_data.get("authenticated"):
308
+ raise AuthenticationError("Server did not confirm authentication")
309
+
310
+ # Extract token from response
311
+ tokens = verify_data.get("tokens")
312
+ if tokens and tokens.get("access_token"):
313
+ expires_in_seconds = tokens.get("expires_in", 3600)
314
+ token = Token(
315
+ access_token=tokens["access_token"],
316
+ token_type=tokens.get("token_type", "Bearer"),
317
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds),
318
+ refresh_token=tokens.get("refresh_token"),
319
+ )
320
+ _cached_token = token
321
+ logger.info(
322
+ "TPM authentication successful for %s (handle: %s)",
323
+ identity_id,
324
+ verify_data.get("identity", {}).get("handle", "?"),
325
+ )
326
+ return token
327
+ else:
328
+ raise AuthenticationError(
329
+ "TPM signature verified but no tokens were issued. "
330
+ "The Keycloak token endpoint may be unavailable."
331
+ )