commit - 8cc4eb801418181a7eddf2ad28d85b4e60661ae7
commit + bd84be8a1a59cfed38bb44a3d921b3b16ffa6469
blob - fc2b64876b2b20537494f7fa72e0f2a19756ca54
blob + 4638c10ed322c7d77ce9a38b39a234f80a172047
--- gotd/gotd.conf.5
+++ gotd/gotd.conf.5
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.
.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
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
CPPFLAGS= -I${.CURDIR}/../../../include -I${.CURDIR}/../../../lib -I${.CURDIR}/../..
-DPADD= ${LIBTLS}
-LDADD= -ltls
+DPADD= ${LIBTLS} ${LIBCRYPTO}
+LDADD= -ltls -lcrypto
.include <bsd.prog.mk>
blob - 1869c8bbbc78dc4836ee3e49a118ae6bd8caf3e3
blob + a181d871b13eedee0dc5684d90fb2bdab9e16c42
--- gotd/libexec/got-notify-http/got-notify-http.c
+++ gotd/libexec/got-notify-http/got-notify-http.c
#include <sys/time.h>
#include <sys/types.h>
#include <sys/socket.h>
+#include <sys/queue.h>
#include <err.h>
#include <errno.h>
#include <limits.h>
#include <netdb.h>
#include <poll.h>
+#include <sha1.h>
+#include <sha2.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
+#include <openssl/evp.h>
+#include <openssl/hmac.h>
+
#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"
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)
{
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;
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);
"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
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;
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(),
argv[i] = NULL;
run_notification_helper(GOTD_PATH_PROG_NOTIFY_EMAIL, argv, fd,
- NULL, NULL);
+ NULL, NULL, NULL);
}
static void
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
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 {
%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 <v.string> STRING
%token <v.number> NUMBER
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;
}
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);
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);
}
;
{ "deny", DENY },
{ "email", EMAIL },
{ "from", FROM },
+ { "hmac", HMAC },
{ "insecure", INSECURE },
{ "limit", LIMIT },
{ "listen", LISTEN },
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;
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
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
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)
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)
@$(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'
'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
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
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);
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;
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
+#!/bin/sh
+#
+# Copyright (c) 2024 Omar Polo <op@openbsd.org>
+# Copyright (c) 2024 Stefan Sperling <stsp@openbsd.org>
+#
+# 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