git-authenticate.scm 19 KB

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