grantex-gemma 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.
- grantex_gemma-0.1.0/.gitignore +52 -0
- grantex_gemma-0.1.0/PKG-INFO +499 -0
- grantex_gemma-0.1.0/README.md +464 -0
- grantex_gemma-0.1.0/pyproject.toml +75 -0
- grantex_gemma-0.1.0/src/grantex_gemma/__init__.py +66 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_audit_log.py +312 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_bundle_storage.py +157 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_consent_bundle.py +110 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_errors.py +39 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_hash_chain.py +70 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_scope_enforcer.py +25 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_sync.py +12 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_types.py +78 -0
- grantex_gemma-0.1.0/src/grantex_gemma/_verifier.py +203 -0
- grantex_gemma-0.1.0/src/grantex_gemma/py.typed +0 -0
- grantex_gemma-0.1.0/tests/conftest.py +151 -0
- grantex_gemma-0.1.0/tests/e2e/__init__.py +0 -0
- grantex_gemma-0.1.0/tests/e2e/test_production.py +633 -0
- grantex_gemma-0.1.0/tests/test_audit_log.py +217 -0
- grantex_gemma-0.1.0/tests/test_consent_bundle.py +166 -0
- grantex_gemma-0.1.0/tests/test_hash_chain.py +128 -0
- grantex_gemma-0.1.0/tests/test_security.py +261 -0
- grantex_gemma-0.1.0/tests/test_verifier.py +266 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# OS
|
|
2
|
+
.DS_Store
|
|
3
|
+
Thumbs.db
|
|
4
|
+
|
|
5
|
+
# Editors
|
|
6
|
+
.vscode/
|
|
7
|
+
.idea/
|
|
8
|
+
*.swp
|
|
9
|
+
*.swo
|
|
10
|
+
|
|
11
|
+
# Environment
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
# Claude Code
|
|
17
|
+
.claude/
|
|
18
|
+
|
|
19
|
+
# Node
|
|
20
|
+
node_modules/
|
|
21
|
+
|
|
22
|
+
# Python
|
|
23
|
+
__pycache__/
|
|
24
|
+
*.pyc
|
|
25
|
+
*.pyo
|
|
26
|
+
|
|
27
|
+
# Build outputs
|
|
28
|
+
dist/
|
|
29
|
+
build/
|
|
30
|
+
|
|
31
|
+
# Firebase
|
|
32
|
+
.firebase/
|
|
33
|
+
web/dashboard/
|
|
34
|
+
|
|
35
|
+
# Generated PH gallery screenshots
|
|
36
|
+
web/ph-gallery/
|
|
37
|
+
|
|
38
|
+
# Marketing content (local only)
|
|
39
|
+
docs/marketing/
|
|
40
|
+
LAUNCH_PLAYBOOK.md
|
|
41
|
+
LAUNCH_POSTS.md
|
|
42
|
+
DEVTO_ARTICLE.md
|
|
43
|
+
|
|
44
|
+
# Recordings (local only)
|
|
45
|
+
recordings/
|
|
46
|
+
|
|
47
|
+
# Coverage & caches
|
|
48
|
+
coverage/
|
|
49
|
+
.cache/
|
|
50
|
+
.mypy_cache/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
*.tsbuildinfo
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grantex-gemma
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline authorization for Gemma 4 on-device AI agents
|
|
5
|
+
Project-URL: Homepage, https://grantex.dev
|
|
6
|
+
Project-URL: Documentation, https://docs.grantex.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/mishrasanjeev/grantex
|
|
8
|
+
Project-URL: Issues, https://github.com/mishrasanjeev/grantex/issues
|
|
9
|
+
Author: Sanjeev Kumar
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: ai-agents,audit,authorization,gemma,grantex,hash-chain,jwt,offline-auth,on-device
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: cryptography>=41
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: pyjwt>=2.8
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.9; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# grantex-gemma
|
|
37
|
+
|
|
38
|
+
Offline authorization for Google Gemma on-device AI agents. Issue consent bundles online, verify grant tokens and enforce scopes entirely offline, and sync tamper-evident audit logs back to the Grantex cloud when connectivity returns.
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/grantex-gemma/)
|
|
41
|
+
[](https://pypi.org/project/grantex-gemma/)
|
|
42
|
+
[](https://github.com/mishrasanjeev/grantex/blob/main/LICENSE)
|
|
43
|
+
[](https://pypi.org/project/grantex-gemma/)
|
|
44
|
+
|
|
45
|
+
> **[Homepage](https://grantex.dev)** | **[Docs](https://docs.grantex.dev/integrations/gemma)** | **[API Reference](https://docs.grantex.dev/api-reference)** | **[GitHub](https://github.com/mishrasanjeev/grantex)** | **[Sign Up Free](https://grantex.dev/dashboard/signup)**
|
|
46
|
+
|
|
47
|
+
## What is grantex-gemma?
|
|
48
|
+
|
|
49
|
+
When you run Gemma 4 on a Raspberry Pi, NVIDIA Jetson, or any server-side device, the model often needs to act on behalf of a user — reading contacts, sending messages, accessing calendars. But these devices go offline. WiFi drops, cellular is spotty, and edge deployments may only sync once a day.
|
|
50
|
+
|
|
51
|
+
**grantex-gemma** solves this with a three-phase offline authorization model:
|
|
52
|
+
|
|
53
|
+
1. **Online** — While connected, your agent requests a *consent bundle* from the Grantex API. The bundle contains a signed grant token, a JWKS snapshot for offline verification, and an Ed25519 key pair for signing audit entries.
|
|
54
|
+
|
|
55
|
+
2. **Offline** — The agent verifies the grant token locally (RS256 against the JWKS snapshot), enforces scopes, and logs every action to a tamper-evident, hash-chained audit file. No network required.
|
|
56
|
+
|
|
57
|
+
3. **Sync** — When connectivity returns, the agent uploads the signed audit log to the Grantex cloud. The server verifies the hash chain, checks for revocations, and optionally issues a refreshed bundle.
|
|
58
|
+
|
|
59
|
+
Everything is cryptographically verifiable. Grant tokens are standard Grantex JWTs (RS256). Audit entries are Ed25519-signed and SHA-256 hash-chained. Bundles are encrypted at rest with AES-256-GCM.
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install grantex-gemma
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With development dependencies:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install grantex-gemma[dev]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Requirements:** Python 3.9+, [httpx](https://www.python-httpx.org/), [PyJWT](https://pyjwt.readthedocs.io/), [cryptography](https://cryptography.io/)
|
|
74
|
+
|
|
75
|
+
## Quick Start
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio
|
|
79
|
+
from grantex_gemma import (
|
|
80
|
+
create_consent_bundle,
|
|
81
|
+
create_offline_verifier,
|
|
82
|
+
create_offline_audit_log,
|
|
83
|
+
store_bundle,
|
|
84
|
+
load_bundle,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
ENCRYPTION_KEY = "a1b2c3..." # 64-char hex string (256-bit key)
|
|
88
|
+
|
|
89
|
+
async def main():
|
|
90
|
+
# ── Phase 1: Online — Issue a consent bundle ────────────────────
|
|
91
|
+
bundle = await create_consent_bundle(
|
|
92
|
+
api_key="gx_dev_...",
|
|
93
|
+
agent_id="did:web:my-agent.example.com",
|
|
94
|
+
user_id="did:web:alice.example.com",
|
|
95
|
+
scopes=["read:contacts", "write:calendar"],
|
|
96
|
+
offline_ttl="72h", # Bundle valid for 72 hours offline
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Persist to disk with AES-256-GCM encryption
|
|
100
|
+
store_bundle(bundle, "/data/grantex/bundle.enc", ENCRYPTION_KEY)
|
|
101
|
+
|
|
102
|
+
# ── Phase 2: Offline — Verify tokens and log actions ────────────
|
|
103
|
+
bundle = load_bundle("/data/grantex/bundle.enc", ENCRYPTION_KEY)
|
|
104
|
+
|
|
105
|
+
verifier = create_offline_verifier(
|
|
106
|
+
bundle.jwks_snapshot,
|
|
107
|
+
require_scopes=["read:contacts"],
|
|
108
|
+
max_delegation_depth=2,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Verify the grant token — pure local crypto, no HTTP call
|
|
112
|
+
grant = await verifier.verify(bundle.grant_token)
|
|
113
|
+
print(f"Authorized: {grant.agent_did} for {grant.scopes}")
|
|
114
|
+
|
|
115
|
+
# Create an append-only, hash-chained audit log
|
|
116
|
+
audit = create_offline_audit_log(
|
|
117
|
+
bundle.offline_audit_key,
|
|
118
|
+
log_path="/data/grantex/audit.jsonl",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Log every action the agent performs
|
|
122
|
+
entry = await audit.append("read:contacts", grant, "success", {"count": 42})
|
|
123
|
+
print(f"Logged entry #{entry.seq}, hash: {entry.hash[:16]}...")
|
|
124
|
+
|
|
125
|
+
# ── Phase 3: Online — Sync audit log ────────────────────────────
|
|
126
|
+
result = await audit.sync(
|
|
127
|
+
endpoint=bundle.sync_endpoint,
|
|
128
|
+
api_key="gx_dev_...",
|
|
129
|
+
bundle_id=bundle.bundle_id,
|
|
130
|
+
)
|
|
131
|
+
print(f"Synced: {result.accepted} accepted, {result.rejected} rejected")
|
|
132
|
+
|
|
133
|
+
# If the grant was revoked while offline, stop the agent
|
|
134
|
+
if result.revocation_status == "revoked":
|
|
135
|
+
print("Grant revoked — halting agent")
|
|
136
|
+
|
|
137
|
+
asyncio.run(main())
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Architecture
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
144
|
+
│ PHASE 1: ONLINE │
|
|
145
|
+
│ │
|
|
146
|
+
│ Device ──POST /v1/consent-bundles──▶ Grantex API │
|
|
147
|
+
│ │ │
|
|
148
|
+
│ store_bundle(AES-256-GCM) │
|
|
149
|
+
│ ▼ │
|
|
150
|
+
│ [encrypted file on disk] │
|
|
151
|
+
├──────────────────────────────────────────────────────────────┤
|
|
152
|
+
│ PHASE 2: OFFLINE │
|
|
153
|
+
│ │
|
|
154
|
+
│ load_bundle() ──▶ OfflineVerifier.verify(token) │
|
|
155
|
+
│ │ RS256 + JWKS snapshot │
|
|
156
|
+
│ ▼ │
|
|
157
|
+
│ VerifiedGrant ──▶ AuditLog.append() │
|
|
158
|
+
│ Ed25519 + SHA-256 chain │
|
|
159
|
+
│ ▼ │
|
|
160
|
+
│ [audit.jsonl] │
|
|
161
|
+
├──────────────────────────────────────────────────────────────┤
|
|
162
|
+
│ PHASE 3: SYNC │
|
|
163
|
+
│ │
|
|
164
|
+
│ AuditLog.sync() ──POST batches──▶ Grantex API │
|
|
165
|
+
│ ▼ │
|
|
166
|
+
│ SyncResult │
|
|
167
|
+
│ ├─ accepted / rejected │
|
|
168
|
+
│ ├─ revocation_status │
|
|
169
|
+
│ └─ new_bundle (optional) │
|
|
170
|
+
└──────────────────────────────────────────────────────────────┘
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## API Reference
|
|
174
|
+
|
|
175
|
+
### `create_offline_verifier(jwks_snapshot, ...) -> OfflineVerifier`
|
|
176
|
+
|
|
177
|
+
Create an offline JWT verifier for on-device use.
|
|
178
|
+
|
|
179
|
+
| Parameter | Type | Default | Description |
|
|
180
|
+
|-----------|------|---------|-------------|
|
|
181
|
+
| `jwks_snapshot` | `JWKSSnapshot` | required | Pre-fetched JWKS keys for RS256 verification |
|
|
182
|
+
| `clock_skew_seconds` | `int` | `30` | Allowable clock drift in seconds |
|
|
183
|
+
| `require_scopes` | `list[str] \| None` | `None` | Scopes that must be present on every token |
|
|
184
|
+
| `max_delegation_depth` | `int \| None` | `None` | Maximum delegation chain depth allowed |
|
|
185
|
+
| `on_scope_violation` | `str` | `"throw"` | `"throw"` raises `ScopeViolationError`, `"log"` warns |
|
|
186
|
+
|
|
187
|
+
Returns an `OfflineVerifier` with a single method:
|
|
188
|
+
|
|
189
|
+
- **`async verify(token: str) -> VerifiedGrant`** — Verify a JWT and return the decoded grant. Raises `OfflineVerificationError`, `TokenExpiredError`, or `ScopeViolationError`.
|
|
190
|
+
|
|
191
|
+
### `create_consent_bundle(api_key, agent_id, user_id, scopes, ...) -> ConsentBundle`
|
|
192
|
+
|
|
193
|
+
Request a consent bundle from the Grantex API (requires network).
|
|
194
|
+
|
|
195
|
+
| Parameter | Type | Default | Description |
|
|
196
|
+
|-----------|------|---------|-------------|
|
|
197
|
+
| `api_key` | `str` | required | Grantex developer API key |
|
|
198
|
+
| `agent_id` | `str` | required | Agent DID or identifier |
|
|
199
|
+
| `user_id` | `str` | required | Principal (user) identifier |
|
|
200
|
+
| `scopes` | `list[str]` | required | Requested authorization scopes |
|
|
201
|
+
| `offline_ttl` | `str` | `"72h"` | How long the bundle is valid offline |
|
|
202
|
+
| `base_url` | `str` | `"https://api.grantex.dev"` | Grantex API base URL |
|
|
203
|
+
|
|
204
|
+
### `store_bundle(bundle, path, encryption_key) -> None`
|
|
205
|
+
|
|
206
|
+
Encrypt and write a consent bundle to disk using AES-256-GCM.
|
|
207
|
+
|
|
208
|
+
| Parameter | Type | Description |
|
|
209
|
+
|-----------|------|-------------|
|
|
210
|
+
| `bundle` | `ConsentBundle` | The bundle to encrypt and store |
|
|
211
|
+
| `path` | `str` | File path for the encrypted bundle |
|
|
212
|
+
| `encryption_key` | `str` | Hex-encoded 256-bit key (64 hex characters) |
|
|
213
|
+
|
|
214
|
+
### `load_bundle(path, encryption_key) -> ConsentBundle`
|
|
215
|
+
|
|
216
|
+
Load and decrypt a consent bundle from disk. Raises `BundleTamperedError` if decryption or integrity check fails. Raises `FileNotFoundError` if the file does not exist.
|
|
217
|
+
|
|
218
|
+
### `create_offline_audit_log(signing_key, log_path, ...) -> OfflineAuditLog`
|
|
219
|
+
|
|
220
|
+
Create an append-only, Ed25519-signed, hash-chained audit log backed by a JSONL file.
|
|
221
|
+
|
|
222
|
+
| Parameter | Type | Default | Description |
|
|
223
|
+
|-----------|------|---------|-------------|
|
|
224
|
+
| `signing_key` | `OfflineAuditKey` | required | Ed25519 key pair for signing entries |
|
|
225
|
+
| `log_path` | `str` | required | Path to the JSONL log file |
|
|
226
|
+
| `max_size_mb` | `int` | `50` | Maximum log file size in MB before rotation |
|
|
227
|
+
| `rotate_on_size` | `bool` | `True` | Whether to rotate when size limit is reached |
|
|
228
|
+
|
|
229
|
+
**`OfflineAuditLog` methods:**
|
|
230
|
+
|
|
231
|
+
- **`async append(action, grant, result, metadata=None) -> SignedAuditEntry`** — Append a signed, hash-chained entry.
|
|
232
|
+
- **`async sync(endpoint, api_key, bundle_id, batch_size=100) -> SyncResult`** — Upload audit entries to the Grantex cloud in batches.
|
|
233
|
+
|
|
234
|
+
### `enforce_scopes(grant_scopes, required_scopes) -> None`
|
|
235
|
+
|
|
236
|
+
Ensure all required scopes are present. Raises `ScopeViolationError` if any are missing.
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from grantex_gemma import enforce_scopes
|
|
240
|
+
enforce_scopes(grant.scopes, ["read:contacts", "write:calendar"])
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### `has_scope(grant_scopes, scope) -> bool`
|
|
244
|
+
|
|
245
|
+
Check if a single scope is present in the grant.
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from grantex_gemma import has_scope
|
|
249
|
+
if has_scope(grant.scopes, "write:calendar"):
|
|
250
|
+
...
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### `compute_entry_hash(entry) -> str`
|
|
254
|
+
|
|
255
|
+
Compute the SHA-256 hash of an audit entry's content fields (`seq`, `timestamp`, `action`, `agent_did`, `grant_id`, `scopes`, `result`, `metadata`, `prev_hash`).
|
|
256
|
+
|
|
257
|
+
### `verify_chain(entries) -> tuple[bool, int | None]`
|
|
258
|
+
|
|
259
|
+
Verify the integrity of a hash chain. Returns `(True, None)` if valid, or `(False, index)` where `index` is the first broken entry.
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from grantex_gemma import verify_chain
|
|
263
|
+
valid, broken_at = verify_chain(entries)
|
|
264
|
+
if not valid:
|
|
265
|
+
print(f"Chain broken at entry {broken_at}")
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Type Definitions
|
|
269
|
+
|
|
270
|
+
All types are Python `dataclass` instances importable from `grantex_gemma`:
|
|
271
|
+
|
|
272
|
+
### `JWKSSnapshot`
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
@dataclass
|
|
276
|
+
class JWKSSnapshot:
|
|
277
|
+
keys: list[dict[str, Any]] # JWK key objects
|
|
278
|
+
fetched_at: str # ISO 8601 timestamp
|
|
279
|
+
valid_until: str # ISO 8601 expiry
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### `OfflineAuditKey`
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
@dataclass
|
|
286
|
+
class OfflineAuditKey:
|
|
287
|
+
public_key: str # PEM-encoded Ed25519 public key
|
|
288
|
+
private_key: str # PEM-encoded Ed25519 private key
|
|
289
|
+
algorithm: str # "Ed25519"
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### `ConsentBundle`
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
@dataclass
|
|
296
|
+
class ConsentBundle:
|
|
297
|
+
bundle_id: str # Unique bundle identifier
|
|
298
|
+
grant_token: str # RS256-signed Grantex JWT
|
|
299
|
+
jwks_snapshot: JWKSSnapshot # Keys for offline verification
|
|
300
|
+
offline_audit_key: OfflineAuditKey # Ed25519 key pair for signing
|
|
301
|
+
checkpoint_at: int # Unix timestamp for next sync
|
|
302
|
+
sync_endpoint: str # URL for audit log upload
|
|
303
|
+
offline_expires_at: str # ISO 8601 offline expiry
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### `VerifiedGrant`
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
@dataclass
|
|
310
|
+
class VerifiedGrant:
|
|
311
|
+
agent_did: str # Agent DID (from "agt" claim)
|
|
312
|
+
principal_did: str # User DID (from "sub" claim)
|
|
313
|
+
scopes: list[str] # Authorized scopes (from "scp" claim)
|
|
314
|
+
expires_at: datetime # Token expiry (from "exp" claim)
|
|
315
|
+
jti: str # Token ID (from "jti" claim)
|
|
316
|
+
grant_id: str # Grant ID (from "grnt" or "jti" claim)
|
|
317
|
+
depth: int # Delegation depth (0 = root grant)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### `SignedAuditEntry`
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
@dataclass
|
|
324
|
+
class SignedAuditEntry:
|
|
325
|
+
seq: int # Monotonic sequence number
|
|
326
|
+
timestamp: str # ISO 8601 timestamp
|
|
327
|
+
action: str # Action performed
|
|
328
|
+
agent_did: str # Agent that performed the action
|
|
329
|
+
grant_id: str # Grant that authorized it
|
|
330
|
+
scopes: list[str] # Scopes on the grant
|
|
331
|
+
result: str # "success", "denied", etc.
|
|
332
|
+
metadata: dict[str, Any] # Arbitrary context
|
|
333
|
+
prev_hash: str # SHA-256 hash of previous entry
|
|
334
|
+
hash: str # SHA-256 hash of this entry
|
|
335
|
+
signature: str # Ed25519 signature (base64url)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### `SyncResult`
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
@dataclass
|
|
342
|
+
class SyncResult:
|
|
343
|
+
accepted: int # Entries accepted by server
|
|
344
|
+
rejected: int # Entries rejected by server
|
|
345
|
+
revocation_status: str # "active" or "revoked"
|
|
346
|
+
new_bundle: ConsentBundle | None # Refreshed bundle (if issued)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
## Error Classes
|
|
350
|
+
|
|
351
|
+
All exceptions inherit from `GrantexGemmaError`:
|
|
352
|
+
|
|
353
|
+
| Exception | Raised when |
|
|
354
|
+
|-----------|-------------|
|
|
355
|
+
| `GrantexGemmaError` | Base class for all grantex-gemma errors |
|
|
356
|
+
| `OfflineVerificationError` | JWT verification fails (bad signature, missing claims, unsupported algorithm) |
|
|
357
|
+
| `ScopeViolationError` | A required scope is missing from the grant |
|
|
358
|
+
| `TokenExpiredError` | The grant token has expired (past `exp` + clock skew) |
|
|
359
|
+
| `BundleTamperedError` | AES-256-GCM decryption fails — bundle was modified or wrong key |
|
|
360
|
+
| `GrantexAuthError` | API returns 401/403 or a network error during bundle creation/sync |
|
|
361
|
+
| `HashChainError` | Hash chain integrity verification fails, or sync returns an error |
|
|
362
|
+
|
|
363
|
+
`GrantexAuthError` includes a `status_code` property for HTTP error codes.
|
|
364
|
+
|
|
365
|
+
### Catching errors in practice
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
from grantex_gemma import (
|
|
369
|
+
GrantexGemmaError,
|
|
370
|
+
OfflineVerificationError,
|
|
371
|
+
TokenExpiredError,
|
|
372
|
+
ScopeViolationError,
|
|
373
|
+
BundleTamperedError,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
bundle = load_bundle("/data/bundle.enc", encryption_key)
|
|
378
|
+
grant = await verifier.verify(bundle.grant_token)
|
|
379
|
+
enforce_scopes(grant.scopes, ["write:calendar"])
|
|
380
|
+
except BundleTamperedError:
|
|
381
|
+
# Bundle corrupted or wrong encryption key — re-fetch when online
|
|
382
|
+
pass
|
|
383
|
+
except TokenExpiredError:
|
|
384
|
+
# Grant expired — request a new consent bundle
|
|
385
|
+
pass
|
|
386
|
+
except ScopeViolationError:
|
|
387
|
+
# Agent tried to exceed its permissions
|
|
388
|
+
await audit.append("write:calendar", grant, "denied")
|
|
389
|
+
except OfflineVerificationError:
|
|
390
|
+
# Bad signature, missing claims, etc.
|
|
391
|
+
pass
|
|
392
|
+
except GrantexGemmaError:
|
|
393
|
+
# Catch-all for any grantex-gemma error
|
|
394
|
+
pass
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
## Security
|
|
398
|
+
|
|
399
|
+
- **RS256 only** — The verifier rejects `alg: "none"` and `alg: "HS256"` tokens. Only RS256 with pre-fetched public keys is accepted.
|
|
400
|
+
- **AES-256-GCM** — Consent bundles are encrypted at rest. Tampering is detected by GCM authentication.
|
|
401
|
+
- **Ed25519 audit signatures** — Every audit entry is signed with the bundle's Ed25519 private key. Signatures are verified server-side during sync.
|
|
402
|
+
- **SHA-256 hash chain** — Each audit entry's hash covers the previous entry's hash, forming a tamper-evident chain. Breaking one entry invalidates all subsequent entries.
|
|
403
|
+
- **No secrets in tokens** — Grant tokens are standard JWTs verified against public keys. No shared secrets.
|
|
404
|
+
- **Clock skew tolerance** — Configurable tolerance (default 30s) prevents false rejections on devices with imprecise clocks.
|
|
405
|
+
- **Delegation depth limits** — Prevent unbounded delegation chains with `max_delegation_depth`.
|
|
406
|
+
|
|
407
|
+
## Platform Compatibility
|
|
408
|
+
|
|
409
|
+
`grantex-gemma` runs anywhere Python 3.9+ is available:
|
|
410
|
+
|
|
411
|
+
| Platform | Python | Avg verify time | Notes |
|
|
412
|
+
|----------|--------|-----------------|-------|
|
|
413
|
+
| Raspberry Pi 5 | 3.9+ | 3.2 ms | Tested on Raspberry Pi OS (64-bit) |
|
|
414
|
+
| NVIDIA Jetson | 3.9+ | 1.1 ms | Orin Nano / AGX Orin |
|
|
415
|
+
| Linux server | 3.9+ | < 1 ms | x86_64, tested on Ubuntu 22.04+ |
|
|
416
|
+
| macOS | 3.9+ | < 1 ms | Apple Silicon and Intel |
|
|
417
|
+
| Windows | 3.9+ | < 1 ms | Windows 10/11, native and WSL |
|
|
418
|
+
|
|
419
|
+
Verification is pure CPU (RSA signature check) — no GPU required. The bottleneck on constrained devices is the `cryptography` library's RSA implementation, which is written in Rust/C and well-optimized.
|
|
420
|
+
|
|
421
|
+
## Testing
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
pip install -e ".[dev]"
|
|
425
|
+
pytest
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
41 tests covering offline verification, consent bundle creation, hash chain integrity, audit log operations, and security edge cases. Uses `pytest-asyncio` for async support and `respx` for HTTP mocking.
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
pytest --cov=grantex_gemma --cov-report=term-missing # coverage
|
|
432
|
+
mypy src/grantex_gemma # type checking
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## Examples
|
|
436
|
+
|
|
437
|
+
### Raspberry Pi agent
|
|
438
|
+
|
|
439
|
+
A complete example running a Gemma 4 agent on a Raspberry Pi with offline authorization:
|
|
440
|
+
|
|
441
|
+
```
|
|
442
|
+
examples/gemma-raspberry-pi/
|
|
443
|
+
setup_bundle.py # Phase 1: Create and store a consent bundle
|
|
444
|
+
agent.py # Phase 2: Offline agent with verification and audit
|
|
445
|
+
sync_audit.py # Phase 3: Sync audit log when back online
|
|
446
|
+
verify_audit.py # Verify hash chain integrity of the audit log
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
See the [Raspberry Pi example README](https://github.com/mishrasanjeev/grantex/tree/main/examples/gemma-raspberry-pi) for setup instructions.
|
|
450
|
+
|
|
451
|
+
## Troubleshooting
|
|
452
|
+
|
|
453
|
+
### `BundleTamperedError` when loading a bundle
|
|
454
|
+
|
|
455
|
+
The encryption key does not match the key used to store the bundle, or the file was modified. Use the same 64-character hex key for both `store_bundle` and `load_bundle`.
|
|
456
|
+
|
|
457
|
+
### `OfflineVerificationError: No RSA keys available`
|
|
458
|
+
|
|
459
|
+
The JWKS snapshot contains no RSA keys. This usually means the bundle was created with a test/mock API that returned an empty key set. Create a new bundle against the real Grantex API.
|
|
460
|
+
|
|
461
|
+
### `TokenExpiredError` after being offline too long
|
|
462
|
+
|
|
463
|
+
The grant token's `exp` claim has passed. Consent bundles have a limited offline TTL (default 72 hours). Re-create the bundle when connectivity is available, or request a longer `offline_ttl`.
|
|
464
|
+
|
|
465
|
+
### `ScopeViolationError` on verify
|
|
466
|
+
|
|
467
|
+
The grant token does not include the scopes specified in `require_scopes`. Either request the correct scopes when creating the bundle, or set `on_scope_violation="log"` to downgrade to a warning.
|
|
468
|
+
|
|
469
|
+
### `HashChainError` during sync
|
|
470
|
+
|
|
471
|
+
The server detected a gap or inconsistency in the hash chain. This happens if log entries were manually edited or the file was corrupted. Use `verify_chain()` locally to find the broken entry.
|
|
472
|
+
|
|
473
|
+
### `cryptography` fails to install on Raspberry Pi
|
|
474
|
+
|
|
475
|
+
```bash
|
|
476
|
+
sudo apt-get install -y build-essential libssl-dev libffi-dev python3-dev
|
|
477
|
+
pip install --prefer-binary cryptography
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## Related Packages
|
|
481
|
+
|
|
482
|
+
| Package | Description | Install |
|
|
483
|
+
|---------|-------------|---------|
|
|
484
|
+
| [`grantex`](https://pypi.org/project/grantex/) | Python SDK (full API client) | `pip install grantex` |
|
|
485
|
+
| [`grantex-adk`](https://pypi.org/project/grantex-adk/) | Google ADK integration | `pip install grantex-adk` |
|
|
486
|
+
| [`@grantex/gemma`](https://www.npmjs.com/package/@grantex/gemma) | TypeScript version of this package | `npm install @grantex/gemma` |
|
|
487
|
+
|
|
488
|
+
## Contributing
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
git clone https://github.com/mishrasanjeev/grantex.git
|
|
492
|
+
cd grantex/packages/gemma-py
|
|
493
|
+
pip install -e ".[dev]"
|
|
494
|
+
pytest
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## License
|
|
498
|
+
|
|
499
|
+
Apache-2.0
|