Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update clear key logic #64

Merged
merged 3 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,10 @@ export class BadTokenKeyRequestedError extends HTTPError {
this.code = BadTokenKeyRequestedError.CODE;
}
}

export class PrevRotationTimeError extends Error {
constructor(message: string) {
super(message);
this.name = 'PrevRotationTimeError';
}
}
35 changes: 19 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { hexEncode } from './utils/hex';
import { DIRECTORY_CACHE_REQUEST, clearDirectoryCache, getDirectoryCache } from './cache';
const { BlindRSAMode, Issuer, TokenRequest } = publicVerif;

import { shouldRotateKey } from './utils/keyRotation';
import { getPrevRotationTime, shouldRotateKey, shouldClearKey } from './utils/keyRotation';

const keyToTokenKeyID = async (key: Uint8Array): Promise<number> => {
const hash = await crypto.subtle.digest('SHA-256', key);
Expand All @@ -38,8 +38,6 @@ interface StorageMetadata extends Record<string, string> {
tokenKeyID: string;
}

const KEY_LIFESPAN = 48 * 60 * 60 * 1000;

export const handleTokenRequest = async (ctx: Context, request: Request) => {
ctx.metrics.issuanceRequestTotal.inc({ version: ctx.env.VERSION_METADATA.id ?? RELEASE });
const contentType = request.headers.get('content-type');
Expand Down Expand Up @@ -231,31 +229,36 @@ export const handleRotateKey = async (ctx: Context, _request?: Request) => {

const handleClearKey = async (ctx: Context, _request?: Request) => {
ctx.metrics.keyClearTotal.inc();

const now = Date.now();
const now = new Date();

const keys = await ctx.bucket.ISSUANCE_KEYS.list({ shouldUseCache: false });

if (keys.objects.length === 0) {
return new Response('No keys to clear', { status: 201 });
}

// iterating twice over keys, because we need to know the latest upload key time to enforce key clearance
// Find the latest key based on the upload time
let latestKey = keys.objects[0];
for (const key of keys.objects) {
if (new Date(key.uploaded).getTime() > new Date(latestKey.uploaded).getTime()) {
latestKey = key;
}
}

const effectivePrevRotationTime = getPrevRotationTime(new Date(latestKey.uploaded), ctx);

const toDelete: Set<string> = new Set();

for (const key of keys.objects) {
lbaquerofierro marked this conversation as resolved.
Show resolved Hide resolved
const keyUploadTime = new Date(key.uploaded).getTime();
const keyExpirationTime = keyUploadTime + KEY_LIFESPAN;

if (keyExpirationTime < now) {
const keyUploadTime = new Date(key.uploaded);
if (shouldClearKey(keyUploadTime, now, effectivePrevRotationTime)) {
toDelete.add(key.key);
}

if (latestKey.uploaded < key.uploaded) {
latestKey = key;
}
}

// Ensure the latest key is never deleted
if (toDelete.has(latestKey.key)) {
toDelete.delete(latestKey.key);
}
toDelete.delete(latestKey.key);
lbaquerofierro marked this conversation as resolved.
Show resolved Hide resolved

if (toDelete.size === 0) {
return new Response('No keys to clear', { status: 201 });
Expand Down
50 changes: 42 additions & 8 deletions src/utils/keyRotation.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import { Bindings } from '../bindings';
import cronParser from 'cron-parser';
import { Context } from '../context';
import { PrevRotationTimeError } from '../errors';

interface CronParseResult {
prevTime?: number;
nextTime?: number;
match: boolean;
}

const KEY_LIFESPAN_IN_MS = 48 * 60 * 60 * 1000;

export function getPrevRotationTime(mostRecentKeyUploadTime: Date, ctx: Context): number {
let effectivePrevTime: number;
if (ctx.env.ROTATION_CRON_STRING) {
const { prevTime } = matchCronTime(ctx.env.ROTATION_CRON_STRING, mostRecentKeyUploadTime);

if (prevTime === undefined) {
console.error('Failed to determine previous rotation time for key');
throw new PrevRotationTimeError('Failed to determine previous rotation time');
}

effectivePrevTime = Math.max(prevTime, mostRecentKeyUploadTime.getTime());
} else {
effectivePrevTime = mostRecentKeyUploadTime.getTime();
}
return effectivePrevTime;
}

export function shouldRotateKey(date: Date, env: Bindings): boolean {
const utcDate = new Date(date.toISOString());
return env.ROTATION_CRON_STRING ? matchCronTime(env.ROTATION_CRON_STRING, utcDate).match : false;
}

if (env.ROTATION_CRON_STRING) {
const result = matchCronTime(env.ROTATION_CRON_STRING, utcDate);
return result;
}
return false;
export function shouldClearKey(keyUploadTime: Date, now: Date, effectivePrevTime: number): boolean {
lbaquerofierro marked this conversation as resolved.
Show resolved Hide resolved
const keyExpirationTime = keyUploadTime.getTime() + KEY_LIFESPAN_IN_MS;
const rotationBasedExpirationTime = effectivePrevTime + KEY_LIFESPAN_IN_MS;
return now.getTime() >= Math.max(keyExpirationTime, rotationBasedExpirationTime);
}

function matchCronTime(cronString: string, date: Date): boolean {
export function matchCronTime(cronString: string, date: Date): CronParseResult {
// Set seconds and milliseconds to 0 to truncate to the nearest minute
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/setUTCSeconds#parameters
date.setUTCSeconds(0, 0);
Expand All @@ -25,12 +53,18 @@ function matchCronTime(cronString: string, date: Date): boolean {
try {
interval = cronParser.parseExpression(cronString, options);
} catch (error) {
return false;
console.error('Error parsing cron string', error);
return { match: false };
}

const prevDate = interval.prev().toDate();
const nextDate = interval.next().toDate();

const result = date.getTime() === prevDate.getTime() || date.getTime() === nextDate.getTime();
return result;

return {
prevTime: prevDate.getTime(),
nextTime: nextDate.getTime(),
match: result,
};
}
143 changes: 92 additions & 51 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,26 @@ import {
util,
} from '@cloudflare/privacypass-ts';
import { getDirectoryCache } from '../src/cache';
import { shouldRotateKey } from '../src/utils/keyRotation';
import {
shouldRotateKey,
shouldClearKey,
matchCronTime,
getPrevRotationTime,
} from '../src/utils/keyRotation';
const { TokenRequest } = publicVerif;

import * as keyRotation from '../src/utils/keyRotation';

// Mock the entire module and ensure matchCronTime is mocked
jest.mock('../src/utils/keyRotation', () => {
const originalModule = jest.requireActual<typeof keyRotation>('../src/utils/keyRotation');
return {
__esModule: true, // Important for ESModules
...originalModule,
matchCronTime: jest.fn(), // mock matchCronTime
};
});

const sampleURL = 'http://localhost';

const keyToTokenKeyID = async (key: Uint8Array): Promise<number> => {
Expand Down Expand Up @@ -284,75 +301,99 @@ describe('directory', () => {
});

describe('key rotation', () => {
it('should rotate key at every minute', async () => {
const ctx = getContext({
request: new Request(sampleURL),
env: getEnv(),
ectx: new ExecutionContextMock(),
});
ctx.env.ROTATION_CRON_STRING = '* * * * *'; // Every minute

const date = new Date('2023-08-01T00:01:00Z');
expect(shouldRotateKey(date, ctx.env)).toBe(true);
});

it('should rotate key at midnight on the first day of every month', async () => {
const ctx = getContext({
request: new Request(sampleURL),
env: getEnv(),
ectx: new ExecutionContextMock(),
});
ctx.env.ROTATION_CRON_STRING = '0 0 1 * *'; // At 00:00 on day-of-month 1
it.concurrent.each`
name | rotationCron | date | expected
${'rotate key at every minute'} | ${'* * * * *'} | ${'2023-08-01T00:01:00Z'} | ${true}
${'rotate key at midnight on the first day of every month'} | ${'0 0 1 * *'} | ${'2023-09-01T00:00:00Z'} | ${true}
${'rotate key at 12:30 PM every day'} | ${'30 12 * * *'} | ${'2023-08-01T12:30:00Z'} | ${true}
${'not rotate key at noon on a non-rotation day'} | ${'0 0 1 * *'} | ${'2023-08-02T12:00:00Z'} | ${false}
${'rotate key at 11:59 PM on the last day of the month'} | ${'59 23 * * *'} | ${'2023-08-31T23:59:00Z'} | ${true}
${'handle rotation with millisecond precision'} | ${'* * * * *'} | ${'2023-08-01T00:01:00.010Z'} | ${true}
`(
'should $name',
async ({
rotationCron,
date,
expected,
}: {
rotationCron: string;
date: string;
expected: boolean;
}) => {
const ctx = getContext({
request: new Request(sampleURL),
env: getEnv(),
ectx: new ExecutionContextMock(),
});
ctx.env.ROTATION_CRON_STRING = rotationCron;

expect(shouldRotateKey(new Date(date), ctx.env)).toBe(expected);
}
);
});

const date = new Date('2023-09-01T00:00:00Z');
expect(shouldRotateKey(date, ctx.env)).toBe(true);
});
describe('shouldClearKey Function', () => {
it.concurrent.each`
name | keyUpload | now | effectivePrev | expected
${'not clear key when neither expiration time has passed'} | ${'2023-10-01T12:00:00Z'} | ${'2023-10-02T11:59:59Z'} | ${'2023-09-30T00:00:00Z'} | ${false}
${'not clear key when per-key expiration has passed but rotation-based expiration has not'} | ${'2023-09-29T12:00:00Z'} | ${'2023-10-01T12:00:01Z'} | ${'2023-10-03T00:00:00Z'} | ${false}
${'not clear key when rotation-based expiration has passed but per-key expiration has not'} | ${'2023-10-03T12:00:00Z'} | ${'2023-10-05T11:59:59Z'} | ${'2023-10-01T00:00:00Z'} | ${false}
${'clear key when both expiration times have passed'} | ${'2023-09-29T12:00:00Z'} | ${'2023-10-05T12:00:01Z'} | ${'2023-09-30T00:00:00Z'} | ${true}
${'clear key when current time equals the maximum expiration time'} | ${'2023-10-01T12:00:00Z'} | ${'2023-10-03T12:00:00Z'} | ${'2023-10-01T12:00:00Z'} | ${true}
`(
'should $name',
({
keyUpload,
now,
effectivePrev,
expected,
}: {
keyUpload: string;
now: string;
effectivePrev: string;
expected: boolean;
}) => {
const keyUploadTime = new Date(keyUpload);
const nowTime = new Date(now);
const effectivePrevTime = new Date(effectivePrev).getTime();

const result = shouldClearKey(keyUploadTime, nowTime, effectivePrevTime);
expect(result).toBe(expected);
}
);
});

it('should rotate key at 12:30 PM every day', async () => {
describe('getPrevRotationTime Function', () => {
it('should return the maximum of prevTime and mostRecentKeyUploadTime when ROTATION_CRON_STRING is valid', () => {
const ctx = getContext({
request: new Request(sampleURL),
env: getEnv(),
ectx: new ExecutionContextMock(),
});
ctx.env.ROTATION_CRON_STRING = '30 12 * * *'; // At 12:30 every day

const date = new Date('2023-08-01T12:30:00Z');
expect(shouldRotateKey(date, ctx.env)).toBe(true);
});
const mostRecentKeyUploadTime = new Date('2023-10-03T12:00:00Z');
ctx.env.ROTATION_CRON_STRING = '* * * * *';

it('should not rotate key at noon on a non-rotation day', async () => {
const ctx = getContext({
request: new Request(sampleURL),
env: getEnv(),
ectx: new ExecutionContextMock(),
});
ctx.env.ROTATION_CRON_STRING = '0 0 1 * *'; // At 00:00 on day-of-month 1
const expectedPrevTime = new Date('2023-10-03T00:00:00Z').getTime();
const expected = Math.max(expectedPrevTime, mostRecentKeyUploadTime.getTime());

const date = new Date('2023-08-02T12:00:00Z'); // 2nd August is not the 1st
expect(shouldRotateKey(date, ctx.env)).toBe(false);
const result = getPrevRotationTime(mostRecentKeyUploadTime, ctx);
expect(result).toBe(expected);
});

it('should rotate key at 11:59 PM on the last day of the month', async () => {
it('should return mostRecentKeyUploadTime when ROTATION_CRON_STRING is not set', () => {
const ctx = getContext({
request: new Request(sampleURL),
env: getEnv(),
ectx: new ExecutionContextMock(),
});
ctx.env.ROTATION_CRON_STRING = '59 23 * * *'; // At 23:59 on the last day of the month

const date = new Date('2023-08-31T23:59:00Z'); // 31st August 2023 is the last day of the month
expect(shouldRotateKey(date, ctx.env)).toBe(true);
});
const mostRecentKeyUploadTime = new Date('2023-10-03T12:00:00Z');
ctx.env.ROTATION_CRON_STRING = undefined;

it('should handle rotation with millisecond precision', async () => {
const ctx = getContext({
request: new Request(sampleURL),
env: getEnv(),
ectx: new ExecutionContextMock(),
});
ctx.env.ROTATION_CRON_STRING = '* * * * *';
const expected = mostRecentKeyUploadTime.getTime();

const date = new Date('2023-08-01T00:01:00.010Z');
expect(shouldRotateKey(date, ctx.env)).toBe(true);
const result = getPrevRotationTime(mostRecentKeyUploadTime, ctx);
expect(result).toBe(expected);
});
});