channels: 'latest-channel-instance' authenticates Git checkouts.

Fixes <https://bugs.gnu.org/22883>.

* guix/channels.scm (<channel>)[introduction]: New field.
(<channel-introduction>): New record type.
(%guix-channel-introduction): New variable.
(%default-channels): Use it.
(<channel-metadata>)[keyring-reference]: New field.
(%default-keyring-reference): New variable.
(read-channel-metadata, read-channel-metadata-from-source): Initialize
the 'keyring-reference' field.
(commit-short-id, verify-introductory-commit)
(authenticate-channel): New procedures.
(latest-channel-instance): Call 'authenticate-channel' when CHANNEL has
an introduction.
* tests/channels.scm (gpg+git-available?, commit-id-string): New
procedures.
("authenticate-channel, wrong first commit signer"):
("authenticate-channel, .guix-authorizations"): New tests.
* doc/guix.texi (Invoking guix pull): Mention authentication.
This commit is contained in:
Ludovic Courtès 2020-06-08 12:01:24 +02:00
parent 1e2b9bf2d4
commit 43badf261f
No known key found for this signature in database
GPG key ID: 090B11993D9AEBB5
4 changed files with 304 additions and 7 deletions

View file

@ -99,6 +99,7 @@
(eval . (put 'eventually 'scheme-indent-function 1)) (eval . (put 'eventually 'scheme-indent-function 1))
(eval . (put 'call-with-progress-reporter 'scheme-indent-function 1)) (eval . (put 'call-with-progress-reporter 'scheme-indent-function 1))
(eval . (put 'with-repository 'scheme-indent-function 2))
(eval . (put 'with-temporary-git-repository 'scheme-indent-function 2)) (eval . (put 'with-temporary-git-repository 'scheme-indent-function 2))
(eval . (put 'with-environment-variables 'scheme-indent-function 1)) (eval . (put 'with-environment-variables 'scheme-indent-function 1))
(eval . (put 'with-fresh-gnupg-setup 'scheme-indent-function 1)) (eval . (put 'with-fresh-gnupg-setup 'scheme-indent-function 1))

View file

@ -3721,13 +3721,17 @@ this option is primarily useful when the daemon was running with
@cindex updating Guix @cindex updating Guix
@cindex @command{guix pull} @cindex @command{guix pull}
@cindex pull @cindex pull
@cindex security, @command{guix pull}
@cindex authenticity, of code obtained with @command{guix pull}
Packages are installed or upgraded to the latest version available in Packages are installed or upgraded to the latest version available in
the distribution currently available on your local machine. To update the distribution currently available on your local machine. To update
that distribution, along with the Guix tools, you must run @command{guix that distribution, along with the Guix tools, you must run @command{guix
pull}: the command downloads the latest Guix source code and package pull}: the command downloads the latest Guix source code and package
descriptions, and deploys it. Source code is downloaded from a descriptions, and deploys it. Source code is downloaded from a
@uref{https://git-scm.com, Git} repository, by default the official @uref{https://git-scm.com, Git} repository, by default the official
GNU@tie{}Guix repository, though this can be customized. GNU@tie{}Guix repository, though this can be customized. @command{guix
pull} ensures that the code it downloads is @emph{authentic} by
verifying that commits are signed by Guix developers.
Specifically, @command{guix pull} downloads code from the @dfn{channels} Specifically, @command{guix pull} downloads code from the @dfn{channels}
(@pxref{Channels}) specified by one of the followings, in this order: (@pxref{Channels}) specified by one of the followings, in this order:

View file

@ -21,6 +21,11 @@
(define-module (guix channels) (define-module (guix channels)
#:use-module (git) #:use-module (git)
#:use-module (guix git) #:use-module (guix git)
#:use-module (guix git-authenticate)
#:use-module ((guix openpgp)
#:select (openpgp-public-key-fingerprint
openpgp-format-fingerprint))
#:use-module (guix base16)
#:use-module (guix records) #:use-module (guix records)
#:use-module (guix gexp) #:use-module (guix gexp)
#:use-module (guix modules) #:use-module (guix modules)
@ -28,6 +33,7 @@ (define-module (guix channels)
#:use-module (guix monads) #:use-module (guix monads)
#:use-module (guix profiles) #:use-module (guix profiles)
#:use-module (guix packages) #:use-module (guix packages)
#:use-module (guix progress)
#:use-module (guix derivations) #:use-module (guix derivations)
#:use-module (guix combinators) #:use-module (guix combinators)
#:use-module (guix diagnostics) #:use-module (guix diagnostics)
@ -48,17 +54,23 @@ (define-module (guix channels)
#:autoload (guix self) (whole-package make-config.scm) #:autoload (guix self) (whole-package make-config.scm)
#:autoload (guix inferior) (gexp->derivation-in-inferior) ;FIXME: circular dep #:autoload (guix inferior) (gexp->derivation-in-inferior) ;FIXME: circular dep
#:autoload (guix quirks) (%quirks %patches applicable-patch? apply-patch) #:autoload (guix quirks) (%quirks %patches applicable-patch? apply-patch)
#:use-module (ice-9 format)
#:use-module (ice-9 match) #:use-module (ice-9 match)
#:use-module (ice-9 vlist) #:use-module (ice-9 vlist)
#:use-module ((ice-9 rdelim) #:select (read-string)) #:use-module ((ice-9 rdelim) #:select (read-string))
#:use-module ((rnrs bytevectors) #:select (bytevector=?))
#:export (channel #:export (channel
channel? channel?
channel-name channel-name
channel-url channel-url
channel-branch channel-branch
channel-commit channel-commit
channel-introduction
channel-location channel-location
channel-introduction?
;; <channel-introduction> accessors purposefully omitted for now.
%default-channels %default-channels
guix-channel? guix-channel?
@ -67,6 +79,7 @@ (define-module (guix channels)
channel-instance-commit channel-instance-commit
channel-instance-checkout channel-instance-checkout
authenticate-channel
latest-channel-instances latest-channel-instances
checkout->channel-instance checkout->channel-instance
latest-channel-derivation latest-channel-derivation
@ -104,15 +117,44 @@ (define-record-type* <channel> channel make-channel
(url channel-url) (url channel-url)
(branch channel-branch (default "master")) (branch channel-branch (default "master"))
(commit channel-commit (default #f)) (commit channel-commit (default #f))
(introduction channel-introduction (default #f))
(location channel-location (location channel-location
(default (current-source-location)) (innate))) (default (current-source-location)) (innate)))
;; Channel introductions. A "channel introduction" provides a commit/signer
;; pair that specifies the first commit of the authentication process as well
;; as its signer's fingerprint. The pair must be signed by the signer of that
;; commit so that only them may emit this introduction. Introductions are
;; used to bootstrap trust in a channel.
(define-record-type <channel-introduction>
(make-channel-introduction first-signed-commit first-commit-signer
signature)
channel-introduction?
(first-signed-commit channel-introduction-first-signed-commit) ;hex string
(first-commit-signer channel-introduction-first-commit-signer) ;bytevector
(signature channel-introduction-signature)) ;string
(define %guix-channel-introduction
;; Introduction of the official 'guix channel. The chosen commit is the
;; first one that introduces '.guix-authorizations' on the 'staging'
;; branch that was eventually merged in 'master'. Any branch starting
;; before that commit cannot be merged or it will be rejected by 'guix pull'
;; & co.
(make-channel-introduction
"9edb3f66fd807b096b48283debdcddccfea34bad" ;2020-05-26
(base16-string->bytevector
(string-downcase
(string-filter char-set:hex-digit ;mbakke
"BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA")))
#f)) ;TODO: Add an intro signature so it can be exported.
(define %default-channels (define %default-channels
;; Default list of channels. ;; Default list of channels.
(list (channel (list (channel
(name 'guix) (name 'guix)
(branch "master") (branch "master")
(url "https://git.savannah.gnu.org/git/guix.git")))) (url "https://git.savannah.gnu.org/git/guix.git")
(introduction %guix-channel-introduction))))
(define (guix-channel? channel) (define (guix-channel? channel)
"Return true if CHANNEL is the 'guix' channel." "Return true if CHANNEL is the 'guix' channel."
@ -126,11 +168,16 @@ (define-record-type <channel-instance>
(checkout channel-instance-checkout)) (checkout channel-instance-checkout))
(define-record-type <channel-metadata> (define-record-type <channel-metadata>
(channel-metadata directory dependencies news-file) (channel-metadata directory dependencies news-file keyring-reference)
channel-metadata? channel-metadata?
(directory channel-metadata-directory) ;string with leading slash (directory channel-metadata-directory) ;string with leading slash
(dependencies channel-metadata-dependencies) ;list of <channel> (dependencies channel-metadata-dependencies) ;list of <channel>
(news-file channel-metadata-news-file)) ;string | #f (news-file channel-metadata-news-file) ;string | #f
(keyring-reference channel-metadata-keyring-reference)) ;string
(define %default-keyring-reference
;; Default value of the 'keyring-reference' field.
"keyring")
(define (channel-reference channel) (define (channel-reference channel)
"Return the \"reference\" for CHANNEL, an sexp suitable for "Return the \"reference\" for CHANNEL, an sexp suitable for
@ -147,7 +194,10 @@ (define (read-channel-metadata port)
(('channel ('version 0) properties ...) (('channel ('version 0) properties ...)
(let ((directory (and=> (assoc-ref properties 'directory) first)) (let ((directory (and=> (assoc-ref properties 'directory) first))
(dependencies (or (assoc-ref properties 'dependencies) '())) (dependencies (or (assoc-ref properties 'dependencies) '()))
(news-file (and=> (assoc-ref properties 'news-file) first))) (news-file (and=> (assoc-ref properties 'news-file) first))
(keyring-reference
(or (and=> (assoc-ref properties 'keyring-reference) first)
%default-keyring-reference)))
(channel-metadata (channel-metadata
(cond ((not directory) "/") ;directory (cond ((not directory) "/") ;directory
((string-prefix? "/" directory) directory) ((string-prefix? "/" directory) directory)
@ -164,7 +214,8 @@ (define (read-channel-metadata port)
(url url) (url url)
(commit (get 'commit)))))) (commit (get 'commit))))))
dependencies) dependencies)
news-file))) ;news-file news-file
keyring-reference)))
((and ('channel ('version version) _ ...) sexp) ((and ('channel ('version version) _ ...) sexp)
(raise (condition (raise (condition
(&message (message "unsupported '.guix-channel' version")) (&message (message "unsupported '.guix-channel' version"))
@ -188,7 +239,7 @@ (define (read-channel-metadata-from-source source)
read-channel-metadata)) read-channel-metadata))
(lambda args (lambda args
(if (= ENOENT (system-error-errno args)) (if (= ENOENT (system-error-errno args))
(channel-metadata "/" '() #f) (channel-metadata "/" '() #f %default-keyring-reference)
(apply throw args))))) (apply throw args)))))
(define (channel-instance-metadata instance) (define (channel-instance-metadata instance)
@ -212,6 +263,116 @@ (define (apply-patches checkout commit patches)
(apply-patch patch checkout)) (apply-patch patch checkout))
(loop rest))))) (loop rest)))))
(define commit-short-id
(compose (cut string-take <> 7) oid->string commit-id))
(define (verify-introductory-commit repository introduction keyring)
"Raise an exception if the first commit described in INTRODUCTION doesn't
have the expected signer."
(define commit-id
(channel-introduction-first-signed-commit introduction))
(define actual-signer
(openpgp-public-key-fingerprint
(commit-signing-key repository (string->oid commit-id)
keyring)))
(define expected-signer
(channel-introduction-first-commit-signer introduction))
(unless (bytevector=? expected-signer actual-signer)
(raise (condition
(&message
(message (format #f (G_ "initial commit ~a is signed by '~a' \
instead of '~a'")
commit-id
(openpgp-format-fingerprint actual-signer)
(openpgp-format-fingerprint expected-signer))))))))
(define* (authenticate-channel channel checkout commit
#:key (keyring-reference-prefix "origin/"))
"Authenticate the given COMMIT of CHANNEL, available at CHECKOUT, a
directory containing a CHANNEL checkout. Raise an error if authentication
fails."
;; XXX: Too bad we need to re-open CHECKOUT.
(with-repository checkout repository
(define start-commit
(commit-lookup repository
(string->oid
(channel-introduction-first-signed-commit
(channel-introduction channel)))))
(define end-commit
(commit-lookup repository (string->oid commit)))
(define cache-key
(string-append "channels/" (symbol->string (channel-name channel))))
(define keyring-reference
(channel-metadata-keyring-reference
(read-channel-metadata-from-source checkout)))
(define keyring
(load-keyring-from-reference repository
(string-append keyring-reference-prefix
keyring-reference)))
(define authenticated-commits
;; Previously-authenticated commits that don't need to be checked again.
(filter-map (lambda (id)
(false-if-exception
(commit-lookup repository (string->oid id))))
(previously-authenticated-commits cache-key)))
(define commits
;; Commits to authenticate, excluding the closure of
;; AUTHENTICATED-COMMITS.
(commit-difference end-commit start-commit
authenticated-commits))
(define reporter
(progress-reporter/bar (length commits)))
;; When COMMITS is empty, it's either because AUTHENTICATED-COMMITS
;; contains END-COMMIT or because END-COMMIT is not a descendant of
;; START-COMMIT. Check that.
(if (null? commits)
(match (commit-relation start-commit end-commit)
((or 'self 'ancestor 'descendant) #t) ;nothing to do!
('unrelated
(raise
(condition
(&message
(message
(format #f (G_ "'~a' is not related to introductory \
commit of channel '~a'~%")
(oid->string (commit-id end-commit))
(channel-name channel))))))))
(begin
(format (current-error-port)
(G_ "Authenticating channel '~a', \
commits ~a to ~a (~h new commits)...~%")
(channel-name channel)
(commit-short-id start-commit)
(commit-short-id end-commit)
(length commits))
;; If it's our first time, verify CHANNEL's introductory commit.
(when (null? authenticated-commits)
(verify-introductory-commit repository
(channel-introduction channel)
keyring))
(call-with-progress-reporter reporter
(lambda (report)
(authenticate-commits repository commits
#:keyring keyring
#:report-progress report)))
(cache-authenticated-commit cache-key
(oid->string
(commit-id end-commit)))))))
(define* (latest-channel-instance store channel (define* (latest-channel-instance store channel
#:key (patches %patches) #:key (patches %patches)
starting-commit) starting-commit)
@ -225,6 +386,15 @@ (define (dot-git? file stat)
(update-cached-checkout (channel-url channel) (update-cached-checkout (channel-url channel)
#:ref (channel-reference channel) #:ref (channel-reference channel)
#:starting-commit starting-commit))) #:starting-commit starting-commit)))
(if (channel-introduction channel)
(authenticate-channel channel checkout commit)
;; TODO: Warn for all the channels once the authentication interface
;; is public.
(when (guix-channel? channel)
(warning (G_ "channel '~a' lacks an introduction and \
cannot be authenticated~%")
(channel-name channel))))
(when (guix-channel? channel) (when (guix-channel? channel)
;; Apply the relevant subset of PATCHES directly in CHECKOUT. This is ;; Apply the relevant subset of PATCHES directly in CHECKOUT. This is
;; safe to do because 'switch-to-ref' eventually does a hard reset. ;; safe to do because 'switch-to-ref' eventually does a hard reset.

View file

@ -31,15 +31,28 @@ (define-module (test-channels)
#:use-module ((guix build utils) #:select (which)) #:use-module ((guix build utils) #:select (which))
#:use-module (git) #:use-module (git)
#:use-module (guix git) #:use-module (guix git)
#:use-module (guix git-authenticate)
#:use-module (guix openpgp)
#:use-module (guix tests git) #:use-module (guix tests git)
#:use-module (guix tests gnupg)
#:use-module (srfi srfi-1) #:use-module (srfi srfi-1)
#:use-module (srfi srfi-26) #:use-module (srfi srfi-26)
#:use-module (srfi srfi-34) #:use-module (srfi srfi-34)
#:use-module (srfi srfi-35) #:use-module (srfi srfi-35)
#:use-module (srfi srfi-64) #:use-module (srfi srfi-64)
#:use-module (rnrs bytevectors)
#:use-module (rnrs io ports)
#:use-module (ice-9 control) #:use-module (ice-9 control)
#:use-module (ice-9 match)) #:use-module (ice-9 match))
(define (gpg+git-available?)
(and (which (git-command))
(which (gpg-command)) (which (gpgconf-command))))
(define commit-id-string
(compose oid->string commit-id))
(test-begin "channels") (test-begin "channels")
(define* (make-instance #:key (define* (make-instance #:key
@ -389,4 +402,113 @@ (define (find-commit* message)
(channel-news-for-commit channel commit5 commit1)) (channel-news-for-commit channel commit5 commit1))
'(#f "tag-for-first-news-entry"))))))) '(#f "tag-for-first-news-entry")))))))
(unless (gpg+git-available?) (test-skip 1))
(test-assert "authenticate-channel, wrong first commit signer"
(with-fresh-gnupg-setup (list %ed25519-public-key-file
%ed25519-secret-key-file
%ed25519bis-public-key-file
%ed25519bis-secret-key-file)
(with-temporary-git-repository directory
`((add ".guix-channel"
,(object->string
'(channel (version 0)
(keyring-reference "master"))))
(add ".guix-authorizations"
,(object->string
`(authorizations (version 0)
((,(key-fingerprint
%ed25519-public-key-file)
(name "Charlie"))))))
(add "signer.key" ,(call-with-input-file %ed25519-public-key-file
get-string-all))
(commit "first commit"
(signer ,(key-fingerprint %ed25519-public-key-file))))
(with-repository directory repository
(let* ((commit1 (find-commit repository "first"))
(intro ((@@ (guix channels) make-channel-introduction)
(commit-id-string commit1)
(openpgp-public-key-fingerprint
(read-openpgp-packet
%ed25519bis-public-key-file)) ;different key
#f)) ;no signature
(channel (channel (name 'example)
(url (string-append "file://" directory))
(introduction intro))))
(guard (c ((message? c)
(->bool (string-contains (condition-message c)
"initial commit"))))
(authenticate-channel channel directory
(commit-id-string commit1)
#:keyring-reference-prefix "")
'failed))))))
(unless (gpg+git-available?) (test-skip 1))
(test-assert "authenticate-channel, .guix-authorizations"
(with-fresh-gnupg-setup (list %ed25519-public-key-file
%ed25519-secret-key-file
%ed25519bis-public-key-file
%ed25519bis-secret-key-file)
(with-temporary-git-repository directory
`((add ".guix-channel"
,(object->string
'(channel (version 0)
(keyring-reference "channel-keyring"))))
(add ".guix-authorizations"
,(object->string
`(authorizations (version 0)
((,(key-fingerprint
%ed25519-public-key-file)
(name "Charlie"))))))
(commit "zeroth commit")
(add "a.txt" "A")
(commit "first commit"
(signer ,(key-fingerprint %ed25519-public-key-file)))
(add "b.txt" "B")
(commit "second commit"
(signer ,(key-fingerprint %ed25519-public-key-file)))
(add "c.txt" "C")
(commit "third commit"
(signer ,(key-fingerprint %ed25519bis-public-key-file)))
(branch "channel-keyring")
(checkout "channel-keyring")
(add "signer.key" ,(call-with-input-file %ed25519-public-key-file
get-string-all))
(add "other.key" ,(call-with-input-file %ed25519bis-public-key-file
get-string-all))
(commit "keyring commit")
(checkout "master"))
(with-repository directory repository
(let* ((commit1 (find-commit repository "first"))
(commit2 (find-commit repository "second"))
(commit3 (find-commit repository "third"))
(intro ((@@ (guix channels) make-channel-introduction)
(commit-id-string commit1)
(openpgp-public-key-fingerprint
(read-openpgp-packet
%ed25519-public-key-file))
#f)) ;no signature
(channel (channel (name 'example)
(url (string-append "file://" directory))
(introduction intro))))
;; COMMIT1 and COMMIT2 are fine.
(and (authenticate-channel channel directory
(commit-id-string commit2)
#:keyring-reference-prefix "")
;; COMMIT3 is signed by an unauthorized key according to its
;; parent's '.guix-authorizations' file.
(guard (c ((unauthorized-commit-error? c)
(and (oid=? (git-authentication-error-commit c)
(commit-id commit3))
(bytevector=?
(openpgp-public-key-fingerprint
(unauthorized-commit-error-signing-key c))
(openpgp-public-key-fingerprint
(read-openpgp-packet
%ed25519bis-public-key-file))))))
(authenticate-channel channel directory
(commit-id-string commit3)
#:keyring-reference-prefix "")
'failed)))))))
(test-end "channels") (test-end "channels")