From 64d4f006291b2db2f725b27fb38865f8f94ae47c Mon Sep 17 00:00:00 2001 From: Timo Hocker Date: Fri, 9 Sep 2022 15:49:53 +0200 Subject: [PATCH] blacklist with automatic garbage collector --- lib/Authority.ts | 6 ++- lib/Blacklist.ts | 72 ++++++++++++++++++++++----- lib/KeyStore.ts | 2 +- lib/RedisData/RedisBlacklistStore.ts | 12 +++-- lib/token_id.ts | 21 ++++++++ package.json | 2 +- test/spec/Blacklist.ts | 74 +++++++++++++++++----------- test/spec/Gateway.ts | 2 +- test/spec/token_id.ts | 13 +++++ 9 files changed, 153 insertions(+), 51 deletions(-) create mode 100644 lib/token_id.ts create mode 100644 test/spec/token_id.ts diff --git a/lib/Authority.ts b/lib/Authority.ts index fd28448..8f503a9 100644 --- a/lib/Authority.ts +++ b/lib/Authority.ts @@ -6,13 +6,13 @@ */ import { - create_salt, sign_object, verify_signature_get_info } from '@sapphirecode/crypto-helper'; import keystore from './KeyStore'; import blacklist from './Blacklist'; import { debug } from './debug'; +import { generate_token_id } from './token_id'; const logger = debug ('authority'); @@ -102,13 +102,15 @@ class Authority { const log = logger.extend ('sign'); log ('signing new %s', type); const time = Date.now (); + const valid_until = time + (valid_for * 1e3); const key = await keystore.get_sign_key (time / 1000, valid_for); const attributes = { - id: create_salt (), + id: generate_token_id (new Date (valid_until)), iat: time, iss: keystore.instance_id, type, valid_for, + valid_until, next_module: options?.next_module }; const signature = sign_object (options?.data, key, attributes); diff --git a/lib/Blacklist.ts b/lib/Blacklist.ts index d672f72..e3f2bad 100644 --- a/lib/Blacklist.ts +++ b/lib/Blacklist.ts @@ -7,19 +7,31 @@ import { debug } from './debug'; import { redis_blacklist_store } from './RedisData/RedisBlacklistStore'; +import { parse_token_id } from './token_id'; const logger = debug ('blacklist'); interface Signature { - hash: string; + token_id: string; + iat: number; + valid_until: Date; +} + +interface ExportedSignature { + token_id: string; iat: number; } class Blacklist { private _signatures: Signature[]; + private _interval: NodeJS.Timeout; public constructor () { this._signatures = []; + this._interval = setInterval ( + this.garbage_collect.bind (this), + 3600000 + ); } public async clear ( @@ -34,10 +46,15 @@ class Blacklist { } } - public async add_signature (hash: string): Promise { - logger.extend ('add_signature') ('blacklisting signature %s', hash); - this._signatures.push ({ iat: Date.now (), hash }); - await redis_blacklist_store.add (hash); + public async add_signature (token_id: string): Promise { + logger.extend ('add_signature') ('blacklisting signature %s', token_id); + const parsed = parse_token_id (token_id); + this._signatures.push ({ + iat: Date.now (), + token_id, + valid_until: parsed.valid_until + }); + await redis_blacklist_store.add (token_id, parsed.valid_until); } public async remove_signature (signature: number | string): Promise { @@ -48,13 +65,20 @@ class Blacklist { log ('received string, searching through signatures'); key = signature; for (let i = this._signatures.length - 1; i >= 0; i--) { - if (this._signatures[i].hash === signature) + if (this._signatures[i].token_id === signature) { + log ('removing sigature %s at %d', signature, i); this._signatures.splice (i, 1); + } } } else { - log ('received index, removing at index'); - key = this._signatures[signature].hash; + log ( + 'received index, removing signature %s at index %s', + this._signatures[signature].token_id, + signature + ); + + key = this._signatures[signature].token_id; this._signatures.splice (signature, 1); } await redis_blacklist_store.remove (key); @@ -64,7 +88,7 @@ class Blacklist { const log = logger.extend ('is_valid'); log ('checking signature for blacklist entry %s', hash); for (const sig of this._signatures) { - if (sig.hash === hash) { + if (sig.token_id === hash) { log ('found matching blacklist entry'); return false; } @@ -80,22 +104,44 @@ class Blacklist { return true; } - public export_blacklist (): Signature[] { + public export_blacklist (): ExportedSignature[] { logger.extend ('export_blacklist') ('exporting blacklist'); - return this._signatures; + return this._signatures.map ((v) => ({ + iat: v.iat, + token_id: v.token_id + })); } - public import_blacklist (data: Signature[]): void { + public import_blacklist (data: ExportedSignature[]): void { logger.extend ('import_blacklist') ( 'importing %d blacklist entries', data.length ); - this._signatures.push (...data); + for (const token of data) { + const parsed = parse_token_id (token.token_id); + this._signatures.push ({ + token_id: token.token_id, + iat: token.iat, + valid_until: parsed.valid_until + }); + } } public sync_redis (url: string): void { redis_blacklist_store.connect (url); } + + private async garbage_collect (): Promise { + const log = logger.extend ('garbage_collect'); + const time = new Date; + log ('removing signatures expired before', time); + for (let i = this._signatures.length - 1; i >= 0; i--) { + if (this._signatures[i].valid_until < time) { + log ('signature %s expired', this._signatures[i].token_id); + await this.remove_signature (i); + } + } + } } const bl = (new Blacklist); diff --git a/lib/KeyStore.ts b/lib/KeyStore.ts index 7c83647..b12a27a 100644 --- a/lib/KeyStore.ts +++ b/lib/KeyStore.ts @@ -27,7 +27,7 @@ class KeyStore { public constructor () { this._interval = setInterval (() => { this.garbage_collect (); - }, renew_interval); + }, renew_interval * 1000); this._instance = to_b58 (random_hex (16), 'hex'); logger.extend ('constructor') ( 'created keystore instance %s', diff --git a/lib/RedisData/RedisBlacklistStore.ts b/lib/RedisData/RedisBlacklistStore.ts index 29ba8bd..7274877 100644 --- a/lib/RedisData/RedisBlacklistStore.ts +++ b/lib/RedisData/RedisBlacklistStore.ts @@ -11,14 +11,18 @@ import { Redis } from '../Redis'; const logger = debug ('RedisBlacklistStore'); export class RedisBlacklistStore extends Redis { - public async add (key: string): Promise { + public async add (key: string, valid_until: Date): Promise { const log = logger.extend ('set'); log ('trying to add key %s to redis blacklist', key); if (!this.is_active) { log ('redis is inactive, skipping'); return; } - await this.redis.sadd ('blacklist', key); + await this.redis.setex ( + `blacklist_${key}`, + (valid_until.getTime () - Date.now ()) / 1000, + 1 + ); log ('saved key'); } @@ -29,7 +33,7 @@ export class RedisBlacklistStore extends Redis { log ('redis is inactive, skipping'); return; } - await this.redis.srem ('blacklist', key); + await this.redis.del (`blacklist_${key}`); log ('removed key'); } @@ -40,7 +44,7 @@ export class RedisBlacklistStore extends Redis { log ('redis is inactive, skipping'); return false; } - const res = await this.redis.sismember ('blacklist', key) === 1; + const res = await this.redis.exists (`blacklist_${key}`) === 1; log ('found key %s', res); return res; } diff --git a/lib/token_id.ts b/lib/token_id.ts new file mode 100644 index 0000000..096bbd3 --- /dev/null +++ b/lib/token_id.ts @@ -0,0 +1,21 @@ +import { create_salt } from '@sapphirecode/crypto-helper'; +import { to_b58 } from '@sapphirecode/encoding-helper'; + +export function generate_token_id (valid_until: Date) { + const salt = create_salt (); + return `${to_b58 (salt, 'hex')};${valid_until.toISOString ()}`; +} + +export function parse_token_id (id: string) { + // eslint-disable-next-line max-len + const regex = /^(?[A-HJ-NP-Za-km-z1-9]+);(?\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d{3}Z)$/u; + const result = regex.exec (id); + if (result === null) + throw new Error (`invalid token id ${id}`); + if (typeof result.groups === 'undefined') + throw new Error ('invalid state'); + return { + hash: result.groups.hash as string, + valid_until: new Date (result.groups.date as string) + }; +} diff --git a/package.json b/package.json index b305c2a..9790b15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sapphirecode/auth-server-helper", - "version": "4.0.0", + "version": "4.0.1", "main": "dist/lib/index.js", "author": { "name": "Timo Hocker", diff --git a/test/spec/Blacklist.ts b/test/spec/Blacklist.ts index 79bcdfa..f480227 100644 --- a/test/spec/Blacklist.ts +++ b/test/spec/Blacklist.ts @@ -6,10 +6,15 @@ */ import blacklist, { Blacklist } from '../../lib/Blacklist'; +import { generate_token_id, parse_token_id } from '../../lib/token_id'; import { clock_finalize, clock_setup } from '../Helper'; // eslint-disable-next-line max-lines-per-function describe ('blacklist', () => { + const token1 = generate_token_id (new Date (Date.now () + 3600000)); + const token2 = generate_token_id (new Date (Date.now () + 3600000)); + const token3 = generate_token_id (new Date (Date.now () + 3600000)); + beforeAll (() => { clock_setup (); }); @@ -19,77 +24,88 @@ describe ('blacklist', () => { }); it ('should validate any string', async () => { - expect (await blacklist.is_valid ('foo')) + expect (await blacklist.is_valid (token1)) .toBeTrue (); - expect (await blacklist.is_valid ('bar')) + expect (await blacklist.is_valid (token2)) .toBeTrue (); - expect (await blacklist.is_valid ('baz')) + expect (await blacklist.is_valid (token3)) .toBeTrue (); }); it ('should blacklist strings', async () => { - await blacklist.add_signature ('foo'); - await blacklist.add_signature ('bar'); - expect (await blacklist.is_valid ('foo')) + await blacklist.add_signature (token1); + await blacklist.add_signature (token2); + expect (await blacklist.is_valid (token1)) .toBeFalse (); - expect (await blacklist.is_valid ('bar')) + expect (await blacklist.is_valid (token2)) .toBeFalse (); - expect (await blacklist.is_valid ('baz')) + expect (await blacklist.is_valid (token3)) .toBeTrue (); }); it ('should remove one string', async () => { - await blacklist.remove_signature ('foo'); - expect (await blacklist.is_valid ('foo')) + await blacklist.remove_signature (token1); + expect (await blacklist.is_valid (token1)) .toBeTrue (); - expect (await blacklist.is_valid ('bar')) + expect (await blacklist.is_valid (token2)) .toBeFalse (); - expect (await blacklist.is_valid ('baz')) + expect (await blacklist.is_valid (token3)) .toBeTrue (); }); it ('should clear after time', async () => { jasmine.clock () .tick (5000); - await blacklist.add_signature ('baz'); + await blacklist.add_signature (token3); await blacklist.clear (Date.now () - 100); - expect (await blacklist.is_valid ('foo')) + expect (await blacklist.is_valid (token1)) .toBeTrue (); - expect (await blacklist.is_valid ('bar')) + expect (await blacklist.is_valid (token2)) .toBeTrue (); - expect (await blacklist.is_valid ('baz')) + expect (await blacklist.is_valid (token3)) .toBeFalse (); }); it ('should clear all', async () => { - await blacklist.add_signature ('foo'); - await blacklist.add_signature ('bar'); - await blacklist.add_signature ('baz'); - expect (await blacklist.is_valid ('foo')) + await blacklist.add_signature (token1); + await blacklist.add_signature (token2); + await blacklist.add_signature (token3); + expect (await blacklist.is_valid (token1)) .toBeFalse (); - expect (await blacklist.is_valid ('bar')) + expect (await blacklist.is_valid (token2)) .toBeFalse (); - expect (await blacklist.is_valid ('baz')) + expect (await blacklist.is_valid (token3)) .toBeFalse (); await blacklist.clear (); - expect (await blacklist.is_valid ('foo')) + expect (await blacklist.is_valid (token1)) .toBeTrue (); - expect (await blacklist.is_valid ('bar')) + expect (await blacklist.is_valid (token2)) .toBeTrue (); - expect (await blacklist.is_valid ('baz')) + expect (await blacklist.is_valid (token3)) .toBeTrue (); }); it ('should export and import data', async () => { - await blacklist.add_signature ('baz'); - const exp = blacklist.export_blacklist (); + const time = new Date; + const token = generate_token_id (time); + await blacklist.add_signature (token); // eslint-disable-next-line dot-notation expect (blacklist['_signatures']) - .toEqual (exp); + .toEqual ([ + { + token_id: token, + iat: time.getTime (), + valid_until: time + } + ]); + const exp = blacklist.export_blacklist (); + expect (exp) + .toEqual ([ { token_id: token, iat: time.getTime () } ]); const bl2 = (new Blacklist); bl2.import_blacklist (exp); // eslint-disable-next-line dot-notation expect (bl2['_signatures']) - .toEqual (exp); + // eslint-disable-next-line dot-notation + .toEqual (blacklist['_signatures']); }); }); diff --git a/test/spec/Gateway.ts b/test/spec/Gateway.ts index 2969f30..5e1e851 100644 --- a/test/spec/Gateway.ts +++ b/test/spec/Gateway.ts @@ -192,7 +192,7 @@ describe ('gateway', () => { expect (resp.statusCode) .toEqual (200); const blacklisted = blacklist.export_blacklist () - .map ((v) => v.hash); + .map ((v) => v.token_id); expect (blacklisted) .toContain (token.id); expect (blacklisted) diff --git a/test/spec/token_id.ts b/test/spec/token_id.ts new file mode 100644 index 0000000..ba207ca --- /dev/null +++ b/test/spec/token_id.ts @@ -0,0 +1,13 @@ +import { generate_token_id, parse_token_id } from '../../lib/token_id'; + +describe ('token_id', () => { + it ('should always generate valid tokens', () => { + for (let i = 0; i < 1000; i++) { + const date = new Date; + const token_id = generate_token_id (new Date); + const parsed = parse_token_id (token_id); + expect (parsed.valid_until) + .toEqual (date); + } + }); +});