diff --git a/README.md b/README.md index 33247b5..bd413f8 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ What they cover (high level): `login` **verifies** the token by running `git ls-remote` via the broker and **only stores on success**. ```bash -gitw login --as https://github.com/OWNER/REPO.git +gitw login --as --name "Your Name" --email you@example.com https://github.com/OWNER/REPO.git ``` You’ll be prompted for: @@ -142,6 +142,10 @@ You’ll be prompted for: - GitHub username - GitHub Personal Access Token (PAT) +The name/email are passed to Git on every invocation as: +- `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL` +- `GIT_COMMITTER_NAME`, `GIT_COMMITTER_EMAIL` + ### 2) Use `gitw` like `git` ```bash @@ -207,10 +211,9 @@ sequenceDiagram participant A as gitw-askpass U->>W: gitw - W->>KC: Load username + token (Keychain) + W->>KC: Load profile (github username + token + name + email) W->>B: Start broker - Note over W,B: Create temp dir (0700) under FileManager.temporaryDirectory - Note over W,B: (typically /var/folders/.../T/ on macOS) + Note over W,B: Create temp dir (0700) under /tmp Note over W,B: Create UDS socket at /askpass.sock Note over W,B: Generate random nonce W->>G: exec git with env: @@ -219,6 +222,7 @@ sequenceDiagram Note over W,G: GITW_NONCE= Note over W,G: GIT_TERMINAL_PROMPT=0 Note over W,G: credential.helper disabled via GIT_CONFIG_COUNT + Note over W,G: GIT_AUTHOR_* and GIT_COMMITTER_* set from Keychain profile G->>A: invoke askpass("Username for https://github.com") A->>B: connect to UDS + send nonce + request "username" diff --git a/Sources/GitwCore/GitRunner.swift b/Sources/GitwCore/GitRunner.swift index 658c268..b66bcca 100644 --- a/Sources/GitwCore/GitRunner.swift +++ b/Sources/GitwCore/GitRunner.swift @@ -37,7 +37,7 @@ public enum GitRunner { return GitEnvironment(gitPath: systemGit, requirementUsed: req) } - public static func runGit(args: [String], askpassPath: String, creds: GitHubCredentials?) throws -> Int32 { + public static func runGit(args: [String], askpassPath: String, profile: GitwProfile) throws -> Int32 { // Always validate arguments we can see. try URLPolicy.validateGitArguments(args) @@ -49,11 +49,12 @@ public enum GitRunner { try validateRepoRemotes(gitPath: gitEnv.gitPath, prefixArgs: extractGitPrefixArgs(args)) } - var env = buildGitEnvironment(base: ProcessInfo.processInfo.environment) + var env = buildGitEnvironment(base: ProcessInfo.processInfo.environment, profile: profile) var broker: AskpassBroker? var tmpDir: URL? - if let creds { + let creds = profile.creds + guard FileManager.default.isExecutableFile(atPath: askpassPath) else { throw GitwError.io("askpass helper not found or not executable at: \(askpassPath)") } @@ -83,10 +84,8 @@ public enum GitRunner { env = buildGitEnvironment(base: env, askpassPath: askpassPath, brokerSocket: sock, - brokerNonce: nonce) - } else { - // No credentials. Askpass is not set; git will fail closed if it prompts. - } + brokerNonce: nonce, + profile: profile) defer { broker?.close() @@ -113,7 +112,8 @@ public enum GitRunner { public static func buildGitEnvironment(base: [String: String], askpassPath: String? = nil, brokerSocket: String? = nil, - brokerNonce: String? = nil) -> [String: String] { + brokerNonce: String? = nil, + profile: GitwProfile) -> [String: String] { var env = base // Fail closed: never allow terminal prompting. @@ -139,6 +139,12 @@ public enum GitRunner { env["GITW_NONCE"] = brokerNonce } + // Force commit identity from Keychain profile for every invocation. + env["GIT_AUTHOR_NAME"] = profile.gitName + env["GIT_AUTHOR_EMAIL"] = profile.gitEmail + env["GIT_COMMITTER_NAME"] = profile.gitName + env["GIT_COMMITTER_EMAIL"] = profile.gitEmail + return env } diff --git a/Sources/GitwCore/GitwApp.swift b/Sources/GitwCore/GitwApp.swift index 60384ca..f100588 100644 --- a/Sources/GitwCore/GitwApp.swift +++ b/Sources/GitwCore/GitwApp.swift @@ -1,24 +1,24 @@ import Foundation public protocol KeychainProviding { - func load(alias: String) throws -> GitHubCredentials? - func save(alias: String, creds: GitHubCredentials) throws + func load(alias: String) throws -> GitwProfile? + func save(alias: String, profile: GitwProfile) throws func delete(alias: String) throws } public protocol GitRunning { - func runGit(args: [String], askpassPath: String, creds: GitHubCredentials?) throws -> Int32 + func runGit(args: [String], askpassPath: String, profile: GitwProfile) throws -> Int32 } public struct RealKeychainProvider: KeychainProviding { public init() {} - public func load(alias: String) throws -> GitHubCredentials? { + public func load(alias: String) throws -> GitwProfile? { try KeychainStore.load(alias: alias) } - public func save(alias: String, creds: GitHubCredentials) throws { - try KeychainStore.save(alias: alias, creds: creds) + public func save(alias: String, profile: GitwProfile) throws { + try KeychainStore.save(alias: alias, profile: profile) } public func delete(alias: String) throws { @@ -29,8 +29,8 @@ public struct RealKeychainProvider: KeychainProviding { public struct RealGitRunner: GitRunning { public init() {} - public func runGit(args: [String], askpassPath: String, creds: GitHubCredentials?) throws -> Int32 { - try GitRunner.runGit(args: args, askpassPath: askpassPath, creds: creds) + public func runGit(args: [String], askpassPath: String, profile: GitwProfile) throws -> Int32 { + try GitRunner.runGit(args: args, askpassPath: askpassPath, profile: profile) } } @@ -54,11 +54,13 @@ public struct GitwApp { public func run(_ cmd: GitwCommand, ttyReadLine: (String) throws -> String, - ttyReadSecret: (String) throws -> String) throws -> Int32 { + ttyReadSecret: (String) throws -> String, + name: String? = nil, + email: String? = nil) throws -> Int32 { switch cmd { case .whoami(let alias): guard (try keychain.load(alias: alias)) != nil else { - throw GitwError.io("No GitHub credentials in Keychain for alias \(alias).") + throw GitwError.io("No profile in Keychain for alias \(alias).") } return 0 @@ -69,25 +71,36 @@ public struct GitwApp { case .login(let alias, let repoURL): try URLPolicy.validateGitArguments([repoURL]) + guard let name, !name.isEmpty else { + throw GitwError.usage("login requires --name ") + } + guard let email, !email.isEmpty else { + throw GitwError.usage("login requires --email ") + } + let username = try ttyReadLine("GitHub username: ") let token = try ttyReadSecret("GitHub personal access token: ") + let profile = GitwProfile(githubUsername: username, token: token, gitName: name, gitEmail: email) + // Verify using ls-remote via askpass broker (without storing unless it works). let status = try git.runGit( args: ["ls-remote", repoURL], askpassPath: askpassPath(), - creds: GitHubCredentials(username: username, token: token) + profile: profile ) guard status == 0 else { throw GitwError.io("Login check failed (git exit \(status)). Not saved.") } - try keychain.save(alias: alias, creds: GitHubCredentials(username: username, token: token)) + try keychain.save(alias: alias, profile: profile) return 0 case .git(let alias, let args): - let creds = try keychain.load(alias: alias) - let status = try git.runGit(args: args, askpassPath: askpassPath(), creds: creds) + guard let profile = try keychain.load(alias: alias) else { + throw GitwError.io("No profile in Keychain for alias \(alias).") + } + let status = try git.runGit(args: args, askpassPath: askpassPath(), profile: profile) return status } } diff --git a/Sources/GitwCore/Keychain.swift b/Sources/GitwCore/Keychain.swift index 3c5a2dc..e700998 100644 --- a/Sources/GitwCore/Keychain.swift +++ b/Sources/GitwCore/Keychain.swift @@ -12,6 +12,23 @@ public struct GitHubCredentials: Sendable { } } +/// Full per-alias profile stored in Keychain. +public struct GitwProfile: Sendable, Codable, Equatable { + public let githubUsername: String + public let token: String + public let gitName: String + public let gitEmail: String + + public init(githubUsername: String, token: String, gitName: String, gitEmail: String) { + self.githubUsername = githubUsername + self.token = token + self.gitName = gitName + self.gitEmail = gitEmail + } + + public var creds: GitHubCredentials { .init(username: githubUsername, token: token) } +} + public enum KeychainStore { // We deliberately keep this fixed; gitw only supports GitHub HTTPS. public static let server = "github.com" @@ -20,7 +37,7 @@ public enum KeychainStore { /// Load credentials for a given alias. /// /// - alias: Local selector key. Not necessarily the GitHub username. - public static func load(alias: String) throws -> GitHubCredentials? { + public static func load(alias: String) throws -> GitwProfile? { let query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrServer as String: server, @@ -42,31 +59,45 @@ public enum KeychainStore { let dict = item as? [String: Any], let accountAlias = dict[kSecAttrAccount as String] as? String, let data = dict[kSecValueData as String] as? Data, - let token = String(data: data, encoding: .utf8) + let _ = String(data: data, encoding: .utf8) else { throw GitwError.keychain("unexpected keychain item shape") } - // Option B (true alias): store the actual GitHub username in kSecAttrComment. - // If absent (older installs), fall back to using the account as username. - let username = (dict[kSecAttrComment as String] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let effectiveUser = (username?.isEmpty == false) ? username! : accountAlias + // Primary storage: JSON in kSecAttrGeneric. + if let generic = dict[kSecAttrGeneric as String] as? Data { + do { + let p = try JSONDecoder().decode(GitwProfile.self, from: generic) + return p + } catch { + throw GitwError.keychain("failed to decode profile JSON: \(error)") + } + } - return GitHubCredentials(username: effectiveUser, token: token) + // We expect profile JSON for all entries in this design. + // If it's missing, fail closed. + let username = (dict[kSecAttrComment as String] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let hint = (username?.isEmpty == false) ? username! : accountAlias + throw GitwError.keychain("profile for alias \(accountAlias) is missing profile metadata (expected JSON). Please re-run login for this alias (github username was \(hint)).") } /// Save credentials under a local alias. - public static func save(alias: String, creds: GitHubCredentials) throws { - let data = Data(creds.token.utf8) + public static func save(alias: String, profile: GitwProfile) throws { + let tokenData = Data(profile.token.utf8) + let generic = try JSONEncoder().encode(profile) + let attrs: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrServer as String: server, kSecAttrProtocol as String: kSecAttrProtocolHTTPS, // Account is the selector alias. kSecAttrAccount as String: alias, - // Store actual GitHub username separately. - kSecAttrComment as String: creds.username, - kSecValueData as String: data, + // Store actual GitHub username separately (also kept for human inspection). + kSecAttrComment as String: profile.githubUsername, + // Store full profile JSON. + kSecAttrGeneric as String: generic, + // Secret token stays as value data. + kSecValueData as String: tokenData, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, kSecAttrLabel as String: service ] @@ -81,8 +112,9 @@ public enum KeychainStore { kSecAttrLabel as String: service ] let update: [String: Any] = [ - kSecAttrComment as String: creds.username, - kSecValueData as String: data + kSecAttrComment as String: profile.githubUsername, + kSecAttrGeneric as String: generic, + kSecValueData as String: tokenData ] let s2 = SecItemUpdate(query as CFDictionary, update as CFDictionary) guard s2 == errSecSuccess else { diff --git a/Sources/gitw/main.swift b/Sources/gitw/main.swift index c06744f..f927071 100644 --- a/Sources/gitw/main.swift +++ b/Sources/gitw/main.swift @@ -6,7 +6,7 @@ private func usage() -> String { gitw - secure Git wrapper (GitHub HTTPS + Keychain) Usage: - gitw login --as + gitw login --as --name --email gitw logout --as gitw whoami --as gitw --as @@ -78,29 +78,44 @@ do { case "-h", "--help", "help": throw GitwError.usage(usage()) case "whoami": - let creds = try keychain.load(alias: alias) - guard let creds else { - die("No GitHub credentials in Keychain for alias \(alias).", code: 1) + let profile = try keychain.load(alias: alias) + guard let profile else { + die("No profile in Keychain for alias \(alias).", code: 1) } - print(creds.username) + print(profile.githubUsername) + print("name=\(profile.gitName)") + print("email=\(profile.gitEmail)") exit(0) case "logout": _ = try app.run(.logout(alias: alias), ttyReadLine: TTY.readLine(prompt:), ttyReadSecret: TTY.readSecret(prompt:)) print("Deleted GitHub credentials for alias \(alias) (\(KeychainStore.server)) from Keychain.") exit(0) case "login": + // login requires a repo URL plus identity fields stored in Keychain. + let name = popFlag("--name") + let email = popFlag("--email") + + guard let name, !name.isEmpty else { + throw GitwError.usage("login requires --name \n\n" + usage()) + } + guard let email, !email.isEmpty else { + throw GitwError.usage("login requires --email \n\n" + usage()) + } guard args.count >= 2 else { throw GitwError.usage("login requires a GitHub HTTPS repo URL\n\n" + usage()) } let repoURL = args[1] - // We keep the prompts + verification inside GitwApp, but printing stays here. - let status = try app.run(.login(alias: alias, repoURL: repoURL), ttyReadLine: TTY.readLine(prompt:), ttyReadSecret: TTY.readSecret(prompt:)) + + let status = try app.run(.login(alias: alias, repoURL: repoURL), + ttyReadLine: TTY.readLine(prompt:), + ttyReadSecret: TTY.readSecret(prompt:), + name: name, + email: email) if status == 0 { - // Re-load to show the effective username stored for this alias. - if let c = try keychain.load(alias: alias) { - print("Credentials stored in Keychain for alias \(alias) (user \(c.username), \(KeychainStore.server)).") + if let p = try keychain.load(alias: alias) { + print("Profile stored in Keychain for alias \(alias) (github=\(p.githubUsername), name=\(p.gitName), email=\(p.gitEmail)).") } else { - print("Credentials stored in Keychain for alias \(alias) (\(KeychainStore.server)).") + print("Profile stored in Keychain for alias \(alias).") } } exit(status) diff --git a/Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift b/Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift index 51663ae..3fcd5be 100644 --- a/Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift +++ b/Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift @@ -12,7 +12,7 @@ struct GitEnvironmentBuilderTests { "GIT_SSH_COMMAND": "evil" ] - let env = GitRunner.buildGitEnvironment(base: base) + let env = GitRunner.buildGitEnvironment(base: base, profile: GitwProfile(githubUsername: "u", token: "t", gitName: "n", gitEmail: "e@example.com")) #expect(env["GIT_TERMINAL_PROMPT"] == "0") #expect(env["GIT_CONFIG_COUNT"] == "2") @@ -27,16 +27,28 @@ struct GitEnvironmentBuilderTests { @Test func askpassAndBrokerVarsAreSetWhenProvided() { + let id = String(UUID().uuidString.prefix(8)) + let askpass = "/usr/local/bin/gitw-askpass" + let sock = "/tmp/sock-\(id)" + let nonce = UUID().uuidString + let name = "N-\(id)" + let email = "e-\(id)@example.com" + let env = GitRunner.buildGitEnvironment( base: [:], - askpassPath: "/usr/local/bin/gitw-askpass", - brokerSocket: "/tmp/sock", - brokerNonce: "nonce" + askpassPath: askpass, + brokerSocket: sock, + brokerNonce: nonce, + profile: GitwProfile(githubUsername: "u-\(id)", token: "t-\(id)", gitName: name, gitEmail: email) ) - #expect(env["GIT_ASKPASS"] == "/usr/local/bin/gitw-askpass") - #expect(env["SSH_ASKPASS"] == "/usr/local/bin/gitw-askpass") - #expect(env["GITW_SOCKET"] == "/tmp/sock") - #expect(env["GITW_NONCE"] == "nonce") + #expect(env["GIT_ASKPASS"] == askpass) + #expect(env["SSH_ASKPASS"] == askpass) + #expect(env["GITW_SOCKET"] == sock) + #expect(env["GITW_NONCE"] == nonce) + #expect(env["GIT_AUTHOR_NAME"] == name) + #expect(env["GIT_AUTHOR_EMAIL"] == email) + #expect(env["GIT_COMMITTER_NAME"] == name) + #expect(env["GIT_COMMITTER_EMAIL"] == email) } } diff --git a/Tests/GitwCoreTests/GitwAppTests.swift b/Tests/GitwCoreTests/GitwAppTests.swift index 7a0babb..19bf7d5 100644 --- a/Tests/GitwCoreTests/GitwAppTests.swift +++ b/Tests/GitwCoreTests/GitwAppTests.swift @@ -5,22 +5,22 @@ import Testing final class MockKeychain: KeychainProviding { var loadedAliases: [String] = [] var deletedAliases: [String] = [] - var saved: [(alias: String, creds: GitHubCredentials)] = [] - var credsByAlias: [String: GitHubCredentials] = [:] + var saved: [(alias: String, profile: GitwProfile)] = [] + var profileByAlias: [String: GitwProfile] = [:] - func load(alias: String) throws -> GitHubCredentials? { + func load(alias: String) throws -> GitwProfile? { loadedAliases.append(alias) - return credsByAlias[alias] + return profileByAlias[alias] } - func save(alias: String, creds: GitHubCredentials) throws { - saved.append((alias, creds)) - credsByAlias[alias] = creds + func save(alias: String, profile: GitwProfile) throws { + saved.append((alias, profile)) + profileByAlias[alias] = profile } func delete(alias: String) throws { deletedAliases.append(alias) - credsByAlias.removeValue(forKey: alias) + profileByAlias.removeValue(forKey: alias) } } @@ -28,15 +28,22 @@ final class MockGit: GitRunning { struct Call: Equatable { let args: [String] let askpassPath: String - let username: String? + let githubUsername: String? let token: String? + let name: String? + let email: String? } var calls: [Call] = [] var nextStatus: Int32 = 0 - func runGit(args: [String], askpassPath: String, creds: GitHubCredentials?) throws -> Int32 { - calls.append(.init(args: args, askpassPath: askpassPath, username: creds?.username, token: creds?.token)) + func runGit(args: [String], askpassPath: String, profile: GitwProfile) throws -> Int32 { + calls.append(.init(args: args, + askpassPath: askpassPath, + githubUsername: profile.githubUsername, + token: profile.token, + name: profile.gitName, + email: profile.gitEmail)) return nextStatus } } @@ -53,16 +60,20 @@ struct GitwAppTests { let status = try app.run( .login(alias: "work", repoURL: "https://github.com/OWNER/REPO.git"), ttyReadLine: { _ in "real-user" }, - ttyReadSecret: { _ in "tok" } + ttyReadSecret: { _ in "tok" }, + name: "Real Name", + email: "real@example.com" ) #expect(status == 0) #expect(git.calls.count == 1) #expect(git.calls[0].args == ["ls-remote", "https://github.com/OWNER/REPO.git"]) - #expect(git.calls[0].username == "real-user") + #expect(git.calls[0].githubUsername == "real-user") + #expect(git.calls[0].name == "Real Name") + #expect(git.calls[0].email == "real@example.com") #expect(kc.saved.count == 1) #expect(kc.saved[0].alias == "work") - #expect(kc.saved[0].creds.username == "real-user") + #expect(kc.saved[0].profile.githubUsername == "real-user") } @Test @@ -77,7 +88,9 @@ struct GitwAppTests { _ = try app.run( .login(alias: "work", repoURL: "https://github.com/OWNER/REPO.git"), ttyReadLine: { _ in "real-user" }, - ttyReadSecret: { _ in "tok" } + ttyReadSecret: { _ in "tok" }, + name: "Real Name", + email: "real@example.com" ) Issue.record("Expected login to fail") } catch { @@ -90,7 +103,7 @@ struct GitwAppTests { @Test func gitCommandLoadsByAliasAndPassesCreds() throws { let kc = MockKeychain() - kc.credsByAlias["work"] = GitHubCredentials(username: "u", token: "t") + kc.profileByAlias["work"] = GitwProfile(githubUsername: "u", token: "t", gitName: "N", gitEmail: "e@example.com") let git = MockGit() let app = GitwApp(keychain: kc, git: git, askpassPath: { "/tmp/gitw-askpass" }) @@ -104,6 +117,6 @@ struct GitwAppTests { #expect(kc.loadedAliases == ["work"]) #expect(git.calls.count == 1) #expect(git.calls[0].args == ["status"]) - #expect(git.calls[0].username == "u") + #expect(git.calls[0].githubUsername == "u") } } diff --git a/Tests/GitwCoreTests/IntegrityChecksTests.swift b/Tests/GitwCoreTests/IntegrityChecksTests.swift index 5551d28..9c48ffb 100644 --- a/Tests/GitwCoreTests/IntegrityChecksTests.swift +++ b/Tests/GitwCoreTests/IntegrityChecksTests.swift @@ -26,10 +26,10 @@ struct IntegrityChecksTests { contents: Data("#!/bin/sh\necho nope\n".utf8)) _ = chmod(fakeAskpass, 0o755) - let creds = GitHubCredentials(username: "u", token: "t") + let profile = GitwProfile(githubUsername: "u", token: "t", gitName: "N", gitEmail: "e@example.com") do { - _ = try GitRunner.runGit(args: ["--version"], askpassPath: fakeAskpass, creds: creds) + _ = try GitRunner.runGit(args: ["--version"], askpassPath: fakeAskpass, profile: profile) Issue.record("Expected runGit to fail due to askpass hash mismatch") } catch let e as GitwError { switch e { diff --git a/Tests/GitwCoreTests/KeychainStoreTests.swift b/Tests/GitwCoreTests/KeychainStoreTests.swift index e8ad6b5..8d5164b 100644 --- a/Tests/GitwCoreTests/KeychainStoreTests.swift +++ b/Tests/GitwCoreTests/KeychainStoreTests.swift @@ -4,12 +4,11 @@ import Testing struct KeychainStoreTests { @Test - func loadAndDeleteAreScopedToUsername() throws { + func loadAndDeleteAreScopedToAlias() throws { // This is a compile-time / query-shape test only (no real Keychain). // We can't hit Security.framework deterministically in CI. - // Instead we ensure the API forces a username selector. + // Instead we ensure the API forces an alias selector. - // These calls should compile and require a username parameter. _ = try KeychainStore.load(alias: "work") try KeychainStore.delete(alias: "work") }