User:MSK/proof.js

/* proof.js - wikipedia identity verification without editing * for peerjs(https://peerjs.com/) * * Usage in another Wikipedia userscript: * * Option 1:

/* proof.js - wikipedia identity verification without editing 
 * for peerjs(https://peerjs.com/)
 * 
 * Usage in another Wikipedia userscript:
 * 
 * Option 1: Load via MediaWiki ResourceLoader
 *   mw.loader.load('https://en.wikipedia.org/wiki/User:Monkeysmashingkeyboards/proof.js?action=raw', 'text/javascript');
 *   // Then wait and access via: window.proof or mw.proof
 * 
 * Option 2: Dynamic script tag
 *   const script = document.createElement('script');
 *   script.src = 'https://en.wikipedia.org/wiki/User:Monkeysmashingkeyboards/proof.js?action=raw';
 *   document.head.appendChild(script);
 *   // Access via: window.proof or mw.proof after load
 * 
 * Option 3: If both scripts are on the same page, just use:
 *   window.proof or mw.proof (available immediately)
 */

(function () {
    'use strict';
  
    const OPTION_NAME = 'userjs-proof-privatekey';
    const PUBLIC_KEY_PAGE = 'public.js';
    const CHALLENGE_TIMEOUT_MS = 30000;
    const TIMESTAMP_TOLERANCE_MS = 60000; // 1 minute tolerance for clock skew
  
    const proof = {
      publicKey: null,
      privateKey: null,
      _api: null,
  
      get api() {
        if (!this._api) this._api = new mw.Api();
        return this._api;
      },

      /**
       * Load PeerJS library if not already loaded
       * @returns {Promise<void>}
       */
      async _loadPeerJS() {
        // Check if PeerJS is already loaded
        if (typeof window.Peer !== 'undefined') {
          return;
        }

        // Check if script is already being loaded
        const existingScript = document.querySelector('script[src*="peerjs"]');
        if (existingScript) {
          return new Promise((resolve, reject) => {
            existingScript.onload = () => resolve();
            existingScript.onerror = () => reject(new Error('Failed to load PeerJS'));
            // If already loaded, resolve immediately
            if (typeof window.Peer !== 'undefined') {
              resolve();
            }
          });
        }

        // Load PeerJS
        return new Promise((resolve, reject) => {
          const script = document.createElement('script');
          script.src = 'https://unpkg.com/[email protected]/dist/peerjs.min.js';
          script.onload = () => resolve();
          script.onerror = () => reject(new Error('Failed to load PeerJS'));
          document.head.appendChild(script);
        });
      },

      /**
       * Initialize the identity system
       * @param {Object} [options]
       * @param {CryptoKey} [options.publicKey] - Manually provide public key
       * @param {CryptoKey} [options.privateKey] - Manually provide private key
       * @param {boolean} [options.autoSetup=true] - Auto-run setup if keys don't match
       * @returns {Promise<proof>}
       */
      async init(options = {}) {
        // Load PeerJS library
        await this._loadPeerJS();

        const { publicKey = null, privateKey = null, autoSetup = true } = options;
  
        if (publicKey && privateKey) {
          this.publicKey = publicKey;
          this.privateKey = privateKey;
          return this;
        }
  
        const username = mw.config.get('wgUserName');
        if (!username) throw new Error('Not logged in to Wikipedia');
  
        const privateKeyJwk = await this._getPrivateKeyFromPrefs();
        const publicKeyJwk = await this._getPublicKeyJwk(username);
  
        if (!privateKeyJwk || !publicKeyJwk) {
          if (autoSetup) {
            await this.setup();
            return this;
          }
          throw new Error('Keys not found and autoSetup is disabled');
        }
  
        this.privateKey = await crypto.subtle.importKey(
          'jwk',
          JSON.parse(privateKeyJwk),
          { name: 'ECDH', namedCurve: 'P-256' },
          true,
          ['deriveBits']
        );
  
        this.publicKey = await crypto.subtle.importKey(
          'jwk',
          JSON.parse(publicKeyJwk),
          { name: 'ECDH', namedCurve: 'P-256' },
          true,
          []
        );
  
        if (!(await this._keysMatch())) {
          if (autoSetup) {
            console.warn('proof: Keys mismatch, running setup...');
            await this.setup();
          } else {
            throw new Error('Public and private keys do not match');
          }
        }
  
        return this;
      },
  
      // ========== VERIFICATION (as verifier) ==========
  
      /**
       * Verify a peer claims to be a Wikipedia user
       * @param {DataConnection} conn - PeerJS DataConnection
       * @param {string} username - Wikipedia username to verify
       * @returns {Promise<boolean>}
       */
      async verifyPeer(conn, username) {
        const publicKey = await this.getPublicKey(username);
        const { nonce, timestamp, encrypted } = await this.createChallenge(publicKey);
  
        return new Promise((resolve, reject) => {
          const timeout = setTimeout(() => {
            conn.off('data', handler);
            reject(new Error('Verification timeout'));
          }, CHALLENGE_TIMEOUT_MS);
  
          const handler = async (data) => {
            if (data?.type !== 'identity-response') return;
            conn.off('data', handler);
            clearTimeout(timeout);
            resolve(this.verifyResponse(nonce, timestamp, data.decrypted, data.timestamp));
          };
  
          conn.on('data', handler);
          conn.send({ type: 'identity-challenge', encrypted, username });
        });
      },
  
      /**
       * Create a challenge for manual verification flow
       * @param {CryptoKey} peerPublicKey - Target user's public key
       * @returns {Promise<{nonce: Uint8Array, timestamp: number, encrypted: Object}>}
       */
      async createChallenge(peerPublicKey) {
        const nonce = crypto.getRandomValues(new Uint8Array(32));
        const timestamp = Date.now();
        
        const payload = {
          nonce: Array.from(nonce),
          timestamp
        };
        
        const encrypted = await this._encrypt(
          peerPublicKey,
          new TextEncoder().encode(JSON.stringify(payload))
        );
        
        return { nonce, timestamp, encrypted };
      },
  
      /**
       * Verify a challenge response
       * @param {Uint8Array} originalNonce - The original nonce
       * @param {number} originalTimestamp - The original timestamp
       * @param {Uint8Array|Array} decryptedNonce - The decrypted nonce from peer
       * @param {number} responseTimestamp - The timestamp from the response
       * @returns {boolean}
       */
      verifyResponse(originalNonce, originalTimestamp, decryptedNonce, responseTimestamp) {
        // Check timestamp freshness
        const now = Date.now();
        if (Math.abs(now - originalTimestamp) > TIMESTAMP_TOLERANCE_MS) {
          console.warn('proof: Challenge expired');
          return false;
        }
        
        if (responseTimestamp !== originalTimestamp) {
          console.warn('proof: Timestamp mismatch');
          return false;
        }
  
        const decrypted = decryptedNonce instanceof Uint8Array
          ? decryptedNonce
          : new Uint8Array(decryptedNonce);
  
        if (originalNonce.length !== decrypted.length) return false;
        return originalNonce.every((byte, i) => byte === decrypted[i]);
      },
  
      /**
       * Respond to a verification challenge over PeerJS
       * @param {DataConnection} conn - PeerJS DataConnection
       * @param {Object} encrypted - The encrypted challenge
       */
      async respondToChallenge(conn, encrypted) {
        const { nonce, timestamp } = await this.decryptChallenge(encrypted);
        conn.send({
          type: 'identity-response',
          decrypted: Array.from(nonce),
          timestamp
        });
      },
  
      /**
       * Decrypt a challenge (standalone, no PeerJS)
       * @param {Object} encrypted - The encrypted challenge object
       * @returns {Promise<{nonce: Uint8Array, timestamp: number}>}
       */
      async decryptChallenge(encrypted) {
        const plaintext = await this._decrypt(encrypted);
        const payload = JSON.parse(new TextDecoder().decode(plaintext));
        return {
          nonce: new Uint8Array(payload.nonce),
          timestamp: payload.timestamp
        };
      },
  
      /**
       * Get another user's public key as CryptoKey
       * @param {string} username
       * @returns {Promise<CryptoKey>}
       */
      async getPublicKey(username) {
        const jwk = await this._getPublicKeyJwk(username);
        if (!jwk) throw new Error(`No public key found for User:${username}`);
  
        return crypto.subtle.importKey(
          'jwk',
          JSON.parse(jwk),
          { name: 'ECDH', namedCurve: 'P-256' },
          true,
          []
        );
      },
  
      /**
       * Get another user's public key as JWK string
       * @param {string} username
       * @returns {Promise<string|null>}
       */
      async getPublicKeyJwk(username) {
        return this._getPublicKeyJwk(username);
      },
  
      /**
       * Encrypt a message and send it over PeerJS
       * @param {DataConnection} conn - PeerJS DataConnection
       * @param {CryptoKey} recipientPublicKey - Recipient's public key
       * @param {any} message - Message to encrypt (will be JSON serialized)
       */
      async encryptAndSend(conn, recipientPublicKey, message) {
        const encrypted = await this.encrypt(recipientPublicKey, message);
        conn.send({ type: 'encrypted-message', encrypted });
      },
  
      /**
       * Encrypt a message (standalone, no PeerJS)
       * @param {CryptoKey} recipientPublicKey
       * @param {any} message - Will be JSON serialized
       * @returns {Promise<Object>} - Encrypted payload
       */
      async encrypt(recipientPublicKey, message) {
        const plaintext = new TextEncoder().encode(JSON.stringify(message));
        return this._encrypt(recipientPublicKey, plaintext);
      },
  
      /**
       * Listen for encrypted messages and decrypt them
       * @param {DataConnection} conn - PeerJS DataConnection
       * @param {Function} callback - Called with decrypted message
       * @returns {Function} - Cleanup function to remove listener
       */
      onDecryptedMessage(conn, callback) {
        const handler = async (data) => {
          if (data?.type !== 'encrypted-message') return;
          try {
            const message = await this.decrypt(data.encrypted);
            callback(message);
          } catch (err) {
            console.error('proof: Failed to decrypt message', err);
          }
        };
        
        conn.on('data', handler);
        return () => conn.off('data', handler);
      },
  
      /**
       * Decrypt an encrypted payload (standalone)
       * @param {Object} encrypted
       * @returns {Promise<any>} - Decrypted and JSON-parsed message
       */
      async decrypt(encrypted) {
        const plaintext = await this._decrypt(encrypted);
        return JSON.parse(new TextDecoder().decode(plaintext));
      },
  
      /**
       * Generate new keypair and save to Wikipedia
       * Public key -> User:Username/public.js
       * Private key -> userjs-proof-privatekey preference
       * @returns {Promise<proof>}
       */
      async setup() {
        const username = mw.config.get('wgUserName');
        if (!username) throw new Error('Not logged in');
  
        const keyPair = await crypto.subtle.generateKey(
          { name: 'ECDH', namedCurve: 'P-256' },
          true,
          ['deriveBits']
        );
  
        const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
        const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
  
        // Save private key to preferences
        await this.api.saveOption(OPTION_NAME, JSON.stringify(privateJwk));
  
        // Save public key to userpage (action=edit creates if doesn't exist)
        await this.api.postWithToken('csrf', {
          action: 'edit',
          title: `User:${username}/${PUBLIC_KEY_PAGE}`,
          text: JSON.stringify(publicJwk),
          summary: 'Update PeerJS identity public key'
        });
  
        this.publicKey = keyPair.publicKey;
        this.privateKey = keyPair.privateKey;
  
        console.log('proof: Setup complete');
        return this;
      },
  
      /**
       * Set up automatic challenge response listener
       * @param {DataConnection} conn - PeerJS DataConnection
       * @returns {Function} - Cleanup function to remove listener
       */
      setupChallengeListener(conn) {
        const handler = async (data) => {
          if (data?.type !== 'identity-challenge') return;
          try {
            await this.respondToChallenge(conn, data.encrypted);
          } catch (err) {
            console.error('proof: Failed to respond to challenge', err);
          }
        };
        
        conn.on('data', handler);
        return () => conn.off('data', handler);
      },
  
      async _keysMatch() {
        const [privJwk, pubJwk] = await Promise.all([
          crypto.subtle.exportKey('jwk', this.privateKey),
          crypto.subtle.exportKey('jwk', this.publicKey)
        ]);
        return privJwk.x === pubJwk.x && privJwk.y === pubJwk.y;
      },
  
      /**
       * ECIES-style encryption: ephemeral ECDH + AES-GCM
       */
      async _encrypt(recipientPublicKey, plaintext) {
        const ephemeral = await crypto.subtle.generateKey(
          { name: 'ECDH', namedCurve: 'P-256' },
          true,
          ['deriveBits']
        );
  
        const sharedBits = await crypto.subtle.deriveBits(
          { name: 'ECDH', public: recipientPublicKey },
          ephemeral.privateKey,
          256
        );
  
        const aesKey = await crypto.subtle.importKey(
          'raw',
          sharedBits,
          { name: 'AES-GCM' },
          false,
          ['encrypt']
        );
  
        const iv = crypto.getRandomValues(new Uint8Array(12));
        const ciphertext = await crypto.subtle.encrypt(
          { name: 'AES-GCM', iv },
          aesKey,
          plaintext
        );
  
        const ephemeralPubJwk = await crypto.subtle.exportKey('jwk', ephemeral.publicKey);
  
        return {
          ephemeralPublicKey: ephemeralPubJwk,
          iv: Array.from(iv),
          ciphertext: Array.from(new Uint8Array(ciphertext))
        };
      },
  
      async _decrypt(encrypted) {
        const { ephemeralPublicKey, iv, ciphertext } = encrypted;
  
        const ephemeralPub = await crypto.subtle.importKey(
          'jwk',
          ephemeralPublicKey,
          { name: 'ECDH', namedCurve: 'P-256' },
          false,
          []
        );
  
        const sharedBits = await crypto.subtle.deriveBits(
          { name: 'ECDH', public: ephemeralPub },
          this.privateKey,
          256
        );
  
        const aesKey = await crypto.subtle.importKey(
          'raw',
          sharedBits,
          { name: 'AES-GCM' },
          false,
          ['decrypt']
        );
  
        const plaintext = await crypto.subtle.decrypt(
          { name: 'AES-GCM', iv: new Uint8Array(iv) },
          aesKey,
          new Uint8Array(ciphertext)
        );
  
        return new Uint8Array(plaintext);
      },
  
      async _getPrivateKeyFromPrefs() {
        const data = await this.api.get({
          action: 'query',
          meta: 'userinfo',
          uiprop: 'options'
        });
        return data.query?.userinfo?.options?.[OPTION_NAME] || null;
      },
  
      async _getPublicKeyJwk(username) {
        const data = await this.api.get({
          action: 'query',
          titles: `User:${username}/${PUBLIC_KEY_PAGE}`,
          prop: 'revisions',
          rvprop: 'content',
          rvslots: 'main'
        });
  
        const pages = data.query?.pages;
        const pageId = Object.keys(pages)[0];
  
        if (pageId === '-1') return null;
        return pages[pageId]?.revisions?.[0]?.slots?.main?.['*'] || null;
      }
    };
  
    // Export for different module systems
    if (typeof module !== 'undefined' && module.exports) {
      module.exports = proof;
    }
    
    // AMD
    if (typeof define === 'function' && define.amd) {
      define(function() { return proof; });
    }
    
    // MediaWiki module system
    if (typeof mw !== 'undefined' && mw.loader) {
      mw.proof = proof;
    }
    
    // Global fallback
    window.proof = proof;
  
  })();

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.