git-authenticate.scm 19 KB

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