Skip to content

Commit 343f11a

Browse files
committed
2 parents 9c5690b + 4c510d6 commit 343f11a

File tree

2 files changed

+158
-3
lines changed

2 files changed

+158
-3
lines changed

tools/pinned.pl

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/perl
2+
# An apt method that validates InRelease files are a configured hash value. Useful to ensure the package repo is exactly what is expected.
3+
use strict;
4+
use warnings;
5+
use IPC::Open2;
6+
use feature 'signatures';
7+
no warnings 'experimental::signatures';
8+
9+
$| = 1; # disable output buffering
10+
11+
my %expected_hashes;
12+
my ($http_child_out, $http_child_in);
13+
my $pid = open2($http_child_out, $http_child_in, "/usr/lib/apt/methods/http");
14+
15+
# get the capabilities from the http method and forward it on to apt unchanged
16+
print STDOUT read_block($http_child_out);
17+
18+
# read the configuration from apt and forward it to http method unchanged, but also inspect for our configuration
19+
my @config_block = read_block(*STDIN);
20+
if (@config_block && $config_block[0] =~ /^601 Configuration/) {
21+
for my $line (@config_block) {
22+
if ($line =~ /^Config-Item: ?Acquire::pinned::InReleaseHashes::([^=]+)=([^\s]+)/) {
23+
my ($key, $hash) = ($1, $2);
24+
$expected_hashes{$key} = $hash;
25+
}
26+
}
27+
print $http_child_in @config_block;
28+
} else {
29+
die "did not receive 601 Configuration message from apt as expected.\n";
30+
}
31+
32+
#main loop: read a block from apt with the command to run
33+
while (my @command_block = read_block(*STDIN)) {
34+
my $original_uri_from_apt = parse_block(@command_block)->{URI} // die "failed to get URI from command block\n";
35+
#perform some filtering on the headers before forwarding on to http method
36+
@command_block = map { s/(URI: ?)pinned:/$1http:/r } # replace URI: pinned://... with URI: http://...
37+
grep { !/^Last-Modified:/ } # scrub Last-Modified because cache hits will not have a file to check hash against
38+
@command_block;
39+
40+
print $http_child_in @command_block;
41+
42+
#make note if we're getting an InRelease file and the expected hash of it. We need to track this now because we may encounter a redirect
43+
# to a very opaque filename
44+
my $expected_hash;
45+
if($original_uri_from_apt =~ /InRelease$/) {
46+
my ($inrelease_suite) = $original_uri_from_apt =~ m{/([^/]+)/InRelease$} or die "\nInRelease without a suite match\n";
47+
unless (exists $expected_hashes{$inrelease_suite}) {
48+
die "Unconfigured hash for suite '$inrelease_suite'\n";
49+
}
50+
$expected_hash = $expected_hashes{$inrelease_suite};
51+
}
52+
elsif($original_uri_from_apt =~ /Release$/) { #just paranoia that apt doesn't fetch from this unchecked Release file instead
53+
die "Got $original_uri_from_apt which is not expected";
54+
}
55+
56+
#read back all response blocks from http method until seeing a result code that is the end of the request
57+
while (my @child_response_block = read_block($http_child_out)) {
58+
my $child_response_info = parse_block(@child_response_block);
59+
60+
#annoyingly, on buster, the http method won't handle redirects itself and expects apt to with this 103. Allowing apt
61+
# to handle the redirect makes it much harder to be certain we are validating the file we think we ought to be validating.
62+
# So we'll have to deal with the redirect ourselves.
63+
if($child_response_info->{Code} == 103) {
64+
@command_block = map { s/^URI:.*$/URI: $child_response_info->{'New-URI'}/r } @command_block;
65+
print $http_child_in @command_block;
66+
}
67+
elsif($child_response_info->{Code} < 201) {
68+
# an "in progress" notification; forward through
69+
print STDOUT inject_original_uri($original_uri_from_apt, @child_response_block);
70+
}
71+
else {
72+
# completion message; check if this is for InRelease and verify hash, or if for any other file just forward through.
73+
# for failure cases 'die' which stops apt in its tracks, otherwise it might ignore a failure if a local cached file is still available.
74+
if ($child_response_info->{Code} == 201 && $expected_hash) {
75+
if (validate_file($child_response_info->{Filename}, $expected_hash)) {
76+
print STDOUT inject_original_uri($original_uri_from_apt, @child_response_block);
77+
} else {
78+
die "\nHash mismatch for $child_response_info->{URI}\n";
79+
}
80+
}
81+
else {
82+
print STDOUT inject_original_uri($original_uri_from_apt, @child_response_block);
83+
}
84+
85+
last;
86+
}
87+
}
88+
}
89+
90+
close($http_child_in);
91+
close($http_child_out);
92+
waitpid($pid, 0);
93+
94+
sub read_block($fh) {
95+
my @lines;
96+
return @lines unless defined(my $first_line = <$fh>);
97+
push @lines, $first_line;
98+
while (my $line = <$fh>) {
99+
push @lines, $line;
100+
last if $line eq "\n";
101+
}
102+
return @lines;
103+
}
104+
105+
sub parse_block(@block) {
106+
return {} unless @block;
107+
my $info = {};
108+
if ($block[0] =~ /^(\d+)\s+(.*)/) {
109+
$info->{Code} = $1;
110+
$info->{Description} = $2;
111+
}
112+
for my $line (@block[1 .. $#block]) {
113+
if ($line =~ /^([^:]+):\s*(.*)/) {
114+
$info->{$1} = $2;
115+
}
116+
}
117+
return $info;
118+
}
119+
120+
#responses need to go back to apt with the URL it originally sent; since we're handling redirects internally need to touch this up
121+
sub inject_original_uri($original_uri, @response_block) {
122+
return map { s/^URI:.*$/URI: $original_uri/r } @response_block;
123+
}
124+
125+
sub validate_file($file_path, $expected_hash) {
126+
return 0 unless defined $file_path && -f $file_path;
127+
128+
#can't use Digest::SHA as that package is not installed; call off to sha256sum instead
129+
my ($sha_out, $sha_in);
130+
my $sha_pid = open2($sha_out, $sha_in, 'sha256sum', $file_path);
131+
close $sha_in;
132+
133+
my $output = <$sha_out>;
134+
close $sha_out;
135+
waitpid($sha_pid, 0);
136+
137+
return 0 if $? != 0; #command failed
138+
return 0 unless defined $output;
139+
140+
chomp $output;
141+
my ($calculated_hash) = split /\s+/, $output;
142+
return 0 unless defined $calculated_hash;
143+
144+
return $calculated_hash eq $expected_hash;
145+
}

tools/reproducible.Dockerfile

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@ ENV SOURCE_DATE_EPOCH=1752331000
1010
# nominally exists to ensure older versions of the package repo which may contain defective packages aren't served in the far
1111
# future. But in our case, we want this pinned package repo at any future date. So [check-valid-until=no] to disable this check.
1212
RUN DATETIMESTR=$(date -d @${SOURCE_DATE_EPOCH} +%Y%m%dT%H%M%SZ) && cat <<EOF > /etc/apt/sources.list
13-
deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DATETIMESTR}/ buster main
14-
deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DATETIMESTR}/ buster-updates main
15-
deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DATETIMESTR}/ buster/updates main
13+
deb [check-valid-until=no] pinned://snapshot.debian.org/archive/debian/${DATETIMESTR}/ buster main
14+
deb [check-valid-until=no] pinned://snapshot.debian.org/archive/debian/${DATETIMESTR}/ buster-updates main
15+
deb [check-valid-until=no] pinned://snapshot.debian.org/archive/debian-security/${DATETIMESTR}/ buster/updates main
16+
EOF
17+
18+
# Install the 'pinned' apt method, and define required hashes for the InRelease files of the package repos
19+
COPY tools/pinned.pl /usr/lib/apt/methods/pinned
20+
RUN cat <<EOF > /etc/apt/apt.conf.d/99pinned.conf
21+
Acquire::pinned::InReleaseHashes {
22+
"buster" "d2126c57347cfe5ca81d912ddfecc02e9a741c6e100d8d8295b735f979bc1a9d";
23+
"buster-updates" "2efadfba571a0c888a8e0175c6f782f7a0afe18dee0e3fbcf1939931639749b8";
24+
"updates" "5a9bda70b67ba71088bc7576dd6ee078f75428ea6291ca7b22ac7d79a9ec73e8";
25+
};
1626
EOF
1727

1828
RUN apt-get update && apt-get -y upgrade && DEBIAN_FRONTEND=noninteractive apt-get -y install build-essential \

0 commit comments

Comments
 (0)