glacis 0.1.0__py3-none-any.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.
glacis/models.py ADDED
@@ -0,0 +1,293 @@
1
+ """
2
+ Pydantic models for the GLACIS API.
3
+
4
+ These models match the API responses from the management-api service
5
+ and the TypeScript SDK types.
6
+ """
7
+
8
+ from typing import Any, Literal, Optional
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class GlacisConfig(BaseModel):
14
+ """Configuration for the Glacis client."""
15
+
16
+ api_key: str = Field(..., description="API key (glsk_live_xxx or glsk_test_xxx)")
17
+ base_url: str = Field(
18
+ default="https://api.glacis.dev", description="Base URL for the API"
19
+ )
20
+ debug: bool = Field(default=False, description="Enable debug logging")
21
+ timeout: float = Field(default=30.0, description="Request timeout in seconds")
22
+ max_retries: int = Field(default=3, description="Maximum number of retries")
23
+ base_delay: float = Field(
24
+ default=1.0, description="Base delay in seconds for exponential backoff"
25
+ )
26
+ max_delay: float = Field(
27
+ default=30.0, description="Maximum delay in seconds for backoff"
28
+ )
29
+
30
+
31
+ class MerkleInclusionProof(BaseModel):
32
+ """Merkle inclusion proof structure."""
33
+
34
+ leaf_index: int = Field(alias="leafIndex", description="Index of the leaf (0-based)")
35
+ tree_size: int = Field(alias="treeSize", description="Total leaves when proof generated")
36
+ hashes: list[str] = Field(description="Sibling hashes (hex-encoded)")
37
+
38
+ class Config:
39
+ populate_by_name = True
40
+
41
+
42
+ class SignedTreeHead(BaseModel):
43
+ """Signed Tree Head - cryptographic commitment to tree state."""
44
+
45
+ tree_size: int = Field(alias="treeSize", description="Total number of leaves")
46
+ timestamp: str = Field(description="ISO 8601 timestamp when signed")
47
+ root_hash: str = Field(alias="rootHash", description="Root hash (hex-encoded)")
48
+ signature: str = Field(description="Ed25519 signature (base64-encoded)")
49
+
50
+ class Config:
51
+ populate_by_name = True
52
+
53
+
54
+ class AttestInput(BaseModel):
55
+ """Input for attestation."""
56
+
57
+ service_id: str = Field(alias="serviceId", description="Service identifier")
58
+ operation_type: str = Field(
59
+ alias="operationType",
60
+ description="Type of operation (inference, embedding, completion, classification)",
61
+ )
62
+ input: Any = Field(description="Input data (hashed locally, never sent)")
63
+ output: Any = Field(description="Output data (hashed locally, never sent)")
64
+ metadata: Optional[dict[str, str]] = Field(
65
+ default=None, description="Optional metadata (sent to server)"
66
+ )
67
+
68
+ class Config:
69
+ populate_by_name = True
70
+
71
+
72
+ class AttestReceipt(BaseModel):
73
+ """Receipt returned from attestation."""
74
+
75
+ attestation_id: str = Field(alias="attestationId", description="Unique attestation ID")
76
+ timestamp: str = Field(description="ISO 8601 timestamp")
77
+ leaf_index: int = Field(alias="leafIndex", description="Merkle tree leaf index")
78
+ leaf_hash: str = Field(alias="leafHash", description="Leaf node hash")
79
+ merkle_proof: MerkleInclusionProof = Field(
80
+ alias="merkleProof", description="Inclusion proof"
81
+ )
82
+ signed_tree_head: SignedTreeHead = Field(
83
+ alias="signedTreeHead", description="Tree head at attestation time"
84
+ )
85
+ badge_url: str = Field(alias="badgeUrl", description="Verification badge URL")
86
+ verify_url: str = Field(alias="verifyUrl", description="Verification endpoint URL")
87
+
88
+ class Config:
89
+ populate_by_name = True
90
+
91
+
92
+ class AttestationEntry(BaseModel):
93
+ """Attestation entry from the log."""
94
+
95
+ entry_id: str = Field(alias="entryId")
96
+ timestamp: str
97
+ org_id: str = Field(alias="orgId")
98
+ service_id: str = Field(alias="serviceId")
99
+ operation_type: str = Field(alias="operationType")
100
+ payload_hash: str = Field(alias="payloadHash")
101
+ signature: str
102
+ leaf_index: int = Field(alias="leafIndex")
103
+ leaf_hash: str = Field(alias="leafHash")
104
+
105
+ class Config:
106
+ populate_by_name = True
107
+
108
+
109
+ class OrgInfo(BaseModel):
110
+ """Organization info."""
111
+
112
+ id: str
113
+ name: str
114
+ domain: Optional[str] = None
115
+ public_key: Optional[str] = Field(alias="publicKey", default=None)
116
+ verified_at: Optional[str] = Field(alias="verifiedAt", default=None)
117
+
118
+ class Config:
119
+ populate_by_name = True
120
+
121
+
122
+ class Verification(BaseModel):
123
+ """Verification details."""
124
+
125
+ signature_valid: bool = Field(alias="signatureValid")
126
+ proof_valid: bool = Field(alias="proofValid")
127
+ verified_at: str = Field(alias="verifiedAt")
128
+
129
+ class Config:
130
+ populate_by_name = True
131
+
132
+
133
+ class VerifyResult(BaseModel):
134
+ """Result of verifying an attestation."""
135
+
136
+ valid: bool = Field(description="Whether the attestation is valid")
137
+ attestation: Optional[AttestationEntry] = Field(
138
+ default=None, description="The attestation entry (if valid)"
139
+ )
140
+ org: Optional[OrgInfo] = Field(default=None, description="Organization info")
141
+ verification: Verification = Field(description="Verification details")
142
+ proof: MerkleInclusionProof = Field(description="Merkle proof")
143
+ tree_head: SignedTreeHead = Field(alias="treeHead", description="Current tree head")
144
+ error: Optional[str] = Field(
145
+ default=None, description="Error message if validation failed"
146
+ )
147
+
148
+ class Config:
149
+ populate_by_name = True
150
+
151
+
152
+ class LogQueryParams(BaseModel):
153
+ """Parameters for querying the log."""
154
+
155
+ org_id: Optional[str] = Field(alias="orgId", default=None)
156
+ service_id: Optional[str] = Field(alias="serviceId", default=None)
157
+ start: Optional[str] = Field(default=None, description="Start timestamp (ISO 8601)")
158
+ end: Optional[str] = Field(default=None, description="End timestamp (ISO 8601)")
159
+ limit: Optional[int] = Field(default=50, ge=1, le=1000)
160
+ cursor: Optional[str] = Field(default=None, description="Pagination cursor")
161
+
162
+ class Config:
163
+ populate_by_name = True
164
+
165
+
166
+ class LogEntry(BaseModel):
167
+ """Log entry in query results."""
168
+
169
+ entry_id: str = Field(alias="entryId")
170
+ timestamp: str
171
+ org_id: str = Field(alias="orgId")
172
+ org_name: Optional[str] = Field(alias="orgName", default=None)
173
+ service_id: str = Field(alias="serviceId")
174
+ operation_type: str = Field(alias="operationType")
175
+ payload_hash: str = Field(alias="payloadHash")
176
+ signature: str
177
+ leaf_index: int = Field(alias="leafIndex")
178
+ leaf_hash: str = Field(alias="leafHash")
179
+
180
+ class Config:
181
+ populate_by_name = True
182
+
183
+
184
+ class LogQueryResult(BaseModel):
185
+ """Result of querying the log."""
186
+
187
+ entries: list[LogEntry] = Field(description="Log entries")
188
+ has_more: bool = Field(alias="hasMore", description="Whether more results exist")
189
+ next_cursor: Optional[str] = Field(
190
+ alias="nextCursor", default=None, description="Cursor for next page"
191
+ )
192
+ count: int = Field(description="Number of entries returned")
193
+ tree_head: SignedTreeHead = Field(alias="treeHead", description="Current tree head")
194
+
195
+ class Config:
196
+ populate_by_name = True
197
+
198
+
199
+ class TreeHeadResponse(BaseModel):
200
+ """Response from get_tree_head."""
201
+
202
+ size: int
203
+ root_hash: str = Field(alias="rootHash")
204
+ timestamp: str
205
+ signature: str
206
+
207
+ class Config:
208
+ populate_by_name = True
209
+
210
+
211
+ class GlacisApiError(Exception):
212
+ """Error from the GLACIS API."""
213
+
214
+ def __init__(
215
+ self,
216
+ message: str,
217
+ status: int,
218
+ code: Optional[str] = None,
219
+ details: Optional[dict[str, Any]] = None,
220
+ ):
221
+ super().__init__(message)
222
+ self.status = status
223
+ self.code = code
224
+ self.details = details
225
+
226
+
227
+ class GlacisRateLimitError(GlacisApiError):
228
+ """Rate limit error."""
229
+
230
+ def __init__(self, message: str, retry_after_ms: Optional[int] = None):
231
+ super().__init__(message, 429, "RATE_LIMITED")
232
+ self.retry_after_ms = retry_after_ms
233
+
234
+
235
+ # Offline Mode Models
236
+
237
+
238
+ class OfflineAttestReceipt(BaseModel):
239
+ """Receipt for offline/local attestations.
240
+
241
+ Unlike server receipts, offline receipts are signed locally and do not
242
+ have Merkle tree proofs or server-side tree heads. They can be verified
243
+ locally using the public key, but are not witnessed by the transparency log.
244
+ """
245
+
246
+ attestation_id: str = Field(
247
+ alias="attestationId", description="Local attestation ID (oatt_xxx)"
248
+ )
249
+ timestamp: str = Field(description="ISO 8601 timestamp")
250
+ service_id: str = Field(alias="serviceId", description="Service identifier")
251
+ operation_type: str = Field(
252
+ alias="operationType", description="Type of operation"
253
+ )
254
+ payload_hash: str = Field(
255
+ alias="payloadHash", description="SHA-256 hash of input+output (hex)"
256
+ )
257
+ signature: str = Field(description="Ed25519 signature (base64)")
258
+ public_key: str = Field(
259
+ alias="publicKey", description="Public key derived from seed (hex)"
260
+ )
261
+ is_offline: bool = Field(default=True, alias="isOffline")
262
+ witness_status: Literal["UNVERIFIED"] = Field(
263
+ default="UNVERIFIED",
264
+ alias="witnessStatus",
265
+ description="Always UNVERIFIED for offline receipts",
266
+ )
267
+
268
+ class Config:
269
+ populate_by_name = True
270
+
271
+
272
+ class OfflineVerifyResult(BaseModel):
273
+ """Verification result for offline receipts.
274
+
275
+ Offline receipts can only have their signatures verified locally.
276
+ The witness_status is always UNVERIFIED since there is no server-side
277
+ transparency log entry.
278
+ """
279
+
280
+ valid: bool = Field(description="Whether the signature is valid")
281
+ witness_status: Literal["UNVERIFIED"] = Field(
282
+ default="UNVERIFIED", alias="witnessStatus"
283
+ )
284
+ signature_valid: bool = Field(alias="signatureValid")
285
+ attestation: Optional[OfflineAttestReceipt] = Field(
286
+ default=None, description="The verified offline receipt"
287
+ )
288
+ error: Optional[str] = Field(
289
+ default=None, description="Error message if verification failed"
290
+ )
291
+
292
+ class Config:
293
+ populate_by_name = True
glacis/storage.py ADDED
@@ -0,0 +1,331 @@
1
+ """
2
+ SQLite storage for offline receipts.
3
+
4
+ Stores local attestation receipts in ~/.glacis/receipts.db for persistence
5
+ and later verification.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Optional
15
+
16
+ if TYPE_CHECKING:
17
+ from glacis.models import OfflineAttestReceipt
18
+
19
+ DEFAULT_DB_PATH = Path.home() / ".glacis" / "receipts.db"
20
+
21
+ SCHEMA_VERSION = 1
22
+
23
+ SCHEMA = """
24
+ CREATE TABLE IF NOT EXISTS offline_receipts (
25
+ attestation_id TEXT PRIMARY KEY,
26
+ timestamp TEXT NOT NULL,
27
+ service_id TEXT NOT NULL,
28
+ operation_type TEXT NOT NULL,
29
+ payload_hash TEXT NOT NULL,
30
+ signature TEXT NOT NULL,
31
+ public_key TEXT NOT NULL,
32
+ created_at TEXT NOT NULL,
33
+ input_preview TEXT,
34
+ output_preview TEXT,
35
+ metadata_json TEXT
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_service_id ON offline_receipts(service_id);
39
+ CREATE INDEX IF NOT EXISTS idx_timestamp ON offline_receipts(timestamp);
40
+ CREATE INDEX IF NOT EXISTS idx_payload_hash ON offline_receipts(payload_hash);
41
+ CREATE INDEX IF NOT EXISTS idx_created_at ON offline_receipts(created_at);
42
+
43
+ CREATE TABLE IF NOT EXISTS schema_version (
44
+ version INTEGER PRIMARY KEY
45
+ );
46
+ """
47
+
48
+
49
+ class ReceiptStorage:
50
+ """
51
+ SQLite storage for offline attestation receipts.
52
+
53
+ Default location: ~/.glacis/receipts.db
54
+ """
55
+
56
+ def __init__(self, db_path: Optional[Path] = None) -> None:
57
+ """
58
+ Initialize the receipt storage.
59
+
60
+ Args:
61
+ db_path: Path to SQLite database file. Defaults to ~/.glacis/receipts.db
62
+ """
63
+ self.db_path = db_path or DEFAULT_DB_PATH
64
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
65
+ self._conn: Optional[sqlite3.Connection] = None
66
+
67
+ def _get_connection(self) -> sqlite3.Connection:
68
+ """Get or create database connection."""
69
+ if self._conn is None:
70
+ self._conn = sqlite3.connect(str(self.db_path))
71
+ self._conn.row_factory = sqlite3.Row
72
+ self._init_schema()
73
+ return self._conn
74
+
75
+ def _init_schema(self) -> None:
76
+ """Initialize database schema if needed."""
77
+ conn = self._conn
78
+ if conn is None:
79
+ return
80
+
81
+ cursor = conn.cursor()
82
+
83
+ # Check if schema_version table exists
84
+ cursor.execute(
85
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
86
+ )
87
+ if cursor.fetchone() is None:
88
+ # Fresh database - create schema
89
+ cursor.executescript(SCHEMA)
90
+ cursor.execute(
91
+ "INSERT OR REPLACE INTO schema_version (version) VALUES (?)",
92
+ (SCHEMA_VERSION,),
93
+ )
94
+ conn.commit()
95
+ else:
96
+ # Check version for migrations
97
+ cursor.execute("SELECT version FROM schema_version LIMIT 1")
98
+ row = cursor.fetchone()
99
+ if row is None or row[0] < SCHEMA_VERSION:
100
+ # Run migrations if needed
101
+ self._run_migrations(row[0] if row else 0)
102
+
103
+ def _run_migrations(self, from_version: int) -> None:
104
+ """Run schema migrations."""
105
+ # No migrations needed yet since this is v1
106
+ conn = self._conn
107
+ if conn is None:
108
+ return
109
+
110
+ cursor = conn.cursor()
111
+ cursor.execute(
112
+ "INSERT OR REPLACE INTO schema_version (version) VALUES (?)",
113
+ (SCHEMA_VERSION,),
114
+ )
115
+ conn.commit()
116
+
117
+ def store_receipt(
118
+ self,
119
+ receipt: "OfflineAttestReceipt",
120
+ input_preview: Optional[str] = None,
121
+ output_preview: Optional[str] = None,
122
+ metadata: Optional[dict] = None,
123
+ ) -> None:
124
+ """
125
+ Store an offline receipt.
126
+
127
+ Args:
128
+ receipt: The offline attestation receipt to store
129
+ input_preview: Optional preview of input (first 100 chars)
130
+ output_preview: Optional preview of output (first 100 chars)
131
+ metadata: Optional metadata dict
132
+ """
133
+ conn = self._get_connection()
134
+ cursor = conn.cursor()
135
+
136
+ cursor.execute(
137
+ """
138
+ INSERT INTO offline_receipts
139
+ (attestation_id, timestamp, service_id, operation_type,
140
+ payload_hash, signature, public_key, created_at,
141
+ input_preview, output_preview, metadata_json)
142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
143
+ """,
144
+ (
145
+ receipt.attestation_id,
146
+ receipt.timestamp,
147
+ receipt.service_id,
148
+ receipt.operation_type,
149
+ receipt.payload_hash,
150
+ receipt.signature,
151
+ receipt.public_key,
152
+ datetime.utcnow().isoformat() + "Z",
153
+ input_preview[:100] if input_preview else None,
154
+ output_preview[:100] if output_preview else None,
155
+ json.dumps(metadata) if metadata else None,
156
+ ),
157
+ )
158
+ conn.commit()
159
+
160
+ def get_receipt(self, attestation_id: str) -> Optional["OfflineAttestReceipt"]:
161
+ """
162
+ Retrieve a receipt by ID.
163
+
164
+ Args:
165
+ attestation_id: The attestation ID (oatt_xxx)
166
+
167
+ Returns:
168
+ The receipt if found, None otherwise
169
+ """
170
+ from glacis.models import OfflineAttestReceipt
171
+
172
+ conn = self._get_connection()
173
+ cursor = conn.cursor()
174
+ cursor.execute(
175
+ "SELECT * FROM offline_receipts WHERE attestation_id = ?",
176
+ (attestation_id,),
177
+ )
178
+ row = cursor.fetchone()
179
+ if row is None:
180
+ return None
181
+
182
+ return OfflineAttestReceipt(
183
+ attestation_id=row["attestation_id"],
184
+ timestamp=row["timestamp"],
185
+ service_id=row["service_id"],
186
+ operation_type=row["operation_type"],
187
+ payload_hash=row["payload_hash"],
188
+ signature=row["signature"],
189
+ public_key=row["public_key"],
190
+ )
191
+
192
+ def get_last_receipt(self) -> Optional["OfflineAttestReceipt"]:
193
+ """
194
+ Get the most recently created receipt.
195
+
196
+ Returns:
197
+ The most recent receipt, or None if no receipts exist
198
+ """
199
+ from glacis.models import OfflineAttestReceipt
200
+
201
+ conn = self._get_connection()
202
+ cursor = conn.cursor()
203
+ cursor.execute(
204
+ "SELECT * FROM offline_receipts ORDER BY created_at DESC LIMIT 1"
205
+ )
206
+ row = cursor.fetchone()
207
+ if row is None:
208
+ return None
209
+
210
+ return OfflineAttestReceipt(
211
+ attestation_id=row["attestation_id"],
212
+ timestamp=row["timestamp"],
213
+ service_id=row["service_id"],
214
+ operation_type=row["operation_type"],
215
+ payload_hash=row["payload_hash"],
216
+ signature=row["signature"],
217
+ public_key=row["public_key"],
218
+ )
219
+
220
+ def query_receipts(
221
+ self,
222
+ service_id: Optional[str] = None,
223
+ start: Optional[str] = None,
224
+ end: Optional[str] = None,
225
+ limit: int = 50,
226
+ ) -> list["OfflineAttestReceipt"]:
227
+ """
228
+ Query receipts with optional filters.
229
+
230
+ Args:
231
+ service_id: Filter by service ID
232
+ start: Filter by timestamp >= start (ISO 8601)
233
+ end: Filter by timestamp <= end (ISO 8601)
234
+ limit: Maximum number of results (default 50)
235
+
236
+ Returns:
237
+ List of matching receipts
238
+ """
239
+ from glacis.models import OfflineAttestReceipt
240
+
241
+ conn = self._get_connection()
242
+ cursor = conn.cursor()
243
+
244
+ query = "SELECT * FROM offline_receipts WHERE 1=1"
245
+ params: list = []
246
+
247
+ if service_id:
248
+ query += " AND service_id = ?"
249
+ params.append(service_id)
250
+ if start:
251
+ query += " AND timestamp >= ?"
252
+ params.append(start)
253
+ if end:
254
+ query += " AND timestamp <= ?"
255
+ params.append(end)
256
+
257
+ query += " ORDER BY created_at DESC LIMIT ?"
258
+ params.append(limit)
259
+
260
+ cursor.execute(query, params)
261
+ rows = cursor.fetchall()
262
+
263
+ return [
264
+ OfflineAttestReceipt(
265
+ attestation_id=row["attestation_id"],
266
+ timestamp=row["timestamp"],
267
+ service_id=row["service_id"],
268
+ operation_type=row["operation_type"],
269
+ payload_hash=row["payload_hash"],
270
+ signature=row["signature"],
271
+ public_key=row["public_key"],
272
+ )
273
+ for row in rows
274
+ ]
275
+
276
+ def count_receipts(self, service_id: Optional[str] = None) -> int:
277
+ """
278
+ Count receipts, optionally filtered by service ID.
279
+
280
+ Args:
281
+ service_id: Optional service ID filter
282
+
283
+ Returns:
284
+ Number of matching receipts
285
+ """
286
+ conn = self._get_connection()
287
+ cursor = conn.cursor()
288
+
289
+ if service_id:
290
+ cursor.execute(
291
+ "SELECT COUNT(*) FROM offline_receipts WHERE service_id = ?",
292
+ (service_id,),
293
+ )
294
+ else:
295
+ cursor.execute("SELECT COUNT(*) FROM offline_receipts")
296
+
297
+ row = cursor.fetchone()
298
+ return row[0] if row else 0
299
+
300
+ def delete_receipt(self, attestation_id: str) -> bool:
301
+ """
302
+ Delete a receipt by ID.
303
+
304
+ Args:
305
+ attestation_id: The attestation ID to delete
306
+
307
+ Returns:
308
+ True if a receipt was deleted, False otherwise
309
+ """
310
+ conn = self._get_connection()
311
+ cursor = conn.cursor()
312
+ cursor.execute(
313
+ "DELETE FROM offline_receipts WHERE attestation_id = ?",
314
+ (attestation_id,),
315
+ )
316
+ conn.commit()
317
+ return cursor.rowcount > 0
318
+
319
+ def close(self) -> None:
320
+ """Close the database connection."""
321
+ if self._conn:
322
+ self._conn.close()
323
+ self._conn = None
324
+
325
+ def __enter__(self) -> "ReceiptStorage":
326
+ """Context manager entry."""
327
+ return self
328
+
329
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
330
+ """Context manager exit."""
331
+ self.close()