Support cryptographically signed binary caches

NAR info files in binary caches can now have a cryptographic signature
that Nix will verify before using the corresponding NAR file.

To create a private/public key pair for signing and verifying a binary
cache, do:

  $ openssl genrsa -out ./cache-key.sec 2048
  $ openssl rsa -in ./cache-key.sec -pubout > ./cache-key.pub

You should also come up with a symbolic name for the key, such as
"cache.example.org-1".  This will be used by clients to look up the
public key.  (It's a good idea to number keys, in case you ever need
to revoke/replace one.)

To create a binary cache signed with the private key:

  $ nix-push --dest /path/to/binary-cache --key ./cache-key.sec --key-name cache.example.org-1

The public key (cache-key.pub) should be distributed to the clients.
They should have a nix.conf should contain something like:

  signed-binary-caches = *
  binary-cache-public-key-cache.example.org-1 = /path/to/cache-key.pub

If all works well, then if Nix fetches something from the signed
binary cache, you will see a message like:

  *** Downloading ‘http://cache.example.org/nar/7dppcj5sc1nda7l54rjc0g5l1hamj09j-subversion-1.7.11’ (signed by ‘cache.example.org-1’) to ‘/nix/store/7dppcj5sc1nda7l54rjc0g5l1hamj09j-subversion-1.7.11’...

On the other hand, if the signature is wrong, you get a message like

  NAR info file `http://cache.example.org/7dppcj5sc1nda7l54rjc0g5l1hamj09j.narinfo' has an invalid signature; ignoring

Signatures are implemented as a single line appended to the NAR info
file, which looks like this:

  Signature: 1;cache.example.org-1;HQ9Xzyanq9iV...muQ==

Thus the signature has 3 fields: a version (currently "1"), the ID of
key, and the base64-encoded signature of the SHA-256 hash of the
contents of the NAR info file up to but not including the Signature
line.

Issue #75.
This commit is contained in:
Eelco Dolstra 2014-01-08 15:23:41 +01:00
parent 405434e084
commit 0fdf4da0e9
8 changed files with 126 additions and 12 deletions

View file

@ -1,4 +1,4 @@
PERL_MODULES = lib/Nix/Store.pm lib/Nix/Manifest.pm lib/Nix/GeneratePatches.pm lib/Nix/SSH.pm lib/Nix/CopyClosure.pm lib/Nix/Config.pm.in lib/Nix/Utils.pm PERL_MODULES = lib/Nix/Store.pm lib/Nix/Manifest.pm lib/Nix/GeneratePatches.pm lib/Nix/SSH.pm lib/Nix/CopyClosure.pm lib/Nix/Config.pm.in lib/Nix/Utils.pm lib/Nix/Crypto.pm
all: $(PERL_MODULES:.in=) all: $(PERL_MODULES:.in=)

View file

@ -13,6 +13,7 @@ $storeDir = $ENV{"NIX_STORE_DIR"} || "@storedir@";
$bzip2 = "@bzip2@"; $bzip2 = "@bzip2@";
$xz = "@xz@"; $xz = "@xz@";
$curl = "@curl@"; $curl = "@curl@";
$openssl = "@openssl@";
$useBindings = "@perlbindings@" eq "yes"; $useBindings = "@perlbindings@" eq "yes";
@ -32,7 +33,7 @@ sub readConfig {
open CONFIG, "<$config" or die "cannot open `$config'"; open CONFIG, "<$config" or die "cannot open `$config'";
while (<CONFIG>) { while (<CONFIG>) {
/^\s*([\w|-]+)\s*=\s*(.*)$/ or next; /^\s*([\w\-\.]+)\s*=\s*(.*)$/ or next;
$config{$1} = $2; $config{$1} = $2;
} }
close CONFIG; close CONFIG;

42
perl/lib/Nix/Crypto.pm Normal file
View file

@ -0,0 +1,42 @@
package Nix::Crypto;
use strict;
use MIME::Base64;
use Nix::Store;
use Nix::Config;
use IPC::Open2;
our @ISA = qw(Exporter);
our @EXPORT = qw(signString isValidSignature);
sub signString {
my ($privateKeyFile, $s) = @_;
my $hash = hashString("sha256", 0, $s);
my ($from, $to);
my $pid = open2($from, $to, $Nix::Config::openssl, "rsautl", "-sign", "-inkey", $privateKeyFile);
print $to $hash;
close $to;
local $/ = undef;
my $sig = <$from>;
close $from;
waitpid($pid, 0);
die "$0: OpenSSL returned exit code $? while signing hash\n" if $? != 0;
my $sig64 = encode_base64($sig, "");
return $sig64;
}
sub isValidSignature {
my ($publicKeyFile, $sig64, $s) = @_;
my ($from, $to);
my $pid = open2($from, $to, $Nix::Config::openssl, "rsautl", "-verify", "-inkey", $publicKeyFile, "-pubin");
print $to decode_base64($sig64);
close $to;
my $decoded = <$from>;
close $from;
waitpid($pid, 0);
return 0 if $? != 0;
my $hash = hashString("sha256", 0, $s);
return $decoded eq $hash;
}
1;

View file

@ -8,6 +8,7 @@ use File::stat;
use File::Path; use File::Path;
use Fcntl ':flock'; use Fcntl ':flock';
use Nix::Config; use Nix::Config;
use Nix::Crypto;
our @ISA = qw(Exporter); our @ISA = qw(Exporter);
our @EXPORT = qw(readManifest writeManifest updateManifestDB addPatch deleteOldManifests parseNARInfo); our @EXPORT = qw(readManifest writeManifest updateManifestDB addPatch deleteOldManifests parseNARInfo);
@ -394,9 +395,10 @@ sub deleteOldManifests {
# Parse a NAR info file. # Parse a NAR info file.
sub parseNARInfo { sub parseNARInfo {
my ($storePath, $content) = @_; my ($storePath, $content, $requireValidSig, $location) = @_;
my ($storePath2, $url, $fileHash, $fileSize, $narHash, $narSize, $deriver, $system); my ($storePath2, $url, $fileHash, $fileSize, $narHash, $narSize, $deriver, $system, $sig);
my $signedData = "";
my $compression = "bzip2"; my $compression = "bzip2";
my @refs; my @refs;
@ -412,11 +414,13 @@ sub parseNARInfo {
elsif ($1 eq "References") { @refs = split / /, $2; } elsif ($1 eq "References") { @refs = split / /, $2; }
elsif ($1 eq "Deriver") { $deriver = $2; } elsif ($1 eq "Deriver") { $deriver = $2; }
elsif ($1 eq "System") { $system = $2; } elsif ($1 eq "System") { $system = $2; }
elsif ($1 eq "Signature") { $sig = $2; last; }
$signedData .= "$line\n";
} }
return undef if $storePath ne $storePath2 || !defined $url || !defined $narHash; return undef if $storePath ne $storePath2 || !defined $url || !defined $narHash;
return my $res =
{ url => $url { url => $url
, compression => $compression , compression => $compression
, fileHash => $fileHash , fileHash => $fileHash
@ -427,6 +431,36 @@ sub parseNARInfo {
, deriver => $deriver , deriver => $deriver
, system => $system , system => $system
}; };
if ($requireValidSig) {
if (!defined $sig) {
warn "NAR info file `$location' lacks a signature; ignoring\n";
return undef;
}
my ($sigVersion, $keyName, $sig64) = split ";", $sig;
$sigVersion //= 0;
if ($sigVersion != 1) {
warn "NAR info file `$location' has unsupported version $sigVersion; ignoring\n";
return undef;
}
return undef unless defined $keyName && defined $sig64;
my $publicKeyFile = $Nix::Config::config{"binary-cache-public-key-$keyName"};
if (!defined $publicKeyFile) {
warn "NAR info file `$location' is signed by unknown key `$keyName'; ignoring\n";
return undef;
}
if (! -f $publicKeyFile) {
die "binary cache public key file `$publicKeyFile' does not exist\n";
return undef;
}
if (!isValidSignature($publicKeyFile, $sig64, $signedData)) {
warn "NAR info file `$location' has an invalid signature; ignoring\n";
return undef;
}
$res->{signedBy} = $keyName;
}
return $res;
} }

View file

@ -42,6 +42,8 @@ my $caBundle = $ENV{"CURL_CA_BUNDLE"} // $ENV{"OPENSSL_X509_CERT_FILE"};
my $userName = getpwuid($<) or die "cannot figure out user name"; my $userName = getpwuid($<) or die "cannot figure out user name";
my $requireSignedBinaryCaches = ($Nix::Config::config{"signed-binary-caches"} // "0") ne "0";
sub addRequest { sub addRequest {
my ($storePath, $url, $head) = @_; my ($storePath, $url, $head) = @_;
@ -120,9 +122,10 @@ sub processRequests {
sub initCache { sub initCache {
my $dbPath = "$Nix::Config::stateDir/binary-cache-v2.sqlite"; my $dbPath = "$Nix::Config::stateDir/binary-cache-v3.sqlite";
unlink "$Nix::Config::stateDir/binary-cache-v1.sqlite"; unlink "$Nix::Config::stateDir/binary-cache-v1.sqlite";
unlink "$Nix::Config::stateDir/binary-cache-v2.sqlite";
# Open/create the database. # Open/create the database.
$dbh = DBI->connect("dbi:SQLite:dbname=$dbPath", "", "") $dbh = DBI->connect("dbi:SQLite:dbname=$dbPath", "", "")
@ -159,7 +162,7 @@ EOF
narSize integer, narSize integer,
refs text, refs text,
deriver text, deriver text,
system text, signedBy text,
timestamp integer not null, timestamp integer not null,
primary key (cache, storePath), primary key (cache, storePath),
foreign key (cache) references BinaryCaches(id) on delete cascade foreign key (cache) references BinaryCaches(id) on delete cascade
@ -183,7 +186,7 @@ EOF
$insertNAR = $dbh->prepare( $insertNAR = $dbh->prepare(
"insert or replace into NARs(cache, storePath, url, compression, fileHash, fileSize, narHash, " . "insert or replace into NARs(cache, storePath, url, compression, fileHash, fileSize, narHash, " .
"narSize, refs, deriver, system, timestamp) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") or die; "narSize, refs, deriver, signedBy, timestamp) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") or die;
$queryNAR = $dbh->prepare("select * from NARs where cache = ? and storePath = ?") or die; $queryNAR = $dbh->prepare("select * from NARs where cache = ? and storePath = ?") or die;
@ -309,14 +312,16 @@ sub processNARInfo {
return undef; return undef;
} }
my $narInfo = parseNARInfo($storePath, $request->{content}); my $narInfo = parseNARInfo($storePath, $request->{content}, $requireSignedBinaryCaches, $request->{url});
return undef unless defined $narInfo; return undef unless defined $narInfo;
die if $requireSignedBinaryCaches && !defined $narInfo->{signedBy};
# Cache the result. # Cache the result.
$insertNAR->execute( $insertNAR->execute(
$cache->{id}, basename($storePath), $narInfo->{url}, $narInfo->{compression}, $cache->{id}, basename($storePath), $narInfo->{url}, $narInfo->{compression},
$narInfo->{fileHash}, $narInfo->{fileSize}, $narInfo->{narHash}, $narInfo->{narSize}, $narInfo->{fileHash}, $narInfo->{fileSize}, $narInfo->{narHash}, $narInfo->{narSize},
join(" ", @{$narInfo->{refs}}), $narInfo->{deriver}, $narInfo->{system}, time()) join(" ", @{$narInfo->{refs}}), $narInfo->{deriver}, $narInfo->{signedBy}, time())
if shouldCache $request->{url}; if shouldCache $request->{url};
return $narInfo; return $narInfo;
@ -330,6 +335,10 @@ sub getCachedInfoFrom {
my $res = $queryNAR->fetchrow_hashref(); my $res = $queryNAR->fetchrow_hashref();
return undef unless defined $res; return undef unless defined $res;
# We may previously have cached this info when signature checking
# was disabled. In that case, ignore the cached info.
return undef if $requireSignedBinaryCaches && !defined $res->{signedBy};
return return
{ url => $res->{url} { url => $res->{url}
, compression => $res->{compression} , compression => $res->{compression}
@ -339,6 +348,7 @@ sub getCachedInfoFrom {
, narSize => $res->{narSize} , narSize => $res->{narSize}
, refs => [ split " ", $res->{refs} ] , refs => [ split " ", $res->{refs} ]
, deriver => $res->{deriver} , deriver => $res->{deriver}
, signedBy => $res->{signedBy}
} if defined $res; } if defined $res;
} }
@ -522,7 +532,8 @@ sub downloadBinary {
next; next;
} }
my $url = "$cache->{url}/$info->{url}"; # FIXME: handle non-relative URLs my $url = "$cache->{url}/$info->{url}"; # FIXME: handle non-relative URLs
print STDERR "\n*** Downloading $url to $storePath...\n"; die if $requireSignedBinaryCaches && !defined $info->{signedBy};
print STDERR "\n*** Downloading $url ", ($requireSignedBinaryCaches ? "(signed by $info->{signedBy}) " : ""), "to $storePath...\n";
checkURL $url; checkURL $url;
if (system("$Nix::Config::curl --fail --location --insecure '$url' $decompressor | $Nix::Config::binDir/nix-store --restore $destPath") != 0) { if (system("$Nix::Config::curl --fail --location --insecure '$url' $decompressor | $Nix::Config::binDir/nix-store --restore $destPath") != 0) {
warn "download of `$url' failed" . ($! ? ": $!" : "") . "\n"; warn "download of `$url' failed" . ($! ? ": $!" : "") . "\n";
@ -530,6 +541,7 @@ sub downloadBinary {
} }
# Tell Nix about the expected hash so it can verify it. # Tell Nix about the expected hash so it can verify it.
die unless defined $info->{narHash} && $info->{narHash} ne "";
print "$info->{narHash}\n"; print "$info->{narHash}\n";
print STDERR "\n"; print STDERR "\n";

View file

@ -10,6 +10,7 @@ use Nix::Config;
use Nix::Store; use Nix::Store;
use Nix::Manifest; use Nix::Manifest;
use Nix::Utils; use Nix::Utils;
use Nix::Crypto;
my $tmpDir = tempdir("nix-push.XXXXXX", CLEANUP => 1, TMPDIR => 1) my $tmpDir = tempdir("nix-push.XXXXXX", CLEANUP => 1, TMPDIR => 1)
or die "cannot create a temporary directory"; or die "cannot create a temporary directory";
@ -25,6 +26,8 @@ my $writeManifest = 0;
my $manifestPath; my $manifestPath;
my $archivesURL; my $archivesURL;
my $link = 0; my $link = 0;
my $privateKeyFile;
my $keyName;
my @roots; my @roots;
for (my $n = 0; $n < scalar @ARGV; $n++) { for (my $n = 0; $n < scalar @ARGV; $n++) {
@ -57,6 +60,14 @@ for (my $n = 0; $n < scalar @ARGV; $n++) {
$archivesURL = $ARGV[$n]; $archivesURL = $ARGV[$n];
} elsif ($arg eq "--link") { } elsif ($arg eq "--link") {
$link = 1; $link = 1;
} elsif ($arg eq "--key") {
$n++;
die "$0: `$arg' requires an argument\n" unless $n < scalar @ARGV;
$privateKeyFile = $ARGV[$n];
} elsif ($arg eq "--key-name") {
$n++;
die "$0: `$arg' requires an argument\n" unless $n < scalar @ARGV;
$keyName = $ARGV[$n];
} elsif (substr($arg, 0, 1) eq "-") { } elsif (substr($arg, 0, 1) eq "-") {
die "$0: unknown flag `$arg'\n"; die "$0: unknown flag `$arg'\n";
} else { } else {
@ -99,7 +110,7 @@ foreach my $storePath (@storePaths) {
my $pathHash = substr(basename($storePath), 0, 32); my $pathHash = substr(basename($storePath), 0, 32);
my $narInfoFile = "$destDir/$pathHash.narinfo"; my $narInfoFile = "$destDir/$pathHash.narinfo";
if (-e $narInfoFile) { if (-e $narInfoFile) {
my $narInfo = parseNARInfo($storePath, readFile($narInfoFile)); my $narInfo = parseNARInfo($storePath, readFile($narInfoFile), 0, $narInfoFile) or die "cannot read `$narInfoFile'\n";
my $narFile = "$destDir/$narInfo->{url}"; my $narFile = "$destDir/$narInfo->{url}";
if (-e $narFile) { if (-e $narFile) {
print STDERR "skipping existing $storePath\n"; print STDERR "skipping existing $storePath\n";
@ -245,6 +256,11 @@ for (my $n = 0; $n < scalar @storePaths2; $n++) {
} }
} }
if (defined $privateKeyFile && defined $keyName) {
my $sig = signString($privateKeyFile, $info);
$info .= "Signature: 1;$keyName;$sig\n";
}
my $pathHash = substr(basename($storePath), 0, 32); my $pathHash = substr(basename($storePath), 0, 32);
$dst = "$destDir/$pathHash.narinfo"; $dst = "$destDir/$pathHash.narinfo";

View file

@ -35,6 +35,7 @@
-e "s^@version\@^$(VERSION)^g" \ -e "s^@version\@^$(VERSION)^g" \
-e "s^@perlbindings\@^$(perlbindings)^g" \ -e "s^@perlbindings\@^$(perlbindings)^g" \
-e "s^@testPath\@^$(coreutils):$$(dirname $$(type -p expr))^g" \ -e "s^@testPath\@^$(coreutils):$$(dirname $$(type -p expr))^g" \
-e "s^@openssl\@^$(openssl_prog)^g" \
< $< > $@ || rm $@ < $< > $@ || rm $@
if test -x $<; then chmod +x $@; fi if test -x $<; then chmod +x $@; fi

View file

@ -40,6 +40,14 @@ nix-store --check-validity $outPath
nix-store -qR $outPath | grep input-2 nix-store -qR $outPath | grep input-2
# Test whether this unsigned cache is rejected if the user requires signed caches.
clearStore
rm -f $NIX_STATE_DIR/binary-cache*
nix-store --option binary-caches "file://$cacheDir" --option signed-binary-caches 1 -r $outPath
# Test whether fallback works if we have cached info but the # Test whether fallback works if we have cached info but the
# corresponding NAR has disappeared. # corresponding NAR has disappeared.
clearStore clearStore