for packages.) So you should ensure that your archive is
well-maintained and keep the hosting system secure.
- One way to increase the security of your packages is to @dfn{sign}
-them using a cryptographic key. If you have generated a
-private/public gpg key pair, you can use gpg to sign the package like
-this:
+ To increase the security of your packages, you should distribute
+package checksums in the package metadata file
+@file{archive-contents}. You should also @dfn{sign} the package
+metadata file using a cryptographic key. Finally, it is important to
+include creation and expiration timestamps information in that file.
+
+ Signing individual packages is also supported, but considered
+obsolete. It provides less security than package checksums, signing
+the @file{archive-contents} file, and creation and expiration
+timestamps does when used together. More specifically, signing
+individual packages does not protect against ``replay attacks''. Note
+that distributing signatures for individual packages is still
+recommended to support Emacs versions older than 28.1.
+
+ If you have generated a private/public gpg key pair, you can use gpg
+to sign a package or the @file{archive-contents} file like this:
@c FIXME EasyPG / package-x way to do this.
@example
of 'package-desc' structures (see @file{package.el}), except the first
element of the list is the archive version.
+@item archive-contents.sig
+Return the signature for @file{archive-contents}.
+
@item <package name>-readme.txt
Return the long description of the package.
:risky t
:version "28.1")
+(defcustom package-check-timestamp t
+ "Non-nil means to verify the package archive timestamp.
+
+Note that setting this to nil is intended for debugging, and
+should normally not be used since it will decrease security."
+ :type 'boolean
+ :risky t
+ :version "28.1")
+
(defcustom package-check-signature 'allow-unsigned
"Non-nil means to check package signatures when installing.
More specifically the value can be:
(define-error 'bad-size "Package size mismatch" 'package-error)
(define-error 'bad-signature "Failed to verify signature" 'package-error)
(define-error 'bad-checksum "Failed to verify checksum" 'package-error)
+(define-error 'bad-timestamp "Failed to verify timestamp" 'package-error)
\f
;;; `package-desc' object definition
(message "Package refresh done")
(run-hooks 'package--post-download-archives-hook)))
+(defun package--parse-header-from-buffer (header name)
+ "Find and return \"archive-contents\" HEADER for archive NAME.
+This function assumes that the current buffer contains the
+\"archive-contents\" file.
+
+A valid header looks like: \";; HEADER: <TIMESTAMP>\"
+
+Where <TIMESTAMP> is a valid ISO-8601 (RFC 3339) date. If there
+is such a line but <TIMESTAMP> is invalid, show a warning and
+return nil. If there is no valid header, return nil."
+ (save-excursion
+ (goto-char (point-min))
+ (when (re-search-forward (concat "^;; " header ": *\\(.+?\\) *$") nil t)
+ (condition-case-unless-debug nil
+ (encode-time (iso8601-parse (match-string 1)))
+ (lwarn '(package timestamp)
+ (list (format "Malformed timestamp for archive `%s': `%s'"
+ name (match-string 1))))))))
+
+(defun package--parse-valid-until-from-buffer (name)
+ "Find and return \"Valid-Until\" header for archive NAME."
+ (package--parse-header-from-buffer "Valid-Until" name))
+
+(defun package--parse-last-updated-from-buffer (name)
+ "Find and return \"Last-Updated\" header for archive NAME."
+ (package--parse-header-from-buffer "Last-Updated" name))
+
+(defun package--archive-verify-timestamp (new old name)
+ "Return t if timestamp NEW is more recent than OLD for archive NAME.
+Signal error otherwise.
+Warn if NEW is in the future."
+ ;; If timestamp is missing on cached (old) file, do nothing here.
+ ;; This package archive recently introduced support for timestamps.
+ ;; We will require a timestamp for that archive in future updates.
+ (if old
+ (cond
+ ((not new)
+ (signal 'bad-timestamp
+ (list (format-message
+ (concat
+ "New archive contents for `%s' missing "
+ "timestamp, refusing to proceed")
+ name))))
+ ((time-less-p new old)
+ (signal 'bad-timestamp
+ (list (format-message
+ (concat
+ "New archive contents for `%s' older than "
+ "cached, refusing to proceed")
+ name))))
+ ((time-less-p (current-time) new)
+ (signal 'bad-timestamp
+ (list (format-message
+ (concat
+ "New archive contents for `%s' is "
+ "in the future: %s")
+ name (format-time-string "%c" new)))))
+ ;; Check ok, return t.
+ (t))
+ t))
+
+(defun package--archive-verify-not-expired (timestamp name)
+ "Return t if TIMESTAMP has not yet expired for archive NAME.
+Signal error otherwise."
+ (unless (time-less-p (current-time) timestamp)
+ (signal 'bad-timestamp
+ (list (format-message
+ (concat
+ "Package archive `%s' has sent "
+ "an expired `archive-contents' file")
+ name)))))
+
+(defun package--check-archive-timestamp (name)
+ "Verify timestamp of \"archive-contents\" file for archive NAME.
+Compare the archive timestamp of the previously downloaded
+\"archive-contents\" file to the timestamp in the current buffer.
+Signal error if the old timestamp is more recent than the new one.
+
+Do nothing if there is no previously downloaded file, if such a
+file exists but does not contain any timestamp, or if
+`package-check-timestamp' is nil."
+ (let ((old-file (expand-file-name
+ (concat "archives/" name "/archive-contents")
+ package-user-dir)))
+ (when (and package-check-timestamp
+ (file-readable-p old-file))
+ (let ((old (with-temp-buffer
+ (insert-file-contents old-file)
+ (package--parse-last-updated-from-buffer name)))
+ (new (package--parse-last-updated-from-buffer name))
+ (new-expires (package--parse-valid-until-from-buffer name)))
+ (package--archive-verify-timestamp new old name)
+ (package--archive-verify-not-expired new-expires name)))))
+
(defun package--download-one-archive (archive file &optional async)
"Retrieve an archive file FILE from ARCHIVE, and cache it.
ARCHIVE should be a cons cell of the form (NAME . LOCATION),
(content (buffer-string))
(dir (expand-file-name (concat "archives/" name) package-user-dir))
(local-file (expand-file-name file dir)))
+ (package--check-archive-timestamp name)
(when (listp (read content))
(make-directory dir t)
(if (or (not (package-check-signature))
(insert "7")
(should-error (package--verify-package-size pkg-desc)))))
+(ert-deftest package-test-parse-valid-until-from-buffer ()
+ (with-temp-buffer
+ (insert ";; Valid-Until: 2020-05-01T15:43:35.000Z\n(foo bar baz)")
+ (should (equal (package--parse-valid-until-from-buffer "foo")
+ '(24236 17319)))))
+
+(ert-deftest package-test-parse-last-updated-from-buffer ()
+ (with-temp-buffer
+ (insert ";; Last-Updated: 2020-05-01T15:43:35.000Z\n(foo bar baz)")
+ (should (equal (package--parse-last-updated-from-buffer "foo")
+ '(24236 17319)))))
+
+(defun package-tests--parse-last-updated (timestamp)
+ (with-temp-buffer
+ (insert timestamp)
+ (package--parse-last-updated-from-buffer "test")))
+
+(ert-deftest package-test-archive-verify-timestamp ()
+ (let ((a (package-tests--parse-last-updated
+ ";; Last-Updated: 2020-05-01T15:43:35.000Z\n"))
+ (b (package-tests--parse-last-updated
+ ";; Last-Updated: 2020-06-01T15:43:35.000Z\n"))
+ (c (package-tests--parse-last-updated
+ ";; Last-Updated: 2020-07-01T15:43:35.000Z\n")))
+ (should (package--archive-verify-timestamp b nil "foo"))
+ (should (package--archive-verify-timestamp b a "foo"))
+ (should (package--archive-verify-timestamp c a "foo"))
+ (should (package--archive-verify-timestamp c b "foo"))
+ ;; Signal error.
+ (should-error (package--archive-verify-timestamp a b "foo")
+ :type 'bad-timestamp)
+ (should-error (package--archive-verify-timestamp a c "foo")
+ :type 'bad-timestamp)
+ (should-error (package--archive-verify-timestamp b c "foo")
+ :type 'bad-timestamp)
+ (should-error (package--archive-verify-timestamp nil a "foo")
+ :type 'bad-timestamp)))
+
+(ert-deftest package-test-check-archive-timestamp ()
+ (let ((package-user-dir package-test-data-dir))
+ (with-temp-buffer
+ (insert ";; Last-Updated: 2020-01-01T00:00:00.000Z\n")
+ (package--check-archive-timestamp "older")
+ (package--check-archive-timestamp "missing")
+ (should-error (package--check-archive-timestamp "newer")
+ :type 'bad-timestamp))))
+
+(ert-deftest package-test-check-archive-timestamp/not-expired ()
+ (let ((package-user-dir package-test-data-dir))
+ (with-temp-buffer
+ (insert ";; Last-Updated: 2020-01-01T00:00:00.000Z\n"
+ ";; Valid-Until: 2999-01-02T00:00:00.000Z\n")
+ (should-not (package--check-archive-timestamp "older")))))
+
+(ert-deftest package-test-check-archive-timestamp/expired ()
+ (let ((package-user-dir package-test-data-dir))
+ (with-temp-buffer
+ (insert ";; Last-Updated: 2020-01-01T00:00:00.000Z\n"
+ ";; Valid-Until: 2020-01-02T00:00:00.000Z\n")
+ (should-error (package--check-archive-timestamp "older")))))
+
\f
;;; Tests for package-x features.