├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── TRADEMARK ├── index.js ├── lib ├── signer.js └── util.js ├── package.json └── test ├── .eslintrc.js ├── fixtures └── cases.json ├── integration ├── unpack.js └── unsign.js └── unit ├── signer.js └── util.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | coverage/* 3 | .nyc_output/* 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['scratch', 'scratch/node'], 3 | env: { 4 | node: true, 5 | es6: true 6 | }, 7 | rules: { 8 | 'no-div-regex': [0] 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | # NPM 5 | /node_modules 6 | npm-* 7 | 8 | # Code coverage 9 | /coverage 10 | /.nyc_output 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | - 'stable' 5 | sudo: false 6 | cache: 7 | directories: 8 | - node_modules 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Massachusetts Institute of Technology 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## scratch-auth 2 | #### Authentication utilities for Scratch 3 | 4 | [![Build Status](https://travis-ci.org/LLK/scratch-auth.svg?branch=develop)](https://travis-ci.org/LLK/scratch-auth) 5 | [![Coverage Status](https://coveralls.io/repos/github/LLK/scratch-auth/badge.svg?branch=develop)](https://coveralls.io/github/LLK/scratch-auth?branch=develop) 6 | [![dependencies Status](https://david-dm.org/LLK/scratch-auth/status.svg)](https://david-dm.org/LLK/scratch-auth) 7 | [![devDependencies Status](https://david-dm.org/LLK/scratch-auth/dev-status.svg)](https://david-dm.org/LLK/scratch-auth?type=dev) 8 | 9 | ## Installation 10 | ```bash 11 | npm install scratch-auth 12 | ``` 13 | 14 | ## Usage 15 | ```js 16 | const Auth = require('scratch-auth'); 17 | const a = new Auth('test', 'secret'); 18 | 19 | const unsigned = a.unsign('value:zyBNJHpGyml3X-RhCx0mbjLFzPs'); 20 | const unpacked = a.unpack(unsigned); 21 | ``` 22 | 23 | ## Donate 24 | We provide [Scratch](https://scratch.mit.edu) free of charge, and want to keep it that way! Please consider making a [donation](https://secure.donationpay.org/scratchfoundation/) to support our continued engineering, design, community, and resource development efforts. Donations of any size are appreciated. Thank you! 25 | -------------------------------------------------------------------------------- /TRADEMARK: -------------------------------------------------------------------------------- 1 | The Scratch trademarks, including the Scratch name, logo, the Scratch Cat, Gobo, Pico, Nano, Tera and Giga graphics (the "Marks"), are property of the Massachusetts Institute of Technology (MIT). Marks may not be used to endorse or promote products derived from this software without specific prior written permission. 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const pako = require('pako'); 2 | 3 | const signer = require('./lib/signer'); 4 | const util = require('./lib/util'); 5 | 6 | class Auth { 7 | constructor (salt, secret) { 8 | this.salt = salt; 9 | this.secret = secret; 10 | } 11 | 12 | /** 13 | * Port of django.core.signing.signer.unsign 14 | * @param {string} signedString String in the form value:time:signature 15 | * @return {string} Unsigned string 16 | */ 17 | unsign (signedString) { 18 | // Validate 19 | if (typeof signedString !== 'string') return; 20 | if (signedString.indexOf(':') === -1) return; 21 | 22 | // Decode 23 | var components = signedString.split(':'); 24 | var value = components.slice(0, -1).join(':'); 25 | var signature = components.slice(-1)[0]; 26 | var challenge = signer.base64Hmac(this.salt, value, this.secret); 27 | 28 | // Compare signature to challenge 29 | if (util.md5(signature) !== util.md5(challenge)) return; 30 | return value; 31 | } 32 | 33 | /** 34 | * Return the usable content portion of a signed, compressed cookie 35 | * generated by Django's signing module 36 | * See: github.com/django/django/blob/stable/1.8.x/django/core/signing.py 37 | * @param {string} s Signed (and optionally compressed) cookie 38 | * @return {object} Unpacked cookie 39 | */ 40 | unpack (s) { 41 | // Validate 42 | if (typeof s !== 'string') return; 43 | 44 | // Storage objects 45 | const decompress = (s[0] === '.'); 46 | const b64data = s.split(':')[0]; 47 | 48 | // Base64 decode 49 | var result = util.b64Decode(b64data); 50 | 51 | try { 52 | // Handle decompression 53 | if (decompress) { 54 | var charData = result.split('').map(function (c) { 55 | return c.charCodeAt(0); 56 | }); 57 | var binData = new Uint8Array(charData); 58 | var data = pako.inflate(binData); 59 | result = String.fromCharCode.apply(null, new Uint16Array(data)); 60 | } 61 | 62 | // Convert to object 63 | result = JSON.parse(result); 64 | } catch (e) { 65 | return; 66 | } 67 | 68 | return result; 69 | } 70 | } 71 | 72 | module.exports = Auth; 73 | -------------------------------------------------------------------------------- /lib/signer.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const util = require('./util'); 3 | 4 | const signer = module.exports = { 5 | /** 6 | * Method for obtaining the key for the HMAC-SHA1. 7 | * @param {string} salt Crypto salt 8 | * @param {string} secret Crypto secret 9 | * @return {Buffer} Binary SHA1 digest of the salt and secret 10 | */ 11 | getSaltedHmacKey: function (salt, secret) { 12 | // Validate 13 | if (typeof salt !== 'string') return; 14 | if (typeof secret !== 'string') return; 15 | 16 | // Create SHA1 hash 17 | var keySha1Sum = crypto.createHash('sha1'); 18 | keySha1Sum.update(salt + secret, 'binary'); 19 | return keySha1Sum.digest('binary'); 20 | }, 21 | 22 | /** 23 | * Port of django.utils.crypto.salted_hmac: 24 | * Returns the HMAC-SHA1 of `value`, using a key generated from the 25 | * specified salt and secret. 26 | * @param {string} salt Crypto salt 27 | * @param {string} value Value to be transformed 28 | * @param {string} secret Crypto secret 29 | * @return {object} HMAC object 30 | */ 31 | getSaltedHmac: function (salt, value, secret) { 32 | // Validate 33 | if (typeof salt !== 'string') return; 34 | if (typeof value !== 'string') return; 35 | if (typeof secret !== 'string') return; 36 | 37 | // Return HMAC-SHA1 of the specified `value` 38 | var hmac = crypto.createHmac( 39 | 'sha1', 40 | new Buffer(signer.getSaltedHmacKey(salt, secret), 'binary') 41 | ); 42 | hmac.update(value, 'binary'); 43 | return hmac; 44 | }, 45 | 46 | /** 47 | * Port of django.core.signing.base64_hmac: 48 | * Returns a URL-safe Base64 encoded representation of the digest of the 49 | * HMAC of `value`. 50 | * @param {string} salt Crypto salt 51 | * @param {string} value Value to be transformed 52 | * @param {string} key Crypto secret 53 | * @return {string} URL-safe and Base64 encoded digest 54 | */ 55 | base64Hmac: function (salt, value, key) { 56 | // Validate 57 | if (typeof salt !== 'string') return; 58 | if (typeof value !== 'string') return; 59 | if (typeof key !== 'string') return; 60 | 61 | // Get salted HMAC digest and Base64 encode 62 | var saltedHmac = signer.getSaltedHmac(salt + 'signer', value, key); 63 | return util.b64Encode(saltedHmac.digest('binary')); 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | module.exports = { 4 | /** 5 | * Port of Python's base64.urlsafe_b64encode: Returns a base64-encoded 6 | * representation of s, made URL-safe by replacing + and / with - and _ 7 | * respectively. Additionally all trailing =s are stripped from the 8 | * resulting value to mirror django.core.signing.b64_encode. 9 | * @param {string} s Input string. 10 | * @return {string} URL-safe encoded string. 11 | */ 12 | b64Encode: function (s) { 13 | // Validate 14 | if (typeof s !== 'string') return; 15 | 16 | // Convert from binary to Base64 17 | var b64String = new Buffer(s, 'binary').toString('base64'); 18 | 19 | // Replace special characters with URL-safe alternates 20 | return b64String.replace( 21 | /[+/]/g, 22 | function (c) { 23 | return {'+': '-', '/': '_'}[c]; 24 | } 25 | ).replace(/=+$/, ''); 26 | }, 27 | 28 | /** 29 | * Port of Python's base64.urlsafe_b64decode: Returns a base64-decoded 30 | * representation of s, made URL-safe by replacing + and / with - and _ 31 | * respectively. Handles removal of trailing =s which are stripped from 32 | * encoded values to mirror django.core.signing.b64_decode. 33 | * @param {string} s Base64 encoded string. 34 | * @return {string} Decoded string. 35 | */ 36 | b64Decode: function (s) { 37 | // Validate 38 | if (typeof s !== 'string') return; 39 | 40 | // Trim leading "dot" character 41 | if (s[0] === '.') s = s.substring(1); 42 | 43 | // Replace encoded special characters 44 | s = s.replace( 45 | /[-_]/g, 46 | function (c) { 47 | return {'-': '+', '_': '/'}[c]; 48 | } 49 | ); 50 | 51 | // Convert from Base64 to binary 52 | return new Buffer(s, 'base64').toString('binary'); 53 | }, 54 | 55 | /** 56 | * Creates an MD5 hash from the specified input string. 57 | * @param {string} s Input string. 58 | * @return {string} Hash. 59 | */ 60 | md5: function (s) { 61 | // Validate 62 | if (typeof s !== 'string') return; 63 | 64 | // Create hash and return hex digest 65 | return crypto 66 | .createHash('md5') 67 | .update(s) 68 | .digest('hex'); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scratch-auth", 3 | "version": "1.0.0", 4 | "description": "Authentication utilities for Scratch", 5 | "author": "Massachusetts Institute of Technology", 6 | "license": "BSD-3-Clause", 7 | "homepage": "https://github.com/LLK/scratch-auth#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+ssh://git@github.com/LLK/scratch-auth.git" 11 | }, 12 | "main": "index.js", 13 | "scripts": { 14 | "test": "npm run lint && npm run tap", 15 | "lint": "eslint .", 16 | "tap": "tap test/{unit,integration}/*.js", 17 | "coverage": "tap test/{unit,integration}/*.js --coverage --coverage-report=lcov" 18 | }, 19 | "dependencies": { 20 | "pako": "1.0.3" 21 | }, 22 | "devDependencies": { 23 | "babel-eslint": "7.1.0", 24 | "eslint": "3.9.1", 25 | "eslint-config-scratch": "2.0.3", 26 | "tap": "8.0.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'no-undefined': [0] 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/cases.json: -------------------------------------------------------------------------------- 1 | { 2 | "valid": { 3 | "salt": "test", 4 | "secret": "secret", 5 | "signed": "value:zyBNJHpGyml3X-RhCx0mbjLFzPs", 6 | "unsigned": "value" 7 | }, 8 | "invalidSalt": { 9 | "salt": "hax0r", 10 | "secret": "secret", 11 | "signed": "value:zyBNJHpGyml3X-RhCx0mbjLFzPs" 12 | }, 13 | "invalidSecret": { 14 | "salt": "test", 15 | "secret": "hax0r", 16 | "signed": "value:zyBNJHpGyml3X-RhCx0mbjLFzPs" 17 | }, 18 | "invalidToken": { 19 | "salt": "test", 20 | "secret": "secret", 21 | "signed": "zyBNJHpGyml3X-RhCx0mbjLFzPs" 22 | }, 23 | "missingToken": { 24 | "salt": "test", 25 | "secret": "secret" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/integration/unpack.js: -------------------------------------------------------------------------------- 1 | const test = require('tap').test; 2 | const Auth = require('../../index'); 3 | 4 | const object = {foo: 'bar'}; 5 | const encoded = 'eyJmb28iOiJiYXIifQ=='; 6 | 7 | test('spec', function (t) { 8 | const a = new Auth(); 9 | t.type(Auth, 'function'); 10 | t.type(a, 'object'); 11 | t.type(a.unpack, 'function'); 12 | t.end(); 13 | }); 14 | 15 | test('uncompressed', function (t) { 16 | const a = new Auth(); 17 | t.strictDeepEqual(a.unpack(encoded), object); 18 | t.end(); 19 | }); 20 | 21 | test('invalid', function (t) { 22 | const a = new Auth(); 23 | t.strictDeepEqual(a.unpack(undefined), undefined); 24 | t.end(); 25 | }); 26 | -------------------------------------------------------------------------------- /test/integration/unsign.js: -------------------------------------------------------------------------------- 1 | const test = require('tap').test; 2 | const Auth = require('../../index'); 3 | 4 | const cases = require('../fixtures/cases'); 5 | 6 | test('spec', function (t) { 7 | const a = new Auth(); 8 | t.type(Auth, 'function'); 9 | t.type(a, 'object'); 10 | t.type(a.unsign, 'function'); 11 | t.end(); 12 | }); 13 | 14 | test('valid', function (t) { 15 | const c = cases.valid; 16 | const a = new Auth(c.salt, c.secret); 17 | t.strictEqual(a.unsign(c.signed), c.unsigned); 18 | t.end(); 19 | }); 20 | 21 | test('invalid salt', function (t) { 22 | const c = cases.invalidSalt; 23 | const a = new Auth(c.salt, c.secret); 24 | t.strictEqual(a.unsign(c.signed), c.unsigned); 25 | t.end(); 26 | }); 27 | 28 | test('invalid secret', function (t) { 29 | const c = cases.invalidSecret; 30 | const a = new Auth(c.salt, c.secret); 31 | t.strictEqual(a.unsign(c.signed), c.unsigned); 32 | t.end(); 33 | }); 34 | 35 | test('invalid token', function (t) { 36 | const c = cases.invalidToken; 37 | const a = new Auth(c.salt, c.secret); 38 | t.strictEqual(a.unsign(c.signed), c.unsigned); 39 | t.end(); 40 | }); 41 | 42 | test('missing token', function (t) { 43 | const c = cases.missingToken; 44 | const a = new Auth(c.salt, c.secret); 45 | t.strictEqual(a.unsign(c.signed), c.unsigned); 46 | t.end(); 47 | }); 48 | -------------------------------------------------------------------------------- /test/unit/signer.js: -------------------------------------------------------------------------------- 1 | const test = require('tap').test; 2 | const signer = require('../../lib/signer'); 3 | 4 | test('spec', function (t) { 5 | t.type(signer, 'object'); 6 | t.type(signer.getSaltedHmacKey, 'function'); 7 | t.type(signer.getSaltedHmac, 'function'); 8 | t.type(signer.base64Hmac, 'function'); 9 | t.end(); 10 | }); 11 | 12 | test('getSaltedHmacKey', function (t) { 13 | // Valid input 14 | t.strictEqual( 15 | signer.getSaltedHmacKey('foo', 'bar'), 16 | '\x88C×ù$\x16!\x1Déë¹cÿLâ\x81%\x93(x' 17 | ); 18 | 19 | // Invalid input 20 | t.strictEqual(signer.getSaltedHmacKey('foo', undefined), undefined); 21 | t.strictEqual(signer.getSaltedHmacKey(undefined, 'bar'), undefined); 22 | t.end(); 23 | }); 24 | 25 | test('getSaltedHmac', function (t) { 26 | // Valid input 27 | t.type(signer.getSaltedHmac('foo', 'bar', 'baz'), 'object'); 28 | 29 | // Invalid input 30 | t.strictEqual(signer.getSaltedHmac('foo', 'bar', undefined), undefined); 31 | t.strictEqual(signer.getSaltedHmac('foo', undefined, 'baz'), undefined); 32 | t.strictEqual(signer.getSaltedHmac(undefined, 'bar', 'baz'), undefined); 33 | t.end(); 34 | }); 35 | 36 | test('base64Hmac', function (t) { 37 | // Valid input 38 | t.type(signer.base64Hmac('foo', 'bar', 'baz'), 'string'); 39 | 40 | // Invalid input 41 | t.strictEqual(signer.base64Hmac('foo', 'bar', undefined), undefined); 42 | t.strictEqual(signer.base64Hmac('foo', undefined, 'baz'), undefined); 43 | t.strictEqual(signer.base64Hmac(undefined, 'bar', 'baz'), undefined); 44 | t.end(); 45 | }); 46 | -------------------------------------------------------------------------------- /test/unit/util.js: -------------------------------------------------------------------------------- 1 | const test = require('tap').test; 2 | const util = require('../../lib/util'); 3 | 4 | test('spec', function (t) { 5 | t.type(util, 'object'); 6 | t.type(util.b64Encode, 'function'); 7 | t.type(util.b64Decode, 'function'); 8 | t.type(util.md5, 'function'); 9 | t.end(); 10 | }); 11 | 12 | test('b64Encode', function (t) { 13 | // Valid input 14 | t.strictEqual(util.b64Encode('foobar'), 'Zm9vYmFy'); 15 | t.strictEqual(util.b64Encode('http://foo-bar'), 'aHR0cDovL2Zvby1iYXI'); 16 | t.strictEqual(util.b64Encode('http:/`\\'), 'aHR0cDovYFw'); 17 | 18 | // Invalid input 19 | t.strictEqual(util.b64Encode(undefined), undefined); 20 | t.strictEqual(util.b64Encode(null), undefined); 21 | t.strictEqual(util.b64Encode(NaN), undefined); 22 | t.strictEqual(util.b64Encode(0), undefined); 23 | t.strictEqual(util.b64Encode({}), undefined); 24 | t.end(); 25 | }); 26 | 27 | test('b64Decode', function (t) { 28 | // Valid input 29 | t.strictEqual(util.b64Decode('Zm9vYmFy'), 'foobar'); 30 | t.strictEqual(util.b64Decode('aHR0cDovL2Zvby1iYXI'), 'http://foo-bar'); 31 | t.strictEqual(util.b64Decode('.aHR0cDovYFy='), 'http:/`\\'); 32 | 33 | // Invalid input 34 | t.strictEqual(util.b64Decode(undefined), undefined); 35 | t.strictEqual(util.b64Decode(null), undefined); 36 | t.strictEqual(util.b64Decode(NaN), undefined); 37 | t.strictEqual(util.b64Decode(0), undefined); 38 | t.strictEqual(util.b64Decode({}), undefined); 39 | t.end(); 40 | }); 41 | 42 | test('md5', function (t) { 43 | // Valid input 44 | t.strictEqual(util.md5('foobar'), '3858f62230ac3c915f300c664312c63f'); 45 | 46 | // Invalid input 47 | t.strictEqual(util.md5(undefined), undefined); 48 | t.strictEqual(util.md5(null), undefined); 49 | t.strictEqual(util.md5(NaN), undefined); 50 | t.strictEqual(util.md5(0), undefined); 51 | t.strictEqual(util.md5({}), undefined); 52 | t.end(); 53 | }); 54 | --------------------------------------------------------------------------------