88 * leaveForSignIn the one command the page offers an extension: it must take the tab to the
99 * account site's /ai/ pages and nowhere else
1010 * createUrlCallbackProvider .drain / .whenListening: what is delivered when the page starts
11+ * createSecretStorage the session store, shared by every tab of the origin
1112 * startReturnFromSignIn ?signin=1 and the return from a sign-in
1213 *--------------------------------------------------------------------------------------------*/
1314// @ts -check
@@ -19,10 +20,19 @@ const path = require('path');
1920
2021const SOURCE = fs . readFileSync ( path . join ( __dirname , '..' , '..' , 'main.js' ) , 'utf8' ) ;
2122
22- /** The text of a top-level `function name(...) { ... }` — it ends at the first `}` in column 0. */
23+ /**
24+ * The text of a top-level `function name(...) { ... }` (or `async function`): a one-line function is its
25+ * line, any other ends at the first `}` in column 0.
26+ */
2327function extract ( name ) {
24- const start = SOURCE . indexOf ( '\nfunction ' + name + '(' ) ;
28+ let start = SOURCE . indexOf ( '\nfunction ' + name + '(' ) ;
29+ if ( start < 0 ) { start = SOURCE . indexOf ( '\nasync function ' + name + '(' ) ; }
2530 assert . ok ( start >= 0 , 'main.js has no top-level function ' + name ) ;
31+ const eol = SOURCE . indexOf ( '\n' , start + 1 ) ;
32+ const line = SOURCE . slice ( start + 1 , eol ) ;
33+ const opens = ( line . match ( / \{ / g) || [ ] ) . length ;
34+ const closes = ( line . match ( / \} / g) || [ ] ) . length ;
35+ if ( opens > 0 && opens === closes && line . endsWith ( '}' ) ) { return line + '\n' ; }
2636 const end = SOURCE . indexOf ( '\n}\n' , start ) ;
2737 assert . ok ( end > start , 'could not find the end of ' + name ) ;
2838 return SOURCE . slice ( start + 1 , end + 2 ) ;
@@ -197,6 +207,162 @@ const ACCOUNT = 'https://levelcode.example';
197207 } finally { t . restore ( ) ; }
198208 } ) ;
199209
210+ /* ---- the secret store --------------------------------------------------------------------------- */
211+ const nodeCrypto = require ( 'crypto' ) ;
212+ const STORE_KEY = 'levelcode-web.secrets.v1' ;
213+ const SECRET_SOURCE = [ 'b64' , 'unb64' , 'seal' , 'unseal' , 'createSecretStorage' ] . map ( extract ) . join ( '\n' ) ;
214+ const memoryStorage = ( ) => {
215+ const m = new Map ( ) ;
216+ return {
217+ m,
218+ getItem : ( k ) => ( m . has ( k ) ? m . get ( k ) : null ) ,
219+ setItem : ( k , v ) => { m . set ( k , String ( v ) ) ; } ,
220+ removeItem : ( k ) => { m . delete ( k ) ; } ,
221+ } ;
222+ } ;
223+ /** A mutex shared by every "tab" that is handed the same one, as Web Locks is. */
224+ const sharedLocks = ( ) => {
225+ let tail = Promise . resolve ( ) ;
226+ const held = { max : 0 , now : 0 } ;
227+ return {
228+ held,
229+ request : ( _name , fn ) => {
230+ const run = async ( ) => { held . now ++ ; held . max = Math . max ( held . max , held . now ) ; try { return await fn ( ) ; } finally { held . now -- ; } } ;
231+ const p = tail . then ( run , run ) ;
232+ tail = p . then ( ( ) => undefined , ( ) => undefined ) ;
233+ return p ;
234+ } ,
235+ } ;
236+ } ;
237+ /** One tab of the editor: its own storage object over the shared localStorage, the shared origin key. */
238+ const tab = async ( { storage, key, locks, failKey = false , noKey = false } ) => {
239+ const logs = { error : [ ] , warn : [ ] } ;
240+ const factory = new Function ( 'localStorage' , 'navigator' , 'console' , 'loadOrCreateKey' , 'crypto' , 'TextEncoder' , 'TextDecoder' , 'btoa' , 'atob' , 'Uint8Array' ,
241+ SECRET_SOURCE + '\nreturn createSecretStorage;' ) ;
242+ const create = factory ( storage , locks ? { locks } : { } , { warn : ( ...a ) => logs . warn . push ( a ) , error : ( ...a ) => logs . error . push ( a ) } ,
243+ async ( ) => { if ( noKey ) { throw new Error ( 'no indexedDB' ) ; } return key ; } , nodeCrypto . webcrypto , TextEncoder , TextDecoder , btoa , atob , Uint8Array ) ;
244+ return { store : await create ( ) , logs } ;
245+ } ;
246+ const newKey = ( ) => nodeCrypto . webcrypto . subtle . generateKey ( { name : 'AES-GCM' , length : 256 } , false , [ 'encrypt' , 'decrypt' ] ) ;
247+
248+ await test ( 'secrets: set, get, keys and delete, and they are there after a reload' , async ( ) => {
249+ const storage = memoryStorage ( ) ;
250+ const key = await newKey ( ) ;
251+ const a = await tab ( { storage, key } ) ;
252+ assert . strictEqual ( a . store . type , 'persisted' ) ;
253+ await a . store . set ( 'access' , 'token-AAA' ) ;
254+ await a . store . set ( 'refresh' , 'token-RRR' ) ;
255+ assert . strictEqual ( await a . store . get ( 'access' ) , 'token-AAA' ) ;
256+ assert . deepStrictEqual ( ( await a . store . keys ( ) ) . sort ( ) , [ 'access' , 'refresh' ] ) ;
257+ await a . store . delete ( 'access' ) ;
258+ assert . strictEqual ( await a . store . get ( 'access' ) , undefined ) ;
259+ const reloaded = await tab ( { storage, key } ) ;
260+ assert . strictEqual ( await reloaded . store . get ( 'refresh' ) , 'token-RRR' ) ;
261+ assert . deepStrictEqual ( await reloaded . store . keys ( ) , [ 'refresh' ] ) ;
262+ } ) ;
263+ await test ( 'secrets: what is stored is sealed — neither the value nor its base64 is in it' , async ( ) => {
264+ const storage = memoryStorage ( ) ;
265+ const a = await tab ( { storage, key : await newKey ( ) } ) ;
266+ await a . store . set ( 'refresh' , 'a-very-recognisable-secret-value' ) ;
267+ const blob = storage . getItem ( STORE_KEY ) ;
268+ assert . ok ( blob && blob . length > 20 ) ;
269+ assert . ok ( ! blob . includes ( 'recognisable' ) && ! blob . includes ( Buffer . from ( 'a-very-recognisable-secret-value' ) . toString ( 'base64' ) ) ) ;
270+ } ) ;
271+ await test ( 'secrets: another tab\'s change is seen at once — the rotated refresh token is the one a second tab uses' , async ( ) => {
272+ const storage = memoryStorage ( ) ;
273+ const key = await newKey ( ) ;
274+ const locks = sharedLocks ( ) ;
275+ const first = await tab ( { storage, key, locks } ) ;
276+ await first . store . set ( 'refresh' , 'R1' ) ;
277+ await first . store . set ( 'access' , 'A1' ) ;
278+ const second = await tab ( { storage, key, locks } ) ; // started while R1 was current
279+ assert . strictEqual ( await second . store . get ( 'refresh' ) , 'R1' ) ;
280+ await first . store . set ( 'refresh' , 'R2' ) ; // the first tab renews the session
281+ await first . store . set ( 'access' , 'A2' ) ;
282+ assert . strictEqual ( await second . store . get ( 'refresh' ) , 'R2' , 'a second tab must not present the refresh token that was rotated away' ) ;
283+ assert . strictEqual ( await second . store . get ( 'access' ) , 'A2' ) ;
284+ } ) ;
285+ await test ( 'secrets: a change is one key — a tab does not write back what it saw at start-up' , async ( ) => {
286+ const storage = memoryStorage ( ) ;
287+ const key = await newKey ( ) ;
288+ const locks = sharedLocks ( ) ;
289+ const first = await tab ( { storage, key, locks } ) ;
290+ await first . store . set ( 'access' , 'A1' ) ;
291+ const second = await tab ( { storage, key, locks } ) ;
292+ await first . store . set ( 'refresh' , 'R2' ) ; // after the second tab started
293+ await second . store . set ( 'verifier' , 'V' ) ; // an unrelated change in the second tab
294+ assert . strictEqual ( await first . store . get ( 'refresh' ) , 'R2' , 'the second tab\'s write did not undo the first\'s' ) ;
295+ assert . strictEqual ( await first . store . get ( 'verifier' ) , 'V' ) ;
296+ await second . store . delete ( 'access' ) ; // the second tab signs out of the access token
297+ assert . strictEqual ( await first . store . get ( 'refresh' ) , 'R2' ) ;
298+ assert . strictEqual ( await first . store . get ( 'access' ) , undefined ) ;
299+ } ) ;
300+ await test ( 'secrets: changes made at the same moment in two tabs all land, one at a time' , async ( ) => {
301+ const storage = memoryStorage ( ) ;
302+ const key = await newKey ( ) ;
303+ const locks = sharedLocks ( ) ;
304+ const a = await tab ( { storage, key, locks } ) ;
305+ const b = await tab ( { storage, key, locks } ) ;
306+ const work = [ ] ;
307+ for ( let i = 0 ; i < 12 ; i ++ ) {
308+ work . push ( a . store . set ( 'a' + i , 'x' + i ) ) ;
309+ work . push ( b . store . set ( 'b' + i , 'y' + i ) ) ;
310+ }
311+ await Promise . all ( work ) ;
312+ const keys = await a . store . keys ( ) ;
313+ assert . strictEqual ( keys . length , 24 , 'lost: ' + [ ...Array ( 12 ) . keys ( ) ] . flatMap ( ( i ) => [ 'a' + i , 'b' + i ] ) . filter ( ( k ) => ! keys . includes ( k ) ) . join ( ',' ) ) ;
314+ assert . strictEqual ( locks . held . max , 1 , 'never two changes inside the lock at once' ) ;
315+ } ) ;
316+ await test ( 'secrets: with no Web Locks a tab still applies its own changes one at a time' , async ( ) => {
317+ const storage = memoryStorage ( ) ;
318+ const a = await tab ( { storage, key : await newKey ( ) } ) ;
319+ await Promise . all ( [ ...Array ( 10 ) . keys ( ) ] . map ( ( i ) => a . store . set ( 'k' + i , 'v' + i ) ) ) ;
320+ assert . strictEqual ( ( await a . store . keys ( ) ) . length , 10 ) ;
321+ } ) ;
322+ await test ( 'secrets: a store that cannot be read is dropped, with a warning, and the tab carries on' , async ( ) => {
323+ const storage = memoryStorage ( ) ;
324+ storage . setItem ( STORE_KEY , 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' ) ;
325+ const a = await tab ( { storage, key : await newKey ( ) } ) ;
326+ assert . strictEqual ( await a . store . get ( 'refresh' ) , undefined ) ;
327+ assert . strictEqual ( storage . getItem ( STORE_KEY ) , null ) ;
328+ assert . strictEqual ( a . logs . warn . length >= 1 , true ) ;
329+ await a . store . set ( 'refresh' , 'R' ) ;
330+ assert . strictEqual ( await a . store . get ( 'refresh' ) , 'R' ) ;
331+ } ) ;
332+ await test ( 'secrets: a key from another browser profile cannot read it — that is a sign-in again, not an error' , async ( ) => {
333+ const storage = memoryStorage ( ) ;
334+ const mine = await tab ( { storage, key : await newKey ( ) } ) ;
335+ await mine . store . set ( 'refresh' , 'R' ) ;
336+ const other = await tab ( { storage, key : await newKey ( ) } ) ;
337+ assert . strictEqual ( await other . store . get ( 'refresh' ) , undefined ) ;
338+ } ) ;
339+ await test ( 'secrets: where there is no key the store is in memory, writes nothing, and works for the session' , async ( ) => {
340+ const storage = memoryStorage ( ) ;
341+ const a = await tab ( { storage, key : null , noKey : true } ) ;
342+ assert . strictEqual ( a . store . type , 'in-memory' ) ;
343+ await a . store . set ( 'access' , 'A' ) ;
344+ assert . strictEqual ( await a . store . get ( 'access' ) , 'A' ) ;
345+ assert . deepStrictEqual ( await a . store . keys ( ) , [ 'access' ] ) ;
346+ await a . store . delete ( 'access' ) ;
347+ assert . strictEqual ( await a . store . get ( 'access' ) , undefined ) ;
348+ assert . strictEqual ( storage . m . size , 0 , 'nothing was written to localStorage' ) ;
349+ } ) ;
350+ await test ( 'secrets: when the browser refuses the write the session is kept for this tab, not lost' , async ( ) => {
351+ const storage = memoryStorage ( ) ;
352+ const key = await newKey ( ) ;
353+ const a = await tab ( { storage, key } ) ;
354+ await a . store . set ( 'refresh' , 'R1' ) ;
355+ const realSet = storage . setItem ;
356+ storage . setItem = ( ) => { throw new Error ( 'QuotaExceededError' ) ; } ;
357+ await a . store . set ( 'access' , 'A1' ) ; // refused: kept in memory, with R1 carried over
358+ storage . setItem = realSet ;
359+ assert . strictEqual ( await a . store . get ( 'access' ) , 'A1' ) ;
360+ assert . strictEqual ( await a . store . get ( 'refresh' ) , 'R1' ) ;
361+ await a . store . set ( 'refresh' , 'R2' ) ;
362+ assert . strictEqual ( await a . store . get ( 'refresh' ) , 'R2' ) ;
363+ assert . ok ( a . logs . error . length >= 1 , 'said so' ) ;
364+ } ) ;
365+
200366 /* ---- startReturnFromSignIn --------------------------------------------------------------------- */
201367 function makeStart ( href , { drained = 0 , listening = true , sessionThrows = false } = { } ) {
202368 const calls = { replaced : [ ] , drain : [ ] , fired : [ ] , removed : [ ] } ;
0 commit comments