Skip to content

Commit b89363e

Browse files
committed
signed push: fortify against replay attacks
In order to prevent a valid push certificate for pushing into an repository from getting replayed in a different push operation, send a nonce string from the receive-pack process and have the signer include it in the push certificate. The receiving end uses an HMAC hash of the path to the repository it serves and the current time stamp, hashed with a secret seed (the secret seed does not have to be per-repository but can be defined in /etc/gitconfig) to generate the nonce, in order to ensure that a random third party cannot forge a nonce that looks like it originated from it. The original nonce is exported as GIT_PUSH_CERT_NONCE for the hooks to examine and match against the value on the "nonce" header in the certificate to notice a replay, but returned "nonce" header in the push certificate is examined by receive-pack and the result is exported as GIT_PUSH_CERT_NONCE_STATUS, whose value would be "OK" if the nonce recorded in the certificate matches what we expect, so that the hooks can more easily check. Signed-off-by: Junio C Hamano <[email protected]>
1 parent 9be8916 commit b89363e

File tree

7 files changed

+187
-29
lines changed

7 files changed

+187
-29
lines changed

Documentation/config.txt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2038,17 +2038,17 @@ rebase.autostash::
20382038
successful rebase might result in non-trivial conflicts.
20392039
Defaults to false.
20402040

2041-
receive.acceptpushcert::
2042-
By default, `git receive-pack` will advertise that it
2043-
accepts `git push --signed`. Setting this variable to
2044-
false disables it (this is a tentative variable that
2045-
will go away at the end of this series).
2046-
20472041
receive.autogc::
20482042
By default, git-receive-pack will run "git-gc --auto" after
20492043
receiving data from git-push and updating refs. You can stop
20502044
it by setting this variable to false.
20512045

2046+
receive.certnonceseed::
2047+
By setting this variable to a string, `git receive-pack`
2048+
will accept a `git push --signed` and verifies it by using
2049+
a "nonce" protected by HMAC using this string as a secret
2050+
key.
2051+
20522052
receive.fsckObjects::
20532053
If it is set to true, git-receive-pack will check all received
20542054
objects. It will abort in the case of a malformed object or a

Documentation/git-receive-pack.txt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ the following environment variables:
7272
using the same mnemonic as used in `%G?` format of `git log`
7373
family of commands (see linkgit:git-log[1]).
7474

75+
`GIT_PUSH_CERT_NONCE`::
76+
The nonce string the process asked the signer to include
77+
in the push certificate. If this does not match the value
78+
recorded on the "nonce" header in the push certificate, it
79+
may indicate that the certificate is a valid one that is
80+
being replayed from a separate "git push" session.
81+
82+
`GIT_PUSH_CERT_NONCE_STATUS`::
83+
`UNSOLICITED`;;
84+
"git push --signed" sent a nonce when we did not ask it to
85+
send one.
86+
`MISSING`;;
87+
"git push --signed" did not send any nonce header.
88+
`BAD`;;
89+
"git push --signed" sent a bogus nonce.
90+
`OK`;;
91+
"git push --signed" sent the nonce we asked it to send.
92+
7593
This hook is called before any refname is updated and before any
7694
fast-forward checks are performed.
7795

@@ -147,6 +165,7 @@ service:
147165
if test -n "${GIT_PUSH_CERT-}" && test ${GIT_PUSH_CERT_STATUS} = G
148166
then
149167
(
168+
echo expected nonce is ${GIT_PUSH_NONCE}
150169
git cat-file blob ${GIT_PUSH_CERT}
151170
) | mail -s "push certificate from $GIT_PUSH_CERT_SIGNER" push-log@mydomain
152171
fi

Documentation/technical/pack-protocol.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ references.
485485
PKT-LINE("certificate version 0.1" LF)
486486
PKT-LINE("pusher" SP ident LF)
487487
PKT-LINE("pushee" SP url LF)
488+
PKT-LINE("nonce" SP nonce LF)
488489
PKT-LINE(LF)
489490
*PKT-LINE(command LF)
490491
*PKT-LINE(gpg-signature-lines LF)
@@ -533,6 +534,11 @@ Currently, the following header fields are defined:
533534
authentication material) the user who ran `git push`
534535
intended to push into.
535536

537+
`nonce` nonce::
538+
The 'nonce' string the receiving repository asked the
539+
pushing user to include in the certificate, to prevent
540+
replay attacks.
541+
536542
The GPG signature lines are a detached signature for the contents
537543
recorded in the push certificate before the signature block begins.
538544
The detached signature is used to certify that the commands were

Documentation/technical/protocol-capabilities.txt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,10 +251,11 @@ If the upload-pack server advertises this capability, fetch-pack may
251251
send "want" lines with SHA-1s that exist at the server but are not
252252
advertised by upload-pack.
253253

254-
push-cert
255-
---------
254+
push-cert=<nonce>
255+
-----------------
256256

257257
The receive-pack server that advertises this capability is willing
258-
to accept a signed push certificate. A send-pack client MUST NOT
258+
to accept a signed push certificate, and asks the <nonce> to be
259+
included in the push certificate. A send-pack client MUST NOT
259260
send a push-cert packet unless the receive-pack server advertises
260261
this capability.

builtin/receive-pack.c

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,17 @@ static void *head_name_to_free;
4848
static int sent_capabilities;
4949
static int shallow_update;
5050
static const char *alt_shallow_file;
51-
static int accept_push_cert = 1;
5251
static struct strbuf push_cert = STRBUF_INIT;
5352
static unsigned char push_cert_sha1[20];
5453
static struct signature_check sigcheck;
54+
static const char *push_cert_nonce;
55+
static const char *cert_nonce_seed;
56+
57+
static const char *NONCE_UNSOLICITED = "UNSOLICITED";
58+
static const char *NONCE_BAD = "BAD";
59+
static const char *NONCE_MISSING = "MISSING";
60+
static const char *NONCE_OK = "OK";
61+
static const char *nonce_status;
5562

5663
static enum deny_action parse_deny_action(const char *var, const char *value)
5764
{
@@ -135,10 +142,8 @@ static int receive_pack_config(const char *var, const char *value, void *cb)
135142
return 0;
136143
}
137144

138-
if (strcmp(var, "receive.acceptpushcert") == 0) {
139-
accept_push_cert = git_config_bool(var, value);
140-
return 0;
141-
}
145+
if (strcmp(var, "receive.certnonceseed") == 0)
146+
return git_config_string(&cert_nonce_seed, var, value);
142147

143148
return git_default_config(var, value, cb);
144149
}
@@ -157,8 +162,8 @@ static void show_ref(const char *path, const unsigned char *sha1)
157162
"report-status delete-refs side-band-64k quiet");
158163
if (prefer_ofs_delta)
159164
strbuf_addstr(&cap, " ofs-delta");
160-
if (accept_push_cert)
161-
strbuf_addstr(&cap, " push-cert");
165+
if (push_cert_nonce)
166+
strbuf_addf(&cap, " push-cert=%s", push_cert_nonce);
162167
strbuf_addf(&cap, " agent=%s", git_user_agent_sanitized());
163168
packet_write(1, "%s %s%c%s\n",
164169
sha1_to_hex(sha1), path, 0, cap.buf);
@@ -271,6 +276,110 @@ static int copy_to_sideband(int in, int out, void *arg)
271276
return 0;
272277
}
273278

279+
#define HMAC_BLOCK_SIZE 64
280+
281+
static void hmac_sha1(unsigned char out[20],
282+
const char *key_in, size_t key_len,
283+
const char *text, size_t text_len)
284+
{
285+
unsigned char key[HMAC_BLOCK_SIZE];
286+
unsigned char k_ipad[HMAC_BLOCK_SIZE];
287+
unsigned char k_opad[HMAC_BLOCK_SIZE];
288+
int i;
289+
git_SHA_CTX ctx;
290+
291+
/* RFC 2104 2. (1) */
292+
memset(key, '\0', HMAC_BLOCK_SIZE);
293+
if (HMAC_BLOCK_SIZE < key_len) {
294+
git_SHA1_Init(&ctx);
295+
git_SHA1_Update(&ctx, key_in, key_len);
296+
git_SHA1_Final(key, &ctx);
297+
} else {
298+
memcpy(key, key_in, key_len);
299+
}
300+
301+
/* RFC 2104 2. (2) & (5) */
302+
for (i = 0; i < sizeof(key); i++) {
303+
k_ipad[i] = key[i] ^ 0x36;
304+
k_opad[i] = key[i] ^ 0x5c;
305+
}
306+
307+
/* RFC 2104 2. (3) & (4) */
308+
git_SHA1_Init(&ctx);
309+
git_SHA1_Update(&ctx, k_ipad, sizeof(k_ipad));
310+
git_SHA1_Update(&ctx, text, text_len);
311+
git_SHA1_Final(out, &ctx);
312+
313+
/* RFC 2104 2. (6) & (7) */
314+
git_SHA1_Init(&ctx);
315+
git_SHA1_Update(&ctx, k_opad, sizeof(k_opad));
316+
git_SHA1_Update(&ctx, out, sizeof(out));
317+
git_SHA1_Final(out, &ctx);
318+
}
319+
320+
static char *prepare_push_cert_nonce(const char *path, unsigned long stamp)
321+
{
322+
struct strbuf buf = STRBUF_INIT;
323+
unsigned char sha1[20];
324+
325+
strbuf_addf(&buf, "%s:%lu", path, stamp);
326+
hmac_sha1(sha1, buf.buf, buf.len, cert_nonce_seed, strlen(cert_nonce_seed));;
327+
strbuf_release(&buf);
328+
329+
/* RFC 2104 5. HMAC-SHA1-80 */
330+
strbuf_addf(&buf, "%lu-%.*s", stamp, 20, sha1_to_hex(sha1));
331+
return strbuf_detach(&buf, NULL);
332+
}
333+
334+
/*
335+
* NEEDSWORK: reuse find_commit_header() from jk/commit-author-parsing
336+
* after dropping "_commit" from its name and possibly moving it out
337+
* of commit.c
338+
*/
339+
static char *find_header(const char *msg, size_t len, const char *key)
340+
{
341+
int key_len = strlen(key);
342+
const char *line = msg;
343+
344+
while (line && line < msg + len) {
345+
const char *eol = strchrnul(line, '\n');
346+
347+
if ((msg + len <= eol) || line == eol)
348+
return NULL;
349+
if (line + key_len < eol &&
350+
!memcmp(line, key, key_len) && line[key_len] == ' ') {
351+
int offset = key_len + 1;
352+
return xmemdupz(line + offset, (eol - line) - offset);
353+
}
354+
line = *eol ? eol + 1 : NULL;
355+
}
356+
return NULL;
357+
}
358+
359+
static const char *check_nonce(const char *buf, size_t len)
360+
{
361+
char *nonce = find_header(buf, len, "nonce");
362+
const char *retval = NONCE_BAD;
363+
364+
if (!nonce) {
365+
retval = NONCE_MISSING;
366+
goto leave;
367+
} else if (!push_cert_nonce) {
368+
retval = NONCE_UNSOLICITED;
369+
goto leave;
370+
} else if (!strcmp(push_cert_nonce, nonce)) {
371+
retval = NONCE_OK;
372+
goto leave;
373+
}
374+
375+
/* returned nonce MUST match what we gave out earlier */
376+
retval = NONCE_BAD;
377+
378+
leave:
379+
free(nonce);
380+
return retval;
381+
}
382+
274383
static void prepare_push_cert_sha1(struct child_process *proc)
275384
{
276385
static int already_done;
@@ -305,6 +414,7 @@ static void prepare_push_cert_sha1(struct child_process *proc)
305414

306415
strbuf_release(&gpg_output);
307416
strbuf_release(&gpg_status);
417+
nonce_status = check_nonce(push_cert.buf, bogs);
308418
}
309419
if (!is_null_sha1(push_cert_sha1)) {
310420
argv_array_pushf(&env, "GIT_PUSH_CERT=%s", sha1_to_hex(push_cert_sha1));
@@ -313,7 +423,10 @@ static void prepare_push_cert_sha1(struct child_process *proc)
313423
argv_array_pushf(&env, "GIT_PUSH_CERT_KEY=%s",
314424
sigcheck.key ? sigcheck.key : "");
315425
argv_array_pushf(&env, "GIT_PUSH_CERT_STATUS=%c", sigcheck.result);
316-
426+
if (push_cert_nonce) {
427+
argv_array_pushf(&env, "GIT_PUSH_CERT_NONCE=%s", push_cert_nonce);
428+
argv_array_pushf(&env, "GIT_PUSH_CERT_NONCE_STATUS=%s", nonce_status);
429+
}
317430
proc->env = env.argv;
318431
}
319432
}
@@ -1296,6 +1409,8 @@ int cmd_receive_pack(int argc, const char **argv, const char *prefix)
12961409
die("'%s' does not appear to be a git repository", dir);
12971410

12981411
git_config(receive_pack_config, NULL);
1412+
if (cert_nonce_seed)
1413+
push_cert_nonce = prepare_push_cert_nonce(dir, time(NULL));
12991414

13001415
if (0 <= transfer_unpack_limit)
13011416
unpack_limit = transfer_unpack_limit;
@@ -1340,5 +1455,6 @@ int cmd_receive_pack(int argc, const char **argv, const char *prefix)
13401455
packet_flush(1);
13411456
sha1_array_clear(&shallow);
13421457
sha1_array_clear(&ref);
1458+
free((void *)push_cert_nonce);
13431459
return 0;
13441460
}

send-pack.c

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,8 @@ static const char *next_line(const char *line, size_t len)
228228
static int generate_push_cert(struct strbuf *req_buf,
229229
const struct ref *remote_refs,
230230
struct send_pack_args *args,
231-
const char *cap_string)
231+
const char *cap_string,
232+
const char *push_cert_nonce)
232233
{
233234
const struct ref *ref;
234235
char stamp[60];
@@ -245,6 +246,8 @@ static int generate_push_cert(struct strbuf *req_buf,
245246
strbuf_addf(&cert, "pushee %s\n", anon_url);
246247
free(anon_url);
247248
}
249+
if (push_cert_nonce[0])
250+
strbuf_addf(&cert, "nonce %s\n", push_cert_nonce);
248251
strbuf_addstr(&cert, "\n");
249252

250253
for (ref = remote_refs; ref; ref = ref->next) {
@@ -295,6 +298,7 @@ int send_pack(struct send_pack_args *args,
295298
unsigned cmds_sent = 0;
296299
int ret;
297300
struct async demux;
301+
const char *push_cert_nonce = NULL;
298302

299303
/* Does the other end support the reporting? */
300304
if (server_supports("report-status"))
@@ -311,8 +315,14 @@ int send_pack(struct send_pack_args *args,
311315
agent_supported = 1;
312316
if (server_supports("no-thin"))
313317
args->use_thin_pack = 0;
314-
if (args->push_cert && !server_supports("push-cert"))
315-
die(_("the receiving end does not support --signed push"));
318+
if (args->push_cert) {
319+
int len;
320+
321+
push_cert_nonce = server_feature_value("push-cert", &len);
322+
if (!push_cert_nonce)
323+
die(_("the receiving end does not support --signed push"));
324+
push_cert_nonce = xmemdupz(push_cert_nonce, len);
325+
}
316326

317327
if (!remote_refs) {
318328
fprintf(stderr, "No refs in common and none specified; doing nothing.\n"
@@ -343,7 +353,7 @@ int send_pack(struct send_pack_args *args,
343353

344354
if (!args->dry_run && args->push_cert)
345355
cmds_sent = generate_push_cert(&req_buf, remote_refs, args,
346-
cap_buf.buf);
356+
cap_buf.buf, push_cert_nonce);
347357

348358
/*
349359
* Clear the status for each ref and see if we need to send

t/t5534-push-signed.sh

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ test_expect_success 'unsigned push does not send push certificate' '
5050
test_expect_success 'talking with a receiver without push certificate support' '
5151
prepare_dst &&
5252
mkdir -p dst/.git/hooks &&
53-
git -C dst config receive.acceptpushcert no &&
5453
write_script dst/.git/hooks/post-receive <<-\EOF &&
5554
# discard the update list
5655
cat >/dev/null
@@ -68,7 +67,6 @@ test_expect_success 'talking with a receiver without push certificate support' '
6867
test_expect_success 'push --signed fails with a receiver without push certificate support' '
6968
prepare_dst &&
7069
mkdir -p dst/.git/hooks &&
71-
git -C dst config receive.acceptpushcert no &&
7270
test_must_fail git push --signed dst noop ff +noff 2>err &&
7371
test_i18ngrep "the receiving end does not support" err
7472
'
@@ -89,6 +87,7 @@ test_expect_success GPG 'no certificate for a signed push with no update' '
8987
test_expect_success GPG 'signed push sends push certificate' '
9088
prepare_dst &&
9189
mkdir -p dst/.git/hooks &&
90+
git -C dst config receive.certnonceseed sekrit &&
9291
write_script dst/.git/hooks/post-receive <<-\EOF &&
9392
# discard the update list
9493
cat >/dev/null
@@ -102,17 +101,24 @@ test_expect_success GPG 'signed push sends push certificate' '
102101
SIGNER=${GIT_PUSH_CERT_SIGNER-nobody}
103102
KEY=${GIT_PUSH_CERT_KEY-nokey}
104103
STATUS=${GIT_PUSH_CERT_STATUS-nostatus}
104+
NONCE_STATUS=${GIT_PUSH_CERT_NONCE_STATUS-nononcestatus}
105+
NONCE=${GIT_PUSH_CERT_NONCE-nononce}
105106
E_O_F
106107
107108
EOF
108109
109-
cat >expect <<-\EOF &&
110-
SIGNER=C O Mitter <[email protected]>
111-
KEY=13B6F51ECDDE430D
112-
STATUS=G
113-
EOF
114-
115110
git push --signed dst noop ff +noff &&
111+
112+
(
113+
cat <<-\EOF &&
114+
SIGNER=C O Mitter <[email protected]>
115+
KEY=13B6F51ECDDE430D
116+
STATUS=G
117+
NONCE_STATUS=OK
118+
EOF
119+
sed -n -e "s/^nonce /NONCE=/p" -e "/^$/q" dst/push-cert
120+
) >expect &&
121+
116122
grep "$(git rev-parse noop ff) refs/heads/ff" dst/push-cert &&
117123
grep "$(git rev-parse noop noff) refs/heads/noff" dst/push-cert &&
118124
test_cmp expect dst/push-cert-status

0 commit comments

Comments
 (0)