章节 ▾ 第二版

7.14 Git 工具 - 凭证存储

凭证存储

如果你使用 SSH 传输协议连接远程仓库,可以使用没有密码短语(passphrase)的密钥,从而在无需输入用户名和密码的情况下安全地传输数据。然而,HTTP 协议无法做到这一点——每次连接都需要用户名和密码。对于采用双重验证的系统,这种需求变得更加困难,因为你用于密码的令牌是随机生成且难以拼读的。

幸运的是,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 会要求用户输入用户名和密码,并将其回传给调用的 stdout(此处它们被挂载到同一个控制台)。

凭证系统实际上是在调用一个独立于 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 [参数] <动作>”。stdin/stdout 协议与 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'

正如你所见,扩展此系统非常简单,可以为你和你的团队解决一些常见问题。