commit bd84be8a1a59cfed38bb44a3d921b3b16ffa6469 from: Stefan Sperling date: Mon Aug 19 16:50:50 2024 UTC add support for HMAC digests to gotd HTTP notifications Works similar to how several Git forges authenticate their webhooks. Feature requested by dch@freebsd. Help from tb@ regarding which libcrypto calls to use, thanks! ok op@ (with some follow-up tweaks suggested) commit - 8cc4eb801418181a7eddf2ad28d85b4e60661ae7 commit + bd84be8a1a59cfed38bb44a3d921b3b16ffa6469 blob - fc2b64876b2b20537494f7fa72e0f2a19756ca54 blob + 4638c10ed322c7d77ce9a38b39a234f80a172047 --- gotd/gotd.conf.5 +++ gotd/gotd.conf.5 @@ -333,7 +333,7 @@ The and .Ic port directives can be used to specify a different SMTP server address and port. -.It Ic url Ar URL Oo Ic user Ar user Ic password Ar password Oo Ic insecure Oc Oc +.It Ic url Ar URL Oo Ic user Ar user Ic password Ar password Oo Ic insecure Oc Oc Oo Ic hmac Ar secret Oc Send notifications via HTTP. This directive may be specified multiple times to build a list of HTTP servers to send notifications to. @@ -368,6 +368,19 @@ must be a .Dq https:// URL to avoid leaking of authentication credentials. .Pp +If a +.Ic hmac +.Ar secret +is provided, the request body will be signed using HMAC, allowing the +receiver to verify the notification message's authenticity and integrity. +The signature uses HMAC-SHA256 and will be sent in the HTTP header +.Dq HTTP_X_GOTD_SIGNATURE_256 . +Suitable secrets can be generated with +.Xr openssl 1 +as follows: +.Pp +.Dl $ openssl rand -base64 32 +.Pp The request body contains a JSON object with a .Dq notifications property containing an array of notification objects. blob - c51b69d2d80fd63e6ed9b528b0fb749a764c65d7 blob + 4c8e79a9beb60c52b4a922d424c3d7db0000dfb5 --- gotd/gotd.h +++ gotd/gotd.h @@ -112,6 +112,7 @@ struct gotd_notification_target { char *path; char *user; char *password; + char *hmac_secret; } http; } conf; }; blob - 1b2e7a4c9e059ff71925c7d37574891474f4b03a blob + 0088ca5969d4d02be4a1843978465c7ceddec283 --- gotd/libexec/got-notify-http/Makefile +++ gotd/libexec/got-notify-http/Makefile @@ -8,7 +8,7 @@ SRCS= got-notify-http.c bufio.c opentemp.c pollfd.c er CPPFLAGS= -I${.CURDIR}/../../../include -I${.CURDIR}/../../../lib -I${.CURDIR}/../.. -DPADD= ${LIBTLS} -LDADD= -ltls +DPADD= ${LIBTLS} ${LIBCRYPTO} +LDADD= -ltls -lcrypto .include blob - 1869c8bbbc78dc4836ee3e49a118ae6bd8caf3e3 blob + a181d871b13eedee0dc5684d90fb2bdab9e16c42 --- gotd/libexec/got-notify-http/got-notify-http.c +++ gotd/libexec/got-notify-http/got-notify-http.c @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,8 @@ #include #include #include +#include +#include #include #include #include @@ -32,9 +35,15 @@ #include #include +#include +#include + #include "got_opentemp.h" #include "got_version.h" +#include "got_object.h" +#include "got_lib_hash.h" + #include "bufio.h" #include "log.h" #include "utf8d.h" @@ -836,7 +845,62 @@ bufio2poll(struct bufio *bio) ret |= POLLOUT; return ret; } + +static unsigned char * +compute_hmac_sha256(FILE *payload, off_t paylen, const char *hmac_secret, + size_t secret_len, unsigned char *hmac_sig_buf, unsigned int *hmac_siglen) +{ + HMAC_CTX *ctx; + char buf[4096]; + off_t n; + ssize_t r; + *hmac_siglen = 0; + + ctx = HMAC_CTX_new(); + if (ctx == NULL) { + log_warn("HMAC_CTX_new"); + return NULL; + } + + if (!HMAC_Init_ex(ctx, hmac_secret, secret_len, EVP_sha256(), NULL)) { + log_warn("HMAC_Init_ex"); + goto fail; + } + + n = paylen; + while (n > 0) { + r = fread(buf, 1, n > sizeof(buf) ? sizeof(buf) : n, payload); + if (r == 0) { + if (feof(payload)) { + log_warnx("HMAC payload truncated"); + goto fail; + } + log_warnx("reading HMAC payload: %s", + strerror(ferror(payload))); + goto fail; + } + if (!HMAC_Update(ctx, buf, r)) { + log_warn("HMAC_Update"); + goto fail; + } + n -= r; + } + + if (!HMAC_Final(ctx, hmac_sig_buf, hmac_siglen)) { + log_warn("HMAC_Final"); + goto fail; + } + + *hmac_siglen = HMAC_size(ctx); + + HMAC_CTX_free(ctx); + return hmac_sig_buf; +fail: + HMAC_CTX_free(ctx); + return NULL; +} + int main(int argc, char **argv) { @@ -847,11 +911,16 @@ main(int argc, char **argv) const char *username; const char *password; const char *timeoutstr; + const char *hmac_secret; const char *errstr; const char *repo = NULL; const char *host = NULL, *port = NULL, *path = NULL; const char *gotd_auth_user = NULL; char *auth, *line, *spc; + unsigned char *hmac_sig = NULL; + unsigned char hmac_sig_buf[EVP_MAX_MD_SIZE]; + unsigned int hmac_siglen; + char hex[SHA256_DIGEST_STRING_LENGTH]; size_t len; ssize_t r; off_t paylen; @@ -933,7 +1002,20 @@ main(int argc, char **argv) if (pledge("stdio rpath dns inet", NULL) == -1) err(1, "pledge"); #endif + hmac_secret = getenv("GOT_NOTIFY_HTTP_HMAC_SECRET"); + if (hmac_secret) { + hmac_sig = compute_hmac_sha256(tmpfp, paylen, hmac_secret, + strlen(hmac_secret), hmac_sig_buf, &hmac_siglen); + if (hmac_sig == NULL || hmac_siglen != SHA256_DIGEST_LENGTH) + fatalx("HMAC computation failed"); + if (got_sha256_digest_to_str(hmac_sig, hex, sizeof(hex)) + == NULL) + fatalx("HMAC conversion to hex string failed"); + if (fseeko(tmpfp, 0, SEEK_SET) == -1) + fatal("fseeko"); + } + memset(&pfd, 0, sizeof(pfd)); pfd.fd = dial(host, port); @@ -964,10 +1046,15 @@ main(int argc, char **argv) "Content-Type: application/json\r\n" "Content-Length: %lld\r\n" "User-Agent: %s\r\n" - "Connection: close\r\n", + "Connection: close\r\n" + "%s%s%s%s", path, host, nonstd ? ":" : "", nonstd ? port : "", - (long long)paylen, USERAGENT); + (long long)paylen, USERAGENT, + hmac_sig ? "HTTP_X_GOTD_SIGNATURE_256: " : "", + hmac_sig ? "sha256=" : "", + hmac_sig ? hex : "", + hmac_sig ? "\r\n" : ""); if (ret == -1) fatal("bufio_compose_fmt"); blob - 20958912de97f4002cb2fa805ef1624b48a47647 blob + 804d5eca66fbe09fb4b30ded4f78f34438caf7d9 --- gotd/notify.c +++ gotd/notify.c @@ -164,7 +164,7 @@ gotd_notify_sighdlr(int sig, short event, void *arg) static void run_notification_helper(const char *prog, const char **argv, int fd, - const char *user, const char *pass) + const char *user, const char *pass, const char *hmac_secret) { const struct got_error *err = NULL; pid_t pid; @@ -192,6 +192,11 @@ run_notification_helper(const char *prog, const char * setenv("GOT_NOTIFY_HTTP_USER", user, 1); setenv("GOT_NOTIFY_HTTP_PASS", pass, 1); } + + if (hmac_secret) + setenv("GOT_NOTIFY_HTTP_HMAC_SECRET", hmac_secret, 1); + else + unsetenv("GOT_NOTIFY_HTTP_HMAC_SECRET"); if (execv(prog, (char *const *)argv) == -1) { fprintf(stderr, "%s: exec %s: %s\n", getprogname(), @@ -258,7 +263,7 @@ notify_email(struct gotd_notification_target *target, argv[i] = NULL; run_notification_helper(GOTD_PATH_PROG_NOTIFY_EMAIL, argv, fd, - NULL, NULL); + NULL, NULL, NULL); } static void @@ -286,7 +291,8 @@ notify_http(struct gotd_notification_target *target, c argv[argc] = NULL; run_notification_helper(GOTD_PATH_PROG_NOTIFY_HTTP, argv, fd, - target->conf.http.user, target->conf.http.password); + target->conf.http.user, target->conf.http.password, + target->conf.http.hmac_secret); } static const struct got_error * blob - 8788918701c2f2df8e6513ba34076a4f7d493f04 blob + b2d3f58b80dbf587ac68fbec9d354f48f9ef3e86 --- gotd/parse.y +++ gotd/parse.y @@ -111,7 +111,7 @@ static int conf_notify_ref_namespace(struct gotd_re static int conf_notify_email(struct gotd_repo *, char *, char *, char *, char *, char *); static int conf_notify_http(struct gotd_repo *, - char *, char *, char *, int); + char *, char *, char *, int, char *); static enum gotd_procid gotd_proc_id; typedef struct { @@ -128,7 +128,7 @@ typedef struct { %token PATH ERROR LISTEN ON USER REPOSITORY PERMIT DENY %token RO RW CONNECTION LIMIT REQUEST TIMEOUT %token PROTECT NAMESPACE BRANCH TAG REFERENCE RELAY PORT -%token NOTIFY EMAIL FROM REPLY TO URL PASSWORD INSECURE +%token NOTIFY EMAIL FROM REPLY TO URL PASSWORD INSECURE HMAC %token STRING %token NUMBER @@ -611,7 +611,7 @@ notifyflags : BRANCH STRING { gotd_proc_id == PROC_SESSION_WRITE || gotd_proc_id == PROC_NOTIFY) { if (conf_notify_http(new_repo, $2, NULL, - NULL, 0)) { + NULL, 0, NULL)) { free($2); YYERROR; } @@ -622,7 +622,8 @@ notifyflags : BRANCH STRING { if (gotd_proc_id == PROC_GOTD || gotd_proc_id == PROC_SESSION_WRITE || gotd_proc_id == PROC_NOTIFY) { - if (conf_notify_http(new_repo, $2, $4, $6, 0)) { + if (conf_notify_http(new_repo, $2, $4, $6, 0, + NULL)) { free($2); free($4); free($6); @@ -637,16 +638,67 @@ notifyflags : BRANCH STRING { if (gotd_proc_id == PROC_GOTD || gotd_proc_id == PROC_SESSION_WRITE || gotd_proc_id == PROC_NOTIFY) { - if (conf_notify_http(new_repo, $2, $4, $6, 1)) { + if (conf_notify_http(new_repo, $2, $4, $6, 1, + NULL)) { + free($2); + free($4); + free($6); + YYERROR; + } + } + free($2); + free($4); + free($6); + } + | URL STRING HMAC STRING { + if (gotd_proc_id == PROC_GOTD || + gotd_proc_id == PROC_SESSION_WRITE || + gotd_proc_id == PROC_NOTIFY) { + if (conf_notify_http(new_repo, $2, NULL, + NULL, 0, $4)) { + free($2); + free($4); + YYERROR; + } + } + free($2); + free($4); + } + | URL STRING USER STRING PASSWORD STRING HMAC STRING { + if (gotd_proc_id == PROC_GOTD || + gotd_proc_id == PROC_SESSION_WRITE || + gotd_proc_id == PROC_NOTIFY) { + if (conf_notify_http(new_repo, $2, $4, $6, 0, + $8)) { + free($2); + free($4); + free($6); + free($8); + YYERROR; + } + } + free($2); + free($4); + free($6); + free($8); + } + | URL STRING USER STRING PASSWORD STRING INSECURE HMAC STRING { + if (gotd_proc_id == PROC_GOTD || + gotd_proc_id == PROC_SESSION_WRITE || + gotd_proc_id == PROC_NOTIFY) { + if (conf_notify_http(new_repo, $2, $4, $6, 1, + $9)) { free($2); free($4); free($6); + free($9); YYERROR; } } free($2); free($4); free($6); + free($9); } ; @@ -792,6 +844,7 @@ lookup(char *s) { "deny", DENY }, { "email", EMAIL }, { "from", FROM }, + { "hmac", HMAC }, { "insecure", INSECURE }, { "limit", LIMIT }, { "listen", LISTEN }, @@ -1562,7 +1615,7 @@ conf_notify_email(struct gotd_repo *repo, char *sender static int conf_notify_http(struct gotd_repo *repo, char *url, char *user, char *password, - int insecure) + int insecure, char *hmac_secret) { const struct got_error *error; struct gotd_notification_target *target; @@ -1645,6 +1698,11 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch if (target->conf.http.password == NULL) fatal("strdup"); } + if (hmac_secret) { + target->conf.http.hmac_secret = strdup(hmac_secret); + if (target->conf.http.hmac_secret == NULL) + fatal("strdup"); + } STAILQ_INSERT_TAIL(&repo->notification_targets, target, entry); done: blob - 4897b27e2bbd6fa672d2a449a5b25ad65e303850 blob + 184054f12c04e1cd6db0a1f82dff27b5bcbb2de3 --- regress/gotd/Makefile +++ regress/gotd/Makefile @@ -6,7 +6,8 @@ REGRESS_TARGETS=test_repo_read test_repo_read_group \ test_repo_write test_repo_write_empty test_request_bad \ test_repo_write_protected test_repo_write_readonly \ test_email_notification test_http_notification \ - test_git_interop test_email_and_http_notification + test_git_interop test_email_and_http_notification \ + test_http_notification_hmac NOOBJ=Yes CLEANFILES=gotd.conf @@ -20,6 +21,7 @@ GOTD_TEST_REPO_NAME=test-repo GOTD_TEST_REPO_URL=ssh://${GOTD_DEVUSER}@127.0.0.1/$(GOTD_TEST_REPO_NAME) GOTD_TEST_SMTP_PORT=2525 GOTD_TEST_HTTP_PORT=8000 +GOTD_TEST_HMAC_SECRET=test1234 GOTD_TEST_USER?=${DOAS_USER} .if empty(GOTD_TEST_USER) @@ -58,6 +60,7 @@ GOTD_TEST_ENV=GOTD_TEST_ROOT=$(GOTD_TEST_ROOT) \ GOTD_CONF=$(PWD)/gotd.conf \ GOTD_TEST_SMTP_PORT=$(GOTD_TEST_SMTP_PORT) \ GOTD_TEST_HTTP_PORT=$(GOTD_TEST_HTTP_PORT) \ + GOTD_TEST_HMAC_SECRET=$(GOTD_TEST_HMAC_SECRET) \ HOME=$(GOTD_TEST_USER_HOME) \ PATH=$(GOTD_TEST_USER_HOME)/bin:$(PATH) @@ -211,6 +214,18 @@ start_gotd_email_and_http_notification: ensure_root @$(GOTD_TRAP); $(GOTD_START_CMD) @$(GOTD_TRAP); sleep .5 +start_gotd_http_notification_hmac: ensure_root + @echo 'listen on "$(GOTD_SOCK)"' > $(PWD)/gotd.conf + @echo "user $(GOTD_USER)" >> $(PWD)/gotd.conf + @echo 'repository "test-repo" {' >> $(PWD)/gotd.conf + @echo ' path "$(GOTD_TEST_REPO)"' >> $(PWD)/gotd.conf + @echo ' permit rw $(GOTD_DEVUSER)' >> $(PWD)/gotd.conf + @echo ' notify {' >> $(PWD)/gotd.conf + @echo ' url "http://localhost:${GOTD_TEST_HTTP_PORT}/" user flan password "password" insecure hmac "${GOTD_TEST_HMAC_SECRET}"' >> $(PWD)/gotd.conf + @echo " }" >> $(PWD)/gotd.conf + @echo "}" >> $(PWD)/gotd.conf + @$(GOTD_TRAP); $(GOTD_START_CMD) + @$(GOTD_TRAP); sleep .5 prepare_test_repo: ensure_root @chown ${GOTD_USER} "${GOTD_TEST_REPO}" @su -m ${GOTD_USER} -c 'env $(GOTD_TEST_ENV) sh ./prepare_test_repo.sh' @@ -294,6 +309,11 @@ test_http_notification: prepare_test_repo start_gotd_h 'env $(GOTD_TEST_ENV) sh ./http_notification.sh' @$(GOTD_STOP_CMD) 2>/dev/null +test_http_notification_hmac: prepare_test_repo start_gotd_http_notification_hmac + @-$(GOTD_TRAP); su -m ${GOTD_TEST_USER} -c \ + 'env $(GOTD_TEST_ENV) sh ./http_notification_hmac.sh' + @$(GOTD_STOP_CMD) 2>/dev/null + test_email_and_http_notification: prepare_test_repo start_gotd_email_and_http_notification @-$(GOTD_TRAP); su -m ${GOTD_TEST_USER} -c \ 'env $(GOTD_TEST_ENV) sh ./http_notification.sh test_file_changed' blob - 4e5c767b884376a69d6df25e19f47a8c53829fba blob + a2518bf858a18dbc66fcb6ad278ee0cfc54f9a8c --- regress/gotd/README +++ regress/gotd/README @@ -51,7 +51,7 @@ sshd must be restarted for configuration changes to ta The server test suite can now be run from the top-level directory: - $ doas pkg_add git p5-http-daemon + $ doas pkg_add git p5-http-daemon p5-digest-hmac $ doas make server-regress The suite must be started as root in order to be able to start and stop gotd. blob - a9297fb4c098dec8fe8f0e0da89a4516a4ddaecc blob + 800393052afde0178d8c080fbbf7ab7dffd883e8 --- regress/gotd/http-server +++ regress/gotd/http-server @@ -17,12 +17,17 @@ use v5.36; use IPC::Open2; use Getopt::Long qw(:config bundling); +use Digest; +use Digest::HMAC; my $auth; my $port = 8000; +my $hmac_secret; +my $hmac_signature; +my $hmac; -GetOptions("a:s" => \$auth, "p:i" => \$port) - or die("usage: $0 [-a auth] [-p port]\n"); +GetOptions("a:s" => \$auth, "p:i" => \$port, "s:s" => \$hmac_secret) + or die("usage: $0 [-a auth] [-p port] [-s hmac_secret]\n"); my $pid = open2(my $out, my $in, 'nc', '-l', 'localhost', $port); @@ -71,10 +76,23 @@ while (<$out>) { if not defined($auth) or $auth ne $t; next; } + + if (m/HTTP_X_GOTD_SIGNATURE_256/) { + die "bad hmac signature header" + unless m/HTTP_X_GOTD_SIGNATURE_256: sha256=(.*)$/; + $hmac_signature = $1; + next; + } } die "no Content-Length header" unless defined $clen; +if (defined $hmac_signature) { + die "no HMAC secret provided" if not (defined $hmac_secret); + my $sha256 = Digest->new("SHA-256"); + $hmac = Digest::HMAC->new($hmac_secret, $sha256); +} + while ($clen != 0) { my $len = $clen; $len = 512 if $clen > 512; @@ -82,10 +100,21 @@ while ($clen != 0) { my $r = read($out, my $buf, $len); $clen -= $r; + if (defined $hmac) { + $hmac->add($buf); + } + print $buf; } say ""; +if (defined $hmac) { + my $digest = $hmac->hexdigest; + if ($digest ne $hmac_signature) { + die "bad hmac signature: expected: $hmac_signature, actual: $digest"; + } +} + print $in "HTTP/1.1 200 OK\r\n"; print $in "Content-Length: 0\r\n"; print $in "Connection: close\r\n"; blob - /dev/null blob + 486096684215bcc49c73b7826b11ec392222db4f (mode 644) --- /dev/null +++ regress/gotd/http_notification_hmac.sh @@ -0,0 +1,114 @@ +#!/bin/sh +# +# Copyright (c) 2024 Omar Polo +# Copyright (c) 2024 Stefan Sperling +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +. ../cmdline/common.sh +. ./common.sh + +# flan:password encoded in base64 +AUTH="ZmxhbjpwYXNzd29yZA==" + +test_file_changed() { + local testroot=`test_init file_changed 1` + + got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone + ret=$? + if [ $ret -ne 0 ]; then + echo "got clone failed unexpectedly" >&2 + test_done "$testroot" 1 + return 1 + fi + + got checkout -q $testroot/repo-clone $testroot/wt >/dev/null + ret=$? + if [ $ret -ne 0 ]; then + echo "got checkout failed unexpectedly" >&2 + test_done "$testroot" 1 + return 1 + fi + + echo "change alpha" > $testroot/wt/alpha + (cd $testroot/wt && got commit -m 'make changes' > /dev/null) + local commit_id=`git_show_head $testroot/repo-clone` + local author_time=`git_show_author_time $testroot/repo-clone` + + timeout 5 ./http-server -a $AUTH -p $GOTD_TEST_HTTP_PORT \ + -s $GOTD_TEST_HMAC_SECRET > $testroot/stdout & + + sleep 1 # server starts up + + got send -b main -q -r $testroot/repo-clone + ret=$? + if [ $ret -ne 0 ]; then + echo "got send failed unexpectedly" >&2 + test_done "$testroot" "1" + return 1 + fi + + wait %1 # wait for the http "server" + + touch "$testroot/stdout.expected" + ed -s "$testroot/stdout.expected" <<-EOF + a + {"notifications":[{ + "type":"commit", + "short":false, + "repo":"test-repo", + "authenticated_user":"${GOTD_DEVUSER}", + "id":"$commit_id", + "author":{ + "full":"$GOT_AUTHOR", + "name":"$GIT_AUTHOR_NAME", + "mail":"$GIT_AUTHOR_EMAIL", + "user":"$GOT_AUTHOR_11" + }, + "committer":{ + "full":"$GOT_AUTHOR", + "name":"$GIT_AUTHOR_NAME", + "mail":"$GIT_AUTHOR_EMAIL", + "user":"$GOT_AUTHOR_11" + }, + "date":$author_time, + "short_message":"make changes", + "message":"make changes\n", + "diffstat":{ + "files":[{ + "action":"modified", + "file":"alpha", + "added":1, + "removed":1 + }], + "total":{ + "added":1, + "removed":1 + } + } + }]} + . + ,j + w + EOF + + cmp -s $testroot/stdout.expected $testroot/stdout + ret=$? + if [ $ret -ne 0 ]; then + diff -u $testroot/stdout.expected $testroot/stdout + fi + test_done "$testroot" "$ret" +} + +test_parseargs "$@" +run_test test_file_changed