UNPKG

schemapin

Version:

Cryptographic schema integrity verification for AI tools

472 lines (421 loc) 15.3 kB
/** * Key pinning storage and management for Trust-On-First-Use (TOFU). */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { InteractivePinningManager, UserDecision, PromptType, InteractiveHandler } from './interactive.js'; /** * Pinning operation modes. */ export const PinningMode = { AUTOMATIC: 'automatic', INTERACTIVE: 'interactive', STRICT: 'strict' }; /** * Per-domain pinning policies. */ export const PinningPolicy = { DEFAULT: 'default', ALWAYS_TRUST: 'always_trust', NEVER_TRUST: 'never_trust', INTERACTIVE_ONLY: 'interactive_only' }; /** * Manages key pinning storage using JSON file. */ export class KeyPinning { /** * Initialize key pinning storage. * * @param {string|null} dbPath - Path to JSON storage file. If null, uses default location. * @param {string} mode - Pinning operation mode (automatic, interactive, strict) * @param {InteractiveHandler|null} interactiveHandler - Handler for interactive prompts */ constructor(dbPath = null, mode = PinningMode.AUTOMATIC, interactiveHandler = null) { if (dbPath === null) { const homeDir = homedir(); dbPath = join(homeDir, '.schemapin', 'pinned_keys.json'); } this.dbPath = dbPath; this.mode = mode; this.interactiveManager = mode === PinningMode.INTERACTIVE ? new InteractivePinningManager(interactiveHandler) : null; this._ensureDbDirectory(); this._initDatabase(); } /** * Ensure database directory exists. * @private */ _ensureDbDirectory() { const dir = dirname(this.dbPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } /** * Initialize database file. * @private */ _initDatabase() { if (!existsSync(this.dbPath)) { this._saveData({ pinned_keys: {}, domain_policies: {} }); } else { // Migrate old format if needed const data = this._loadData(); if (!data.pinned_keys && !data.domain_policies) { // Old format - migrate this._saveData({ pinned_keys: data, domain_policies: {} }); } } } /** * Load data from JSON file. * @private * @returns {Object} Pinned keys data */ _loadData() { try { const data = readFileSync(this.dbPath, 'utf8'); const parsed = JSON.parse(data); // Handle both old and new format if (parsed.pinned_keys !== undefined) { return parsed; } else { // Old format return { pinned_keys: parsed, domain_policies: {} }; } } catch (error) { return { pinned_keys: {}, domain_policies: {} }; } } /** * Save data to JSON file. * @private * @param {Object} data - Data to save */ _saveData(data) { writeFileSync(this.dbPath, JSON.stringify(data, null, 2), 'utf8'); } /** * Pin a public key for a tool. * * @param {string} toolId - Unique tool identifier * @param {string} publicKeyPem - PEM-encoded public key * @param {string} domain - Tool provider domain * @param {string|null} developerName - Optional developer name * @returns {boolean} True if key was pinned successfully, false if already exists */ pinKey(toolId, publicKeyPem, domain, developerName = null) { const data = this._loadData(); if (toolId in data.pinned_keys) { return false; // Key already exists } data.pinned_keys[toolId] = { public_key_pem: publicKeyPem, domain: domain, developer_name: developerName, pinned_at: new Date().toISOString(), last_verified: null }; this._saveData(data); return true; } /** * Get pinned public key for tool. * * @param {string} toolId - Tool identifier * @returns {string|null} PEM-encoded public key if pinned, null otherwise */ getPinnedKey(toolId) { const data = this._loadData(); const keyInfo = data.pinned_keys[toolId]; return keyInfo ? keyInfo.public_key_pem : null; } /** * Check if key is pinned for tool. * * @param {string} toolId - Tool identifier * @returns {boolean} True if key is pinned, false otherwise */ isKeyPinned(toolId) { return this.getPinnedKey(toolId) !== null; } /** * Update last verification timestamp for tool. * * @param {string} toolId - Tool identifier * @returns {boolean} True if updated successfully, false if tool not found */ updateLastVerified(toolId) { const data = this._loadData(); if (!(toolId in data.pinned_keys)) { return false; } data.pinned_keys[toolId].last_verified = new Date().toISOString(); this._saveData(data); return true; } /** * List all pinned keys with metadata. * * @returns {Array} Array of objects containing key information */ listPinnedKeys() { const data = this._loadData(); return Object.entries(data.pinned_keys) .map(([toolId, keyInfo]) => ({ tool_id: toolId, domain: keyInfo.domain, developer_name: keyInfo.developer_name, pinned_at: keyInfo.pinned_at, last_verified: keyInfo.last_verified })) .sort((a, b) => new Date(b.pinned_at) - new Date(a.pinned_at)); } /** * Remove pinned key for tool. * * @param {string} toolId - Tool identifier * @returns {boolean} True if removed successfully, false if not found */ removePinnedKey(toolId) { const data = this._loadData(); if (!(toolId in data.pinned_keys)) { return false; } delete data.pinned_keys[toolId]; this._saveData(data); return true; } /** * Get complete information about pinned key. * * @param {string} toolId - Tool identifier * @returns {Object|null} Object with key information if found, null otherwise */ getKeyInfo(toolId) { const data = this._loadData(); const keyInfo = data.pinned_keys[toolId]; if (!keyInfo) { return null; } return { tool_id: toolId, public_key_pem: keyInfo.public_key_pem, domain: keyInfo.domain, developer_name: keyInfo.developer_name, pinned_at: keyInfo.pinned_at, last_verified: keyInfo.last_verified }; } /** * Export all pinned keys to JSON format. * * @returns {string} JSON string containing all pinned keys */ exportPinnedKeys() { const keys = this.listPinnedKeys(); // Add public key data for export keys.forEach(keyInfo => { keyInfo.public_key_pem = this.getPinnedKey(keyInfo.tool_id); }); return JSON.stringify(keys, null, 2); } /** * Import pinned keys from JSON format. * * @param {string} jsonData - JSON string containing key data * @param {boolean} overwrite - Whether to overwrite existing keys * @returns {number} Number of keys imported */ importPinnedKeys(jsonData, overwrite = false) { try { const keys = JSON.parse(jsonData); let imported = 0; for (const keyInfo of keys) { if (overwrite && this.isKeyPinned(keyInfo.tool_id)) { this.removePinnedKey(keyInfo.tool_id); } if (this.pinKey( keyInfo.tool_id, keyInfo.public_key_pem, keyInfo.domain, keyInfo.developer_name )) { imported++; } } return imported; } catch (error) { return 0; } } /** * Set pinning policy for a domain. * * @param {string} domain - Domain to set policy for * @param {string} policy - Pinning policy to apply * @returns {boolean} True if policy was set successfully */ setDomainPolicy(domain, policy) { try { const data = this._loadData(); data.domain_policies[domain] = { policy: policy, created_at: new Date().toISOString() }; this._saveData(data); return true; } catch (error) { return false; } } /** * Get pinning policy for a domain. * * @param {string} domain - Domain to get policy for * @returns {string} Pinning policy for domain, defaults to DEFAULT */ getDomainPolicy(domain) { const data = this._loadData(); const policyInfo = data.domain_policies[domain]; return policyInfo ? policyInfo.policy : PinningPolicy.DEFAULT; } /** * Pin a key with interactive user confirmation. * * @param {string} toolId - Unique tool identifier * @param {string} publicKeyPem - PEM-encoded public key * @param {string} domain - Tool provider domain * @param {string|null} developerName - Optional developer name * @param {boolean} forcePrompt - Force interactive prompt even in automatic mode * @returns {Promise<boolean>} True if key was pinned, false if rejected or error */ async interactivePinKey(toolId, publicKeyPem, domain, developerName = null, forcePrompt = false) { // Check domain policy first const domainPolicy = this.getDomainPolicy(domain); if (domainPolicy === PinningPolicy.NEVER_TRUST) { return false; } else if (domainPolicy === PinningPolicy.ALWAYS_TRUST) { return this.pinKey(toolId, publicKeyPem, domain, developerName); } // Check if key is already pinned const existingKey = this.getPinnedKey(toolId); if (existingKey) { if (existingKey === publicKeyPem) { // Same key, just update verification time this.updateLastVerified(toolId); return true; } else { // Different key - handle key change return await this._handleKeyChange(toolId, domain, existingKey, publicKeyPem, developerName); } } // First-time key encounter return await this._handleFirstTimeKey(toolId, domain, publicKeyPem, developerName, forcePrompt); } /** * Handle first-time key encounter. * @private */ async _handleFirstTimeKey(toolId, domain, publicKeyPem, developerName, forcePrompt) { // Note: In a full implementation, you'd check revocation here // For now, we'll assume the key is not revoked // Automatic mode without force prompt if (this.mode === PinningMode.AUTOMATIC && !forcePrompt) { return this.pinKey(toolId, publicKeyPem, domain, developerName); } // Interactive mode or forced prompt if (this.interactiveManager) { // Note: In a full implementation, you'd fetch developer info here const developerInfo = { developer_name: developerName }; const decision = await this.interactiveManager.promptFirstTimeKey( toolId, domain, publicKeyPem, developerInfo ); if (decision === UserDecision.ACCEPT) { return this.pinKey(toolId, publicKeyPem, domain, developerName); } else if (decision === UserDecision.ALWAYS_TRUST) { this.setDomainPolicy(domain, PinningPolicy.ALWAYS_TRUST); return this.pinKey(toolId, publicKeyPem, domain, developerName); } else if (decision === UserDecision.NEVER_TRUST) { this.setDomainPolicy(domain, PinningPolicy.NEVER_TRUST); return false; } else if (decision === UserDecision.TEMPORARY_ACCEPT) { // Don't pin, but allow this verification return true; } } return false; } /** * Handle key change scenario. * @private */ async _handleKeyChange(toolId, domain, currentKeyPem, newKeyPem, developerName) { // Note: In a full implementation, you'd check revocation here // In strict mode, always reject key changes if (this.mode === PinningMode.STRICT) { return false; } // Interactive prompt for key change if (this.interactiveManager) { const currentKeyInfo = this.getKeyInfo(toolId); // Note: In a full implementation, you'd fetch developer info here const developerInfo = { developer_name: developerName }; const decision = await this.interactiveManager.promptKeyChange( toolId, domain, currentKeyPem, newKeyPem, currentKeyInfo, developerInfo ); if (decision === UserDecision.ACCEPT) { // Remove old key and pin new one this.removePinnedKey(toolId); return this.pinKey(toolId, newKeyPem, domain, developerName); } else if (decision === UserDecision.ALWAYS_TRUST) { this.setDomainPolicy(domain, PinningPolicy.ALWAYS_TRUST); this.removePinnedKey(toolId); return this.pinKey(toolId, newKeyPem, domain, developerName); } else if (decision === UserDecision.NEVER_TRUST) { this.setDomainPolicy(domain, PinningPolicy.NEVER_TRUST); return false; } else if (decision === UserDecision.TEMPORARY_ACCEPT) { // Don't update pinned key, but allow this verification return true; } } return false; } /** * Verify and potentially pin a key with interactive prompts. * * @param {string} toolId - Tool identifier * @param {string} domain - Tool provider domain * @param {string} publicKeyPem - PEM-encoded public key to verify * @param {string|null} developerName - Optional developer name * @returns {Promise<boolean>} True if key is verified/pinned and can be used, false otherwise */ async verifyWithInteractivePinning(toolId, domain, publicKeyPem, developerName = null) { return await this.interactivePinKey(toolId, publicKeyPem, domain, developerName); } }