From 2b4382d01360b5618cbfe14502996dab47d299b6 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 8 Oct 2024 18:08:34 +1100 Subject: [PATCH] handshakes --- .../migration.sql | 19 +++++++ prisma/schema.prisma | 11 +++-- server/api/v1/client/callback/index.get.ts | 2 +- server/api/v1/client/callback/index.post.ts | 2 +- server/api/v1/client/handshake.post.ts | 49 +++++++++++++++++-- server/internal/clients/README.md | 2 +- server/internal/clients/ca.ts | 22 +++++++++ server/internal/clients/handler.ts | 36 +++++++++++++- 8 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20241008062519_remove_shared_token_and_add_last_connected/migration.sql diff --git a/prisma/migrations/20241008062519_remove_shared_token_and_add_last_connected/migration.sql b/prisma/migrations/20241008062519_remove_shared_token_and_add_last_connected/migration.sql new file mode 100644 index 0000000..52d9c03 --- /dev/null +++ b/prisma/migrations/20241008062519_remove_shared_token_and_add_last_connected/migration.sql @@ -0,0 +1,19 @@ +/* + Warnings: + + - The primary key for the `Client` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `sharedToken` on the `Client` table. All the data in the column will be lost. + - The required column `id` was added to the `Client` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + - Added the required column `lastConnected` to the `Client` table without a default value. This is not possible if the table is not empty. + - Added the required column `name` to the `Client` table without a default value. This is not possible if the table is not empty. + - Added the required column `platform` to the `Client` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Client" DROP CONSTRAINT "Client_pkey", +DROP COLUMN "sharedToken", +ADD COLUMN "id" TEXT NOT NULL, +ADD COLUMN "lastConnected" TIMESTAMP(3) NOT NULL, +ADD COLUMN "name" TEXT NOT NULL, +ADD COLUMN "platform" TEXT NOT NULL, +ADD CONSTRAINT "Client_pkey" PRIMARY KEY ("id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 946e294..26dbd2b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,15 +43,16 @@ enum ClientCapabilities { // References a device model Client { - sharedToken String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id]) + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) endpoint String capabilities ClientCapabilities[] - name String - platform String + name String + platform String + lastConnected DateTime } enum MetadataSource { diff --git a/server/api/v1/client/callback/index.get.ts b/server/api/v1/client/callback/index.get.ts index 327f281..fd3a617 100644 --- a/server/api/v1/client/callback/index.get.ts +++ b/server/api/v1/client/callback/index.get.ts @@ -12,7 +12,7 @@ export default defineEventHandler(async (h3) => { statusMessage: "Provide client ID in request params as 'id'", }); - const data = await clientHandler.fetchInitiateClientMetadata( + const data = await clientHandler.fetchClientMetadata( providedClientId ); if (!data) diff --git a/server/api/v1/client/callback/index.post.ts b/server/api/v1/client/callback/index.post.ts index 502d4a7..3673792 100644 --- a/server/api/v1/client/callback/index.post.ts +++ b/server/api/v1/client/callback/index.post.ts @@ -7,7 +7,7 @@ export default defineEventHandler(async (h3) => { const body = await readBody(h3); const clientId = await body.id; - const data = await clientHandler.fetchInitiateClientMetadata(clientId); + const data = await clientHandler.fetchClientMetadata(clientId); if (!data) throw createError({ statusCode: 400, diff --git a/server/api/v1/client/handshake.post.ts b/server/api/v1/client/handshake.post.ts index 7b67c41..96b06d9 100644 --- a/server/api/v1/client/handshake.post.ts +++ b/server/api/v1/client/handshake.post.ts @@ -1,3 +1,46 @@ -export default defineEventHandler((h3) => { - -}) \ No newline at end of file +import clientHandler from "~/server/internal/clients/handler"; +import { useGlobalCertificateAuthority } from "~/server/plugins/ca"; + +export default defineEventHandler(async (h3) => { + const body = await readBody(h3); + const clientId = body.clientId; + const token = body.token; + if (!clientId || !token) + throw createError({ + statusCode: 400, + statusMessage: "Missing token or client ID from body", + }); + + const metadata = await clientHandler.fetchClient(clientId); + if (!metadata) + throw createError({ + statusCode: 403, + statusMessage: "Invalid client ID", + }); + if (!metadata.authToken || !metadata.userId) + throw createError({ + statusCode: 400, + statusMessage: "Un-authorized client ID", + }); + if (metadata.authToken !== token) + throw createError({ + statusCode: 403, + statusMessage: "Invalid token", + }); + + const ca = useGlobalCertificateAuthority(); + const bundle = await ca.generateClientCertificate( + clientId, + metadata.data.name + ); + + const client = await clientHandler.finialiseClient(clientId); + await ca.storeClientCertificate(clientId, bundle); + + return { + private: bundle.priv, + public: bundle.pub, + certificate: bundle.cert, + id: client.id, + }; +}); diff --git a/server/internal/clients/README.md b/server/internal/clients/README.md index 07dcc49..63f9770 100644 --- a/server/internal/clients/README.md +++ b/server/internal/clients/README.md @@ -17,7 +17,7 @@ Client makes request: `POST /api/v1/client/handshake` with the token recieved in The server uses it's CA to generate a public-private key pair, the CN of the client ID. It then sends that pair, plus the CA's public key, to the client, which stores it all. -The certificate lasts for a year, and is rotated when it has 3 months or less left on it's expiry. +*The certificate lasts for a year, and is rotated when it has 3 months or less left on it's expiry.* ## 4.a Client requests one-time device endpoint The client generates a nonce and signs it with their private key. This is then attached to any device-related request. diff --git a/server/internal/clients/ca.ts b/server/internal/clients/ca.ts index 15b9ab9..85ba57f 100644 --- a/server/internal/clients/ca.ts +++ b/server/internal/clients/ca.ts @@ -31,4 +31,26 @@ export class CertificateAuthority { } return new CertificateAuthority(store, root); } + + async generateClientCertificate(clientId: string, clientName: string) { + const caCertificate = await this.certificateStore.fetch("ca"); + if (!caCertificate) + throw new Error("Certificate authority not initialised"); + const [priv, pub, cert] = droplet.generateClientCertificate( + clientId, + clientName, + caCertificate.cert, + caCertificate.priv + ); + const certBundle: CertificateBundle = { + priv, + pub, + cert, + }; + return certBundle; + } + + async storeClientCertificate(clientId: string, bundle: CertificateBundle) { + await this.certificateStore.store(`client:${clientId}`, bundle); + } } diff --git a/server/internal/clients/handler.ts b/server/internal/clients/handler.ts index 429ff26..aa64961 100644 --- a/server/internal/clients/handler.ts +++ b/server/internal/clients/handler.ts @@ -1,4 +1,6 @@ import { v4 as uuidv4 } from "uuid"; +import { CertificateBundle } from "./ca"; +import prisma from "../db/database"; export interface ClientMetadata { name: string; @@ -29,10 +31,14 @@ export class ClientHandler { return clientId; } - async fetchInitiateClientMetadata(clientId: string) { + async fetchClientMetadata(clientId: string) { + return (await this.fetchClient(clientId))?.data; + } + + async fetchClient(clientId: string) { const entry = this.temporaryClientTable[clientId]; if (!entry) return undefined; - return entry.data; + return entry; } async attachUserId(clientId: string, userId: string) { @@ -50,6 +56,32 @@ export class ClientHandler { return token; } + + async fetchClientMetadataByToken(token: string) { + return Object.entries(this.temporaryClientTable) + .map((e) => Object.assign(e[1], { id: e[0] })) + .find((e) => e.authToken === token); + } + + async finialiseClient(id: string) { + const metadata = this.temporaryClientTable[id]; + if (!metadata) throw new Error("Invalid client ID"); + if (!metadata.userId) throw new Error("Un-authorized client ID"); + + return await prisma.client.create({ + data: { + id: id, + userId: metadata.userId, + + endpoint: "", + capabilities: [], + + name: metadata.data.name, + platform: metadata.data.platform, + lastConnected: new Date(), + }, + }); + } } export const clientHandler = new ClientHandler();