diff --git a/docs/REVOCATION_LIST_DISTRIBUTION.md b/docs/REVOCATION_LIST_DISTRIBUTION.md new file mode 100644 index 0000000..9e9016c --- /dev/null +++ b/docs/REVOCATION_LIST_DISTRIBUTION.md @@ -0,0 +1,160 @@ +# Offline Revocation List Distribution for EIP-712 Attestations + +## Problem + +Attestations are designed to be verifiable entirely offline once cached, but there's no mechanism to distribute revocation state in a way that's usable offline with freshness guarantees. + +**Core Issue**: If a guild issuer key is compromised and revoked, offline verifiers have no way to know because: +- Revocation data is only available online +- No cached revocation list with bounded trust window +- Portable attestations lack revocation context + +## Solution Overview + +A versioned, monotonically-increasing revocation list per guild issuer, cached locally with a bounded trust window, with explicit policy for stale/unavailable data and bundling for third-party verification. + +## Design + +### 1. Revocation List Format + +```typescript +interface RevocationList { + guildId: string; + version: number; // Monotonically increasing + issuedAt: number; // Unix timestamp (ms) + expiresAt: number; // Unix timestamp (ms) + revokedKeys: RevokedKey[]; + signature: string; // EIP-191 signature +} + +interface RevokedKey { + keyId?: string; // For multi-key guilds + address: string; // Issuer address (0x-prefixed) + revokedAt: number; // When revoked (Unix ms) + reason?: string; // 'compromised' | 'rotation' | 'admin' | 'other' +} +``` + +### 2. Trust Model + +- **Revocation lists are signed** by the guild's authority key +- **Version monotonicity**: prevents rollback attacks +- **Bounded trust window**: 24 hours default for offline use +- **Fail-closed**: when data unavailable, reject the attestation + +### 3. Local Caching Strategy + +```typescript +// Cache tiers +- Memory: 15 min TTL (fast lookups) +- Persistent: 24 hour offline trust window +- Expiry: lists are deleted after 7 days + +// Freshness policy +- Fresh: age <= 15 min → use from memory +- Stale Trusted: 15 min < age <= 24 hours → use from persistent storage +- Expired: age > 24 hours → reject (fail closed) +``` + +### 4. Distribution Mechanisms + +#### Push Distribution (Primary) +- Backend pushes revocation lists during sync +- Cached locally in secure storage +- Automatic refresh on app startup + +#### Bundled Distribution (Portability) +- Attestations can include recent revocation list snapshot +- Third-party verifiers get attestation + revocation state +- Presenter responsible for keeping revocation data current + +### 5. Implementation Plan + +**Phase 1: Core Types & Storage** +- `revocationList.types.ts` - type definitions +- `revocationListStorage.ts` - secure storage layer +- `revocationListVerifier.ts` - signature verification + +**Phase 2: Cache Management** +- `revocationListCache.ts` - in-memory + persistent cache +- Integration with existing `attestationService.ts` + +**Phase 3: Sync & Distribution** +- `revocationListSync.ts` - automatic sync service +- `portableAttestation.ts` - bundling for third-party verification + +## Security Considerations + +### Replay Protection +- Version numbers must be monotonically increasing +- Reject lists with version ≤ cached version +- Content hashes for integrity checking + +### Signature Verification +- All revocation lists cryptographically signed +- Authority key verification chain +- Invalid signatures rejected immediately + +### Fail-Closed Policy +```typescript +// When revocation status indeterminate: +if (revocationDataUnavailable) { + return { + valid: false, + reason: 'Revocation data unavailable', + failClosed: true + }; +} +``` + +## Files Created + +``` +src/features/attestation/ +├── revocationList.types.ts # Core types & interfaces +├── revocationListStorage.ts # Secure storage operations +├── revocationListVerifier.ts # Signature & validation +├── revocationListCache.ts # Cache management +├── revocationListSync.ts # Sync service integration +└── portableAttestation.ts # Bundled distribution + +docs/ +└── REVOCATION_LIST_DISTRIBUTION.md # Design document +``` + +## Acceptance Criteria + +- [x] Revocation list format defined and implemented +- [x] Local caching with bounded trust window (24h default) +- [x] Fail-closed policy when revocation status indeterminate +- [x] Signature verification for all revocation lists +- [x] Backward compatibility with existing attestations +- [ ] Comprehensive test coverage +- [ ] Documentation updated +- [ ] Integration with sync service + +## Backward Compatibility + +- No changes to existing `RoleAttestation` structure +- Revocation checking is additive verification step +- Graceful degradation when revocation data unavailable + +## Error Handling + +```typescript +// Error categories +- INVALID_SIGNATURE +- EXPIRED_LIST +- VERSION_ROLLBACK +- CORRUPTED_DATA +- CACHE_EXPIRED +- TRUST_WINDOW_EXCEEDED +- NETWORK_UNAVAILABLE +``` + +## Next Steps + +1. **SDK Integration**: Backend APIs for revocation list distribution +2. **Testing**: Unit + integration tests for offline scenarios +3. **Performance**: Profile storage operations for large lists +4. **Documentation**: Update attestation protocol docs diff --git a/package.json b/package.json index 15c266a..06ac7ab 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", "react-native-webview": "13.6.4", - "shopify/flash-list": "^1.6.4", + "@shopify/flash-list": "^1.6.4", "viem": "^2.55.2", "zod": "^3.23.8", "zustand": "^4.5.2" diff --git a/src/features/attestation/attestationService.ts b/src/features/attestation/attestationService.ts index 8d594b9..4d74074 100644 --- a/src/features/attestation/attestationService.ts +++ b/src/features/attestation/attestationService.ts @@ -310,3 +310,4 @@ export class AttestationService { return this.getIssuerKey(guildId); } } +import { checkRevocationStatus } from "./revocationListCache"; \ No newline at end of file diff --git a/src/features/attestation/portableAttestation.ts b/src/features/attestation/portableAttestation.ts new file mode 100644 index 0000000..ab4e2fa --- /dev/null +++ b/src/features/attestation/portableAttestation.ts @@ -0,0 +1,257 @@ +/** + * Portable attestation bundling for offline third-party verification + */ + +import type { RoleAttestation, RevocationList, PortableAttestation } from "./revocationList.types"; +import { RevocationListError, REVOCATION_ERROR_CODES } from "./revocationList.types"; +import { getCachedRevocationList } from "./revocationListCache"; +import { getCachedIssuerKey } from "./issuerKeyRegistry"; +import { validateRevocationList } from "./revocationListVerifier"; +import { validateAttestation } from "./verifySignature"; + +export async function createPortableAttestation( + attestation: RoleAttestation, + options: { + includePublicKey?: boolean; + maxRevocationAge?: number; + } = {} +): Promise { + const { includePublicKey = false, maxRevocationAge = 60 * 60 * 1000 } = options; + + const issuerKey = await getCachedIssuerKey(attestation.guildId); + if (!issuerKey) { + throw new RevocationListError( + REVOCATION_ERROR_CODES.UNKNOWN_AUTHORITY, + `No cached issuer key for guild ${attestation.guildId}`, + { guildId: attestation.guildId } + ); + } + + const cachedRevocation = await getCachedRevocationList(attestation.guildId); + if (!cachedRevocation) { + throw new RevocationListError( + REVOCATION_ERROR_CODES.CACHE_EXPIRED, + `No cached revocation list for guild ${attestation.guildId}`, + { guildId: attestation.guildId } + ); + } + + const revocationAge = Date.now() - cachedRevocation.cachedAt; + if (revocationAge > maxRevocationAge) { + throw new RevocationListError( + REVOCATION_ERROR_CODES.CACHE_EXPIRED, + `Revocation list too old: ${Math.round(revocationAge / 1000 / 60)} minutes`, + { guildId: attestation.guildId, age: revocationAge, maxAge: maxRevocationAge } + ); + } + + return { + attestation: { + guildId: attestation.guildId, + roleId: attestation.roleId, + wallet: attestation.wallet, + issuedAt: attestation.issuedAt, + expiresAt: attestation.expiresAt, + signature: attestation.signature, + }, + issuerKey: { + address: issuerKey.issuerAddress, + ...(includePublicKey && { publicKey: issuerKey.issuerAddress }), + ...(attestation as any).kid && { keyId: (attestation as any).kid }, + }, + revocationList: cachedRevocation.list, + bundledAt: Date.now(), + version: 1, + }; +} + +export async function verifyPortableAttestation( + portable: PortableAttestation, + chainId: number = 1 +): Promise<{ + valid: boolean; + reason?: string; + attestationValid?: boolean; + revocationValid?: boolean; + keyRevoked?: boolean; + expired?: boolean; + bundleAge?: number; +}> { + const now = Date.now(); + const bundleAge = now - portable.bundledAt; + + try { + if (portable.version !== 1) { + return { valid: false, reason: `Unsupported version: ${portable.version}`, bundleAge }; + } + + if (!portable.attestation || !portable.issuerKey || !portable.revocationList) { + return { valid: false, reason: 'Incomplete portable attestation bundle', bundleAge }; + } + + const attestationExpired = now > portable.attestation.expiresAt; + if (attestationExpired) { + return { valid: false, reason: 'Attestation has expired', expired: true, bundleAge }; + } + + const revocationValidation = await validateRevocationList( + portable.revocationList, + portable.issuerKey.address, + undefined, + 10000 + ); + + if (!revocationValidation.valid) { + return { + valid: false, + reason: `Invalid revocation list: ${revocationValidation.reason}`, + revocationValid: false, + bundleAge, + }; + } + + const keyRevoked = portable.revocationList.revokedKeys.some(revokedKey => { + const addrMatch = revokedKey.address.toLowerCase() === portable.issuerKey.address?.toLowerCase(); + const kidMatch = portable.issuerKey.keyId && revokedKey.keyId === portable.issuerKey.keyId; + return (portable.issuerKey.keyId && kidMatch) || (portable.issuerKey.address && addrMatch && !revokedKey.keyId); + }); + + if (keyRevoked) { + const revokedEntry = portable.revocationList.revokedKeys.find(revokedKey => { + const addrMatch = revokedKey.address.toLowerCase() === portable.issuerKey.address?.toLowerCase(); + const kidMatch = portable.issuerKey.keyId && revokedKey.keyId === portable.issuerKey.keyId; + return (portable.issuerKey.keyId && kidMatch) || (portable.issuerKey.address && addrMatch && !revokedKey.keyId); + }); + + return { + valid: false, + reason: `Issuer key was revoked at ${new Date(revokedEntry?.revokedAt || 0).toISOString()}`, + keyRevoked: true, + revocationValid: true, + bundleAge, + }; + } + + const attestationResult = await validateAttestation( + portable.attestation as RoleAttestation, + portable.issuerKey.address as `0x${string}`, + chainId + ); + + if (!attestationResult.valid) { + return { + valid: false, + reason: `Attestation signature invalid: ${attestationResult.reason}`, + attestationValid: false, + revocationValid: true, + bundleAge, + }; + } + + return { + valid: true, + attestationValid: true, + revocationValid: true, + keyRevoked: false, + expired: false, + bundleAge, + }; + + } catch (error) { + return { + valid: false, + reason: `Verification error: ${error instanceof Error ? error.message : String(error)}`, + bundleAge, + }; + } +} + +export function createPortableAttestationQR(portable: PortableAttestation, compressionLevel: 'minimal' | 'standard' | 'full' = 'standard'): string { + let payload: any; + switch (compressionLevel) { + case 'minimal': + payload = { + v: portable.version, + a: { + g: portable.attestation.guildId, + r: portable.attestation.roleId, + w: portable.attestation.wallet, + i: portable.attestation.issuedAt, + e: portable.attestation.expiresAt, + s: portable.attestation.signature, + }, + k: { a: portable.issuerKey.address, ...(portable.issuerKey.keyId && { k: portable.issuerKey.keyId }) }, + rl: { + g: portable.revocationList.guildId, + v: portable.revocationList.version, + i: portable.revocationList.issuedAt, + e: portable.revocationList.expiresAt, + rk: portable.revocationList.revokedKeys.map(k => ({ a: k.address, ...(k.keyId && { k: k.keyId }), r: k.revokedAt })), + s: portable.revocationList.signature, + }, + b: portable.bundledAt, + }; + break; + case 'standard': + payload = { + version: portable.version, + attestation: portable.attestation, + issuerKey: portable.issuerKey, + revocationList: { + ...portable.revocationList, + revokedKeys: portable.revocationList.revokedKeys.map(({ reason, ...k }) => k), + }, + bundledAt: portable.bundledAt, + }; + break; + case 'full': + default: + payload = portable; + break; + } + return `guildpass://portable/${Buffer.from(JSON.stringify(payload)).toString('base64')}`; +} + +export function parsePortableAttestationQR(qrPayload: string): PortableAttestation { + try { + const prefix = 'guildpass://portable/'; + if (!qrPayload.startsWith(prefix)) { + throw new Error('Invalid portable attestation QR format'); + } + + const base64Data = qrPayload.slice(prefix.length); + const jsonStr = Buffer.from(base64Data, 'base64').toString('utf-8'); + const parsed = JSON.parse(jsonStr); + + if (parsed.v !== undefined) { + return { + version: parsed.v, + attestation: { + guildId: parsed.a.g, + roleId: parsed.a.r, + wallet: parsed.a.w, + issuedAt: parsed.a.i, + expiresAt: parsed.a.e, + signature: parsed.a.s, + }, + issuerKey: { address: parsed.k.a, ...(parsed.k.k && { keyId: parsed.k.k }) }, + revocationList: { + guildId: parsed.rl.g, + version: parsed.rl.v, + issuedAt: parsed.rl.i, + expiresAt: parsed.rl.e, + revokedKeys: parsed.rl.rk.map((k: any) => ({ address: k.a, ...(k.k && { keyId: k.k }), revokedAt: k.r })), + signature: parsed.rl.s, + }, + bundledAt: parsed.b, + }; + } + + return parsed as PortableAttestation; + } catch (error) { + throw new RevocationListError( + REVOCATION_ERROR_CODES.CORRUPTED_DATA, + `Failed to parse portable attestation QR: ${error instanceof Error ? error.message : String(error)}` + ); + } +} \ No newline at end of file diff --git a/src/features/attestation/revocationList.types.ts b/src/features/attestation/revocationList.types.ts new file mode 100644 index 0000000..a570544 --- /dev/null +++ b/src/features/attestation/revocationList.types.ts @@ -0,0 +1,127 @@ +/** + * Types for offline revocation list distribution + */ + +export interface RevokedKeyEntry { + keyId?: string; + address: string; + revokedAt: number; + reason?: 'compromised' | 'rotation' | 'admin' | 'other'; +} + +export interface RevocationList { + guildId: string; + version: number; + issuedAt: number; + expiresAt: number; + revokedKeys: RevokedKeyEntry[]; + signature: string; +} + +export interface CachedRevocationList { + list: RevocationList; + cachedAt: number; + contentHash: string; + signatureVerified: boolean; +} + +export interface RevocationCheckResult { + status: 'valid' | 'revoked' | 'unknown' | 'unavailable'; + reason?: string; + failClosed?: boolean; + listVersion?: number; + dataAge?: number; +} + +export interface RevocationCacheConfig { + cacheTtlMs: number; + offlineTrustWindowMs: number; + maxListSize: number; + failClosed: boolean; +} + +export interface SerializedRevocationList { + version: 1; + guildId: string; + listVersion: number; + issuedAt: number; + expiresAt: number; + revokedKeys: Array<{ + keyId?: string; + address: string; + revokedAt: number; + reason?: string; + }>; + signature: string; + cachedAt: number; + contentHash: string; + signatureVerified: boolean; + checksum: string; +} + +export interface RevocationSyncStatus { + guildId: string; + lastSyncAt?: number; + lastAttemptAt?: number; + currentVersion?: number; + syncing: boolean; + lastError?: string; +} + +export const REVOCATION_STORAGE_KEYS = { + REVOCATION_LIST: 'guildpass:revocation-list:v1:', + REVOCATION_INDEX: 'guildpass:revocation-list-index:v1', + SYNC_STATUS: 'guildpass:revocation-sync-status:v1', +} as const; + +export const REVOCATION_DEFAULTS = { + CACHE_TTL_MS: 15 * 60 * 1000, + OFFLINE_TRUST_WINDOW_MS: 24 * 60 * 60 * 1000, + MAX_LIST_SIZE: 10_000, + FAIL_CLOSED: true, + LIST_VALIDITY_MS: 7 * 24 * 60 * 60 * 1000, +} as const; + +export const REVOCATION_ERROR_CODES = { + INVALID_SIGNATURE: 'REVOCATION_INVALID_SIGNATURE', + EXPIRED_LIST: 'REVOCATION_EXPIRED_LIST', + VERSION_ROLLBACK: 'REVOCATION_VERSION_ROLLBACK', + LIST_TOO_LARGE: 'REVOCATION_LIST_TOO_LARGE', + CORRUPTED_DATA: 'REVOCATION_CORRUPTED_DATA', + NETWORK_UNAVAILABLE: 'REVOCATION_NETWORK_UNAVAILABLE', + CACHE_EXPIRED: 'REVOCATION_CACHE_EXPIRED', + TRUST_WINDOW_EXCEEDED: 'REVOCATION_TRUST_WINDOW_EXCEEDED', + UNKNOWN_AUTHORITY: 'REVOCATION_UNKNOWN_AUTHORITY', +} as const; + +export type RevocationErrorCode = typeof REVOCATION_ERROR_CODES[keyof typeof REVOCATION_ERROR_CODES]; + +export class RevocationListError extends Error { + constructor( + public code: RevocationErrorCode, + message: string, + public details?: Record + ) { + super(message); + this.name = 'RevocationListError'; + } +} + +export interface PortableAttestation { + attestation: { + guildId: string; + roleId: string; + wallet: string; + issuedAt: number; + expiresAt: number; + signature: string; + }; + issuerKey: { + address?: string; + publicKey?: string; + keyId?: string; + }; + revocationList: RevocationList; + bundledAt: number; + version: number; +} \ No newline at end of file diff --git a/src/features/attestation/revocationListCache.ts b/src/features/attestation/revocationListCache.ts new file mode 100644 index 0000000..905a322 --- /dev/null +++ b/src/features/attestation/revocationListCache.ts @@ -0,0 +1,198 @@ +/** + * Revocation list cache management + */ + +import type { RevocationList, CachedRevocationList, RevocationCheckResult, RevocationCacheConfig } from "./revocationList.types"; +import { REVOCATION_DEFAULTS } from "./revocationList.types"; +import { storeRevocationList, loadRevocationList, removeRevocationList } from "./revocationListStorage"; +import { migratingSecureStorage } from "../../lib/storage"; +import { validateRevocationList, checkKeyRevocationStatus, isCacheFresh, isWithinTrustWindow } from "./revocationListVerifier"; + +class RevocationListCache { + private cache = new Map(); + private config: RevocationCacheConfig; + + constructor(config?: Partial) { + this.config = { + cacheTtlMs: config?.cacheTtlMs ?? REVOCATION_DEFAULTS.CACHE_TTL_MS, + offlineTrustWindowMs: config?.offlineTrustWindowMs ?? REVOCATION_DEFAULTS.OFFLINE_TRUST_WINDOW_MS, + maxListSize: config?.maxListSize ?? REVOCATION_DEFAULTS.MAX_LIST_SIZE, + failClosed: config?.failClosed ?? REVOCATION_DEFAULTS.FAIL_CLOSED, + }; + } + + async getCachedList(guildId: string): Promise { + let cached = this.cache.get(guildId); + + if (cached) { + const now = Date.now(); + if (isCacheFresh(cached.cachedAt, now, this.config.cacheTtlMs)) { + return cached; + } + if (isWithinTrustWindow(cached.cachedAt, now, this.config.offlineTrustWindowMs)) { + return cached; + } + this.cache.delete(guildId); + } + + cached = await loadRevocationList(guildId); + if (cached) { + const now = Date.now(); + if (isWithinTrustWindow(cached.cachedAt, now, this.config.offlineTrustWindowMs)) { + this.cache.set(guildId, cached); + return cached; + } + await removeRevocationList(guildId); + } + + return null; + } + + async setCachedList( + guildId: string, + list: RevocationList, + authorityAddress: string + ): Promise { + const current = await this.getCachedList(guildId); + const currentVersion = current?.list.version; + + const validation = await validateRevocationList(list, authorityAddress, currentVersion, this.config.maxListSize); + if (!validation.valid) { + throw new Error(`Invalid revocation list: ${validation.reason}`); + } + + const cached: CachedRevocationList = { + list, + cachedAt: Date.now(), + contentHash: '', + signatureVerified: true, + }; + + this.cache.set(guildId, cached); + await storeRevocationList(guildId, list, true); + } + + async removeCachedList(guildId: string): Promise { + this.cache.delete(guildId); + await removeRevocationList(guildId); + } + + async checkRevocationStatus( + guildId: string, + keyAddress?: string, + keyId?: string, + networkAvailable: boolean = true + ): Promise { + const context: any = { + guildId, + now: Date.now(), + networkAvailable, + config: this.config, + }; + + const cached = await this.getCachedList(guildId); + + if (!cached) { + return { + status: 'unavailable', + reason: 'No revocation data for guild', + failClosed: this.config.failClosed, + }; + } + + return checkKeyRevocationStatus(cached, context, keyAddress, keyId); + } + + async clearCache(): Promise { + this.cache.clear(); + const guilds = await getAllCachedGuilds(); + await Promise.all(guilds.map(guildId => removeRevocationList(guildId))); + } + + async cleanupExpired(): Promise<{ removed: number }> { + const now = Date.now(); + let removed = 0; + + for (const [guildId, cached] of this.cache.entries()) { + if (!isWithinTrustWindow(cached.cachedAt, now, this.config.offlineTrustWindowMs)) { + this.cache.delete(guildId); + removed++; + } + } + + const guilds = await getAllCachedGuilds(); + await Promise.all( + guilds.map(async (guildId) => { + const cached = await loadRevocationList(guildId); + if (cached && !isWithinTrustWindow(cached.cachedAt, now, this.config.offlineTrustWindowMs)) { + await removeRevocationList(guildId); + removed++; + } + }) + ); + + return { removed }; + } + + updateConfig(newConfig: Partial): void { + this.config = { ...this.config, ...newConfig }; + } + + getConfig(): RevocationCacheConfig { + return { ...this.config }; + } +} + +async function getAllCachedGuilds(): Promise { + try { + const raw = await migratingSecureStorage.getItem('guildpass:revocation-list-index:v1'); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +let globalCache: RevocationListCache | null = null; + +export function getRevocationCache(config?: Partial): RevocationListCache { + if (!globalCache) { + globalCache = new RevocationListCache(config); + } else if (config) { + globalCache.updateConfig(config); + } + return globalCache; +} + +export function initializeRevocationCache(config?: Partial): RevocationListCache { + globalCache = new RevocationListCache(config); + return globalCache; +} + +export async function getCachedRevocationList(guildId: string): Promise { + return getRevocationCache().getCachedList(guildId); +} + +export async function setCachedRevocationList( + guildId: string, + list: RevocationList, + authorityAddress: string +): Promise { + return getRevocationCache().setCachedList(guildId, list, authorityAddress); +} + +export async function checkRevocationStatus( + guildId: string, + keyAddress?: string, + keyId?: string, + networkAvailable: boolean = true +): Promise { + return getRevocationCache().checkRevocationStatus(guildId, keyAddress, keyId, networkAvailable); +} + +export async function clearRevocationCache(): Promise { + return getRevocationCache().clearCache(); +} + +export async function cleanupExpiredRevocationCache(): Promise<{ removed: number }> { + return getRevocationCache().cleanupExpired(); +} \ No newline at end of file diff --git a/src/features/attestation/revocationListStorage.ts b/src/features/attestation/revocationListStorage.ts new file mode 100644 index 0000000..666b957 --- /dev/null +++ b/src/features/attestation/revocationListStorage.ts @@ -0,0 +1,199 @@ +/** + * Secure storage for revocation lists + */ + +import { sha256, toHex } from "viem"; +import { migratingSecureStorage } from "../../lib/storage"; +import type { + RevocationList, + CachedRevocationList, + SerializedRevocationList, + RevocationSyncStatus, + REVOCATION_STORAGE_KEYS, +} from "./revocationList.types"; +import { RevocationListError, REVOCATION_ERROR_CODES } from "./revocationList.types"; + +function generateContentHash(list: RevocationList): string { + const payload = { + guildId: list.guildId, + version: list.version, + issuedAt: list.issuedAt, + expiresAt: list.expiresAt, + revokedKeys: [...list.revokedKeys].sort((a, b) => { + const addr = a.address.localeCompare(b.address); + if (addr !== 0) return addr; + return (a.keyId || '').localeCompare(b.keyId || ''); + }), + }; + return sha256(toHex(JSON.stringify(payload))); +} + +function generateChecksum(data: Omit): string { + return sha256(toHex(JSON.stringify(data))); +} + +function serializeRevocationList(cached: CachedRevocationList): string { + const data: Omit = { + version: 1, + guildId: cached.list.guildId, + listVersion: cached.list.version, + issuedAt: cached.list.issuedAt, + expiresAt: cached.list.expiresAt, + revokedKeys: cached.list.revokedKeys, + signature: cached.list.signature, + cachedAt: cached.cachedAt, + contentHash: cached.contentHash, + signatureVerified: cached.signatureVerified, + }; + const serialized: SerializedRevocationList = { ...data, checksum: generateChecksum(data) }; + return JSON.stringify(serialized); +} + +function deserializeRevocationList(raw: string, expectedGuildId: string): CachedRevocationList | null { + try { + const parsed = JSON.parse(raw) as Partial; + + if ( + parsed.version !== 1 || + typeof parsed.guildId !== 'string' || parsed.guildId !== expectedGuildId || + typeof parsed.listVersion !== 'number' || + typeof parsed.issuedAt !== 'number' || + typeof parsed.expiresAt !== 'number' || + !Array.isArray(parsed.revokedKeys) || + typeof parsed.signature !== 'string' || + typeof parsed.cachedAt !== 'number' || + typeof parsed.contentHash !== 'string' || + typeof parsed.signatureVerified !== 'boolean' || + typeof parsed.checksum !== 'string' + ) { + return null; + } + + const { checksum, ...data } = parsed as SerializedRevocationList; + if (generateChecksum(data) !== checksum) { + console.warn(`Checksum mismatch for ${expectedGuildId}`); + return null; + } + + const list: RevocationList = { + guildId: parsed.guildId, + version: parsed.listVersion, + issuedAt: parsed.issuedAt, + expiresAt: parsed.expiresAt, + revokedKeys: parsed.revokedKeys, + signature: parsed.signature, + }; + + if (generateContentHash(list) !== parsed.contentHash) { + return null; + } + + return { + list, + cachedAt: parsed.cachedAt, + contentHash: parsed.contentHash, + signatureVerified: parsed.signatureVerified, + }; + } catch { + return null; + } +} + +export async function storeRevocationList( + guildId: string, + list: RevocationList, + signatureVerified: boolean = false +): Promise { + const cached: CachedRevocationList = { + list, + cachedAt: Date.now(), + contentHash: generateContentHash(list), + signatureVerified, + }; + + try { + const storageKey = `${REVOCATION_STORAGE_KEYS.REVOCATION_LIST}${guildId}`; + await migratingSecureStorage.setItem(storageKey, serializeRevocationList(cached)); + await updateRevocationIndex(guildId, true); + console.log(`Stored revocation list for ${guildId}, version ${list.version}`); + } catch (error) { + throw new RevocationListError( + REVOCATION_ERROR_CODES.CORRUPTED_DATA, + `Failed to store revocation list for ${guildId}`, + { guildId, error: String(error) } + ); + } +} + +export async function loadRevocationList(guildId: string): Promise { + try { + const storageKey = `${REVOCATION_STORAGE_KEYS.REVOCATION_LIST}${guildId}`; + const raw = await migratingSecureStorage.getItem(storageKey); + if (!raw) return null; + + const cached = deserializeRevocationList(raw, guildId); + if (!cached) { + await removingSecureStorage.removeItem(storageKey); + await updateRevocationIndex(guildId, false); + return null; + } + return cached; + } catch (error) { + console.warn(`Failed to load revocation list for ${guildId}:`, error); + return null; + } +} + +export async function removeRevocationList(guildId: string): Promise { + try { + const storageKey = `${REVOCATION_STORAGE_KEYS.REVOCATION_LIST}${guildId}`; + await migratingSecureStorage.removeItem(storageKey); + await updateRevocationIndex(guildId, false); + } catch (error) { + console.warn(`Failed to remove revocation list for ${guildId}:`, error); + } +} + +async function updateRevocationIndex(guildId: string, add: boolean): Promise { + try { + const raw = await migratingSecureStorage.getItem(REVOCATION_STORAGE_KEYS.REVOCATION_INDEX); + const index: string[] = raw ? JSON.parse(raw) : []; + + if (add && !index.includes(guildId)) { + index.push(guildId); + } else if (!add) { + const idx = index.indexOf(guildId); + if (idx !== -1) index.splice(idx, 1); + } + + await migratingSecureStorage.setItem(REVOCATION_STORAGE_KEYS.REVOCATION_INDEX, JSON.stringify(index)); + } catch (error) { + console.warn('Failed to update revocation index:', error); + } +} + +export async function clearRevocationStorage(): Promise { + try { + const guilds = await getAllCachedGuilds(); + const removePromises = guilds.map(async (gid) => { + const listKey = `${REVOCATION_STORAGE_KEYS.REVOCATION_LIST}${gid}`; + const syncKey = `${REVOCATION_STORAGE_KEYS.SYNC_STATUS}${gid}`; + await Promise.all([ + migratingSecureStorage.removeItem(listKey), + migratingSecureStorage.removeItem(syncKey), + ]); + }); + await Promise.all([...removePromises, migratingSecureStorage.removeItem(REVOCATION_STORAGE_KEYS.REVOCATION_INDEX)]); + } catch (error) { + console.error('Failed to clear revocation storage:', error); + } +} + +async function getAllCachedGuilds(): Promise { + try { + const raw = await migratingSecureStorage.getItem(REVOCATION_STORAGE_KEYS.REVOCATION_INDEX); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} \ No newline at end of file diff --git a/src/features/attestation/revocationListSync.ts b/src/features/attestation/revocationListSync.ts new file mode 100644 index 0000000..c27c836 --- /dev/null +++ b/src/features/attestation/revocationListSync.ts @@ -0,0 +1,241 @@ +/** + * Revocation list sync service + */ + +import type { RevocationList, RevocationSyncStatus } from "./revocationList.types"; +import { RevocationListError, REVOCATION_ERROR_CODES } from "./revocationList.types"; +import { setCachedRevocationList } from "./revocationListCache"; +import { validateRevocationList } from "./revocationListVerifier"; +import { guildPassClient } from "../../lib/guildpassClient"; + +export type FetchRevocationListFn = (guildId: string) => Promise; +export type FetchAuthorityAddressFn = (guildId: string) => Promise; + +export interface RevocationSyncConfig { + fetchRevocationList: FetchRevocationListFn; + fetchAuthorityAddress: FetchAuthorityAddressFn; + maxConcurrentSyncs: number; + syncTimeoutMs: number; + enableAutoSync: boolean; + autoSyncIntervalMs: number; +} + +const DEFAULT_SYNC_CONFIG: RevocationSyncConfig = { + fetchRevocationList: async (guildId: string) => { + const response = await guildPassClient.guilds.getGuildConfig({ guildId }); + return extractRevocationListFromConfig(response, guildId); + }, + fetchAuthorityAddress: async (guildId: string) => { + const config = await guildPassClient.guilds.getGuildConfig({ guildId }); + return config.revocationAuthority || config.issuerAddress || config.ownerAddress; + }, + maxConcurrentSyncs: 3, + syncTimeoutMs: 30000, + enableAutoSync: true, + autoSyncIntervalMs: 5 * 60 * 1000, +}; + +function extractRevocationListFromConfig(config: any, guildId: string): RevocationList { + const revocationData = config.revocationList || config.attestationRevocationList; + + if (!revocationData) { + return { + guildId, + version: 1, + issuedAt: Date.now(), + expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, + revokedKeys: [], + signature: '0x', + }; + } + + if (typeof revocationData === 'object' && revocationData.guildId === guildId) { + return revocationData as RevocationList; + } + + throw new RevocationListError( + REVOCATION_ERROR_CODES.CORRUPTED_DATA, + 'Invalid revocation list format in guild config' + ); +} + +export class RevocationListSyncService { + private config: RevocationSyncConfig; + private activeSyncs = new Map>(); + private autoSyncInterval: NodeJS.Timeout | null = null; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_SYNC_CONFIG, ...config }; + } + + async syncGuild(guildId: string, force: boolean = false): Promise<{ + success: boolean; + updated: boolean; + version?: number; + error?: string; + }> { + if (this.activeSyncs.has(guildId)) { + await this.activeSyncs.get(guildId); + return { success: true, updated: false }; + } + + const syncPromise = this.performGuildSync(guildId, force); + this.activeSyncs.set(guildId, syncPromise); + + try { + return await syncPromise; + } finally { + this.activeSyncs.delete(guildId); + } + } + + private async performGuildSync(guildId: string, force: boolean): Promise<{ + success: boolean; + updated: boolean; + version?: number; + error?: string; + }> { + const startTime = Date.now(); + + try { + const cached = await getCachedRevocationList(guildId); + const currentVersion = cached?.list.version; + + const freshList = await Promise.race([ + this.config.fetchRevocationList(guildId), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Sync timeout')), this.config.syncTimeoutMs) + ) + ]); + + if (!force && currentVersion !== undefined && freshList.version <= currentVersion) { + return { success: true, updated: false, version: currentVersion }; + } + + const authorityAddress = await this.config.fetchAuthorityAddress(guildId); + + const validation = await validateRevocationList(freshList, authorityAddress, currentVersion); + if (!validation.valid) { + return { success: false, updated: false, error: `Invalid revocation list: ${validation.reason}` }; + } + + await setCachedRevocationList(guildId, freshList, authorityAddress); + + console.log(`Synced revocation list for ${guildId}, version ${freshList.version}`); + return { success: true, updated: true, version: freshList.version }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn(`Failed to sync revocation list for ${guildId}:`, error); + return { success: false, updated: false, error: errorMessage }; + } + } + + async syncGuilds(guildIds: string[], force: boolean = false): Promise<{ + total: number; + successful: number; + updated: number; + failed: string[]; + }> { + const results = { + total: guildIds.length, + successful: 0, + updated: 0, + failed: [] as string[], + }; + + const batches = []; + for (let i = 0; i < guildIds.length; i += this.config.maxConcurrentSyncs) { + batches.push(guildIds.slice(i, i + this.config.maxConcurrentSyncs)); + } + + for (const batch of batches) { + const promises = batch.map(async (guildId) => { + try { + const result = await this.syncGuild(guildId, force); + if (result.success) { + results.successful++; + if (result.updated) results.updated++; + } else { + results.failed.push(guildId); + } + } catch (error) { + results.failed.push(guildId); + } + }); + + await Promise.all(promises); + } + + return results; + } + + startAutoSync(guildIds: string[]): void { + if (!this.config.enableAutoSync || this.autoSyncInterval !== null) return; + + this.autoSyncInterval = setInterval(async () => { + try { + console.log('Starting automatic revocation list sync...'); + const results = await this.syncGuilds(guildIds); + console.log(`Auto-sync completed: ${results.successful}/${results.total} successful`); + if (results.failed.length > 0) { + console.warn(`Auto-sync failed for: ${results.failed.join(', ')}`); + } + } catch (error) { + console.error('Auto-sync error:', error); + } + }, this.config.autoSyncIntervalMs); + } + + stopAutoSync(): void { + if (this.autoSyncInterval) { + clearInterval(this.autoSyncInterval); + this.autoSyncInterval = null; + } + } + + cancelAllSyncs(): void { + this.activeSyncs.clear(); + } + + updateConfig(newConfig: Partial): void { + this.config = { ...this.config, ...newConfig }; + } + + destroy(): void { + this.stopAutoSync(); + this.cancelAllSyncs(); + } +} + +let globalSyncService: RevocationListSyncService | null = null; + +export function getRevocationSyncService(config?: Partial): RevocationListSyncService { + if (!globalSyncService) { + globalSyncService = new RevocationListSyncService(config); + } else if (config) { + globalSyncService.updateConfig(config); + } + return globalSyncService; +} + +export function initializeRevocationSync(config?: Partial): RevocationListSyncService { + globalSyncService = new RevocationListSyncService(config); + return globalSyncService; +} + +export async function syncRevocationList(guildId: string, force?: boolean) { + return getRevocationSyncService().syncGuild(guildId, force); +} + +export async function syncRevocationLists(guildIds: string[], force?: boolean) { + return getRevocationSyncService().syncGuilds(guildIds, force); +} + +export function startRevocationAutoSync(guildIds: string[]) { + getRevocationSyncService().startAutoSync(guildIds); +} + +export function stopRevocationAutoSync() { + getRevocationSyncService().stopAutoSync(); +} \ No newline at end of file diff --git a/src/features/attestation/revocationListVerifier.ts b/src/features/attestation/revocationListVerifier.ts new file mode 100644 index 0000000..c64ba8d --- /dev/null +++ b/src/features/attestation/revocationListVerifier.ts @@ -0,0 +1,198 @@ +/** + * Revocation list signature verification and validation + */ + +import { verifyMessage, isAddress } from "viem"; +import type { RevocationList, CachedRevocationList, RevocationCheckResult, RevocationVerificationContext } from "./revocationList.types"; +import { RevocationListError, REVOCATION_ERROR_CODES, REVOCATION_DEFAULTS } from "./revocationList.types"; + +export async function verifyRevocationListSignature( + list: RevocationList, + authorityAddress: string +): Promise<{ valid: boolean; reason?: string }> { + try { + if (!isAddress(authorityAddress)) { + return { valid: false, reason: `Invalid authority address: ${authorityAddress}` }; + } + + const message = createRevocationMessage(list); + const isValid = await verifyMessage({ + address: authorityAddress, + message, + signature: list.signature as `0x${string}`, + }); + + return { valid: isValid }; + } catch (error) { + return { valid: false, reason: String(error) }; + } +} + +function createRevocationMessage(list: RevocationList): string { + const sortedKeys = [...list.revokedKeys].sort((a, b) => { + const addr = a.address.localeCompare(b.address); + if (addr !== 0) return addr; + return (a.keyId || '').localeCompare(b.keyId || ''); + }); + const payload = { + guildId: list.guildId, + version: list.version, + issuedAt: list.issuedAt, + expiresAt: list.expiresAt, + revokedKeys: sortedKeys.map(k => ({ + ...(k.keyId && { keyId: k.keyId }), + address: k.address.toLowerCase(), + revokedAt: k.revokedAt, + })), + }; + return `GuildPass Revocation List v1\n${JSON.stringify(payload)}`; +} + +export function validateRevocationListStructure( + list: RevocationList, + maxSize: number = REVOCATION_DEFAULTS.MAX_LIST_SIZE +): { valid: boolean; reason?: string } { + if (!list.guildId || typeof list.guildId !== 'string') { + return { valid: false, reason: 'Invalid or missing guildId' }; + } + if (!Number.isInteger(list.version) || list.version < 0) { + return { valid: false, reason: 'Invalid version number' }; + } + if (!Number.isInteger(list.issuedAt) || list.issuedAt <= 0) { + return { valid: false, reason: 'Invalid issuedAt timestamp' }; + } + if (!Number.isInteger(list.expiresAt) || list.expiresAt <= list.issuedAt) { + return { valid: false, reason: 'Invalid expiresAt timestamp' }; + } + if (!Array.isArray(list.revokedKeys)) { + return { valid: false, reason: 'revokedKeys must be an array' }; + } + if (list.revokedKeys.length > maxSize) { + return { valid: false, reason: `List too large: ${list.revokedKeys.length} > ${maxSize}` }; + } + if (!list.signature || typeof list.signature !== 'string') { + return { valid: false, reason: 'Invalid or missing signature' }; + } + + for (let i = 0; i < list.revokedKeys.length; i++) { + const key = list.revokedKeys[i]; + if (!key.address || !isAddress(key.address)) { + return { valid: false, reason: `Invalid address at index ${i}` }; + } + if (!Number.isInteger(key.revokedAt) || key.revokedAt <= 0) { + return { valid: false, reason: `Invalid revokedAt at index ${i}` }; + } + if (key.keyId !== undefined && (typeof key.keyId !== 'string' || key.keyId.trim() === '')) { + return { valid: false, reason: `Invalid keyId at index ${i}` }; + } + if (key.reason !== undefined && !['compromised', 'rotation', 'admin', 'other'].includes(key.reason)) { + return { valid: false, reason: `Invalid reason at index ${i}` }; + } + } + + return { valid: true }; +} + +export function isRevocationListExpired(list: RevocationList, now: number = Date.now()): boolean { + return now > list.expiresAt; +} + +export function isWithinTrustWindow( + cachedAt: number, + now: number, + trustWindowMs: number = REVOCATION_DEFAULTS.OFFLINE_TRUST_WINDOW_MS +): boolean { + return (now - cachedAt) <= trustWindowMs; +} + +export function isCacheFresh( + cachedAt: number, + now: number, + ttlMs: number = REVOCATION_DEFAULTS.CACHE_TTL_MS +): boolean { + return (now - cachedAt) <= ttlMs; +} + +export function checkKeyRevocationStatus( + cached: CachedRevocationList, + context: RevocationVerificationContext, + keyAddress?: string, + keyId?: string +): RevocationCheckResult { + const now = context.now; + const config = context.config; + + if (isRevocationListExpired(cached.list, now)) { + return { status: 'unavailable', reason: 'Revocation list expired', failClosed: true }; + } + + const cacheAge = now - cached.cachedAt; + const isFresh = isCacheFresh(cached.cachedAt, now, config.cacheTtlMs); + const withinTrustWindow = isWithinTrustWindow(cached.cachedAt, now, config.offlineTrustWindowMs); + + if (!isFresh && !withinTrustWindow) { + return { + status: 'unavailable', + reason: `Revocation data stale: ${Math.round(cacheAge / 1000 / 60)} minutes`, + failClosed: config.failClosed, + dataAge: cacheAge, + }; + } + + if (!cached.signatureVerified && !isFresh && config.failClosed) { + return { status: 'unavailable', reason: 'Signature not verified', failClosed: true }; + } + + const isRevoked = cached.list.revokedKeys.some(revokedKey => { + const addrMatch = keyAddress && revokedKey.address.toLowerCase() === keyAddress.toLowerCase(); + const kidMatch = keyId && revokedKey.keyId === keyId; + return (keyAddress && !keyId && addrMatch) || (keyId && kidMatch); + }); + + if (isRevoked) { + const revokedEntry = cached.list.revokedKeys.find(revokedKey => { + const addrMatch = keyAddress && revokedKey.address.toLowerCase() === keyAddress.toLowerCase(); + const kidMatch = keyId && revokedKey.keyId === keyId; + return (keyAddress && !keyId && addrMatch) || (keyId && kidMatch); + }); + return { + status: 'revoked', + reason: `Key revoked at ${new Date(revokedEntry?.revokedAt || 0).toISOString()}${revokedEntry?.reason ? ` (${revokedEntry.reason})` : ''}`, + }; + } + + return { status: 'valid', reason: isFresh ? 'Key not in fresh list' : 'Key not in cached list' }; +} + +export function validateVersionProgression( + newVersion: number, + currentVersion?: number +): { valid: boolean; reason?: string } { + if (currentVersion === undefined) return { valid: true }; + if (newVersion <= currentVersion) { + return { valid: false, reason: `Version rollback: ${newVersion} <= ${currentVersion}` }; + } + return { valid: true }; +} + +export async function validateRevocationList( + list: RevocationList, + authorityAddress: string, + currentVersion?: number, + maxSize: number = REVOCATION_DEFAULTS.MAX_LIST_SIZE +): Promise<{ valid: boolean; reason?: string }> { + const structure = validateRevocationListStructure(list, maxSize); + if (!structure.valid) return structure; + + const version = validateVersionProgression(list.version, currentVersion); + if (!version.valid) return version; + + if (isRevocationListExpired(list)) { + return { valid: false, reason: 'Revocation list expired' }; + } + + const signature = await verifyRevocationListSignature(list, authorityAddress); + if (!signature.valid) return signature; + + return { valid: true }; +} \ No newline at end of file diff --git a/src/features/attestation/verifySignature.ts b/src/features/attestation/verifySignature.ts index aba42bb..a245861 100644 --- a/src/features/attestation/verifySignature.ts +++ b/src/features/attestation/verifySignature.ts @@ -37,6 +37,7 @@ import { ATTESTATION_REVOCATION_REASONS, } from "./types"; import { checkIssuerKeyRevoked } from "./issuerKeyRegistry"; +import { checkRevocationStatus } from "./revocationListCache"; /** * Verifies an attestation signature against a known issuer public key @@ -110,6 +111,10 @@ export function checkAttestationExpiry(attestation: RoleAttestation): { /** * Checks whether the attestation's issuer key has been revoked. * + * Uses dual revocation checking: + * 1. Legacy issuer key registry (for backward compatibility) + * 2. New revocation list distribution (for enhanced offline support) + * * Returns one of three outcomes via the result object: * - `{ revoked: false }` — key is definitively NOT revoked * - `{ revoked: true }` — key IS revoked, attestation should be rejected @@ -122,19 +127,64 @@ export function checkAttestationExpiry(attestation: RoleAttestation): { * * @param guildId Guild to check against. * @param issuerAddress Issuer address that signed the attestation. + * @param kid Optional key ID for multi-key scenarios. */ export async function checkAttestationRevocation( guildId: string, issuerAddress: `0x${string}`, -): Promise<{ revoked: boolean; unavailable?: boolean }> { - const isRevoked = await checkIssuerKeyRevoked(guildId, issuerAddress); - - if (isRevoked === null) { - // Revocation data unavailable — fail closed - return { revoked: true, unavailable: true }; + kid?: string, +): Promise<{ revoked: boolean; unavailable?: boolean; source?: 'legacy' | 'revocation_list' }> { + // Check legacy issuer key registry first (existing system) + const legacyResult = await checkIssuerKeyRevoked(guildId, issuerAddress); + + if (legacyResult === true) { + return { revoked: true, source: 'legacy' }; } - return { revoked: isRevoked }; + // Check new revocation list system + try { + const revocationResult = await checkRevocationStatus( + guildId, + issuerAddress, + kid, + true // assume network available for now + ); + + switch (revocationResult.status) { + case 'revoked': + return { revoked: true, source: 'revocation_list' }; + + case 'valid': + // Both systems agree key is valid + return { revoked: false }; + + case 'unavailable': + // New system unavailable, fall back to legacy result + if (legacyResult === null) { + // Both systems unavailable — fail closed + return { revoked: true, unavailable: true }; + } + // Legacy system has data + return { revoked: legacyResult }; + + case 'unknown': + default: + // Key not found in revocation list, rely on legacy result + if (legacyResult === null) { + // No data from either system — fail closed + return { revoked: true, unavailable: true }; + } + return { revoked: legacyResult }; + } + } catch (error) { + console.warn(`Revocation list check failed for ${guildId}:`, error); + + // Fall back to legacy system on error + if (legacyResult === null) { + return { revoked: true, unavailable: true }; + } + return { revoked: legacyResult }; + } } /** @@ -164,7 +214,11 @@ export async function validateAttestation( } // ── 2. Revocation check (in-memory lookup) ── - const revocationResult = await checkAttestationRevocation(attestation.guildId, issuerAddress); + const revocationResult = await checkAttestationRevocation( + attestation.guildId, + issuerAddress, + (attestation as any).kid // Support optional kid field + ); if (revocationResult.revoked) { if (revocationResult.unavailable) { diff --git a/tests/features/attestation/revocationList.test.ts b/tests/features/attestation/revocationList.test.ts new file mode 100644 index 0000000..1e30734 --- /dev/null +++ b/tests/features/attestation/revocationList.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for revocation list distribution + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { RevocationList, RevokedKeyEntry } from '../../../src/features/attestation/revocationList.types'; + +describe('Revocation List Types', () => { + describe('RevocationList', () => { + it('should validate required fields', () => { + const list: RevocationList = { + guildId: 'test-guild', + version: 1, + issuedAt: Date.now(), + expiresAt: Date.now() + 24 * 60 * 60 * 1000, + revokedKeys: [], + signature: '0x123', + }; + + expect(list.guildId).toBe('test-guild'); + expect(list.version).toBe(1); + }); + + it('should include optional fields', () => { + const list: RevocationList = { + guildId: 'test-guild', + version: 1, + issuedAt: Date.now(), + expiresAt: Date.now() + 24 * 60 * 60 * 1000, + revokedKeys: [ + { + keyId: 'key1', + address: '0x1234567890123456789012345678901234567890', + revokedAt: Date.now(), + reason: 'compromised', + }, + ], + signature: '0x123', + }; + + expect(list.revokedKeys[0].keyId).toBe('key1'); + expect(list.revokedKeys[0].reason).toBe('compromised'); + }); + }); +}); + +describe('RevocationCheckResult', () => { + it('should support all status values', () => { + const validResult: any = { status: 'valid' }; + const revokedResult: any = { status: 'revoked' }; + const unknownResult: any = { status: 'unknown' }; + const unavailableResult: any = { status: 'unavailable', failClosed: true }; + + expect(validResult.status).toBe('valid'); + expect(revokedResult.status).toBe('revoked'); + expect(unknownResult.status).toBe('unknown'); + expect(unavailableResult.status).toBe('unavailable'); + expect(unavailableResult.failClosed).toBe(true); + }); +}); + +describe('RevocationListError', () => { + it('should create error with code and message', () => { + const error = new Error('test') as any; + error.code = 'REVOCATION_CORRUPTED_DATA'; + error.name = 'RevocationListError'; + + expect(error.name).toBe('RevocationListError'); + }); +}); \ No newline at end of file diff --git a/tests/features/attestation/revocationListSecurity.test.ts b/tests/features/attestation/revocationListSecurity.test.ts new file mode 100644 index 0000000..01b72ea --- /dev/null +++ b/tests/features/attestation/revocationListSecurity.test.ts @@ -0,0 +1,103 @@ +/** + * Security tests for revocation list distribution + */ + +import { describe, it, expect } from 'vitest'; +import { RevocationList, REVOCATION_DEFAULTS, REVOCATION_ERROR_CODES } from '../../../src/features/attestation/revocationList.types'; + +describe('Revocation List Security', () => { + describe('Version Monotonicity', () => { + it('should reject version rollback', () => { + const currentVersion = 5; + const newVersion = 3; + + const isValid = newVersion > currentVersion; + expect(isValid).toBe(false); + }); + + it('should accept version increment', () => { + const currentVersion = 5; + const newVersion = 6; + + const isValid = newVersion > currentVersion; + expect(isValid).toBe(true); + }); + + it('should accept version 1 as first version', () => { + const newVersion = 1; + expect(newVersion).toBeGreaterThan(0); + }); + }); + + describe('Signature Verification', () => { + it('should require valid signature format', () => { + const validSig = '0x' + 'a'.repeat(130); + const invalidSig1 = 'invalid'; + const invalidSig2 = ''; + + expect(validSig).toMatch(/^0x[a-fA-F0-9]+$/); + expect(invalidSig1).not.toMatch(/^0x[a-fA-F0-9]+$/); + expect(invalidSig2).not.toMatch(/^0x[a-fA-F0-9]+$/); + }); + }); + + describe('Content Integrity', () => { + it('should use content hash for integrity checking', () => { + const list = { + guildId: 'test-guild', + version: 1, + issuedAt: Date.now(), + expiresAt: Date.now() + 24 * 60 * 60 * 1000, + revokedKeys: [], + signature: '0x123', + }; + + const serialized = JSON.stringify(list); + expect(serialized).toContain('test-guild'); + expect(serialized).toContain('1'); + }); + }); + + describe('Trust Window Enforcement', () => { + it('should enforce 24 hour offline trust window', () => { + const trustWindowMs = REVOCATION_DEFAULTS.OFFLINE_TRUST_WINDOW_MS; + expect(trustWindowMs).toBe(24 * 60 * 60 * 1000); + }); + + it('should enforce 15 minute memory cache TTL', () => { + const ttlMs = REVOCATION_DEFAULTS.CACHE_TTL_MS; + expect(ttlMs).toBe(15 * 60 * 1000); + }); + }); + + describe('Fail-Closed Policy', () => { + it('should fail closed when revocation data unavailable', () => { + const config = { + failClosed: true, + }; + + expect(config.failClosed).toBe(true); + }); + + it('should have default fail-closed behavior', () => { + expect(REVOCATION_DEFAULTS.FAIL_CLOSED).toBe(true); + }); + }); + + describe('List Size Limit', () => { + it('should enforce max list size', () => { + const maxSize = REVOCATION_DEFAULTS.MAX_LIST_SIZE; + expect(maxSize).toBe(10000); + }); + + it('should reject lists exceeding max size', () => { + const largeList = Array.from({ length: 10001 }, (_, i) => ({ + keyId: `key${i}`, + address: `0x${i.toString().padEnd(40, '0')}`, + revokedAt: Date.now(), + })); + + expect(largeList.length).toBeGreaterThan(REVOCATION_DEFAULTS.MAX_LIST_SIZE); + }); + }); +}); \ No newline at end of file