diff --git a/.env.example b/.env.example index 1e98a941..239f191a 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,5 @@ GITHUB_CLIENT_ID=GitHub App client ID GITHUB_CLIENT_SECRET=GitHub App client secret GITHUB_APP_ID=123456 GITHUB_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key - see README.md for more info +ENCRYPTION_PUBLIC_KEY_BASE_64=base 64 encoded version of the public key +ENCRYPTION_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key diff --git a/__test__/encrypt/EncryptionService.test.ts b/__test__/encrypt/EncryptionService.test.ts new file mode 100644 index 00000000..3b378088 --- /dev/null +++ b/__test__/encrypt/EncryptionService.test.ts @@ -0,0 +1,80 @@ +import RsaEncryptionService from '../../src/features/encrypt/EncryptionService' + +const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1k4JT719AUz/wuXb2rt +8933okfM2Iynmc6akSsZWEsW19byzO0UHp8b79xvsmNQKM1wBEBnXb5t+uLjJJZe +rqCiTB7fBL64tExSKIDIRAlMnQtMfHs/rMgR+o/N2Yo2KimQw9G84goCEbBF2kbw +5/MQfe43HeEoVWbNfgmRyP8VudO1UtVr07dGoUEWvFjudtd/h5H9THVdEpp2vH2Z +pSGypn8hRAbOzhIM4ExLOH4ZHb8gPQGiHRGUYXk3Cy95RSf/SpEnRi0p4/63Nx5M +JNXGM2Jk0RgGcYZcwJvLanT5Xdb9LM/IsDxLKXN+utDUgkzddvJbBC12aLaKaJA5 +LwIDAQAB +-----END PUBLIC KEY-----` + +const privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7WTglPvX0BTP/ +C5dvau3z3feiR8zYjKeZzpqRKxlYSxbX1vLM7RQenxvv3G+yY1AozXAEQGddvm36 +4uMkll6uoKJMHt8Evri0TFIogMhECUydC0x8ez+syBH6j83ZijYqKZDD0bziCgIR +sEXaRvDn8xB97jcd4ShVZs1+CZHI/xW507VS1WvTt0ahQRa8WO5213+Hkf1MdV0S +mna8fZmlIbKmfyFEBs7OEgzgTEs4fhkdvyA9AaIdEZRheTcLL3lFJ/9KkSdGLSnj +/rc3Hkwk1cYzYmTRGAZxhlzAm8tqdPld1v0sz8iwPEspc3660NSCTN128lsELXZo +topokDkvAgMBAAECggEAAWQMl0laQ8OZfiqWY72Ry0oYPgFvFO1PpkQHObm3+S+d +8Q81IgXNLNtWKSA4VpXYQ4zcJUpADmg1ZdxAfszUB4kcshHdpz4Z9Y849i6KW4l4 +qZsP3hbQWtTbgYWG71+M+y2sqJu0hgCkLPmm31AsJDG6zPtEKokKbYH7jWV0Xo5z +0g6IUqepc1ElNzsJAU10hgX5UZUPxvzbWHxhBhFzC51GKpfx/W5ZOQtB+W8+nlmC +OSVlZ9pfr6qxOZbSLWESU1xplywPTPLoYs/38oN5OHIJvB2j8kl+JfcR7v2ezLeV +fx1Z+x9ME0at7AbGCfhjIfJtftPsoCR60nzN3wWoAQKBgQDfOmfzLaWhVkvt49Hn +zeLdLI8pwqWXVYozsPMRlExwuIT1KeNolPzWWKx6dG38UzY4XWSvq+w3WAcQ7m6E +qiRWoRPL3qlWu3pDJYr/EfR2haPMQMwbJM/hg+nC0bhUSVqBEjOZgaQUHStIyugb +SWQFI3jE9fgj71DtbiVNrb1vAQKBgQDW2ljkotAjF81vI+EoN9QmuPYnejo42nK9 +jlSEU4hrDQMLiqxc5yJidQh75vZRfaO9rdUqHxoXK0DEU3Jk16Kb0n4nkM+xqKoc +yHTtAgUyflpenbrr4pRZf783XgI0bn/FhoMFQtAvSblru3NfEFQUtKIY82+Xa5H5 +g+cezSDYLwKBgBeViB39GJ6vC16azzZ6XhmX95gl5HDUrMFBVKzqyhiupf1w64HF +G+FZhP97BZO/Bt91nomg1FgUiMqVJkAF6cjtQ7YqVCHBtO0bLlA8iWNsQx31Spsj +jIL6+NuIZL0i8tjoH2N8euVVH5mVNmiLnHGeicflZM4HHrm3BWHrlTQBAoGBALeW +W98CQFe8Pw542ixDiESOR8fz6UwrXWAb/pwTxL20oKV8GUxJNFhtKJK3CEMZ2JB7 +uWoEqYairvUTWOxSVeBQPPwSAWcNeE6f+0mKMGa1EQNIRDDLq3fOcNYevkOPKB7g +kZQtQzclCAvGYQ8aJL6MmvY3DWOVx2YuD4+COE6BAoGAEGdChfJW5QGXaXEO/PnA +PbQCCzcqbs+0O6LVR1w68H0WQww94tZjfWPqn9kvwjzLd22ZMmdiBJ3bEbDeCjmG +Ybt48kS7y9n22CDgL7JkatszYpybvBSrDQL7ms7x2kKPkTMb7C5zpIIzdtvwH+Jf +6K3kQbqfFCM7VmyR7AmoyOk= +-----END PRIVATE KEY-----` + +const encryptionService = new RsaEncryptionService({ publicKey, privateKey }) + +describe('RsaEncryptionService', () => { + it('should encrypt and decrypt data correctly', () => { + const data = 'Hello, World!' + const encryptedData = encryptionService.encrypt(data) + const decryptedData = encryptionService.decrypt(encryptedData) + + expect(decryptedData).toBe(data) + }) + + it('should throw an error when decrypting with incorrect data', () => { + const incorrectData = 'invalidEncryptedData' + + expect(() => { + encryptionService.decrypt(incorrectData) + }).toThrow() + }) + + it('should throw an error when encrypting with an invalid public key', () => { + const invalidPublicKey = 'invalidPublicKey' + const invalidEncryptionService = new RsaEncryptionService({ publicKey: invalidPublicKey, privateKey }) + + expect(() => { + invalidEncryptionService.encrypt('test') + }).toThrow() + }) + + it('should throw an error when decrypting with an invalid private key', () => { + const data = 'Hello, World!' + const encryptedData = encryptionService.encrypt(data) + const invalidPrivateKey = 'invalidPrivateKey' + const invalidEncryptionService = new RsaEncryptionService({ publicKey, privateKey: invalidPrivateKey }) + + expect(() => { + invalidEncryptionService.decrypt(encryptedData) + }).toThrow() + }) +}) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index ac01eaa1..6dcbda37 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -1,4 +1,29 @@ import { GitHubProjectDataSource } from "@/features/projects/data" +import RemoteConfig from "@/features/projects/domain/RemoteConfig" + +/** + * Simple encryption service for testing. Does nothing. + */ +const noopEncryptionService = { + encrypt: function (data: string): string { + return data + }, + decrypt: function (encryptedDataBase64: string): string { + return encryptedDataBase64 + } +} + +/** + * Simple encoder for testing + */ +const base64RemoteConfigEncoder = { + encode: function (remoteConfig: RemoteConfig): string { + return Buffer.from(JSON.stringify(remoteConfig)).toString("base64") + }, + decode: function (encodedString: string): RemoteConfig { + return JSON.parse(Buffer.from(encodedString, "base64").toString()) + } +} test("It loads repositories from data source", async () => { let didLoadRepositories = false @@ -9,7 +34,9 @@ test("It loads repositories from data source", async () => { didLoadRepositories = true return [] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) await sut.getProjects() expect(didLoadRepositories).toBeTruthy() @@ -43,7 +70,9 @@ test("It maps projects including branches and tags", async () => { }] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects).toEqual([{ @@ -107,7 +136,9 @@ test("It removes suffix from project name", async () => { }] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].id).toEqual("acme-foo") @@ -147,7 +178,9 @@ test("It supports multiple OpenAPI specifications on a branch", async () => { }] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects).toEqual([{ @@ -209,7 +242,9 @@ test("It filters away projects with no versions", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects.length).toEqual(0) @@ -243,7 +278,9 @@ test("It filters away branches with no specifications", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].versions.length).toEqual(1) @@ -283,7 +320,9 @@ test("It filters away tags with no specifications", async () => { }] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].versions.length).toEqual(2) @@ -314,7 +353,9 @@ test("It reads image from configuration file with .yml extension", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") @@ -345,7 +386,9 @@ test("It reads display name from configuration file with .yml extension", async tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].id).toEqual("acme-foo") @@ -378,7 +421,9 @@ test("It reads image from configuration file with .yaml extension", async () => tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") @@ -409,7 +454,9 @@ test("It reads display name from configuration file with .yaml extension", async tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].id).toEqual("acme-foo") @@ -478,7 +525,9 @@ test("It sorts projects alphabetically", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].name).toEqual("anne") @@ -529,7 +578,9 @@ test("It sorts versions alphabetically", async () => { }] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].versions[0].name).toEqual("1.0") @@ -593,7 +644,9 @@ test("It prioritizes main, master, develop, and development branch names when so }] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].versions[0].name).toEqual("main") @@ -641,7 +694,9 @@ test("It identifies the default branch in returned versions", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() const defaultVersionNames = projects[0] @@ -682,7 +737,9 @@ test("It adds remote versions from the project configuration", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].versions).toEqual([{ @@ -692,11 +749,11 @@ test("It adds remote versions from the project configuration", async () => { specifications: [{ id: "huey", name: "Huey", - url: `/api/proxy?url=${encodeURIComponent("https://example.com/huey.yml")}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}` }, { id: "dewey", name: "Dewey", - url: `/api/proxy?url=${encodeURIComponent("https://example.com/dewey.yml")}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}` }] }, { id: "bobby", @@ -705,7 +762,7 @@ test("It adds remote versions from the project configuration", async () => { specifications: [{ id: "louie", name: "Louie", - url: `/api/proxy?url=${encodeURIComponent("https://example.com/louie.yml")}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}` }] }]) }) @@ -745,7 +802,9 @@ test("It modifies ID of remote version if the ID already exists", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].versions).toEqual([{ @@ -766,7 +825,7 @@ test("It modifies ID of remote version if the ID already exists", async () => { specifications: [{ id: "baz", name: "Baz", - url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}` }] }, { id: "bar2", @@ -775,7 +834,7 @@ test("It modifies ID of remote version if the ID already exists", async () => { specifications: [{ id: "hello", name: "Hello", - url: `/api/proxy?url=${encodeURIComponent("https://example.com/hello.yml")}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}` }] }]) }) @@ -806,7 +865,9 @@ test("It lets users specify the ID of a remote version", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].versions).toEqual([{ @@ -816,7 +877,7 @@ test("It lets users specify the ID of a remote version", async () => { specifications: [{ id: "baz", name: "Baz", - url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}` }] }]) }) @@ -847,7 +908,9 @@ test("It lets users specify the ID of a remote specification", async () => { tags: [] }] } - } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() expect(projects[0].versions).toEqual([{ @@ -857,7 +920,7 @@ test("It lets users specify the ID of a remote specification", async () => { specifications: [{ id: "some-spec", name: "Baz", - url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}` }] }]) }) diff --git a/__test__/projects/RemoteConfigEncoder.test.ts b/__test__/projects/RemoteConfigEncoder.test.ts new file mode 100644 index 00000000..924f5633 --- /dev/null +++ b/__test__/projects/RemoteConfigEncoder.test.ts @@ -0,0 +1,38 @@ +import RemoteConfigEncoder from "@/features/projects/domain/RemoteConfigEncoder" +import { IEncryptionService } from "@/features/encrypt/EncryptionService" +import RemoteConfig from "@/features/projects/domain/RemoteConfig" +import { ZodError } from "zod" + +describe('RemoteConfigEncoder', () => { + const encryptionService: IEncryptionService = { + encrypt: (data: string) => `encrypted-${data}`, + decrypt: (data: string) => data.replace('encrypted-', '') + } + + const encoder = new RemoteConfigEncoder(encryptionService) + + it('should encode a remote config by first encrypting and then encoding with base64', () => { + const remoteConfig: RemoteConfig = { url: 'https://example.com/spec.yaml' } + const encoded = encoder.encode(remoteConfig) + const expectedEncoded = Buffer.from('encrypted-{"url":"https://example.com/spec.yaml"}').toString('base64') + expect(encoded).toEqual(expectedEncoded) + }) + + it('should decode an encoded string', () => { + const encodedString = Buffer.from('encrypted-{"url":"https://example.com/spec.yaml"}').toString('base64') + const decoded = encoder.decode(encodedString) + const expectedDecoded: RemoteConfig = { url: 'https://example.com/spec.yaml' } + expect(decoded).toEqual(expectedDecoded) + }) + + it('should throw an error if the decrypted string is not valid JSON', () => { + const invalidJson = Buffer.from('encrypted-invalid-json').toString('base64') + expect(() => encoder.decode(invalidJson)).toThrow(/Unexpected token/) + }) + + it('should throw an error if the remote config is not valid', () => { + const remoteConfig: RemoteConfig = { url: '' } + const encoded = encoder.encode(remoteConfig) + expect(() => encoder.decode(encoded)).toThrow(ZodError) + }) +}) diff --git a/src/app/(authed)/(home)/encrypt/layout.tsx b/src/app/(authed)/(home)/encrypt/layout.tsx new file mode 100644 index 00000000..d4114d0c --- /dev/null +++ b/src/app/(authed)/(home)/encrypt/layout.tsx @@ -0,0 +1,20 @@ +import { Box, Stack } from "@mui/material" +import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader" + +export default function Page({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ) +} \ No newline at end of file diff --git a/src/app/(authed)/(home)/encrypt/page.tsx b/src/app/(authed)/(home)/encrypt/page.tsx new file mode 100644 index 00000000..5e6c28d4 --- /dev/null +++ b/src/app/(authed)/(home)/encrypt/page.tsx @@ -0,0 +1,44 @@ +import { Box, Typography } from '@mui/material' +import MessageLinkFooter from "@/common/ui/MessageLinkFooter" +import { EncryptionForm } from "@/features/encrypt/view/EncryptionForm" +import { env } from '@/common' + +const HELP_URL = env.getOrThrow("FRAMNA_DOCS_HELP_URL") +const SITE_NAME = env.getOrThrow("FRAMNA_DOCS_TITLE") + +export default async function EncryptPage() { + const possessiveName = SITE_NAME + (SITE_NAME.endsWith('s') ? "'" : "'s") + return ( + + + + Encrypt secrets + + + Use the form below to encrypt a secret using {possessiveName} public key. +

+ Authentication in remote specifications must be encrypted using {possessiveName} public key + before it is stored in a repository on GitHub. +
+ + {HELP_URL && + + } +
+
+ ) +} diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts deleted file mode 100644 index 256ee955..00000000 --- a/src/app/api/proxy/route.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { NextRequest, NextResponse } from "next/server" -import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" -import { session } from "@/composition" -import { parse as parseYaml } from "yaml" - -const ErrorName = { - MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError", - TIMEOUT: "TimeoutError", - NOT_JSON_OR_YAML: "NotJsonOrYamlError", -} - -export async function GET(req: NextRequest) { - const isAuthenticated = await session.getIsAuthenticated() - if (!isAuthenticated) { - return makeUnauthenticatedAPIErrorResponse() - } - const rawURL = req.nextUrl.searchParams.get("url") - if (!rawURL) { - return makeAPIErrorResponse(400, "Missing \"url\" query parameter.") - } - let url: URL - try { - url = new URL(rawURL) - } catch { - return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.") - } - try { - const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES")) - const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS")) - const maxBytes = maxMegabytes * 1024 * 1024 - const fileText = await downloadFile({ url, maxBytes, timeoutInSeconds }) - checkIfJsonOrYaml(fileText) - return new NextResponse(fileText, { status: 200, headers: { "Content-Type": "text/plain" } }) - } catch (error) { - if (error instanceof Error == false) { - return makeAPIErrorResponse(500, "An unknown error occurred.") - } - if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) { - return makeAPIErrorResponse(413, "The operation was aborted.") - } else if (error.name === ErrorName.TIMEOUT) { - return makeAPIErrorResponse(408, "The operation timed out.") - } else if (error.name === ErrorName.NOT_JSON_OR_YAML) { - return makeAPIErrorResponse(400, "Url does not point to a JSON or YAML file.") - } else { - return makeAPIErrorResponse(500, error.message) - } - } -} - -async function downloadFile(params: { - url: URL, - maxBytes: number, - timeoutInSeconds: number -}): Promise { - const { url, maxBytes, timeoutInSeconds } = params - const abortController = new AbortController() - const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000) - const headers: {[key: string]: string} = {} - // Extract basic auth from URL and construct an Authorization header instead. - if ((url.username && url.username.length > 0) || (url.password && url.password.length > 0)) { - const username = decodeURIComponent(url.username) - const password = decodeURIComponent(url.password) - headers["Authorization"] = "Basic " + btoa(`${username}:${password}`) - } - // Make sure basic auth is removed from URL. - const urlWithoutAuth = url - urlWithoutAuth.username = "" - urlWithoutAuth.password = "" - const response = await fetch(urlWithoutAuth, { - method: "GET", - headers, - signal: AbortSignal.any([abortController.signal, timeoutSignal]) - }) - if (!response.body) { - throw new Error("Response body unavailable") - } - let totalBytes = 0 - let didExceedMaxBytes = false - const reader = response.body.getReader() - const chunks: Uint8Array[] = [] - // eslint-disable-next-line no-constant-condition - while (true) { - // eslint-disable-next-line no-await-in-loop - const { done, value } = await reader.read() - if (done) { - break - } - totalBytes += value.length - chunks.push(value) - if (totalBytes >= maxBytes) { - didExceedMaxBytes = true - abortController.abort() - break - } - } - if (didExceedMaxBytes) { - const error = new Error("Maximum file size exceeded") - error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED - throw error - } - const blob = new Blob(chunks) - const arrayBuffer = await blob.arrayBuffer() - const decoder = new TextDecoder() - return decoder.decode(arrayBuffer) -} - -function checkIfJsonOrYaml(fileText: string) { - try { - parseYaml(fileText) // will also parse JSON as it is a subset of YAML - } catch { - const error = new Error("File is not JSON or YAML") - error.name = ErrorName.NOT_JSON_OR_YAML - throw error - } -} diff --git a/src/app/api/remotes/[encodedRemoteConfig]/route.ts b/src/app/api/remotes/[encodedRemoteConfig]/route.ts new file mode 100644 index 00000000..61950a87 --- /dev/null +++ b/src/app/api/remotes/[encodedRemoteConfig]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server" +import { remoteConfigEncoder, session } from "@/composition" +import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" +import { downloadFile, checkIfJsonOrYaml, ErrorName } from "@/common/utils/fileUtils"; + +interface RemoteSpecificationParams { + encodedRemoteConfig: string +} + +export async function GET(_req: NextRequest, { params }: { params: RemoteSpecificationParams }) { + const isAuthenticated = await session.getIsAuthenticated() + if (!isAuthenticated) { + return makeUnauthenticatedAPIErrorResponse() + } + + const remoteConfig = remoteConfigEncoder.decode(params.encodedRemoteConfig) + + let url: URL + try { + url = new URL(remoteConfig.url) + } catch { + return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.") + } + try { + const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES")) + const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS")) + const maxBytes = maxMegabytes * 1024 * 1024 + + const fileText = await downloadFile({ + url, + maxBytes, + timeoutInSeconds, + basicAuthUsername: remoteConfig.auth?.username, + basicAuthPassword: remoteConfig.auth?.password + }) + + checkIfJsonOrYaml(fileText) + + const fileName = url.pathname.split('/').pop() + + return new NextResponse(fileText, { + status: 200, + headers: { + "Content-Type": "text/plain", + "Content-Disposition": `attachment; filename="${fileName}"` // used for when downloading the file + } + }) + } catch (error) { + if (error instanceof Error == false) { + return makeAPIErrorResponse(500, "An unknown error occurred.") + } + if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) { + return makeAPIErrorResponse(413, "The operation was aborted.") + } else if (error.name === ErrorName.TIMEOUT) { + return makeAPIErrorResponse(408, "The operation timed out.") + } else if (error.name === ErrorName.NOT_JSON_OR_YAML) { + return makeAPIErrorResponse(400, "Url does not point to a JSON or YAML file.") + } else if (error.name === ErrorName.URL_MAY_NOT_INCLUDE_BASIC_AITH) { + return makeAPIErrorResponse(400, "Url may not include basic auth.") + } else { + return makeAPIErrorResponse(500, error.message) + } + } +} diff --git a/src/common/utils/fileUtils.ts b/src/common/utils/fileUtils.ts new file mode 100644 index 00000000..acc3b062 --- /dev/null +++ b/src/common/utils/fileUtils.ts @@ -0,0 +1,73 @@ +import { parse as parseYaml } from "yaml" + +export const ErrorName = { + MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError", + TIMEOUT: "TimeoutError", + NOT_JSON_OR_YAML: "NotJsonOrYamlError", + URL_MAY_NOT_INCLUDE_BASIC_AITH: "UrlMayNotIncludeBasicAuth" +} + +export async function downloadFile(params: { + url: URL; + maxBytes: number; + timeoutInSeconds: number; + basicAuthUsername?: string; + basicAuthPassword?: string; +}): Promise { + const { url, maxBytes, timeoutInSeconds, basicAuthUsername, basicAuthPassword } = params; + const abortController = new AbortController(); + const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000); + const headers: { [key: string]: string; } = {}; + if (basicAuthUsername && basicAuthPassword) { + headers["Authorization"] = "Basic " + btoa(`${basicAuthUsername}:${basicAuthPassword}`); + } + // Make sure basic auth is removed from URL. + if ((url.username && url.username.length > 0) || (url.password && url.password.length > 0)) { + const error = new Error("URL may not include basic auth"); + error.name = ErrorName.URL_MAY_NOT_INCLUDE_BASIC_AITH; + throw error; + } + const fetchSignal = AbortSignal.any([abortController.signal, timeoutSignal]) + const response = await fetch(url, { method: "GET", headers, signal: fetchSignal }) + if (!response.body) { + throw new Error("Response body unavailable"); + } + let totalBytes = 0; + let didExceedMaxBytes = false; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.length; + chunks.push(value); + if (totalBytes >= maxBytes) { + didExceedMaxBytes = true; + abortController.abort(); + break; + } + } + if (didExceedMaxBytes) { + const error = new Error("Maximum file size exceeded"); + error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED; + throw error; + } + const blob = new Blob(chunks); + const arrayBuffer = await blob.arrayBuffer(); + const decoder = new TextDecoder(); + return decoder.decode(arrayBuffer); +} + +export function checkIfJsonOrYaml(fileText: string) { + try { + parseYaml(fileText) // will also parse JSON as it is a subset of YAML + } catch { + const error = new Error("File is not JSON or YAML") + error.name = ErrorName.NOT_JSON_OR_YAML + throw error + } +} diff --git a/src/composition.ts b/src/composition.ts index 8514a16b..40a754bc 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -51,6 +51,8 @@ import { PullRequestCommenter } from "@/features/hooks/domain" import { RepoRestrictedGitHubClient } from "./common/github/RepoRestrictedGitHubClient" +import RsaEncryptionService from "./features/encrypt/EncryptionService" +import RemoteConfigEncoder from "./features/projects/domain/RemoteConfigEncoder" const gitHubAppCredentials = { appId: env.getOrThrow("GITHUB_APP_ID"), @@ -176,6 +178,13 @@ export const projectRepository = new ProjectRepository({ repository: projectUserDataRepository }) +export const encryptionService = new RsaEncryptionService({ + publicKey: Buffer.from(env.getOrThrow("ENCRYPTION_PUBLIC_KEY_BASE_64"), "base64").toString("utf-8"), + privateKey: Buffer.from(env.getOrThrow("ENCRYPTION_PRIVATE_KEY_BASE_64"), "base64").toString("utf-8") +}) + +export const remoteConfigEncoder = new RemoteConfigEncoder(encryptionService) + export const projectDataSource = new CachingProjectDataSource({ dataSource: new GitHubProjectDataSource({ repositoryDataSource: new FilteringGitHubRepositoryDataSource({ @@ -189,7 +198,9 @@ export const projectDataSource = new CachingProjectDataSource({ projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME") }) }), - repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX") + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), + encryptionService: encryptionService, + remoteConfigEncoder: remoteConfigEncoder }), repository: projectRepository }) @@ -219,4 +230,4 @@ export const gitHubHookHandler = new GitHubHookHandler({ }) }) }) -}) \ No newline at end of file +}) diff --git a/src/features/encrypt/EncryptionService.ts b/src/features/encrypt/EncryptionService.ts new file mode 100644 index 00000000..67155e5c --- /dev/null +++ b/src/features/encrypt/EncryptionService.ts @@ -0,0 +1,42 @@ +import { publicEncrypt, privateDecrypt, constants } from 'crypto'; + +export interface IEncryptionService { + encrypt(data: string): string; + decrypt(encryptedDataBase64: string): string; +} + +class RsaEncryptionService implements IEncryptionService { + private publicKey: string; + private privateKey: string; + + constructor({ publicKey, privateKey }: { publicKey: string; privateKey: string }) { + this.publicKey = publicKey; + this.privateKey = privateKey; + } + + encrypt(data: string): string { + const buffer = Buffer.from(data, 'utf-8'); + const encrypted = publicEncrypt( + { + key: this.publicKey, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256' + }, + buffer + ); + return encrypted.toString('base64'); + } + + decrypt(encryptedDataBase64: string): string { + return privateDecrypt( + { + key: this.privateKey, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256' + }, + Buffer.from(encryptedDataBase64, 'base64') + ).toString('utf-8') + } +} + +export default RsaEncryptionService; diff --git a/src/features/encrypt/view/EncryptionForm.tsx b/src/features/encrypt/view/EncryptionForm.tsx new file mode 100644 index 00000000..1c23f660 --- /dev/null +++ b/src/features/encrypt/view/EncryptionForm.tsx @@ -0,0 +1,101 @@ +'use client' + +import { useState } from 'react' +import { Box, Button, Snackbar, TextField, Tooltip, InputAdornment } from '@mui/material' +import { styled } from '@mui/material/styles' +import { encrypt } from "./encryptAction" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faClipboard } from "@fortawesome/free-regular-svg-icons" + +export const EncryptionForm = () => { + const [inputText, setInputText] = useState('') + const [encryptedText, setEncryptedText] = useState('') + const [openSnackbar, setOpenSnackbar] = useState(false) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + if (inputText.length > 0) { + const encrypted = await encrypt(inputText) + setEncryptedText(encrypted) + } else { + setEncryptedText("") + } + } + + const handleCopy = () => { + if (encryptedText.length > 0) { + navigator.clipboard.writeText(encryptedText) + setOpenSnackbar(true) + } + } + + const handleCloseSnackbar = () => { + setOpenSnackbar(false) + } + + const EncryptedValueTextField = styled(TextField)({ + '& .MuiInputBase-root': { + backgroundColor: '#F8F8F8' + } + }) + + return + setInputText(e.target.value)} + multiline + rows={8} + variant="outlined" + placeholder="Enter text to encrypt" + sx={{ width: "300px" }} + /> + + + + + + + + ) + } + }} + placeholder="Encrypted text appears here" + /> + + + + ; +} diff --git a/src/features/encrypt/view/encryptAction.ts b/src/features/encrypt/view/encryptAction.ts new file mode 100644 index 00000000..ed1161c1 --- /dev/null +++ b/src/features/encrypt/view/encryptAction.ts @@ -0,0 +1,7 @@ +'use server' + +import { encryptionService } from '@/composition' + +export async function encrypt(text: string): Promise { + return encryptionService.encrypt(text) +} diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index bc79fe25..df97c6bb 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,3 +1,4 @@ +import { IEncryptionService } from "@/features/encrypt/EncryptionService" import { Project, Version, @@ -9,17 +10,25 @@ import { GitHubRepository, GitHubRepositoryRef } from "../domain" +import RemoteConfig from "../domain/RemoteConfig" +import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" export default class GitHubProjectDataSource implements IProjectDataSource { private readonly repositoryDataSource: IGitHubRepositoryDataSource private readonly repositoryNameSuffix: string + private readonly encryptionService: IEncryptionService + private readonly remoteConfigEncoder: IRemoteConfigEncoder constructor(config: { repositoryDataSource: IGitHubRepositoryDataSource repositoryNameSuffix: string + encryptionService: IEncryptionService + remoteConfigEncoder: IRemoteConfigEncoder }) { this.repositoryDataSource = config.repositoryDataSource this.repositoryNameSuffix = config.repositoryNameSuffix + this.encryptionService = config.encryptionService + this.remoteConfigEncoder = config.remoteConfigEncoder } async getProjects(): Promise { @@ -167,11 +176,22 @@ export default class GitHubProjectDataSource implements IProjectDataSource { const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") const specifications = remoteVersion.specifications.map(e => { + const remoteConfig: RemoteConfig = { + url: e.url, + auth: e.auth ? { + type: e.auth.type, + username: this.encryptionService.decrypt(e.auth.encryptedUsername), + password: this.encryptionService.decrypt(e.auth.encryptedPassword) + } : undefined + }; + + const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig); + return { id: this.makeURLSafeID((e.id || e.name).toLowerCase()), name: e.name, - url: `/api/proxy?url=${encodeURIComponent(e.url)}` - } + url: `/api/remotes/${encodedRemoteConfig}` + }; }) versions.push({ id: versionId, diff --git a/src/features/projects/domain/IProjectConfig.ts b/src/features/projects/domain/IProjectConfig.ts index ef4fd74b..08e27f7c 100644 --- a/src/features/projects/domain/IProjectConfig.ts +++ b/src/features/projects/domain/IProjectConfig.ts @@ -3,7 +3,12 @@ import { z } from "zod" export const ProjectConfigRemoteSpecificationSchema = z.object({ id: z.coerce.string().optional(), name: z.coerce.string(), - url: z.string() + url: z.string(), + auth: z.object({ + type: z.string(), + encryptedUsername: z.string(), + encryptedPassword: z.string() + }).optional(), }) export const ProjectConfigRemoteVersionSchema = z.object({ diff --git a/src/features/projects/domain/RemoteConfig.ts b/src/features/projects/domain/RemoteConfig.ts new file mode 100644 index 00000000..5afb559b --- /dev/null +++ b/src/features/projects/domain/RemoteConfig.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { RemoteSpecAuthSchema } from './RemoteSpecAuth' + +export const RemoteConfigSchema = z.object({ + url: z.string().url(), + auth: RemoteSpecAuthSchema.optional(), +}) + +type RemoteConfig = z.infer + +export default RemoteConfig diff --git a/src/features/projects/domain/RemoteConfigEncoder.ts b/src/features/projects/domain/RemoteConfigEncoder.ts new file mode 100644 index 00000000..6336efec --- /dev/null +++ b/src/features/projects/domain/RemoteConfigEncoder.ts @@ -0,0 +1,34 @@ +import { IEncryptionService } from "@/features/encrypt/EncryptionService"; +import RemoteConfig, { RemoteConfigSchema } from "./RemoteConfig"; + +export interface IRemoteConfigEncoder { + encode(remoteConfig: RemoteConfig): string; + decode(encodedString: string): RemoteConfig; +} + +/** + * Encodes and decodes remote configs. + * + * The remote config is first stringified to JSON, then encrypted, and finally encoded in base64. + * + * At the receiving end, the encoded string is first decoded from base64, then decrypted, and finally parsed as JSON. + */ +export default class RemoteConfigEncoder implements IRemoteConfigEncoder { + private readonly encryptionService: IEncryptionService; + + constructor(encryptionService: IEncryptionService) { + this.encryptionService = encryptionService; + } + + encode(remoteConfig: RemoteConfig): string { + const jsonString = JSON.stringify(remoteConfig); + const encryptedString = this.encryptionService.encrypt(jsonString); + return Buffer.from(encryptedString).toString('base64'); + } + + decode(encodedString: string): RemoteConfig { + const decodedString = Buffer.from(encodedString, 'base64').toString('utf-8'); + const decryptedString = this.encryptionService.decrypt(decodedString); + return RemoteConfigSchema.parse(JSON.parse(decryptedString)); + } +} diff --git a/src/features/projects/domain/RemoteSpecAuth.ts b/src/features/projects/domain/RemoteSpecAuth.ts new file mode 100644 index 00000000..5d72a335 --- /dev/null +++ b/src/features/projects/domain/RemoteSpecAuth.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const RemoteSpecAuthSchema = z.object({ + type: z.string(), + username: z.string(), + password: z.string(), +}) + +type RemoteSpecAuth = z.infer + +export default RemoteSpecAuth