From e2c7fa40a0aab5c0bc4a1c829c98032623435359 Mon Sep 17 00:00:00 2001 From: Sergio Arroutbi Date: Mon, 21 Sep 2026 13:23:34 +0200 Subject: [PATCH 1/3] Return ECMR public key (tang_pub) in /rec/$kid response Embed the server's ECMR public key as a `tang_pub` field in the recovery response so that clients can obtain it at recovery time instead of persisting it on disk. The public key is a deep copy with private material stripped via jose_jwk_pub(), ensuring no secret data leaks. The field is additive: old clients ignore the unknown JSON field, so existing Clevis bindings and recovery flows are unaffected. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Sergio Arroutbi --- src/tangd.c | 8 ++++++++ tests/rec | 24 ++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/tangd.c b/src/tangd.c index 9ae3739..696581a 100644 --- a/src/tangd.c +++ b/src/tangd.c @@ -100,6 +100,7 @@ adv(http_method_t method, const char *path, const char *body, return http_reply(HTTP_STATUS_OK, "Content-Type: application/jose+json\r\n" + "Tang-Capabilities: tang_pub\r\n" "Content-Length: %zu\r\n" "\r\n%s", strlen(adv), adv); } @@ -178,6 +179,13 @@ rec(http_method_t method, const char *path, const char *body, if (json_object_set_new(rep, "key_ops", json_pack("[s]", "deriveKey")) < 0) return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + json_auto_t *pub = json_deep_copy(jwk); + if (!pub || !jose_jwk_pub(NULL, pub)) + return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + + if (json_object_set(rep, "tang_pub", pub) < 0) + return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + enc = json_dumps(rep, JSON_SORT_KEYS | JSON_COMPACT); if (!enc) return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); diff --git a/tests/rec b/tests/rec index d30f72f..5d6c842 100755 --- a/tests/rec +++ b/tests/rec @@ -45,11 +45,21 @@ rec_second_phase () { # Make a recovery request (NOTE: this is insecure! Don't do this in real code!) good=`jose jwk exc -i '{"alg":"ECMR","key_ops":["deriveKey"]}' -l $TMP/exc.jwk -r $TMP/db/exc.jwk` - test=`curl -sf -X POST \ + full_resp=`curl -sf -X POST \ -H "Content-Type: application/jwk+json" \ --data-binary @- \ http://127.0.0.1:$PORT/rec/${exc_kid} < $TMP/exc.pub.jwk` + + # Strip tang_pub from response and verify the exchange result + test=`printf '%s' "$full_resp" | jose fmt -j- -Od tang_pub -o-` [ "$good" = "$test" ] + + # Verify tang_pub is present and contains no private material + printf '%s' "$full_resp" | jose fmt -j- -Og tang_pub -o- > $TMP/tang_pub.jwk + ! jose fmt -j $TMP/tang_pub.jwk -Og d 2>/dev/null + + # Verify tang_pub thumbprint matches the requested key identifier + [ "$(jose jwk thp -i $TMP/tang_pub.jwk)" = "$exc_kid" ] } rec_second_phase_endpoint () { @@ -59,9 +69,19 @@ rec_second_phase_endpoint () { # Make a recovery request (NOTE: this is insecure! Don't do this in real code!) good=`jose jwk exc -i '{"alg":"ECMR","key_ops":["deriveKey"]}' -l $TMP/exc.jwk -r $TMP/db/exc.jwk` - test=`curl -sf -X POST \ + full_resp=`curl -sf -X POST \ -H "Content-Type: application/jwk+json" \ --data-binary @- \ http://127.0.0.1:$PORT/$ENDPOINT/rec/${exc_kid} < $TMP/exc.pub.jwk` + + # Strip tang_pub from response and verify the exchange result + test=`printf '%s' "$full_resp" | jose fmt -j- -Od tang_pub -o-` [ "$good" = "$test" ] + + # Verify tang_pub is present and contains no private material + printf '%s' "$full_resp" | jose fmt -j- -Og tang_pub -o- > $TMP/tang_pub.jwk + ! jose fmt -j $TMP/tang_pub.jwk -Og d 2>/dev/null + + # Verify tang_pub thumbprint matches the requested key identifier + [ "$(jose jwk thp -i $TMP/tang_pub.jwk)" = "$exc_kid" ] } From c0521e00c9bb3eb69b734daa297c44b0bd440237 Mon Sep 17 00:00:00 2001 From: Sergio Arroutbi Date: Mon, 21 Sep 2026 13:23:34 +0200 Subject: [PATCH 2/3] Return ECMR public key and advertise capability in /rec and /adv Embed the server's ECMR public key as a `tang_pub` field in the POST /rec/$kid response so that clients can obtain it at recovery time instead of persisting it on disk. Private material is stripped via jose_jwk_pub() before inclusion. Add a Tang-Capabilities: tang_pub header to the GET /adv response so clients can detect server support before creating a binding. Both changes are additive: old clients ignore the unknown JSON field and HTTP header, so existing bindings and recovery flows are unaffected. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Sergio Arroutbi --- tests/adv | 4 +++ tests/helpers | 4 +++ tests/verify-tang-pub | 78 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100755 tests/verify-tang-pub diff --git a/tests/adv b/tests/adv index 416dc6e..2cd3d2f 100755 --- a/tests/adv +++ b/tests/adv @@ -47,6 +47,10 @@ adv_second_phase () { fetch "${ENDPOINT}"/adv | ver $TMP/db/sig.jwk fetch "${ENDPOINT}"/adv/ | ver $TMP/db/sig.jwk + # Verify Tang-Capabilities header advertises tang_pub + fetch_headers "${ENDPOINT}"/adv $TMP/adv_headers + grep -q "Tang-Capabilities:.*tang_pub" $TMP/adv_headers + # Fetching by any thumbprint should work fetch "${ENDPOINT}"/adv/`jose jwk thp -i $TMP/db/sig.jwk` | ver $TMP/db/sig.jwk fetch "${ENDPOINT}"/adv/`jose jwk thp -a S512 -i $TMP/db/sig.jwk` | ver $TMP/db/sig.jwk diff --git a/tests/helpers b/tests/helpers index ebce966..50a44b1 100755 --- a/tests/helpers +++ b/tests/helpers @@ -22,6 +22,10 @@ fetch() { curl -sfg "http://127.0.0.1:${PORT}${1}" } +fetch_headers() { + curl -sfg -D "${2}" -o /dev/null "http://127.0.0.1:${PORT}${1}" +} + ver() { jose jws ver -i- -k "${1}" } diff --git a/tests/verify-tang-pub b/tests/verify-tang-pub new file mode 100755 index 0000000..59721f8 --- /dev/null +++ b/tests/verify-tang-pub @@ -0,0 +1,78 @@ +#!/bin/sh -e +# vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: +# +# Verify that a running Tang server returns tang_pub in /rec/$kid responses. +# +# Usage: verify-tang-pub [jwkdir] [url] +# jwkdir: path to Tang key directory (default: /var/db/tang) +# url: Tang server URL (default: http://localhost) + +JWKDIR="${1:-/var/db/tang}" +URL="${2:-http://localhost}" + +if [ ! -d "$JWKDIR" ]; then + echo "Error: key directory $JWKDIR does not exist" >&2 + exit 1 +fi + +exc_file="" +for f in "$JWKDIR"/*.jwk; do + [ -f "$f" ] || continue + alg=$(jose fmt -j "$f" -Og alg -u- 2>/dev/null) || continue + if [ "$alg" = "ECMR" ]; then + exc_file="$f" + break + fi +done + +if [ -z "$exc_file" ]; then + echo "Error: no ECMR exchange key found in $JWKDIR" >&2 + exit 1 +fi + +exc_kid=$(jose jwk thp -i "$exc_file") +echo "Exchange key: $(basename "$exc_file")" +echo "Thumbprint: $exc_kid" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +curl -sf -D "$TMP/adv_headers" -o /dev/null "$URL/adv" || { + echo "Error: /adv request failed" >&2 + exit 1 +} + +if ! grep -q "Tang-Capabilities:.*tang_pub" "$TMP/adv_headers"; then + echo "FAIL: Tang-Capabilities header does not advertise tang_pub" >&2 + exit 1 +fi +echo "OK: Tang-Capabilities header advertises tang_pub" + +jose jwk gen -i '{"alg":"ECMR"}' -o "$TMP/client.jwk" +jose jwk pub -i "$TMP/client.jwk" -o "$TMP/client.pub.jwk" + +resp=$(curl -sf -X POST \ + -H "Content-Type: application/jwk+json" \ + --data-binary @"$TMP/client.pub.jwk" \ + "$URL/rec/$exc_kid" 2>/dev/null) || { + echo "Error: /rec/$exc_kid request failed" >&2 + exit 1 +} + +tang_pub=$(printf '%s' "$resp" | jose fmt -j- -Og tang_pub -o- 2>/dev/null) || { + echo "FAIL: tang_pub not present in /rec/$exc_kid response" >&2 + exit 1 +} + +if printf '%s' "$tang_pub" | jose fmt -j- -Og d 2>/dev/null >/dev/null; then + echo "FAIL: tang_pub contains private material" >&2 + exit 1 +fi + +tang_pub_thp=$(printf '%s' "$tang_pub" | jose jwk thp -i-) +if [ "$tang_pub_thp" != "$exc_kid" ]; then + echo "FAIL: tang_pub thumbprint ($tang_pub_thp) does not match key identifier ($exc_kid)" >&2 + exit 1 +fi + +echo "OK: tang_pub is present, public-only, and matches key identifier" From afcb6d06e798e6589006e10234ee0252c82fad39 Mon Sep 17 00:00:00 2001 From: Sergio Arroutbi Date: Thu, 24 Sep 2026 12:50:39 +0200 Subject: [PATCH 3/3] Replace Tang-Capabilities header with /version endpoint Replace the Tang-Capabilities HTTP header with a dedicated GET /version endpoint that returns structured JSON describing server capabilities. The response format: {"features":{"tang_pub":true},"tang_version":"15"} Clients calling /version on an old Tang server receive a 404, which cleanly signals the server lacks capability discovery and the client should fall back to legacy binding behavior. The /version endpoint respects the -e (endpoint) option, so /endpoint/version works consistently with /endpoint/adv and /endpoint/rec. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Sergio Arroutbi --- src/tangd.c | 32 ++++++++++++++++++++++++++++---- tests/adv | 7 ++++--- tests/helpers | 4 ---- tests/verify-tang-pub | 10 +++++----- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/tangd.c b/src/tangd.c index 696581a..ced38f6 100644 --- a/src/tangd.c +++ b/src/tangd.c @@ -100,7 +100,6 @@ adv(http_method_t method, const char *path, const char *body, return http_reply(HTTP_STATUS_OK, "Content-Type: application/jose+json\r\n" - "Tang-Capabilities: tang_pub\r\n" "Content-Length: %zu\r\n" "\r\n%s", strlen(adv), adv); } @@ -196,10 +195,32 @@ rec(http_method_t method, const char *path, const char *body, "\r\n%s", strlen(enc), enc); } +static int +tang_version(http_method_t method, const char *path, const char *body, + regmatch_t matches[], void *misc) +{ + __attribute__((cleanup(str_cleanup))) char *out = NULL; + json_auto_t *ver = json_pack("{s:s, s:{s:b}}", + "tang_version", VERSION, + "features", "tang_pub", 1); + if (!ver) + return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + + out = json_dumps(ver, JSON_SORT_KEYS | JSON_COMPACT); + if (!out) + return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + + return http_reply(HTTP_STATUS_OK, + "Content-Type: application/json\r\n" + "Content-Length: %zu\r\n" + "\r\n%s", strlen(out), out); +} + static struct http_dispatch s_dispatch[] = { - { adv, 1 << HTTP_GET, 2, "^/+adv/+([0-9A-Za-z_-]+)$" }, - { adv, 1 << HTTP_GET, 2, "^/+adv/*$" }, - { rec, 1 << HTTP_POST, 2, "^/+rec/+([0-9A-Za-z_-]+)$" }, + { adv, 1 << HTTP_GET, 2, "^/+adv/+([0-9A-Za-z_-]+)$" }, + { adv, 1 << HTTP_GET, 2, "^/+adv/*$" }, + { rec, 1 << HTTP_POST, 2, "^/+rec/+([0-9A-Za-z_-]+)$" }, + { tang_version, 1 << HTTP_GET, 1, "^/+version/*$" }, {} }; @@ -335,6 +356,7 @@ main(int argc, char *argv[]) char adv_thp_endpoint[MAX_URL] = {}; char adv_endpoint[MAX_URL] = {}; char rec_endpoint[MAX_URL] = {}; + char ver_endpoint[MAX_URL] = {}; if (endpoint != NULL) { char *endpoint_ptr = (char*)endpoint; while (*endpoint_ptr == '/') { @@ -343,9 +365,11 @@ main(int argc, char *argv[]) snprintf(adv_thp_endpoint, MAX_URL, "^/%s/+adv/+([0-9A-Za-z_-]+)$", endpoint_ptr); snprintf(adv_endpoint, MAX_URL, "^/%s/+adv/*$", endpoint_ptr); snprintf(rec_endpoint, MAX_URL, "^/%s/+rec/+([0-9A-Za-z_-]+)$", endpoint_ptr); + snprintf(ver_endpoint, MAX_URL, "^/%s/+version/*$", endpoint_ptr); s_dispatch[0].re = adv_thp_endpoint; s_dispatch[1].re = adv_endpoint; s_dispatch[2].re = rec_endpoint; + s_dispatch[3].re = ver_endpoint; } if (listen == 0) { /* process one-shot query from stdin */ return process_request(jwkdir, STDIN_FILENO); diff --git a/tests/adv b/tests/adv index 2cd3d2f..d16e4e2 100755 --- a/tests/adv +++ b/tests/adv @@ -47,9 +47,10 @@ adv_second_phase () { fetch "${ENDPOINT}"/adv | ver $TMP/db/sig.jwk fetch "${ENDPOINT}"/adv/ | ver $TMP/db/sig.jwk - # Verify Tang-Capabilities header advertises tang_pub - fetch_headers "${ENDPOINT}"/adv $TMP/adv_headers - grep -q "Tang-Capabilities:.*tang_pub" $TMP/adv_headers + # Verify /version endpoint reports tang_pub capability + ver_resp=$(fetch "${ENDPOINT}"/version) + printf '%s' "$ver_resp" | jose fmt -j- -Og tang_version -u- >/dev/null + printf '%s' "$ver_resp" | jose fmt -j- -Og features -Og tang_pub >/dev/null # Fetching by any thumbprint should work fetch "${ENDPOINT}"/adv/`jose jwk thp -i $TMP/db/sig.jwk` | ver $TMP/db/sig.jwk diff --git a/tests/helpers b/tests/helpers index 50a44b1..ebce966 100755 --- a/tests/helpers +++ b/tests/helpers @@ -22,10 +22,6 @@ fetch() { curl -sfg "http://127.0.0.1:${PORT}${1}" } -fetch_headers() { - curl -sfg -D "${2}" -o /dev/null "http://127.0.0.1:${PORT}${1}" -} - ver() { jose jws ver -i- -k "${1}" } diff --git a/tests/verify-tang-pub b/tests/verify-tang-pub index 59721f8..cc71dfa 100755 --- a/tests/verify-tang-pub +++ b/tests/verify-tang-pub @@ -37,16 +37,16 @@ echo "Thumbprint: $exc_kid" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT -curl -sf -D "$TMP/adv_headers" -o /dev/null "$URL/adv" || { - echo "Error: /adv request failed" >&2 +ver_resp=$(curl -sf "$URL/version" 2>/dev/null) || { + echo "FAIL: /version endpoint not available (server may not support tang_pub)" >&2 exit 1 } -if ! grep -q "Tang-Capabilities:.*tang_pub" "$TMP/adv_headers"; then - echo "FAIL: Tang-Capabilities header does not advertise tang_pub" >&2 +if ! printf '%s' "$ver_resp" | jose fmt -j- -Og features -Og tang_pub >/dev/null 2>&1; then + echo "FAIL: /version does not report tang_pub capability" >&2 exit 1 fi -echo "OK: Tang-Capabilities header advertises tang_pub" +echo "OK: /version reports tang_pub capability" jose jwk gen -i '{"alg":"ECMR"}' -o "$TMP/client.jwk" jose jwk pub -i "$TMP/client.jwk" -o "$TMP/client.pub.jwk"