-
Notifications
You must be signed in to change notification settings - Fork 3
/
index.js
343 lines (281 loc) · 11.5 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
'use strict'
var shuffle = require('knuth-shuffle-seeded');
var crypto = require('crypto');
var sha = 'RSA-SHA256';
var sigEncoding = 'hex';
var sigLength = 8;
var pemRegex = new RegExp('^-----BEGIN.*-----');
var sigRegex = new RegExp('^[a-f0-9]{' + sigLength + '}$');
var lowercase = 'abcdefghijklmnopqrstuvwxyz';
var numbers = '0123456789';
var uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
var otherUnreserved = "-";
var dictionaryAsArray = (lowercase + numbers + uppercase + otherUnreserved).split('');
var dictionary = arrayToObject(dictionaryAsArray);
var numPossibleChars = dictionaryAsArray.length;
// example Next url with article uuid is https://next.ft.com/content/b09f4c12-7c75-11e5-98fb-5a6d4728f74e
var uuidRegexChar = "[a-z0-9]";
var uuidRegexFragmentLengths = [8,4,4,4,12];
var uuidLengthWithoutHyphens = uuidRegexFragmentLengths.reduce(function(tot,n){return tot+n;});
var uuidLengthWithHyphens = uuidLengthWithoutHyphens + uuidRegexFragmentLengths.length - 1;
var uuidRegexFragments = uuidRegexFragmentLengths.map(function(n){return "(" + uuidRegexChar + "{" + n +"})";});
var uuidRegexWithHyphens = new RegExp('^' + uuidRegexFragments.join('-') + '$'); // e.g. ([a-z0-9]{8})-([a-z0-9]{4})-([a-z0-9]{4})-([a-z0-9]{4})-([a-z0-9]{12})
var uuidRegexWithoutHyphens = new RegExp('^' + uuidRegexFragments.join('') + '$'); // e.g. ([a-z0-9]{8})([a-z0-9]{4})([a-z0-9]{4})([a-z0-9]{4})([a-z0-9]{12})
var uuidReconstructPattern = uuidRegexFragmentLengths.map(function(n,i){return '$' + (i+1);}).join('-'); // e.g. "$1-$2-$3-$4-$5"
var unixtimeStringLength = 10;
var maxTokensStringLength = 4;
var contextStringLength = 1;
var checksumStringLength = sigLength;
var shareDetailsLength = uuidLengthWithoutHyphens + unixtimeStringLength + maxTokensStringLength + contextStringLength;
var shareCodeLength = shareDetailsLength + checksumStringLength;
var defaultContext = '0';
var codeRegexChar = '[' + dictionaryAsArray.join('') + ']';
var codeRegex = new RegExp( '^' + codeRegexChar + '{' + shareCodeLength + '}$' );
var detailsRegex = new RegExp( '^' + codeRegexChar + '{' + shareDetailsLength + '}' + '$' );
var checksumRegex = new RegExp('^' + codeRegexChar + '$');
var unixtimeRegex = new RegExp('^[0-9]{' + unixtimeStringLength + '}$');
var maxTokensRegex = new RegExp('^(-[0-9]{' + (maxTokensStringLength -1) + '}|[0-9]{' + maxTokensStringLength + '})$');
var contextRegex = new RegExp('^' + codeRegexChar + '{' + contextStringLength + '}$');
if (process.env.NODE_ENV !== 'production') {
console.log("share code config dump:\n" + [
" sha = " + sha,
" sigEncoding = " + sigEncoding,
" sigLength = " + sigLength,
" pemRegex = " + pemRegex,
" sigRegex = " + sigRegex,
" checksumRegex = " + checksumRegex,
" shareDetailsLength = " + shareDetailsLength,
" detailsRegex = " + detailsRegex,
" uuidRegexChar = " + uuidRegexChar,
" numPossibleChars = " + numPossibleChars,
"uuidRegexFragmentLengths = " + uuidRegexFragmentLengths,
"uuidLengthWithoutHyphens = " + uuidLengthWithoutHyphens,
" uuidLengthWithHyphens = " + uuidLengthWithHyphens,
" uuidRegexFragments = " + uuidRegexFragments,
" uuidRegexWithHyphens = " + uuidRegexWithHyphens,
" uuidRegexWithoutHyphens = " + uuidRegexWithoutHyphens,
" uuidReconstructPattern = " + uuidReconstructPattern,
" unixtimeStringLength = " + unixtimeStringLength,
" maxTokensStringLength = " + maxTokensStringLength,
" contextStringLength = " + contextStringLength,
" shareCodeLength = " + shareCodeLength,
" defaultContext = " + defaultContext,
" codeRegex = " + codeRegex,
" unixtimeRegex = " + unixtimeRegex,
" maxTokensRegex = " + maxTokensRegex,
" contextRegex = " + contextRegex
].join("\n")
);
}
function dictionaryIndexesToString(dictionaryIndexes) {
return dictionaryIndexes.map(dictionaryIndexToChar).join('');
}
function dictionaryIndexToChar(index) {
return dictionary[index];
}
function formatAsUUID(string) {
return string.replace(uuidRegexWithoutHyphens, uuidReconstructPattern);
}
function toArray(string) {
return string.split('');
}
function dictionaryIndexes(string) {
return toArray(string).map(positionWithinDictionary);
}
function positionWithinDictionary(character) {
return dictionaryAsArray.indexOf(character);
}
function addOverArrays(a, b) { // zip -> map(reduce(sum))
return a.map((x, index) => {
if (b[index] != null ) {
return b[index] + x;
} else {
return x;
}
});
}
function subtractOverArrays(a, b) {
return a.map((x, index) => {
if (b[index] != null ) {
return x - b[index];
} else {
return x;
}
});
}
function mod(n, m) {
return (n + (Math.abs(Math.trunc(n))*m)) % m;
}
function arrayToObject(arr) {
return arr.reduce(function(o, v, i) {
o[i] = v;
return o;
}, {});
}
function addInPairs(arr) {
return arr.map(function(item, index) {
if (index % 2 === 0) {
if (arr[index + 1] !== null) {
return item + arr[index + 1];
} else {
return item;
}
}
}).filter(x => x !== null || x !== undefined )
}
function removeHyphens(string) {
return string.replace(/-/g,'');
}
function validateStringOrThrow(name, value, regexp){
if (! regexp.test(value)) {
throw new Error('Not a valid ' + name + '. value="' + value + '" (length=' + value.length + ') does not match regexp=' + regexp.toString());
}
}
function validateEqualityOrThrow(v1, v2, message){
if (v1 !== v2) {
throw new Error('Failed equality check: ' + v1 + ' !== ' + v2 + ': ' + message);
}
}
// any -ve num is coerced to -1
function constructMaxTokensString( num, maxLength ) {
if (maxLength < 2) {
throw new Error( "constructMaxTokensString: maxLength(" + maxLength + ") is too short to accommodate a possible value of num=-1" );
}
// coerce any -ve num to be -1
if (num < 0) {
num = -1;
} else if( num < (1+Math.floor(Math.log10(num))) ) {
throw new Error("constructMaxTokensString: maxLength(" + maxLength + ") is too short to accommodate num=" + num);
}
return zeroPadNumToN( num, maxLength );
}
// given an integer (num), ensure it is zero-padded to n places,
// e.g. (12,3) -> "012", (12,5) -> "00012", (-1,4) -> "-001" [NB: result is a string of n chars]
function zeroPadNumToN(num, n) {
var zeroPadded;
if (num<0) {
zeroPadded = '-' + zeroPadNumToN(-1 * num, n-1);
} else {
var nZeroes = Array(n+1).join('0')
zeroPadded = (nZeroes + num).slice(-1 * n);
}
return zeroPadded;
}
function seededShuffle( array, seed ) {
var clonedArray = array.slice(0);
var shuffledArray = shuffle(clonedArray, seed);
return shuffledArray;
}
function integerSequence( from, to ) {
var list = [];
var step = Math.sign( to - from );
if (step === 0){
step = 1;
} else if (step === -1) {
var temp = from;
from = to;
to = temp;
}
for (var i = from; i <= to; i += 1) {
list.push(i);
}
return (step === 1)? list : list.reverse();
}
function seededUnShuffle( array, seed ) {
var sequence = integerSequence(0, array.length - 1);
var shuffledSequence = seededShuffle( sequence, seed );
var shuffledIndicies = [];
var i;
for (i = 0; i < shuffledSequence.length; i++) {
shuffledIndicies[shuffledSequence[i]] = i;
}
var unShuffledArray = [];
for (i = 0; i < shuffledSequence.length; i++) {
unShuffledArray[i] = array[shuffledIndicies[i]];
}
return unShuffledArray;
}
function calcIndiciesChecksum( indicies ){
return indicies.reduce(function(prev,current){ return prev+current; }, 0);
}
function calcChecksumAsIndex( checksum, modulus ){
return mod(checksum, modulus);
}
function calcSigOfText(text, pem){
var signer = crypto.createSign(sha);
signer.update(text);
var sign = signer.sign(pem, sigEncoding);
var sig = sign.slice(0,sigLength);
return sig;
}
function calcSigOfShareDetails(shareDetails, articleId, pem){
validateStringOrThrow('shareDetails', shareDetails, detailsRegex);
validateStringOrThrow( 'articleId', articleId, uuidRegexWithHyphens);
var sig = calcSigOfText(shareDetails + articleId, pem);
validateStringOrThrow( 'sig', sig, sigRegex );
return sig;
}
//------------------------------------------
// exported functions
function encrypt(userId, articleId, time, tokens, context, pem) {
var timeString = '' + time;
var tokensString = constructMaxTokensString(tokens, maxTokensStringLength);
validateStringOrThrow( 'userId', userId, uuidRegexWithHyphens);
validateStringOrThrow( 'articleId', articleId, uuidRegexWithHyphens);
validateStringOrThrow( 'timeString', timeString, unixtimeRegex );
validateStringOrThrow( 'tokensString', tokensString, maxTokensRegex );
validateStringOrThrow('contextString', context, contextRegex );
validateStringOrThrow( 'pem', pem, pemRegex );
var user = removeHyphens(userId);
var shareDetails = user + timeString + tokensString + context;
validateStringOrThrow( 'shareDetails', shareDetails, detailsRegex );
var sig = calcSigOfShareDetails( shareDetails, articleId, pem );
var shareCodeUnshuffled = shareDetails + sig;
var shareCodeUnshuffledArray = shareCodeUnshuffled.split('');
var shareCodeArray = seededShuffle(shareCodeUnshuffledArray, pem+articleId);
var shareCode = shareCodeArray.join('');
validateStringOrThrow( 'shareCode', shareCode, codeRegex );
return shareCode;
}
function decrypt(code, articleId, pem) {
validateStringOrThrow( 'code', code, codeRegex );
validateStringOrThrow('articleId', articleId, uuidRegexWithHyphens);
validateStringOrThrow( 'pem', pem, pemRegex );
var codeArray = code.split('');
var codeUnshuffledArray = seededUnShuffle( codeArray, pem+articleId );
var codeUnshuffled = codeUnshuffledArray.join('');
var shareDetails = codeUnshuffled.slice(0,shareDetailsLength);
var sig = codeUnshuffled.slice(shareDetailsLength);
validateStringOrThrow( 'sig', sig, sigRegex );
var recalcSig = calcSigOfShareDetails( shareDetails, articleId, pem);
if ( sig !== recalcSig ) {
throw new Error('Corrupt sharecode: sig mismatch');
}
var context = shareDetails.slice(uuidLengthWithoutHyphens + unixtimeStringLength + maxTokensStringLength);
var tokens = shareDetails.slice(uuidLengthWithoutHyphens + unixtimeStringLength, uuidLengthWithoutHyphens + unixtimeStringLength + maxTokensStringLength);
var time = shareDetails.slice(uuidLengthWithoutHyphens, uuidLengthWithoutHyphens + unixtimeStringLength);
var user = formatAsUUID(shareDetails.slice(0,uuidLengthWithoutHyphens));
// Barf if we produce invalid values from the decryption.
// In this case, the requirements that the unixtime and maxTokens are valid integer strings is acting as a sort of checksum.
validateStringOrThrow('decrypted unixtime', time, unixtimeRegex );
validateStringOrThrow('decrypted maxTokens', tokens, maxTokensRegex );
validateStringOrThrow('decrypted sharer UUID', user, uuidRegexWithHyphens );
validateStringOrThrow('decrypted context', context, contextRegex );
return {
tokens: tokens,
time: time,
user: user,
context: context
};
}
function isShareCodePattern(code) {
return codeRegex.test(code);
}
module.exports = {
encrypt: encrypt,
decrypt: decrypt,
isShareCodePattern: isShareCodePattern,
_seededShuffle: seededShuffle,
_seededUnShuffle: seededUnShuffle,
_integerSequence: integerSequence
};