|
| 1 | +(function(Scratch) { |
| 2 | + 'use strict'; |
| 3 | + |
| 4 | + function bufToHex(buffer) { |
| 5 | + const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) |
| 6 | + : buffer instanceof Uint8Array ? buffer |
| 7 | + : new Uint8Array(buffer); |
| 8 | + return Array.prototype.map.call(bytes, b => b.toString(16).padStart(2, '0')).join(''); |
| 9 | + } |
| 10 | + |
| 11 | + function hexToBuf(hex) { |
| 12 | + const clean = String(hex).replace(/[^0-9a-f]/gi, '').toLowerCase(); |
| 13 | + if (clean.length % 2 !== 0) throw new Error('Invalid hex string'); |
| 14 | + const out = new Uint8Array(clean.length / 2); |
| 15 | + for (let i = 0; i < out.length; i++) { |
| 16 | + out[i] = parseInt(clean.substr(i * 2, 2), 16); |
| 17 | + } |
| 18 | + return out.buffer; |
| 19 | + } |
| 20 | + |
| 21 | + const menuIconURI = 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIiB2aWV3Qm94PSIwLDAsMzAwLDMwMCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE1MCwwKSI+PGcgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIj48cGF0aCBkPSJNMTUwLDMwMHYtMzAwaDMwMHYzMDB6IiBmaWxsPSIjNjc2NzY3IiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMC41Ii8+PHBhdGggZD0iTTE1MCwzMDB2LTMwMGgzMDBsLTExOS4wOTA1LDE3Ny4wMzY2M3oiIGZpbGw9IiM0NTQ1NDUiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIwLjUiLz48cGF0aCBkPSJNMzExLjM3NzUzLDE0OS45OTk5OWMwLC0yMS40NjQyMSAxNy40MDAxOSwtMzguODY0MzkgMzguODY0NCwtMzguODY0MzljMjEuNDY0MjEsMCAzOC44NjQ0LDE3LjQwMDE4IDM4Ljg2NDQsMzguODY0NGMwLDIxLjQ2NDIxIC0xNy40MDAxOSwzOC44NjQ0IC0zOC44NjQzOSwzOC44NjQ0Yy0yMS40NjQyMSwwIC0zOC44NjQzOSwtMTcuNDAwMTggLTM4Ljg2NDM5LC0zOC44NjQzOXpNMzUwLjI0MTk0LDE3OS4xNDE4NGMxNi4wOTQ2LDAgMjkuMTQxODUsLTEzLjA0NzI2IDI5LjE0MTg1LC0yOS4xNDE4NWMwLC0xNi4wOTQ1OSAtMTMuMDQ3MjYsLTI5LjE0MTg1IC0yOS4xNDE4NSwtMjkuMTQxODVjLTE2LjA5NDYsMCAtMjkuMTQxODUsMTMuMDQ3MjYgLTI5LjE0MTg1LDI5LjE0MTg1YzAsMTYuMDk0NTkgMTMuMDQ3MjUsMjkuMTQxODUgMjkuMTQxODUsMjkuMTQxODV6IiBmaWxsPSIjZmY5OTAwIiBzdHJva2U9IiNmZjk5MDAiIHN0cm9rZS13aWR0aD0iNyIvPjxwYXRoIGQ9Ik0yMTAuODkzNjcsMTU2LjE3NzI4di0xMi4zNTQ1N2gxMDYuODk5NDR2MTIuMzU0NTd6IiBmaWxsPSIjZmY5OTAwIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMCIvPjxwYXRoIGQ9Ik0yMTAuODkzNjcsMTc5LjEwODM0di0zNS4yODU2M2gxMy4yMzIxMXYzNS4yODU2M3oiIGZpbGw9IiNmZjk5MDAiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIwIi8+PHBhdGggZD0iTTI0OS41MTY3MSwxNjcuODA1NXYtMjMuMjU2NDRoOC4wMTk0NnYyMy4yNTY0NHoiIGZpbGw9IiNmZjk5MDAiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIwIi8+PC9nPjwvZz48L3N2Zz48IS0tcm90YXRpb25DZW50ZXI6MTUwOjE1MC0tPg=='; |
| 22 | + |
| 23 | + class Extension { |
| 24 | + getInfo() { |
| 25 | + return { |
| 26 | + id: "gaimeriCryptoExtension", |
| 27 | + name: "Cryptography", |
| 28 | + color1: "#676767", |
| 29 | + color2: "#444444", |
| 30 | + color3: "#222222", |
| 31 | + menuIconURI, |
| 32 | + blocks: [ |
| 33 | + { blockType: Scratch.BlockType.LABEL, text: 'Random' }, |
| 34 | + { |
| 35 | + opcode: 'randomUUID', |
| 36 | + text: 'random UUID', |
| 37 | + blockType: Scratch.BlockType.REPORTER, |
| 38 | + disableMonitor: true |
| 39 | + }, |
| 40 | + { |
| 41 | + opcode: 'randomValues', |
| 42 | + text: '[SCALE] of [AMOUNT] random values', |
| 43 | + blockType: Scratch.BlockType.REPORTER, |
| 44 | + arguments: { |
| 45 | + AMOUNT: { type: Scratch.ArgumentType.NUMBER, defaultValue: 10 }, |
| 46 | + SCALE: { type: Scratch.ArgumentType.STRING, menu: 'TYPED_ARRAYS', allowReporters: false } |
| 47 | + } |
| 48 | + }, |
| 49 | + |
| 50 | + '---', |
| 51 | + { blockType: Scratch.BlockType.LABEL, text: 'Hashing' }, |
| 52 | + { |
| 53 | + opcode: 'digest', |
| 54 | + text: 'hash [VALUE] with [ALGORITHM]', |
| 55 | + blockType: Scratch.BlockType.REPORTER, |
| 56 | + arguments: { |
| 57 | + VALUE: { type: Scratch.ArgumentType.STRING, defaultValue: 'apple' }, |
| 58 | + ALGORITHM: { type: Scratch.ArgumentType.STRING, menu: 'ALGORITHM' } |
| 59 | + } |
| 60 | + }, |
| 61 | + |
| 62 | + '---', |
| 63 | + { blockType: Scratch.BlockType.LABEL, text: 'HMAC' }, |
| 64 | + { |
| 65 | + opcode: 'generateSignKey', |
| 66 | + text: 'generate HMAC key', |
| 67 | + blockType: Scratch.BlockType.REPORTER, |
| 68 | + blockShape: Scratch.BlockShape.OCTAGONAL, |
| 69 | + disableMonitor: true |
| 70 | + }, |
| 71 | + { |
| 72 | + opcode: 'signValue', |
| 73 | + text: 'HMAC sign [VALUE] with key [KEY]', |
| 74 | + blockType: Scratch.BlockType.REPORTER, |
| 75 | + arguments: { |
| 76 | + VALUE: { type: Scratch.ArgumentType.STRING, defaultValue: 'apple' }, |
| 77 | + KEY: { type: Scratch.ArgumentType.EMPTY, shape: Scratch.BlockShape.OCTAGONAL } |
| 78 | + } |
| 79 | + }, |
| 80 | + { |
| 81 | + opcode: 'verifySignature', |
| 82 | + text: 'HMAC verify signature [SIGNATURE] of [VALUE] with key [KEY]', |
| 83 | + blockType: Scratch.BlockType.BOOLEAN, |
| 84 | + arguments: { |
| 85 | + SIGNATURE: { type: Scratch.ArgumentType.STRING, defaultValue: 'signature' }, |
| 86 | + VALUE: { type: Scratch.ArgumentType.STRING, defaultValue: 'apple' }, |
| 87 | + KEY: { type: Scratch.ArgumentType.EMPTY, shape: Scratch.BlockShape.OCTAGONAL } |
| 88 | + } |
| 89 | + }, |
| 90 | + |
| 91 | + '---', |
| 92 | + { blockType: Scratch.BlockType.LABEL, text: 'AES-GCM Encryption' }, |
| 93 | + { |
| 94 | + opcode: 'generateAESKey', |
| 95 | + text: 'generate AES-GCM key (256-bit)', |
| 96 | + blockType: Scratch.BlockType.REPORTER, |
| 97 | + blockShape: Scratch.BlockShape.OCTAGONAL, |
| 98 | + disableMonitor: true |
| 99 | + }, |
| 100 | + { |
| 101 | + opcode: 'encryptAES', |
| 102 | + text: 'encrypt [PLAINTEXT] with AES key [KEY]', |
| 103 | + blockType: Scratch.BlockType.REPORTER, |
| 104 | + arguments: { |
| 105 | + PLAINTEXT: { type: Scratch.ArgumentType.STRING, defaultValue: 'apple' }, |
| 106 | + KEY: { type: Scratch.ArgumentType.EMPTY, shape: Scratch.BlockShape.OCTAGONAL } |
| 107 | + } |
| 108 | + }, |
| 109 | + { |
| 110 | + opcode: 'decryptAES', |
| 111 | + text: 'decrypt [CIPHERTEXT] with AES key [KEY]', |
| 112 | + blockType: Scratch.BlockType.REPORTER, |
| 113 | + arguments: { |
| 114 | + CIPHERTEXT: { type: Scratch.ArgumentType.STRING, defaultValue: 'encrypted' }, |
| 115 | + KEY: { type: Scratch.ArgumentType.EMPTY, shape: Scratch.BlockShape.OCTAGONAL } |
| 116 | + } |
| 117 | + }, |
| 118 | + |
| 119 | + '---', |
| 120 | + { blockType: Scratch.BlockType.LABEL, text: 'RSA-PSS' }, |
| 121 | + { |
| 122 | + opcode: 'generateRSAKey', |
| 123 | + text: 'generate RSA-PSS keypair', |
| 124 | + blockType: Scratch.BlockType.REPORTER, |
| 125 | + blockShape: Scratch.BlockShape.OCTAGONAL, |
| 126 | + disableMonitor: true |
| 127 | + }, |
| 128 | + { |
| 129 | + opcode: 'rsaSign', |
| 130 | + text: 'RSA sign [VALUE] with private key [KEY]', |
| 131 | + blockType: Scratch.BlockType.REPORTER, |
| 132 | + arguments: { |
| 133 | + VALUE: { type: Scratch.ArgumentType.STRING, defaultValue: 'apple' }, |
| 134 | + KEY: { type: Scratch.ArgumentType.EMPTY, shape: Scratch.BlockShape.OCTAGONAL } |
| 135 | + } |
| 136 | + }, |
| 137 | + { |
| 138 | + opcode: 'rsaVerify', |
| 139 | + text: 'RSA verify [SIGNATURE] of [VALUE] with public key [KEY]', |
| 140 | + blockType: Scratch.BlockType.BOOLEAN, |
| 141 | + arguments: { |
| 142 | + SIGNATURE: { type: Scratch.ArgumentType.STRING, defaultValue: 'signature' }, |
| 143 | + VALUE: { type: Scratch.ArgumentType.STRING, defaultValue: 'apple' }, |
| 144 | + KEY: { type: Scratch.ArgumentType.EMPTY, shape: Scratch.BlockShape.OCTAGONAL } |
| 145 | + } |
| 146 | + }, |
| 147 | + { |
| 148 | + opcode: 'rsaPublicKey', |
| 149 | + text: 'RSA public key from [KEY]', |
| 150 | + blockType: Scratch.BlockType.REPORTER, |
| 151 | + blockShape: Scratch.BlockShape.OCTAGONAL, |
| 152 | + arguments: { |
| 153 | + KEY: { type: Scratch.ArgumentType.EMPTY, shape: Scratch.BlockShape.OCTAGONAL } |
| 154 | + } |
| 155 | + }, |
| 156 | + { |
| 157 | + opcode: 'rsaPrivateKey', |
| 158 | + text: 'RSA private key from [KEY]', |
| 159 | + blockType: Scratch.BlockType.REPORTER, |
| 160 | + blockShape: Scratch.BlockShape.OCTAGONAL, |
| 161 | + arguments: { |
| 162 | + KEY: { type: Scratch.ArgumentType.EMPTY, shape: Scratch.BlockShape.OCTAGONAL } |
| 163 | + } |
| 164 | + }, |
| 165 | + ], |
| 166 | + |
| 167 | + menus: { |
| 168 | + ALGORITHM: { items: ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'] }, |
| 169 | + TYPED_ARRAYS: ['Uint8Array','Uint16Array','Uint32Array','Int8Array','Int16Array','Int32Array'] |
| 170 | + } |
| 171 | + }; |
| 172 | + } |
| 173 | + |
| 174 | + randomUUID() { |
| 175 | + return (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : ''; |
| 176 | + } |
| 177 | + |
| 178 | + randomValues(args) { |
| 179 | + const typedArrayConstructors = { |
| 180 | + Uint8Array: Uint8Array, |
| 181 | + Uint16Array: Uint16Array, |
| 182 | + Uint32Array: Uint32Array, |
| 183 | + Int8Array: Int8Array, |
| 184 | + Int16Array: Int16Array, |
| 185 | + Int32Array: Int32Array |
| 186 | + }; |
| 187 | + |
| 188 | + const TypedArrayConstructor = typedArrayConstructors[args.SCALE]; |
| 189 | + if (!TypedArrayConstructor) throw new Error('Unsupported TypedArray type specified.'); |
| 190 | + |
| 191 | + let amt = Number(args.AMOUNT) || 0; |
| 192 | + amt = Math.max(0, Math.floor(amt)); |
| 193 | + if (amt > 1000000) amt = 1000000; |
| 194 | + |
| 195 | + const arr = new TypedArrayConstructor(amt); |
| 196 | + crypto.getRandomValues(arr); |
| 197 | + return JSON.stringify(Array.from(arr)); |
| 198 | + } |
| 199 | + |
| 200 | + async digest(args) { |
| 201 | + const algo = typeof args.ALGORITHM === 'string' ? args.ALGORITHM : 'SHA-256'; |
| 202 | + const bytes = new TextEncoder().encode(String(args.VALUE)); |
| 203 | + const hash = await crypto.subtle.digest(algo, bytes); |
| 204 | + return bufToHex(hash); |
| 205 | + } |
| 206 | + |
| 207 | + async generateSignKey() { |
| 208 | + const key = await crypto.subtle.generateKey({ |
| 209 | + name: 'HMAC', |
| 210 | + hash: { name: 'SHA-512' } |
| 211 | + }, true, ['sign', 'verify']); |
| 212 | + const jwk = await crypto.subtle.exportKey('jwk', key); |
| 213 | + return JSON.stringify(jwk); |
| 214 | + } |
| 215 | + |
| 216 | + async signValue(args) { |
| 217 | + let jwk; |
| 218 | + try { |
| 219 | + jwk = JSON.parse(String(args.KEY)); |
| 220 | + } catch { |
| 221 | + return ''; |
| 222 | + } |
| 223 | + const key = await crypto.subtle.importKey('jwk', jwk, { |
| 224 | + name: 'HMAC', |
| 225 | + hash: { name: 'SHA-512' } |
| 226 | + }, false, ['sign']); |
| 227 | + const data = new TextEncoder().encode(String(args.VALUE)); |
| 228 | + const sig = await crypto.subtle.sign('HMAC', key, data); |
| 229 | + return bufToHex(sig); |
| 230 | + } |
| 231 | + |
| 232 | + async verifySignature(args) { |
| 233 | + let jwk; |
| 234 | + try { |
| 235 | + jwk = JSON.parse(String(args.KEY)); |
| 236 | + } catch { |
| 237 | + return ''; |
| 238 | + } |
| 239 | + const key = await crypto.subtle.importKey('jwk', jwk, { |
| 240 | + name: 'HMAC', |
| 241 | + hash: { name: 'SHA-512' } |
| 242 | + }, false, ['verify']); |
| 243 | + const data = new TextEncoder().encode(String(args.VALUE)); |
| 244 | + try { |
| 245 | + return await crypto.subtle.verify('HMAC', key, hexToBuf(args.SIGNATURE), data); |
| 246 | + } catch (e) { |
| 247 | + return false; |
| 248 | + } |
| 249 | + } |
| 250 | + |
| 251 | + async generateAESKey() { |
| 252 | + const key = await crypto.subtle.generateKey({ |
| 253 | + name: 'AES-GCM', |
| 254 | + length: 256 |
| 255 | + }, true, ['encrypt', 'decrypt']); |
| 256 | + const jwk = await crypto.subtle.exportKey('jwk', key); |
| 257 | + return JSON.stringify(jwk); |
| 258 | + } |
| 259 | + |
| 260 | + async encryptAES(args) { |
| 261 | + let jwk; |
| 262 | + try { |
| 263 | + jwk = JSON.parse(String(args.KEY)); |
| 264 | + } catch { |
| 265 | + return ''; |
| 266 | + } |
| 267 | + const key = await crypto.subtle.importKey('jwk', jwk, { name: 'AES-GCM' }, false, ['encrypt']); |
| 268 | + const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 269 | + const data = new TextEncoder().encode(String(args.PLAINTEXT)); |
| 270 | + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); |
| 271 | + return JSON.stringify({ |
| 272 | + iv: bufToHex(iv), |
| 273 | + ct: bufToHex(ciphertext) |
| 274 | + }); |
| 275 | + } |
| 276 | + |
| 277 | + async decryptAES(args) { |
| 278 | + let obj; |
| 279 | + try { |
| 280 | + obj = JSON.parse(String(args.CIPHERTEXT)); |
| 281 | + } catch { |
| 282 | + return ''; |
| 283 | + } |
| 284 | + let jwk; |
| 285 | + try { |
| 286 | + jwk = JSON.parse(String(args.KEY)); |
| 287 | + } catch { |
| 288 | + return ''; |
| 289 | + } |
| 290 | + const key = await crypto.subtle.importKey('jwk', jwk, { name: 'AES-GCM' }, false, ['decrypt']); |
| 291 | + try { |
| 292 | + const plaintextBuf = await crypto.subtle.decrypt({ |
| 293 | + name: 'AES-GCM', |
| 294 | + iv: new Uint8Array(hexToBuf(String(obj.iv))) |
| 295 | + }, key, hexToBuf(String(obj.ct))); |
| 296 | + return new TextDecoder().decode(plaintextBuf); |
| 297 | + } catch { |
| 298 | + return ''; |
| 299 | + } |
| 300 | + } |
| 301 | + |
| 302 | + async generateRSAKey() { |
| 303 | + const keyPair = await crypto.subtle.generateKey({ |
| 304 | + name: 'RSA-PSS', |
| 305 | + modulusLength: 2048, |
| 306 | + publicExponent: new Uint8Array([1, 0, 1]), |
| 307 | + hash: { name: 'SHA-256' } |
| 308 | + }, true, ['sign', 'verify']); |
| 309 | + |
| 310 | + const ret = { |
| 311 | + publicKey: await crypto.subtle.exportKey('jwk', keyPair.publicKey), |
| 312 | + privateKey: await crypto.subtle.exportKey('jwk', keyPair.privateKey) |
| 313 | + }; |
| 314 | + return JSON.stringify(ret); |
| 315 | + } |
| 316 | + |
| 317 | + async rsaSign(args) { |
| 318 | + let jwk; |
| 319 | + try { |
| 320 | + jwk = JSON.parse(String(args.KEY)); |
| 321 | + } catch { |
| 322 | + return ''; |
| 323 | + } |
| 324 | + const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSA-PSS', hash: { name: 'SHA-256' } }, false, ['sign']); |
| 325 | + const data = new TextEncoder().encode(String(args.VALUE)); |
| 326 | + const sig = await crypto.subtle.sign({ name: 'RSA-PSS', saltLength: 32 }, key, data); |
| 327 | + return bufToHex(sig); |
| 328 | + } |
| 329 | + |
| 330 | + async rsaVerify(args) { |
| 331 | + let jwk; |
| 332 | + try { |
| 333 | + jwk = JSON.parse(String(args.KEY)); |
| 334 | + } catch { |
| 335 | + return ''; |
| 336 | + } |
| 337 | + const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSA-PSS', hash: { name: 'SHA-256' } }, false, ['verify']); |
| 338 | + const data = new TextEncoder().encode(String(args.VALUE)); |
| 339 | + try { |
| 340 | + return await crypto.subtle.verify({ name: 'RSA-PSS', saltLength: 32 }, key, hexToBuf(args.SIGNATURE), data); |
| 341 | + } catch { |
| 342 | + return false; |
| 343 | + } |
| 344 | + } |
| 345 | + |
| 346 | + // these two key methods have more validation (i cried while making them) |
| 347 | + async rsaPublicKey(args) { |
| 348 | + if (typeof args.KEY !== 'string') return ''; |
| 349 | + try { |
| 350 | + const key = JSON.parse(args.KEY); |
| 351 | + if (key && key.publicKey) { |
| 352 | + return JSON.stringify(key.publicKey); |
| 353 | + } |
| 354 | + return ''; |
| 355 | + } catch (error) { |
| 356 | + return ''; |
| 357 | + } |
| 358 | + } |
| 359 | + |
| 360 | + async rsaPrivateKey(args) { |
| 361 | + if (typeof args.KEY !== 'string') return ''; |
| 362 | + try { |
| 363 | + const key = JSON.parse(args.KEY); |
| 364 | + if (key && key.privateKey) { |
| 365 | + return JSON.stringify(key.privateKey); |
| 366 | + } |
| 367 | + return ''; |
| 368 | + } catch (error) { |
| 369 | + return ''; |
| 370 | + } |
| 371 | + } |
| 372 | + } |
| 373 | + |
| 374 | + Scratch.extensions.register(new Extension()); |
| 375 | +})(Scratch); |
0 commit comments