Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,18 @@ 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 <alias> https://github.com/OWNER/REPO.git
gitw login --as <alias> --name "Your Name" --email you@example.com https://github.com/OWNER/REPO.git
```

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
Expand Down Expand Up @@ -207,10 +211,9 @@ sequenceDiagram
participant A as gitw-askpass

U->>W: gitw <git args>
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 <tempdir>/askpass.sock
Note over W,B: Generate random nonce
W->>G: exec git with env:
Expand All @@ -219,6 +222,7 @@ sequenceDiagram
Note over W,G: GITW_NONCE=<random>
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"
Expand Down
22 changes: 14 additions & 8 deletions Sources/GitwCore/GitRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)")
}
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand All @@ -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
}

Expand Down
41 changes: 27 additions & 14 deletions Sources/GitwCore/GitwApp.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
}
}

Expand All @@ -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

Expand All @@ -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 <name>")
}
guard let email, !email.isEmpty else {
throw GitwError.usage("login requires --email <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
}
}
Expand Down
60 changes: 46 additions & 14 deletions Sources/GitwCore/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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
]
Expand All @@ -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 {
Expand Down
37 changes: 26 additions & 11 deletions Sources/gitw/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ private func usage() -> String {
gitw - secure Git wrapper (GitHub HTTPS + Keychain)

Usage:
gitw login --as <alias> <https://github.com/owner/repo.git>
gitw login --as <alias> --name <name> --email <email> <https://github.com/owner/repo.git>
gitw logout --as <alias>
gitw whoami --as <alias>
gitw <git-args...> --as <alias>
Expand Down Expand Up @@ -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 <name>\n\n" + usage())
}
guard let email, !email.isEmpty else {
throw GitwError.usage("login requires --email <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)
Expand Down
Loading
Loading