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/__init__.py +88 -0
- glacis/__main__.py +76 -0
- glacis/client.py +847 -0
- glacis/crypto.py +121 -0
- glacis/integrations/__init__.py +12 -0
- glacis/integrations/anthropic.py +222 -0
- glacis/integrations/openai.py +208 -0
- glacis/models.py +293 -0
- glacis/storage.py +331 -0
- glacis/streaming.py +363 -0
- glacis/wasm/s3p_core_wasi.wasm +0 -0
- glacis/wasm_runtime.py +519 -0
- glacis-0.1.0.dist-info/METADATA +324 -0
- glacis-0.1.0.dist-info/RECORD +16 -0
- glacis-0.1.0.dist-info/WHEEL +4 -0
- glacis-0.1.0.dist-info/licenses/LICENSE +190 -0
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()
|