Metadata-Version: 2.4
Name: hg-git-sign
Version: 0.1.0
Summary: Sign Mercurial changesets so hg-git pushes them to Git as signed commits
Author-email: Stephen Skory <s@skory.us>
License-Expression: GPL-2.0-or-later
Project-URL: Homepage, https://github.com/stephenskory/hg-git-sign
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Version Control :: Git
Classifier: Topic :: Software Development :: Version Control :: Mercurial
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: hg-git>=1.2
Dynamic: license-file

# hg-git-sign

A Mercurial extension that signs changesets the way `git commit -S` does, so
that [hg-git](https://foss.heptapod.net/mercurial/hg-git) pushes them to
GitHub (or any Git host) as signed commits that show up as **Verified**.

Unlike the bundled [gpg](https://mercurial-scm.readthedocs.io/en/latest/help/extensions/gpg.html)
extension, no extra changeset is created. The
signature is computed when the changeset is committed and stored inside it,
in the `gpgsig` extra field, which is the field hg-git already uses for
signatures on commits it pulls from Git.

> [!NOTE]
> This code was written partially by Claude AI.

## Requirements

- Mercurial 6.6 through 7.1
- hg-git 1.2.0, with the dulwich it requires (0.21.6 or newer, before 0.23)
- Python 3.9 or newer, as supported by the Mercurial version in use
- `gpg` for OpenPGP signatures, or `ssh-keygen` (OpenSSH 8.0+) for SSH
  signatures

The full test suite passes on every combination below:

| Mercurial            | hg-git | dulwich         | Python           |
| -------------------- | ------ | --------------- | ---------------- |
| 6.6.3, 6.7.4         | 1.2.0  | 0.22.8          | 3.11             |
| 6.8.2                | 1.2.0  | 0.22.8          | 3.9, 3.12        |
| 6.9.5, 7.0.3         | 1.2.0  | 0.22.8          | 3.12             |
| 7.1.2                | 1.2.0  | 0.21.6, 0.22.8  | 3.12, 3.13       |

## Setup

Install from PyPI into the same Python environment that runs `hg`; this also
installs [hg-git](https://foss.heptapod.net/mercurial/hg-git) if needed:

```sh
pip install hg-git-sign
```

Then add to `~/.hgrc`:

```ini
[extensions]
hggit =
hggitsign =

[hggitsign]
# Default OpenPGP key id or fingerprint, can be overwritten per-repository
# in .hg/hgrc
key = 0123456789ABCDEF
```

To run from a checkout instead, install hg-git separately and point at the file:
`hggitsign = /path/to/hg-git-sign/hggitsign.py`.

For SSH signing instead:

```ini
[hggitsign]
format = ssh
key = ~/.ssh/id_ed25519
# optional, used by `hg gitsign-verify`
allowedsigners = ~/.ssh/allowed_signers
```

`key` may also be a literal public key (`ssh-ed25519 AAAA...` or
`key::ssh-ed25519 AAAA...`), in which case the private key must be in
`ssh-agent`.

Then upload the public key to GitHub under *Settings > SSH and GPG keys* (see
[Signing keys](#signing-keys) if you don't have one yet). The
email in `ui.username` must be a verified email on your GitHub account and,
for OpenPGP, a user id on the key. hg-git uses `ui.username` as both author
and committer, which is what GitHub checks.

## Signing keys

Creating and managing keys is outside the scope of this project, but these
are the essentials. The signatures are the same ones `git commit -S` makes, so
any guide written for Git applies here too.

### OpenPGP

```sh
gpg --quick-generate-key "Your Name <you@example.com>" ed25519 sign 2y
gpg --list-secret-keys --keyid-format=long   # the key id follows "sec   ed25519/"
gpg --armor --export <key id>                # paste into GitHub as a GPG key
```

- Can replace `2y` in the `--quick-generate-key` command with `never` for a key
  that never expires. [See these docs](https://www.gnupg.org/documentation/manuals/gnupg/OpenPGP-Key-Management.html)
- You may consider [uploading your key(s)](https://keys.openpgp.org/about/usage/)
  for public consumption
- Installing: `brew install gnupg pinentry-mac` or [GPG Suite](https://gpgtools.org/)
  on macOS, the `gnupg` package on Linux, [Gpg4win](https://www.gpg4win.org/)
  on Windows.
- If `hg commit` aborts with `Inappropriate ioctl for device`, or never asks
  for the passphrase, gpg has no terminal for its passphrase prompt. Add
  `export GPG_TTY=$(tty)` to your shell startup file. On macOS you can instead
  put `pinentry-program /opt/homebrew/bin/pinentry-mac` (check the path with
  `which pinentry-mac`) in `~/.gnupg/gpg-agent.conf` and run
  `gpgconf --kill gpg-agent`.
- GitHub: [Generating a new GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key),
  [Adding a GPG key to your account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account).
- [The GnuPG manual](https://www.gnupg.org/documentation/manuals/gnupg/).

### SSH

```sh
ssh-keygen -t ed25519 -C "you@example.com"   # or reuse an existing key
echo "you@example.com $(cat ~/.ssh/id_ed25519.pub)" >> ~/.ssh/allowed_signers
```

- On GitHub, add the public key with *Key type: Signing Key*. A key added
  only as an authentication key does not verify commits, even if it is the
  same key, so add it a second time for signing.
- If the key has a passphrase, load it into `ssh-agent` with `ssh-add`
  (`ssh-add --apple-use-keychain` on macOS) so signing doesn't prompt on
  every commit.
- The `allowed_signers` format is described under
  [ALLOWED SIGNERS in ssh-keygen(1)](https://man.openbsd.org/ssh-keygen#ALLOWED_SIGNERS).
- GitHub: [Generating a new SSH key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent),
  [Adding a new SSH key to your account](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account).

### Further reading

- [Signing Your Work](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work)
  in *Pro Git*
- [Troubleshooting commit signature verification](https://docs.github.com/en/authentication/troubleshooting-commit-signature-verification)
  on GitHub
- [Signed commits](https://docs.gitlab.com/user/project/repository/signed_commits/)
  on GitLab

## Configuration

| Setting                     | Default              | Meaning                                                            |
| --------------------------- | -------------------- | ------------------------------------------------------------------ |
| `hggitsign.enabled`         | `True`               | Sign new changesets.                                               |
| `hggitsign.format`          | `openpgp`            | `openpgp` or `ssh`, like git's `gpg.format`.                       |
| `hggitsign.key`             | committer id         | Key id (OpenPGP) or key file / literal key (SSH, required).        |
| `hggitsign.program`         | `gpg` / `ssh-keygen` | Signing program, like `gpg.program` / `gpg.ssh.program`.           |
| `hggitsign.allowedsigners`  | unset                | Allowed signers file for SSH verification.                         |

## Add Ons Directory

Please check out the `add-ons` directory for examples of how to
integrate signing commits with password managers.
This can simplify signing commits, e.g. with
[TouchID](https://support.apple.com/guide/mac-help/use-touch-id-mchl16fbf90a/mac).
An example implementation for 1Password is included at the moment, others may
be included in the future.

## Usage

```sh
hg commit -m "..."               # signed
hg commit --no-gitsign -m "..."  # not signed, once
hg commit --gitsign -m "..."     # signed even if hggitsign.enabled=false
hg rebase --config hggitsign.enabled=false ...   # other commands: config override

hg gitsign-verify                # verify the working copy parent
hg gitsign-verify -r 'all()'     # verify everything; exit 1 if anything fails
hg log -r "extra('gpgsig')"      # list signed changesets
```

`hg gitsign-verify` checks the signature against the exact Git commit that
hg-git has exported (exporting first if needed), so it validates what GitHub
will see. For SSH signatures it reports `untrusted` unless the signer is listed
in `hggitsign.allowedsigners`.

If `gpg` or `ssh-keygen` cannot sign, the commit is
aborted and the working copy is left untouched, just like Git.

## How it works

A Git commit signature covers the bytes of the Git commit object. For a
Mercurial changeset, that object is what hg-git builds at export time: a tree
id, the Git ids of the parents, author and committer lines, the message and
hg-git's `HG:` metadata headers.

`hggitsign` hooks `commitctx`, the single place where every new changeset
passes through (`hg commit`, `--amend`, `rebase`, `histedit`, `graft`,
`import`, ...). Before the changeset is written it:

1. makes sure the parents already have Git ids, exporting to the hg-git cache
   in `.hg/git` if they do not, exactly as the first `hg push` would;
2. builds the Git commit object the changeset will export to, using hg-git's
   own author, message and tree code on the pending context;
3. signs those bytes with `gpg` or `ssh-keygen`;
4. stores the signature in the `gpgsig` extra of the changeset.

On export, the extension attaches the stored signature as the Git `gpgsig`
header. The resulting Git commit is byte-for-byte what Git itself would have
produced, so GitHub verifies it, and a clone of the Git repository back into
Mercurial yields the identical changeset ids.

Changesets without a signature export exactly as they would without the
extension. Rewriting a signed changeset drops the old signature and signs the
new one (or leaves it unsigned if signing is disabled), as Git does.

## Caveats

- **Load the extension on every machine that pushes.** hg-git 1.2 on its own
  does not export the `gpgsig` extra as a signature header; it would turn it
  into an `HG:extra` line instead, producing an unsigned commit with a
  different id.
- The first signed commit in a repository that has never talked to Git
  exports the whole history into `.hg/git`. That is a one-time cost, the same
  as the first `hg push` to a Git remote.
- The signature needs the parents' Git ids, so the Git ids of ancestors are
  frozen once a child is signed. That is inherent in signing and is the same
  in Git.
- Not supported: `experimental.copies.write-to=changeset-only` (signing
  aborts with a message), X.509 / S/MIME signatures, signed tags.
- Changesets pulled from Git keep whatever signature they came with; they are
  never re-signed.

## Tests

```sh
tests/test-hggitsign.sh
```

The script runs in an isolated `HOME` with throwaway OpenPGP and SSH keys and
exercises plain, partial, amended, rebased, grafted, merge and named-branch
commits, round trips through a bare Git repository, commits made by Git, a
tampered commit, and SSH signing. If `git` is on the `PATH`, every pushed
commit is also checked with `git verify-commit`, which is the same check
GitHub performs.

## License

GNU General Public License, version 2 or later, like Mercurial and hg-git.
