git-authenticate.scm 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. ;;; GNU Guix --- Functional package management for GNU
  2. ;;; Copyright © 2019, 2020, 2021 Ludovic Courtès <ludo@gnu.org>
  3. ;;;
  4. ;;; This file is part of GNU Guix.
  5. ;;;
  6. ;;; GNU Guix is free software; you can redistribute it and/or modify it
  7. ;;; under the terms of the GNU General Public License as published by
  8. ;;; the Free Software Foundation; either version 3 of the License, or (at
  9. ;;; your option) any later version.
  10. ;;;
  11. ;;; GNU Guix is distributed in the hope that it will be useful, but
  12. ;;; WITHOUT ANY WARRANTY; without even the implied warranty of
  13. ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. ;;; GNU General Public License for more details.
  15. ;;;
  16. ;;; You should have received a copy of the GNU General Public License
  17. ;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
  18. (define-module (guix git-authenticate)
  19. #:use-module (git)
  20. #:autoload (gcrypt hash) (sha256)
  21. #:use-module (guix base16)
  22. #:autoload (guix base64) (base64-encode)
  23. #:use-module ((guix git)
  24. #:select (commit-difference false-if-git-not-found))
  25. #:use-module (guix i18n)
  26. #:use-module ((guix diagnostics) #:select (formatted-message))
  27. #:use-module (guix openpgp)
  28. #:use-module ((guix utils)
  29. #:select (cache-directory with-atomic-file-output))
  30. #:use-module ((guix build utils)
  31. #:select (mkdir-p))
  32. #:use-module (guix progress)
  33. #:use-module (srfi srfi-1)
  34. #:use-module (srfi srfi-11)
  35. #:use-module (srfi srfi-26)
  36. #:use-module (srfi srfi-34)
  37. #:use-module (srfi srfi-35)
  38. #:use-module (rnrs bytevectors)
  39. #:use-module (rnrs io ports)
  40. #:use-module (ice-9 match)
  41. #:autoload (ice-9 pretty-print) (pretty-print)
  42. #:export (read-authorizations
  43. commit-signing-key
  44. commit-authorized-keys
  45. authenticate-commit
  46. authenticate-commits
  47. load-keyring-from-reference
  48. previously-authenticated-commits
  49. cache-authenticated-commit
  50. repository-cache-key
  51. authenticate-repository
  52. git-authentication-error?
  53. git-authentication-error-commit
  54. unsigned-commit-error?
  55. unauthorized-commit-error?
  56. unauthorized-commit-error-signing-key
  57. signature-verification-error?
  58. signature-verification-error-keyring
  59. signature-verification-error-signature
  60. missing-key-error?
  61. missing-key-error-signature))
  62. ;;; Commentary:
  63. ;;;
  64. ;;; This module provides tools to authenticate a range of Git commits. A
  65. ;;; commit is considered "authentic" if and only if it is signed by an
  66. ;;; authorized party. Parties authorized to sign a commit are listed in the
  67. ;;; '.guix-authorizations' file of the parent commit.
  68. ;;;
  69. ;;; Code:
  70. (define-condition-type &git-authentication-error &error
  71. git-authentication-error?
  72. (commit git-authentication-error-commit))
  73. (define-condition-type &unsigned-commit-error &git-authentication-error
  74. unsigned-commit-error?)
  75. (define-condition-type &unauthorized-commit-error &git-authentication-error
  76. unauthorized-commit-error?
  77. (signing-key unauthorized-commit-error-signing-key))
  78. (define-condition-type &signature-verification-error &git-authentication-error
  79. signature-verification-error?
  80. (signature signature-verification-error-signature)
  81. (keyring signature-verification-error-keyring))
  82. (define-condition-type &missing-key-error &git-authentication-error
  83. missing-key-error?
  84. (signature missing-key-error-signature))
  85. (define* (commit-signing-key repo commit-id keyring
  86. #:key (disallowed-hash-algorithms '(sha1)))
  87. "Return the OpenPGP key that signed COMMIT-ID (an OID). Raise an exception
  88. if the commit is unsigned, has an invalid signature, has a signature using one
  89. of the hash algorithms in DISALLOWED-HASH-ALGORITHMS, or if its signing key is
  90. not in KEYRING."
  91. (let-values (((signature signed-data)
  92. (catch 'git-error
  93. (lambda ()
  94. (commit-extract-signature repo commit-id))
  95. (lambda _
  96. (values #f #f)))))
  97. (unless signature
  98. (raise (make-compound-condition
  99. (condition (&unsigned-commit-error (commit commit-id)))
  100. (formatted-message (G_ "commit ~a lacks a signature")
  101. (oid->string commit-id)))))
  102. (let ((signature (string->openpgp-packet signature)))
  103. (when (memq (openpgp-signature-hash-algorithm signature)
  104. `(,@disallowed-hash-algorithms md5))
  105. (raise (make-compound-condition
  106. (condition (&unsigned-commit-error (commit commit-id)))
  107. (formatted-message (G_ "commit ~a has a ~a signature, \
  108. which is not permitted")
  109. (oid->string commit-id)
  110. (openpgp-signature-hash-algorithm
  111. signature)))))
  112. (with-fluids ((%default-port-encoding "UTF-8"))
  113. (let-values (((status data)
  114. (verify-openpgp-signature signature keyring
  115. (open-input-string signed-data))))
  116. (match status
  117. ('bad-signature
  118. ;; There's a signature but it's invalid.
  119. (raise (make-compound-condition
  120. (condition
  121. (&signature-verification-error (commit commit-id)
  122. (signature signature)
  123. (keyring keyring)))
  124. (formatted-message (G_ "signature verification failed \
  125. for commit ~a")
  126. (oid->string commit-id)))))
  127. ('missing-key
  128. (raise (make-compound-condition
  129. (condition (&missing-key-error (commit commit-id)
  130. (signature signature)))
  131. (formatted-message (G_ "could not authenticate \
  132. commit ~a: key ~a is missing")
  133. (oid->string commit-id)
  134. (openpgp-format-fingerprint data)))))
  135. ('good-signature data)))))))
  136. (define (read-authorizations port)
  137. "Read authorizations in the '.guix-authorizations' format from PORT, and
  138. return a list of authorized fingerprints."
  139. (match (read port)
  140. (('authorizations ('version 0)
  141. (((? string? fingerprints) _ ...) ...)
  142. _ ...)
  143. (map (lambda (fingerprint)
  144. (base16-string->bytevector
  145. (string-downcase (string-filter char-set:graphic fingerprint))))
  146. fingerprints))))
  147. (define* (commit-authorized-keys repository commit
  148. #:optional (default-authorizations '()))
  149. "Return the list of OpenPGP fingerprints authorized to sign COMMIT, based on
  150. authorizations listed in its parent commits. If one of the parent commits
  151. does not specify anything, fall back to DEFAULT-AUTHORIZATIONS."
  152. (define (parents-have-authorizations-file? commit)
  153. ;; Return true if at least one of the parents of COMMIT has the
  154. ;; '.guix-authorizations' file.
  155. (find (lambda (commit)
  156. (false-if-git-not-found
  157. (tree-entry-bypath (commit-tree commit)
  158. ".guix-authorizations")))
  159. (commit-parents commit)))
  160. (define (assert-parents-lack-authorizations commit)
  161. ;; If COMMIT removes the '.guix-authorizations' file found in one of its
  162. ;; parents, raise an error.
  163. (when (parents-have-authorizations-file? commit)
  164. (raise (make-compound-condition
  165. (condition
  166. (&unauthorized-commit-error (commit (commit-id commit))
  167. (signing-key #f)))
  168. (formatted-message (G_ "commit ~a attempts \
  169. to remove '.guix-authorizations' file")
  170. (oid->string (commit-id commit)))))))
  171. (define (commit-authorizations commit)
  172. (catch 'git-error
  173. (lambda ()
  174. (let* ((tree (commit-tree commit))
  175. (entry (tree-entry-bypath tree ".guix-authorizations"))
  176. (blob (blob-lookup repository (tree-entry-id entry))))
  177. (read-authorizations
  178. (open-bytevector-input-port (blob-content blob)))))
  179. (lambda (key error)
  180. (if (= (git-error-code error) GIT_ENOTFOUND)
  181. (begin
  182. ;; Prevent removal of '.guix-authorizations' since it would make
  183. ;; it trivial to force a fallback to DEFAULT-AUTHORIZATIONS.
  184. (assert-parents-lack-authorizations commit)
  185. default-authorizations)
  186. (throw key error)))))
  187. (match (commit-parents commit)
  188. (() default-authorizations)
  189. (parents
  190. (apply lset-intersection bytevector=?
  191. (map commit-authorizations parents)))))
  192. (define* (authenticate-commit repository commit keyring
  193. #:key (default-authorizations '()))
  194. "Authenticate COMMIT from REPOSITORY and return the signing key fingerprint.
  195. Raise an error when authentication fails. If one of the parent commits does
  196. not specify anything, fall back to DEFAULT-AUTHORIZATIONS."
  197. (define id
  198. (commit-id commit))
  199. (define recent-commit?
  200. (false-if-git-not-found
  201. (tree-entry-bypath (commit-tree commit) ".guix-authorizations")))
  202. (define signing-key
  203. (commit-signing-key repository id keyring
  204. ;; Reject SHA1 signatures unconditionally as suggested
  205. ;; by the authors of "SHA-1 is a Shambles" (2019).
  206. ;; Accept it for "historical" commits (there are such
  207. ;; signatures from April 2020 in the repository).
  208. #:disallowed-hash-algorithms
  209. (if recent-commit? '(sha1) '())))
  210. (unless (member (openpgp-public-key-fingerprint signing-key)
  211. (commit-authorized-keys repository commit
  212. default-authorizations))
  213. (raise (make-compound-condition
  214. (condition
  215. (&unauthorized-commit-error (commit id)
  216. (signing-key signing-key)))
  217. (formatted-message (G_ "commit ~a not signed by an authorized \
  218. key: ~a")
  219. (oid->string id)
  220. (openpgp-format-fingerprint
  221. (openpgp-public-key-fingerprint
  222. signing-key))))))
  223. signing-key)
  224. (define (load-keyring-from-blob repository oid keyring)
  225. "Augment KEYRING with the keyring available in the blob at OID, which may or
  226. may not be ASCII-armored."
  227. (let* ((blob (blob-lookup repository oid))
  228. (port (open-bytevector-input-port (blob-content blob))))
  229. (get-openpgp-keyring (if (port-ascii-armored? port)
  230. (open-bytevector-input-port (read-radix-64 port))
  231. port)
  232. keyring)))
  233. (define (load-keyring-from-reference repository reference)
  234. "Load the '.key' files from the tree at REFERENCE in REPOSITORY and return
  235. an OpenPGP keyring."
  236. (let* ((reference (branch-lookup repository reference BRANCH-ALL))
  237. (target (reference-target reference))
  238. (commit (commit-lookup repository target))
  239. (tree (commit-tree commit)))
  240. (fold (lambda (name keyring)
  241. (if (string-suffix? ".key" name)
  242. (let ((entry (tree-entry-bypath tree name)))
  243. (load-keyring-from-blob repository
  244. (tree-entry-id entry)
  245. keyring))
  246. keyring))
  247. %empty-keyring
  248. (tree-list tree))))
  249. (define* (authenticate-commits repository commits
  250. #:key
  251. (default-authorizations '())
  252. (keyring-reference "keyring")
  253. (keyring (load-keyring-from-reference
  254. repository keyring-reference))
  255. (report-progress (const #t)))
  256. "Authenticate COMMITS, a list of commit objects, calling REPORT-PROGRESS for
  257. each of them. Return an alist showing the number of occurrences of each key.
  258. If KEYRING is omitted, the OpenPGP keyring is loaded from KEYRING-REFERENCE in
  259. REPOSITORY."
  260. (fold (lambda (commit stats)
  261. (report-progress)
  262. (let ((signer (authenticate-commit repository commit keyring
  263. #:default-authorizations
  264. default-authorizations)))
  265. (match (assq signer stats)
  266. (#f (cons `(,signer . 1) stats))
  267. ((_ . count) (cons `(,signer . ,(+ count 1))
  268. (alist-delete signer stats))))))
  269. '()
  270. commits))
  271. ;;;
  272. ;;; Caching.
  273. ;;;
  274. (define (authenticated-commit-cache-file key)
  275. "Return the name of the file that contains the cache of
  276. previously-authenticated commits for KEY."
  277. (string-append (cache-directory) "/authentication/" key))
  278. (define (previously-authenticated-commits key)
  279. "Return the previously-authenticated commits under KEY as a list of commit
  280. IDs (hex strings)."
  281. (catch 'system-error
  282. (lambda ()
  283. (call-with-input-file (authenticated-commit-cache-file key)
  284. (lambda (port)
  285. ;; If PORT has the wrong permissions, it might have been tampered
  286. ;; with by another user so ignore its contents.
  287. (if (= #o600 (stat:perms (stat port)))
  288. (read port)
  289. (begin
  290. (chmod port #o600)
  291. '())))))
  292. (lambda args
  293. (if (= ENOENT (system-error-errno args))
  294. '()
  295. (apply throw args)))))
  296. (define (cache-authenticated-commit key commit-id)
  297. "Record in ~/.cache, under KEY, COMMIT-ID and its closure as
  298. authenticated (only COMMIT-ID is written to cache, though)."
  299. (define %max-cache-length
  300. ;; Maximum number of commits in cache.
  301. 200)
  302. (let ((lst (delete-duplicates
  303. (cons commit-id (previously-authenticated-commits key))))
  304. (file (authenticated-commit-cache-file key)))
  305. (mkdir-p (dirname file))
  306. (with-atomic-file-output file
  307. (lambda (port)
  308. (let ((lst (if (> (length lst) %max-cache-length)
  309. (take lst %max-cache-length) ;truncate
  310. lst)))
  311. (chmod port #o600)
  312. (display ";; List of previously-authenticated commits.\n\n"
  313. port)
  314. (pretty-print lst port))))))
  315. ;;;
  316. ;;; High-level interface.
  317. ;;;
  318. (define (repository-cache-key repository)
  319. "Return a unique key to store the authenticate commit cache for REPOSITORY."
  320. (string-append "checkouts/"
  321. (base64-encode
  322. (sha256 (string->utf8 (repository-directory repository))))))
  323. (define (verify-introductory-commit repository keyring commit expected-signer)
  324. "Look up COMMIT in REPOSITORY, and raise an exception if it is not signed by
  325. EXPECTED-SIGNER."
  326. (define actual-signer
  327. (openpgp-public-key-fingerprint
  328. (commit-signing-key repository (commit-id commit) keyring)))
  329. (unless (bytevector=? expected-signer actual-signer)
  330. (raise (formatted-message (G_ "initial commit ~a is signed by '~a' \
  331. instead of '~a'")
  332. (oid->string (commit-id commit))
  333. (openpgp-format-fingerprint actual-signer)
  334. (openpgp-format-fingerprint expected-signer)))))
  335. (define* (authenticate-repository repository start signer
  336. #:key
  337. (keyring-reference "keyring")
  338. (cache-key (repository-cache-key repository))
  339. (end (reference-target
  340. (repository-head repository)))
  341. (authentic-commits '())
  342. (historical-authorizations '())
  343. (make-reporter
  344. (const progress-reporter/silent)))
  345. "Authenticate REPOSITORY up to commit END, an OID. Authentication starts
  346. with commit START, an OID, which must be signed by SIGNER; an exception is
  347. raised if that is not the case. Commits listed in AUTHENTIC-COMMITS and their
  348. closure are considered authentic. Return an alist mapping OpenPGP public keys
  349. to the number of commits signed by that key that have been traversed.
  350. The OpenPGP keyring is loaded from KEYRING-REFERENCE in REPOSITORY, where
  351. KEYRING-REFERENCE is the name of a branch. The list of authenticated commits
  352. is cached in the authentication cache under CACHE-KEY.
  353. HISTORICAL-AUTHORIZATIONS must be a list of OpenPGP fingerprints (bytevectors)
  354. denoting the authorized keys for commits whose parent lack the
  355. '.guix-authorizations' file."
  356. (define start-commit
  357. (commit-lookup repository start))
  358. (define end-commit
  359. (commit-lookup repository end))
  360. (define keyring
  361. (load-keyring-from-reference repository keyring-reference))
  362. (define authenticated-commits
  363. ;; Previously-authenticated commits that don't need to be checked again.
  364. (filter-map (lambda (id)
  365. (false-if-git-not-found
  366. (commit-lookup repository (string->oid id))))
  367. (append (previously-authenticated-commits cache-key)
  368. authentic-commits)))
  369. (define commits
  370. ;; Commits to authenticate, excluding the closure of
  371. ;; AUTHENTICATED-COMMITS.
  372. (commit-difference end-commit start-commit
  373. authenticated-commits))
  374. ;; When COMMITS is empty, it's because END-COMMIT is in the closure of
  375. ;; START-COMMIT and/or AUTHENTICATED-COMMITS, in which case it's known to
  376. ;; be authentic already.
  377. (if (null? commits)
  378. '()
  379. (let ((reporter (make-reporter start-commit end-commit commits)))
  380. ;; If it's our first time, verify START-COMMIT's signature.
  381. (when (null? authenticated-commits)
  382. (verify-introductory-commit repository keyring
  383. start-commit signer))
  384. (let ((stats (call-with-progress-reporter reporter
  385. (lambda (report)
  386. (authenticate-commits repository commits
  387. #:keyring keyring
  388. #:default-authorizations
  389. historical-authorizations
  390. #:report-progress report)))))
  391. (cache-authenticated-commit cache-key
  392. (oid->string (commit-id end-commit)))
  393. stats))))