diff --git a/meson.build b/meson.build index 2d5ed1d..f5ae2af 100644 --- a/meson.build +++ b/meson.build @@ -60,6 +60,23 @@ if meson.is_cross_build() compiler = meson.get_compiler('c', native : false) endif +has_explicit_bzero = compiler.has_function( + 'explicit_bzero', + prefix: '#define _DEFAULT_SOURCE\n#include ', + args: ['-D_DEFAULT_SOURCE'], +) + +has_pqc = compiler.links(''' +#include +int main(void) { + json_auto_t *j = json_pack("{s:s}", "alg", "ML-KEM-768"); + if (!jose_jwk_gen(NULL, j)) + return 1; + json_auto_t *enc = jose_jwk_kem_enc(NULL, j); + return enc ? 0 : 1; +} +''', dependencies: jose, name: 'ML-KEM KEM API support in jose') + message(compiler.version()) message(compiler.get_id()) diff --git a/src/keys.c b/src/keys.c index 3403922..9a66273 100644 --- a/src/keys.c +++ b/src/keys.c @@ -130,7 +130,9 @@ free_tang_keys_info(struct tang_keys_info* tki) } json_t* to_free[] = {tki->m_keys, tki->m_rotated_keys, - tki->m_payload, tki->m_sign + tki->m_payload, + tki->m_kem_payload, + tki->m_sign }; size_t len = sizeof(to_free) / sizeof(to_free[0]); @@ -164,10 +166,11 @@ new_tang_keys_info(void) tki->m_keys = json_array(); tki->m_rotated_keys = json_array(); tki->m_payload = json_array(); + tki->m_kem_payload = json_array(); tki->m_sign = json_array(); if (!tki->m_keys || !tki->m_rotated_keys || - !tki->m_payload || !tki->m_sign) { + !tki->m_payload || !tki->m_kem_payload || !tki->m_sign) { free_tang_keys_info(tki); return NULL; } @@ -220,7 +223,7 @@ cleanup_str(char** str) *str = NULL; } -static json_t* +json_t* jwk_sign(const json_t* to_sign, const json_t* sig_keys) { if (!sig_keys || !json_is_array(sig_keys) || !json_is_array(to_sign)) { @@ -291,8 +294,12 @@ prepare_payload_and_sign(struct tang_keys_info* tki) continue; } } else if (jwk_valid_for_deriving_keys(jwk)) { - if (json_array_append(tki->m_payload, jwk) == -1) { - continue; + const char* kty = json_string_value( + json_object_get(jwk, "kty")); + if (kty && strcmp(kty, "AKP") == 0) { + json_array_append(tki->m_kem_payload, jwk); + } else { + json_array_append(tki->m_payload, jwk); } } } @@ -302,17 +309,32 @@ prepare_payload_and_sign(struct tang_keys_info* tki) return 1; } +struct key_spec { + const char* alg; + int optional; +}; + static int create_new_keys(const char* jwkdir) { - const char* alg[] = {"ES512", "ECMR", NULL}; + const struct key_spec specs[] = { + {"ES512", 0}, + {"ECMR", 0}, + {"ML-KEM-768", 1}, + {NULL, 0} + }; char path[PATH_MAX]; /* Set default umask for file creation. */ umask(0337); - for (int i = 0; alg[i] != NULL; i++) { - json_auto_t* jwk = jwk_generate(alg[i]); + for (int i = 0; specs[i].alg != NULL; i++) { + json_auto_t* jwk = jwk_generate(specs[i].alg); if (!jwk) { + if (specs[i].optional) { + fprintf(stderr, "Skipping optional key %s (not supported)\n", + specs[i].alg); + continue; + } return 0; } __attribute__ ((__cleanup__(cleanup_str))) char* thp = jwk_thumbprint(jwk, DEFAULT_THP_HASH); @@ -387,6 +409,11 @@ load_keys(const char* jwkdir) tki->m_rotated_keys_count++; } else { tki->m_keys_count++; + const char* kty = json_string_value( + json_object_get(json, "kty")); + if (kty && strcmp(kty, "AKP") == 0) { + tki->m_has_pqc = 1; + } } if (json_array_append(arr, json) == -1) { diff --git a/src/keys.h b/src/keys.h index f299955..1825e8a 100644 --- a/src/keys.h +++ b/src/keys.h @@ -27,19 +27,26 @@ struct tang_keys_info { json_t* m_keys; /* Regular keys. */ json_t* m_rotated_keys; /* Rotated keys. */ - json_t* m_payload; /* Payload made of regular keys capable of + json_t* m_payload; /* Payload made of regular EC keys capable of * either signing+verifying or deriving new - * keys. */ + * keys (excludes KEM keys). */ + + json_t* m_kem_payload; /* Payload made of KEM keys (kty=AKP) for + * the /adv-kem endpoint. */ json_t* m_sign; /* Set of signing keys made from regular keys. */ size_t m_keys_count; /* Number of regular keys. */ size_t m_rotated_keys_count; /* Number of rotated keys. */ + + int m_has_pqc; /* Non-zero if a PQC key (e.g. ML-KEM) is + available among regular keys. */ }; void cleanup_tang_keys_info(struct tang_keys_info**); void free_tang_keys_info(struct tang_keys_info*); struct tang_keys_info* read_keys(const char* /* jwkdir */); +json_t* jwk_sign(const json_t* /* to_sign */, const json_t* /* sig_keys */); json_t* find_jws(struct tang_keys_info* /* tki */, const char* /* thp */); json_t* find_jwk(struct tang_keys_info* /* tki */, const char* /* thp */); diff --git a/src/meson.build b/src/meson.build index 27aa51e..3a61f4c 100644 --- a/src/meson.build +++ b/src/meson.build @@ -1,8 +1,16 @@ +pqc_source = has_pqc ? 'pqc.c' : 'pqc_noop.c' +pqc_args = [] +if has_explicit_bzero + pqc_args += ['-DHAVE_EXPLICIT_BZERO'] +endif + tangd = executable('tangd', 'http.c', 'keys.c', 'tangd.c', 'socket.c', + pqc_source, + c_args: pqc_args, dependencies: [jose, http_parser], install: true, install_dir: libexecdir diff --git a/src/pqc.c b/src/pqc.c new file mode 100644 index 0000000..cf7fdbe --- /dev/null +++ b/src/pqc.c @@ -0,0 +1,355 @@ +/* vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: */ +/* + * Copyright (c) 2025 Red Hat, Inc. + * Author: Sergio Arroutbi + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include + +#include +#include + +#include "pqc.h" + +int +pqc_is_available(void) +{ + return 1; +} + +static void +str_cleanup(char **str) +{ + if (str) + free(*str); +} + +#ifdef HAVE_EXPLICIT_BZERO +void explicit_bzero(void *, size_t); +#define SECURE_WIPE(ptr, len) explicit_bzero((ptr), (len)) +#elif defined(__STDC_LIB_EXT1__) +#define SECURE_WIPE(ptr, len) memset_s((ptr), (len), 0, (len)) +#else +static inline void +secure_wipe_fallback(void *ptr, size_t len) +{ + if (!ptr) + return; + memset(ptr, 0, len); + __asm__ __volatile__("" : : "r"(ptr) : "memory"); +} +#define SECURE_WIPE(ptr, len) secure_wipe_fallback((ptr), (len)) +#endif + +static void +str_cleanse(char **str) +{ + if (str && *str) { + SECURE_WIPE(*str, strlen(*str)); + free(*str); + *str = NULL; + } +} + +static void +wipe_jwk_secret(json_t *jwk) +{ + json_t *k = json_object_get(jwk, "k"); + if (k && json_is_string(k)) { + char *kval = (char *)json_string_value(k); + if (kval) + SECURE_WIPE(kval, strlen(kval)); + } +} + +static json_t * +jwe_from_compact(const char *compact) +{ + const char *dots[4]; + int n = 0; + + for (const char *c = compact; *c && n < 4; c++) { + if (*c == '.') + dots[n++] = c; + } + if (n != 4) + return NULL; + + const char *p0 = compact; + const char *p1 = dots[0] + 1; + const char *p2 = dots[1] + 1; + const char *p3 = dots[2] + 1; + const char *p4 = dots[3] + 1; + + json_auto_t *jwe = json_object(); + if (!jwe) + return NULL; + + json_object_set_new(jwe, "protected", + json_stringn(p0, dots[0] - p0)); + json_object_set_new(jwe, "encrypted_key", + json_stringn(p1, dots[1] - p1)); + json_object_set_new(jwe, "iv", + json_stringn(p2, dots[2] - p2)); + json_object_set_new(jwe, "ciphertext", + json_stringn(p3, dots[3] - p3)); + json_object_set_new(jwe, "tag", json_string(p4)); + + return json_incref(jwe); +} + +static char * +jwe_to_compact(const json_t *jwe) +{ + const char *prot = json_string_value( + json_object_get(jwe, "protected")); + const char *ekey = json_string_value( + json_object_get(jwe, "encrypted_key")); + const char *iv = json_string_value(json_object_get(jwe, "iv")); + const char *ct = json_string_value( + json_object_get(jwe, "ciphertext")); + const char *tag = json_string_value(json_object_get(jwe, "tag")); + + if (!prot || !iv || !ct || !tag) + return NULL; + if (!ekey) + ekey = ""; + + size_t len = strlen(prot) + strlen(ekey) + strlen(iv) + + strlen(ct) + strlen(tag) + 5; + char *out = malloc(len); + if (!out) + return NULL; + snprintf(out, len, "%s.%s.%s.%s.%s", prot, ekey, iv, ct, tag); + return out; +} + +static int +rec_kem_secure(json_t *tang_kem_priv, const json_t *req, char **out) +{ + const char *clevis_encrypted_blob = NULL; + const char *clevis_transport_ct = NULL; + + if (json_unpack((json_t *)req, "{s:s, s:s}", + "clevis_encrypted_blob", &clevis_encrypted_blob, + "clevis_transport_ct", &clevis_transport_ct) < 0) + return HTTP_STATUS_BAD_REQUEST; + + json_auto_t *fwd_ct_json = json_string(clevis_transport_ct); + if (!fwd_ct_json) + return HTTP_STATUS_BAD_REQUEST; + + json_auto_t *clevis_transport_key = + jose_jwk_kem_dec(NULL, tang_kem_priv, fwd_ct_json); + if (!clevis_transport_key) + return HTTP_STATUS_BAD_REQUEST; + + json_auto_t *blob_jwe = jwe_from_compact(clevis_encrypted_blob); + if (!blob_jwe) + return HTTP_STATUS_BAD_REQUEST; + + size_t ptl = 0; + void *pt = jose_jwe_dec(NULL, blob_jwe, NULL, + clevis_transport_key, &ptl); + wipe_jwk_secret(clevis_transport_key); + json_decref(clevis_transport_key); + clevis_transport_key = NULL; + if (!pt) + return HTTP_STATUS_BAD_REQUEST; + + json_auto_t *payload = json_loadb(pt, ptl, 0, NULL); + SECURE_WIPE(pt, ptl); + free(pt); + if (!payload) + return HTTP_STATUS_BAD_REQUEST; + + const char *clevis_kem_ct = NULL; + const char *ek_digest = NULL; + json_t *clevis_kem_pub = NULL; + if (json_unpack(payload, "{s:s, s:o, s:s}", + "clevis_kem_ct", &clevis_kem_ct, + "clevis_kem_pub", &clevis_kem_pub, + "ek_digest", &ek_digest) < 0) + return HTTP_STATUS_BAD_REQUEST; + + json_auto_t *kem_ct_json = json_string(clevis_kem_ct); + if (!kem_ct_json) + return HTTP_STATUS_BAD_REQUEST; + + json_auto_t *enc_key = + jose_jwk_kem_dec(NULL, tang_kem_priv, kem_ct_json); + if (!enc_key) + return HTTP_STATUS_BAD_REQUEST; + + size_t dlen = jose_jwk_thp_buf(NULL, NULL, "S256", NULL, 0); + if (dlen == SIZE_MAX) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + size_t elen = jose_b64_enc_buf(NULL, dlen, NULL, 0); + if (elen == SIZE_MAX) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + uint8_t hash_buf[dlen]; + char thp_buf[elen + 1]; + + if (!jose_jwk_thp_buf(NULL, enc_key, "S256", hash_buf, dlen)) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + if (jose_b64_enc_buf(hash_buf, dlen, thp_buf, elen) != elen) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + thp_buf[elen] = '\0'; + + if (strcmp(thp_buf, ek_digest) != 0) + return HTTP_STATUS_BAD_REQUEST; + + json_auto_t *ret_encap = jose_jwk_kem_enc(NULL, clevis_kem_pub); + if (!ret_encap) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + const char *tang_transport_ct = + json_string_value(json_object_get(ret_encap, "ct")); + json_t *tang_transport_key = + json_object_get(ret_encap, "ss"); + if (!tang_transport_ct || !tang_transport_key) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + __attribute__((cleanup(str_cleanse))) char *enc_key_json = + json_dumps(enc_key, JSON_SORT_KEYS | JSON_COMPACT); + if (!enc_key_json) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + json_auto_t *resp_jwe = json_pack( + "{s:{s:s,s:s}}", "protected", "alg", "dir", "enc", "A256GCM"); + if (!resp_jwe) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + if (!jose_jwe_enc(NULL, resp_jwe, NULL, tang_transport_key, + enc_key_json, strlen(enc_key_json))) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + __attribute__((cleanup(str_cleanup))) char *tang_encrypted_key = + jwe_to_compact(resp_jwe); + if (!tang_encrypted_key) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + json_auto_t *response = json_pack( + "{s:s, s:s}", + "tang_encrypted_key", tang_encrypted_key, + "tang_transport_ct", tang_transport_ct); + if (!response) + return HTTP_STATUS_INTERNAL_SERVER_ERROR; + + *out = json_dumps(response, JSON_SORT_KEYS | JSON_COMPACT); + + wipe_jwk_secret(enc_key); + wipe_jwk_secret(tang_transport_key); + + return *out ? HTTP_STATUS_OK : HTTP_STATUS_INTERNAL_SERVER_ERROR; +} + +static json_t* +find_jws_kem(struct tang_keys_info* tki) +{ + if (!tki || json_array_size(tki->m_kem_payload) == 0) { + return NULL; + } + + json_auto_t* jws = jwk_sign(tki->m_kem_payload, tki->m_sign); + if (!jws) { + return NULL; + } + return json_incref(jws); +} + +int +adv_kem(http_method_t method, const char *path, const char *body, + regmatch_t matches[], void *misc) +{ + __attribute__((cleanup(str_cleanup))) char *out = NULL; + __attribute__((cleanup(cleanup_tang_keys_info))) + struct tang_keys_info *tki = NULL; + json_auto_t *jws = NULL; + const char *jwkdir = misc; + + tki = read_keys(jwkdir); + if (!tki || tki->m_keys_count == 0) + return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + + jws = find_jws_kem(tki); + if (!jws) + return http_reply(HTTP_STATUS_NOT_FOUND, NULL); + + out = json_dumps(jws, 0); + if (!out) + return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + + return http_reply(HTTP_STATUS_OK, + "Content-Type: application/jose+json\r\n" + "Content-Length: %zu\r\n" + "\r\n%s", strlen(out), out); +} + +int +rec_kem(http_method_t method, const char *path, const char *body, + regmatch_t matches[], void *misc) +{ + __attribute__((cleanup(str_cleanup))) char *enc = NULL; + __attribute__((cleanup(str_cleanup))) char *thp = NULL; + __attribute__((cleanup(cleanup_tang_keys_info))) + struct tang_keys_info *tki = NULL; + size_t size = matches[1].rm_eo - matches[1].rm_so; + const char *jwkdir = misc; + json_auto_t *tang_kem_priv = NULL; + json_auto_t *req = NULL; + const char *kty = NULL; + int status; + + req = json_loads(body, 0, NULL); + if (!req) + return http_reply(HTTP_STATUS_BAD_REQUEST, NULL); + + tki = read_keys(jwkdir); + if (!tki || tki->m_keys_count == 0) + return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + + thp = strndup(&path[matches[1].rm_so], size); + if (!thp) + return http_reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, NULL); + + tang_kem_priv = find_jwk(tki, thp); + if (!tang_kem_priv) + return http_reply(HTTP_STATUS_NOT_FOUND, NULL); + + if (json_unpack(tang_kem_priv, "{s:s}", "kty", &kty) < 0) + return http_reply(HTTP_STATUS_FORBIDDEN, NULL); + + if (strcmp(kty, "AKP") != 0) + return http_reply(HTTP_STATUS_FORBIDDEN, NULL); + + if (!jose_jwk_prm(NULL, tang_kem_priv, true, "deriveKey")) + return http_reply(HTTP_STATUS_FORBIDDEN, NULL); + + status = rec_kem_secure(tang_kem_priv, req, &enc); + if (status != HTTP_STATUS_OK) + return http_reply(status, NULL); + + return http_reply(HTTP_STATUS_OK, + "Content-Type: application/jwk+json\r\n" + "Content-Length: %zu\r\n" + "\r\n%s", strlen(enc), enc); +} diff --git a/src/pqc.h b/src/pqc.h new file mode 100644 index 0000000..4f46b88 --- /dev/null +++ b/src/pqc.h @@ -0,0 +1,31 @@ +/* vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: */ +/* + * Copyright (c) 2025 Red Hat, Inc. + * Author: Sergio Arroutbi + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "http.h" +#include "keys.h" + +int pqc_is_available(void); + +int adv_kem(http_method_t method, const char *path, const char *body, + regmatch_t matches[], void *misc); + +int rec_kem(http_method_t method, const char *path, const char *body, + regmatch_t matches[], void *misc); diff --git a/src/pqc_noop.c b/src/pqc_noop.c new file mode 100644 index 0000000..1f29065 --- /dev/null +++ b/src/pqc_noop.c @@ -0,0 +1,40 @@ +/* vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: */ +/* + * Copyright (c) 2025 Red Hat, Inc. + * Author: Sergio Arroutbi + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "pqc.h" + +int +pqc_is_available(void) +{ + return 0; +} + +int +adv_kem(http_method_t method, const char *path, const char *body, + regmatch_t matches[], void *misc) +{ + return http_reply(HTTP_STATUS_NOT_FOUND, NULL); +} + +int +rec_kem(http_method_t method, const char *path, const char *body, + regmatch_t matches[], void *misc) +{ + return http_reply(HTTP_STATUS_NOT_FOUND, NULL); +} diff --git a/src/tangd-keygen.in b/src/tangd-keygen.in index 6e0b033..7b3e26c 100755 --- a/src/tangd-keygen.in +++ b/src/tangd-keygen.in @@ -52,3 +52,11 @@ jwe=$(jose jwk gen -i '{"alg":"ECMR"}') [ -z "$exc" ] && exc=$(echo "$jwe" | jose jwk thp -i- -a "${THP_DEFAULT_HASH}") echo "$jwe" > "$1/$exc.jwk" set_perms "$1/$exc.jwk" + +if pqc=$(jose jwk gen -i '{"alg":"ML-KEM-768"}' 2>/dev/null); then + pqc_thp=$(echo "$pqc" | jose jwk thp -i- -a "${THP_DEFAULT_HASH}") + echo "$pqc" > "$1/$pqc_thp.jwk" + set_perms "$1/$pqc_thp.jwk" +else + echo "Skipping ML-KEM-768 key generation (not supported by jose)" >&2 +fi diff --git a/src/tangd.c b/src/tangd.c index 9ae3739..d1b9263 100644 --- a/src/tangd.c +++ b/src/tangd.c @@ -30,6 +30,7 @@ #include #include "keys.h" +#include "pqc.h" #include "socket.h" #define MAX_URL 256 @@ -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); @@ -188,10 +196,54 @@ 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; + __attribute__((cleanup(cleanup_tang_keys_info))) struct tang_keys_info *tki = NULL; + const char *jwkdir = misc; + int has_pqc = 0; + + tki = read_keys(jwkdir); + if (tki) + has_pqc = tki->m_has_pqc && pqc_is_available(); + + json_auto_t *ver = json_pack("{s:s, s:{s:b, s:b}}", + "tang_version", VERSION, + "features", + "tang_pub", 1, + "hybrid_recovery", has_pqc); + 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); +} + +enum dispatch_idx { + DISPATCH_ADV_THP, + DISPATCH_ADV, + DISPATCH_ADV_KEM, + DISPATCH_REC, + DISPATCH_REC_KEM, + DISPATCH_VER, + DISPATCH_COUNT +}; + 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/*$" }, + { adv_kem, 1 << HTTP_GET, 1, "^/+adv-kem/*$" }, + { rec, 1 << HTTP_POST, 2, "^/+rec/+([0-9A-Za-z_-]+)$" }, + { rec_kem, 1 << HTTP_POST, 2, "^/+rec-kem/+([0-9A-Za-z_-]+)$" }, + { tang_version, 1 << HTTP_GET, 1, "^/+version/*$" }, {} }; @@ -326,7 +378,10 @@ main(int argc, char *argv[]) char adv_thp_endpoint[MAX_URL] = {}; char adv_endpoint[MAX_URL] = {}; + char adv_kem_endpoint[MAX_URL] = {}; char rec_endpoint[MAX_URL] = {}; + char rec_kem_endpoint[MAX_URL] = {}; + char ver_endpoint[MAX_URL] = {}; if (endpoint != NULL) { char *endpoint_ptr = (char*)endpoint; while (*endpoint_ptr == '/') { @@ -334,10 +389,16 @@ 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(adv_kem_endpoint, MAX_URL, "^/%s/+adv-kem/*$", endpoint_ptr); snprintf(rec_endpoint, MAX_URL, "^/%s/+rec/+([0-9A-Za-z_-]+)$", endpoint_ptr); - s_dispatch[0].re = adv_thp_endpoint; - s_dispatch[1].re = adv_endpoint; - s_dispatch[2].re = rec_endpoint; + snprintf(rec_kem_endpoint, MAX_URL, "^/%s/+rec-kem/+([0-9A-Za-z_-]+)$", endpoint_ptr); + snprintf(ver_endpoint, MAX_URL, "^/%s/+version/*$", endpoint_ptr); + s_dispatch[DISPATCH_ADV_THP].re = adv_thp_endpoint; + s_dispatch[DISPATCH_ADV].re = adv_endpoint; + s_dispatch[DISPATCH_ADV_KEM].re = adv_kem_endpoint; + s_dispatch[DISPATCH_REC].re = rec_endpoint; + s_dispatch[DISPATCH_REC_KEM].re = rec_kem_endpoint; + s_dispatch[DISPATCH_VER].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 416dc6e..e38f30a 100755 --- a/tests/adv +++ b/tests/adv @@ -24,6 +24,56 @@ trap 'on_exit' EXIT export TMP=`mktemp -d` mkdir -p $TMP/db +jose_supports_mlkem () { + jose alg 2>/dev/null | grep -q "ML-KEM" +} + +adv_kem_test () { + prefix="${1}" + + if ! jose_supports_mlkem; then + echo "jose does not support ML-KEM, skipping /adv-kem tests" >&2 + return 0 + fi + + # /adv-kem should return a signed JWS containing only KEM keys + kem_adv=$(fetch "${prefix}"/adv-kem) + if [ -z "$kem_adv" ]; then + echo "/adv-kem returned empty response" >&2 + return 1 + fi + + # Verify the JWS is signed by the signing key + printf '%s' "$kem_adv" | ver $TMP/db/sig.jwk + + # Extract payload and verify all keys are AKP + printf '%s' "$kem_adv" | jose fmt -j- -Og payload -SyOg keys -f- \ + | while read -r jwk; do + kty=$(printf '%s' "$jwk" | jose fmt -j- -Og kty -u-) + if [ "$kty" != "AKP" ]; then + echo "Expected AKP key in /adv-kem, got $kty" >&2 + exit 1 + fi + done + [ $? -eq 0 ] + + # /adv should NOT contain any AKP keys + ec_adv=$(fetch "${prefix}"/adv) + printf '%s' "$ec_adv" | jose fmt -j- -Og payload -SyOg keys -f- \ + | while read -r jwk; do + kty=$(printf '%s' "$jwk" | jose fmt -j- -Og kty -u-) + if [ "$kty" = "AKP" ]; then + echo "AKP key must not appear in /adv" >&2 + exit 1 + fi + done + [ $? -eq 0 ] + + # Verify cty parameter is present + printf '%s' "$kem_adv" \ + | jose fmt -j- -Og protected -SyOg cty -Sq "jwk-set+json" -E +} + adv_startup () { tangd-keygen $TMP/db sig exc # Make sure keys generated by tangd-keygen have proper permissions. @@ -35,6 +85,10 @@ adv_startup () { } adv_second_phase () { + # Verify tangd CLI flags + tangd -v 2>/dev/null + tangd -h 2>/dev/null + # Make sure requests on the root fail fetch "${ENDPOINT}"/ && expected_fail @@ -47,6 +101,11 @@ adv_second_phase () { fetch "${ENDPOINT}"/adv | ver $TMP/db/sig.jwk fetch "${ENDPOINT}"/adv/ | ver $TMP/db/sig.jwk + # 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 fetch "${ENDPOINT}"/adv/`jose jwk thp -a S512 -i $TMP/db/sig.jwk` | ver $TMP/db/sig.jwk @@ -71,6 +130,8 @@ adv_second_phase () { -g 0 -Og protected -SyOg cty -Sq "jwk-set+json" -EUUUUU \ -g 1 -Og protected -SyOg cty -Sq "jwk-set+json" -EUUUUU + adv_kem_test "${ENDPOINT}" + THP_DEFAULT_HASH=S256 # SHA-256. test "$(tang-show-keys $PORT $ENDPOINT)" = "$(jose jwk thp -a "${THP_DEFAULT_HASH}" -i $TMP/db/sig.jwk)" diff --git a/tests/rec b/tests/rec index d30f72f..cc237dc 100755 --- a/tests/rec +++ b/tests/rec @@ -38,6 +38,10 @@ rec_startup () { jose jwk pub -i $TMP/exc.jwk -o $TMP/exc.pub.jwk } +jose_supports_mlkem () { + jose alg 2>/dev/null | grep -q "ML-KEM" +} + rec_second_phase () { # Make sure that GET fails curl -sf http://127.0.0.1:$PORT/rec && expected_fail @@ -45,11 +49,23 @@ 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_kem_test "" } rec_second_phase_endpoint () { @@ -59,9 +75,148 @@ 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" ] + + rec_kem_test "${ENDPOINT}/" +} + +rec_kem_test () { + prefix="${1}" + + if ! jose_supports_mlkem; then + echo "jose does not support ML-KEM, skipping /rec-kem tests" >&2 + return 0 + fi + + # Find the ML-KEM key in the server db + kem_jwk="" + for f in "$TMP"/db/*.jwk; do + kty=$(jose fmt -j "$f" -Og kty -u- 2>/dev/null) || continue + if [ "$kty" = "AKP" ]; then + kem_jwk="$f" + break + fi + done + if [ -z "$kem_jwk" ]; then + echo "No ML-KEM key found in server db, skipping /rec-kem tests" >&2 + return 0 + fi + + kem_kid=$(jose jwk thp -i "$kem_jwk" -a S256) + jose jwk pub -i "$kem_jwk" -o "$TMP/kem.pub.jwk" + + # Step 1: Simulate the stored KEM binding (client encapsulates at bind time) + stored_encap=$(jose jwk encap -i "$TMP/kem.pub.jwk") + enc_key=$(printf '%s' "$stored_encap" | jose fmt -j- -Og ss -o-) + clevis_kem_ct=$(printf '%s' "$stored_encap" | jose fmt -j- -Og ct -u-) + + # Compute ek_digest (JWK thumbprint of enc_key) + printf '%s' "$enc_key" > "$TMP/enc_key.jwk" + ek_digest=$(jose jwk thp -i "$TMP/enc_key.jwk" -a S256) + + # Step 2: Generate client ephemeral KEM keypair for return channel + jose jwk gen -i '{"alg":"ML-KEM-768"}' -o "$TMP/clevis_kem_priv.jwk" + jose jwk pub -i "$TMP/clevis_kem_priv.jwk" -o "$TMP/clevis_kem_pub.jwk" + + # Step 3: Forward channel — encapsulate to tang's KEM pub + fwd_encap=$(jose jwk encap -i "$TMP/kem.pub.jwk") + clevis_transport_ct=$(printf '%s' "$fwd_encap" | jose fmt -j- -Og ct -u-) + printf '%s' "$fwd_encap" | jose fmt -j- -Og ss -o- > "$TMP/clevis_transport_key.jwk" + + # Step 4: Build inner payload {clevis_kem_ct, clevis_kem_pub, ek_digest} + clevis_kem_pub=$(cat "$TMP/clevis_kem_pub.jwk") + printf '{"clevis_kem_ct":"%s","clevis_kem_pub":%s,"ek_digest":"%s"}' \ + "$clevis_kem_ct" "$clevis_kem_pub" "$ek_digest" \ + > "$TMP/inner_payload.json" + + # Step 5: Encrypt inner payload under clevis_transport_key → clevis_encrypted_blob + clevis_encrypted_blob=$(jose jwe enc \ + -i '{"protected":{"alg":"dir","enc":"A256GCM"}}' \ + -I "$TMP/inner_payload.json" \ + -k "$TMP/clevis_transport_key.jwk" -c) + + # Step 6: POST {clevis_encrypted_blob, clevis_transport_ct} to /rec-kem + req_body=$(printf '{"clevis_encrypted_blob":"%s","clevis_transport_ct":"%s"}' \ + "$clevis_encrypted_blob" "$clevis_transport_ct") + server_resp=$(curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d "$req_body" \ + "http://127.0.0.1:$PORT/${prefix}rec-kem/${kem_kid}") + + # Step 7: Parse response {tang_encrypted_key, tang_transport_ct} + tang_transport_ct=$(printf '%s' "$server_resp" | jose fmt -j- -Og tang_transport_ct -u-) + tang_encrypted_key=$(printf '%s' "$server_resp" | jose fmt -j- -Og tang_encrypted_key -u-) + + # Step 8: Decapsulate return channel with clevis_kem_priv + tang_transport_key=$(jose jwk decap -i "$TMP/clevis_kem_priv.jwk" -c "$tang_transport_ct") + printf '%s' "$tang_transport_key" > "$TMP/tang_transport_key.jwk" + + # Step 9: Split compact JWE into JSON and decrypt enc_key + IFS='.' read -r prot ekey iv ct tag < "$TMP/resp_jwe.json" + recovered_enc_key=$(jose jwe dec -i "$TMP/resp_jwe.json" -k "$TMP/tang_transport_key.jwk") + + # Step 10: Verify recovered enc_key matches the original + enc_key_k=$(printf '%s' "$enc_key" | jose fmt -j- -Og k -u-) + recovered_enc_key_k=$(printf '%s' "$recovered_enc_key" | jose fmt -j- -Og k -u-) + [ "$enc_key_k" = "$recovered_enc_key_k" ] + + # Error cases + curl -sf "http://127.0.0.1:$PORT/${prefix}rec-kem/${kem_kid}" && expected_fail + + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -d "not-json" \ + "http://127.0.0.1:$PORT/${prefix}rec-kem/${kem_kid}") + [ "$code" = "400" ] + + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -d '{"foo":"bar"}' \ + "http://127.0.0.1:$PORT/${prefix}rec-kem/${kem_kid}") + [ "$code" = "400" ] + + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -d "$req_body" \ + "http://127.0.0.1:$PORT/${prefix}rec-kem/nonexistent") + [ "$code" = "404" ] + + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -d "$req_body" \ + "http://127.0.0.1:$PORT/${prefix}rec-kem/${exc_kid}") + [ "$code" = "403" ] + + # Wrong ek_digest must be rejected + bad_fwd_encap=$(jose jwk encap -i "$TMP/kem.pub.jwk") + bad_clevis_transport_ct=$(printf '%s' "$bad_fwd_encap" | jose fmt -j- -Og ct -u-) + printf '%s' "$bad_fwd_encap" | jose fmt -j- -Og ss -o- > "$TMP/bad_clevis_transport_key.jwk" + printf '{"clevis_kem_ct":"%s","clevis_kem_pub":%s,"ek_digest":"%s"}' \ + "$clevis_kem_ct" "$clevis_kem_pub" "INVALID_DIGEST_VALUE" \ + > "$TMP/bad_payload.json" + bad_blob=$(jose jwe enc \ + -i '{"protected":{"alg":"dir","enc":"A256GCM"}}' \ + -I "$TMP/bad_payload.json" \ + -k "$TMP/bad_clevis_transport_key.jwk" -c) + bad_req=$(printf '{"clevis_encrypted_blob":"%s","clevis_transport_ct":"%s"}' \ + "$bad_blob" "$bad_clevis_transport_ct") + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Content-Type: application/json" \ + -d "$bad_req" \ + "http://127.0.0.1:$PORT/${prefix}rec-kem/${kem_kid}") + [ "$code" = "400" ] } diff --git a/tests/test-keys.c.in b/tests/test-keys.c.in index 59556b5..e578a4b 100644 --- a/tests/test-keys.c.in +++ b/tests/test-keys.c.in @@ -89,7 +89,7 @@ test_create_new_keys(void) ASSERT(newdir); __attribute__((cleanup(cleanup_tang_keys_info))) struct tang_keys_info* tki = read_keys(newdir); ASSERT(tki); - ASSERT(tki->m_keys_count == 2); + ASSERT(tki->m_keys_count >= 2); /* Make sure keys have proper permissions. */ verify_keys_permissions(newdir); @@ -299,6 +299,83 @@ test_read_keys(void) ASSERT(tki2 == NULL); } +static void +test_cleanup_null(void) +{ + free_tang_keys_info(NULL); + cleanup_tang_keys_info(NULL); + struct tang_keys_info* null_tki = NULL; + cleanup_tang_keys_info(&null_tki); +} + +static void +test_jwk_sign_null_inputs(void) +{ + ASSERT(jwk_sign(NULL, NULL) == NULL); + + json_auto_t* arr = json_array(); + ASSERT(jwk_sign(arr, NULL) == NULL); + ASSERT(jwk_sign(NULL, arr) == NULL); + + json_auto_t* obj = json_object(); + ASSERT(jwk_sign(obj, arr) == NULL); + ASSERT(jwk_sign(arr, obj) == NULL); +} + +static void +test_read_keys_no_kem(void) +{ + __attribute__((cleanup(cleanup_tang_keys_info))) struct tang_keys_info* tki = read_keys(jwkdir); + ASSERT(tki); + ASSERT(tki->m_has_pqc == 0); + ASSERT(json_array_size(tki->m_kem_payload) == 0); +} + +static void +test_read_keys_with_kem(void) +{ + json_auto_t* kem = jwk_generate("ML-KEM-768"); + if (!kem) { + fprintf(stderr, "Skipping KEM tests (ML-KEM-768 not supported)\n"); + return; + } + + umask(0022); + __attribute__((cleanup(cleanup_str))) char* newdir = create_tempdir(); + ASSERT(newdir); + + json_auto_t* sig = jwk_generate("ES512"); + ASSERT(sig); + __attribute__((cleanup(cleanup_str))) char* sig_thp = jwk_thumbprint(sig, "S256"); + ASSERT(sig_thp); + char sig_path[PATH_MAX]; + snprintf(sig_path, PATH_MAX, "%s/%s.jwk", newdir, sig_thp); + ASSERT(json_dump_file(sig, sig_path, 0) == 0); + + json_auto_t* exc = jwk_generate("ECMR"); + ASSERT(exc); + __attribute__((cleanup(cleanup_str))) char* exc_thp = jwk_thumbprint(exc, "S256"); + ASSERT(exc_thp); + char exc_path[PATH_MAX]; + snprintf(exc_path, PATH_MAX, "%s/%s.jwk", newdir, exc_thp); + ASSERT(json_dump_file(exc, exc_path, 0) == 0); + + __attribute__((cleanup(cleanup_str))) char* kem_thp = jwk_thumbprint(kem, "S256"); + ASSERT(kem_thp); + char kem_path[PATH_MAX]; + snprintf(kem_path, PATH_MAX, "%s/%s.jwk", newdir, kem_thp); + ASSERT(json_dump_file(kem, kem_path, 0) == 0); + + __attribute__((cleanup(cleanup_tang_keys_info))) struct tang_keys_info* tki = read_keys(newdir); + ASSERT(tki); + ASSERT(tki->m_keys_count == 3); + ASSERT(tki->m_has_pqc == 1); + ASSERT(json_array_size(tki->m_kem_payload) == 1); + ASSERT(json_array_size(tki->m_payload) == 2); + + remove_tempdir(newdir); +} + static void run_tests(void) { @@ -308,6 +385,10 @@ run_tests(void) test_jwk_generate(); test_is_hash(); test_create_new_keys(); + test_cleanup_null(); + test_jwk_sign_null_inputs(); + test_read_keys_no_kem(); + test_read_keys_with_kem(); } int main(int argc, char** argv) diff --git a/tests/verify-tang-pub b/tests/verify-tang-pub new file mode 100755 index 0000000..cc71dfa --- /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 + +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 ! 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: /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" + +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"