Skip to main content

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

  1. Protect local key storage — Use encrypted storage when available
  2. Sync keys regularly — At least daily when online
  3. Check sync timestamp — Warn users if keys are stale (>7 days)
  4. 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');