Skip to content

Commit 68c388a

Browse files
authored
feat: Add support for signatures and verification. (#259)
* feat: Add support for signatures and verification. * Implement verifyHash. * Implement verifySignatures.
1 parent 2aa347b commit 68c388a

29 files changed

+754
-186
lines changed

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,29 @@ controller.abort();
422422
To list a specific event type, call the `readEventType` function with the event type as an argument. The function returns the detailed event type, which includes the schema:
423423

424424
```typescript
425-
eventType = await client.readEventType("io.eventsourcingdb.library.book-acquired")
425+
eventType = await client.readEventType("io.eventsourcingdb.library.book-acquired");
426+
```
427+
428+
### Verifying an Event's Hash
429+
430+
To verify the integrity of an event, call the `verifyHash` function on the event instance. This recomputes the event's hash locally and compares it to the hash stored in the event. If the hashes differ, the function returns an error:
431+
432+
```typescript
433+
event.VerifyHash();
434+
```
435+
436+
*Note that this only verifies the hash. If you also want to verify the signature, you can skip this step and call `verifySignature` directly, which performs a hash verification internally.*
437+
438+
### Verifying an Event's Signature
439+
440+
To verify the authenticity of an event, call the `verifySignature` function on the event instance. This requires the public key that matches the private key used for signing on the server.
441+
442+
The function first verifies the event's hash, and then checks the signature. If any verification step fails, it returns an error:
443+
444+
```typescript
445+
const verificationKey = // an ed25519 public key
446+
447+
event.verifySignature(verificationKey);
426448
```
427449

428450
### Using Testcontainers
@@ -465,6 +487,22 @@ const container = new Container()
465487
.withApiToken('secret');
466488
```
467489

490+
If you want to sign events, call the `withSigningKey` function. This generates a new signing and verification key pair inside the container:
491+
492+
```typescript
493+
const container = new Container()
494+
.withSigningKey();
495+
```
496+
497+
You can retrieve the private key (for signing) and the public key (for verifying signatures) once the container has been started:
498+
499+
```typescript
500+
const signingKey = container.getSigningKey();
501+
const verificationKey = container.getVerificationKey();
502+
```
503+
504+
The `signingKey` can be used when configuring the container to sign outgoing events. The `verificationKey` can be passed to `verifySignature` when verifying events read from the database.
505+
468506
#### Configuring the Client Manually
469507

470508
In case you need to set up the client yourself, use the following functions to get details on the container:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"typescript": "5.9.2"
2626
},
2727
"scripts": {
28-
"analyze": "npx biome check --error-on-warnings .",
28+
"analyze": "npx tsc --noEmit && npx biome check --error-on-warnings .",
2929
"build": "npx tsc --noEmit && npx tsup --clean --dts --format cjs,esm --minify --out-dir=./dist/ ./src/index.ts",
3030
"format": "npx biome check --write .",
3131
"qa": "npm run analyze && npm run test",

src/Client.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ import { isStreamHeartbeat } from './stream/isStreamHeartbeat.js';
1414
import { isStreamRow } from './stream/isStreamRow.js';
1515
import { isStreamSubject } from './stream/isStreamSubject.js';
1616
import { hasShapeOf } from './types/hasShapeOf.js';
17+
import { isBoolean } from './types/isBoolean.js';
18+
import { isString } from './types/isString.js';
19+
20+
interface ResponseBodyPing {
21+
type: string;
22+
}
23+
24+
interface ResponseBodyVerifyApiToken {
25+
type: string;
26+
}
27+
28+
interface ResponseBodyReadEventType {
29+
eventType: string;
30+
isPhantom: boolean;
31+
}
1732

1833
class Client {
1934
#url: URL;
@@ -39,7 +54,11 @@ class Client {
3954
}
4055

4156
const responseBody = await response.json();
42-
if (!hasShapeOf(responseBody, { type: 'string' })) {
57+
if (
58+
!hasShapeOf<ResponseBodyPing>(responseBody, {
59+
type: isString,
60+
})
61+
) {
4362
throw new Error('Failed to parse response.');
4463
}
4564

@@ -65,7 +84,11 @@ class Client {
6584
}
6685

6786
const responseBody = await response.json();
68-
if (!hasShapeOf(responseBody, { type: 'string' })) {
87+
if (
88+
!hasShapeOf<ResponseBodyVerifyApiToken>(responseBody, {
89+
type: isString,
90+
})
91+
) {
6992
throw new Error('Failed to parse response.');
7093
}
7194

@@ -444,9 +467,9 @@ class Client {
444467
}
445468
const responseBody = await response.json();
446469
if (
447-
!hasShapeOf(responseBody, {
448-
eventType: 'string',
449-
isPhantom: true,
470+
!hasShapeOf<ResponseBodyReadEventType>(responseBody, {
471+
eventType: isString,
472+
isPhantom: isBoolean,
450473
})
451474
) {
452475
throw new Error('Failed to parse response.');

src/CloudEvent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface CloudEvent {
1111
predecessorhash: string;
1212
traceparent?: string;
1313
tracestate?: string;
14+
signature: string | null;
1415
}
1516

1617
export type { CloudEvent };

src/Container.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
import type { StartedTestContainer } from 'testcontainers';
1+
import crypto from 'node:crypto';
2+
import type { Content, StartedTestContainer } from 'testcontainers';
23
import { GenericContainer, Wait } from 'testcontainers';
34
import { Client } from './Client.js';
45

6+
type ContentToCopy = {
7+
content: Content;
8+
target: string;
9+
mode?: number;
10+
};
11+
512
class Container {
613
#imageName = 'thenativeweb/eventsourcingdb';
714
#imageTag = 'latest';
815
#internalPort = 3000;
916
#apiToken = 'secret';
17+
#signingKey: crypto.KeyObject | undefined;
1018
#container: StartedTestContainer | undefined;
1119

1220
public withImageTag(tag: string): this {
@@ -19,22 +27,44 @@ class Container {
1927
return this;
2028
}
2129

30+
public withSigningKey(): this {
31+
const { privateKey } = crypto.generateKeyPairSync('ed25519');
32+
this.#signingKey = privateKey;
33+
return this;
34+
}
35+
2236
public withPort(port: number): this {
2337
this.#internalPort = port;
2438
return this;
2539
}
2640

2741
public async start(): Promise<void> {
42+
const command = [
43+
'run',
44+
'--api-token',
45+
this.#apiToken,
46+
'--data-directory-temporary',
47+
'--http-enabled',
48+
'--https-enabled=false',
49+
];
50+
51+
const contents: ContentToCopy[] = [];
52+
53+
if (this.#signingKey !== undefined) {
54+
command.push('--signing-key-file');
55+
command.push('/etc/esdb/signing-key.pem');
56+
57+
contents.push({
58+
content: this.#signingKey.export({ format: 'pem', type: 'pkcs8' }),
59+
target: '/etc/esdb/signing-key.pem',
60+
mode: 0o777,
61+
});
62+
}
63+
2864
this.#container = await new GenericContainer(`${this.#imageName}:${this.#imageTag}`)
2965
.withExposedPorts(this.#internalPort)
30-
.withCommand([
31-
'run',
32-
'--api-token',
33-
this.#apiToken,
34-
'--data-directory-temporary',
35-
'--http-enabled',
36-
'--https-enabled=false',
37-
])
66+
.withCommand(command)
67+
.withCopyContentToContainer(contents)
3868
.withWaitStrategy(Wait.forHttp('/api/v1/ping', this.#internalPort).withStartupTimeout(10_000))
3969
.start();
4070
}
@@ -70,6 +100,22 @@ class Container {
70100
return this.#apiToken;
71101
}
72102

103+
public getSigningKey(): crypto.KeyObject {
104+
if (this.#signingKey === undefined) {
105+
throw new Error('Signing key not set.');
106+
}
107+
return this.#signingKey;
108+
}
109+
110+
public getVerificationKey(): crypto.KeyObject {
111+
if (this.#signingKey === undefined) {
112+
throw new Error('Signing key not set.');
113+
}
114+
115+
const verificationKey = crypto.createPublicKey(this.#signingKey);
116+
return verificationKey;
117+
}
118+
73119
public isRunning(): boolean {
74120
return this.#container !== undefined;
75121
}

0 commit comments

Comments
 (0)