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 --- 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 +- 7 files changed, 133 insertions(+), 18 deletions(-) (limited to 'packages') 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, -- cgit v1.2.3-18-g5258