diff --git a/GitUpKit/Core/GCCommitSigning-Tests.m b/GitUpKit/Core/GCCommitSigning-Tests.m new file mode 100644 index 00000000..09506fcf --- /dev/null +++ b/GitUpKit/Core/GCCommitSigning-Tests.m @@ -0,0 +1,216 @@ +// Copyright (C) 2015-2019 Pierre-Olivier Latour +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#if !__has_feature(objc_arc) +#error This file requires ARC +#endif + +#import "GCTestCase.h" +#import "GCRepository+Index.h" + +static NSString* _CommitSignature(GCCommit* commit) { + git_buf buffer = {0}; + int status = git_commit_header_field(&buffer, commit.private, "gpgsig"); + if (status != GIT_OK) { + git_buf_free(&buffer); + return nil; + } + NSString* signature = [[NSString alloc] initWithBytes:buffer.ptr length:buffer.size encoding:NSUTF8StringEncoding]; + git_buf_free(&buffer); + return signature; +} + +static BOOL _WriteLocalConfigOption(GCRepository* repository, NSString* variable, NSString* value) { + return [repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:variable withValue:value error:NULL]; +} + +static BOOL _CommitHasSSHSignature(GCCommit* commit) { + return [_CommitSignature(commit) containsString:@"BEGIN SSH SIGNATURE"]; +} + +static BOOL _ConfigureSSHSigningWithKeyPath(GCRepository* repository, NSString* keyPath) { + if (!_WriteLocalConfigOption(repository, @"commit.gpgsign", @"true")) { + return NO; + } + if (!_WriteLocalConfigOption(repository, @"gpg.format", @"ssh")) { + return NO; + } + return _WriteLocalConfigOption(repository, @"user.signingkey", keyPath); +} + +static NSString* _CreateFakeSSHSigner(NSString* directory, int exitStatus) { + NSString* path = [directory stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; + NSString* contents; + + if (exitStatus == 0) { + NSArray* lines = @[ + @"#!/bin/sh", + @"cat >/dev/null", + @"printf '%s\\n' '-----BEGIN SSH SIGNATURE-----' 'fake-signature' '-----END SSH SIGNATURE-----'", + @"" + ]; + contents = [lines componentsJoinedByString:@"\n"]; + } else { + NSArray* lines = @[ + @"#!/bin/sh", + @"cat >/dev/null", + @"echo signer failed >&2", + [NSString stringWithFormat:@"exit %i", exitStatus], + @"" + ]; + contents = [lines componentsJoinedByString:@"\n"]; + } + + if (![contents writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:NULL]) { + return nil; + } + NSDictionary* attributes = @{NSFilePosixPermissions : @(0755)}; + if (![[NSFileManager defaultManager] setAttributes:attributes ofItemAtPath:path error:NULL]) { + return nil; + } + return path; +} + +static GCCommit* _CreateCommitFromRepositoryIndex(GCRepository* repository, NSString* message, NSError** error) { + GCCommit* commit = nil; + git_index* index = NULL; + git_tree* tree = NULL; + + index = [repository reloadRepositoryIndex:error]; + if (!index) { + goto cleanup; + } + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, repository.private); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, repository.private, &oid); + commit = GCCreateCommitFromTreeWithOptionalSignature(repository, + tree, + NULL, + 0, + NULL, + message, + error); + +cleanup: + git_tree_free(tree); + git_index_free(index); + return commit; +} + +@implementation GCEmptyRepositoryTests (GCCommitSigning) + +- (void)testCommitSigningLeavesCommitsUnsignedWhenDisabledOrUnsupported { + [self updateFileAtPath:@"unsigned.txt" withString:@"unsigned\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"unsigned.txt" error:NULL]); + + GCCommit* unsignedCommit = _CreateCommitFromRepositoryIndex(self.repository, @"Unsigned", NULL); + XCTAssertNotNil(unsignedCommit); + XCTAssertNil(_CommitSignature(unsignedCommit)); + + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"commit.gpgsign", @"true")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.format", @"openpgp")); + [self updateFileAtPath:@"openpgp.txt" withString:@"openpgp\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"openpgp.txt" error:NULL]); + + GCCommit* openPGPCommit = _CreateCommitFromRepositoryIndex(self.repository, @"OpenPGP config remains unsigned", NULL); + XCTAssertNotNil(openPGPCommit); + XCTAssertNil(_CommitSignature(openPGPCommit)); +} + +- (void)testCommitSigningRequiresSSHKey { + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"commit.gpgsign", @"true")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.format", @"ssh")); + [self updateFileAtPath:@"missing-key.txt" withString:@"missing key\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"missing-key.txt" error:NULL]); + + NSError* error; + XCTAssertNil(_CreateCommitFromRepositoryIndex(self.repository, @"Missing key", &error)); + XCTAssertTrue([error.localizedDescription containsString:@"user.signingkey"]); +} + +- (void)testCommitSigningSupportsInlineKeyAndDefaultKeyCommand { + NSString* signer = _CreateFakeSSHSigner(self.temporaryPath, 0); + NSString* inlineKey = @"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeInlineKey test@example.com"; + NSString* defaultKeyCommand = @"printf 'key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDefaultCommandKey test@example.com\\n'"; + + XCTAssertNotNil(signer); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"commit.gpgsign", @"true")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.format", @"ssh")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.ssh.program", signer)); + + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"user.signingkey", inlineKey)); + [self updateFileAtPath:@"inline-key.txt" withString:@"inline key\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"inline-key.txt" error:NULL]); + + GCCommit* inlineCommit = _CreateCommitFromRepositoryIndex(self.repository, @"Inline key", NULL); + XCTAssertNotNil(inlineCommit); + XCTAssertTrue(_CommitHasSSHSignature(inlineCommit)); + + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"user.signingkey", nil)); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.ssh.defaultKeyCommand", defaultKeyCommand)); + [self updateFileAtPath:@"default-key-command.txt" withString:@"default key command\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"default-key-command.txt" error:NULL]); + + GCCommit* defaultCommandCommit = _CreateCommitFromRepositoryIndex(self.repository, @"Default key command", NULL); + XCTAssertNotNil(defaultCommandCommit); + XCTAssertTrue(_CommitHasSSHSignature(defaultCommandCommit)); +} + +- (void)testCommitSigningSupportsKeyPath { + NSString* keyPath = [self.temporaryPath stringByAppendingPathComponent:@"signing_key"]; + GCTask* keygen = [[GCTask alloc] initWithExecutablePath:@"/usr/bin/ssh-keygen"]; + int status; + NSArray* keygenArguments = @[ @"-t", @"ed25519", @"-f", keyPath, @"-N", @"", @"-q" ]; + BOOL keygenSuccess = [keygen runWithArguments:keygenArguments stdin:nil stdout:NULL stderr:NULL exitStatus:&status error:NULL]; + XCTAssertTrue(keygenSuccess); + XCTAssertEqual(status, 0); + XCTAssertTrue(_ConfigureSSHSigningWithKeyPath(self.repository, keyPath)); + + [self updateFileAtPath:@"path-key.txt" withString:@"path key\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"path-key.txt" error:NULL]); + + GCCommit* commit = _CreateCommitFromRepositoryIndex(self.repository, @"Path key", NULL); + XCTAssertNotNil(commit); + XCTAssertTrue(_CommitHasSSHSignature(commit)); + + NSString* publicKey = [NSString stringWithContentsOfFile:[keyPath stringByAppendingString:@".pub"] encoding:NSUTF8StringEncoding error:NULL]; + NSString* allowedSignersPath = [self.temporaryPath stringByAppendingPathComponent:@"allowed_signers"]; + NSString* allowedSigners = [NSString stringWithFormat:@"bot@example.com %@", publicKey]; + XCTAssertTrue([allowedSigners writeToFile:allowedSignersPath atomically:YES encoding:NSUTF8StringEncoding error:NULL]); + + NSString* allowedSignersConfig = [NSString stringWithFormat:@"gpg.ssh.allowedSignersFile=%@", allowedSignersPath]; + NSString* verifyOutput = [self runGitCLTWithRepository:self.repository command:@"-c", allowedSignersConfig, @"verify-commit", commit.SHA1, nil]; + XCTAssertNotNil(verifyOutput); +} + +- (void)testCommitSigningFailsOnSignerFailure { + NSString* signer = _CreateFakeSSHSigner(self.temporaryPath, 7); + NSString* signingKey = @"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFailingSignerKey test@example.com"; + + XCTAssertNotNil(signer); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"commit.gpgsign", @"true")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.format", @"ssh")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.ssh.program", signer)); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"user.signingkey", signingKey)); + [self updateFileAtPath:@"failing-signer.txt" withString:@"failing signer\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"failing-signer.txt" error:NULL]); + + NSError* error; + XCTAssertNil(_CreateCommitFromRepositoryIndex(self.repository, @"Failing signer", &error)); + XCTAssertTrue([error.localizedDescription containsString:@"non-zero status"]); +} + +@end diff --git a/GitUpKit/Core/GCCommitSigning.m b/GitUpKit/Core/GCCommitSigning.m new file mode 100644 index 00000000..afa5a978 --- /dev/null +++ b/GitUpKit/Core/GCCommitSigning.m @@ -0,0 +1,321 @@ +// Copyright (C) 2015-2019 Pierre-Olivier Latour +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#if !__has_feature(objc_arc) +#error This file requires ARC +#endif + +#import "GCPrivate.h" + +#if !TARGET_OS_IPHONE + +static NSString* _StringFromTaskOutput(NSData* data) { + return [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +static NSString* _RepositoryTaskDirectoryPath(GCRepository* repository) { + return repository.workingDirectoryPath ?: repository.repositoryPath; +} + +static NSError* _TaskFailureError(NSString* message, int status, NSData* stdoutData, NSData* stderrData) { + NSString* output = _StringFromTaskOutput(stderrData.length ? stderrData : stdoutData); + NSString* reason = output.length ? [NSString stringWithFormat:@": %@", output] : @""; + return GCNewError(kGCErrorCode_Generic, [NSString stringWithFormat:@"%@ exited with non-zero status (%i)%@", message, status, reason]); +} + +static GCTask* _TaskWithPATH(GCRepository* repository, NSString* executablePath, NSString* path) { + GCTask* task = [[GCTask alloc] initWithExecutablePath:executablePath]; + task.currentDirectoryPath = _RepositoryTaskDirectoryPath(repository); + task.additionalEnvironment = @{@"PATH" : path}; + return task; +} + +static BOOL _ReadConfigBool(GCRepository* repository, const char* variable, BOOL* value, NSError** error) { + BOOL success = NO; + git_config* config = NULL; + int configValue = 0; + int status; + + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_repository_config, &config, repository.private); + status = git_config_get_bool(&configValue, config, variable); + if (status == GIT_ENOTFOUND) { + *value = NO; + success = YES; + goto cleanup; + } + CHECK_LIBGIT2_FUNCTION_CALL(goto cleanup, status, == GIT_OK); + *value = configValue ? YES : NO; + success = YES; + +cleanup: + git_config_free(config); + return success; +} + +static BOOL _ShouldSSHSignCommit(GCRepository* repository, BOOL* shouldSign, NSError** error) { + BOOL gpgSign = NO; + if (!_ReadConfigBool(repository, "commit.gpgsign", &gpgSign, error)) { + return NO; + } + if (!gpgSign) { + *shouldSign = NO; + return YES; + } + + NSString* format = [[repository readConfigOptionForVariable:@"gpg.format" error:NULL] value]; + if (!format.length || ([format caseInsensitiveCompare:@"ssh"] != NSOrderedSame)) { + // Only SSH commit signing is currently supported. + // Preserve existing GitUp behavior for OpenPGP/X.509 configs by creating an unsigned commit. + *shouldSign = NO; + return YES; + } + + *shouldSign = YES; + return YES; +} + +static NSString* _CommitSigningPATH(GCRepository* repository, NSError** error) { + static NSString* cachedPATH = nil; + if (cachedPATH == nil) { + NSString* shell = NSProcessInfo.processInfo.environment[@"SHELL"]; + cachedPATH = [repository getPATHUsingShell:shell error:error] ?: [repository getPATHUsingShell:@"/bin/sh" error:error]; + XLOG_DEBUG_CHECK(cachedPATH); + } + return cachedPATH; +} + +static BOOL _LooksLikeInlineSSHKey(NSString* key) { + return [key hasPrefix:@"ssh-"] || [key hasPrefix:@"ecdsa-"] || [key hasPrefix:@"sk-"]; +} + +static NSString* _SSHKeyFromDefaultKeyCommand(GCRepository* repository, NSString* command, NSError** error) { + NSString* path = _CommitSigningPATH(repository, error); + if (!path) { + return nil; + } + + GCTask* task = _TaskWithPATH(repository, @"/bin/sh", path); + int status; + NSData* stdoutData; + NSData* stderrData; + if (![task runWithArguments:@[ @"-c", command ] stdin:nil stdout:&stdoutData stderr:&stderrData exitStatus:&status error:error]) { + return nil; + } + if (status != 0) { + if (error) { + *error = _TaskFailureError(@"SSH signing default key command", status, stdoutData, stderrData); + } + return nil; + } + + NSString* output = [[NSString alloc] initWithData:stdoutData encoding:NSUTF8StringEncoding]; + for (NSString* line in [output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) { + NSString* trimmedLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([trimmedLine hasPrefix:@"key::"]) { + NSString* key = [trimmedLine substringFromIndex:5]; + return key.length ? key : nil; + } + } + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH signing default key command did not return a key:: entry"); + } + return nil; +} + +static NSString* _TemporaryKeyFileForInlineSSHKey(NSString* key, NSError** error) { + NSString* path = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; + NSString* contents = [key hasSuffix:@"\n"] ? key : [key stringByAppendingString:@"\n"]; + if (![contents writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:error]) { + return nil; + } + return path; +} + +static NSString* _SSHSigningKeyPath(GCRepository* repository, NSString** temporaryPath, NSError** error) { + NSString* key = [[repository readConfigOptionForVariable:@"user.signingkey" error:NULL] value]; + if (key.length) { + key = [key stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + } else { + NSString* command = [[repository readConfigOptionForVariable:@"gpg.ssh.defaultKeyCommand" error:NULL] value]; + if (!command.length) { + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH commit signing requires user.signingkey or gpg.ssh.defaultKeyCommand"); + } + return nil; + } + key = _SSHKeyFromDefaultKeyCommand(repository, command, error); + if (!key) { + return nil; + } + } + + if ([key hasPrefix:@"key::"]) { + key = [key substringFromIndex:5]; + } + if (_LooksLikeInlineSSHKey(key)) { + NSString* path = _TemporaryKeyFileForInlineSSHKey(key, error); + if (!path) { + return nil; + } + *temporaryPath = path; + return path; + } + + NSString* path = key.stringByExpandingTildeInPath; + if (!path.absolutePath) { + NSString* relativePath = [_RepositoryTaskDirectoryPath(repository) stringByAppendingPathComponent:path]; + if ([[NSFileManager defaultManager] fileExistsAtPath:relativePath]) { + path = relativePath; + } + } + return path; +} + +static NSString* _SSHSignatureForCommitBuffer(GCRepository* repository, NSData* commitBuffer, NSError** error) { + NSString* temporaryKeyPath = nil; + NSString* keyPath = _SSHSigningKeyPath(repository, &temporaryKeyPath, error); + if (!keyPath) { + return nil; + } + + NSString* path = _CommitSigningPATH(repository, error); + if (!path) { + if (temporaryKeyPath) { + [[NSFileManager defaultManager] removeItemAtPath:temporaryKeyPath error:NULL]; + } + return nil; + } + + NSString* program = [[repository readConfigOptionForVariable:@"gpg.ssh.program" error:NULL] value]; + program = program.length ? program.stringByExpandingTildeInPath : @"ssh-keygen"; + + GCTask* task = _TaskWithPATH(repository, @"/usr/bin/env", path); + int status; + NSData* stdoutData; + NSData* stderrData; + NSArray* arguments = @[ program, @"-Y", @"sign", @"-n", @"git", @"-f", keyPath ]; + BOOL success = [task runWithArguments:arguments stdin:commitBuffer stdout:&stdoutData stderr:&stderrData exitStatus:&status error:error]; + if (temporaryKeyPath) { + [[NSFileManager defaultManager] removeItemAtPath:temporaryKeyPath error:NULL]; + } + if (!success) { + return nil; + } + if (status != 0) { + if (error) { + *error = _TaskFailureError(@"SSH commit signer", status, stdoutData, stderrData); + } + return nil; + } + + NSString* signature = _StringFromTaskOutput(stdoutData); + if (!signature.length) { + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH commit signer did not return a signature"); + } + return nil; + } + return signature; +} + +#endif + +GCCommit* GCCreateCommitFromTreeWithOptionalSignature(GCRepository* repository, git_tree* tree, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error) { + GCCommit* commit = nil; + const git_signature* authorSignature = NULL; + git_signature* signature = NULL; + git_commit* newCommit = NULL; + NSData* cleanedMessage = nil; + const char* cleanedMessageBytes = NULL; +#if !TARGET_OS_IPHONE + git_buf commitBuffer = {0}; + NSData* commitData = nil; + NSString* sshSignature = nil; + BOOL shouldSign = NO; +#endif + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_signature_default, &signature, repository.private); + authorSignature = author ?: signature; + cleanedMessage = GCCleanedUpCommitMessage(message); + cleanedMessageBytes = (const char*)cleanedMessage.bytes; +#if !TARGET_OS_IPHONE + if (!_ShouldSSHSignCommit(repository, &shouldSign, error)) { + goto cleanup; + } + + if (shouldSign) { + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_buffer, &commitBuffer, repository.private, authorSignature, signature, NULL, cleanedMessageBytes, tree, count, parents); + commitData = [[NSData alloc] initWithBytes:commitBuffer.ptr length:commitBuffer.size]; + sshSignature = _SSHSignatureForCommitBuffer(repository, commitData, error); + if (!sshSignature) { + goto cleanup; + } + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_with_signature, &oid, repository.private, commitBuffer.ptr, sshSignature.UTF8String, "gpgsig"); + } else { +#endif + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create, &oid, repository.private, NULL, authorSignature, signature, NULL, cleanedMessageBytes, tree, count, parents); +#if !TARGET_OS_IPHONE + } +#endif + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_lookup, &newCommit, repository.private, &oid); + commit = [[GCCommit alloc] initWithRepository:repository commit:newCommit]; + newCommit = NULL; + +cleanup: + git_commit_free(newCommit); +#if !TARGET_OS_IPHONE + git_buf_free(&commitBuffer); +#endif + git_signature_free(signature); + return commit; +} + +GCCommit* GCCreateCommitFromCommitWithIndexAndOptionalSignature(GCRepository* repository, git_commit* amendedCommit, git_index* index, NSString* message, NSError** error) { + GCCommit* commit = nil; + git_tree* tree = NULL; + git_commit** parentCommits = NULL; + unsigned int parentCount = 0; + const git_commit** parents = NULL; + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, repository.private); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, repository.private, &oid); + + parentCount = git_commit_parentcount(amendedCommit); + if (parentCount) { + parentCommits = (git_commit**)calloc(parentCount, sizeof(git_commit*)); + if (!parentCommits) { + GC_SET_GENERIC_ERROR(@"Unable to allocate commit parent list"); + goto cleanup; + } + for (unsigned int i = 0; i < parentCount; ++i) { + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_parent, &parentCommits[i], amendedCommit, i); + } + } + parents = (const git_commit**)parentCommits; + + commit = GCCreateCommitFromTreeWithOptionalSignature(repository, tree, parents, parentCount, git_commit_author(amendedCommit), message, error); + +cleanup: + if (parentCommits) { + for (unsigned int i = 0; i < parentCount; ++i) { + git_commit_free(parentCommits[i]); + } + free(parentCommits); + } + git_tree_free(tree); + return commit; +} diff --git a/GitUpKit/Core/GCPrivate.h b/GitUpKit/Core/GCPrivate.h index b05b63bc..b915102f 100644 --- a/GitUpKit/Core/GCPrivate.h +++ b/GitUpKit/Core/GCPrivate.h @@ -118,6 +118,8 @@ extern NSString* GCGitOIDToSHA1(const git_oid* oid); extern BOOL GCGitOIDFromSHA1(NSString* sha1, git_oid* oid, NSError** error); extern BOOL GCGitOIDFromSHA1Prefix(NSString* prefix, git_oid* oid, NSError** error); extern NSData* GCCleanedUpCommitMessage(NSString* message); +extern GCCommit* GCCreateCommitFromTreeWithOptionalSignature(GCRepository* repository, git_tree* tree, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error); +extern GCCommit* GCCreateCommitFromCommitWithIndexAndOptionalSignature(GCRepository* repository, git_commit* commit, git_index* index, NSString* message, NSError** error); extern NSString* GCUserFromSignature(const git_signature* signature); extern const void* GCOIDCopyCallBack(CFAllocatorRef allocator, const void* value); extern Boolean GCOIDEqualCallBack(const void* value1, const void* value2); @@ -262,6 +264,9 @@ extern int git_submodule_foreach_block(git_repository* repo, int (^block)(git_su - (void)setRemoteCallbacks:(git_remote_callbacks*)callbacks; - (NSData*)exportBlobWithOID:(const git_oid*)oid error:(NSError**)error; - (BOOL)exportBlobWithOID:(const git_oid*)oid toPath:(NSString*)path error:(NSError**)error; +#if !TARGET_OS_IPHONE +- (NSString*)getPATHUsingShell:(NSString*)shell error:(NSError**)error; +#endif @end @interface GCHistory () diff --git a/GitUpKit/Core/GCRepository+HEAD-Tests.m b/GitUpKit/Core/GCRepository+HEAD-Tests.m index fdfcc211..4fc65008 100644 --- a/GitUpKit/Core/GCRepository+HEAD-Tests.m +++ b/GitUpKit/Core/GCRepository+HEAD-Tests.m @@ -21,6 +21,30 @@ #import #import +static NSString* _CommitSignature(GCCommit* commit) { + git_buf buffer = {0}; + int status = git_commit_header_field(&buffer, commit.private, "gpgsig"); + if (status != GIT_OK) { + git_buf_free(&buffer); + return nil; + } + NSString* signature = [[NSString alloc] initWithBytes:buffer.ptr length:buffer.size encoding:NSUTF8StringEncoding]; + git_buf_free(&buffer); + return signature; +} + +static BOOL _ConfigureSSHSigningWithKeyPath(GCRepository* repository, NSString* keyPath) { + return [repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL] && + [repository writeConfigOptionForLevel:kGCConfigLevel_Local + variable:@"gpg.format" + withValue:@"ssh" + error:NULL] && + [repository writeConfigOptionForLevel:kGCConfigLevel_Local + variable:@"user.signingkey" + withValue:keyPath + error:NULL]; +} + @implementation GCEmptyRepositoryTests (GCRepository_HEAD) - (void)testUnbornHEAD { @@ -111,6 +135,39 @@ - (void)testHEAD { XCTAssertFalse([self.repository checkClean:0 error:NULL]); } +- (void)testSSHSignsUserFacingCommits { + NSString* keyPath = [self.temporaryPath stringByAppendingPathComponent:@"signing_key"]; + GCTask* keygen = [[GCTask alloc] initWithExecutablePath:@"/usr/bin/ssh-keygen"]; + int status; + NSArray* keygenArguments = @[ @"-t", @"ed25519", @"-f", keyPath, @"-N", @"", @"-q" ]; + BOOL keygenSuccess = [keygen runWithArguments:keygenArguments stdin:nil stdout:NULL stderr:NULL exitStatus:&status error:NULL]; + XCTAssertTrue(keygenSuccess); + XCTAssertEqual(status, 0); + XCTAssertTrue(_ConfigureSSHSigningWithKeyPath(self.repository, keyPath)); + + GCCommit* emptyCommit = [self.repository createCommitFromHEADWithMessage:@"Signed empty" error:NULL]; + XCTAssertNotNil(emptyCommit); + XCTAssertTrue([_CommitSignature(emptyCommit) containsString:@"BEGIN SSH SIGNATURE"]); + + GCCommit* mergeCommit = [self.repository createCommitFromHEADAndOtherParent:self.commitA withMessage:@"Signed merge" error:NULL]; + XCTAssertNotNil(mergeCommit); + XCTAssertTrue([_CommitSignature(mergeCommit) containsString:@"BEGIN SSH SIGNATURE"]); + + [self updateFileAtPath:@"hello_world.txt" withString:@"SIGNED AMEND\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"hello_world.txt" error:NULL]); + GCCommit* amendCommit = [self.repository createCommitByAmendingHEADWithMessage:@"Signed amend" error:NULL]; + XCTAssertNotNil(amendCommit); + XCTAssertTrue([_CommitSignature(amendCommit) containsString:@"BEGIN SSH SIGNATURE"]); + + NSString* publicKey = [NSString stringWithContentsOfFile:[keyPath stringByAppendingString:@".pub"] encoding:NSUTF8StringEncoding error:NULL]; + NSString* allowedSignersPath = [self.temporaryPath stringByAppendingPathComponent:@"allowed_signers"]; + NSString* allowedSigners = [NSString stringWithFormat:@"bot@example.com %@", publicKey]; + XCTAssertTrue([allowedSigners writeToFile:allowedSignersPath atomically:YES encoding:NSUTF8StringEncoding error:NULL]); + NSString* allowedSignersConfig = [NSString stringWithFormat:@"gpg.ssh.allowedSignersFile=%@", allowedSignersPath]; + NSString* verifyOutput = [self runGitCLTWithRepository:self.repository command:@"-c", allowedSignersConfig, @"verify-commit", amendCommit.SHA1, nil]; + XCTAssertNotNil(verifyOutput); +} + - (void)testCheckoutFileToWorkingDirectory { // Working directory should have content from commit3 (master) [self assertContentsOfFileAtPath:@"hello_world.txt" equalsString:@"Hola Mundo!\n"]; diff --git a/GitUpKit/Core/GCRepository+HEAD.m b/GitUpKit/Core/GCRepository+HEAD.m index 02896a46..1c2288ef 100644 --- a/GitUpKit/Core/GCRepository+HEAD.m +++ b/GitUpKit/Core/GCRepository+HEAD.m @@ -19,6 +19,20 @@ #import "GCPrivate.h" +static GCCommit* _CreateCommitFromIndex(GCRepository* repository, git_index* index, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error) { + GCCommit* commit = nil; + git_tree* tree = NULL; + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, repository.private); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, repository.private, &oid); + commit = GCCreateCommitFromTreeWithOptionalSignature(repository, tree, parents, count, author, message, error); + +cleanup: + git_tree_free(tree); + return commit; +} + @implementation GCRepository (HEAD) #pragma mark - HEAD Manipulation @@ -98,6 +112,7 @@ - (GCCommit*)createCommitFromHEADAndOtherParent:(GCCommit*)parent withMessage:(N git_reference* headReference = NULL; git_commit* headCommit = NULL; git_index* index = NULL; + const git_commit* parents[2] = {NULL, NULL}; NSString* reflogMessage; int status = git_repository_head(&headReference, self.private); // Returns a direct reference or GIT_EUNBORNBRANCH @@ -115,8 +130,9 @@ - (GCCommit*)createCommitFromHEADAndOtherParent:(GCCommit*)parent withMessage:(N goto cleanup; } - const git_commit* parents[2] = {headCommit, parent.private}; - commit = [self createCommitFromIndex:index withParents:parents count:(headCommit ? (parent ? 2 : 1) : 0)author:NULL message:message error:error]; + parents[0] = headCommit; + parents[1] = parent.private; + commit = _CreateCommitFromIndex(self, index, parents, (headCommit ? (parent ? 2 : 1) : 0), NULL, message, error); if (commit == nil) { goto cleanup; } @@ -162,7 +178,7 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE goto cleanup; } - commit = [self createCommitFromCommit:headCommit withIndex:index updatedMessage:message updatedParents:nil updateCommitter:YES error:error]; + commit = GCCreateCommitFromCommitWithIndexAndOptionalSignature(self, headCommit, index, message, error); if (commit == nil) { goto cleanup; } diff --git a/GitUpKit/GitUpKit.xcodeproj/project.pbxproj b/GitUpKit/GitUpKit.xcodeproj/project.pbxproj index 36820fd7..362a56cb 100644 --- a/GitUpKit/GitUpKit.xcodeproj/project.pbxproj +++ b/GitUpKit/GitUpKit.xcodeproj/project.pbxproj @@ -105,6 +105,8 @@ E21753671B91634C00BE234A /* GCCommit.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338DE19F85C8600063D95 /* GCCommit.m */; }; E21753681B91634C00BE234A /* GCCommitDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = E2FEED451AEAA6AD00CBED80 /* GCCommitDatabase.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; E21753691B91634C00BE234A /* GCDiff.m in Sources */ = {isa = PBXBuildFile; fileRef = E2B14B5D1A8A764400003E64 /* GCDiff.m */; }; + E217536E1B91634C00BE234A /* GCCommitSigning.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753651B91634C00BE234A /* GCCommitSigning.m */; }; + E21753851B91635800BE234A /* GCCommitSigning-Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753861B91635800BE234A /* GCCommitSigning-Tests.m */; }; E217536A1B91634C00BE234A /* GCFoundation.m in Sources */ = {isa = PBXBuildFile; fileRef = E2790D411ACB1B1100965A98 /* GCFoundation.m */; }; E217536B1B91634C00BE234A /* GCFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = E21739F21A4FE39E00EC6777 /* GCFunctions.m */; }; E217536C1B91634C00BE234A /* GCHistory.m in Sources */ = {isa = PBXBuildFile; fileRef = E20F10F21A043E2100076AAC /* GCHistory.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; @@ -185,6 +187,7 @@ E267E1D21B84D83100BAB377 /* GCCommit.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338DE19F85C8600063D95 /* GCCommit.m */; }; E267E1D31B84D83100BAB377 /* GCCommitDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = E2FEED451AEAA6AD00CBED80 /* GCCommitDatabase.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; E267E1D41B84D83100BAB377 /* GCDiff.m in Sources */ = {isa = PBXBuildFile; fileRef = E2B14B5D1A8A764400003E64 /* GCDiff.m */; }; + E267E1DE1B84D83100BAB377 /* GCCommitSigning.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753651B91634C00BE234A /* GCCommitSigning.m */; }; E267E1D51B84D83100BAB377 /* GCFoundation.m in Sources */ = {isa = PBXBuildFile; fileRef = E2790D411ACB1B1100965A98 /* GCFoundation.m */; }; E267E1D61B84D83100BAB377 /* GCFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = E21739F21A4FE39E00EC6777 /* GCFunctions.m */; }; E267E1D71B84D83100BAB377 /* GCHistory.m in Sources */ = {isa = PBXBuildFile; fileRef = E20F10F21A043E2100076AAC /* GCHistory.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; @@ -348,6 +351,7 @@ E2C338F019F85C8600063D95 /* GCReference.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E119F85C8600063D95 /* GCReference.m */; }; E2C338F219F85C8600063D95 /* GCRemote.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E319F85C8600063D95 /* GCRemote.m */; }; E2C338F419F85C8600063D95 /* GCRepository.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E519F85C8600063D95 /* GCRepository.m */; }; + E2C338F519F85C8600063D95 /* GCCommitSigning.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753651B91634C00BE234A /* GCCommitSigning.m */; }; E2C338F619F85C8600063D95 /* GCStash.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E719F85C8600063D95 /* GCStash.m */; }; E2C338F819F85C8600063D95 /* GCTag.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E919F85C8600063D95 /* GCTag.m */; }; E2C3AA5C19FF0B0600BA89F3 /* GCRepository+Bare.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C3AA5A19FF0B0600BA89F3 /* GCRepository+Bare.m */; }; @@ -408,6 +412,8 @@ E2146C911A58D83100F4550B /* GCReflogMessages.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GCReflogMessages.h; sourceTree = ""; }; E21739F11A4FE39E00EC6777 /* GCFunctions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCFunctions.h; sourceTree = ""; }; E21739F21A4FE39E00EC6777 /* GCFunctions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCFunctions.m; sourceTree = ""; }; + E21753651B91634C00BE234A /* GCCommitSigning.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCCommitSigning.m; sourceTree = ""; }; + E21753861B91635800BE234A /* GCCommitSigning-Tests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "GCCommitSigning-Tests.m"; sourceTree = ""; }; E21739FC1A51FA6200EC6777 /* GCSubmodule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCSubmodule.h; sourceTree = ""; }; E21739FD1A51FA6200EC6777 /* GCSubmodule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCSubmodule.m; sourceTree = ""; }; E217531B1B91613300BE234A /* GitUpKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GitUpKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -831,6 +837,8 @@ E259C2E41A6624DC0079616B /* GCCommit-Tests.m */, E2C338DD19F85C8600063D95 /* GCCommit.h */, E2C338DE19F85C8600063D95 /* GCCommit.m */, + E21753651B91634C00BE234A /* GCCommitSigning.m */, + E21753861B91635800BE234A /* GCCommitSigning-Tests.m */, E2FEED441AEAA6AD00CBED80 /* GCCommitDatabase.h */, E2FEED451AEAA6AD00CBED80 /* GCCommitDatabase.m */, E2FEED481AEAA6B500CBED80 /* GCCommitDatabase-Tests.m */, @@ -1266,6 +1274,7 @@ E21753671B91634C00BE234A /* GCCommit.m in Sources */, E21753681B91634C00BE234A /* GCCommitDatabase.m in Sources */, E21753691B91634C00BE234A /* GCDiff.m in Sources */, + E217536E1B91634C00BE234A /* GCCommitSigning.m in Sources */, E217536A1B91634C00BE234A /* GCFoundation.m in Sources */, E217536B1B91634C00BE234A /* GCFunctions.m in Sources */, E217536C1B91634C00BE234A /* GCHistory.m in Sources */, @@ -1312,6 +1321,7 @@ E267E1D21B84D83100BAB377 /* GCCommit.m in Sources */, E267E1D31B84D83100BAB377 /* GCCommitDatabase.m in Sources */, E267E1D41B84D83100BAB377 /* GCDiff.m in Sources */, + E267E1DE1B84D83100BAB377 /* GCCommitSigning.m in Sources */, E267E1D51B84D83100BAB377 /* GCFoundation.m in Sources */, E267E1D61B84D83100BAB377 /* GCFunctions.m in Sources */, E267E1D71B84D83100BAB377 /* GCHistory.m in Sources */, @@ -1406,6 +1416,7 @@ E2790D4A1ACF12E200965A98 /* GCRepository+Index.m in Sources */, DC040FC52BC9FECC00DF54D5 /* GCLiveRepository-Tests.m in Sources */, E27E43021A74A94700D04ED1 /* GIGraph-Tests.m in Sources */, + E2C338F519F85C8600063D95 /* GCCommitSigning.m in Sources */, E259C2C71A64C9980079616B /* GCRepository-Tests.m in Sources */, E24509031A9A50F3003E602D /* GCRepository+Config-Tests.m in Sources */, E2B1BF361A85C5ED00A999DF /* GIFunctions-Tests.m in Sources */, @@ -1423,6 +1434,7 @@ E218A58F1A56706600DFF1DF /* GCRepository+Utilities.m in Sources */, E2C338F019F85C8600063D95 /* GCReference.m in Sources */, E259C2DD1A64FDD00079616B /* GCRepository+HEAD-Tests.m in Sources */, + E21753851B91635800BE234A /* GCCommitSigning-Tests.m in Sources */, E259C2C51A64C8EA0079616B /* GCTestCase.m in Sources */, E259C2E51A6624DC0079616B /* GCCommit-Tests.m in Sources */, E299D00C1A71F0E9005035F7 /* GCSQLiteRepository.m in Sources */,