Offline Verification
Optropic uses Ed25519 digital signatures, enabling fully offline verification when you have the public key. This is critical for:
- Remote locations without internet
- High-security environments that block external connections
- Latency-sensitive applications requiring instant verification
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ ONLINE (One-time setup) │
│ │
│ 1. Download public keys from Optropic │
│ 2. Store keys in local secure storage │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ OFFLINE (Every verification) │
│ │
│ 1. Scan QR code → extract URL │
│ 2. Parse signature from URL │
│ 3. Verify signature against local public key │
│ 4. Display result: AUTHENTIC or INVALID │
│ │
└─────────────────────────────────────────────────────────────────┘
Step 1: Download Public Keys
Fetch your registered public keys while online:
async function downloadPublicKeys() {
const response = await fetch('https://api.optropic.com/api/v1/keys', {
headers: { 'x-api-key': process.env.OPTROPIC_API_KEY },
});
const { keys } = await response.json();
// Store keys locally (secure storage recommended)
await localStorage.setItem('optropic_keys', JSON.stringify(keys));
return keys;
}
Step 2: Implement Offline Verification
Use a cryptographic library to verify Ed25519 signatures:
JavaScript (using @noble/ed25519)
import { verify } from '@noble/ed25519';
// Base64url decode helper
function base64urlDecode(str) {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const decoded = atob(base64 + padding);
return Uint8Array.from(decoded, c => c.charCodeAt(0));
}
// Hex to bytes helper
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
async function verifyOffline(url, publicKeys) {
const parsed = new URL(url);
// 1. Extract the message (path only)
const message = new TextEncoder().encode(parsed.pathname);
// 2. Extract and decode the signature
const signatureB64 = parsed.searchParams.get('sig');
if (!signatureB64) {
return { valid: false, error: 'No signature found' };
}
const signature = base64urlDecode(signatureB64);
// 3. Try each public key until one verifies
for (const key of publicKeys) {
try {
const publicKey = hexToBytes(key.publicKey);
const isValid = await verify(signature, message, publicKey);
if (isValid) {
return {
valid: true,
keyId: key.keyId,
keyLabel: key.label,
};
}
} catch (e) {
// Try next key
continue;
}
}
return { valid: false, error: 'No matching key found' };
}
Python (using cryptography library)
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from urllib.parse import urlparse, parse_qs
import base64
def base64url_decode(data: str) -> bytes:
padding = 4 - len(data) % 4
data += '=' * padding
return base64.urlsafe_b64decode(data)
def verify_offline(url: str, public_keys: list) -> dict:
parsed = urlparse(url)
# 1. Extract message (path only)
message = parsed.path.encode('utf-8')
# 2. Extract and decode signature
query = parse_qs(parsed.query)
sig_b64 = query.get('sig', [None])[0]
if not sig_b64:
return {'valid': False, 'error': 'No signature found'}
signature = base64url_decode(sig_b64)
# 3. Try each public key
for key in public_keys:
try:
public_key_bytes = bytes.fromhex(key['publicKey'])
public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes)
public_key.verify(signature, message)
return {
'valid': True,
'keyId': key['keyId'],
'keyLabel': key.get('label'),
}
except InvalidSignature:
continue
except Exception:
continue
return {'valid': False, 'error': 'No matching key found'}
Step 3: Handle Edge Cases
Key Rotation
Periodically sync keys when online:
async function syncKeysIfOnline() {
if (!navigator.onLine) {
console.log('Offline - using cached keys');
return JSON.parse(localStorage.getItem('optropic_keys') || '[]');
}
try {
const keys = await downloadPublicKeys();
console.log(`Synced ${keys.length} keys`);
return keys;
} catch (e) {
console.warn('Sync failed, using cached keys');
return JSON.parse(localStorage.getItem('optropic_keys') || '[]');
}
}
Revoked Keys
Check key status during sync:
async function downloadPublicKeys() {
const response = await fetch('https://api.optropic.com/api/v1/keys', {
headers: { 'x-api-key': process.env.OPTROPIC_API_KEY },
});
const { keys } = await response.json();
// Only store active keys
const activeKeys = keys.filter(k => k.isActive && !k.revokedAt);
await localStorage.setItem('optropic_keys', JSON.stringify(activeKeys));
await localStorage.setItem('optropic_keys_synced', new Date().toISOString());
return activeKeys;
}
Limitations
Offline verification cannot:
- Report scan events to OIDS (fraud detection)
- Update scan count statistics
- Detect geographic anomalies
- Access revocation status in real-time
For full protection, use online verification when possible and sync keys regularly.
Security Considerations
- Protect local key storage — Use encrypted storage when available
- Sync keys regularly — At least daily when online
- Check sync timestamp — Warn users if keys are stale (>7 days)
- Handle revocations — Revoked keys should trigger manual review
Complete Example
class OfflineVerifier {
constructor() {
this.keys = [];
this.lastSync = null;
}
async initialize() {
const cached = localStorage.getItem('optropic_keys');
if (cached) {
this.keys = JSON.parse(cached);
this.lastSync = localStorage.getItem('optropic_keys_synced');
}
// Try to sync if online
if (navigator.onLine) {
await this.sync();
}
}
async sync() {
try {
const response = await fetch('https://api.optropic.com/api/v1/keys', {
headers: { 'x-api-key': process.env.OPTROPIC_API_KEY },
});
const { keys } = await response.json();
this.keys = keys.filter(k => k.isActive);
this.lastSync = new Date().toISOString();
localStorage.setItem('optropic_keys', JSON.stringify(this.keys));
localStorage.setItem('optropic_keys_synced', this.lastSync);
} catch (e) {
console.warn('Key sync failed:', e);
}
}
async verify(url) {
if (this.keys.length === 0) {
return { valid: false, error: 'No keys available' };
}
return verifyOffline(url, this.keys);
}
isStale() {
if (!this.lastSync) return true;
const syncDate = new Date(this.lastSync);
const daysOld = (Date.now() - syncDate.getTime()) / (1000 * 60 * 60 * 24);
return daysOld > 7;
}
}
// Usage
const verifier = new OfflineVerifier();
await verifier.initialize();
if (verifier.isStale()) {
console.warn('Keys are stale - online sync recommended');
}
const result = await verifier.verify('https://id.optropic.com/01/...');
console.log(result.valid ? '✅ Authentic' : '❌ Invalid');