diff --git a/chart/values.dev.yml b/chart/values.dev.yml index f404ef7f9..daae73b3a 100644 --- a/chart/values.dev.yml +++ b/chart/values.dev.yml @@ -14,7 +14,7 @@ logging: ingress: enableTls: false -internetAddress: gateway.tld +internetAddress: public-gateway-pohttp.default # Skip the broker; post directly to the CloudEvents endpoint in development queueChannel: http://public-gateway-queue diff --git a/package-lock.json b/package-lock.json index 4b75fb22b..052694833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,13 @@ "dependencies": { "@grpc/grpc-js": "^1.9.5", "@redis/client": "^1.5.11", - "@relaycorp/awala-keystore-cloud": "^2.1.30", + "@relaycorp/awala-keystore-cloud": "^2.2.2", "@relaycorp/awala-keystore-mongodb": "^1.1.25", "@relaycorp/cloudevents-transport": "^2.0.6", "@relaycorp/cogrpc": "^1.4.1", "@relaycorp/object-storage": "^1.4.93", "@relaycorp/pino-cloud": "^1.0.28", - "@relaycorp/relaynet-core": "^1.87.8", + "@relaycorp/relaynet-core": "^1.88.1", "@relaycorp/relaynet-pohttp": "^1.7.76", "@typegoose/typegoose": "^11.5.1", "abortable-iterator": "^3.0.0", @@ -2725,35 +2725,35 @@ } }, "node_modules/@relaycorp/awala-keystore-cloud": { - "version": "2.1.30", - "resolved": "https://registry.npmjs.org/@relaycorp/awala-keystore-cloud/-/awala-keystore-cloud-2.1.30.tgz", - "integrity": "sha512-aw9yP+Z715yMfOVwnlWYrBHyxTp5b8LkLckQBjsY0Baay74fuDs/xqkjM0vRDzPlCfO7p1g9241i0/dom4QnOA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@relaycorp/awala-keystore-cloud/-/awala-keystore-cloud-2.2.2.tgz", + "integrity": "sha512-zG6xAjkLVBOy6OaDPbyO57f9Yx+gCdd9y1ZHHONePM4XXqpOkOinSOR8GElSR+lJNLwtrNCBFnnNqJF1lrGPag==", "dependencies": { "@google-cloud/kms": "^4.0.1", - "@relaycorp/relaynet-core": ">=1.81.6, < 2.0", + "@relaycorp/relaynet-core": ">=1.88.1, < 2.0", "@typegoose/typegoose": "< 12.0", - "axios": "^1.6.2", + "axios": "^1.6.5", "env-var": "^7.4.1", "fast-crc32c": "^2.0.0", "mongoose": "< 8.0", "webcrypto-core": "< 2.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "@relaycorp/relaynet-core": ">=1.81.6, < 2.0", + "@relaycorp/relaynet-core": ">=1.88.1, < 2.0", "@typegoose/typegoose": "< 12.0", "mongoose": "< 8.0", "webcrypto-core": "< 2.0" } }, "node_modules/@relaycorp/awala-keystore-cloud/node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", + "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -2852,9 +2852,9 @@ } }, "node_modules/@relaycorp/relaynet-core": { - "version": "1.87.8", - "resolved": "https://registry.npmjs.org/@relaycorp/relaynet-core/-/relaynet-core-1.87.8.tgz", - "integrity": "sha512-nD33UZZkd18Gr336/rtupPp8/bdr5UA8BwDdlU8ayD97II5qm5Bin8wikWhOMgjZ1lNZwkLDwvb1OYZd68Ho4A==", + "version": "1.88.1", + "resolved": "https://registry.npmjs.org/@relaycorp/relaynet-core/-/relaynet-core-1.88.1.tgz", + "integrity": "sha512-ts+1z19psaDyWYIHa2BuEEKj2IlZ+v8DjgXHWhIrEGnOAfllAMXpv/sXRK5zXzdA8HJgYThmSngJf2ikIsPCQw==", "dependencies": { "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", @@ -2863,7 +2863,7 @@ "@types/verror": "^1.10.9", "asn1js": "^3.0.5", "buffer-to-arraybuffer": "0.0.6", - "date-fns": "^3.1.0", + "date-fns": "^3.2.0", "dohdec": "^3.1.0", "is-valid-domain": "^0.1.6", "moment": "^2.30.1", @@ -2882,9 +2882,9 @@ } }, "node_modules/@relaycorp/relaynet-core/node_modules/date-fns": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.1.0.tgz", - "integrity": "sha512-ZO7yefXV/wCWzd3I9haCHmfzlfA3i1a2HHO7ZXjtJrRjXt8FULKJ2Vl8wji3XYF4dQ0ZJ/tokXDZeYlFvgms9Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.2.0.tgz", + "integrity": "sha512-E4KWKavANzeuusPi0jUjpuI22SURAznGkx7eZV+4i6x2A+IZxAMcajgkvuDAU1bg40+xuhW1zRdVIIM/4khuIg==", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -14764,14 +14764,14 @@ } }, "@relaycorp/awala-keystore-cloud": { - "version": "2.1.30", - "resolved": "https://registry.npmjs.org/@relaycorp/awala-keystore-cloud/-/awala-keystore-cloud-2.1.30.tgz", - "integrity": "sha512-aw9yP+Z715yMfOVwnlWYrBHyxTp5b8LkLckQBjsY0Baay74fuDs/xqkjM0vRDzPlCfO7p1g9241i0/dom4QnOA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@relaycorp/awala-keystore-cloud/-/awala-keystore-cloud-2.2.2.tgz", + "integrity": "sha512-zG6xAjkLVBOy6OaDPbyO57f9Yx+gCdd9y1ZHHONePM4XXqpOkOinSOR8GElSR+lJNLwtrNCBFnnNqJF1lrGPag==", "requires": { "@google-cloud/kms": "^4.0.1", - "@relaycorp/relaynet-core": ">=1.81.6, < 2.0", + "@relaycorp/relaynet-core": ">=1.88.1, < 2.0", "@typegoose/typegoose": "< 12.0", - "axios": "^1.6.2", + "axios": "^1.6.5", "env-var": "^7.4.1", "fast-crc32c": "^2.0.0", "mongoose": "< 8.0", @@ -14779,11 +14779,11 @@ }, "dependencies": { "axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", + "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", "requires": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -14852,9 +14852,9 @@ } }, "@relaycorp/relaynet-core": { - "version": "1.87.8", - "resolved": "https://registry.npmjs.org/@relaycorp/relaynet-core/-/relaynet-core-1.87.8.tgz", - "integrity": "sha512-nD33UZZkd18Gr336/rtupPp8/bdr5UA8BwDdlU8ayD97II5qm5Bin8wikWhOMgjZ1lNZwkLDwvb1OYZd68Ho4A==", + "version": "1.88.1", + "resolved": "https://registry.npmjs.org/@relaycorp/relaynet-core/-/relaynet-core-1.88.1.tgz", + "integrity": "sha512-ts+1z19psaDyWYIHa2BuEEKj2IlZ+v8DjgXHWhIrEGnOAfllAMXpv/sXRK5zXzdA8HJgYThmSngJf2ikIsPCQw==", "requires": { "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", @@ -14863,7 +14863,7 @@ "@types/verror": "^1.10.9", "asn1js": "^3.0.5", "buffer-to-arraybuffer": "0.0.6", - "date-fns": "^3.1.0", + "date-fns": "^3.2.0", "dohdec": "^3.1.0", "is-valid-domain": "^0.1.6", "moment": "^2.30.1", @@ -14875,9 +14875,9 @@ }, "dependencies": { "date-fns": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.1.0.tgz", - "integrity": "sha512-ZO7yefXV/wCWzd3I9haCHmfzlfA3i1a2HHO7ZXjtJrRjXt8FULKJ2Vl8wji3XYF4dQ0ZJ/tokXDZeYlFvgms9Q==" + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.2.0.tgz", + "integrity": "sha512-E4KWKavANzeuusPi0jUjpuI22SURAznGkx7eZV+4i6x2A+IZxAMcajgkvuDAU1bg40+xuhW1zRdVIIM/4khuIg==" }, "is-valid-domain": { "version": "0.1.6", diff --git a/package.json b/package.json index 3be84522e..0cc2c1524 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,13 @@ "dependencies": { "@grpc/grpc-js": "^1.9.5", "@redis/client": "^1.5.11", - "@relaycorp/awala-keystore-cloud": "^2.1.30", + "@relaycorp/awala-keystore-cloud": "^2.2.2", "@relaycorp/awala-keystore-mongodb": "^1.1.25", "@relaycorp/cloudevents-transport": "^2.0.6", "@relaycorp/cogrpc": "^1.4.1", "@relaycorp/object-storage": "^1.4.93", "@relaycorp/pino-cloud": "^1.0.28", - "@relaycorp/relaynet-core": "^1.87.8", + "@relaycorp/relaynet-core": "^1.88.1", "@relaycorp/relaynet-pohttp": "^1.7.76", "@typegoose/typegoose": "^11.5.1", "abortable-iterator": "^3.0.0", diff --git a/src/bin/generate-keypairs.ts b/src/bin/generate-keypairs.ts index 0fe2adb2d..879ad4eb6 100644 --- a/src/bin/generate-keypairs.ts +++ b/src/bin/generate-keypairs.ts @@ -1,53 +1,28 @@ -import { CertificationPath, issueGatewayCertificate } from '@relaycorp/relaynet-core'; -import { MongoCertificateStore } from '@relaycorp/awala-keystore-mongodb'; -import { addDays } from 'date-fns'; -import { Connection } from 'mongoose'; +import type { Connection } from 'mongoose'; import { createMongooseConnectionFromEnv } from '../backingServices/mongo'; -import { initPrivateKeyStore } from '../backingServices/keystore'; -import { CERTIFICATE_TTL_DAYS } from '../pki'; -import { Config, ConfigKey } from '../utilities/config'; import { configureExitHandling } from '../utilities/exitHandling'; import { makeLogger } from '../utilities/logging'; +import { InternetGatewayManager } from '../node/InternetGatewayManager'; const LOGGER = makeLogger(); configureExitHandling(LOGGER); async function main(): Promise { - const connection = await createMongooseConnectionFromEnv(); + const connection = createMongooseConnectionFromEnv(); try { - const certificateStore = new MongoCertificateStore(connection); - - const config = new Config(connection); - const currentId = await config.get(ConfigKey.CURRENT_ID); - if (currentId) { - LOGGER.info({ id: currentId }, `Gateway key pair already exists`); - } else { - await generateKeyPair(certificateStore, config, connection); - } + await bootstrap(connection); } finally { await connection.close(); } -} - -async function generateKeyPair( - certificateStore: MongoCertificateStore, - config: Config, - connection: Connection, -): Promise { - const privateKeyStore = initPrivateKeyStore(connection); - const { id, privateKey, publicKey } = await privateKeyStore.generateIdentityKeyPair(); - const gatewayCertificate = await issueGatewayCertificate({ - issuerPrivateKey: privateKey, - subjectPublicKey: publicKey, - validityEndDate: addDays(new Date(), CERTIFICATE_TTL_DAYS), - }); - await certificateStore.save(new CertificationPath(gatewayCertificate, []), id); - - await config.set(ConfigKey.CURRENT_ID, id); + LOGGER.info('Done'); +} - LOGGER.info({ id }, 'Identity key pair was successfully generated'); +async function bootstrap(connection: Connection): Promise { + const manager = await InternetGatewayManager.init(connection); + const gateway = await manager.getOrCreateCurrent(); + await gateway.makeInitialSessionKeyIfMissing(); } main(); diff --git a/src/functionalTests/poweb.test.ts b/src/functionalTests/poweb.test.ts index 84dd628a7..e2b4c8ec5 100644 --- a/src/functionalTests/poweb.test.ts +++ b/src/functionalTests/poweb.test.ts @@ -3,6 +3,7 @@ import { generateRSAKeyPair, issueDeliveryAuthorization, issueEndpointCertificate, + NodeConnectionParams, Parcel, ParcelDeliverySigner, PrivateNodeRegistrationRequest, @@ -18,7 +19,7 @@ import uuid from 'uuid-random'; import { GeneratedParcel } from '../testUtils/awala'; import { ExternalPdaChain } from '../testUtils/pki'; -import { GW_POWEB_HOST_PORT } from './utils/constants'; +import { GW_INTERNET_ADDRESS, GW_POWEB_HOST_PORT } from './utils/constants'; import { createAndRegisterPrivateGateway, registerPrivateGateway, @@ -26,6 +27,13 @@ import { import { extractPong, makePingParcel } from './utils/ping'; import { collectNextParcel, waitForNextParcel } from './utils/poweb'; +test('Connection params should be available', async () => { + const response = await fetch(`http://127.0.0.1:${GW_POWEB_HOST_PORT}/connection-params.der`); + + const params = await NodeConnectionParams.deserialize(await response.arrayBuffer()); + expect(params.internetAddress).toBe(GW_INTERNET_ADDRESS); +}); + describe('Node registration', () => { test('Valid registration requests should be accepted', async () => { const client = PoWebClient.initLocal(GW_POWEB_HOST_PORT); diff --git a/src/functionalTests/utils/poweb.ts b/src/functionalTests/utils/poweb.ts index 1ae071801..0e1610133 100644 --- a/src/functionalTests/utils/poweb.ts +++ b/src/functionalTests/utils/poweb.ts @@ -1,6 +1,7 @@ import { Parcel, ParcelCollectionHandshakeSigner, StreamingMode } from '@relaycorp/relaynet-core'; import { PoWebClient } from '@relaycorp/relaynet-poweb'; import { collect, pipeline, take } from 'streaming-iterables'; + import { ExternalPdaChain } from '../../testUtils/pki'; /** diff --git a/src/node/InternetGateway.spec.ts b/src/node/InternetGateway.spec.ts index 75aa488f5..2c138fcf0 100644 --- a/src/node/InternetGateway.spec.ts +++ b/src/node/InternetGateway.spec.ts @@ -1,4 +1,4 @@ -import { Cargo, MockKeyStoreSet, SessionKeyPair } from '@relaycorp/relaynet-core'; +import { Cargo, MockKeyStoreSet, type SessionKey, SessionKeyPair } from '@relaycorp/relaynet-core'; import { CDACertPath, generateCDACertificationPath, @@ -7,10 +7,10 @@ import { } from '@relaycorp/relaynet-testing'; import bufferToArray from 'buffer-to-arraybuffer'; import { addMinutes } from 'date-fns'; +import { collect } from 'streaming-iterables'; import { arrayToAsyncIterable } from '../testUtils/iter'; import { InternetGateway } from './InternetGateway'; -import { collect } from 'streaming-iterables'; let keyPairSet: NodeKeyPairSet; let cdaChain: CDACertPath; @@ -94,3 +94,27 @@ describe('getChannelFromCda', () => { expect(channel.peer.internetAddress).toBeUndefined(); }); }); + +describe('makeInitialSessionKeyIfMissing', () => { + test('Key should be generated if there are no existing unbound keys', async () => { + await expect( + KEY_STORES.privateKeyStore.retrieveUnboundSessionPublicKey(internetGateway.id), + ).resolves.toBeNull(); + + await expect(internetGateway.makeInitialSessionKeyIfMissing()).resolves.toBeTrue(); + + await expect( + KEY_STORES.privateKeyStore.retrieveUnboundSessionPublicKey(internetGateway.id), + ).resolves.not.toBeNull(); + }); + + test('Key should not be generated if there are existing unbound keys', async () => { + const preExistingKey = await internetGateway.generateSessionKey(); + + await expect(internetGateway.makeInitialSessionKeyIfMissing()).resolves.toBeFalse(); + + await expect( + KEY_STORES.privateKeyStore.retrieveUnboundSessionPublicKey(internetGateway.id), + ).resolves.toSatisfy((key) => key.keyId.equals(preExistingKey.keyId)); + }); +}); diff --git a/src/node/InternetGateway.ts b/src/node/InternetGateway.ts index 3fa88ae7c..c8fd26f9d 100644 --- a/src/node/InternetGateway.ts +++ b/src/node/InternetGateway.ts @@ -22,4 +22,20 @@ export class InternetGateway extends Gateway { }; return new InternetGatewayChannel(this, peer, new CertificationPath(cda, []), this.keyStores); } + + /** + * Generate the initial session key if it doesn't exist yet. + * @returns Whether the initial session key was created. + */ + public async makeInitialSessionKeyIfMissing(): Promise { + const existingKey = await this.keyStores.privateKeyStore.retrieveUnboundSessionPublicKey( + this.id, + ); + if (existingKey !== null) { + return false; + } + + await this.generateSessionKey(); + return true; + } } diff --git a/src/node/InternetGatewayManager.spec.ts b/src/node/InternetGatewayManager.spec.ts index 951837d75..44dc4c7d8 100644 --- a/src/node/InternetGatewayManager.spec.ts +++ b/src/node/InternetGatewayManager.spec.ts @@ -1,5 +1,6 @@ -import { KeyStoreSet, MockKeyStoreSet } from '@relaycorp/relaynet-core'; +import { derSerializePublicKey, KeyStoreSet, MockKeyStoreSet } from '@relaycorp/relaynet-core'; import { MongoCertificateStore, MongoPublicKeyStore } from '@relaycorp/awala-keystore-mongodb'; +import { addDays, setMilliseconds } from 'date-fns'; import * as vault from '../backingServices/keystore'; import { InternetGatewayError } from '../errors'; @@ -7,6 +8,7 @@ import { setUpTestDBConnection } from '../testUtils/db'; import { mockSpy } from '../testUtils/jest'; import { Config, ConfigKey } from '../utilities/config'; import { InternetGatewayManager } from './InternetGatewayManager'; +import { CERTIFICATE_TTL_DAYS } from '../pki'; jest.mock('@relaycorp/awala-keystore-mongodb'); @@ -57,6 +59,106 @@ describe('init', () => { }); }); +describe('getOrCreateCurrent', () => { + describe('Retrieval', () => { + test('Error should be thrown if current address is set but key does not exist', async () => { + const connection = getMongoConnection(); + const manager = new InternetGatewayManager(connection, keyStoreSet); + const invalidId = 'invalid'; + const config = new Config(connection); + await config.set(ConfigKey.CURRENT_ID, invalidId); + + await expect(manager.getOrCreateCurrent()).rejects.toThrowWithMessage( + InternetGatewayError, + `Internet gateway does not exist (id: ${invalidId})`, + ); + }); + + test('Current gateway should be returned if address is set', async () => { + const mongoConnection = getMongoConnection(); + const manager = new InternetGatewayManager(mongoConnection, keyStoreSet); + const { id } = await keyStoreSet.privateKeyStore.generateIdentityKeyPair(); + const config = new Config(mongoConnection); + await config.set(ConfigKey.CURRENT_ID, id); + + const gateway = await manager.getOrCreateCurrent(); + + expect(gateway.id).toEqual(id); + }); + }); + + describe('Creation', () => { + describe('Identity certificate', () => { + test('Certificate path should be stored', async () => { + const manager = new InternetGatewayManager(getMongoConnection(), keyStoreSet); + + const gateway = await manager.getOrCreateCurrent(); + + await expect( + keyStoreSet.certificateStore.retrieveLatest(gateway.id, gateway.id), + ).resolves.toBeDefined(); + }); + + test('Certificate should be self-issued with identity key pair', async () => { + const manager = new InternetGatewayManager(getMongoConnection(), keyStoreSet); + + const gateway = await manager.getOrCreateCurrent(); + + const { leafCertificate } = (await keyStoreSet.certificateStore.retrieveLatest( + gateway.id, + gateway.id, + ))!!; + await expect( + derSerializePublicKey(await leafCertificate.getPublicKey()), + ).resolves.toStrictEqual(await derSerializePublicKey(gateway.identityKeyPair.publicKey)); + }); + + test('Certificate should be valid for the expected duration', async () => { + const manager = new InternetGatewayManager(getMongoConnection(), keyStoreSet); + const beforeIssuance = setMilliseconds(new Date(), 0); + + const gateway = await manager.getOrCreateCurrent(); + + const afterIssuance = setMilliseconds(new Date(), 0); + const { leafCertificate } = (await keyStoreSet.certificateStore.retrieveLatest( + gateway.id, + gateway.id, + ))!!; + expect(leafCertificate.startDate).toBeAfterOrEqualTo(beforeIssuance); + expect(leafCertificate.startDate).toBeBeforeOrEqualTo(afterIssuance); + expect(leafCertificate.expiryDate).toBeBeforeOrEqualTo( + addDays(afterIssuance, CERTIFICATE_TTL_DAYS), + ); + expect(leafCertificate.expiryDate).toBeAfterOrEqualTo( + addDays(beforeIssuance, CERTIFICATE_TTL_DAYS), + ); + }); + + test('Path should have no intermediate CAs', async () => { + const manager = new InternetGatewayManager(getMongoConnection(), keyStoreSet); + + const gateway = await manager.getOrCreateCurrent(); + + const { certificateAuthorities } = (await keyStoreSet.certificateStore.retrieveLatest( + gateway.id, + gateway.id, + ))!!; + expect(certificateAuthorities).toBeEmpty(); + }); + }); + + test('New gateway id should be stored as current', async () => { + const connection = getMongoConnection(); + const manager = new InternetGatewayManager(connection, keyStoreSet); + + const gateway = await manager.getOrCreateCurrent(); + + const config = new Config(connection); + expect(gateway.id).toBe(await config.get(ConfigKey.CURRENT_ID)); + }); + }); +}); + describe('getCurrent', () => { test('Error should be thrown if current address is unset', async () => { const manager = new InternetGatewayManager(getMongoConnection(), keyStoreSet); diff --git a/src/node/InternetGatewayManager.ts b/src/node/InternetGatewayManager.ts index 6481a71d9..06769a99f 100644 --- a/src/node/InternetGatewayManager.ts +++ b/src/node/InternetGatewayManager.ts @@ -1,11 +1,18 @@ -import { GatewayManager, KeyStoreSet } from '@relaycorp/relaynet-core'; +import { + CertificationPath, + GatewayManager, + issueGatewayCertificate, + KeyStoreSet, +} from '@relaycorp/relaynet-core'; import { MongoCertificateStore, MongoPublicKeyStore } from '@relaycorp/awala-keystore-mongodb'; +import { addDays } from 'date-fns'; import { Connection } from 'mongoose'; import { initPrivateKeyStore } from '../backingServices/keystore'; import { InternetGatewayError } from '../errors'; import { Config, ConfigKey } from '../utilities/config'; import { InternetGateway } from './InternetGateway'; +import { CERTIFICATE_TTL_DAYS } from '../pki'; export class InternetGatewayManager extends GatewayManager { public static async init(mongoConnection: Connection): Promise { @@ -28,16 +35,48 @@ export class InternetGatewayManager extends GatewayManager { super(keyStores); } + public async getOrCreateCurrent(): Promise { + let id = await this.getCurrentId(); + if (!id) { + id = await this.create(); + const config = new Config(this.connection); + await config.set(ConfigKey.CURRENT_ID, id); + } + return this.getOrThrow(id); + } + public async getCurrent(): Promise { - const config = new Config(this.connection); - const id = await config.get(ConfigKey.CURRENT_ID); + const id = await this.getCurrentId(); if (!id) { throw new InternetGatewayError('Current id is unset'); } + return this.getOrThrow(id); + } + + protected async getCurrentId(): Promise { + const config = new Config(this.connection); + return config.get(ConfigKey.CURRENT_ID); + } + + protected async getOrThrow(id: string): Promise { const gateway = (await this.get(id)) as InternetGateway; if (!gateway) { throw new InternetGatewayError(`Internet gateway does not exist (id: ${id})`); } return gateway; } + + protected async create(): Promise { + const { id, privateKey, publicKey } = + await this.keyStores.privateKeyStore.generateIdentityKeyPair(); + + const gatewayCertificate = await issueGatewayCertificate({ + issuerPrivateKey: privateKey, + subjectPublicKey: publicKey, + validityEndDate: addDays(new Date(), CERTIFICATE_TTL_DAYS), + }); + await this.keyStores.certificateStore.save(new CertificationPath(gatewayCertificate, []), id); + + return id; + } } diff --git a/src/services/poweb/PowebRouteOptions.ts b/src/services/poweb/PowebRouteOptions.ts new file mode 100644 index 000000000..4fa9ba327 --- /dev/null +++ b/src/services/poweb/PowebRouteOptions.ts @@ -0,0 +1,3 @@ +export interface PowebRouteOptions { + readonly internetAddress: string; +} diff --git a/src/services/poweb/RouteOptions.ts b/src/services/poweb/RouteOptions.ts deleted file mode 100644 index 34d16f845..000000000 --- a/src/services/poweb/RouteOptions.ts +++ /dev/null @@ -1 +0,0 @@ -export default interface RouteOptions {} diff --git a/src/services/poweb/connectionParams.spec.ts b/src/services/poweb/connectionParams.spec.ts new file mode 100644 index 000000000..b56db67f6 --- /dev/null +++ b/src/services/poweb/connectionParams.spec.ts @@ -0,0 +1,35 @@ +import { NodeConnectionParams } from '@relaycorp/relaynet-core'; + +import { testDisallowedMethods } from '../../testUtils/fastify'; +import { makePoWebTestServer } from './_test_utils'; +import { CONTENT_TYPES } from './contentTypes'; +import { InternetGatewayManager } from '../../node/InternetGatewayManager'; +import { GATEWAY_INTERNET_ADDRESS } from '../../testUtils/awala'; + +jest.mock('../../utilities/exitHandling'); + +const ENDPOINT_URL = '/connection-params.der'; + +describe('connectionParams', () => { + const getFixtures = makePoWebTestServer(); + + testDisallowedMethods(['HEAD', 'GET'], ENDPOINT_URL, async () => getFixtures().server); + + test('Should respond with connection parameters', async () => { + const { server, dbConnection } = getFixtures(); + const gatewayManager = await InternetGatewayManager.init(dbConnection); + const gateway = await gatewayManager.getCurrent(); + await gateway.makeInitialSessionKeyIfMissing(); + + const response = await server.inject({ method: 'GET', url: ENDPOINT_URL }); + + expect(response).toHaveProperty('statusCode', 200); + expect(response).toHaveProperty('headers.content-type', CONTENT_TYPES.CONNECTION_PARAMS); + const expectedParameters = new NodeConnectionParams( + GATEWAY_INTERNET_ADDRESS, + gateway.identityKeyPair.publicKey, + (await gateway.keyStores.privateKeyStore.retrieveUnboundSessionPublicKey(gateway.id))!, + ); + expect(response.rawPayload).toMatchObject(Buffer.from(await expectedParameters.serialize())); + }); +}); diff --git a/src/services/poweb/connectionParams.ts b/src/services/poweb/connectionParams.ts new file mode 100644 index 000000000..7d2f44a6f --- /dev/null +++ b/src/services/poweb/connectionParams.ts @@ -0,0 +1,32 @@ +import { NodeConnectionParams } from '@relaycorp/relaynet-core'; +import { FastifyInstance } from 'fastify'; + +import { registerDisallowedMethods } from '../../utilities/fastify/server'; +import { InternetGatewayManager } from '../../node/InternetGatewayManager'; +import type { PowebRouteOptions } from './PowebRouteOptions'; +import { CONTENT_TYPES } from './contentTypes'; + +const ENDPOINT_URL = '/connection-params.der'; + +export default async function registerRoutes( + fastify: FastifyInstance, + options: PowebRouteOptions, +): Promise { + registerDisallowedMethods(['HEAD', 'GET'], ENDPOINT_URL, fastify); + + fastify.route({ + method: ['HEAD', 'GET'], + url: ENDPOINT_URL, + async handler(_req, reply): Promise { + const gatewayManager = await InternetGatewayManager.init(fastify.mongoose); + const gateway = await gatewayManager.getCurrent(); + const params = new NodeConnectionParams( + options.internetAddress, + gateway.identityKeyPair.publicKey, + (await gateway.keyStores.privateKeyStore.retrieveUnboundSessionPublicKey(gateway.id))!, + ); + const paramsSerialised = await params.serialize(); + reply.type(CONTENT_TYPES.CONNECTION_PARAMS).send(Buffer.from(paramsSerialised)); + }, + }); +} diff --git a/src/services/poweb/contentTypes.ts b/src/services/poweb/contentTypes.ts index f56f43e8a..d29c0612d 100644 --- a/src/services/poweb/contentTypes.ts +++ b/src/services/poweb/contentTypes.ts @@ -5,4 +5,5 @@ export const CONTENT_TYPES = { REQUEST: 'application/vnd.awala.node-registration.request', }, PARCEL: 'application/vnd.awala.parcel', + CONNECTION_PARAMS: 'application/vnd.etsi.tsl.der', }; diff --git a/src/services/poweb/parcelDelivery.ts b/src/services/poweb/parcelDelivery.ts index a07e5259f..5873e7d2b 100644 --- a/src/services/poweb/parcelDelivery.ts +++ b/src/services/poweb/parcelDelivery.ts @@ -12,16 +12,12 @@ import { ParcelStore } from '../../parcelStore'; import { retrieveOwnCertificates } from '../../pki'; import { registerDisallowedMethods } from '../../utilities/fastify/server'; import { CONTENT_TYPES } from './contentTypes'; -import RouteOptions from './RouteOptions'; import { RedisPubSubClient } from '../../backingServices/RedisPubSubClient'; import { QueueEmitter } from '../../utilities/backgroundQueue/QueueEmitter'; const ENDPOINT_URL = '/v1/parcels'; -export default async function registerRoutes( - fastify: FastifyInstance, - _options: RouteOptions, -): Promise { +export default async function registerRoutes(fastify: FastifyInstance): Promise { const parcelStore = ParcelStore.initFromEnv(); registerDisallowedMethods(['POST'], ENDPOINT_URL, fastify); diff --git a/src/services/poweb/registration.ts b/src/services/poweb/registration.ts index 360e2b800..243ef9fe9 100644 --- a/src/services/poweb/registration.ts +++ b/src/services/poweb/registration.ts @@ -7,7 +7,6 @@ import { } from '@relaycorp/relaynet-core'; import { MongoCertificateStore } from '@relaycorp/awala-keystore-mongodb'; import bufferToArray from 'buffer-to-arraybuffer'; -import { get as getEnvVar } from 'env-var'; import { FastifyInstance, FastifyReply } from 'fastify'; import { initPrivateKeyStore } from '../../backingServices/keystore'; @@ -16,10 +15,14 @@ import { Config, ConfigKey } from '../../utilities/config'; import { sha256 } from '../../utilities/crypto'; import { registerDisallowedMethods } from '../../utilities/fastify/server'; import { CONTENT_TYPES } from './contentTypes'; +import { PowebRouteOptions } from './PowebRouteOptions'; const ENDPOINT_URL = '/v1/nodes'; -export default async function registerRoutes(fastify: FastifyInstance): Promise { +export default async function registerRoutes( + fastify: FastifyInstance, + options: PowebRouteOptions, +): Promise { registerDisallowedMethods(['POST'], ENDPOINT_URL, fastify); fastify.addContentTypeParser( @@ -30,8 +33,6 @@ export default async function registerRoutes(fastify: FastifyInstance): Promise< const privateKeyStore = initPrivateKeyStore((fastify as any).mongoose); - const internetAddress = getEnvVar('INTERNET_ADDRESS').required().asString(); - fastify.route<{ readonly Body: Buffer }>({ method: ['POST'], url: ENDPOINT_URL, @@ -102,7 +103,7 @@ export default async function registerRoutes(fastify: FastifyInstance): Promise< const registration = new PrivateNodeRegistration( privateGatewayCertificate, internetGatewayCertPath!.leafCertificate, - internetAddress, + options.internetAddress, sessionKeyPair.sessionKey, ); return reply diff --git a/src/services/poweb/server.spec.ts b/src/services/poweb/server.spec.ts index b7f1be335..df86e2fbe 100644 --- a/src/services/poweb/server.spec.ts +++ b/src/services/poweb/server.spec.ts @@ -1,3 +1,4 @@ +import envVar from 'env-var'; import pino from 'pino'; import { mockSpy } from '../../testUtils/jest'; @@ -7,7 +8,7 @@ import { makeServer } from './server'; jest.mock('../../utilities/exitHandling'); -makePoWebTestServer(); +const getFixtures = makePoWebTestServer(); const mockFastifyInstance = { close: jest.fn() }; const mockConfigureFastify = mockSpy( @@ -30,6 +31,13 @@ describe('makeServer', () => { expect(mockConfigureFastify).toBeCalledWith(expect.anything(), expect.anything(), logger); }); + test('Env var INTERNET_ADDRESS should be defined', async () => { + const { envVarMocker } = getFixtures(); + envVarMocker({}); + + await expect(makeServer()).rejects.toThrowWithMessage(envVar.EnvVarError, /INTERNET_ADDRESS/); + }); + test('Fastify instance should be returned', async () => { await expect(makeServer()).resolves.toEqual(mockFastifyInstance); }); diff --git a/src/services/poweb/server.ts b/src/services/poweb/server.ts index 0a3853545..07d6590d4 100644 --- a/src/services/poweb/server.ts +++ b/src/services/poweb/server.ts @@ -1,16 +1,19 @@ +import { get as getEnvVar } from 'env-var'; import { FastifyInstance, FastifyPluginCallback } from 'fastify'; import type { BaseLogger } from 'pino'; import { configureFastify } from '../../utilities/fastify/server'; +import type { PowebRouteOptions } from './PowebRouteOptions'; import healthcheck from './healthcheck'; +import connectionParams from './connectionParams'; import parcelCollection from './parcelCollection'; import parcelDelivery from './parcelDelivery'; import preRegistrationRoutes from './preRegistration'; import registrationRoutes from './registration'; -import RouteOptions from './RouteOptions'; -const ROUTES: ReadonlyArray> = [ +const ROUTES: ReadonlyArray> = [ healthcheck, + connectionParams, parcelCollection, parcelDelivery, preRegistrationRoutes, @@ -23,5 +26,6 @@ const ROUTES: ReadonlyArray> = [ * This function doesn't call .listen() so we can use .inject() for testing purposes. */ export async function makeServer(logger?: BaseLogger): Promise { - return configureFastify(ROUTES, {}, logger); + const internetAddress = getEnvVar('INTERNET_ADDRESS').required().asString(); + return configureFastify(ROUTES, { internetAddress }, logger); } diff --git a/src/services/queue/sinks/crcIncoming.spec.ts b/src/services/queue/sinks/crcIncoming.spec.ts index 913296326..89cfa2749 100644 --- a/src/services/queue/sinks/crcIncoming.spec.ts +++ b/src/services/queue/sinks/crcIncoming.spec.ts @@ -68,8 +68,6 @@ beforeAll(async () => { internetGatewayId = await certificateChain.internetGatewayCert.calculateSubjectId(); - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); const sessionKeyPair = await SessionKeyPair.generate(); internetGatewaySessionKey = sessionKeyPair.sessionKey; internetGatewaySessionPrivateKey = sessionKeyPair.privateKey;