pygit2 icon indicating copy to clipboard operation
pygit2 copied to clipboard

GPG-signed commits

Open parisk opened this issue 9 years ago • 14 comments

Is there any way to sign commits (and tags maybe) with pygit2? I performed a quick search about gpg and signed both in the repository and documentation website and could not find any related document.

parisk avatar Aug 11 '16 14:08 parisk

Sorry for nagging here. Can any of the maintainers share if this can happen right now, if there is any kind of workaround for this or, there are any features that need to be implemented first?

Thanks 🙂 .

parisk avatar Jan 30 '17 18:01 parisk

I assume that it may be possible by manually assigning Commit.gpg_signature.

cc @jdavid

webknjaz avatar Sep 09 '20 10:09 webknjaz

Looks like I was wrong. pygit2 only exposes already existing signatures.

But I've collected some pointers:

  • It seems like more code need to be added around https://github.com/libgit2/pygit2/blob/0462c5ca1c9147804e15d594aafc086d7e4e3f98/src/repository.c#L1001 to optionally sign commits
  • Producing a signed commit will probably involve invoking git_commit_create_buffer to create an in-memory commit in a git_buf object having unsigned commit_content that will be passed to git_commit_create_with_signature. After this is done, I assume we'll need to update the ref manually because unlike git_commit_create, git_commit_create_with_signature does not have update_ref.
  • Also we need to set a signing callback in git_rebase_options: https://github.com/libgit2/libgit2/blob/b0692d6b3e818b9389295d7d33a0601143cc0c16/include/git2/rebase.h#L84 / git_commit_signing_cb

I'd expect the public API of pygit2 to just accept some args via Repository.create_commit() toggling the signing as well as maybe some global settings/callbacks for defaults.

webknjaz avatar Sep 09 '20 12:09 webknjaz

I really think this is an important feature and in my opinion should be prioritized. Without this it's too easy to impersonate people even on GitHub. Without this capability users are forced between choosing security or automation but not both.

kuwv avatar Apr 20 '22 14:04 kuwv

So it looks like there's no documentation for singing either for what is there. It's just implemented as a BYO signature.

Unfortunately, gpgme python integration isn't published. The python-gnupg module may work though but I'll have to dig in and try it.

References: https://github.com/libgit2/libgit2/pull/3673 https://github.com/libgit2/pygit2/blob/master/test/test_commit_gpg.py

kuwv avatar Apr 20 '22 19:04 kuwv

https://github.com/libgit2/pygit2/pull/1142

NOTE: as outlined by @webknjaz, the head must manually be updated. repo.head.set_target(Oid, str)

Example:

"""Prototype GnuPG commit signing."""

import logging
import os
from io import BytesIO
from tempfile import NamedTemporaryFile

from gnupg import GPG, Sign
from pygit2 import Repository, discover_repository

log = logging.getLogger(__name__)
log.setLevel(logging.INFO)
log.addHandler(logging.StreamHandler())

REPO_DIR = discover_repository(os.getcwd())
GNUPGHOME = os.path.join(os.path.expanduser('~'), '.gnupg-test')


def setup_gpg(gnupghome: str) -> GPG:
    """Get GnuPG instance."""
    with open('/dev/stdout') as fd:
        tty_path = os.ttyname(fd.fileno())
        os.environ['GPG_TTY'] = tty_path

    if not os.path.exists(gnupghome):
        log.info('gpg: making gnupghome')
        os.mkdir(gnupghome)
        os.chmod(gnupghome, 0o700)
        gpg = GPG(gnupghome=gnupghome)

        log.info('gpg: generating a gpg key')
        input_data = gpg.gen_key_input(
            name_real='Jesse P. Johnson',
            name_email='[email protected]',
            name_comment='*** WARNING: These keys are for testing! ***',
            key_type='RSA',
            key_length=2048,
            subkey_type='RSA',
            subkey_length=2048,
            passphrase='secret',
        )
        key = gpg.gen_key(input_data)
        log.info(type(key), vars(key))
    else:
        gpg = GPG(gnupghome=gnupghome)
        log.info('gpg: found gpg key')
    return gpg


def create_commit(repo: Repository, message: str) -> str:
    """Create commit string for signing."""
    log.info('pygit2: prepare commit info')
    author = repo.default_signature
    commiter = repo.default_signature

    log.info('pygit2: populate index')
    repo.index.add(os.path.basename(__file__))  # this file
    repo.index.write()
    tree = repo.index.write_tree()
    parents = [repo.head.target]
    encoding = 'utf-8'

    log.info('pygit2: create commit object')
    commit_string = repo.create_commit_string(
        author,
        commiter,
        message,
        tree,
        parents,
        encoding,
    )
    return commit_string


def sign_commit(gpg: GPG, commit: str) -> Sign:
    """Sign commit with GPG key."""
    # signature = gpg.sign(
    #     commit,
    #     passphrase='secret',
    #     detach=True,
    # )
    # log.info('gpg: signed commit')
    # log.info((signature.data).decode('utf-8'))

    # verified = gpg.verify(signature.data)
    # log.info('Verified' if verified else 'Unverified')

    log.info('gpg: signing commit file')
    signature = gpg.sign_file(
        BytesIO(commit.encode()),
        passphrase='secret',
        detach=True,
    )
    log.info('gpg: signed commit file')
    log.info((signature.data).decode('utf-8'))

    with NamedTemporaryFile() as f:
        f.write(commit.encode())
        f.seek(0)
        verified = gpg.verify_file(BytesIO(signature.data), f.name)
        log.info(
            'gpg: commit verified' if verified else 'gpg: commit is unverified'
        )

    return signature


def main(repo: Repository, gpg: GPG) -> None:
    """Demonstrate commit signing."""
    message = 'ci: generate signed commit'
    commit_string = create_commit(repo, message)
    signature = sign_commit(gpg, commit_string)
    log.info(signature)

    commit = repo.create_commit_with_signature(
        commit_string, signature.data.decode('utf-8')
    )

    repo.head.set_target(commit, message)


if __name__ == '__main__':
    repo = Repository(REPO_DIR)
    gpg = setup_gpg(GNUPGHOME)
    main(repo, gpg)

kuwv avatar May 02 '22 20:05 kuwv

@vsajip Do you have any suggestions for performing the validation of the signed commit?

Currently, this fails when verifying with the detached signature. How should a detached signature be validated?

kuwv avatar May 05 '22 14:05 kuwv

Hmmm, can't see anything obviously wrong. It might be worth turning on logging for the gnupg logger and seeing what that shows. Do you get the same result if you do gpg.verify_file(io.BytesIO(signed_commit.data))? The test test_signature_verification in the source distribution's test_gnupg.py tests verifying a detached signature, and that test seems to pass. :thinking:

vsajip avatar May 06 '22 10:05 vsajip

Do you get the same result if you do gpg.verify_file(io.BytesIO(signed_commit.data))?

I was thinking about doing it this way but decided on sign() instead.

Let me give it a try and I'll update what I find.

kuwv avatar May 06 '22 12:05 kuwv

It shouldn't matter, but ... wait, I'm talking about using verify(), not sign().

vsajip avatar May 06 '22 12:05 vsajip

Thought about it some more ... when verifying a detached signature, two things are needed: the signature data and the signed data. The signature is in signed_commit.data, but the data you signed is in commit_string. So, I suggest writing that to a temporary file, then passing the name of that into gpg.verify(signed_commit.data, data_filename=path_to_temp_file).

vsajip avatar May 06 '22 12:05 vsajip

It shouldn't matter, but ... wait, I'm talking about using verify(), not sign().

Yeah, I meant the tandems of sign/verify vs sign_file/verify_file

Thought about it some more ... when verifying a detached signature, two things are needed: the signature data and the signed data. The signature is in signed_commit.data, but the data you signed is in commit_string. So, I suggest writing that to a temporary file, then passing the name of that into gpg.verify(signed_commit.data, data_filename=path_to_temp_file).

Edit: nm, I didn't get it working yet. Still looking at it though.

kuwv avatar May 06 '22 16:05 kuwv

# XXX: this attempt did not work
# verified = gpg.verify_file(BytesIO(signature.data), f.name)

After writing, the file pointer is at the end of the file, and there is nothing left to read. To get a proper verification, open the temporary file with mode w+b and file.seek(0) after writing out the file.

chet-manley avatar Jun 11 '23 22:06 chet-manley

# XXX: this attempt did not work
# verified = gpg.verify_file(BytesIO(signature.data), f.name)

After writing, the file pointer is at the end of the file, and there is nothing left to read. To get a proper verification, open the temporary file with mode w+b and file.seek(0) after writing out the file.

Yup, it works. Don't know why that didn't occur to me.

kuwv avatar Oct 15 '23 19:10 kuwv