/* 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;
})();
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.