Add contrib/credentials/netrc with GPG support

This credential helper supports multiple files, returning the first one that matches. It checks file permissions and owner. For *.gpg files, it will run GPG to decrypt the file. Signed-off-by: Ted Zlatanov <tzz@lifelogs.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Ted Zlatanov committed Feb 25, 2013 at 10:49 UTC 5482920919f5ff14b1e4b01cf81eab17c32c2734
4 files changed +545
contrib/credential/netrc/Makefile new
+5
@@ -0,0 +1,5 @@
1 +test:
2 + ./test.pl
3 +
4 +testverbose:
5 + ./test.pl -d -v
contrib/credential/netrc/git-credential-netrc new
+421
@@ -0,0 +1,421 @@
1 +#!/usr/bin/perl
2 +
3 +use strict;
4 +use warnings;
5 +
6 +use Getopt::Long;
7 +use File::Basename;
8 +
9 +my $VERSION = "0.1";
10 +
11 +my %options = (
12 + help => 0,
13 + debug => 0,
14 + verbose => 0,
15 + insecure => 0,
16 + file => [],
17 +
18 + # identical token maps, e.g. host -> host, will be inserted later
19 + tmap => {
20 + port => 'protocol',
21 + machine => 'host',
22 + path => 'path',
23 + login => 'username',
24 + user => 'username',
25 + password => 'password',
26 + }
27 + );
28 +
29 +# Map each credential protocol token to itself on the netrc side.
30 +foreach (values %{$options{tmap}}) {
31 + $options{tmap}->{$_} = $_;
32 +}
33 +
34 +# Now, $options{tmap} has a mapping from the netrc format to the Git credential
35 +# helper protocol.
36 +
37 +# Next, we build the reverse token map.
38 +
39 +# When $rmap{foo} contains 'bar', that means that what the Git credential helper
40 +# protocol calls 'bar' is found as 'foo' in the netrc/authinfo file. Keys in
41 +# %rmap are what we expect to read from the netrc/authinfo file.
42 +
43 +my %rmap;
44 +foreach my $k (keys %{$options{tmap}}) {
45 + push @{$rmap{$options{tmap}->{$k}}}, $k;
46 +}
47 +
48 +Getopt::Long::Configure("bundling");
49 +
50 +# TODO: maybe allow the token map $options{tmap} to be configurable.
51 +GetOptions(\%options,
52 + "help|h",
53 + "debug|d",
54 + "insecure|k",
55 + "verbose|v",
56 + "file|f=s@",
57 + );
58 +
59 +if ($options{help}) {
60 + my $shortname = basename($0);
61 + $shortname =~ s/git-credential-//;
62 +
63 + print <<EOHIPPUS;
64 +
65 +$0 [-f AUTHFILE1] [-f AUTHFILEN] [-d] [-v] [-k] get
66 +
67 +Version $VERSION by tzz\@lifelogs.com. License: BSD.
68 +
69 +Options:
70 +
71 + -f|--file AUTHFILE : specify netrc-style files. Files with the .gpg extension
72 + will be decrypted by GPG before parsing. Multiple -f
73 + arguments are OK. They are processed in order, and the
74 + first matching entry found is returned via the credential
75 + helper protocol (see below).
76 +
77 + When no -f option is given, .authinfo.gpg, .netrc.gpg,
78 + .authinfo, and .netrc files in your home directory are used
79 + in this order.
80 +
81 + -k|--insecure : ignore bad file ownership or permissions
82 +
83 + -d|--debug : turn on debugging (developer info)
84 +
85 + -v|--verbose : be more verbose (show files and information found)
86 +
87 +To enable this credential helper:
88 +
89 + git config credential.helper '$shortname -f AUTHFILE1 -f AUTHFILE2'
90 +
91 +(Note that Git will prepend "git-credential-" to the helper name and look for it
92 +in the path.)
93 +
94 +...and if you want lots of debugging info:
95 +
96 + git config credential.helper '$shortname -f AUTHFILE -d'
97 +
98 +...or to see the files opened and data found:
99 +
100 + git config credential.helper '$shortname -f AUTHFILE -v'
101 +
102 +Only "get" mode is supported by this credential helper. It opens every AUTHFILE
103 +and looks for the first entry that matches the requested search criteria:
104 +
105 + 'port|protocol':
106 + The protocol that will be used (e.g., https). (protocol=X)
107 +
108 + 'machine|host':
109 + The remote hostname for a network credential. (host=X)
110 +
111 + 'path':
112 + The path with which the credential will be used. (path=X)
113 +
114 + 'login|user|username':
115 + The credential’s username, if we already have one. (username=X)
116 +
117 +Thus, when we get this query on STDIN:
118 +
119 +host=github.com
120 +protocol=https
121 +username=tzz
122 +
123 +this credential helper will look for the first entry in every AUTHFILE that
124 +matches
125 +
126 +machine github.com port https login tzz
127 +
128 +OR
129 +
130 +machine github.com protocol https login tzz
131 +
132 +OR... etc. acceptable tokens as listed above. Any unknown tokens are
133 +simply ignored.
134 +
135 +Then, the helper will print out whatever tokens it got from the entry, including
136 +"password" tokens, mapping back to Git's helper protocol; e.g. "port" is mapped
137 +back to "protocol". Any redundant entry tokens (part of the original query) are
138 +skipped.
139 +
140 +Again, note that only the first matching entry from all the AUTHFILEs, processed
141 +in the sequence given on the command line, is used.
142 +
143 +Netrc/authinfo tokens can be quoted as 'STRING' or "STRING".
144 +
145 +No caching is performed by this credential helper.
146 +
147 +EOHIPPUS
148 +
149 + exit 0;
150 +}
151 +
152 +my $mode = shift @ARGV;
153 +
154 +# Credentials must get a parameter, so die if it's missing.
155 +die "Syntax: $0 [-f AUTHFILE1] [-f AUTHFILEN] [-d] get" unless defined $mode;
156 +
157 +# Only support 'get' mode; with any other unsupported ones we just exit.
158 +exit 0 unless $mode eq 'get';
159 +
160 +my $files = $options{file};
161 +
162 +# if no files were given, use a predefined list.
163 +# note that .gpg files come first
164 +unless (scalar @$files) {
165 + my @candidates = qw[
166 + ~/.authinfo.gpg
167 + ~/.netrc.gpg
168 + ~/.authinfo
169 + ~/.netrc
170 + ];
171 +
172 + $files = $options{file} = [ map { glob $_ } @candidates ];
173 +}
174 +
175 +my $query = read_credential_data_from_stdin();
176 +
177 +FILE:
178 +foreach my $file (@$files) {
179 + my $gpgmode = $file =~ m/\.gpg$/;
180 + unless (-r $file) {
181 + log_verbose("Unable to read $file; skipping it");
182 + next FILE;
183 + }
184 +
185 + # the following check is copied from Net::Netrc, for non-GPG files
186 + # OS/2 and Win32 do not handle stat in a way compatible with this check :-(
187 + unless ($gpgmode || $options{insecure} ||
188 + $^O eq 'os2'
189 + || $^O eq 'MSWin32'
190 + || $^O eq 'MacOS'
191 + || $^O =~ /^cygwin/) {
192 + my @stat = stat($file);
193 +
194 + if (@stat) {
195 + if ($stat[2] & 077) {
196 + log_verbose("Insecure $file (mode=%04o); skipping it",
197 + $stat[2] & 07777);
198 + next FILE;
199 + }
200 +
201 + if ($stat[4] != $<) {
202 + log_verbose("Not owner of $file; skipping it");
203 + next FILE;
204 + }
205 + }
206 + }
207 +
208 + my @entries = load_netrc($file, $gpgmode);
209 +
210 + unless (scalar @entries) {
211 + if ($!) {
212 + log_verbose("Unable to open $file: $!");
213 + } else {
214 + log_verbose("No netrc entries found in $file");
215 + }
216 +
217 + next FILE;
218 + }
219 +
220 + my $entry = find_netrc_entry($query, @entries);
221 + if ($entry) {
222 + print_credential_data($entry, $query);
223 + # we're done!
224 + last FILE;
225 + }
226 +}
227 +
228 +exit 0;
229 +
230 +sub load_netrc {
231 + my $file = shift @_;
232 + my $gpgmode = shift @_;
233 +
234 + my $io;
235 + if ($gpgmode) {
236 + my @cmd = (qw(gpg --decrypt), $file);
237 + log_verbose("Using GPG to open $file: [@cmd]");
238 + open $io, "-|", @cmd;
239 + } else {
240 + log_verbose("Opening $file...");
241 + open $io, '<', $file;
242 + }
243 +
244 + # nothing to do if the open failed (we log the error later)
245 + return unless $io;
246 +
247 + # Net::Netrc does this, but the functionality is merged with the file
248 + # detection logic, so we have to extract just the part we need
249 + my @netrc_entries = net_netrc_loader($io);
250 +
251 + # these entries will use the credential helper protocol token names
252 + my @entries;
253 +
254 + foreach my $nentry (@netrc_entries) {
255 + my %entry;
256 + my $num_port;
257 +
258 + if (!defined $nentry->{machine}) {
259 + next;
260 + }
261 + if (defined $nentry->{port} && $nentry->{port} =~ m/^\d+$/) {
262 + $num_port = $nentry->{port};
263 + delete $nentry->{port};
264 + }
265 +
266 + # create the new entry for the credential helper protocol
267 + $entry{$options{tmap}->{$_}} = $nentry->{$_} foreach keys %$nentry;
268 +
269 + # for "host X port Y" where Y is an integer (captured by
270 + # $num_port above), set the host to "X:Y"
271 + if (defined $entry{host} && defined $num_port) {
272 + $entry{host} = join(':', $entry{host}, $num_port);
273 + }
274 +
275 + push @entries, \%entry;
276 + }
277 +
278 + return @entries;
279 +}
280 +
281 +sub net_netrc_loader {
282 + my $fh = shift @_;
283 + my @entries;
284 + my ($mach, $macdef, $tok, @tok);
285 +
286 + LINE:
287 + while (<$fh>) {
288 + undef $macdef if /\A\n\Z/;
289 +
290 + if ($macdef) {
291 + next LINE;
292 + }
293 +
294 + s/^\s*//;
295 + chomp;
296 +
297 + while (length && s/^("((?:[^"]+|\\.)*)"|((?:[^\\\s]+|\\.)*))\s*//) {
298 + (my $tok = $+) =~ s/\\(.)/$1/g;
299 + push(@tok, $tok);
300 + }
301 +
302 + TOKEN:
303 + while (@tok) {
304 + if ($tok[0] eq "default") {
305 + shift(@tok);
306 + $mach = { machine => undef };
307 + next TOKEN;
308 + }
309 +
310 + $tok = shift(@tok);
311 +
312 + if ($tok eq "machine") {
313 + my $host = shift @tok;
314 + $mach = { machine => $host };
315 + push @entries, $mach;
316 + } elsif (exists $options{tmap}->{$tok}) {
317 + unless ($mach) {
318 + log_debug("Skipping token $tok because no machine was given");
319 + next TOKEN;
320 + }
321 +
322 + my $value = shift @tok;
323 + unless (defined $value) {
324 + log_debug("Token $tok had no value, skipping it.");
325 + next TOKEN;
326 + }
327 +
328 + # Following line added by rmerrell to remove '/' escape char in .netrc
329 + $value =~ s/\/\\/\\/g;
330 + $mach->{$tok} = $value;
331 + } elsif ($tok eq "macdef") { # we ignore macros
332 + next TOKEN unless $mach;
333 + my $value = shift @tok;
334 + $macdef = 1;
335 + }
336 + }
337 + }
338 +
339 + return @entries;
340 +}
341 +
342 +sub read_credential_data_from_stdin {
343 + # the query: start with every token with no value
344 + my %q = map { $_ => undef } values(%{$options{tmap}});
345 +
346 + while (<STDIN>) {
347 + next unless m/^([^=]+)=(.+)/;
348 +
349 + my ($token, $value) = ($1, $2);
350 + die "Unknown search token $token" unless exists $q{$token};
351 + $q{$token} = $value;
352 + log_debug("We were given search token $token and value $value");
353 + }
354 +
355 + foreach (sort keys %q) {
356 + log_debug("Searching for %s = %s", $_, $q{$_} || '(any value)');
357 + }
358 +
359 + return \%q;
360 +}
361 +
362 +# takes the search tokens and then a list of entries
363 +# each entry is a hash reference
364 +sub find_netrc_entry {
365 + my $query = shift @_;
366 +
367 + ENTRY:
368 + foreach my $entry (@_)
369 + {
370 + my $entry_text = join ', ', map { "$_=$entry->{$_}" } keys %$entry;
371 + foreach my $check (sort keys %$query) {
372 + if (defined $query->{$check}) {
373 + log_debug("compare %s [%s] to [%s] (entry: %s)",
374 + $check,
375 + $entry->{$check},
376 + $query->{$check},
377 + $entry_text);
378 + unless ($query->{$check} eq $entry->{$check}) {
379 + next ENTRY;
380 + }
381 + } else {
382 + log_debug("OK: any value satisfies check $check");
383 + }
384 + }
385 +
386 + return $entry;
387 + }
388 +
389 + # nothing was found
390 + return;
391 +}
392 +
393 +sub print_credential_data {
394 + my $entry = shift @_;
395 + my $query = shift @_;
396 +
397 + log_debug("entry has passed all the search checks");
398 + TOKEN:
399 + foreach my $git_token (sort keys %$entry) {
400 + log_debug("looking for useful token $git_token");
401 + # don't print unknown (to the credential helper protocol) tokens
402 + next TOKEN unless exists $query->{$git_token};
403 +
404 + # don't print things asked in the query (the entry matches them)
405 + next TOKEN if defined $query->{$git_token};
406 +
407 + log_debug("FOUND: $git_token=$entry->{$git_token}");
408 + printf "%s=%s\n", $git_token, $entry->{$git_token};
409 + }
410 +}
411 +sub log_verbose {
412 + return unless $options{verbose};
413 + printf STDERR @_;
414 + printf STDERR "\n";
415 +}
416 +
417 +sub log_debug {
418 + return unless $options{debug};
419 + printf STDERR @_;
420 + printf STDERR "\n";
421 +}
contrib/credential/netrc/test.netrc new
+13
@@ -0,0 +1,13 @@
1 +machine imap login tzz@lifelogs.com port imaps password letmeknow
2 +machine imap login bob port imaps password bobwillknow
3 +
4 +# comment test
5 +
6 +machine imap2 login tzz port 1099 password tzzknow
7 +machine imap2 login bob password bobwillknow
8 +
9 +# another command
10 +
11 +machine github.com
12 + multilinetoken anothervalue
13 + login carol password carolknows
contrib/credential/netrc/test.pl new
+106
@@ -0,0 +1,106 @@
1 +#!/usr/bin/perl
2 +
3 +use warnings;
4 +use strict;
5 +use Test;
6 +use IPC::Open2;
7 +
8 +BEGIN { plan tests => 15 }
9 +
10 +my @global_credential_args = @ARGV;
11 +my $netrc = './test.netrc';
12 +print "# Testing insecure file, nothing should be found\n";
13 +chmod 0644, $netrc;
14 +my $cred = run_credential(['-f', $netrc, 'get'],
15 + { host => 'github.com' });
16 +
17 +ok(scalar keys %$cred, 0, "Got 0 keys from insecure file");
18 +
19 +print "# Testing missing file, nothing should be found\n";
20 +chmod 0644, $netrc;
21 +$cred = run_credential(['-f', '///nosuchfile///', 'get'],
22 + { host => 'github.com' });
23 +
24 +ok(scalar keys %$cred, 0, "Got 0 keys from missing file");
25 +
26 +chmod 0600, $netrc;
27 +
28 +print "# Testing with invalid data\n";
29 +$cred = run_credential(['-f', $netrc, 'get'],
30 + "bad data");
31 +ok(scalar keys %$cred, 4, "Got first found keys with bad data");
32 +
33 +print "# Testing netrc file for a missing corovamilkbar entry\n";
34 +$cred = run_credential(['-f', $netrc, 'get'],
35 + { host => 'corovamilkbar' });
36 +
37 +ok(scalar keys %$cred, 0, "Got no corovamilkbar keys");
38 +
39 +print "# Testing netrc file for a github.com entry\n";
40 +$cred = run_credential(['-f', $netrc, 'get'],
41 + { host => 'github.com' });
42 +
43 +ok(scalar keys %$cred, 2, "Got 2 Github keys");
44 +
45 +ok($cred->{password}, 'carolknows', "Got correct Github password");
46 +ok($cred->{username}, 'carol', "Got correct Github username");
47 +
48 +print "# Testing netrc file for a username-specific entry\n";
49 +$cred = run_credential(['-f', $netrc, 'get'],
50 + { host => 'imap', username => 'bob' });
51 +
52 +ok(scalar keys %$cred, 2, "Got 2 username-specific keys");
53 +
54 +ok($cred->{password}, 'bobwillknow', "Got correct user-specific password");
55 +ok($cred->{protocol}, 'imaps', "Got correct user-specific protocol");
56 +
57 +print "# Testing netrc file for a host:port-specific entry\n";
58 +$cred = run_credential(['-f', $netrc, 'get'],
59 + { host => 'imap2:1099' });
60 +
61 +ok(scalar keys %$cred, 2, "Got 2 host:port-specific keys");
62 +
63 +ok($cred->{password}, 'tzzknow', "Got correct host:port-specific password");
64 +ok($cred->{username}, 'tzz', "Got correct host:port-specific username");
65 +
66 +print "# Testing netrc file that 'host:port kills host' entry\n";
67 +$cred = run_credential(['-f', $netrc, 'get'],
68 + { host => 'imap2' });
69 +
70 +ok(scalar keys %$cred, 2, "Got 2 'host:port kills host' keys");
71 +
72 +ok($cred->{password}, 'bobwillknow', "Got correct 'host:port kills host' password");
73 +ok($cred->{username}, 'bob', "Got correct 'host:port kills host' username");
74 +
75 +sub run_credential
76 +{
77 + my $args = shift @_;
78 + my $data = shift @_;
79 + my $pid = open2(my $chld_out, my $chld_in,
80 + './git-credential-netrc', @global_credential_args,
81 + @$args);
82 +
83 + die "Couldn't open pipe to netrc credential helper: $!" unless $pid;
84 +
85 + if (ref $data eq 'HASH')
86 + {
87 + print $chld_in "$_=$data->{$_}\n" foreach sort keys %$data;
88 + }
89 + else
90 + {
91 + print $chld_in "$data\n";
92 + }
93 +
94 + close $chld_in;
95 + my %ret;
96 +
97 + while (<$chld_out>)
98 + {
99 + chomp;
100 + next unless m/^([^=]+)=(.+)/;
101 +
102 + $ret{$1} = $2;
103 + }
104 +
105 + return \%ret;
106 +}