code-suggester icon indicating copy to clipboard operation
code-suggester copied to clipboard

Add GPG Signature option

Open chingor13 opened this issue 3 years ago • 4 comments

The GitHub commit API allows signing a commit, but the signature must be calculated externally.

https://docs.github.com/en/rest/reference/git#create-a-commit

chingor13 avatar Feb 24 '22 18:02 chingor13

Is there a reason this PR is still open, isn't this now supported? @chingor13

IchordeDionysos avatar Jan 29 '24 21:01 IchordeDionysos

Hi @IchordeDionysos,

Could you tell how to configure release-please to sign commits?

Zebradil avatar Jan 30 '24 06:01 Zebradil

@Zebradil I'm testing around myself! As I understand it's not possible right now to sign commits using release-please it would have to supply a commit signer.

My current status:

  • I'm using the openpgp library to sign commits.
  • I can construct most of the commit payload required for signing
  • The only missing piece in the code-suggester library is the commit author/committed date to properly sign the commit

I'm attaching my WIP code for signing the commit:

WIP commit signing

// These would be constructed/passed by release-please.
const octokit = new Octokit();
const fileData = new FileData('hello world', '100644');
const changes = new Map();
changes.set('foo.md', fileData);

// The PR would be created by release-please according to the release-please configuration.
await suggester.createPullRequest(octokit, changes, {
  upstreamOwner: '<owner>',
  upstreamRepo: '<repo>',
  title: 'Test PR',
  message: 'Test commit',
  description: '',
  fork: false,
  force: true,
  author: {
    name: '<authorName>',
    email: '<authorEmail>,
  },
  committer: {
    name: '<committerName>',
    email: '<committerEmail>',
  },
  // The next part would have to be added to release-please
  signer: {
    async generateSignature(commit: CommitData) {
      // We'd need to figure out how to pass the private key and an optional passphrase for signing.
      const privateKey = await openpgp.readPrivateKey({
        armoredKey: privateKeyArmored,
      });

      // Here the commit/authored date is missing, otherwise, the payload looks good.
      const commitObject = `tree ${commit.tree}
${commit.parents.map(parent => `parent ${parent}`).join('\n')}
author ${commit.author?.name ?? ''} <${commit.author?.email ?? ''}>
committer ${commit.committer?.name ?? ''} <${commit.committer?.email ?? ''}>

${commit.message}`;
      console.log(commit);
      console.log(commitObject);

      const message = await openpgp.createCleartextMessage({
        text: commitObject,
      });
      const signedMessage = await openpgp.sign({
        message,
        signingKeys: privateKey,
      });
      // We maybe need to find a more elegant solution to get only the signature (the `signedMessage` contains the passed message and only at the end the signature is attached)
      const signature =
        '-----BEGIN PGP SIGNATURE-----' +
        signedMessage.split('-----BEGIN PGP SIGNATURE-----')[1];
      console.log('signature', signature);
      return signature;
    },
  },
});

Next steps to support code signing properly:

  1. 🔜 Add date field to the UserData object as this is needed for proper signing of commits. (Fixed by #485)
  2. ⚙️ Finish the signature signing (either in this package or a companion package; @chingor13 do you have a preference for where to put the commit signing algorithm? It's not trivial to figure out this algorithm, so a helper might be useful here)
  3. Extend release-please to allow passing GPG private key and optional passphrase.
  4. Extend the release-please GitHub action to allow passing GPG private key and optional passphrase.

IchordeDionysos avatar Jan 30 '24 16:01 IchordeDionysos

Okay, a quick update from my side. I've created the PR that allows commit signing (see example below): #485

The commit signing was done using this signer:

class GPGCommitSigner implements CommitSigner {
  private privateKey: string;
  private passphrase: string | undefined;

  constructor({
    privateKey,
    passphrase,
  }: {
    privateKey: string;
    passphrase?: string;
  }) {
    this.privateKey = privateKey;
    this.passphrase = passphrase;
  }

  async generateSignature(commit: CommitDataWithRequiredDate): Promise<string> {
    const privateKey = await openpgp.readPrivateKey({
      armoredKey: this.privateKey,
    });

    const commitObject = this.buildCommitObject(commit);

    const message = await openpgp.createCleartextMessage({
      text: commitObject,
    });
    const signedMessage = await openpgp.sign({
      message,
      signingKeys: privateKey,
      // todo: Figure out how to use passphrases
    });

    return this.parseSignature(signedMessage);
  }

  private buildCommitObject(commit: CommitDataWithRequiredDate) {
    const rows = [];

    rows.push(`tree ${commit.tree}`);
    for (const parent of commit.parents) {
      rows.push(`parent ${parent}`);
    }
    if (commit.author) {
      rows.push(`author ${this.buildUserCommitObjectRow(commit.author)}`);
    }
    if (commit.committer) {
      rows.push(`committer ${this.buildUserCommitObjectRow(commit.committer)}`);
    }
    rows.push('');
    rows.push(commit.message);

    return rows.join('\n');
  }

  private buildUserCommitObjectRow({
    name,
    email,
    date,
  }: {
    name: string;
    email: string;
    date: Date;
  }) {
    const unixDate = Math.floor(date.getTime() / 1000);
    const timezoneOffset = this.buildTimezoneOffset(date);
    return `${name} <${email}> ${unixDate} ${timezoneOffset}`;
  }

  private buildTimezoneOffset(date: Date): string {
    const timezoneOffset = date.getTimezoneOffset();
    const timezoneOffsetAbsolute = Math.abs(timezoneOffset);
    if (timezoneOffset === 0) {
      return '+0000';
    }
    const offsetInHourFormat = (timezoneOffsetAbsolute / 60) * 100;
    const offsetString = String(offsetInHourFormat).padStart(4, '0');

    if (timezoneOffset < 0) {
      return `+${offsetString}`;
    }
    return `-${offsetString}`;
  }

  private parseSignature(signedMessage: string): string {
    const signature =
      '-----BEGIN PGP SIGNATURE-----' +
      signedMessage.split('-----BEGIN PGP SIGNATURE-----')[1];

    return signature;
  }
}

And the generated commit is verified as you can see here: https://github.com/simpleclub-extended/code-suggester/pull/2/commits/ca1c82b7559f28d730b18a56a75c86a910d677a3

I called the function like this:

const octokit = new Octokit();
const fileData = new FileData('hello world', '100644');
const changes = new Map();
changes.set('foo.md', fileData);
await suggester.createPullRequest(octokit, changes, {
  upstreamOwner: 'simpleclub-extended',
  upstreamRepo: 'code-suggester',
  title: 'Test commit signing using GPG keys',
  message: 'Test signed commit',
  description: '',
  fork: false,
  force: true,
  author: {
    name: '<name>',
    email: '<email>',
  },
  committer: {
    name: '<name>',
    email: '<email>',
  },
  signer: new GPGCommitSigner({privateKey: privateKeyArmored}),
});

@chingor13 could you review my PR to expose the date and let me know if you have a preference for where to best put the GPGCommitSigner class?

IchordeDionysos avatar Jan 30 '24 19:01 IchordeDionysos