Skip to content

Commit

Permalink
feat: replay attack prevention using watermarks (#854)
Browse files Browse the repository at this point in the history
* pulling watermark from readstate response

* test failing (good)

* retry query refactor and tests passing

* changelog

* fixing merge inconsistencies

* different strategy for isArrayBuffer

* skip watermark verification if validation is disabled

* fix: watermark tests now run query verification

* remove redundant check for timestamp
  • Loading branch information
krpeacock authored Mar 13, 2024
1 parent 3502217 commit a38cb18
Show file tree
Hide file tree
Showing 11 changed files with 339 additions and 64 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ build_info.json

# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache

# Next.js build output
.next
Expand Down
5 changes: 4 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

## [Unreleased]

* feat: adds `fromPem` method for `identity-secp256k1`
### Added

* feat: adds `fromPem` method for `identity-secp256k1`
* feat: HttpAgent tracks a watermark from the latest readState call. Queries with signatures made before the watermark will be automatically retried, and rejected if they are still behind.

## [1.0.1] - 2024-02-20

Expand Down
129 changes: 129 additions & 0 deletions e2e/node/basic/watermark.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { test, expect, vi } from 'vitest';
import { createActor } from '../canisters/counter';
import { Actor, HttpAgent } from '@dfinity/agent';

class FetchProxy {
#history: Response[] = [];
#calls = 0;
#replyIndex: number | null = null;

async fetch(...args): Promise<Response> {
this.#calls++;
if (this.#replyIndex !== null) {
const response = this.#history[this.#replyIndex].clone();
return response;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await(global.fetch as any)(...args);
this.#history.push(response);
return response.clone();
}

get history() {
return this.#history;
}

get calls() {
return this.#calls;
}

clearHistory() {
this.#history = [];
this.#calls = 0;
this.#replyIndex = null;
}

replayFromHistory(index: number) {
this.#replyIndex = index;
}
}

function indexOfQueryResponse(history: Response[]) {
return history.findIndex(response => response.url.endsWith('query'));
}

test('basic', async () => {
const fetchProxy = new FetchProxy();
global.fetch;

const actor = await createActor({
fetch: fetchProxy.fetch.bind(fetchProxy),
verifyQuerySignatures: true,
});

fetchProxy.clearHistory();
const startValue = await actor.read();
expect(startValue).toBe(0n);
expect(fetchProxy.calls).toBe(2);
}, 10_000);

test('replay queries only', async () => {
const fetchProxy = new FetchProxy();
global.fetch;

const actor = await createActor({
fetch: fetchProxy.fetch.bind(fetchProxy),
verifyQuerySignatures: true,
});

fetchProxy.clearHistory();
const startValue = await actor.read();
expect(startValue).toBe(0n);
expect(fetchProxy.calls).toBe(2);

const queryResponseIndex = indexOfQueryResponse(fetchProxy.history);

fetchProxy.replayFromHistory(queryResponseIndex);

const startValue2 = await actor.read();
expect(startValue2).toBe(0n);
expect(fetchProxy.calls).toBe(3);
}, 10_000);

test('replay attack', async () => {
const fetchProxy = new FetchProxy();
global.fetch;

const actor = await createActor({
verifyQuerySignatures: true,
fetch: fetchProxy.fetch.bind(fetchProxy),
});

const agent = Actor.agentOf(actor) as HttpAgent;
const logFn = vi.fn();
agent.log.subscribe(logFn);

fetchProxy.clearHistory();
const startValue = await actor.read();
expect(startValue).toBe(0n);

// 1: make query
// 2: fetch subnet keys
expect(fetchProxy.calls).toBe(2);

const startValue2 = await actor.read();
expect(startValue2).toBe(0n);
expect(fetchProxy.calls).toBe(3);

await actor.inc();

// wait 1 second
await new Promise(resolve => setTimeout(resolve, 1000));
const startValue3 = await actor.read();
expect(startValue3).toBe(1n);

const queryResponseIndex = indexOfQueryResponse(fetchProxy.history);

fetchProxy.replayFromHistory(queryResponseIndex);

// the replayed request should throw an error
expect(fetchProxy.calls).toBe(7);

await expect(actor.read()).rejects.toThrowError(
'Timestamp failed to pass the watermark after retrying the configured 3 times. We cannot guarantee the integrity of the response since it could be a replay attack.',
);

// The agent should should have made 4 additional requests (3 retries + 1 original request)
expect(fetchProxy.calls).toBe(11);
}, 10_000);
2 changes: 2 additions & 0 deletions packages/agent/src/agent/http/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function createIdentity(seed: number): Ed25519KeyIdentity {
const originalDateNowFn = global.Date.now;
const originalWindow = global.window;
const originalFetch = global.fetch;

beforeEach(() => {
global.Date.now = jest.fn(() => new Date(NANOSECONDS_PER_MILLISECONDS).getTime());
Object.assign(global, 'window', {
Expand All @@ -45,6 +46,7 @@ beforeEach(() => {
global.fetch = originalFetch;
});


afterEach(() => {
global.Date.now = originalDateNowFn;
global.window = originalWindow;
Expand Down
Loading

0 comments on commit a38cb18

Please sign in to comment.