From 108b469cbb09f911c9a52d4134a504d9b51ac30d Mon Sep 17 00:00:00 2001 From: HampusM Date: Wed, 11 Aug 2021 12:59:23 +0200 Subject: Implemented gpg signed commit support --- README.md | 2 +- docs_src/installation.md | 1 + packages/api/src/commit.d.ts | 12 +-- packages/api/src/misc.d.ts | 4 +- packages/server/package.json | 3 +- packages/server/src/git/commit.ts | 114 ++++++++++++++++++++++++-- packages/server/src/git/error.ts | 6 ++ packages/server/src/routes/api/v1/repo/log.ts | 6 +- packages/server/src/routes/api/v1/repo/map.ts | 6 +- test/unit/commit.unit.test.ts | 40 +++++++++ test/util.ts | 9 +- yarn.lock | 22 +++++ 12 files changed, 203 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c2d212d..b50e218 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Githermit requires no such thing. All the steps to get it set up are in [Usage]( - [x] Custom website title support - [ ] [Redesign](https://www.figma.com/file/r8P4m4SFTFkPfxkfoRrhtV/Githermit) - [x] Documentation -- [ ] GPG support +- [x] GPG support # Installation You can find installation instructions in [the documentation](/docs_src/installation.md) diff --git a/docs_src/installation.md b/docs_src/installation.md index 2c68650..723d35e 100644 --- a/docs_src/installation.md +++ b/docs_src/installation.md @@ -10,6 +10,7 @@ For example: Githermit also requires NodeJS. You can find a comprehensive guide on how to install it in [the NodeJS documentation](https://nodejs.dev/download/package-manager/). And then there's a couple of packages that may or may not already be installed on your system. +- gpg - libpcre - libpcreposix - libkrb5 diff --git a/packages/api/src/commit.d.ts b/packages/api/src/commit.d.ts index 6eb598c..0a76f8a 100644 --- a/packages/api/src/commit.d.ts +++ b/packages/api/src/commit.d.ts @@ -18,9 +18,13 @@ export type Patch = { too_large: boolean, hunks: Hunk[] } +export interface CommitAuthor extends Author { + fingerprint: string | null +} export interface Commit { message: string, - author: Author, + author: CommitAuthor, + isSigned: boolean, date: number, insertions: number, deletions: number, @@ -30,10 +34,8 @@ export interface Commit { export type LogCommit = { id: string, - author: { - name: string, - email: string - }, + author: CommitAuthor, + isSigned: boolean, message: string, date: number, insertions: number, diff --git a/packages/api/src/misc.d.ts b/packages/api/src/misc.d.ts index e43fae5..70ab631 100644 --- a/packages/api/src/misc.d.ts +++ b/packages/api/src/misc.d.ts @@ -1,4 +1,4 @@ -export type Author = { +export interface Author { name: string, - email: string + email: string, } \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index a29aecc..8ffb19b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,6 +14,7 @@ "fastify-static": "^4.2.2", "js-yaml": "^4.1.0", "nodegit": "^0.27.0", + "openpgp": "^5.0.0-5", "tar-stream": "^2.2.0", "whatwg-url": "^9.0.0" }, @@ -25,6 +26,7 @@ "@types/whatwg-url": "^8.2.0", "@typescript-eslint/eslint-plugin": "^4.26.0", "@typescript-eslint/parser": "^4.26.0", + "api": "^1.0.0", "eslint": "^7.31.0", "eslint-config-base": " ^1.0.0", "eslint-config-standard": "^16.0.3", @@ -33,7 +35,6 @@ "eslint-plugin-promise": "^5.1.0", "eslint-plugin-tsdoc": "^0.2.14", "nodemon": "^2.0.12", - "api": "^1.0.0", "ts-node": "^10.1.0", "typescript": "^4.3.2" } diff --git a/packages/server/src/git/commit.ts b/packages/server/src/git/commit.ts index cf1ac5a..32b5869 100644 --- a/packages/server/src/git/commit.ts +++ b/packages/server/src/git/commit.ts @@ -3,6 +3,13 @@ import { Author } from "api"; import { Diff } from "./diff"; import { Repository } from "./repository"; import { Tree } from "./tree"; +import { createMessage, readKey, readSignature, verify } from "openpgp"; +import { promisify } from "util"; +import { exec } from "child_process"; +import { findAsync } from "./misc"; +import { CommitError, createError } from "./error"; + +const pExec = promisify(exec); export type CommitSummary = { id: string, @@ -16,6 +23,89 @@ type DiffStats = { files_changed: number } +/** + * A author of a commit + */ +export class CommitAuthor implements Author { + private _ng_commit: NodeGitCommit; + + constructor(ng_commit: NodeGitCommit) { + this._ng_commit = ng_commit; + } + + public get name(): string { + return this._ng_commit.author().name(); + } + + public get email(): string { + return this._ng_commit.author().email(); + } + + /** + * Returns the public key fingerprint of the commit's signer. + */ + public async fingerprint(): Promise { + const basic_signature = await this._ng_commit.getSignature().catch(() => { + throw(createError(CommitError, 500, "Commit isn't signed!")); + }); + + const message = await createMessage({ text: basic_signature.signedData }); + + const pub_keys_list = await pExec("gpg --list-public-keys"); + + if(pub_keys_list.stderr) { + throw(createError(CommitError, 500, "Failed to get public keys from gpg!")); + } + + const pub_keys = pub_keys_list.stdout + .split("\n") + .slice(2, -1) + .join("\n") + .split(/^\n/gm); + + // Find a public key that matches the signature + const pub_key = await findAsync(pub_keys, async key => { + // Make sure the UID is the same as the commit author + const uid = key + .split("\n")[2] + .replace(/^uid\s*\[.*\]\s/, ""); + + if(uid !== `${this.name} <${this.email}>`) { + return false; + } + + // Get the public key as an armored key + const fingerprint = key.split("\n")[1].replace(/^\s*/, ""); + const key_export = await pExec(`gpg --armor --export ${fingerprint}`); + + if(key_export.stderr) { + throw(createError(CommitError, 500, "Failed to export a public key from gpg!")); + } + + const signature = await readSignature({ armoredSignature: basic_signature.signature }); + + const verification = await verify({ + message: message, + verificationKeys: await readKey({ armoredKey: key_export.stdout }), + expectSigned: true, + signature: signature + }) + .then(result => result.signatures[0].verified) + .catch(() => Promise.resolve(false)); + + return verification; + }); + + if(!pub_key) { + throw(createError(CommitError, 500, "Failed to find a public key matching the commit signature!")); + } + + return pub_key + .split("\n")[1] + .replace(/^\s*/, ""); + } +} + /** * A representation of a commit */ @@ -24,7 +114,6 @@ export class Commit { private _owner: Repository; public id: string; - public author: Author; public date: number; public message: string; @@ -35,16 +124,20 @@ export class Commit { constructor(owner: Repository, commit: NodeGitCommit) { this._ng_commit = commit; this._owner = owner; - this.id = commit.sha(); - this.author = { - name: commit.author().name(), - email: commit.author().email() - }; this.date = commit.time(); this.message = commit.message(); } + /** + * Returns the commit's author + * + * @returns An instance of a commit author + */ + public author(): CommitAuthor { + return new CommitAuthor(this._ng_commit); + } + /** * Returns the commit's diff * @@ -78,6 +171,15 @@ export class Commit { return new Tree(this._owner, await this._ng_commit.getTree()); } + /** + * Returns whether or not the commit is signed + */ + public isSigned(): Promise { + return this._ng_commit.getSignature() + .then(() => true) + .catch(() => false); + } + /** * Lookup a commit * diff --git a/packages/server/src/git/error.ts b/packages/server/src/git/error.ts index 6715d8f..b89b994 100644 --- a/packages/server/src/git/error.ts +++ b/packages/server/src/git/error.ts @@ -54,6 +54,12 @@ export class DiffError extends BaseError { } } +export class CommitError extends BaseError { + constructor(code: number, message: string) { + super(code, "A commit error has occured: " + message); + } +} + type ErrorConstructorType = new (code: number, message: string) => T; /** diff --git a/packages/server/src/routes/api/v1/repo/log.ts b/packages/server/src/routes/api/v1/repo/log.ts index a7d8dfa..cf53d6b 100644 --- a/packages/server/src/routes/api/v1/repo/log.ts +++ b/packages/server/src/routes/api/v1/repo/log.ts @@ -46,9 +46,11 @@ export default function(fastify: FastifyInstance, opts: FastifyPluginOptions, do const data: APICommit = { message: commit.message, author: { - name: commit.author.name, - email: commit.author.email + name: commit.author().name, + email: commit.author().email, + fingerprint: await commit.author().fingerprint().catch(() => null) }, + isSigned: await commit.isSigned(), date: commit.date, insertions: stats.insertions, deletions: stats.deletions, diff --git a/packages/server/src/routes/api/v1/repo/map.ts b/packages/server/src/routes/api/v1/repo/map.ts index a3d0aa8..0544e4f 100644 --- a/packages/server/src/routes/api/v1/repo/map.ts +++ b/packages/server/src/routes/api/v1/repo/map.ts @@ -6,9 +6,11 @@ export async function commitMap(commit: Commit): Promise { return { id: commit.id, author: { - name: commit.author.name, - email: commit.author.email + name: commit.author().name, + email: commit.author().email, + fingerprint: await commit.author().fingerprint().catch(() => null) }, + isSigned: await commit.isSigned(), message: commit.message, date: commit.date, insertions: stats.insertions, diff --git a/test/unit/commit.unit.test.ts b/test/unit/commit.unit.test.ts index 137c64c..c8cc06f 100644 --- a/test/unit/commit.unit.test.ts +++ b/test/unit/commit.unit.test.ts @@ -80,5 +80,45 @@ describe("Commit", () => { expect(tree).toBeDefined(); expect(tree).toBeInstanceOf(Tree); }); + + it("Should get if it's signed and respond true", async() => { + expect.assertions(2); + + const is_signed = await commit.isSigned(); + + expect(is_signed).toBeDefined(); + expect(is_signed).toBeTruthy(); + }); + + it("Should get if a unsigned commit is signed and respond false", async() => { + expect.assertions(2); + + const other_commit = await Commit.lookup(repository, "7578c24113ba71d7435a94c649566e4e39e0e88c"); + + const is_signed = await other_commit.isSigned(); + + expect(is_signed).toBeDefined(); + expect(is_signed).toBeFalsy(); + }); + + it("Should get the author", async() => { + expect.assertions(7); + + const author = await commit.author(); + + expect(author).toBeDefined(); + + expect(author).toHaveProperty("name"); + expect(author.name).toBeDefined(); + + expect(author).toHaveProperty("email"); + expect(author.email).toBeDefined(); + + expect(author).toHaveProperty("fingerprint"); + + const fingerprint = await author.fingerprint(); + + expect(fingerprint).toBeDefined(); + }); }); }); \ No newline at end of file diff --git a/test/util.ts b/test/util.ts index 5886514..0079580 100644 --- a/test/util.ts +++ b/test/util.ts @@ -1,4 +1,4 @@ -import { Commit } from "server/src/git/commit"; +import { Commit, CommitAuthor } from "server/src/git/commit"; export type EnvironmentVariables = { BASE_DIR: string, @@ -14,8 +14,11 @@ export type EnvironmentVariables = { export function expectCommitProperties(commit: Commit): void { expect(commit).toHaveProperty("id"); expect(commit).toHaveProperty("author"); - expect(commit).toHaveProperty("author.name"); - expect(commit).toHaveProperty("author.email"); + + const author = commit.author(); + expect(author).toBeInstanceOf(CommitAuthor); + expect(commit).toHaveProperty("date"); expect(commit).toHaveProperty("message"); + expect(commit).toHaveProperty("isSigned"); } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d8825ce..2b3d1c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2690,6 +2690,16 @@ arrify@^2.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== +asn1.js@^5.0.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + asn1@~0.2.3: version "0.2.4" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" @@ -2969,6 +2979,11 @@ bluebird@^3.1.1, bluebird@^3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +bn.js@^4.0.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== + body-parser@1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" @@ -8036,6 +8051,13 @@ opener@^1.5.2: resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== +openpgp@^5.0.0-5: + version "5.0.0-5" + resolved "https://registry.yarnpkg.com/openpgp/-/openpgp-5.0.0-5.tgz#55b0b8e6825b0a651244ddd468f70af1a2922767" + integrity sha512-9UgpUiWzh14ciVWdCbWwXAeHJ7Vcnu1vnSs38kyYd9S+z1apP4A76jRpOrhG3Xl6yPG9p7jzspjU4y4LrDMjTQ== + dependencies: + asn1.js "^5.0.0" + opn@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" -- cgit v1.2.3-18-g5258