章节 ▾ 第二版

7.14 Git 工具 - 凭证存储

凭证存储

如果你使用 SSH 传输协议连接远程仓库,可以创建一个没有密码的密钥,这样你无需输入用户名和密码即可安全地传输数据。然而,HTTP 协议无法做到这一点——每次连接都需要用户名和密码。对于使用双重认证(2FA)的系统来说,这就变得更加困难,因为你用作密码的令牌通常是随机生成且难以读出的。

幸运的是,Git 提供了一套凭证系统来解决这个问题。Git 内置了几个选项:

  • 默认设置是不进行任何缓存。每次连接时,系统都会提示你输入用户名和密码。

  • “cache” 模式会将凭证存储在内存中一段时间。密码绝不会存储在磁盘上,并且会在 15 分钟后从缓存中清除。

  • “store” 模式会将凭证以明文形式保存到磁盘文件,且永不过期。这意味着在你更改 Git 主机的密码之前,你无需再次输入凭证。这种方法的缺点是密码以明文形式存储在主目录的一个普通文件中。

  • 如果你使用的是 macOS,Git 自带了“osxkeychain”模式,它会将凭证缓存到与你的系统账户关联的安全钥匙串(Keychain)中。这种方法会将凭证存储在磁盘上,且永不过期,但它们会通过存储 HTTPS 证书和 Safari 自动填充项的同一系统进行加密。

  • 如果你使用的是 Windows,在安装 Git for Windows 时可以启用 Git Credential Manager 功能,或者单独安装 最新版的 GCM 作为独立服务。这与上述的“osxkeychain”助手类似,但利用 Windows 凭证存储来控制敏感信息。它还可以为 WSL1 或 WSL2 提供凭证。更多信息请参阅 GCM 安装指南

你可以通过设置 Git 配置值来选择这些方法之一:

$ git config --global credential.helper cache

其中一些助手还提供配置选项。“store”助手可以接受 --file <path> 参数,用于自定义明文文件的保存位置(默认是 ~/.git-credentials)。“cache”助手接受 --timeout <seconds> 选项,用于更改其守护进程的运行时间(默认是 “900”,即 15 分钟)。以下是如何配置“store”助手并使用自定义文件名的示例:

$ git config --global credential.helper 'store --file ~/.my-credentials'

Git 甚至允许你配置多个助手。当为特定主机查找凭证时,Git 会按顺序查询它们,并在获得第一个答案后停止。当保存凭证时,Git 会将用户名和密码发送给列表中的所有助手,由它们决定如何处理。如果你有一个存储在 U 盘上的凭证文件,但又想在 U 盘未插入时使用内存缓存来减少输入,你的 .gitconfig 配置可能如下所示:

[credential]
    helper = store --file /mnt/thumbdrive/.git-credentials
    helper = cache --timeout 30000

幕后原理

这一切是如何工作的呢?Git 凭证助手系统的根命令是 git credential,它接受一个命令作为参数,并通过标准输入(stdin)接收更多输入。

通过一个例子可能更容易理解。假设已经配置了一个凭证助手,并且该助手已经为 mygithost 存储了凭证。以下是一个使用 “fill” 命令的会话,当 Git 尝试为某个主机查找凭证时,会调用该命令:

$ git credential fill (1)
protocol=https (2)
host=mygithost
(3)
protocol=https (4)
host=mygithost
username=bob
password=s3cre7
$ git credential fill (5)
protocol=https
host=unknownhost

Username for 'https://unknownhost': bob
Password for 'https://bob@unknownhost':
protocol=https
host=unknownhost
username=bob
password=s3cre7
  1. 这是启动交互的命令行。

  2. Git-credential 随后会等待标准输入(stdin)的输入。我们提供了已知的信息:协议和主机名。

  3. 空行表示输入结束,凭证系统应根据已知信息进行回答。

  4. 接着 Git-credential 接管任务,并将找到的信息写入标准输出(stdout)。

  5. 如果找不到凭证,Git 会要求用户输入用户名和密码,并将它们返回给调用的标准输出(此处它们被连接到同一个控制台)。

凭证系统实际上调用的是一个独立于 Git 本身的程序;具体调用哪个程序以及如何调用,取决于 credential.helper 的配置值。它可以有几种形式:

配置值 行为

foo

运行 git-credential-foo

foo -a --opt=bcd

运行 git-credential-foo -a --opt=bcd

/absolute/path/foo -xyz

运行 /absolute/path/foo -xyz

!f() { echo "password=s3cre7"; }; f

! 后的代码在 Shell 中执行

因此,上述提到的助手实际上名为 git-credential-cachegit-credential-store 等,我们可以将它们配置为接受命令行参数。其通用形式为 “git-credential-foo [args] <action>”。标准输入/输出协议与 git-credential 相同,但它们使用一套略有不同的操作:

  • get 是对用户名/密码对的请求。

  • store 是将一组凭证保存到助手内存中的请求。

  • erase 是从助手内存中清除给定属性的凭证。

对于 storeerase 操作,不需要任何响应(Git 也会忽略它)。然而,对于 get 操作,Git 非常关心助手的回答。如果助手没有有用的信息,它可以直接退出而不输出任何内容;但如果它知道,它应该用存储的信息来增强所提供的信息。输出被视为一系列赋值语句;任何提供的内容都将覆盖 Git 原有的认知。

这是与上述相同的例子,但跳过了 git-credential,直接使用 git-credential-store

$ git credential-store --file ~/git.store store (1)
protocol=https
host=mygithost
username=bob
password=s3cre7
$ git credential-store --file ~/git.store get (2)
protocol=https
host=mygithost

username=bob (3)
password=s3cre7
  1. 在这里,我们告诉 git-credential-store 保存一些凭证:当访问 https://mygithost 时,应使用用户名 “bob” 和密码 “s3cre7”。

  2. 现在我们来检索这些凭证。我们提供连接中已知的部分(https://mygithost)和一个空行。

  3. git-credential-store 回复了我们上面存储的用户名和密码。

~/git.store 文件看起来如下所示:

https://bob:s3cre7@mygithost

它只是连续的几行,每一行都包含一个带凭证的 URL。osxkeychainwincred 助手使用它们底层存储的本地格式,而 cache 使用其自身的内存格式(其他进程无法读取)。

自定义凭证缓存

鉴于 git-credential-store 及其同类程序是独立于 Git 的程序,不难看出任何程序都可以成为 Git 凭证助手。Git 提供的助手涵盖了许多常见用例,但并非全部。例如,假设你的团队有一些与全队共享的凭证(也许用于部署)。这些凭证存储在一个共享目录中,但你不想将它们复制到你自己的凭证存储中,因为它们经常更改。现有的助手无法处理这种情况;让我们看看编写自己的助手需要做些什么。这个程序需要具备几个关键特性:

  1. 我们需要关注的唯一操作是 getstoreerase 是写操作,所以当收到它们时,我们将直接安全退出。

  2. 共享凭证文件的格式与 git-credential-store 使用的格式相同。

  3. 该文件的位置通常是标准的,但为了以防万一,我们应该允许用户传递自定义路径。

我们再次使用 Ruby 编写此扩展,但只要 Git 能执行该成品,任何语言都可以。以下是我们新凭证助手的完整源代码:

#!/usr/bin/env ruby

require 'optparse'

path = File.expand_path '~/.git-credentials' # (1)
OptionParser.new do |opts|
    opts.banner = 'USAGE: git-credential-read-only [options] <action>'
    opts.on('-f', '--file PATH', 'Specify path for backing store') do |argpath|
        path = File.expand_path argpath
    end
end.parse!

exit(0) unless ARGV[0].downcase == 'get' # (2)
exit(0) unless File.exist? path

known = {} # (3)
while line = STDIN.gets
    break if line.strip == ''
    k,v = line.strip.split '=', 2
    known[k] = v
end

File.readlines(path).each do |fileline| # (4)
    prot,user,pass,host = fileline.scan(/^(.*?):\/\/(.*?):(.*?)@(.*)$/).first
    if prot == known['protocol'] and host == known['host'] and user == known['username'] then
        puts "protocol=#{prot}"
        puts "host=#{host}"
        puts "username=#{user}"
        puts "password=#{pass}"
        exit(0)
    end
end
  1. 在这里,我们解析命令行选项,允许用户指定输入文件。默认值是 ~/.git-credentials

  2. 该程序仅在操作为 get 且后端存储文件存在时才会响应。

  3. 此循环从标准输入(stdin)读取,直到遇到第一个空行。输入被存储在 known 哈希中以便后续参考。

  4. 此循环读取存储文件的内容并寻找匹配项。如果 known 中的协议、主机和用户名与此行匹配,程序将结果打印到标准输出(stdout)并退出。

我们将助手保存为 git-credential-read-only,将其放在 PATH 中的某个位置并标记为可执行。以下是交互式会话的样子:

$ git credential-read-only --file=/mnt/shared/creds get
protocol=https
host=mygithost
username=bob

protocol=https
host=mygithost
username=bob
password=s3cre7

由于其名称以 “git-” 开头,我们可以使用配置值的简单语法:

$ git config --global credential.helper 'read-only --file /mnt/shared/creds'

如你所见,扩展这个系统非常直观,可以为你和你的团队解决一些常见问题。