pygit2
pygit2 copied to clipboard
GPG-signed commits
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.
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 🙂 .
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_bufferto create an in-memory commit in agit_bufobject having unsignedcommit_contentthat will be passed togit_commit_create_with_signature. After this is done, I assume we'll need to update the ref manually because unlikegit_commit_create,git_commit_create_with_signaturedoes not haveupdate_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.
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.
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
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)
@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?
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:
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.
It shouldn't matter, but ... wait, I'm talking about using verify(), not sign().
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).
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 incommit_string. So, I suggest writing that to a temporary file, then passing the name of that intogpg.verify(signed_commit.data, data_filename=path_to_temp_file).
Edit: nm, I didn't get it working yet. Still looking at it though.
# 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.
# 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+bandfile.seek(0)after writing out the file.
Yup, it works. Don't know why that didn't occur to me.