aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHampusM <hampus@hampusmat.com>2021-08-11 12:59:23 +0200
committerHampusM <hampus@hampusmat.com>2021-08-11 12:59:23 +0200
commit108b469cbb09f911c9a52d4134a504d9b51ac30d (patch)
tree309763642bf53b4adecf20bfd09047715f572c39
parent3eb8757b8db81476782870d460e3d856907186a7 (diff)
Implemented gpg signed commit support
-rw-r--r--README.md2
-rw-r--r--docs_src/installation.md1
-rw-r--r--packages/api/src/commit.d.ts12
-rw-r--r--packages/api/src/misc.d.ts4
-rw-r--r--packages/server/package.json3
-rw-r--r--packages/server/src/git/commit.ts114
-rw-r--r--packages/server/src/git/error.ts6
-rw-r--r--packages/server/src/routes/api/v1/repo/log.ts6
-rw-r--r--packages/server/src/routes/api/v1/repo/map.ts6
-rw-r--r--test/unit/commit.unit.test.ts40
-rw-r--r--test/util.ts9
-rw-r--r--yarn.lock22
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,
@@ -17,6 +24,89 @@ type DiffStats = {
}
/**
+ * 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<string> {
+ 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
*/
export class Commit {
@@ -24,7 +114,6 @@ export class Commit {
private _owner: Repository;
public id: string;
- public author: Author;
public date: number;
public message: string;
@@ -35,17 +124,21 @@ 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
*
* @returns An instance of a diff
@@ -79,6 +172,15 @@ export class Commit {
}
/**
+ * Returns whether or not the commit is signed
+ */
+ public isSigned(): Promise<boolean> {
+ return this._ng_commit.getSignature()
+ .then(() => true)
+ .catch(() => false);
+ }
+
+ /**
* Lookup a commit
*
* @param repository - The repository which the commit is in
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<T> = 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<LogCommit> {
return <LogCommit>{
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"