| 1 | import { execFileSync } from 'node:child_process'; |
| 2 | import { mkdtempSync, rmSync |
| 3 | import { tmpdir } |
| 4 | import path from 'node:path |
| 5 | import { afterEach, beforeEach |
| 6 | |
| 7 | import { |
| 8 | GITHUB_EXPIRED_KEY_COMMIT, |
| 9 | GITHUB_MERGE_COMMIT_OBJECT, |
| 10 | } from '../../gpg/__fixtures__/github-commits.js'; |
| 11 | import { |
| 12 | SIGNED_COMMIT_OBJECT, |
| 13 | SIGNING_PUBLIC_KEY, |
| 14 | } from '../../gpg/__fixtures__/signed-commit.js'; |
| 15 | import { GITHUB_WEB_FLOW_KEYS } |
| 16 | import { verifySignature } |
| 17 | import { |
| 18 | insertSignature, |
| 19 | readSignedCommits, |
| 20 | splitSignature, |
| 21 | } from './commit-signature.js'; |
| 22 | |
| 23 | describe('splitSignature', () |
| 24 | it('splits a real git signature from what it covers', |
| 25 | const signed = |
| 26 | |
| 27 | expect(signed?.signature |
| 28 | true, |
| 29 | ); |
| 30 | expect( |
| 31 | signed?.signature.trimEnd |
| 32 | ).toBe(true); |
| 33 | // The payload is the commit as it would read without the header. |
| 34 | expect(signed?.payload |
| 35 | expect(signed?.payload |
| 36 | expect(signed?.payload |
| 37 | }); |
| 38 | |
| 39 | it('verifies against the key that signed it', |
| 40 | const signed = |
| 41 | |
| 42 | await expect( |
| 43 | verifySignature({ |
| 44 | payload: signed!.payload |
| 45 | armoredSignature: signed!.signature |
| 46 | armoredKey: SIGNING_PUBLIC_KEY, |
| 47 | }), |
| 48 | ).resolves.toBe( |
| 49 | }); |
| 50 | |
| 51 | it('rejects a payload that was edited after signing', |
| 52 | const signed = |
| 53 | |
| 54 | await expect( |
| 55 | verifySignature({ |
| 56 | payload: signed!.payload |
| 57 | armoredSignature: signed!.signature |
| 58 | armoredKey: SIGNING_PUBLIC_KEY, |
| 59 | }), |
| 60 | ).resolves.toBe( |
| 61 | }); |
| 62 | |
| 63 | it("verifies GitHub's merge commits under its current and its expired web-flow key", |
| 64 | const merge = |
| 65 | |
| 66 | await expect( |
| 67 | verifySignature({ |
| 68 | payload: merge!.payload |
| 69 | armoredSignature: merge!.signature |
| 70 | armoredKey: GITHUB_WEB_FLOW_KEYS, |
| 71 | }), |
| 72 | ).resolves.toBe( |
| 73 | await expect( |
| 74 | verifySignature({ |
| 75 | payload: GITHUB_EXPIRED_KEY_COMMIT.payload |
| 76 | armoredSignature: GITHUB_EXPIRED_KEY_COMMIT.signature |
| 77 | armoredKey: GITHUB_WEB_FLOW_KEYS, |
| 78 | }), |
| 79 | ).resolves.toBe( |
| 80 | }); |
| 81 | |
| 82 | it('is null for an unsigned commit', |
| 83 | expect( |
| 84 | splitSignature( |
| 85 | 'tree abc\nauthor A <a@b.c> 1 +0000\n\nmentions gpgsig in the body\n' |
| 86 | ), |
| 87 | ).toBeNull(); |
| 88 | }); |
| 89 | |
| 90 | it('ignores an SSH signature, which this does not verify', |
| 91 | const commit = |
| 92 | '-----BEGIN PGP SIGNATURE-----', |
| 93 | '-----BEGIN SSH SIGNATURE-----', |
| 94 | ); |
| 95 | |
| 96 | expect(splitSignature(commit |
| 97 | }); |
| 98 | }); |
| 99 | |
| 100 | describe('insertSignature', () |
| 101 | it('puts back exactly the header splitSignature takes out', |
| 102 | const signed = |
| 103 | |
| 104 | expect(insertSignature(signed |
| 105 | SIGNED_COMMIT_OBJECT, |
| 106 | ); |
| 107 | }); |
| 108 | |
| 109 | it('refuses an object with no end to its headers rather than corrupt it', |
| 110 | expect(() => |
| 111 | insertSignature('tree abc\nauthor A <a@b.c> 1 +0000' |
| 112 | ).toThrow('no blank line' |
| 113 | }); |
| 114 | }); |
| 115 | |
| 116 | describe('readSignedCommits', () |
| 117 | let root: |
| 118 | let gitDir: |
| 119 | let git: |
| 120 | |
| 121 | beforeEach(() => |
| 122 | root = mkdtempSync( |
| 123 | gitDir = path. |
| 124 | const env = |
| 125 | delete env.GIT_DIR; |
| 126 | git = (... |
| 127 | execFileSync('git', |
| 128 | |
| 129 | execFileSync('git', |
| 130 | git('config', |
| 131 | git('config', |
| 132 | // The machine running the tests may sign by default. |
| 133 | git('commit', |
| 134 | }); |
| 135 | |
| 136 | afterEach(() => |
| 137 | |
| 138 | it('returns only the commits that carry a signature', |
| 139 | const unsigned = |
| 140 | // The fixture object is written straight in, so the test needs no gpg. |
| 141 | const signed = |
| 142 | 'git', |
| 143 | ['hash-object', '-t' |
| 144 | { cwd: root, |
| 145 | ).trim(); |
| 146 | |
| 147 | const found = |
| 148 | gitDir, |
| 149 | shas: [unsigned, |
| 150 | }); |
| 151 | |
| 152 | expect([...found. |
| 153 | expect(found.get |
| 154 | }); |
| 155 | |
| 156 | it('is empty when nothing was asked for', |
| 157 | await expect(readSignedCommits |
| 158 | new Map(), |
| 159 | ); |
| 160 | }); |
| 161 | }); |