From 23abd0fa0bcaa522deeb750a96ce358650223f73 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 17:18:43 -0400 Subject: [PATCH 1/8] Add the signing library libmoddims_sign holds the /dims4/ and /dims5/ rules behind a C99 header that links libcrypto. A caller outside the module builds against it without APR and without httpd. One object library compiles the source once, and the archive and the shared object are built from those objects. Two calls sign a URL. The rest of the header is what the module and a verifier need: the escape, the canonical query, the two digests, the two comparisons, and the field check. No other target links it yet. --- CMakeLists.txt | 8 + sign/CMakeLists.txt | 38 ++ sign/include/dims_sign.h | 218 +++++++ sign/src/sign.c | 1210 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 1474 insertions(+) create mode 100644 sign/CMakeLists.txt create mode 100644 sign/include/dims_sign.h create mode 100644 sign/src/sign.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 902d167..35979a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,6 +67,14 @@ pkg_check_modules(CRYPTO REQUIRED IMPORTED_TARGET libcrypto) # ImageMagick 7. Version 6 is refused: it does not provide the headers the # module includes. find_package(MagickWand7 REQUIRED) + +# -- The signing library +# +# The module links it, and so do the command and the tests. It builds first +# because every other target here reaches for it. + +add_subdirectory(sign) + # -- The module add_library(mod_dims MODULE diff --git a/sign/CMakeLists.txt b/sign/CMakeLists.txt new file mode 100644 index 0000000..3d27a48 --- /dev/null +++ b/sign/CMakeLists.txt @@ -0,0 +1,38 @@ +# The signing rules, as a library every client can check itself against. +# +# It links libcrypto. A caller outside the module builds it without APR and +# without an httpd development package. +# +# One object library compiles the source once. The archive and the shared +# object are built from those objects, so the two hold the same code. The +# module links the archive, so libmod_dims.so gains no run time dependency. + +add_library(moddims_sign_objects OBJECT src/sign.c) + +set_target_properties(moddims_sign_objects PROPERTIES + POSITION_INDEPENDENT_CODE ON) + +target_include_directories(moddims_sign_objects PUBLIC + "$") + +target_link_libraries(moddims_sign_objects PUBLIC PkgConfig::CRYPTO) + +dims_set_warnings(moddims_sign_objects) +dims_set_hardening(moddims_sign_objects) +dims_set_sanitizers(moddims_sign_objects) + +add_library(moddims_sign STATIC $) +add_library(moddims_sign_shared SHARED $) + +set_target_properties(moddims_sign_shared PROPERTIES + OUTPUT_NAME "moddims_sign" + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR}) + +foreach(target moddims_sign moddims_sign_shared) + target_include_directories(${target} PUBLIC + "$") + target_link_libraries(${target} PUBLIC PkgConfig::CRYPTO) + dims_set_hardening(${target}) + dims_set_sanitizers(${target}) +endforeach() diff --git a/sign/include/dims_sign.h b/sign/include/dims_sign.h new file mode 100644 index 0000000..313a785 --- /dev/null +++ b/sign/include/dims_sign.h @@ -0,0 +1,218 @@ +/* + * The mod_dims signing rules. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef DIMS_SIGN_H +#define DIMS_SIGN_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* The hex MD5 a /dims4/ signature comes from. */ +#define DIMS_SIGN_DIMS4_DIGEST 32 + +/* + * How many characters of that digest a /dims4/ path holds. The module compares + * six, and every /dims4/ URL has held six. A caller asks for more with a + * longer placeholder, up to the whole digest. + */ +#define DIMS_SIGN_DIMS4_LENGTH 6 + +/* A /dims5/ signature: a hex HMAC-SHA256. */ +#define DIMS_SIGN_DIMS5_LENGTH 64 + +/* The prefix each endpoint conventionally serves at. */ +#define DIMS_SIGN_DIMS4_PREFIX "/dims4/" +#define DIMS_SIGN_DIMS5_PREFIX "/dims5/" + +typedef enum { + DIMS_SIGN_OK = 0, + DIMS_SIGN_MEMORY, /* malloc refused */ + DIMS_SIGN_BAD_ARGUMENT, /* a required argument is NULL or empty */ + DIMS_SIGN_BAD_URL, /* the path does not start with the prefix, a + percent escape is not two hex digits, a + /dims4/ path has fewer than four segments after + the prefix, a /dims4/ expiry is not decimal + digits, a /dims4/ signature segment is shorter + than DIMS_SIGN_DIMS4_LENGTH, or the query has + no url */ + DIMS_SIGN_BAD_FIELD, /* a signed field holds a control character */ + DIMS_SIGN_CRYPTO /* libcrypto refused */ +} dims_sign_status; + +/* A short description of a status, for an error message. */ +const char *dims_sign_strerror(dims_sign_status status); + +/* + * Releases a string one of the functions below wrote. + * + * Use this and not free. A shared library build allocates from its own heap, + * and on Windows that is not the heap the caller frees from. + */ +void dims_sign_free(char *string); + +/* + * Signs one /dims5/ URL under one key. + * + * url is absolute or a path. prefix is what comes before the commands in that + * path, and NULL means DIMS_SIGN_DIMS5_PREFIX. The signature covers everything + * between the prefix and the query, percent decoded, so a caller behind a + * rewrite passes the public prefix and gets the signature the module computes + * after the rewrite. + * + * The query passes through as it is. This function reads it and does not + * rewrite it. sig goes on the end, and an input that already holds one gets a + * new one in its place. + * + * On DIMS_SIGN_OK, *out holds the signed URL. Release it with dims_sign_free. + */ +dims_sign_status dims_sign_dims5_url(const char *url, const char *key, + const char *prefix, char **out); + +/* + * Signs one /dims4/ URL under one client secret. + * + * prefix is what comes before the client id, and NULL means + * DIMS_SIGN_DIMS4_PREFIX. Four segments follow it: the client id, the + * signature, the expiry, and the commands. + * + * The length of the placeholder in the signature segment sets the length of + * the signature, from DIMS_SIGN_DIMS4_LENGTH to DIMS_SIGN_DIMS4_DIGEST + * characters. + * + * The image URL signs with every plus written as a space. The module applies + * that rule on this endpoint. /dims5/ keeps the plus. + * + * On DIMS_SIGN_OK, *out holds the signed URL. Release it with dims_sign_free. + */ +dims_sign_status dims_sign_dims4_url(const char *url, const char *key, + const char *prefix, char **out); + +/* + * Builds the message dims_sign_dims5_url hashes, so a caller can read what a + * server disagreed with. It hashes nothing itself. + * + * The message is the commands, the image URL, and the canonical query, + * separated by newlines: + * + * resize/100x100/ + * http://origin:8080/grid.png + * overlay=http%3A%2F%2Forigin%3A8080%2Foverlay.png + * + * There is no /dims4/ version. A /dims4/ message contains the client secret. + * + * On DIMS_SIGN_OK, *out holds the message. Release it with dims_sign_free. + */ +dims_sign_status dims_sign_dims5_message(const char *url, const char *prefix, + char **out); + +/* -- The rules, for the module and the command -- */ + +/* + * Percent encodes one query component. + * + * Everything outside A-Za-z0-9-_.~ becomes %XX with uppercase hex, and a space + * becomes a plus. + * + * On DIMS_SIGN_OK, *out holds the escaped value. Release it with + * dims_sign_free. + */ +dims_sign_status dims_sign_escape(const char *value, char **out); + +/* + * Builds the canonical query, which is the third line of a /dims5/ message. + * + * Each parameter is decoded, with a plus read as a space, then written + * name=value and percent encoded. The parameters are ordered by the bytes of + * the name. A name that appears more than once keeps the order the query + * gives. A parameter with no equals sign has an empty value. + * + * sig, url, eurl, _keys, and download take no part and are left out. + * + * On DIMS_SIGN_OK, *out holds the canonical query. Release it with + * dims_sign_free. + */ +dims_sign_status dims_sign_canonical_query(const char *query, char **out); + +/* + * Hashes the commands, the image URL, and the canonical query under the key, + * and writes the digest as 64 lowercase hex characters. + * + * The commands go in as given. The module signs what follows the prefix in + * the decoded r->uri, trailing slash or not, so a verifier passes those bytes + * and adds nothing. + * + * Returns DIMS_SIGN_BAD_FIELD when the commands or the image URL hold a + * control character. See dims_sign_field_ok. + */ +dims_sign_status dims_sign_dims5_digest(const char *key, const char *commands, + const char *image_url, + const char *canonical_query, + char out[DIMS_SIGN_DIMS5_LENGTH + 1]); + +/* One name and one value, for a /dims4/ signature. */ +typedef struct { + const char *name; + const char *value; +} dims_sign_param; + +/* + * Hashes the expiry, the secret, the commands, the image URL, and the value of + * each named parameter, joined with nothing between them, and writes the MD5 + * as 32 lowercase hex characters. + * + * The commands go in as given, with a space already written as a plus. Each + * value goes in as it appears in the query, not decoded. A parameter with a + * NULL value contributes nothing and does not stop the ones after it, which is + * what the module does when _keys names a parameter the request leaves out. + * + * out holds the whole digest. The URL takes the first + * DIMS_SIGN_DIMS4_LENGTH characters, or more when the caller asks for more. + */ +dims_sign_status dims_sign_dims4_digest(const char *secret, + const char *expires, + const char *commands, + const char *image_url, + const dims_sign_param *keys, + size_t key_count, + char out[DIMS_SIGN_DIMS4_DIGEST + 1]); + +/* + * Reports whether a /dims5/ signature matches the expected digest. Returns 1 + * when both are exactly DIMS_SIGN_DIMS5_LENGTH characters and equal, case + * sensitively. + * + * The comparison reads every byte whatever the answer. One that stops at the + * first difference reports how many leading characters were right. + */ +int dims_sign_dims5_equal(const char *expected, const char *given); + +/* + * Reports whether a /dims4/ signature matches the expected digest. Returns 1 + * when the first DIMS_SIGN_DIMS4_LENGTH characters agree without regard to + * case. given may be longer, and the rest is not read. + * + * The comparison reads every one of those bytes whatever the answer. + */ +int dims_sign_dims4_equal(const char *expected, const char *given); + +/* + * Reports whether a field is safe to put in a /dims5/ message. Returns 1 when + * the field holds no control character. + * + * The message puts one field per line, so a field holding a newline could stand + * in for two. + */ +int dims_sign_field_ok(const char *field); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/sign/src/sign.c b/sign/src/sign.c new file mode 100644 index 0000000..ce8548e --- /dev/null +++ b/sign/src/sign.c @@ -0,0 +1,1210 @@ +/* + * The mod_dims signing rules. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "dims_sign.h" + +#include +#include +#include +#include +#include + +/* The parameters a /dims5/ signature never covers. */ +static const char *const unsigned_params[] = { + "sig", "url", "eurl", "_keys", "download", NULL +}; + +/* -- A growable string ------------------------------------------------- */ + +typedef struct { + char *data; + size_t length; + size_t capacity; + int failed; +} buffer; + +static void +buffer_init(buffer *b) +{ + b->data = NULL; + b->length = 0; + b->capacity = 0; + b->failed = 0; +} + +static void +buffer_reserve(buffer *b, size_t extra) +{ + size_t needed; + size_t capacity; + char *grown; + + if (b->failed) { + return; + } + + if (extra > (size_t) -1 - b->length - 1) { + b->failed = 1; + return; + } + + needed = b->length + extra + 1; + if (needed <= b->capacity) { + return; + } + + capacity = (b->capacity != 0) ? b->capacity : 64; + while (capacity < needed) { + if (capacity > (size_t) -1 / 2) { + b->failed = 1; + return; + } + capacity *= 2; + } + + grown = realloc(b->data, capacity); + if (grown == NULL) { + b->failed = 1; + return; + } + + b->data = grown; + b->capacity = capacity; +} + +static void +buffer_add_bytes(buffer *b, const char *at, size_t length) +{ + buffer_reserve(b, length); + if (b->failed) { + return; + } + + memcpy(b->data + b->length, at, length); + b->length += length; + b->data[b->length] = '\0'; +} + +static void +buffer_add(buffer *b, const char *text) +{ + if (text != NULL) { + buffer_add_bytes(b, text, strlen(text)); + } +} + +static void +buffer_add_char(buffer *b, char c) +{ + buffer_add_bytes(b, &c, 1); +} + +/* Hands the string to the caller, or NULL when an allocation failed. */ +static char * +buffer_take(buffer *b) +{ + char *data; + + if (b->failed) { + free(b->data); + buffer_init(b); + return NULL; + } + + if (b->data == NULL) { + buffer_reserve(b, 0); + if (b->failed) { + return NULL; + } + b->data[0] = '\0'; + } + + data = b->data; + buffer_init(b); + + return data; +} + +static void +buffer_release(buffer *b) +{ + free(b->data); + buffer_init(b); +} + +/* -- Percent encoding and decoding -------------------------------------- */ + +static int +is_unreserved(unsigned char c) +{ + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~'; +} + +static int +hex_value(unsigned char c) +{ + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + + return -1; +} + +static void +buffer_add_escaped(buffer *b, const char *value) +{ + static const char hex[] = "0123456789ABCDEF"; + const unsigned char *at; + + if (value == NULL) { + return; + } + + for (at = (const unsigned char *) value; *at != '\0'; at++) { + if (is_unreserved(*at)) { + buffer_add_char(b, (char) *at); + } else if (*at == ' ') { + buffer_add_char(b, '+'); + } else { + buffer_add_char(b, '%'); + buffer_add_char(b, hex[*at >> 4]); + buffer_add_char(b, hex[*at & 0x0F]); + } + } +} + +dims_sign_status +dims_sign_escape(const char *value, char **out) +{ + buffer escaped; + + if (out == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + buffer_init(&escaped); + buffer_add_escaped(&escaped, value); + + *out = buffer_take(&escaped); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +/* + * Decodes one query component the way the module does. A plus is a space, and + * a percent escape that is not two hex digits passes through as it is. The + * module leaves such an escape alone, so the two agree on every input. + */ +static char * +decode_component(const char *at, size_t length) +{ + buffer decoded; + size_t i; + + buffer_init(&decoded); + + for (i = 0; i < length; i++) { + if (at[i] == '+') { + buffer_add_char(&decoded, ' '); + } else if (at[i] == '%' && i + 2 < length && + hex_value((unsigned char) at[i + 1]) >= 0 && + hex_value((unsigned char) at[i + 2]) >= 0) { + int value = hex_value((unsigned char) at[i + 1]) * 16 + + hex_value((unsigned char) at[i + 2]); + + buffer_add_char(&decoded, (char) value); + i += 2; + } else { + buffer_add_char(&decoded, at[i]); + } + } + + return buffer_take(&decoded); +} + +/* + * Decodes a path or an image URL. A plus stays a plus, because the module + * decodes both with ap_unescape_url, which does not read a plus as a space. + * + * A percent escape that is not two hex digits is an error, and so is %00: a C + * string cannot hold the byte it decodes to. + */ +static dims_sign_status +decode_strict(const char *at, size_t length, char **out) +{ + buffer decoded; + size_t i; + + buffer_init(&decoded); + + for (i = 0; i < length; i++) { + if (at[i] == '%') { + int high; + int low; + + if (i + 2 >= length) { + buffer_release(&decoded); + return DIMS_SIGN_BAD_URL; + } + + high = hex_value((unsigned char) at[i + 1]); + low = hex_value((unsigned char) at[i + 2]); + if (high < 0 || low < 0 || (high == 0 && low == 0)) { + buffer_release(&decoded); + return DIMS_SIGN_BAD_URL; + } + + buffer_add_char(&decoded, (char) (high * 16 + low)); + i += 2; + } else { + buffer_add_char(&decoded, at[i]); + } + } + + *out = buffer_take(&decoded); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +/* -- The canonical query ------------------------------------------------ */ + +typedef struct { + char *name; + char *value; + size_t order; +} param; + +static int +by_name_then_order(const void *a, const void *b) +{ + const param *left = a; + const param *right = b; + int cmp = strcmp(left->name, right->name); + + /* A name that appears more than once keeps the order the query gave. */ + if (cmp != 0) { + return cmp; + } + + return (left->order < right->order) ? -1 : (left->order > right->order); +} + +static int +is_unsigned_param(const char *name) +{ + size_t i; + + for (i = 0; unsigned_params[i] != NULL; i++) { + if (strcmp(name, unsigned_params[i]) == 0) { + return 1; + } + } + + return 0; +} + +/* An upper bound on the token count: one more than the ampersand count. */ +static size_t +token_limit(const char *query) +{ + size_t count = 1; + const char *at; + + for (at = query; *at != '\0'; at++) { + if (*at == '&') { + count++; + } + } + + return count; +} + +static void +release_params(param *list, size_t count) +{ + size_t i; + + for (i = 0; i < count; i++) { + free(list[i].name); + free(list[i].value); + } + + free(list); +} + +dims_sign_status +dims_sign_canonical_query(const char *query, char **out) +{ + param *list; + size_t count = 0; + size_t i; + const char *at; + buffer canonical; + + if (out == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (query == NULL || *query == '\0') { + *out = calloc(1, 1); + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; + } + + list = calloc(token_limit(query), sizeof(*list)); + if (list == NULL) { + return DIMS_SIGN_MEMORY; + } + + at = query; + while (*at != '\0') { + const char *end = strchr(at, '&'); + const char *equals; + size_t length; + + if (end == NULL) { + end = at + strlen(at); + } + + length = (size_t) (end - at); + equals = memchr(at, '=', length); + + if (length > 0) { + param *entry = &list[count]; + + if (equals != NULL) { + entry->name = decode_component(at, (size_t) (equals - at)); + entry->value = decode_component(equals + 1, + (size_t) (end - equals - 1)); + } else { + /* A parameter with no equals sign has an empty value. */ + entry->name = decode_component(at, length); + entry->value = calloc(1, 1); + } + + if (entry->name == NULL || entry->value == NULL) { + free(entry->name); + free(entry->value); + release_params(list, count); + return DIMS_SIGN_MEMORY; + } + + if (is_unsigned_param(entry->name)) { + free(entry->name); + free(entry->value); + entry->name = NULL; + entry->value = NULL; + } else { + entry->order = count; + count++; + } + } + + at = (*end == '\0') ? end : end + 1; + } + + qsort(list, count, sizeof(*list), by_name_then_order); + + buffer_init(&canonical); + for (i = 0; i < count; i++) { + if (i > 0) { + buffer_add_char(&canonical, '&'); + } + buffer_add_escaped(&canonical, list[i].name); + buffer_add_char(&canonical, '='); + buffer_add_escaped(&canonical, list[i].value); + } + + release_params(list, count); + + *out = buffer_take(&canonical); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +/* -- The digests -------------------------------------------------------- */ + +static void +to_hex(const unsigned char *bytes, unsigned int count, char *out) +{ + static const char hex[] = "0123456789abcdef"; + unsigned int i; + + for (i = 0; i < count; i++) { + out[i * 2] = hex[bytes[i] >> 4]; + out[i * 2 + 1] = hex[bytes[i] & 0x0F]; + } + + out[count * 2] = '\0'; +} + +dims_sign_status +dims_sign_dims5_digest(const char *key, const char *commands, + const char *image_url, const char *canonical_query, + char out[DIMS_SIGN_DIMS5_LENGTH + 1]) +{ + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int length = 0; + buffer message; + char *text; + + if (out == NULL || key == NULL || *key == '\0') { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (!dims_sign_field_ok(commands) || !dims_sign_field_ok(image_url)) { + return DIMS_SIGN_BAD_FIELD; + } + + buffer_init(&message); + buffer_add(&message, commands); + buffer_add_char(&message, '\n'); + buffer_add(&message, image_url); + buffer_add_char(&message, '\n'); + buffer_add(&message, canonical_query); + + text = buffer_take(&message); + if (text == NULL) { + return DIMS_SIGN_MEMORY; + } + + if (HMAC(EVP_sha256(), key, (int) strlen(key), + (const unsigned char *) text, strlen(text), + digest, &length) == NULL || + length * 2 != DIMS_SIGN_DIMS5_LENGTH) { + free(text); + return DIMS_SIGN_CRYPTO; + } + + free(text); + to_hex(digest, length, out); + + return DIMS_SIGN_OK; +} + +dims_sign_status +dims_sign_dims4_digest(const char *secret, const char *expires, + const char *commands, const char *image_url, + const dims_sign_param *keys, size_t key_count, + char out[DIMS_SIGN_DIMS4_DIGEST + 1]) +{ + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int length = 0; + buffer message; + char *text; + size_t i; + + if (out == NULL || secret == NULL || *secret == '\0' || expires == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + buffer_init(&message); + buffer_add(&message, expires); + buffer_add(&message, secret); + buffer_add(&message, commands); + buffer_add(&message, image_url); + + for (i = 0; i < key_count; i++) { + /* A NULL value contributes nothing. */ + buffer_add(&message, keys[i].value); + } + + text = buffer_take(&message); + if (text == NULL) { + return DIMS_SIGN_MEMORY; + } + + if (!EVP_Digest(text, strlen(text), digest, &length, EVP_md5(), NULL) || + length * 2 != DIMS_SIGN_DIMS4_DIGEST) { + free(text); + return DIMS_SIGN_CRYPTO; + } + + free(text); + to_hex(digest, length, out); + + return DIMS_SIGN_OK; +} + +/* -- The comparisons ---------------------------------------------------- */ + +int +dims_sign_dims5_equal(const char *expected, const char *given) +{ + if (expected == NULL || given == NULL) { + return 0; + } + + if (strlen(expected) != DIMS_SIGN_DIMS5_LENGTH || + strlen(given) != DIMS_SIGN_DIMS5_LENGTH) { + return 0; + } + + return CRYPTO_memcmp(expected, given, DIMS_SIGN_DIMS5_LENGTH) == 0; +} + +static unsigned char +fold(unsigned char c) +{ + return (c >= 'A' && c <= 'Z') ? (unsigned char) (c - 'A' + 'a') : c; +} + +int +dims_sign_dims4_equal(const char *expected, const char *given) +{ + unsigned char difference = 0; + size_t i; + + if (expected == NULL || given == NULL) { + return 0; + } + + if (strlen(expected) < DIMS_SIGN_DIMS4_LENGTH || + strlen(given) < DIMS_SIGN_DIMS4_LENGTH) { + return 0; + } + + /* Every byte is read whatever the answer. */ + for (i = 0; i < DIMS_SIGN_DIMS4_LENGTH; i++) { + difference |= (unsigned char) (fold((unsigned char) expected[i]) ^ + fold((unsigned char) given[i])); + } + + return difference == 0; +} + +int +dims_sign_field_ok(const char *field) +{ + const unsigned char *at; + + if (field == NULL) { + return 1; + } + + for (at = (const unsigned char *) field; *at != '\0'; at++) { + if (*at < 0x20 || *at == 0x7F) { + return 0; + } + } + + return 1; +} + +/* -- Reading a URL ------------------------------------------------------ */ + +typedef struct { + const char *path; + size_t path_length; + const char *query; /* After the question mark, or NULL. */ +} url_parts; + +static int +is_scheme_byte(unsigned char c) +{ + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.'; +} + +/* Where the path starts. An absolute URL has an authority before it. */ +static const char * +path_of(const char *url) +{ + const char *at = url; + + if ((*at >= 'A' && *at <= 'Z') || (*at >= 'a' && *at <= 'z')) { + while (is_scheme_byte((unsigned char) *at)) { + at++; + } + + if (at[0] == ':' && at[1] == '/' && at[2] == '/') { + at += 3; + while (*at != '\0' && *at != '/' && *at != '?') { + at++; + } + + return at; + } + } + + return url; +} + +static void +split_url(const char *url, url_parts *parts) +{ + const char *question; + + parts->path = path_of(url); + question = strchr(parts->path, '?'); + + if (question != NULL) { + parts->path_length = (size_t) (question - parts->path); + parts->query = question + 1; + } else { + parts->path_length = strlen(parts->path); + parts->query = NULL; + } +} + +/* What follows the prefix in the path. */ +static dims_sign_status +after_prefix(const url_parts *parts, const char *prefix, const char **rest, + size_t *rest_length) +{ + size_t length = strlen(prefix); + + if (parts->path_length < length || + memcmp(parts->path, prefix, length) != 0) { + return DIMS_SIGN_BAD_URL; + } + + *rest = parts->path + length; + *rest_length = parts->path_length - length; + + return DIMS_SIGN_OK; +} + +/* + * The last raw value of one query parameter, undecoded, or NULL. + * + * The name is compared as it appears in the query. The module reads the query + * the same way, so a percent escape in a name does not match here either. + */ +static const char * +raw_value(const char *query, const char *name, size_t *length) +{ + size_t name_length = strlen(name); + const char *found = NULL; + const char *at; + + if (query == NULL) { + return NULL; + } + + at = query; + while (*at != '\0') { + const char *end = strchr(at, '&'); + size_t token_length; + + if (end == NULL) { + end = at + strlen(at); + } + + token_length = (size_t) (end - at); + if (token_length > name_length && at[name_length] == '=' && + memcmp(at, name, name_length) == 0) { + found = at + name_length + 1; + *length = token_length - name_length - 1; + } + + at = (*end == '\0') ? end : end + 1; + } + + return found; +} + +/* The image URL, decoded. The query must hold a url parameter. */ +static dims_sign_status +read_image_url(const char *query, char **out) +{ + size_t length = 0; + const char *value = raw_value(query, "url", &length); + + if (value == NULL) { + return DIMS_SIGN_BAD_URL; + } + + return decode_strict(value, length, out); +} + +/* -- /dims5/ ------------------------------------------------------------ */ + +static dims_sign_status +dims5_fields(const char *url, const char *prefix, url_parts *parts, + char **commands, char **image_url, char **canonical) +{ + dims_sign_status status; + const char *rest; + size_t rest_length; + + *commands = NULL; + *image_url = NULL; + *canonical = NULL; + + if (url == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (prefix == NULL || *prefix == '\0') { + prefix = DIMS_SIGN_DIMS5_PREFIX; + } + + split_url(url, parts); + + status = after_prefix(parts, prefix, &rest, &rest_length); + if (status != DIMS_SIGN_OK) { + return status; + } + + status = decode_strict(rest, rest_length, commands); + if (status != DIMS_SIGN_OK) { + return status; + } + + status = read_image_url(parts->query, image_url); + if (status != DIMS_SIGN_OK) { + free(*commands); + *commands = NULL; + return status; + } + + status = dims_sign_canonical_query(parts->query, canonical); + if (status != DIMS_SIGN_OK) { + free(*commands); + free(*image_url); + *commands = NULL; + *image_url = NULL; + return status; + } + + return DIMS_SIGN_OK; +} + +/* Copies the query with every sig parameter left out. */ +static void +buffer_add_query_without_sig(buffer *b, const char *query) +{ + const char *at = query; + int written = 0; + + while (*at != '\0') { + const char *end = strchr(at, '&'); + size_t length; + const char *equals; + size_t name_length; + + if (end == NULL) { + end = at + strlen(at); + } + + length = (size_t) (end - at); + equals = memchr(at, '=', length); + name_length = (equals != NULL) ? (size_t) (equals - at) : length; + + if (length > 0 && + !(name_length == 3 && memcmp(at, "sig", 3) == 0)) { + if (written) { + buffer_add_char(b, '&'); + } + buffer_add_bytes(b, at, length); + written = 1; + } + + at = (*end == '\0') ? end : end + 1; + } + + if (written) { + buffer_add_char(b, '&'); + } +} + +dims_sign_status +dims_sign_dims5_url(const char *url, const char *key, const char *prefix, + char **out) +{ + dims_sign_status status; + char digest[DIMS_SIGN_DIMS5_LENGTH + 1]; + char *commands; + char *image_url; + char *canonical; + url_parts parts; + buffer signed_url; + + if (out == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (key == NULL || *key == '\0') { + return DIMS_SIGN_BAD_ARGUMENT; + } + + status = dims5_fields(url, prefix, &parts, &commands, &image_url, + &canonical); + if (status != DIMS_SIGN_OK) { + return status; + } + + status = dims_sign_dims5_digest(key, commands, image_url, canonical, + digest); + free(commands); + free(image_url); + free(canonical); + + if (status != DIMS_SIGN_OK) { + return status; + } + + buffer_init(&signed_url); + buffer_add_bytes(&signed_url, url, + (size_t) (parts.path - url) + parts.path_length); + buffer_add_char(&signed_url, '?'); + buffer_add_query_without_sig(&signed_url, parts.query); + buffer_add(&signed_url, "sig="); + buffer_add(&signed_url, digest); + + *out = buffer_take(&signed_url); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +dims_sign_status +dims_sign_dims5_message(const char *url, const char *prefix, char **out) +{ + dims_sign_status status; + char *commands; + char *image_url; + char *canonical; + url_parts parts; + buffer message; + + if (out == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + status = dims5_fields(url, prefix, &parts, &commands, &image_url, + &canonical); + if (status != DIMS_SIGN_OK) { + return status; + } + + buffer_init(&message); + buffer_add(&message, commands); + buffer_add_char(&message, '\n'); + buffer_add(&message, image_url); + buffer_add_char(&message, '\n'); + buffer_add(&message, canonical); + + free(commands); + free(image_url); + free(canonical); + + *out = buffer_take(&message); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +/* -- /dims4/ ------------------------------------------------------------ */ + +/* The four segments after the prefix. */ +typedef struct { + const char *client; + size_t client_length; + const char *signature; + size_t signature_length; + const char *expires; + size_t expires_length; + const char *commands; + size_t commands_length; +} dims4_path; + +static dims_sign_status +split_dims4_path(const char *rest, size_t rest_length, dims4_path *out) +{ + const char *end = rest + rest_length; + const char *first = memchr(rest, '/', rest_length); + const char *second; + const char *third; + size_t i; + + if (first == NULL) { + return DIMS_SIGN_BAD_URL; + } + + second = memchr(first + 1, '/', (size_t) (end - first - 1)); + if (second == NULL) { + return DIMS_SIGN_BAD_URL; + } + + third = memchr(second + 1, '/', (size_t) (end - second - 1)); + if (third == NULL) { + return DIMS_SIGN_BAD_URL; + } + + out->client = rest; + out->client_length = (size_t) (first - rest); + out->signature = first + 1; + out->signature_length = (size_t) (second - first - 1); + out->expires = second + 1; + out->expires_length = (size_t) (third - second - 1); + out->commands = third + 1; + out->commands_length = (size_t) (end - third - 1); + + /* The module reads the expiry with atol, so any other text expires it. */ + if (out->expires_length == 0) { + return DIMS_SIGN_BAD_URL; + } + for (i = 0; i < out->expires_length; i++) { + if (out->expires[i] < '0' || out->expires[i] > '9') { + return DIMS_SIGN_BAD_URL; + } + } + + /* The placeholder sets the length of the signature. */ + if (out->signature_length < DIMS_SIGN_DIMS4_LENGTH || + out->signature_length > DIMS_SIGN_DIMS4_DIGEST) { + return DIMS_SIGN_BAD_URL; + } + + return DIMS_SIGN_OK; +} + +static void +release_keys(dims_sign_param *list, size_t count) +{ + size_t i; + + for (i = 0; i < count; i++) { + free((char *) list[i].name); + free((char *) list[i].value); + } + + free(list); +} + +/* A NUL terminated copy of one byte range. */ +static char * +copy_range(const char *at, size_t length) +{ + char *copy = malloc(length + 1); + + if (copy != NULL) { + memcpy(copy, at, length); + copy[length] = '\0'; + } + + return copy; +} + +/* + * The values _keys names, in _keys order, as they appear in the query. + * + * A name the query leaves out has a NULL value, which the digest skips. + */ +static dims_sign_status +read_keys(const char *query, dims_sign_param **out, size_t *count) +{ + size_t keys_length = 0; + const char *keys = raw_value(query, "_keys", &keys_length); + dims_sign_param *list; + size_t found = 0; + size_t at = 0; + + *out = NULL; + *count = 0; + + if (keys == NULL || keys_length == 0) { + return DIMS_SIGN_OK; + } + + /* One name per byte at most, which every comma reduces. */ + list = calloc(keys_length, sizeof(*list)); + if (list == NULL) { + return DIMS_SIGN_MEMORY; + } + + while (at < keys_length) { + size_t start = at; + size_t length; + const char *value; + size_t value_length = 0; + + while (at < keys_length && keys[at] != ',') { + at++; + } + + length = at - start; + at++; + + if (length == 0) { + continue; + } + + list[found].name = copy_range(keys + start, length); + if (list[found].name == NULL) { + release_keys(list, found); + return DIMS_SIGN_MEMORY; + } + + value = raw_value(query, list[found].name, &value_length); + if (value != NULL) { + list[found].value = copy_range(value, value_length); + if (list[found].value == NULL) { + release_keys(list, found + 1); + return DIMS_SIGN_MEMORY; + } + } + + found++; + } + + *out = list; + *count = found; + + return DIMS_SIGN_OK; +} + +dims_sign_status +dims_sign_dims4_url(const char *url, const char *key, const char *prefix, + char **out) +{ + dims_sign_status status; + char digest[DIMS_SIGN_DIMS4_DIGEST + 1]; + char *commands = NULL; + char *image_url = NULL; + char *expires = NULL; + dims_sign_param *keys = NULL; + size_t key_count = 0; + const char *rest; + size_t rest_length; + url_parts parts; + dims4_path path; + buffer signed_url; + char *at; + + if (out == NULL || url == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (key == NULL || *key == '\0') { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (prefix == NULL || *prefix == '\0') { + prefix = DIMS_SIGN_DIMS4_PREFIX; + } + + split_url(url, &parts); + + status = after_prefix(&parts, prefix, &rest, &rest_length); + if (status != DIMS_SIGN_OK) { + return status; + } + + status = split_dims4_path(rest, rest_length, &path); + if (status != DIMS_SIGN_OK) { + return status; + } + + status = decode_strict(path.commands, path.commands_length, &commands); + if (status != DIMS_SIGN_OK) { + return status; + } + + /* A space travels as %20 and signs as a plus. */ + for (at = commands; *at != '\0'; at++) { + if (*at == ' ') { + *at = '+'; + } + } + + status = read_image_url(parts.query, &image_url); + if (status != DIMS_SIGN_OK) { + free(commands); + return status; + } + + /* The module writes every plus in a /dims4/ image URL as a space after it + * decodes the value. /dims5/ keeps the plus. */ + for (at = image_url; *at != '\0'; at++) { + if (*at == '+') { + *at = ' '; + } + } + + status = read_keys(parts.query, &keys, &key_count); + if (status == DIMS_SIGN_OK) { + expires = malloc(path.expires_length + 1); + if (expires == NULL) { + status = DIMS_SIGN_MEMORY; + } else { + memcpy(expires, path.expires, path.expires_length); + expires[path.expires_length] = '\0'; + } + } + + if (status == DIMS_SIGN_OK) { + status = dims_sign_dims4_digest(key, expires, commands, image_url, + keys, key_count, digest); + } + + free(commands); + free(image_url); + free(expires); + release_keys(keys, key_count); + + if (status != DIMS_SIGN_OK) { + return status; + } + + /* The path is rebuilt from its four segments, so a placeholder equal to + * the client id or to the expiry still works. */ + buffer_init(&signed_url); + buffer_add_bytes(&signed_url, url, (size_t) (path.client - url)); + buffer_add_bytes(&signed_url, path.client, path.client_length); + buffer_add_char(&signed_url, '/'); + buffer_add_bytes(&signed_url, digest, path.signature_length); + buffer_add_char(&signed_url, '/'); + buffer_add_bytes(&signed_url, path.expires, path.expires_length); + buffer_add_char(&signed_url, '/'); + buffer_add_bytes(&signed_url, path.commands, path.commands_length); + + if (parts.query != NULL) { + buffer_add_char(&signed_url, '?'); + buffer_add(&signed_url, parts.query); + } + + *out = buffer_take(&signed_url); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +/* -- The rest ----------------------------------------------------------- */ + +const char * +dims_sign_strerror(dims_sign_status status) +{ + switch (status) { + case DIMS_SIGN_OK: + return "ok"; + case DIMS_SIGN_MEMORY: + return "out of memory"; + case DIMS_SIGN_BAD_ARGUMENT: + return "a required argument is missing"; + case DIMS_SIGN_BAD_URL: + return "cannot read the URL"; + case DIMS_SIGN_BAD_FIELD: + return "a control character is in a signed field"; + case DIMS_SIGN_CRYPTO: + return "libcrypto refused"; + } + + return "unknown"; +} + +void +dims_sign_free(char *string) +{ + free(string); +} From 574db1fb658a6c629eeea1d8161cd629fc1c9a03 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 17:18:43 -0400 Subject: [PATCH 2/8] Add the library cases test/unit/test_sign.c covers the escape, the canonical query, the two digests, the two comparisons, and the two URL signers. Each digest has a known vector. One case per clause the header names under DIMS_SIGN_BAD_URL, and one for DIMS_SIGN_BAD_FIELD. A failed call leaves the out parameter untouched, and dims_sign_free accepts NULL. --- test/CMakeLists.txt | 2 + test/unit/main.c | 2 + test/unit/test_sign.c | 488 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 test/unit/test_sign.c diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index fe6f9d7..d545631 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -109,6 +109,7 @@ add_executable(dims_unit_test unit/test_netguard.c unit/test_overlay_cache.c unit/test_param.c + unit/test_sign.c unit/test_signature.c unit/test_svgguard.c unit/test_url.c @@ -147,6 +148,7 @@ target_include_directories(dims_unit_test PRIVATE ${CMAKE_SOURCE_DIR}/src) target_link_libraries(dims_unit_test PRIVATE + moddims_sign PkgConfig::APR PkgConfig::APRUTIL PkgConfig::CURL diff --git a/test/unit/main.c b/test/unit/main.c index a9f23c6..b410b74 100644 --- a/test/unit/main.c +++ b/test/unit/main.c @@ -25,6 +25,7 @@ extern const dims_test dims_tests_unit_metrics[]; extern const dims_test dims_tests_unit_netguard[]; extern const dims_test dims_tests_unit_overlay_cache[]; extern const dims_test dims_tests_unit_param[]; +extern const dims_test dims_tests_unit_sign[]; extern const dims_test dims_tests_unit_signature[]; extern const dims_test dims_tests_unit_svgguard[]; extern const dims_test dims_tests_unit_url[]; @@ -42,6 +43,7 @@ static const dims_test_group groups[] = { { "unit/test_netguard.c", dims_tests_unit_netguard }, { "unit/test_overlay_cache.c", dims_tests_unit_overlay_cache }, { "unit/test_param.c", dims_tests_unit_param }, + { "unit/test_sign.c", dims_tests_unit_sign }, { "unit/test_signature.c", dims_tests_unit_signature }, { "unit/test_svgguard.c", dims_tests_unit_svgguard }, { "unit/test_url.c", dims_tests_unit_url }, diff --git a/test/unit/test_sign.c b/test/unit/test_sign.c new file mode 100644 index 0000000..0f992ff --- /dev/null +++ b/test/unit/test_sign.c @@ -0,0 +1,488 @@ +/* + * The signing library. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../lib/test.h" + +#include +#include + +#define TEST_KEY "0123456789abcdef0123456789abcdef" +#define TEST_SECRET "t3stk3y" +#define TEST_IMAGE "http%3A%2F%2Forigin%3A8080%2Fgrid.png" + +/* A value no function writes, so an untouched out parameter is visible. */ +static char *const untouched = (char *) (void *) &untouched; + +static void +check_escape(const char *in, const char *want) +{ + char *got = NULL; + + CHECK_INT(dims_sign_escape(in, &got), DIMS_SIGN_OK, "escape"); + CHECK_STR(got, want, "escape"); + dims_sign_free(got); +} + +/* + * Everything outside A-Za-z0-9-_.~ is escaped, a space becomes a plus, and the + * hex is uppercase. Another encoder would produce a different signature for + * the same request. + */ +static void +test_escape(void) +{ + check_escape("abcXYZ019", "abcXYZ019"); + check_escape("-_.~", "-_.~"); + check_escape(" ", "+"); + check_escape("a b", "a+b"); + check_escape("/", "%2F"); + check_escape(":", "%3A"); + check_escape("&", "%26"); + check_escape("=", "%3D"); + check_escape("+", "%2B"); + check_escape("%", "%25"); + check_escape("*", "%2A"); + check_escape("https://example.com/a.jpg", + "https%3A%2F%2Fexample.com%2Fa.jpg"); + check_escape("", ""); + check_escape(NULL, ""); +} + +static void +check_query(const char *in, const char *want) +{ + char *got = NULL; + + CHECK_INT(dims_sign_canonical_query(in, &got), DIMS_SIGN_OK, "query"); + CHECK_STR(got, want, "query"); + dims_sign_free(got); +} + +/* Ordered by the bytes of the name, whatever order the query gave. */ +static void +test_canonical_query_orders_by_name(void) +{ + check_query("b=2&a=1&c=3", "a=1&b=2&c=3"); + check_query("c=3&b=2&a=1", "a=1&b=2&c=3"); + check_query("a=1", "a=1"); + check_query("", ""); + check_query(NULL, ""); + + /* A hyphen is 0x2D and sorts before every letter. */ + check_query("ab=2&a-b=1", "a-b=1&ab=2"); + + /* An upper case letter sorts before a lower case one. */ + check_query("a=1&Z=2", "Z=2&a=1"); +} + +/* sig, url, eurl, _keys, and download take no part. */ +static void +test_canonical_query_drops_the_unsigned(void) +{ + check_query("sig=abc&a=1", "a=1"); + check_query("url=http://x/y.jpg&a=1", "a=1"); + check_query("eurl=AAAA&a=1", "a=1"); + check_query("_keys=overlay&a=1", "a=1"); + check_query("download=1&a=1", "a=1"); + check_query("sig=a&url=b&eurl=c&_keys=d&download=e", ""); +} + +/* + * The name is part of the string, so moving a character from one parameter to + * the next changes the result. The values alone would not: "ab" then "c" reads + * the same as "a" then "bc". + */ +static void +test_canonical_query_covers_the_names(void) +{ + char *first = NULL; + char *second = NULL; + + CHECK_INT(dims_sign_canonical_query("a=ab&b=c", &first), DIMS_SIGN_OK, "first"); + CHECK_INT(dims_sign_canonical_query("a=a&b=bc", &second), DIMS_SIGN_OK, "second"); + + CHECK(strcmp(first, second) != 0, + "a=ab&b=c and a=a&b=bc must differ: [%s] and [%s]", first, second); + + dims_sign_free(first); + dims_sign_free(second); +} + +/* A value holding a separator is encoded, so it cannot pose as two. */ +static void +test_canonical_query_encodes_a_separator(void) +{ + check_query("a=1%26b%3D2", "a=1%26b%3D2"); + check_query("a=x y", "a=x+y"); + check_query("a=x+y", "a=x+y"); +} + +/* A name that appears more than once keeps the order the query gave. */ +static void +test_canonical_query_keeps_repeated_values(void) +{ + check_query("a=2&a=1", "a=2&a=1"); + check_query("b=1&a=2&a=1", "a=2&a=1&b=1"); +} + +/* A parameter with no equals sign has an empty value. */ +static void +test_canonical_query_handles_a_valueless_parameter(void) +{ + check_query("a&b=1", "a=&b=1"); + check_query("a=", "a="); +} + +/* A known vector, so a change to the construction is visible. */ +static void +test_dims5_digest(void) +{ + char digest[DIMS_SIGN_DIMS5_LENGTH + 1]; + + CHECK_INT(dims_sign_dims5_digest(TEST_KEY, "resize/100x100/", + "http://origin:8080/grid.png", "", digest), + DIMS_SIGN_OK, "digest"); + CHECK_STR(digest, + "e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff", + "the /dims5/ digest"); + + CHECK_INT(dims_sign_dims5_digest(TEST_KEY, "resize/100x100/", + "http://origin:8080/grid.png", "tag=b&tag=a", + digest), + DIMS_SIGN_OK, "digest with a query"); + CHECK_STR(digest, + "146f18539b8f82768fe271f202b532961db33f8dcffbe3b8646a7ed0d85354e0", + "the /dims5/ digest with a query"); + + CHECK_INT(dims_sign_dims5_digest(NULL, "a", "b", "", digest), + DIMS_SIGN_BAD_ARGUMENT, "no key"); + CHECK_INT(dims_sign_dims5_digest("", "a", "b", "", digest), + DIMS_SIGN_BAD_ARGUMENT, "an empty key"); +} + +/* One field per line, in a fixed order. */ +static void +test_dims5_message(void) +{ + char *got = NULL; + + CHECK_INT(dims_sign_dims5_message( + "/dims5/resize/100x100/?" "url=" TEST_IMAGE "&tag=b&tag=a", + NULL, &got), + DIMS_SIGN_OK, "the message"); + CHECK_STR(got, + "resize/100x100/\nhttp://origin:8080/grid.png\ntag=b&tag=a", + "the message"); + dims_sign_free(got); +} + +static void +test_dims5_equal(void) +{ + char a[DIMS_SIGN_DIMS5_LENGTH + 1]; + char b[DIMS_SIGN_DIMS5_LENGTH + 1]; + + CHECK_INT(dims_sign_dims5_digest("k", "m", "n", "", a), DIMS_SIGN_OK, "a"); + CHECK_INT(dims_sign_dims5_digest("k", "m", "n", "", b), DIMS_SIGN_OK, "b"); + + CHECK(dims_sign_dims5_equal(a, b), "the same digest"); + + b[DIMS_SIGN_DIMS5_LENGTH - 1] ^= 1; + CHECK(!dims_sign_dims5_equal(a, b), "a digest differing in the last byte"); + + CHECK(!dims_sign_dims5_equal(a, "short"), "a short value"); + CHECK(!dims_sign_dims5_equal(a, NULL), "no value"); + CHECK(!dims_sign_dims5_equal(NULL, NULL), "neither value"); +} + +/* A known vector, and the _keys contract the module follows. */ +static void +test_dims4_digest(void) +{ + char digest[DIMS_SIGN_DIMS4_DIGEST + 1]; + dims_sign_param keys[3]; + + CHECK_INT(dims_sign_dims4_digest(TEST_SECRET, "2147483647", "resize/100x100/", + "http://origin:8080/grid.png", NULL, 0, + digest), + DIMS_SIGN_OK, "digest"); + CHECK_STR(digest, "82ea1ce80bea7fd48834e7b7dbab69e1", "the /dims4/ digest"); + + /* The values are concatenated with no separator. */ + keys[0].name = "a"; + keys[0].value = "a"; + keys[1].name = "b"; + keys[1].value = "b"; + + CHECK_INT(dims_sign_dims4_digest(TEST_SECRET, "2147483647", "resize/100x100/", + "http://origin:8080/grid.png", keys, 2, + digest), + DIMS_SIGN_OK, "digest with keys"); + CHECK_STR(digest, "7804fe161fc78d0f401540e8526850ca", + "the /dims4/ digest with two keyed values"); + + /* A NULL value contributes nothing. */ + keys[0].name = "a"; + keys[0].value = "a"; + keys[1].name = "absent"; + keys[1].value = NULL; + keys[2].name = "b"; + keys[2].value = "b"; + + CHECK_INT(dims_sign_dims4_digest(TEST_SECRET, "2147483647", "resize/100x100/", + "http://origin:8080/grid.png", keys, 3, + digest), + DIMS_SIGN_OK, "digest with an absent key"); + CHECK_STR(digest, "7804fe161fc78d0f401540e8526850ca", + "a NULL value leaves the digest unchanged"); +} + +/* The module compares six characters without regard to case. */ +static void +test_dims4_equal(void) +{ + const char *expected = "82ea1ce80bea7fd48834e7b7dbab69e1"; + + CHECK(dims_sign_dims4_equal(expected, "82ea1c"), "the first six"); + CHECK(dims_sign_dims4_equal(expected, "82EA1C"), "upper case"); + CHECK(dims_sign_dims4_equal(expected, expected), "the whole digest"); + CHECK(!dims_sign_dims4_equal(expected, "82ea1d"), "a different sixth"); + CHECK(!dims_sign_dims4_equal(expected, "82ea1"), "five characters"); + CHECK(!dims_sign_dims4_equal(expected, NULL), "no value"); +} + +/* A line break in a signed field could stand in for two fields. */ +static void +test_field_ok(void) +{ + CHECK(dims_sign_field_ok("resize/100x100/"), "an ordinary field"); + CHECK(dims_sign_field_ok(NULL), "no field"); + CHECK(!dims_sign_field_ok("a\nb"), "a line feed"); + CHECK(!dims_sign_field_ok("a\rb"), "a carriage return"); + CHECK(!dims_sign_field_ok("a\tb"), "a tab"); + CHECK(!dims_sign_field_ok("a\x7f" "b"), "a delete"); +} + +static void +check_dims5_url(const char *in, const char *prefix, const char *want) +{ + char *got = NULL; + + CHECK_INT(dims_sign_dims5_url(in, TEST_KEY, prefix, &got), DIMS_SIGN_OK, + "sign"); + CHECK_STR(got, want, in); + dims_sign_free(got); +} + +/* The query passes through, and sig goes on the end. */ +static void +test_dims5_url(void) +{ + const char *digest = + "e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff"; + + check_dims5_url("/dims5/resize/100x100/?url=" TEST_IMAGE, NULL, + "/dims5/resize/100x100/?url=" TEST_IMAGE "&sig=e9d70afb0b295" + "20bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff"); + + /* An empty prefix means the conventional one. */ + check_dims5_url("/dims5/resize/100x100/?url=" TEST_IMAGE, "", + "/dims5/resize/100x100/?url=" TEST_IMAGE "&sig=e9d70afb0b295" + "20bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff"); + + /* A rewrite in front of the module signs the same commands. */ + check_dims5_url("https://cdn.example.com/img/resize/100x100/?url=" TEST_IMAGE, + "/img/", + "https://cdn.example.com/img/resize/100x100/?url=" TEST_IMAGE + "&sig=e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac" + "8055838bff"); + + /* An input that already holds a sig gets a new one in its place. */ + check_dims5_url("/dims5/resize/100x100/?url=" TEST_IMAGE "&sig=deadbeef", + NULL, + "/dims5/resize/100x100/?url=" TEST_IMAGE "&sig=e9d70afb0b295" + "20bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff"); + + CHECK(strlen(digest) == DIMS_SIGN_DIMS5_LENGTH, "a full length signature"); +} + +/* The path is rebuilt from its four segments. */ +static void +test_dims4_url(void) +{ + char *got = NULL; + + CHECK_INT(dims_sign_dims4_url( + "/dims4/TEST/xxxxxx/2147483647/resize/100x100/?url=" + TEST_IMAGE, TEST_SECRET, NULL, &got), + DIMS_SIGN_OK, "sign"); + CHECK_STR(got, "/dims4/TEST/82ea1c/2147483647/resize/100x100/?url=" + TEST_IMAGE, "the signed path"); + dims_sign_free(got); + + /* A placeholder equal to the client id still works. */ + CHECK_INT(dims_sign_dims4_url( + "/dims4/TEST/2147483647/2147483647/resize/100x100/?url=" + TEST_IMAGE, TEST_SECRET, NULL, &got), + DIMS_SIGN_OK, "a placeholder equal to the expiry"); + CHECK_STR(got, "/dims4/TEST/82ea1ce80b/2147483647/resize/100x100/?url=" + TEST_IMAGE, "ten characters in, ten characters out"); + dims_sign_free(got); + + /* The placeholder sets the length, up to the whole digest. */ + CHECK_INT(dims_sign_dims4_url( + "/dims4/TEST/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/2147483647/" + "resize/100x100/?url=" TEST_IMAGE, TEST_SECRET, NULL, &got), + DIMS_SIGN_OK, "a thirty two character placeholder"); + CHECK_STR(got, "/dims4/TEST/82ea1ce80bea7fd48834e7b7dbab69e1/2147483647/" + "resize/100x100/?url=" TEST_IMAGE, "the whole digest"); + dims_sign_free(got); +} + +static void +check_refused(dims_sign_status want, dims_sign_status got, const char *what) +{ + CHECK_INT(got, want, what); +} + +/* One case for each clause the header names. */ +static void +test_refuses_a_bad_url(void) +{ + char *out = untouched; + + check_refused(DIMS_SIGN_BAD_URL, + dims_sign_dims5_url("/other/resize/1x1/?url=" TEST_IMAGE, + TEST_KEY, NULL, &out), + "a path outside the prefix"); + + check_refused(DIMS_SIGN_BAD_URL, + dims_sign_dims5_url("/dims5/crop/%zz/?url=" TEST_IMAGE, + TEST_KEY, NULL, &out), + "a percent escape that is not two hex digits"); + + check_refused(DIMS_SIGN_BAD_URL, + dims_sign_dims5_url("/dims5/resize/1x1/?tag=a", TEST_KEY, + NULL, &out), + "a query with no url"); + + check_refused(DIMS_SIGN_BAD_URL, + dims_sign_dims5_url("/dims5/resize/1x1/", TEST_KEY, NULL, &out), + "no query at all"); + + check_refused(DIMS_SIGN_BAD_FIELD, + dims_sign_dims5_url("/dims5/crop/%0A/?url=" TEST_IMAGE, + TEST_KEY, NULL, &out), + "a control character in the commands"); + + check_refused(DIMS_SIGN_BAD_URL, + dims_sign_dims4_url("/dims4/TEST/xxxxxx/2147483647?url=" + TEST_IMAGE, TEST_SECRET, NULL, &out), + "three segments after the prefix"); + + check_refused(DIMS_SIGN_BAD_URL, + dims_sign_dims4_url("/dims4/TEST/xxxxxx/soon/resize/1x1/?url=" + TEST_IMAGE, TEST_SECRET, NULL, &out), + "an expiry that is not decimal digits"); + + check_refused(DIMS_SIGN_BAD_URL, + dims_sign_dims4_url("/dims4/TEST/xxx/2147483647/resize/1x1/" + "?url=" TEST_IMAGE, TEST_SECRET, NULL, + &out), + "a placeholder under six characters"); + + check_refused(DIMS_SIGN_BAD_URL, + dims_sign_dims4_url("/dims4/TEST/xxxxxx/2147483647/resize/1x1/" + "?tag=a", TEST_SECRET, NULL, &out), + "a /dims4/ query with no url"); + + check_refused(DIMS_SIGN_BAD_ARGUMENT, + dims_sign_dims5_url("/dims5/resize/1x1/?url=" TEST_IMAGE, "", + NULL, &out), + "an empty key"); + + check_refused(DIMS_SIGN_BAD_ARGUMENT, + dims_sign_dims4_url("/dims4/TEST/xxxxxx/2147483647/resize/1x1/" + "?url=" TEST_IMAGE, NULL, NULL, &out), + "no secret"); + + CHECK(out == untouched, "a failure leaves the out parameter untouched"); +} + +/* dims_sign_free releases what the library wrote, and accepts NULL. */ +static void +test_allocation_contract(void) +{ + char *out = NULL; + + dims_sign_free(NULL); + + CHECK_INT(dims_sign_escape("", &out), DIMS_SIGN_OK, "an empty value"); + CHECK(out != NULL, "an empty result is still a string"); + CHECK_STR(out, "", "an empty result"); + dims_sign_free(out); + + out = NULL; + CHECK_INT(dims_sign_canonical_query("", &out), DIMS_SIGN_OK, "an empty query"); + CHECK(out != NULL, "an empty canonical query is still a string"); + dims_sign_free(out); + + CHECK_INT(dims_sign_escape("a", NULL), DIMS_SIGN_BAD_ARGUMENT, "no out"); + CHECK_INT(dims_sign_canonical_query("a=1", NULL), DIMS_SIGN_BAD_ARGUMENT, + "no out"); + CHECK_INT(dims_sign_dims5_url("/dims5/a/?url=" TEST_IMAGE, TEST_KEY, NULL, + NULL), + DIMS_SIGN_BAD_ARGUMENT, "no out"); +} + +/* Every status has a description, so an error message names the failure. */ +static void +test_strerror(void) +{ + const dims_sign_status all[] = { + DIMS_SIGN_OK, DIMS_SIGN_MEMORY, DIMS_SIGN_BAD_ARGUMENT, + DIMS_SIGN_BAD_URL, DIMS_SIGN_BAD_FIELD, DIMS_SIGN_CRYPTO + }; + size_t i; + + for (i = 0; i < sizeof(all) / sizeof(all[0]); i++) { + const char *text = dims_sign_strerror(all[i]); + + CHECK(text != NULL && *text != '\0' && strcmp(text, "unknown") != 0, + "status %d has a description", (int) all[i]); + } +} + +const dims_test dims_tests_unit_sign[] = { + { "TestSignEscape", test_escape, NULL }, + { "TestSignCanonicalQueryOrdersByName", + test_canonical_query_orders_by_name, NULL }, + { "TestSignCanonicalQueryDropsTheUnsigned", + test_canonical_query_drops_the_unsigned, NULL }, + { "TestSignCanonicalQueryCoversTheNames", + test_canonical_query_covers_the_names, NULL }, + { "TestSignCanonicalQueryEncodesASeparator", + test_canonical_query_encodes_a_separator, NULL }, + { "TestSignCanonicalQueryKeepsRepeatedValues", + test_canonical_query_keeps_repeated_values, NULL }, + { "TestSignCanonicalQueryHandlesAValuelessParameter", + test_canonical_query_handles_a_valueless_parameter, NULL }, + { "TestSignDims5Digest", test_dims5_digest, NULL }, + { "TestSignDims5Message", test_dims5_message, NULL }, + { "TestSignDims5Equal", test_dims5_equal, NULL }, + { "TestSignDims4Digest", test_dims4_digest, NULL }, + { "TestSignDims4Equal", test_dims4_equal, NULL }, + { "TestSignFieldOk", test_field_ok, NULL }, + { "TestSignDims5Url", test_dims5_url, NULL }, + { "TestSignDims4Url", test_dims4_url, NULL }, + { "TestSignRefusesABadUrl", test_refuses_a_bad_url, NULL }, + { "TestSignAllocationContract", test_allocation_contract, NULL }, + { "TestSignStrerror", test_strerror, NULL }, + DIMS_TEST_END +}; From 49e306eb31012e7d511b692a2ba9fc7d75b438d5 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 17:18:43 -0400 Subject: [PATCH 3/8] Build the module on the signing library src/signature.c is three wrappers over the library: dims_signature, dims_signature_equal, and dims_signature_field_ok. src/handler.c calls dims_sign_dims4_digest and dims_sign_dims4_equal. The module keeps its own prefix handling. It reads the commands out of r->uri and passes them to dims_signature. test/unit/test_signature.c is removed. test/unit/test_sign.c covers the same rules against the library. --- CMakeLists.txt | 1 + src/dims5.c | 4 +- src/handler.c | 31 +++-- src/signature.c | 241 +++---------------------------------- src/signature.h | 39 ++---- test/CMakeLists.txt | 1 - test/unit/main.c | 2 - test/unit/test_signature.c | 197 ------------------------------ 8 files changed, 43 insertions(+), 473 deletions(-) delete mode 100644 test/unit/test_signature.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 35979a4..ccfc6f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -129,6 +129,7 @@ target_include_directories(mod_dims PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(mod_dims PRIVATE + moddims_sign PkgConfig::APR PkgConfig::APRUTIL PkgConfig::CURL diff --git a/src/dims5.c b/src/dims5.c index 4ef57d1..6577787 100644 --- a/src/dims5.c +++ b/src/dims5.c @@ -121,9 +121,7 @@ dims5_verify(dims_request_rec *d) return DIMS_BAD_URL; } - expected = dims_signature_compute(d->pool, key, - dims_signature_message(d->pool, commands, image_url, - dims_signed_query(d->pool, d->r->args))); + expected = dims_signature(d->pool, key, commands, image_url, d->r->args); if (expected == NULL || !dims_signature_equal(expected, signature)) { dims_metrics_signature(DIMS_ENDPOINT_DIMS5, DIMS_SIG_MISMATCH); diff --git a/src/handler.c b/src/handler.c index a6ad5dd..9396aa7 100644 --- a/src/handler.c +++ b/src/handler.c @@ -17,9 +17,9 @@ #include "status.h" #include "pipeline.h" +#include #include #include -#include apr_status_t @@ -61,7 +61,7 @@ dims_handle_request(dims_request_rec *d) char *hash; char *expires_str; long expires; - char *gen_hash; + char gen_hash[DIMS_SIGN_DIMS4_DIGEST + 1]; long now; hash = ap_getword(d->pool, (const char**)&d->unparsed_commands,'/'); expires_str = ap_getword(d->pool, (const char**)&d->unparsed_commands,'/'); @@ -115,8 +115,8 @@ dims_handle_request(dims_request_rec *d) s++; } - /* Check the key before building the input. apr_pstrcat stops at its - * first NULL argument, which would hash the expiry alone. */ + /* A client with no secret cannot be checked. It gets a status of its + * own, so the log names the configuration and not the signature. */ if (d->client_config->secret_key == NULL) { ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, d->r, "Developer key not set for client '%s'", d->client_config->id); @@ -124,28 +124,33 @@ dims_handle_request(dims_request_rec *d) return dims_cleanup(d, "Missing Developer Key", DIMS_BAD_CLIENT); } - // Standard signature params. - char *signature_params = apr_pstrcat(d->pool, expires_str, d->client_config->secret_key, commands, d->image_url, NULL); - - /* Concatenate the additional params _keys names. _keys is optional. */ + /* The values _keys names, in _keys order, as they appear in the query. + * _keys is optional, and a name the query leaves out has no value. */ char *keys = apr_hash_get(params, "_keys", APR_HASH_KEY_STRING); + apr_array_header_t *keyed = apr_array_make(d->pool, 4, + sizeof(dims_sign_param)); if (keys != NULL) { char *strtokstate = NULL; char *token = apr_strtok(keys, ",", &strtokstate); while (token) { - const char *value = apr_hash_get(params, token, APR_HASH_KEY_STRING); + dims_sign_param *entry = apr_array_push(keyed); - signature_params = apr_pstrcat(d->pool, signature_params, value, NULL); + entry->name = token; + entry->value = apr_hash_get(params, token, APR_HASH_KEY_STRING); token = apr_strtok(NULL, ",", &strtokstate); } } - // Hash. - gen_hash = ap_md5(d->pool, (unsigned char *) signature_params); + if (dims_sign_dims4_digest(d->client_config->secret_key, expires_str, + commands, d->image_url, (const dims_sign_param *) keyed->elts, + (size_t) keyed->nelts, gen_hash) != DIMS_SIGN_OK) { + dims_metrics_signature(d->endpoint, DIMS_SIG_MISMATCH); + return dims_cleanup(d, "Key mismatch", DIMS_BAD_URL); + } - if (strncasecmp(hash, gen_hash, 6) != 0) { + if (!dims_sign_dims4_equal(gen_hash, hash)) { gen_hash[7] = '\0'; ap_log_rerror(APLOG_MARK, APLOG_DEBUG,0, d->r, "Key Mismatch: wanted %6s got %6s [%s?url=%s]", gen_hash, hash, d->r->uri, d->image_url); diff --git a/src/signature.c b/src/signature.c index 8c4b4f3..652600c 100644 --- a/src/signature.c +++ b/src/signature.c @@ -1,5 +1,5 @@ /* - * The /dims5/ signature. + * The /dims5/ signature, over an APR pool. * * Copyright 2026 Jeremy Collins * SPDX-License-Identifier: Apache-2.0 @@ -7,248 +7,35 @@ #include "signature.h" -#include -#include -#include -#include -#include - -/* The parameters a signature never covers. */ -static const char *const dims_unsigned_params[] = { - "sig", "url", "eurl", "_keys", "download", NULL -}; - -static int -is_unreserved(unsigned char c) -{ - return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || - c == '-' || c == '_' || c == '.' || c == '~'; -} - -char * -dims_query_escape(apr_pool_t *pool, const char *value) -{ - static const char hex[] = "0123456789ABCDEF"; - const unsigned char *in; - char *out; - char *at; - - if (value == NULL) { - return apr_pstrdup(pool, ""); - } - - /* Three bytes per input byte at most, and a terminator. */ - out = apr_palloc(pool, strlen(value) * 3 + 1); - at = out; - - for (in = (const unsigned char *) value; *in != '\0'; in++) { - if (is_unreserved(*in)) { - *at++ = (char) *in; - } else if (*in == ' ') { - *at++ = '+'; - } else { - *at++ = '%'; - *at++ = hex[*in >> 4]; - *at++ = hex[*in & 0x0F]; - } - } - - *at = '\0'; - - return out; -} - -/* One name and one value, already decoded. */ -typedef struct { - const char *name; - const char *value; - int order; -} dims_param; - -static int -by_name_then_order(const void *a, const void *b) -{ - const dims_param *left = a; - const dims_param *right = b; - int cmp = strcmp(left->name, right->name); - - /* A parameter with several values keeps the order the query gave. */ - return (cmp != 0) ? cmp : (left->order - right->order); -} - -static int -is_unsigned_param(const char *name) -{ - int i; - - for (i = 0; dims_unsigned_params[i] != NULL; i++) { - if (strcmp(name, dims_unsigned_params[i]) == 0) { - return 1; - } - } - - return 0; -} - -/* Decodes one percent encoded query component in place. */ -static void -query_unescape(char *value) -{ - char *read = value; - char *write = value; - - while (*read != '\0') { - if (*read == '+') { - *write++ = ' '; - read++; - } else if (read[0] == '%' && apr_isxdigit(read[1]) && - apr_isxdigit(read[2])) { - char pair[3] = { read[1], read[2], '\0' }; - - *write++ = (char) strtol(pair, NULL, 16); - read += 3; - } else { - *write++ = *read++; - } - } - - *write = '\0'; -} - -char * -dims_signed_query(apr_pool_t *pool, const char *query) -{ - apr_array_header_t *params; - char *copy; - char *token; - char *state = NULL; - char *out; - int i; - - params = apr_array_make(pool, 8, sizeof(dims_param)); - - if (query == NULL || *query == '\0') { - return apr_pstrdup(pool, ""); - } - - copy = apr_pstrdup(pool, query); - - for (token = apr_strtok(copy, "&", &state); token != NULL; - token = apr_strtok(NULL, "&", &state)) { - char *equals = strchr(token, '='); - dims_param *param; - char *name; - char *value; - - if (*token == '\0') { - continue; - } - - if (equals != NULL) { - *equals = '\0'; - value = apr_pstrdup(pool, equals + 1); - } else { - value = apr_pstrdup(pool, ""); - } - - name = apr_pstrdup(pool, token); - query_unescape(name); - query_unescape(value); - - if (is_unsigned_param(name)) { - continue; - } - - param = (dims_param *) apr_array_push(params); - param->name = name; - param->value = value; - param->order = params->nelts; - } - - qsort(params->elts, (size_t) params->nelts, sizeof(dims_param), - by_name_then_order); - - out = apr_pstrdup(pool, ""); - for (i = 0; i < params->nelts; i++) { - const dims_param *param = &((const dims_param *) params->elts)[i]; - - out = apr_pstrcat(pool, out, (i > 0) ? "&" : "", - dims_query_escape(pool, param->name), "=", - dims_query_escape(pool, param->value), NULL); - } - - return out; -} +#include char * -dims_signature_message(apr_pool_t *pool, const char *commands, - const char *image_url, const char *signed_query) +dims_signature(apr_pool_t *pool, const char *key, const char *commands, + const char *image_url, const char *query) { - return apr_pstrcat(pool, - commands ? commands : "", "\n", - image_url ? image_url : "", "\n", - signed_query ? signed_query : "", NULL); -} + char digest[DIMS_SIGN_DIMS5_LENGTH + 1]; + char *canonical; + dims_sign_status status; -char * -dims_signature_compute(apr_pool_t *pool, const char *key, const char *message) -{ - static const char hex[] = "0123456789abcdef"; - unsigned char digest[EVP_MAX_MD_SIZE]; - unsigned int length = 0; - char *out; - unsigned int i; - - if (key == NULL || message == NULL) { + if (dims_sign_canonical_query(query, &canonical) != DIMS_SIGN_OK) { return NULL; } - if (HMAC(EVP_sha256(), key, (int) strlen(key), - (const unsigned char *) message, strlen(message), - digest, &length) == NULL) { - return NULL; - } - - out = apr_palloc(pool, (apr_size_t) length * 2 + 1); - for (i = 0; i < length; i++) { - out[i * 2] = hex[digest[i] >> 4]; - out[i * 2 + 1] = hex[digest[i] & 0x0F]; - } - out[length * 2] = '\0'; + status = dims_sign_dims5_digest(key, commands, image_url, canonical, + digest); + dims_sign_free(canonical); - return out; + return (status == DIMS_SIGN_OK) ? apr_pstrdup(pool, digest) : NULL; } int dims_signature_equal(const char *a, const char *b) { - if (a == NULL || b == NULL) { - return 0; - } - - if (strlen(a) != DIMS_SIGNATURE_LENGTH || - strlen(b) != DIMS_SIGNATURE_LENGTH) { - return 0; - } - - return CRYPTO_memcmp(a, b, DIMS_SIGNATURE_LENGTH) == 0; + return dims_sign_dims5_equal(a, b); } int dims_signature_field_ok(const char *field) { - const unsigned char *at; - - if (field == NULL) { - return 1; - } - - for (at = (const unsigned char *) field; *at != '\0'; at++) { - if (*at < 0x20 || *at == 0x7F) { - return 0; - } - } - - return 1; + return dims_sign_field_ok(field); } diff --git a/src/signature.h b/src/signature.h index 85fa929..fb3df42 100644 --- a/src/signature.h +++ b/src/signature.h @@ -1,5 +1,7 @@ /* - * The /dims5/ signature. + * The /dims5/ signature, over an APR pool. + * + * The rules are in sign/, so the module and the clients state them once. * * Copyright 2026 Jeremy Collins * SPDX-License-Identifier: Apache-2.0 @@ -10,38 +12,15 @@ #include "mod_dims.h" -/* An HMAC-SHA256 digest, hex encoded. */ -#define DIMS_SIGNATURE_LENGTH 64 - /* - * Percent encodes one query component. + * The signature for one request, hex encoded, from the pool. NULL when the + * library refuses the input. * - * Everything outside A-Za-z0-9-_.~ is escaped as %XX with uppercase hex, and - * a space becomes a plus. + * commands and image_url are already decoded. query is the raw query string, + * and this builds the canonical form of it. */ -char *dims_query_escape(apr_pool_t *pool, const char *value); - -/* - * The canonical form of every query parameter the signature covers. - * - * Each parameter is written name=value, percent encoded, and the whole is - * ordered by name. A parameter with several values contributes each of them, - * in the order the query gives. - * - * sig, url, eurl, _keys, and download take no part and are left out. - */ -char *dims_signed_query(apr_pool_t *pool, const char *query); - -/* - * The message a signature covers: the commands, the image URL, and the - * canonical query, one per line. - */ -char *dims_signature_message(apr_pool_t *pool, const char *commands, - const char *image_url, const char *signed_query); - -/* HMAC-SHA256 of message under key, hex encoded and lowercase. */ -char *dims_signature_compute(apr_pool_t *pool, const char *key, - const char *message); +char *dims_signature(apr_pool_t *pool, const char *key, const char *commands, + const char *image_url, const char *query); /* * Whether two signatures match, comparing every byte whatever the answer. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d545631..30ecb1f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -110,7 +110,6 @@ add_executable(dims_unit_test unit/test_overlay_cache.c unit/test_param.c unit/test_sign.c - unit/test_signature.c unit/test_svgguard.c unit/test_url.c unit/test_thumbnail.c diff --git a/test/unit/main.c b/test/unit/main.c index b410b74..4a34247 100644 --- a/test/unit/main.c +++ b/test/unit/main.c @@ -26,7 +26,6 @@ extern const dims_test dims_tests_unit_netguard[]; extern const dims_test dims_tests_unit_overlay_cache[]; extern const dims_test dims_tests_unit_param[]; extern const dims_test dims_tests_unit_sign[]; -extern const dims_test dims_tests_unit_signature[]; extern const dims_test dims_tests_unit_svgguard[]; extern const dims_test dims_tests_unit_url[]; @@ -44,7 +43,6 @@ static const dims_test_group groups[] = { { "unit/test_overlay_cache.c", dims_tests_unit_overlay_cache }, { "unit/test_param.c", dims_tests_unit_param }, { "unit/test_sign.c", dims_tests_unit_sign }, - { "unit/test_signature.c", dims_tests_unit_signature }, { "unit/test_svgguard.c", dims_tests_unit_svgguard }, { "unit/test_url.c", dims_tests_unit_url }, { NULL, NULL } diff --git a/test/unit/test_signature.c b/test/unit/test_signature.c deleted file mode 100644 index 368585d..0000000 --- a/test/unit/test_signature.c +++ /dev/null @@ -1,197 +0,0 @@ -/* - * The /dims5/ signature. - * - * Copyright 2026 Jeremy Collins - * SPDX-License-Identifier: Apache-2.0 - */ - -#include "signature.h" -#include "../lib/test.h" - -#include -#include -#include - -static apr_pool_t * -sig_pool(void) -{ - static apr_pool_t *pool; - - if (pool == NULL && apr_pool_create(&pool, NULL) != APR_SUCCESS) { - FAIL("cannot create a pool"); - } - - return pool; -} - -static void -check_escape(const char *in, const char *want) -{ - const char *got = dims_query_escape(sig_pool(), in); - - CHECK(strcmp(got, want) == 0, "escape(%s): want %s, got %s", in, want, got); -} - -/* - * Everything outside A-Za-z0-9-_.~ is escaped, a space becomes a plus, and the - * hex is uppercase. Another encoder would produce a different signature for - * the same request. - */ -static void -test_query_escape(void) -{ - check_escape("abcXYZ019", "abcXYZ019"); - check_escape("-_.~", "-_.~"); - check_escape(" ", "+"); - check_escape("a b", "a+b"); - check_escape("/", "%2F"); - check_escape(":", "%3A"); - check_escape("&", "%26"); - check_escape("=", "%3D"); - check_escape("+", "%2B"); - check_escape("%", "%25"); - check_escape("https://example.com/a.jpg", - "https%3A%2F%2Fexample.com%2Fa.jpg"); - check_escape("", ""); - check_escape(NULL, ""); -} - -static void -check_query(const char *in, const char *want) -{ - const char *got = dims_signed_query(sig_pool(), in); - - CHECK(strcmp(got, want) == 0, "query(%s): want [%s], got [%s]", in, want, got); -} - -/* Ordered by name, whatever order the query gave. */ -static void -test_signed_query_orders_by_name(void) -{ - check_query("b=2&a=1&c=3", "a=1&b=2&c=3"); - check_query("c=3&b=2&a=1", "a=1&b=2&c=3"); - check_query("a=1", "a=1"); - check_query("", ""); - check_query(NULL, ""); -} - -/* sig, url, eurl, _keys, and download take no part. */ -static void -test_signed_query_drops_the_unsigned(void) -{ - check_query("sig=abc&a=1", "a=1"); - check_query("url=http://x/y.jpg&a=1", "a=1"); - check_query("eurl=AAAA&a=1", "a=1"); - check_query("_keys=overlay&a=1", "a=1"); - check_query("download=1&a=1", "a=1"); - check_query("sig=a&url=b&eurl=c&_keys=d&download=e", ""); -} - -/* - * The name is part of the string, so moving a character from one parameter to - * the next changes the result. The values alone would not: "ab" then "c" reads - * the same as "a" then "bc". - */ -static void -test_signed_query_covers_the_names(void) -{ - const char *first = dims_signed_query(sig_pool(), "a=ab&b=c"); - const char *second = dims_signed_query(sig_pool(), "a=a&b=bc"); - - CHECK(strcmp(first, second) != 0, - "a=ab&b=c and a=a&b=bc must differ: [%s] and [%s]", first, second); -} - -/* A value holding a separator is encoded, so it cannot pose as two. */ -static void -test_signed_query_encodes_a_separator(void) -{ - check_query("a=1%26b%3D2", "a=1%26b%3D2"); - check_query("a=x y", "a=x+y"); -} - -/* A parameter with several values keeps the order the query gave. */ -static void -test_signed_query_keeps_repeated_values(void) -{ - check_query("a=2&a=1", "a=2&a=1"); - check_query("b=1&a=2&a=1", "a=2&a=1&b=1"); -} - -/* A parameter with no value contributes an empty one. */ -static void -test_signed_query_handles_a_valueless_parameter(void) -{ - check_query("a&b=1", "a=&b=1"); - check_query("a=", "a="); -} - -/* One field per line, in a fixed order. */ -static void -test_message(void) -{ - const char *got = dims_signature_message(sig_pool(), "resize/100x100/", - "https://example.com/a.jpg", "a=1"); - - CHECK(strcmp(got, "resize/100x100/\nhttps://example.com/a.jpg\na=1") == 0, - "the message: [%s]", got); -} - -/* A known vector, so a change to the construction is visible. */ -static void -test_compute(void) -{ - const char *got = dims_signature_compute(sig_pool(), "secret", "message"); - - CHECK(got != NULL && strlen(got) == DIMS_SIGNATURE_LENGTH, - "a full length digest, got %s", got ? got : "(null)"); - CHECK(got != NULL && - strcmp(got, "8b5f48702995c1598c573db1e21866a9b825d4a794d169d7060a03605796360b") == 0, - "hmac-sha256(secret, message): %s", got ? got : "(null)"); -} - -static void -test_equal(void) -{ - const char *a = dims_signature_compute(sig_pool(), "k", "m"); - const char *b = dims_signature_compute(sig_pool(), "k", "m"); - char *wrong = apr_pstrdup(sig_pool(), a); - - CHECK(dims_signature_equal(a, b), "the same digest"); - - wrong[DIMS_SIGNATURE_LENGTH - 1] ^= 1; - CHECK(!dims_signature_equal(a, wrong), "a digest differing in the last byte"); - - CHECK(!dims_signature_equal(a, "short"), "a short value"); - CHECK(!dims_signature_equal(a, NULL), "no value"); - CHECK(!dims_signature_equal(NULL, NULL), "neither value"); -} - -/* A line break in a signed field could stand in for two fields. */ -static void -test_field_ok(void) -{ - CHECK(dims_signature_field_ok("resize/100x100/"), "an ordinary field"); - CHECK(dims_signature_field_ok(NULL), "no field"); - CHECK(!dims_signature_field_ok("a\nb"), "a line feed"); - CHECK(!dims_signature_field_ok("a\rb"), "a carriage return"); - CHECK(!dims_signature_field_ok("a\tb"), "a tab"); - CHECK(!dims_signature_field_ok("a\x7f" "b"), "a delete"); -} - -const dims_test dims_tests_unit_signature[] = { - { "TestQueryEscape", test_query_escape, NULL }, - { "TestSignedQueryOrdersByName", test_signed_query_orders_by_name, NULL }, - { "TestSignedQueryDropsTheUnsigned", test_signed_query_drops_the_unsigned, NULL }, - { "TestSignedQueryCoversTheNames", test_signed_query_covers_the_names, NULL }, - { "TestSignedQueryEncodesASeparator", test_signed_query_encodes_a_separator, NULL }, - { "TestSignedQueryKeepsRepeatedValues", - test_signed_query_keeps_repeated_values, NULL }, - { "TestSignedQueryHandlesAValuelessParameter", - test_signed_query_handles_a_valueless_parameter, NULL }, - { "TestMessage", test_message, NULL }, - { "TestCompute", test_compute, NULL }, - { "TestEqual", test_equal, NULL }, - { "TestFieldOk", test_field_ok, NULL }, - DIMS_TEST_END -}; From 18c35385b8d52c178005629a3cb99373ea8cb656 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 17:18:43 -0400 Subject: [PATCH 4/8] Add the dims-sign command dims-sign signs a URL, prints the /dims5/ message behind one, and compares the signature a URL holds against the one the key produces. The module logs "Key mismatch" without the digests. This prints both. The key comes from --key-file or from DIMS_SIGNING_KEY. A key given on the command line is visible to every user of the machine through ps, so there is no --key flag. --message requires --dims5, because a /dims4/ message contains the client secret. sign/src/dims_sign_internal.h declares dims_sign_dims4_message for the command and stays out of the install. --fixture fills in the computed fields of the signing fixtures. --- sign/CMakeLists.txt | 13 + sign/src/dims_sign_cli.c | 684 ++++++++++++++++++++++++++++++++++ sign/src/dims_sign_internal.h | 27 ++ sign/src/sign.c | 195 +++++++--- 4 files changed, 859 insertions(+), 60 deletions(-) create mode 100644 sign/src/dims_sign_cli.c create mode 100644 sign/src/dims_sign_internal.h diff --git a/sign/CMakeLists.txt b/sign/CMakeLists.txt index 3d27a48..7a6718b 100644 --- a/sign/CMakeLists.txt +++ b/sign/CMakeLists.txt @@ -36,3 +36,16 @@ foreach(target moddims_sign moddims_sign_shared) dims_set_hardening(${target}) dims_set_sanitizers(${target}) endforeach() + +# The command. It signs a URL, prints the message behind one, and compares the +# signature a URL holds against the one the key produces. The module logs +# "Key mismatch" without the digests. This prints both. + +add_executable(dims-sign src/dims_sign_cli.c) + +target_include_directories(dims-sign PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") +target_link_libraries(dims-sign PRIVATE moddims_sign) + +dims_set_warnings(dims-sign) +dims_set_hardening(dims-sign) +dims_set_sanitizers(dims-sign) diff --git a/sign/src/dims_sign_cli.c b/sign/src/dims_sign_cli.c new file mode 100644 index 0000000..0de87fa --- /dev/null +++ b/sign/src/dims_sign_cli.c @@ -0,0 +1,684 @@ +/* + * dims-sign, a command that signs and checks mod_dims URLs. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "dims_sign.h" +#include "dims_sign_internal.h" + +#include +#include +#include + +/* 0 a signature, 1 a mismatch, 2 a usage error, 3 a URL the command cannot + * read. A script reads the number and not the text. */ +#define EXIT_OK 0 +#define EXIT_MISMATCH 1 +#define EXIT_USAGE 2 +#define EXIT_BAD_URL 3 + +typedef enum { + ENDPOINT_NONE = 0, + ENDPOINT_DIMS4, + ENDPOINT_DIMS5 +} endpoint; + +typedef enum { + MODE_SIGN = 0, + MODE_MESSAGE, + MODE_VERIFY, + MODE_FIXTURE +} mode; + +static void +usage(FILE *to) +{ + fputs( + "usage: dims-sign (--dims4 | --dims5) [--key-file FILE] [--prefix P]\n" + " [--message | --verify] URL\n" + " dims-sign --fixture < FILE\n" + "\n" + " --dims4, --dims5 which endpoint signs the URL. The path does not\n" + " say, so the caller names it.\n" + " --key-file FILE read the key from FILE, or from standard input\n" + " when FILE is -. Without this the key comes from\n" + " DIMS_SIGNING_KEY.\n" + " --prefix P what comes before the commands in the path.\n" + " The default is /dims4/ or /dims5/.\n" + " --message print the message the signer hashes. /dims5/\n" + " only: a /dims4/ message holds the secret.\n" + " --verify compare the signature the URL holds against the\n" + " one the key produces.\n" + " --fixture fill in the computed fields of the signing\n" + " fixtures on standard input.\n", + to); +} + +/* -- Reading ------------------------------------------------------------ */ + +/* One line without its newline, or NULL at the end of the stream. */ +static char * +read_line(FILE *from) +{ + size_t capacity = 128; + size_t length = 0; + char *line = malloc(capacity); + int c; + + if (line == NULL) { + return NULL; + } + + c = fgetc(from); + if (c == EOF) { + free(line); + return NULL; + } + + while (c != EOF && c != '\n') { + if (length + 1 >= capacity) { + char *grown = realloc(line, capacity * 2); + + if (grown == NULL) { + free(line); + return NULL; + } + + line = grown; + capacity *= 2; + } + + line[length++] = (char) c; + c = fgetc(from); + } + + line[length] = '\0'; + + return line; +} + +/* The key, with the trailing white space of a text file removed. */ +static char * +read_key_file(const char *path) +{ + FILE *from = (strcmp(path, "-") == 0) ? stdin : fopen(path, "rb"); + size_t capacity = 256; + size_t length = 0; + char *key; + size_t read; + + if (from == NULL) { + fprintf(stderr, "dims-sign: cannot open %s\n", path); + return NULL; + } + + key = malloc(capacity); + if (key == NULL) { + return NULL; + } + + while ((read = fread(key + length, 1, capacity - length - 1, from)) > 0) { + char *grown; + + length += read; + if (length + 1 < capacity) { + break; + } + + grown = realloc(key, capacity * 2); + if (grown == NULL) { + free(key); + return NULL; + } + + key = grown; + capacity *= 2; + } + + if (from != stdin) { + fclose(from); + } + + while (length > 0 && (key[length - 1] == '\n' || key[length - 1] == '\r' || + key[length - 1] == ' ' || key[length - 1] == '\t')) { + length--; + } + + key[length] = '\0'; + + return key; +} + +/* -- The signature a URL already holds ---------------------------------- */ + +/* The last sig parameter of a /dims5/ URL, or NULL. */ +static char * +dims5_signature(const char *url) +{ + const char *query = strchr(url, '?'); + const char *found = NULL; + size_t length = 0; + const char *at; + char *copy; + + if (query == NULL) { + return NULL; + } + + at = query + 1; + while (*at != '\0') { + const char *end = strchr(at, '&'); + size_t token_length; + + if (end == NULL) { + end = at + strlen(at); + } + + token_length = (size_t) (end - at); + if (token_length > 4 && memcmp(at, "sig=", 4) == 0) { + found = at + 4; + length = token_length - 4; + } + + at = (*end == '\0') ? end : end + 1; + } + + if (found == NULL) { + return NULL; + } + + copy = malloc(length + 1); + if (copy != NULL) { + memcpy(copy, found, length); + copy[length] = '\0'; + } + + return copy; +} + +/* The signature segment of a /dims4/ URL, which follows the client id. */ +static char * +dims4_signature(const char *url, const char *prefix) +{ + const char *at = strstr(url, prefix); + const char *end; + char *copy; + + if (at == NULL) { + return NULL; + } + + at += strlen(prefix); + at = strchr(at, '/'); + if (at == NULL) { + return NULL; + } + + at++; + end = strchr(at, '/'); + if (end == NULL) { + return NULL; + } + + copy = malloc((size_t) (end - at) + 1); + if (copy != NULL) { + memcpy(copy, at, (size_t) (end - at)); + copy[end - at] = '\0'; + } + + return copy; +} + +/* -- The modes ---------------------------------------------------------- */ + +static int +report(dims_sign_status status) +{ + fprintf(stderr, "dims-sign: %s\n", dims_sign_strerror(status)); + + return (status == DIMS_SIGN_BAD_ARGUMENT) ? EXIT_USAGE : EXIT_BAD_URL; +} + +static int +run_sign(endpoint which, const char *url, const char *key, const char *prefix) +{ + char *out = NULL; + dims_sign_status status = (which == ENDPOINT_DIMS5) + ? dims_sign_dims5_url(url, key, prefix, &out) + : dims_sign_dims4_url(url, key, prefix, &out); + + if (status != DIMS_SIGN_OK) { + return report(status); + } + + puts(out); + dims_sign_free(out); + + return EXIT_OK; +} + +static int +run_message(const char *url, const char *prefix) +{ + char *out = NULL; + dims_sign_status status = dims_sign_dims5_message(url, prefix, &out); + + if (status != DIMS_SIGN_OK) { + return report(status); + } + + puts(out); + dims_sign_free(out); + + return EXIT_OK; +} + +static int +run_verify(endpoint which, const char *url, const char *key, const char *prefix) +{ + char *signed_url = NULL; + char *wanted; + char *got; + int matched; + dims_sign_status status; + + if (prefix == NULL || *prefix == '\0') { + prefix = (which == ENDPOINT_DIMS5) ? DIMS_SIGN_DIMS5_PREFIX + : DIMS_SIGN_DIMS4_PREFIX; + } + + status = (which == ENDPOINT_DIMS5) + ? dims_sign_dims5_url(url, key, prefix, &signed_url) + : dims_sign_dims4_url(url, key, prefix, &signed_url); + + if (status != DIMS_SIGN_OK) { + return report(status); + } + + if (which == ENDPOINT_DIMS5) { + wanted = dims5_signature(signed_url); + got = dims5_signature(url); + } else { + wanted = dims4_signature(signed_url, prefix); + got = dims4_signature(url, prefix); + } + + dims_sign_free(signed_url); + + if (wanted == NULL) { + free(got); + fputs("dims-sign: cannot read the signature it computed\n", stderr); + return EXIT_BAD_URL; + } + + if (got == NULL) { + free(wanted); + fputs("no signature\n", stdout); + return EXIT_MISMATCH; + } + + matched = (which == ENDPOINT_DIMS5) ? dims_sign_dims5_equal(wanted, got) + : dims_sign_dims4_equal(wanted, got); + + if (matched) { + puts("signature ok"); + } else { + printf("signature mismatch\n wanted %s\n got %s\n", wanted, got); + } + + free(wanted); + free(got); + + return matched ? EXIT_OK : EXIT_MISMATCH; +} + +/* -- The fixture filter -------------------------------------------------- */ +/* + * Reads the signing fixtures and writes them back with signed, query, + * message, and error set to what the library produces. The input fields are + * case, endpoint, prefix, key, and input. + * + * The output field order is fixed, so a second run over the first run's + * output produces the same bytes. + */ + +typedef struct { + char *name; + char *endpoint; + char *prefix; + char *key; + char *input; +} record; + +static void +release_record(record *r) +{ + free(r->name); + free(r->endpoint); + free(r->prefix); + free(r->key); + free(r->input); + memset(r, 0, sizeof(*r)); +} + +/* \n, \t, and \\ are the only escapes. */ +static char * +unescape(const char *value) +{ + char *out = malloc(strlen(value) + 1); + size_t at = 0; + size_t i; + + if (out == NULL) { + return NULL; + } + + for (i = 0; value[i] != '\0'; i++) { + if (value[i] == '\\' && value[i + 1] != '\0') { + i++; + switch (value[i]) { + case 'n': out[at++] = '\n'; break; + case 't': out[at++] = '\t'; break; + default: out[at++] = value[i]; break; + } + } else { + out[at++] = value[i]; + } + } + + out[at] = '\0'; + + return out; +} + +static void +write_field(const char *name, const char *value) +{ + const char *at; + + printf("%s\t", name); + + for (at = value; *at != '\0'; at++) { + switch (*at) { + case '\\': fputs("\\\\", stdout); break; + case '\n': fputs("\\n", stdout); break; + case '\t': fputs("\\t", stdout); break; + default: fputc(*at, stdout); break; + } + } + + fputc('\n', stdout); +} + +static const char * +error_name(dims_sign_status status) +{ + switch (status) { + case DIMS_SIGN_BAD_FIELD: + return "bad-field"; + case DIMS_SIGN_BAD_ARGUMENT: + return "bad-argument"; + default: + return "bad-url"; + } +} + +static int +write_record(const record *r) +{ + int is_dims5; + char *signed_url = NULL; + char *message = NULL; + dims_sign_status status; + + if (r->name == NULL || r->endpoint == NULL || r->key == NULL || + r->input == NULL) { + fprintf(stderr, "dims-sign: a record is missing a field\n"); + return EXIT_USAGE; + } + + is_dims5 = (strcmp(r->endpoint, "dims5") == 0); + if (!is_dims5 && strcmp(r->endpoint, "dims4") != 0) { + fprintf(stderr, "dims-sign: %s: unknown endpoint %s\n", r->name, + r->endpoint); + return EXIT_USAGE; + } + + write_field("case", r->name); + write_field("endpoint", r->endpoint); + write_field("prefix", (r->prefix != NULL) ? r->prefix : ""); + write_field("key", r->key); + write_field("input", r->input); + + status = is_dims5 ? dims_sign_dims5_url(r->input, r->key, r->prefix, + &signed_url) + : dims_sign_dims4_url(r->input, r->key, r->prefix, + &signed_url); + + if (status != DIMS_SIGN_OK) { + write_field("error", error_name(status)); + return EXIT_OK; + } + + write_field("signed", signed_url); + dims_sign_free(signed_url); + + if (is_dims5) { + const char *query = strchr(r->input, '?'); + char *canonical = NULL; + + if (dims_sign_canonical_query((query != NULL) ? query + 1 : NULL, + &canonical) != DIMS_SIGN_OK) { + return EXIT_BAD_URL; + } + + write_field("query", canonical); + dims_sign_free(canonical); + + status = dims_sign_dims5_message(r->input, r->prefix, &message); + } else { + status = dims_sign_dims4_message(r->input, r->key, r->prefix, &message); + } + + if (status != DIMS_SIGN_OK) { + return report(status); + } + + write_field("message", message); + dims_sign_free(message); + + return EXIT_OK; +} + +static int +set_field(record *r, const char *name, char *value) +{ + if (strcmp(name, "case") == 0) { + free(r->name); + r->name = value; + } else if (strcmp(name, "endpoint") == 0) { + free(r->endpoint); + r->endpoint = value; + } else if (strcmp(name, "prefix") == 0) { + free(r->prefix); + r->prefix = value; + } else if (strcmp(name, "key") == 0) { + free(r->key); + r->key = value; + } else if (strcmp(name, "input") == 0) { + free(r->input); + r->input = value; + } else if (strcmp(name, "signed") == 0 || strcmp(name, "query") == 0 || + strcmp(name, "message") == 0 || strcmp(name, "error") == 0) { + /* The library writes these. */ + free(value); + } else { + free(value); + fprintf(stderr, "dims-sign: unknown field %s\n", name); + return 0; + } + + return 1; +} + +static int +run_fixture(void) +{ + record current; + int open = 0; + int result = EXIT_OK; + char *line; + + memset(¤t, 0, sizeof(current)); + + while ((line = read_line(stdin)) != NULL) { + char *tab; + char *value; + + if (*line == '\0') { + if (open) { + result = write_record(¤t); + release_record(¤t); + open = 0; + if (result != EXIT_OK) { + free(line); + return result; + } + } + + puts(""); + free(line); + continue; + } + + if (*line == '#') { + puts(line); + free(line); + continue; + } + + tab = strchr(line, '\t'); + if (tab == NULL) { + fprintf(stderr, "dims-sign: a line has no tab: %s\n", line); + free(line); + release_record(¤t); + return EXIT_USAGE; + } + + *tab = '\0'; + value = unescape(tab + 1); + if (value == NULL || !set_field(¤t, line, value)) { + free(line); + release_record(¤t); + return EXIT_USAGE; + } + + open = 1; + free(line); + } + + if (open) { + result = write_record(¤t); + } + + release_record(¤t); + + return result; +} + +/* -- The command -------------------------------------------------------- */ + +int +main(int argc, char **argv) +{ + endpoint which = ENDPOINT_NONE; + mode how = MODE_SIGN; + const char *key_file = NULL; + const char *prefix = NULL; + const char *url = NULL; + char *key = NULL; + int result; + int i; + + for (i = 1; i < argc; i++) { + const char *arg = argv[i]; + + if (strcmp(arg, "--dims4") == 0) { + which = ENDPOINT_DIMS4; + } else if (strcmp(arg, "--dims5") == 0) { + which = ENDPOINT_DIMS5; + } else if (strcmp(arg, "--message") == 0) { + how = MODE_MESSAGE; + } else if (strcmp(arg, "--verify") == 0) { + how = MODE_VERIFY; + } else if (strcmp(arg, "--fixture") == 0) { + how = MODE_FIXTURE; + } else if (strcmp(arg, "--key-file") == 0 && i + 1 < argc) { + key_file = argv[++i]; + } else if (strcmp(arg, "--prefix") == 0 && i + 1 < argc) { + prefix = argv[++i]; + } else if (strcmp(arg, "--help") == 0) { + usage(stdout); + return EXIT_OK; + } else if (*arg == '-') { + fprintf(stderr, "dims-sign: unknown option %s\n", arg); + usage(stderr); + return EXIT_USAGE; + } else if (url == NULL) { + url = arg; + } else { + fputs("dims-sign: one URL at a time\n", stderr); + return EXIT_USAGE; + } + } + + if (how == MODE_FIXTURE) { + return run_fixture(); + } + + if (which == ENDPOINT_NONE || url == NULL) { + usage(stderr); + return EXIT_USAGE; + } + + if (how == MODE_MESSAGE) { + if (which == ENDPOINT_DIMS4) { + fputs("dims-sign: --message needs --dims5. A /dims4/ message holds " + "the client secret.\n", stderr); + return EXIT_USAGE; + } + + return run_message(url, prefix); + } + + if (key_file != NULL) { + key = read_key_file(key_file); + if (key == NULL) { + return EXIT_USAGE; + } + } else { + const char *from_environment = getenv("DIMS_SIGNING_KEY"); + + if (from_environment == NULL || *from_environment == '\0') { + fputs("dims-sign: no key. Pass --key-file, or set " + "DIMS_SIGNING_KEY.\n", stderr); + return EXIT_USAGE; + } + + key = malloc(strlen(from_environment) + 1); + if (key == NULL) { + return EXIT_USAGE; + } + + strcpy(key, from_environment); + } + + result = (how == MODE_VERIFY) ? run_verify(which, url, key, prefix) + : run_sign(which, url, key, prefix); + + free(key); + + return result; +} diff --git a/sign/src/dims_sign_internal.h b/sign/src/dims_sign_internal.h new file mode 100644 index 0000000..cd45143 --- /dev/null +++ b/sign/src/dims_sign_internal.h @@ -0,0 +1,27 @@ +/* + * What the dims-sign command needs and no client does. + * + * This header is not installed. A /dims4/ message contains the client secret, + * so the installed header does not offer it. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef DIMS_SIGN_INTERNAL_H +#define DIMS_SIGN_INTERNAL_H + +#include "dims_sign.h" + +/* + * Builds the message dims_sign_dims4_url hashes. + * + * The message is the expiry, the secret, the commands, the image URL, and the + * value of each name _keys lists, joined with nothing between them. + * + * On DIMS_SIGN_OK, *out holds the message. Release it with dims_sign_free. + */ +dims_sign_status dims_sign_dims4_message(const char *url, const char *secret, + const char *prefix, char **out); + +#endif diff --git a/sign/src/sign.c b/sign/src/sign.c index ce8548e..f8817b9 100644 --- a/sign/src/sign.c +++ b/sign/src/sign.c @@ -6,6 +6,7 @@ */ #include "dims_sign.h" +#include "dims_sign_internal.h" #include #include @@ -493,22 +494,15 @@ dims_sign_dims5_digest(const char *key, const char *commands, return DIMS_SIGN_OK; } -dims_sign_status -dims_sign_dims4_digest(const char *secret, const char *expires, - const char *commands, const char *image_url, - const dims_sign_param *keys, size_t key_count, - char out[DIMS_SIGN_DIMS4_DIGEST + 1]) +/* The expiry, the secret, the commands, the image URL, and the keyed values. */ +static char * +dims4_message(const char *secret, const char *expires, const char *commands, + const char *image_url, const dims_sign_param *keys, + size_t key_count) { - unsigned char digest[EVP_MAX_MD_SIZE]; - unsigned int length = 0; buffer message; - char *text; size_t i; - if (out == NULL || secret == NULL || *secret == '\0' || expires == NULL) { - return DIMS_SIGN_BAD_ARGUMENT; - } - buffer_init(&message); buffer_add(&message, expires); buffer_add(&message, secret); @@ -520,7 +514,24 @@ dims_sign_dims4_digest(const char *secret, const char *expires, buffer_add(&message, keys[i].value); } - text = buffer_take(&message); + return buffer_take(&message); +} + +dims_sign_status +dims_sign_dims4_digest(const char *secret, const char *expires, + const char *commands, const char *image_url, + const dims_sign_param *keys, size_t key_count, + char out[DIMS_SIGN_DIMS4_DIGEST + 1]) +{ + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int length = 0; + char *text; + + if (out == NULL || secret == NULL || *secret == '\0' || expires == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + text = dims4_message(secret, expires, commands, image_url, keys, key_count); if (text == NULL) { return DIMS_SIGN_MEMORY; } @@ -1065,29 +1076,37 @@ read_keys(const char *query, dims_sign_param **out, size_t *count) return DIMS_SIGN_OK; } -dims_sign_status -dims_sign_dims4_url(const char *url, const char *key, const char *prefix, - char **out) +/* Everything a /dims4/ signature covers, read out of one URL. */ +typedef struct { + url_parts parts; + dims4_path path; + char *commands; + char *image_url; + char *expires; + dims_sign_param *keys; + size_t key_count; +} dims4_fields; + +static void +release_dims4_fields(dims4_fields *fields) +{ + free(fields->commands); + free(fields->image_url); + free(fields->expires); + release_keys(fields->keys, fields->key_count); +} + +static dims_sign_status +read_dims4_fields(const char *url, const char *prefix, dims4_fields *fields) { dims_sign_status status; - char digest[DIMS_SIGN_DIMS4_DIGEST + 1]; - char *commands = NULL; - char *image_url = NULL; - char *expires = NULL; - dims_sign_param *keys = NULL; - size_t key_count = 0; const char *rest; size_t rest_length; - url_parts parts; - dims4_path path; - buffer signed_url; char *at; - if (out == NULL || url == NULL) { - return DIMS_SIGN_BAD_ARGUMENT; - } + memset(fields, 0, sizeof(*fields)); - if (key == NULL || *key == '\0') { + if (url == NULL) { return DIMS_SIGN_BAD_ARGUMENT; } @@ -1095,91 +1114,147 @@ dims_sign_dims4_url(const char *url, const char *key, const char *prefix, prefix = DIMS_SIGN_DIMS4_PREFIX; } - split_url(url, &parts); + split_url(url, &fields->parts); - status = after_prefix(&parts, prefix, &rest, &rest_length); + status = after_prefix(&fields->parts, prefix, &rest, &rest_length); if (status != DIMS_SIGN_OK) { return status; } - status = split_dims4_path(rest, rest_length, &path); + status = split_dims4_path(rest, rest_length, &fields->path); if (status != DIMS_SIGN_OK) { return status; } - status = decode_strict(path.commands, path.commands_length, &commands); + status = decode_strict(fields->path.commands, fields->path.commands_length, + &fields->commands); if (status != DIMS_SIGN_OK) { return status; } /* A space travels as %20 and signs as a plus. */ - for (at = commands; *at != '\0'; at++) { + for (at = fields->commands; *at != '\0'; at++) { if (*at == ' ') { *at = '+'; } } - status = read_image_url(parts.query, &image_url); + status = read_image_url(fields->parts.query, &fields->image_url); if (status != DIMS_SIGN_OK) { - free(commands); + release_dims4_fields(fields); return status; } /* The module writes every plus in a /dims4/ image URL as a space after it * decodes the value. /dims5/ keeps the plus. */ - for (at = image_url; *at != '\0'; at++) { + for (at = fields->image_url; *at != '\0'; at++) { if (*at == '+') { *at = ' '; } } - status = read_keys(parts.query, &keys, &key_count); - if (status == DIMS_SIGN_OK) { - expires = malloc(path.expires_length + 1); - if (expires == NULL) { - status = DIMS_SIGN_MEMORY; - } else { - memcpy(expires, path.expires, path.expires_length); - expires[path.expires_length] = '\0'; - } + status = read_keys(fields->parts.query, &fields->keys, &fields->key_count); + if (status != DIMS_SIGN_OK) { + release_dims4_fields(fields); + return status; } - if (status == DIMS_SIGN_OK) { - status = dims_sign_dims4_digest(key, expires, commands, image_url, - keys, key_count, digest); + fields->expires = copy_range(fields->path.expires, + fields->path.expires_length); + if (fields->expires == NULL) { + release_dims4_fields(fields); + return DIMS_SIGN_MEMORY; } - free(commands); - free(image_url); - free(expires); - release_keys(keys, key_count); + return DIMS_SIGN_OK; +} + +dims_sign_status +dims_sign_dims4_url(const char *url, const char *key, const char *prefix, + char **out) +{ + dims_sign_status status; + char digest[DIMS_SIGN_DIMS4_DIGEST + 1]; + dims4_fields fields; + buffer signed_url; + if (out == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (key == NULL || *key == '\0') { + return DIMS_SIGN_BAD_ARGUMENT; + } + + status = read_dims4_fields(url, prefix, &fields); if (status != DIMS_SIGN_OK) { return status; } + status = dims_sign_dims4_digest(key, fields.expires, fields.commands, + fields.image_url, fields.keys, + fields.key_count, digest); + + if (status != DIMS_SIGN_OK) { + release_dims4_fields(&fields); + return status; + } + /* The path is rebuilt from its four segments, so a placeholder equal to * the client id or to the expiry still works. */ buffer_init(&signed_url); - buffer_add_bytes(&signed_url, url, (size_t) (path.client - url)); - buffer_add_bytes(&signed_url, path.client, path.client_length); + buffer_add_bytes(&signed_url, url, (size_t) (fields.path.client - url)); + buffer_add_bytes(&signed_url, fields.path.client, + fields.path.client_length); buffer_add_char(&signed_url, '/'); - buffer_add_bytes(&signed_url, digest, path.signature_length); + buffer_add_bytes(&signed_url, digest, fields.path.signature_length); buffer_add_char(&signed_url, '/'); - buffer_add_bytes(&signed_url, path.expires, path.expires_length); + buffer_add_bytes(&signed_url, fields.path.expires, + fields.path.expires_length); buffer_add_char(&signed_url, '/'); - buffer_add_bytes(&signed_url, path.commands, path.commands_length); + buffer_add_bytes(&signed_url, fields.path.commands, + fields.path.commands_length); - if (parts.query != NULL) { + if (fields.parts.query != NULL) { buffer_add_char(&signed_url, '?'); - buffer_add(&signed_url, parts.query); + buffer_add(&signed_url, fields.parts.query); } + release_dims4_fields(&fields); + *out = buffer_take(&signed_url); return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; } +dims_sign_status +dims_sign_dims4_message(const char *url, const char *secret, const char *prefix, + char **out) +{ + dims_sign_status status; + dims4_fields fields; + + if (out == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (secret == NULL || *secret == '\0') { + return DIMS_SIGN_BAD_ARGUMENT; + } + + status = read_dims4_fields(url, prefix, &fields); + if (status != DIMS_SIGN_OK) { + return status; + } + + *out = dims4_message(secret, fields.expires, fields.commands, + fields.image_url, fields.keys, fields.key_count); + + release_dims4_fields(&fields); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + /* -- The rest ----------------------------------------------------------- */ const char * From 4f849915ab9c361a0a5e7dabbdcf543a656358c1 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 17:18:43 -0400 Subject: [PATCH 5/8] Add the signing fixture file test/fixtures/signing.tsv holds thirty eight records. A person writes case, endpoint, prefix, key, and input. dims-sign --fixture writes signed, query, message, and error. The keys, the client id, and the expiry match test/conf/dims-test.conf. test/lib/fixtures.c reads the file for both suites. test/unit/test_fixtures.c asserts every field against the library and names the record that differs. A second case runs dims-sign --fixture over the file and compares the output to it line by line. --- test/CMakeLists.txt | 13 +- test/fixtures/signing.tsv | 336 ++++++++++++++++++++++++++++++++++++++ test/lib/fixtures.c | 156 ++++++++++++++++++ test/lib/fixtures.h | 47 ++++++ test/unit/main.c | 2 + test/unit/test_fixtures.c | 188 +++++++++++++++++++++ 6 files changed, 740 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/signing.tsv create mode 100644 test/lib/fixtures.c create mode 100644 test/lib/fixtures.h create mode 100644 test/unit/test_fixtures.c diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 30ecb1f..12cde84 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -109,11 +109,13 @@ add_executable(dims_unit_test unit/test_netguard.c unit/test_overlay_cache.c unit/test_param.c + unit/test_fixtures.c unit/test_sign.c unit/test_svgguard.c unit/test_url.c unit/test_thumbnail.c unit/test_watermark.c + lib/fixtures.c lib/golden.c lib/prometheus.c lib/test.c @@ -144,7 +146,8 @@ add_executable(dims_unit_test target_include_directories(dims_unit_test PRIVATE ${APXS_INCLUDE_DIR} - ${CMAKE_SOURCE_DIR}/src) + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/sign/src) target_link_libraries(dims_unit_test PRIVATE moddims_sign @@ -155,7 +158,13 @@ target_link_libraries(dims_unit_test PRIVATE Dims::MagickWand Dims::MagickCore) -target_compile_definitions(dims_unit_test PRIVATE _GNU_SOURCE) +# The fixture case reads the shared file and runs the command that writes it. +target_compile_definitions(dims_unit_test PRIVATE + _GNU_SOURCE + DIMS_FIXTURE_FILE="${CMAKE_SOURCE_DIR}/test/fixtures/signing.tsv" + DIMS_SIGN_COMMAND="$") + +add_dependencies(dims_unit_test dims-sign) dims_set_warnings(dims_unit_test) dims_set_hardening(dims_unit_test) diff --git a/test/fixtures/signing.tsv b/test/fixtures/signing.tsv new file mode 100644 index 0000000..d42d19e --- /dev/null +++ b/test/fixtures/signing.tsv @@ -0,0 +1,336 @@ +# The /dims4/ and /dims5/ signing fixtures. +# +# One case per record. A blank line ends a record. A line starting with # is +# a comment. \n, \t, and \\ are the only escapes. An empty value is a field +# name, a tab, and nothing. +# +# Every suite builds the signer endpoint names, with key and prefix, and +# signs input. A record with signed asserts that the result equals it. A +# record with error asserts that the signer refuses the input. A record with +# message asserts that the message the signer hashed equals it, so a failure +# names the step that went wrong. query is the canonical query, and only the +# C suite checks it. +# +# An empty prefix means the signer's default. +# +# dims-sign --fixture writes every computed field. The keys, the client id, +# and the expiry are the ones in test/conf/dims-test.conf, so the HTTP suite +# sends each signed URL as it is. + +case dims5-plain +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff +query +message resize/100x100/\nhttp://origin:8080/grid.png\n + +case dims5-empty-query +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&download=1 +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&download=1&sig=e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff +query +message resize/100x100/\nhttp://origin:8080/grid.png\n + +case dims5-no-equals +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&flag +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&flag&sig=083549dc0ec8920601910665b2152742d070678c8933e82f39c56de45f33d6c3 +query flag= +message resize/100x100/\nhttp://origin:8080/grid.png\nflag= + +case dims5-multi-value +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&tag=b&tag=a +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&tag=b&tag=a&sig=146f18539b8f82768fe271f202b532961db33f8dcffbe3b8646a7ed0d85354e0 +query tag=b&tag=a +message resize/100x100/\nhttp://origin:8080/grid.png\ntag=b&tag=a + +case dims5-byte-order +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=ab&b=c +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=ab&b=c&sig=02b19c651246d07bab96d2f340718f327b846e80b00664723a8db8adb36652e8 +query a=ab&b=c +message resize/100x100/\nhttp://origin:8080/grid.png\na=ab&b=c + +case dims5-name-order +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&ab=2&a-b=1 +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&ab=2&a-b=1&sig=149a9623660aea978c83b663ac744f079b77bf137832d872ff0805fcdf057907 +query a-b=1&ab=2 +message resize/100x100/\nhttp://origin:8080/grid.png\na-b=1&ab=2 + +case dims5-upper-lower +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=1&Z=2 +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=1&Z=2&sig=f8b2ad439b1ee35f80d8c9da8278a2f2d51a1bbc90ff86fe20846d25b47883c2 +query Z=2&a=1 +message resize/100x100/\nhttp://origin:8080/grid.png\nZ=2&a=1 + +case dims5-space-plus-tilde +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=x+y&b=%2B&c=~ +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=x+y&b=%2B&c=~&sig=882120b37f421a1b7c6a8490904fa62a32e6d54f5667cd99eba79d36b3e8b616 +query a=x+y&b=%2B&c=~ +message resize/100x100/\nhttp://origin:8080/grid.png\na=x+y&b=%2B&c=~ + +case dims5-percent +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&pct=50%25 +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&pct=50%25&sig=5360d18b32e596475dea65a282d5204ea55b7f34ef156a9fe17ac84fb3721104 +query pct=50%25 +message resize/100x100/\nhttp://origin:8080/grid.png\npct=50%25 + +case dims5-unsigned +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=x&eurl=AAAA&_keys=a&download=1&a=1 +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&eurl=AAAA&_keys=a&download=1&a=1&sig=233d0db06866a9740b9f4df6cdcabd8aef726959416c98e83ad044917b9be51f +query a=1 +message resize/100x100/\nhttp://origin:8080/grid.png\na=1 + +case dims5-keys-absent +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&_keys=overlay&a=1 +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&_keys=overlay&a=1&sig=233d0db06866a9740b9f4df6cdcabd8aef726959416c98e83ad044917b9be51f +query a=1 +message resize/100x100/\nhttp://origin:8080/grid.png\na=1 + +case dims5-supplementary-name +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&%F0%90%80%80=1&%EF%BC%A1=2 +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&%F0%90%80%80=1&%EF%BC%A1=2&sig=59806e7eac17b0fe1f4aa32829c862aa23eeb7b8e182d1319b005fad507f67bf +query %EF%BC%A1=2&%F0%90%80%80=1 +message resize/100x100/\nhttp://origin:8080/grid.png\n%EF%BC%A1=2&%F0%90%80%80=1 + +case dims5-plus-in-url +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png%3Fa%3Db+c +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png%3Fa%3Db+c&sig=632ab73eb4d5344cb4238909cb22a67f462dd973a2e982f0db4cda763a893720 +query +message resize/100x100/\nhttp://origin:8080/grid.png?a=b+c\n + +case dims5-url-repeated +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fportrait.jpg&url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fportrait.jpg&url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff +query +message resize/100x100/\nhttp://origin:8080/grid.png\n + +case dims5-resign +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=deadbeef +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff +query +message resize/100x100/\nhttp://origin:8080/grid.png\n + +case dims5-no-trailing-slash +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims5/resize/100x100?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=cd15aa5b3cc66ecafd3163f5a3ff504889f52038303973c8efbded7a3a875cd5 +query +message resize/100x100\nhttp://origin:8080/grid.png\n + +case dims5-percent-commands +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/crop/50%25x50%25/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims5/crop/50%25x50%25/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=82936c7ccc1e69a66bfda0af251f46ade1e42b2c9858ab697c979cfb459e69c7 +query +message crop/50%x50%/\nhttp://origin:8080/grid.png\n + +case dims5-space-commands +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100%20x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims5/resize/100%20x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=2f5128205e2fa93f2b871429d43de15d1da848496fa075f597c5bd45fab955c1 +query +message resize/100 x100/\nhttp://origin:8080/grid.png\n + +case dims5-control-in-commands +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/%0A100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +error bad-field + +case dims5-malformed-percent +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/%zzx100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +error bad-url + +case dims5-no-url +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?a=1 +error bad-url + +case dims5-other-prefix +endpoint dims5 +prefix /img/ +key 0123456789abcdef0123456789abcdef +input https://images.example.com/img/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed https://images.example.com/img/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff +query +message resize/100x100/\nhttp://origin:8080/grid.png\n + +case dims5-empty-prefix +endpoint dims5 +prefix +key 0123456789abcdef0123456789abcdef +input /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff +query +message resize/100x100/\nhttp://origin:8080/grid.png\n + +case dims5-wrong-prefix +endpoint dims5 +prefix /dims5/ +key 0123456789abcdef0123456789abcdef +input /dims3/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +error bad-url + +case dims4-plain +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims4/TEST/82ea1c/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png + +case dims4-keys-order +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=1&b=2&_keys=b,a +signed /dims4/TEST/8270ff/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=1&b=2&_keys=b,a +message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png21 + +case dims4-keys-absent +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=1&_keys=a,absent +signed /dims4/TEST/6410a9/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=1&_keys=a,absent +message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png1 + +case dims4-keys-encoded +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647/watermark/0.2,0.5,se/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&overlay=http%3A%2F%2Forigin%3A8080%2Foverlay.png&_keys=overlay +signed /dims4/TEST/c58151/2147483647/watermark/0.2,0.5,se/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&overlay=http%3A%2F%2Forigin%3A8080%2Foverlay.png&_keys=overlay +message 2147483647t3stk3ywatermark/0.2,0.5,se/http://origin:8080/grid.pnghttp%3A%2F%2Forigin%3A8080%2Foverlay.png + +case dims4-keys-repeated +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=1&a=2&_keys=a +signed /dims4/TEST/30d087/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&a=1&a=2&_keys=a +message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png2 + +case dims4-percent-commands +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647/crop/50%25x50%25/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims4/TEST/150ed9/2147483647/crop/50%25x50%25/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +message 2147483647t3stk3ycrop/50%x50%/http://origin:8080/grid.png + +case dims4-space-commands +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647/resize/100%20x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims4/TEST/5e3eff/2147483647/resize/100%20x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +message 2147483647t3stk3yresize/100+x100/http://origin:8080/grid.png + +case dims4-plus-in-url +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png%3Fa%3Db+c +signed /dims4/TEST/30eeed/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png%3Fa%3Db+c +message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png?a=b c + +case dims4-placeholder-clash +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/2147483647/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims4/TEST/82ea1ce80b/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png + +case dims4-long-signature +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /dims4/TEST/82ea1ce80bea7fd48834e7b7dbab69e1/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png + +case dims4-short-signature +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +error bad-url + +case dims4-bad-expiry +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/soon/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +error bad-url + +case dims4-missing-segment +endpoint dims4 +prefix /dims4/ +key t3stk3y +input /dims4/TEST/xxxxxx/2147483647?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +error bad-url + +case dims4-other-prefix +endpoint dims4 +prefix /img/ +key t3stk3y +input /img/TEST/xxxxxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +signed /img/TEST/82ea1c/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png +message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png + diff --git a/test/lib/fixtures.c b/test/lib/fixtures.c new file mode 100644 index 0000000..9b75be0 --- /dev/null +++ b/test/lib/fixtures.c @@ -0,0 +1,156 @@ +/* + * Reading test/fixtures/signing.tsv. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "fixtures.h" + +#include +#include +#include + +#ifndef DIMS_FIXTURE_FILE +#define DIMS_FIXTURE_FILE "/build/mod_dims/test/fixtures/signing.tsv" +#endif + +const char * +dims_fixtures_path(void) +{ + const char *from_env = getenv("DIMS_TEST_FIXTURE_FILE"); + + return (from_env != NULL && from_env[0] != '\0') ? from_env + : DIMS_FIXTURE_FILE; +} + +/* \n, \t, and \\ are the only escapes. */ +static void +unescape(const char *value, char *out, size_t size) +{ + size_t at = 0; + size_t i; + + for (i = 0; value[i] != '\0' && at + 1 < size; i++) { + if (value[i] == '\\' && value[i + 1] != '\0') { + i++; + switch (value[i]) { + case 'n': out[at++] = '\n'; break; + case 't': out[at++] = '\t'; break; + default: out[at++] = value[i]; break; + } + } else { + out[at++] = value[i]; + } + } + + out[at] = '\0'; +} + +static int +set_field(dims_fixture *f, const char *name, const char *value) +{ + if (strcmp(name, "case") == 0) { + unescape(value, f->name, sizeof(f->name)); + } else if (strcmp(name, "endpoint") == 0) { + unescape(value, f->endpoint, sizeof(f->endpoint)); + } else if (strcmp(name, "prefix") == 0) { + unescape(value, f->prefix, sizeof(f->prefix)); + } else if (strcmp(name, "key") == 0) { + unescape(value, f->key, sizeof(f->key)); + } else if (strcmp(name, "input") == 0) { + unescape(value, f->input, sizeof(f->input)); + } else if (strcmp(name, "signed") == 0) { + unescape(value, f->expected, sizeof(f->expected)); + f->has_expected = 1; + } else if (strcmp(name, "query") == 0) { + unescape(value, f->query, sizeof(f->query)); + f->has_query = 1; + } else if (strcmp(name, "message") == 0) { + unescape(value, f->message, sizeof(f->message)); + f->has_message = 1; + } else if (strcmp(name, "error") == 0) { + unescape(value, f->error, sizeof(f->error)); + f->has_error = 1; + } else { + return 0; + } + + return 1; +} + +int +dims_fixtures_read(void (*visit)(const dims_fixture *, void *), void *data) +{ + FILE *file = fopen(dims_fixtures_path(), "r"); + char line[DIMS_FIXTURE_FIELD_MAX + 128]; + dims_fixture current; + int open = 0; + int count = 0; + + if (file == NULL) { + return -1; + } + + memset(¤t, 0, sizeof(current)); + + while (fgets(line, (int) sizeof(line), file) != NULL) { + char *tab; + size_t length = strlen(line); + + while (length > 0 && (line[length - 1] == '\n' || + line[length - 1] == '\r')) { + line[--length] = '\0'; + } + + /* A blank line ends a record. */ + if (length == 0) { + if (open) { + visit(¤t, data); + count++; + memset(¤t, 0, sizeof(current)); + open = 0; + } + continue; + } + + if (line[0] == '#') { + continue; + } + + tab = strchr(line, '\t'); + if (tab == NULL) { + fclose(file); + return -1; + } + + *tab = '\0'; + if (!set_field(¤t, line, tab + 1)) { + fclose(file); + return -1; + } + + open = 1; + } + + if (open) { + visit(¤t, data); + count++; + } + + fclose(file); + + return count; +} + +int +dims_fixture_is_dims5(const dims_fixture *f) +{ + return strcmp(f->endpoint, "dims5") == 0; +} + +const char * +dims_fixture_prefix(const dims_fixture *f) +{ + return (f->prefix[0] != '\0') ? f->prefix : NULL; +} diff --git a/test/lib/fixtures.h b/test/lib/fixtures.h new file mode 100644 index 0000000..04ddbd6 --- /dev/null +++ b/test/lib/fixtures.h @@ -0,0 +1,47 @@ +/* + * Reading test/fixtures/signing.tsv. + * + * The file is the contract the C library, the Go client, and the Java client + * share. Both suites read it through here, so one reader states the format. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef DIMS_TEST_FIXTURES_H +#define DIMS_TEST_FIXTURES_H + +#define DIMS_FIXTURE_FIELD_MAX 2048 + +typedef struct { + char name[128]; + char endpoint[16]; + char prefix[128]; + char key[128]; + char input[DIMS_FIXTURE_FIELD_MAX]; + char expected[DIMS_FIXTURE_FIELD_MAX]; + char query[DIMS_FIXTURE_FIELD_MAX]; + char message[DIMS_FIXTURE_FIELD_MAX]; + char error[32]; + int has_expected; + int has_query; + int has_message; + int has_error; +} dims_fixture; + +/* Where the file is. DIMS_TEST_FIXTURE_FILE names another one. */ +const char *dims_fixtures_path(void); + +/* + * Hands every record to visit, in file order. Returns the number of records, + * or -1 when the file cannot be read or a line is malformed. + */ +int dims_fixtures_read(void (*visit)(const dims_fixture *, void *), void *data); + +/* Whether the record is for the /dims5/ endpoint. */ +int dims_fixture_is_dims5(const dims_fixture *f); + +/* The prefix to pass to the signer. An empty prefix means the default. */ +const char *dims_fixture_prefix(const dims_fixture *f); + +#endif diff --git a/test/unit/main.c b/test/unit/main.c index 4a34247..3c7a79f 100644 --- a/test/unit/main.c +++ b/test/unit/main.c @@ -25,6 +25,7 @@ extern const dims_test dims_tests_unit_metrics[]; extern const dims_test dims_tests_unit_netguard[]; extern const dims_test dims_tests_unit_overlay_cache[]; extern const dims_test dims_tests_unit_param[]; +extern const dims_test dims_tests_unit_fixtures[]; extern const dims_test dims_tests_unit_sign[]; extern const dims_test dims_tests_unit_svgguard[]; extern const dims_test dims_tests_unit_url[]; @@ -43,6 +44,7 @@ static const dims_test_group groups[] = { { "unit/test_overlay_cache.c", dims_tests_unit_overlay_cache }, { "unit/test_param.c", dims_tests_unit_param }, { "unit/test_sign.c", dims_tests_unit_sign }, + { "unit/test_fixtures.c", dims_tests_unit_fixtures }, { "unit/test_svgguard.c", dims_tests_unit_svgguard }, { "unit/test_url.c", dims_tests_unit_url }, { NULL, NULL } diff --git a/test/unit/test_fixtures.c b/test/unit/test_fixtures.c new file mode 100644 index 0000000..3080e1d --- /dev/null +++ b/test/unit/test_fixtures.c @@ -0,0 +1,188 @@ +/* + * The shared signing fixtures, against the C library. + * + * test/fixtures/signing.tsv is the contract the three clients share. This + * signs each input and compares every field. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include + +#include "../lib/fixtures.h" +#include "../lib/test.h" + +#include +#include + +#ifndef DIMS_SIGN_COMMAND +#define DIMS_SIGN_COMMAND "/build/mod_dims/build/sign/dims-sign" +#endif + +static const char * +error_name(dims_sign_status status) +{ + switch (status) { + case DIMS_SIGN_BAD_FIELD: + return "bad-field"; + case DIMS_SIGN_BAD_ARGUMENT: + return "bad-argument"; + default: + return "bad-url"; + } +} + +static void +check_signed(const dims_fixture *f) +{ + char *got = NULL; + dims_sign_status status; + + status = dims_fixture_is_dims5(f) + ? dims_sign_dims5_url(f->input, f->key, dims_fixture_prefix(f), &got) + : dims_sign_dims4_url(f->input, f->key, dims_fixture_prefix(f), &got); + + if (f->has_error) { + CHECK(status != DIMS_SIGN_OK, "%s: the signer must refuse the input", + f->name); + CHECK_STR(error_name(status), f->error, f->name); + dims_sign_free(got); + return; + } + + CHECK(f->has_expected, "%s: a record needs signed or error", f->name); + + if (status != DIMS_SIGN_OK) { + FAIL("%s: %s", f->name, dims_sign_strerror(status)); + return; + } + + CHECK_STR(got, f->expected, f->name); + dims_sign_free(got); +} + +static void +check_query(const dims_fixture *f) +{ + const char *args; + char *got = NULL; + + if (!f->has_query) { + return; + } + + CHECK(dims_fixture_is_dims5(f), + "%s: only a /dims5/ record has a canonical query", f->name); + + args = strchr(f->input, '?'); + CHECK_INT(dims_sign_canonical_query((args != NULL) ? args + 1 : NULL, &got), + DIMS_SIGN_OK, f->name); + CHECK_STR(got, f->query, f->name); + dims_sign_free(got); +} + +static void +check_message(const dims_fixture *f) +{ + char *got = NULL; + dims_sign_status status; + + if (!f->has_message) { + return; + } + + status = dims_fixture_is_dims5(f) + ? dims_sign_dims5_message(f->input, dims_fixture_prefix(f), &got) + : dims_sign_dims4_message(f->input, f->key, dims_fixture_prefix(f), + &got); + + if (status != DIMS_SIGN_OK) { + FAIL("%s: %s", f->name, dims_sign_strerror(status)); + return; + } + + CHECK_STR(got, f->message, f->name); + dims_sign_free(got); +} + +static void +check_record(const dims_fixture *f, void *data) +{ + (void) data; + + CHECK(f->name[0] != '\0', "a record needs a case name"); + CHECK(f->input[0] != '\0', "%s: a record needs an input", f->name); + + check_signed(f); + check_query(f); + check_message(f); +} + +/* Every field of every record, against the library. */ +static void +test_fixtures(void) +{ + int count = dims_fixtures_read(check_record, NULL); + + CHECK(count >= 30, "the fixture file holds the cases, got %d", count); +} + +/* + * The file is what dims-sign writes. A change to the library that moves a + * value fails here, and the difference names the line. + */ +static void +test_fixture_round_trip(void) +{ + char command[1024]; + FILE *wrote; + FILE *have; + int line = 1; + + snprintf(command, sizeof(command), "%s --fixture < %s", DIMS_SIGN_COMMAND, + dims_fixtures_path()); + + wrote = popen(command, "r"); + if (wrote == NULL) { + FAIL("cannot run %s", command); + return; + } + + have = fopen(dims_fixtures_path(), "r"); + if (have == NULL) { + pclose(wrote); + FAIL("cannot open %s", dims_fixtures_path()); + return; + } + + for (;;) { + char produced[DIMS_FIXTURE_FIELD_MAX + 128]; + char stored[DIMS_FIXTURE_FIELD_MAX + 128]; + char *a = fgets(produced, (int) sizeof(produced), wrote); + char *b = fgets(stored, (int) sizeof(stored), have); + + if (a == NULL && b == NULL) { + break; + } + + if (a == NULL || b == NULL || strcmp(produced, stored) != 0) { + FAIL("line %d differs:\n wrote [%s]\n file [%s]", line, + (a != NULL) ? produced : "(end)", + (b != NULL) ? stored : "(end)"); + break; + } + + line++; + } + + fclose(have); + CHECK_INT(pclose(wrote), 0, "dims-sign --fixture"); +} + +const dims_test dims_tests_unit_fixtures[] = { + { "TestSigningFixtures", test_fixtures, NULL }, + { "TestSigningFixtureRoundTrip", test_fixture_round_trip, NULL }, + DIMS_TEST_END +}; From 2d6d50fd0906e7b75a2fc5d4831e227f92a587b3 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 17:18:43 -0400 Subject: [PATCH 6/8] Request every fixture URL against the module One case reads test/fixtures/signing.tsv and sends each signed URL to the running module. It scrapes dims_signature_checks_total before and after, then asserts that the ok counter rose by the number of requests and the mismatch counter did not change. A signature refusal and a bad crop return the same status, so the status does not separate them. A record with an error has no signed URL. A record outside /dims4/ and /dims5/ is skipped, because the module reads a fixed seven characters past the start of the path. A record holding eurl is skipped, because the module decrypts eurl and a ciphertext contains a fresh nonce. --- test/CMakeLists.txt | 5 +- test/http/test_signing.c | 124 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 12cde84..945bb69 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable(dims_test http/test_thumbnail.c http/test_watermark.c lib/common.c + lib/fixtures.c lib/golden.c lib/prometheus.c lib/imagesize.c @@ -41,7 +42,9 @@ target_link_libraries(dims_test PRIVATE PkgConfig::CURL PkgConfig::CRYPTO) -target_compile_definitions(dims_test PRIVATE _GNU_SOURCE) +target_compile_definitions(dims_test PRIVATE + _GNU_SOURCE + DIMS_FIXTURE_FILE="${CMAKE_SOURCE_DIR}/test/fixtures/signing.tsv") dims_set_warnings(dims_test) dims_set_hardening(dims_test) diff --git a/test/http/test_signing.c b/test/http/test_signing.c index 0e051ad..f8f602b 100644 --- a/test/http/test_signing.c +++ b/test/http/test_signing.c @@ -14,6 +14,8 @@ */ #include "../lib/common.h" +#include "../lib/fixtures.h" +#include "../lib/prometheus.h" #define COMMANDS "resize/100x100" #define SIGNED_COMMANDS "resize/100x100/" @@ -204,6 +206,127 @@ test_no_query_string_answers(void) free(url); } +/* -- The shared fixtures ------------------------------------------------ */ +/* + * test/fixtures/signing.tsv holds the signatures the C library, the Go client, + * and the Java client all produce. This requests every one of them against the + * running module. A value all three reproduce and the module refuses fails + * here and nowhere else. + */ + +static const char * +dims5_server(void) +{ + const char *from_env = getenv("DIMS_TEST_DIMS5_URL"); + return (from_env != NULL && from_env[0] != '\0') ? from_env + : "http://dims:8007"; +} + +static dims_response * +scrape_metrics(void) +{ + char url[512]; + + snprintf(url, sizeof(url), "%s/metrics", dims_base_url()); + + return dims_get_absolute(url); +} + +typedef struct { + int sent; + int skipped; +} fixture_run; + +static void +request_fixture(const dims_fixture *f, void *data) +{ + fixture_run *run = data; + int is_dims5 = dims_fixture_is_dims5(f); + char url[DIMS_FIXTURE_FIELD_MAX + 256]; + dims_response *response; + + /* A record the signer refuses has no URL to send. */ + if (f->has_error) { + run->skipped++; + return; + } + + /* The module reads a fixed seven characters past the start of the path, so + * it serves no prefix but its own. */ + if (strncmp(f->expected, "/dims4/", 7) != 0 && + strncmp(f->expected, "/dims5/", 7) != 0) { + run->skipped++; + return; + } + + /* The module decrypts eurl and uses it as the image URL. A ciphertext + * holds a fresh nonce, so the file has none to send. */ + if (strstr(f->expected, "&eurl=") != NULL) { + run->skipped++; + return; + } + + snprintf(url, sizeof(url), "%s%s", + is_dims5 ? dims5_server() : dims_base_url(), f->expected); + + response = dims_get_absolute(url); + + CHECK(response->transport_error == NULL, "%s: %s", f->name, + response->transport_error ? response->transport_error : ""); + + run->sent++; + dims_response_free(response); +} + +static double +signature_count(const dims_response *metrics, const char *endpoint, + const char *result) +{ + char prefix[128]; + + snprintf(prefix, sizeof(prefix), + "dims_signature_checks_total{endpoint=\"%s\",result=\"%s\"}", + endpoint, result); + + return dims_prom_value(metrics, prefix); +} + +static void +test_every_fixture_url_verifies(void) +{ + dims_response *before = scrape_metrics(); + double ok_dims4 = signature_count(before, "dims4", "ok"); + double ok_dims5 = signature_count(before, "dims5", "ok"); + double bad_dims4 = signature_count(before, "dims4", "mismatch"); + double bad_dims5 = signature_count(before, "dims5", "mismatch"); + fixture_run run = { 0, 0 }; + dims_response *after; + int count; + + CHECK_INT(before->status, 200, "the metrics endpoint"); + + count = dims_fixtures_read(request_fixture, &run); + CHECK(count > 0, "cannot read %s", dims_fixtures_path()); + CHECK(run.sent > 0, "the file holds a URL the module serves"); + + after = scrape_metrics(); + + CHECK(signature_count(after, "dims4", "mismatch") == bad_dims4, + "no /dims4/ fixture URL is refused, %g became %g", bad_dims4, + signature_count(after, "dims4", "mismatch")); + CHECK(signature_count(after, "dims5", "mismatch") == bad_dims5, + "no /dims5/ fixture URL is refused, %g became %g", bad_dims5, + signature_count(after, "dims5", "mismatch")); + + CHECK(signature_count(after, "dims4", "ok") + + signature_count(after, "dims5", "ok") - + ok_dims4 - ok_dims5 == run.sent, + "every one of the %d URLs verified", run.sent); + + dims_response_free(before); + dims_response_free(after); +} + const dims_test dims_tests_signing[] = { { "TestSignedUrlValidates", test_signed_url_validates, NULL }, { "TestLegacySignatureMatchesModDims", test_legacy_signature_matches_mod_dims, NULL }, @@ -216,5 +339,6 @@ const dims_test dims_tests_signing[] = { { "TestUnsignedParametersAreRefused", test_unsigned_parameters_are_refused, "optimizeResize is never signed" }, { "TestNoQueryStringAnswers", test_no_query_string_answers, NULL }, + { "TestEveryFixtureUrlVerifies", test_every_fixture_url_verifies, NULL }, DIMS_TEST_END }; From a8e055cbebf4167c024e27e7bbb09addb9893512 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 17:18:43 -0400 Subject: [PATCH 7/8] Install the signing library and document it The install writes dims_sign.h under include/dims, the archive and the shared library under lib, dims-sign under bin, and dims-sign.pc under lib/pkgconfig. A caller builds against it with pkg-config --cflags --libs dims-sign. docker/Dockerfile copies only libmod_dims.so out of the build stage, so the server image has no dims-sign. test/endurance/sign.c calls the library for its query escape and its two digests. It keeps the path escape, which writes a space as %20, and the encryption helpers. docs/docs/clients/ describes the library and the command, and the two endpoint pages link to it. dims4.md states that a _keys value goes into the message as it appears in the query string, and that a plus in the image URL signs as a space. --- docs/docs/clients/c.md | 114 +++++++++++++++++++++++++++++++++ docs/docs/clients/dims-sign.md | 80 +++++++++++++++++++++++ docs/docs/clients/index.md | 30 +++++++++ docs/docs/endpoints/dims4.md | 10 ++- docs/docs/endpoints/dims5.md | 2 + docs/sidebars.js | 6 ++ sign/CMakeLists.txt | 21 ++++++ sign/dims-sign.pc.in | 12 ++++ test/endurance/CMakeLists.txt | 1 + test/endurance/gen.c | 34 ++++------ test/endurance/sign.c | 87 +++++++------------------ test/endurance/soak.h | 15 ++--- 12 files changed, 315 insertions(+), 97 deletions(-) create mode 100644 docs/docs/clients/c.md create mode 100644 docs/docs/clients/dims-sign.md create mode 100644 docs/docs/clients/index.md create mode 100644 sign/dims-sign.pc.in diff --git a/docs/docs/clients/c.md b/docs/docs/clients/c.md new file mode 100644 index 0000000..154b761 --- /dev/null +++ b/docs/docs/clients/c.md @@ -0,0 +1,114 @@ +# C library + +`libmoddims_sign` holds the `/dims4/` and `/dims5/` signing rules. The module +compiles the same source. + +It needs C99 and libcrypto. It does not need APR and it does not need httpd. + +## Build + +The library installs with the module. + +``` +cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build build +cmake --install build +``` + +That writes `dims_sign.h` under `include/dims`, `libmoddims_sign.a` and the +shared library under `lib`, `dims-sign` under `bin`, and `dims-sign.pc` under +`lib/pkgconfig`. + +`pkg-config` resolves `-lmoddims_sign` to the shared library. Pass +`--static` to link the archive. + +``` +cc app.c $(pkg-config --cflags --libs dims-sign) +``` + +## Sign a URL + +```c +#include +#include +#include + +int +main(void) +{ + const char *url = + "https://images.example.com/dims5/resize/100x100/" + "?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png"; + char *signed_url; + dims_sign_status status; + + status = dims_sign_dims5_url(url, getenv("DIMS_SIGNING_KEY"), NULL, + &signed_url); + if (status != DIMS_SIGN_OK) { + fprintf(stderr, "cannot sign: %s\n", dims_sign_strerror(status)); + return 1; + } + + puts(signed_url); + dims_sign_free(signed_url); + + return 0; +} +``` + +`dims_sign_dims4_url` uses the client secret instead of the signing key. Four +segments follow the prefix: the client id, the signature, the expiry, and the +commands. Write a placeholder in the signature segment. Its length sets the +length of the signature, from 6 characters to 32. + +## The prefix + +The third argument is what comes before the commands in the path. `NULL` means +`/dims5/` or `/dims4/`. A caller behind a rewrite passes the public prefix. + +```c +dims_sign_dims5_url("https://cdn.example.com/img/resize/100x100/?url=...", + key, "/img/", &signed_url); +``` + +The commands are `resize/100x100/` either way, so the signature matches what +the module computes after the rewrite. + +## Memory + +A string of unknown length comes back allocated. Release it with +`dims_sign_free`. A digest of fixed length goes into a caller buffer, because +its size is a compile time constant. + +A call that fails leaves the out parameter untouched and does not allocate. + +## Thread safety + +Every function is safe to call from any thread. No function reads or writes +static state. + +## Status codes + +| Status | Meaning | +|---|---| +| `DIMS_SIGN_OK` | the call produced a result | +| `DIMS_SIGN_MEMORY` | malloc refused | +| `DIMS_SIGN_BAD_ARGUMENT` | a required argument is NULL or empty | +| `DIMS_SIGN_BAD_URL` | the signer cannot read the URL | +| `DIMS_SIGN_BAD_FIELD` | a signed field holds a control character | +| `DIMS_SIGN_CRYPTO` | libcrypto refused | + +`dims_sign_strerror` returns a short description of each one. + +## What the signer does not do + +It does not repair the URL. The caller supplies a valid one. A percent escape +that is not two hex digits gives `DIMS_SIGN_BAD_URL`. + +It does not encrypt. A caller who wants `eurl` signs with `url`, then replaces +`url` with `eurl` in the signed URL. The signature stays valid, because the +message holds the plaintext image URL. + +It does not read an image URL out of the path. `/dims4/` also accepts the +image URL as the last path segment. The `url` query parameter is the +documented form, and the signer covers only that form. diff --git a/docs/docs/clients/dims-sign.md b/docs/docs/clients/dims-sign.md new file mode 100644 index 0000000..df0a02d --- /dev/null +++ b/docs/docs/clients/dims-sign.md @@ -0,0 +1,80 @@ +# dims-sign + +A command that signs a URL, prints the message behind one, and compares the +signature a URL holds against the one the key produces. + +``` +dims-sign (--dims4 | --dims5) [--key-file FILE] [--prefix P] + [--message | --verify] URL +``` + +The endpoint is a flag. The path alone does not identify the endpoint. +`--prefix` defaults to the prefix that flag names. + +## The key + +The key comes from `--key-file`, or from `DIMS_SIGNING_KEY` in the environment +when the flag is absent. A `--key-file` of `-` reads standard input. + +There is no `--key` flag. A key on the command line is visible to every user of +the machine through `ps`, and the shell records it in the history file. + +## Sign + +``` +$ dims-sign --dims5 --key-file dims.key \ + 'https://images.example.com/dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png' +https://images.example.com/dims5/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png&sig=e9d70afb... +``` + +A `/dims4/` URL holds a placeholder in the signature segment. Its length sets +the length of the signature. + +``` +$ DIMS_SIGNING_KEY=a-secret dims-sign --dims4 \ + '/dims4/CLIENT/xxxxxx/2147483647/resize/100x100/?url=https%3A%2F%2Fexample.com%2Fcat.jpg' +/dims4/CLIENT/0c0bf3/2147483647/resize/100x100/?url=https%3A%2F%2Fexample.com%2Fcat.jpg +``` + +## Read the message + +The server logs a mismatch without the digests. This command runs on a machine +that already holds the key, so it prints the message the key produces. + +``` +$ dims-sign --dims5 --key-file dims.key --message "$url" +watermark/0.2,0.5,se/ +http://origin:8080/grid.png +overlay=http%3A%2F%2Forigin%3A8080%2Foverlay.png +``` + +The three lines are the commands, the image URL, and the canonical query. Read +each one against [`/dims5/`](/endpoints/dims5) to find the line that differs. + +`--message` needs `--dims5`. A `/dims4/` message holds the client secret. + +## Check a signature + +``` +$ dims-sign --dims5 --key-file dims.key --verify "$url" +signature mismatch + wanted e9d70afb0b29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff + got 6d3dcb0a1f29520bae7fa47fb3de2d4c62c85f40d89636f6b190ac8055838bff +``` + +`--verify` accepts both endpoints. It prints digests. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | a signature, or a match | +| `1` | a mismatch | +| `2` | a usage error | +| `3` | a URL the command cannot read | + +## Where it installs + +`cmake --install` writes `dims-sign` under `bin`. `docker/Dockerfile` copies +only `libmod_dims.so` out of the build stage, so the server image has no +`dims-sign`. diff --git a/docs/docs/clients/index.md b/docs/docs/clients/index.md new file mode 100644 index 0000000..9f8868f --- /dev/null +++ b/docs/docs/clients/index.md @@ -0,0 +1,30 @@ +# Clients + +A client signs a URL before a browser requests it. The signing rules live in +one C library, and the module compiles the same source. + +| Client | What it is | +|---|---| +| [C library](/clients/c) | `libmoddims_sign`, the signing rules | +| [dims-sign](/clients/dims-sign) | a command that signs a URL and checks one | + +## One contract + +`test/fixtures/signing.tsv` holds a signed URL, a canonical query, and a +message for each case. The unit suite reads that file and compares the library +output against every field. + +The request suite sends each signed URL in that file to a running module, then +reads the signature counters to confirm the module verified all of them. The +file records what the server accepts. + +## Which endpoint + +Pick the signer that matches the endpoint the server serves. +[`/dims5/`](/endpoints/dims5) signs with HMAC-SHA256 under one key. +[`/dims4/`](/endpoints/dims4) signs with MD5 under a client secret. + +An operator picks the location with `SetHandler`, and a reverse proxy in front +can rewrite a public path onto it. The path alone does not identify the +endpoint. A client names the endpoint, and names the prefix when it differs +from the conventional one. diff --git a/docs/docs/endpoints/dims4.md b/docs/docs/endpoints/dims4.md index c75aaf0..4803eeb 100644 --- a/docs/docs/endpoints/dims4.md +++ b/docs/docs/endpoints/dims4.md @@ -71,7 +71,10 @@ and the URL is: ``` The commands have a trailing slash in the message. The image URL is percent -encoded in the query string but not in the message. +encoded in the query string but not in the message. Every plus in the image URL +becomes a space in the message, so write `%2B` for a plus that has to survive. + +A [client library](/clients/) signs a URL for you. ### Code @@ -139,8 +142,9 @@ well, list it in `_keys` and append its value to the message: message = expires + secret + "watermark/0.2,0.5,se/" + image + overlay ``` -Several parameters are appended in the order `_keys` gives, not in the order -they appear in the query string. +Each value goes into the message as it appears in the query string, before it +is decoded. Several parameters are appended in the order `_keys` gives, not in +the order they appear in the query string. ## Expiry diff --git a/docs/docs/endpoints/dims5.md b/docs/docs/endpoints/dims5.md index 53373e5..253ffee 100644 --- a/docs/docs/endpoints/dims5.md +++ b/docs/docs/endpoints/dims5.md @@ -41,6 +41,8 @@ Take the HMAC-SHA256 of that under the signing key, hex encoded and lowercase. The whole digest is compared, and the comparison reads every byte whatever the answer. +A [client library](/clients/) signs a URL for you. + ### The canonical query Every signed parameter written `name=value`, percent encoded, ordered by name. diff --git a/docs/sidebars.js b/docs/sidebars.js index ef24be0..6318022 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -9,6 +9,12 @@ const sidebars = { link: {type: 'doc', id: 'endpoints/index'}, items: ['endpoints/dims5', 'endpoints/dims4', 'endpoints/dims3', 'endpoints/status', 'endpoints/metrics', 'endpoints/local'], }, + { + type: 'category', + label: 'Clients', + link: {type: 'doc', id: 'clients/index'}, + items: ['clients/c', 'clients/dims-sign'], + }, { type: 'category', label: 'Operations', diff --git a/sign/CMakeLists.txt b/sign/CMakeLists.txt index 7a6718b..85edd8f 100644 --- a/sign/CMakeLists.txt +++ b/sign/CMakeLists.txt @@ -49,3 +49,24 @@ target_link_libraries(dims-sign PRIVATE moddims_sign) dims_set_warnings(dims-sign) dims_set_hardening(dims-sign) dims_set_sanitizers(dims-sign) + +# -- Install +# +# A caller outside the module builds against this: +# +# cc app.c $(pkg-config --cflags --libs dims-sign) +# +# dims_sign_internal.h is not installed. It offers the /dims4/ message, which +# holds the client secret. + +configure_file(dims-sign.pc.in "${CMAKE_CURRENT_BINARY_DIR}/dims-sign.pc" @ONLY) + +install(FILES include/dims_sign.h + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/dims") +install(TARGETS moddims_sign moddims_sign_shared + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}") +install(TARGETS dims-sign + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dims-sign.pc" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/pkgconfig") diff --git a/sign/dims-sign.pc.in b/sign/dims-sign.pc.in new file mode 100644 index 0000000..701ced5 --- /dev/null +++ b/sign/dims-sign.pc.in @@ -0,0 +1,12 @@ +prefix=@CMAKE_INSTALL_PREFIX@ +exec_prefix=${prefix} +libdir=${prefix}/@CMAKE_INSTALL_LIBDIR@ +includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@/dims + +Name: dims-sign +Description: The mod_dims signing rules for /dims4/ and /dims5/ +URL: https://github.com/beetlebugorg/mod_dims +Version: @PROJECT_VERSION@ +Requires.private: libcrypto +Libs: -L${libdir} -lmoddims_sign +Cflags: -I${includedir} diff --git a/test/endurance/CMakeLists.txt b/test/endurance/CMakeLists.txt index 2d57b8d..85db79c 100644 --- a/test/endurance/CMakeLists.txt +++ b/test/endurance/CMakeLists.txt @@ -14,6 +14,7 @@ add_executable(dims_soak image.c) target_link_libraries(dims_soak PRIVATE + moddims_sign PkgConfig::CURL PkgConfig::CRYPTO) diff --git a/test/endurance/gen.c b/test/endurance/gen.c index d721cf8..ea69ca4 100644 --- a/test/endurance/gen.c +++ b/test/endurance/gen.c @@ -15,6 +15,8 @@ #include "soak.h" +#include + #include #include #include @@ -1007,37 +1009,29 @@ dims_plan_make(dims_plan *plan, const dims_world *world, dims_rng *rng, char *as_signed = plus_for_space(with_slash); char *keyed = keyed_values(parameters, parameter_count, wants_overlay ? "overlay" : NULL); - buffer message; + dims_sign_param overlay = { "overlay", NULL }; + char digest[DIMS_SIGN_DIMS4_DIGEST + 1]; - buffer_init(&message); - buffer_add(&message, expires); - buffer_add(&message, world->secret); - buffer_add(&message, as_signed); - buffer_add(&message, signed_url); - buffer_add(&message, keyed); + overlay.value = keyed; - signature = dims_md5_hex(message.data != NULL ? message.data : ""); + if (dims_sign_dims4_digest(world->secret, expires, as_signed, + signed_url, &overlay, 1, digest) == DIMS_SIGN_OK) { + signature = duplicate(digest); + } - free(buffer_take(&message)); free(with_slash); free(as_signed); free(keyed); } else if (endpoint == DIMS_ENDPOINT_DIMS5) { char *with_slash = formatted("%s/", command_string); char *query = canonical_query(parameters, parameter_count); - buffer message; + char digest[DIMS_SIGN_DIMS5_LENGTH + 1]; - buffer_init(&message); - buffer_add(&message, with_slash); - buffer_add(&message, "\n"); - buffer_add(&message, signed_url); - buffer_add(&message, "\n"); - buffer_add(&message, query != NULL ? query : ""); - - signature = dims_hmac_sha256_hex(world->signing_key, - message.data != NULL ? message.data : ""); + if (dims_sign_dims5_digest(world->signing_key, with_slash, signed_url, + (query != NULL) ? query : "", digest) == DIMS_SIGN_OK) { + signature = duplicate(digest); + } - free(buffer_take(&message)); free(with_slash); free(query); } diff --git a/test/endurance/sign.c b/test/endurance/sign.c index a2fce85..0bfc4e5 100644 --- a/test/endurance/sign.c +++ b/test/endurance/sign.c @@ -1,9 +1,8 @@ /* * Signing and encrypting a soak request. * - * Every function here reproduces what the module checks. A change to the - * module's rules that this file does not follow appears in a run as a signed - * request the service refuses. + * The signature comes from libmoddims_sign. The module compiles the same source. + * The encryption is here, because the library does not cover it. * * Copyright 2026 Jeremy Collins * SPDX-License-Identifier: Apache-2.0 @@ -11,6 +10,8 @@ #include "soak.h" +#include + #include #include #include @@ -24,16 +25,26 @@ /* The salt src/encryption.c derives with. */ static const unsigned char dims_kdf_salt[] = "go-dims"; -static int -is_unreserved(unsigned char c) +char * +dims_escape(const char *value) { - return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || - c == '-' || c == '_' || c == '.' || c == '~'; + char *out = NULL; + + if (value == NULL || dims_sign_escape(value, &out) != DIMS_SIGN_OK) { + return NULL; + } + + /* The soak links the static library, so free releases this. */ + return out; } -static char * -escape(const char *value, int keep_slash) +/* + * A path escape, which the library does not offer. A slash separates the + * commands, so it passes through, and a space travels as %20 rather than as a + * plus. + */ +char * +dims_escape_path(const char *value) { static const char hex[] = "0123456789ABCDEF"; const unsigned char *in; @@ -51,10 +62,10 @@ escape(const char *value, int keep_slash) at = out; for (in = (const unsigned char *) value; *in != '\0'; in++) { - if (is_unreserved(*in) || (keep_slash && *in == '/')) { + if ((*in >= 'A' && *in <= 'Z') || (*in >= 'a' && *in <= 'z') || + (*in >= '0' && *in <= '9') || *in == '-' || *in == '_' || + *in == '.' || *in == '~' || *in == '/') { *at++ = (char) *in; - } else if (*in == ' ' && !keep_slash) { - *at++ = '+'; } else { *at++ = '%'; *at++ = hex[*in >> 4]; @@ -67,18 +78,6 @@ escape(const char *value, int keep_slash) return out; } -char * -dims_escape(const char *value) -{ - return escape(value, 0); -} - -char * -dims_escape_path(const char *value) -{ - return escape(value, 1); -} - static char * to_hex(const unsigned char *bytes, unsigned int length) { @@ -99,44 +98,6 @@ to_hex(const unsigned char *bytes, unsigned int length) return out; } -char * -dims_md5_hex(const char *message) -{ - unsigned char digest[EVP_MAX_MD_SIZE]; - unsigned int length = 0; - EVP_MD_CTX *context = EVP_MD_CTX_new(); - - if (context == NULL) { - return NULL; - } - - if (EVP_DigestInit_ex(context, EVP_md5(), NULL) != 1 || - EVP_DigestUpdate(context, message, strlen(message)) != 1 || - EVP_DigestFinal_ex(context, digest, &length) != 1) { - EVP_MD_CTX_free(context); - return NULL; - } - - EVP_MD_CTX_free(context); - - return to_hex(digest, length); -} - -char * -dims_hmac_sha256_hex(const char *key, const char *message) -{ - unsigned char digest[EVP_MAX_MD_SIZE]; - unsigned int length = 0; - - if (HMAC(EVP_sha256(), key, (int) strlen(key), - (const unsigned char *) message, strlen(message), - digest, &length) == NULL) { - return NULL; - } - - return to_hex(digest, length); -} - int dims_key_hkdf(const char *secret, unsigned char key[16]) { diff --git a/test/endurance/soak.h b/test/endurance/soak.h index 977318c..02b389d 100644 --- a/test/endurance/soak.h +++ b/test/endurance/soak.h @@ -65,24 +65,17 @@ int dims_rng_chance(dims_rng *rng, int percent); /* -- Signing ----------------------------------------------------------- */ /* - * Percent-encodes a value the way the module does when it rebuilds the query - * it signed: unreserved characters pass, a space becomes a plus, everything - * else becomes an uppercase hexadecimal pair. The caller frees. + * Percent-encodes a query value the way the module does when it rebuilds the + * query it signed. The caller frees. */ char *dims_escape(const char *value); /* - * The same, for a path segment. A slash separates the commands, so it passes - * through. The caller frees. + * The same, for a path. A slash separates the commands, so it passes through, + * and a space travels as %20. The caller frees. */ char *dims_escape_path(const char *value); -/* Hexadecimal MD5 of message. The caller frees. */ -char *dims_md5_hex(const char *message); - -/* Hexadecimal HMAC-SHA256 of message under key. The caller frees. */ -char *dims_hmac_sha256_hex(const char *key, const char *message); - /* HKDF-SHA256 with the salt the module uses. Returns 0 on failure. */ int dims_key_hkdf(const char *secret, unsigned char key[16]); From 8fd2080c6e61d90f31414c4c04627154887f234e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 19:00:20 -0400 Subject: [PATCH 8/8] Encrypt the image URL for eurl libmoddims_sign gains the key derivation and the two eurl ciphers. dims_sign_dims5_eurl_url and dims_sign_dims4_eurl_url sign a URL and put the encrypted source in place of url. The signature covers the plain image URL, so the server verifies the request after it decrypts. /dims5/ derives with HKDF-SHA256 under the salt go-dims and encrypts with AES-128-GCM. /dims4/ derives from SHA-1 of the client secret and encrypts with AES-128-ECB, or with GCM under DimsEncryptionAlgorithm. The value is percent encoded on /dims5/ and undecoded on /dims4/, because each endpoint reads the parameter that way. test/fixtures/signing.tsv gains four eurl records, each holding a ciphertext and the plain URL it decrypts to. Two request cases send a URL the library encrypted to the running module. test/endurance/sign.c calls the library for its derivations and its ECB encryption, and keeps its GCM, where the IV comes from the run's seed. --- docs/docs/clients/c.md | 36 ++- docs/docs/clients/dims-sign.md | 22 +- docs/docs/clients/index.md | 7 +- sign/CMakeLists.txt | 2 +- sign/include/dims_sign.h | 96 +++++++- sign/src/dims_sign_cli.c | 90 +++++++- sign/src/eurl.c | 405 +++++++++++++++++++++++++++++++++ sign/src/sign.c | 195 ++++++++++++++++ test/CMakeLists.txt | 4 + test/endurance/sign.c | 131 ++--------- test/fixtures/signing.tsv | 41 ++++ test/http/test_signing.c | 82 ++++++- test/lib/fixtures.c | 11 + test/lib/fixtures.h | 9 + test/unit/test_fixtures.c | 58 +++++ test/unit/test_sign.c | 156 +++++++++++++ 16 files changed, 1210 insertions(+), 135 deletions(-) create mode 100644 sign/src/eurl.c diff --git a/docs/docs/clients/c.md b/docs/docs/clients/c.md index 154b761..d0b1f0c 100644 --- a/docs/docs/clients/c.md +++ b/docs/docs/clients/c.md @@ -87,6 +87,37 @@ A call that fails leaves the out parameter untouched and does not allocate. Every function is safe to call from any thread. No function reads or writes static state. +## Encrypting the image URL + +`eurl` hides the source from a public caller. The signature covers the plain +image URL, so the server verifies the request after it decrypts. + +```c +char *signed_url; + +dims_sign_dims5_eurl_url(url, key, NULL, &signed_url); +``` + +That signs the URL and replaces `url` with `eurl` in one call. The `/dims4/` +form takes the cipher the server is configured for: + +```c +dims_sign_dims4_eurl_url(url, secret, NULL, DIMS_SIGN_EURL_ECB, &signed_url); +``` + +| Endpoint | Key | Cipher | +|---|---|---| +| `/dims5/` | HKDF-SHA256 of the signing key, salt `go-dims` | AES-128-GCM | +| `/dims4/` | SHA-1 of the client secret, hex, first 16 characters uppercased | AES-128-ECB, or GCM under [`DimsEncryptionAlgorithm`](/configuration/clients) | + +`dims_sign_derive_key` and `dims_sign_eurl_encrypt` do the two steps on their +own. `dims_sign_eurl_decrypt` reads a value back, so a caller can check what it +wrote. + +A GCM value is the 12 byte IV, the ciphertext, and the 16 byte tag, base64 +encoded. The IV comes from the system random source, so two calls on one URL +produce two values. + ## Status codes | Status | Meaning | @@ -97,6 +128,7 @@ static state. | `DIMS_SIGN_BAD_URL` | the signer cannot read the URL | | `DIMS_SIGN_BAD_FIELD` | a signed field holds a control character | | `DIMS_SIGN_CRYPTO` | libcrypto refused | +| `DIMS_SIGN_BAD_EURL` | an eurl value is not base64, is too short, or fails its tag check | `dims_sign_strerror` returns a short description of each one. @@ -105,10 +137,6 @@ static state. It does not repair the URL. The caller supplies a valid one. A percent escape that is not two hex digits gives `DIMS_SIGN_BAD_URL`. -It does not encrypt. A caller who wants `eurl` signs with `url`, then replaces -`url` with `eurl` in the signed URL. The signature stays valid, because the -message holds the plaintext image URL. - It does not read an image URL out of the path. `/dims4/` also accepts the image URL as the last path segment. The `url` query parameter is the documented form, and the signer covers only that form. diff --git a/docs/docs/clients/dims-sign.md b/docs/docs/clients/dims-sign.md index df0a02d..91f088c 100644 --- a/docs/docs/clients/dims-sign.md +++ b/docs/docs/clients/dims-sign.md @@ -4,8 +4,8 @@ A command that signs a URL, prints the message behind one, and compares the signature a URL holds against the one the key produces. ``` -dims-sign (--dims4 | --dims5) [--key-file FILE] [--prefix P] - [--message | --verify] URL +dims-sign (--dims4 | --dims5) [--key-file FILE] [--prefix P] [--eurl] + [--cipher gcm|ecb] [--message | --verify] URL ``` The endpoint is a flag. The path alone does not identify the endpoint. @@ -36,6 +36,24 @@ $ DIMS_SIGNING_KEY=a-secret dims-sign --dims4 \ /dims4/CLIENT/0c0bf3/2147483647/resize/100x100/?url=https%3A%2F%2Fexample.com%2Fcat.jpg ``` +## Encrypt the image URL + +`--eurl` signs the URL and then replaces `url` with the encrypted source. The +signature covers the plain image URL, so the server verifies the request after +it decrypts. + +``` +$ dims-sign --dims5 --key-file dims.key --eurl "$url" +/dims5/resize/100x100/?eurl=SbEm%2BgYau0i4Bj%2BP%2FLOgRUf9UG3eeq3DRDh%2F...&sig=e9d70afb... +``` + +`/dims5/` reads AES-128-GCM. `/dims4/` reads what +[`DimsEncryptionAlgorithm`](/configuration/clients) names, and its default is +AES-128-ECB. `--cipher` names the one to use, and it defaults to the endpoint +default. + +Every call writes a fresh IV, so two runs on one URL produce two values. + ## Read the message The server logs a mismatch without the digests. This command runs on a machine diff --git a/docs/docs/clients/index.md b/docs/docs/clients/index.md index 9f8868f..36e9dc2 100644 --- a/docs/docs/clients/index.md +++ b/docs/docs/clients/index.md @@ -5,7 +5,7 @@ one C library, and the module compiles the same source. | Client | What it is | |---|---| -| [C library](/clients/c) | `libmoddims_sign`, the signing rules | +| [C library](/clients/c) | `libmoddims_sign`, the signing rules and the `eurl` ciphers | | [dims-sign](/clients/dims-sign) | a command that signs a URL and checks one | ## One contract @@ -14,6 +14,11 @@ one C library, and the module compiles the same source. message for each case. The unit suite reads that file and compares the library output against every field. +An `eurl` record goes the other way: it holds a ciphertext and the plain image +URL it decrypts to. A ciphertext holds a fresh nonce, so the file cannot pin +one a client produces. Each suite round trips its own encrypt through its own +decrypt instead. + The request suite sends each signed URL in that file to a running module, then reads the signature counters to confirm the module verified all of them. The file records what the server accepts. diff --git a/sign/CMakeLists.txt b/sign/CMakeLists.txt index 85edd8f..7e678db 100644 --- a/sign/CMakeLists.txt +++ b/sign/CMakeLists.txt @@ -7,7 +7,7 @@ # object are built from those objects, so the two hold the same code. The # module links the archive, so libmod_dims.so gains no run time dependency. -add_library(moddims_sign_objects OBJECT src/sign.c) +add_library(moddims_sign_objects OBJECT src/sign.c src/eurl.c) set_target_properties(moddims_sign_objects PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/sign/include/dims_sign.h b/sign/include/dims_sign.h index 313a785..883e8b1 100644 --- a/sign/include/dims_sign.h +++ b/sign/include/dims_sign.h @@ -31,6 +31,16 @@ extern "C" { #define DIMS_SIGN_DIMS4_PREFIX "/dims4/" #define DIMS_SIGN_DIMS5_PREFIX "/dims5/" +/* The AES key both eurl schemes use. */ +#define DIMS_SIGN_KEY_BYTES 16 + +/* One AES block, which is the shortest thing ECB can decrypt. */ +#define DIMS_SIGN_AES_BLOCK_BYTES 16 + +/* What a GCM value has before and after the ciphertext. */ +#define DIMS_SIGN_GCM_IV_BYTES 12 +#define DIMS_SIGN_GCM_TAG_BYTES 16 + typedef enum { DIMS_SIGN_OK = 0, DIMS_SIGN_MEMORY, /* malloc refused */ @@ -43,9 +53,22 @@ typedef enum { than DIMS_SIGN_DIMS4_LENGTH, or the query has no url */ DIMS_SIGN_BAD_FIELD, /* a signed field holds a control character */ - DIMS_SIGN_CRYPTO /* libcrypto refused */ + DIMS_SIGN_CRYPTO, /* libcrypto refused */ + DIMS_SIGN_BAD_EURL /* an eurl value is not base64, is too short for + its scheme, or fails its tag check */ } dims_sign_status; +/* Which cipher an eurl value uses. */ +typedef enum { + /* AES-128-GCM. What /dims5/ reads, and what /dims4/ reads under + DimsEncryptionAlgorithm AES/GCM/NoPadding. */ + DIMS_SIGN_EURL_GCM = 0, + + /* AES-128-ECB with PKCS5 padding. The /dims4/ default. It has no + integrity check and no IV. */ + DIMS_SIGN_EURL_ECB +} dims_sign_cipher; + /* A short description of a status, for an error message. */ const char *dims_sign_strerror(dims_sign_status status); @@ -112,8 +135,79 @@ dims_sign_status dims_sign_dims4_url(const char *url, const char *key, dims_sign_status dims_sign_dims5_message(const char *url, const char *prefix, char **out); +/* + * Signs one /dims5/ URL and encrypts its image URL. + * + * The signature covers the plaintext image URL, so the server verifies the + * request after it decrypts. The output holds eurl in place of url, percent + * encoded, because the module decodes that parameter. + * + * key is the DimsSigningKey. This derives the AES key from it. + * + * On DIMS_SIGN_OK, *out holds the signed URL. Release it with dims_sign_free. + */ +dims_sign_status dims_sign_dims5_eurl_url(const char *url, const char *key, + const char *prefix, char **out); + +/* + * Signs one /dims4/ URL and encrypts its image URL. + * + * The output holds eurl in place of url, undecoded, because the module reads + * that parameter as it appears in the query. + * + * cipher names the scheme the server is configured for. This endpoint reads + * one derivation whatever the secret looks like, so the AES key comes from + * SHA-1 of the client secret. + * + * On DIMS_SIGN_OK, *out holds the signed URL. Release it with dims_sign_free. + */ +dims_sign_status dims_sign_dims4_eurl_url(const char *url, const char *key, + const char *prefix, + dims_sign_cipher cipher, char **out); + /* -- The rules, for the module and the command -- */ +/* + * Derives the AES key an eurl value uses. + * + * A secret with a sha1: prefix takes the older path: SHA-1 of the rest, hex + * encoded, the first 16 characters uppercased. That is 64 bits of material + * spread across 16 bytes. Anything else takes HKDF-SHA256, with a hkdf: + * prefix stripped first. + * + * /dims4/ reads the older path whatever the secret looks like, so a /dims4/ + * caller writes sha1: in front of the client secret. + */ +dims_sign_status dims_sign_derive_key(const char *secret, + unsigned char key[DIMS_SIGN_KEY_BYTES]); + +/* + * Encrypts one image URL for the eurl parameter. + * + * A GCM value is the 12 byte IV, the ciphertext, and the 16 byte tag, base64 + * encoded. The IV comes from the system random source, so two calls on one + * URL under one key produce two values. + * + * An ECB value is the ciphertext alone, base64 encoded. + * + * On DIMS_SIGN_OK, *out holds the value. Release it with dims_sign_free. + */ +dims_sign_status dims_sign_eurl_encrypt(const char *image_url, + const unsigned char key[DIMS_SIGN_KEY_BYTES], + dims_sign_cipher cipher, char **out); + +/* + * Decrypts one eurl value, so a caller reads back what it wrote. + * + * Returns DIMS_SIGN_BAD_EURL when the value is not base64, is too short for + * its scheme, or fails its tag check. + * + * On DIMS_SIGN_OK, *out holds the image URL. Release it with dims_sign_free. + */ +dims_sign_status dims_sign_eurl_decrypt(const char *eurl, + const unsigned char key[DIMS_SIGN_KEY_BYTES], + dims_sign_cipher cipher, char **out); + /* * Percent encodes one query component. * diff --git a/sign/src/dims_sign_cli.c b/sign/src/dims_sign_cli.c index 0de87fa..bacf37a 100644 --- a/sign/src/dims_sign_cli.c +++ b/sign/src/dims_sign_cli.c @@ -37,7 +37,7 @@ usage(FILE *to) { fputs( "usage: dims-sign (--dims4 | --dims5) [--key-file FILE] [--prefix P]\n" - " [--message | --verify] URL\n" + " [--eurl [--cipher gcm|ecb]] [--message | --verify] URL\n" " dims-sign --fixture < FILE\n" "\n" " --dims4, --dims5 which endpoint signs the URL. The path does not\n" @@ -47,6 +47,11 @@ usage(FILE *to) " DIMS_SIGNING_KEY.\n" " --prefix P what comes before the commands in the path.\n" " The default is /dims4/ or /dims5/.\n" + " --eurl encrypt the image URL and send it as eurl. The\n" + " signature covers the plain image URL.\n" + " --cipher C gcm or ecb, for --eurl on /dims4/. The default\n" + " is the endpoint default: gcm on /dims5/, ecb on\n" + " /dims4/.\n" " --message print the message the signer hashes. /dims5/\n" " only: a /dims4/ message holds the secret.\n" " --verify compare the signature the URL holds against the\n" @@ -242,12 +247,21 @@ report(dims_sign_status status) } static int -run_sign(endpoint which, const char *url, const char *key, const char *prefix) +run_sign(endpoint which, const char *url, const char *key, const char *prefix, + int eurl, dims_sign_cipher cipher) { char *out = NULL; - dims_sign_status status = (which == ENDPOINT_DIMS5) - ? dims_sign_dims5_url(url, key, prefix, &out) - : dims_sign_dims4_url(url, key, prefix, &out); + dims_sign_status status; + + if (eurl) { + status = (which == ENDPOINT_DIMS5) + ? dims_sign_dims5_eurl_url(url, key, prefix, &out) + : dims_sign_dims4_eurl_url(url, key, prefix, cipher, &out); + } else { + status = (which == ENDPOINT_DIMS5) + ? dims_sign_dims5_url(url, key, prefix, &out) + : dims_sign_dims4_url(url, key, prefix, &out); + } if (status != DIMS_SIGN_OK) { return report(status); @@ -349,7 +363,10 @@ typedef struct { char *endpoint; char *prefix; char *key; + char *cipher; char *input; + char *plain; + char *error; } record; static void @@ -359,7 +376,10 @@ release_record(record *r) free(r->endpoint); free(r->prefix); free(r->key); + free(r->cipher); free(r->input); + free(r->plain); + free(r->error); memset(r, 0, sizeof(*r)); } @@ -439,6 +459,24 @@ write_record(const record *r) return EXIT_USAGE; } + /* An eurl record holds a ciphertext with a fresh nonce, so there is + * nothing here to recompute. Its fields pass through. */ + if (strcmp(r->endpoint, "eurl") == 0) { + write_field("case", r->name); + write_field("endpoint", r->endpoint); + write_field("cipher", (r->cipher != NULL) ? r->cipher : "gcm"); + write_field("key", r->key); + write_field("input", r->input); + + if (r->plain != NULL) { + write_field("plain", r->plain); + } else if (r->error != NULL) { + write_field("error", r->error); + } + + return EXIT_OK; + } + is_dims5 = (strcmp(r->endpoint, "dims5") == 0); if (!is_dims5 && strcmp(r->endpoint, "dims4") != 0) { fprintf(stderr, "dims-sign: %s: unknown endpoint %s\n", r->name, @@ -507,11 +545,22 @@ set_field(record *r, const char *name, char *value) } else if (strcmp(name, "key") == 0) { free(r->key); r->key = value; + } else if (strcmp(name, "cipher") == 0) { + free(r->cipher); + r->cipher = value; } else if (strcmp(name, "input") == 0) { free(r->input); r->input = value; + } else if (strcmp(name, "plain") == 0) { + free(r->plain); + r->plain = value; + } else if (strcmp(name, "error") == 0) { + /* An eurl record states its own error. A signing record gets one + * from the library below. */ + free(r->error); + r->error = value; } else if (strcmp(name, "signed") == 0 || strcmp(name, "query") == 0 || - strcmp(name, "message") == 0 || strcmp(name, "error") == 0) { + strcmp(name, "message") == 0) { /* The library writes these. */ free(value); } else { @@ -599,6 +648,9 @@ main(int argc, char **argv) const char *prefix = NULL; const char *url = NULL; char *key = NULL; + dims_sign_cipher cipher = DIMS_SIGN_EURL_GCM; + int chose_cipher = 0; + int eurl = 0; int result; int i; @@ -615,6 +667,21 @@ main(int argc, char **argv) how = MODE_VERIFY; } else if (strcmp(arg, "--fixture") == 0) { how = MODE_FIXTURE; + } else if (strcmp(arg, "--eurl") == 0) { + eurl = 1; + } else if (strcmp(arg, "--cipher") == 0 && i + 1 < argc) { + const char *name = argv[++i]; + + if (strcmp(name, "gcm") == 0) { + cipher = DIMS_SIGN_EURL_GCM; + } else if (strcmp(name, "ecb") == 0) { + cipher = DIMS_SIGN_EURL_ECB; + } else { + fprintf(stderr, "dims-sign: --cipher takes gcm or ecb\n"); + return EXIT_USAGE; + } + + chose_cipher = 1; } else if (strcmp(arg, "--key-file") == 0 && i + 1 < argc) { key_file = argv[++i]; } else if (strcmp(arg, "--prefix") == 0 && i + 1 < argc) { @@ -675,8 +742,15 @@ main(int argc, char **argv) strcpy(key, from_environment); } - result = (how == MODE_VERIFY) ? run_verify(which, url, key, prefix) - : run_sign(which, url, key, prefix); + /* /dims5/ reads one scheme. /dims4/ reads what the server is configured + * for, and its default is ECB. */ + if (!chose_cipher && which == ENDPOINT_DIMS4) { + cipher = DIMS_SIGN_EURL_ECB; + } + + result = (how == MODE_VERIFY) + ? run_verify(which, url, key, prefix) + : run_sign(which, url, key, prefix, eurl, cipher); free(key); diff --git a/sign/src/eurl.c b/sign/src/eurl.c new file mode 100644 index 0000000..b0bd389 --- /dev/null +++ b/sign/src/eurl.c @@ -0,0 +1,405 @@ +/* + * The eurl parameter: an image URL encrypted under a key derived from the + * secret. + * + * The module decrypts in src/encryption.c. These are the same rules in the + * other direction, plus a decrypt so a caller can read back what it wrote. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "dims_sign.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/* The salt the key derivation uses. Changing it invalidates every eurl. */ +static const unsigned char kdf_salt[] = "go-dims"; + +dims_sign_status +dims_sign_derive_key(const char *secret, unsigned char key[DIMS_SIGN_KEY_BYTES]) +{ + static const char hex[] = "0123456789ABCDEF"; + EVP_KDF *kdf; + EVP_KDF_CTX *context; + OSSL_PARAM params[5]; + OSSL_PARAM *at = params; + int ok; + + if (secret == NULL || *secret == '\0' || key == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + /* SHA-1 of the secret, hex encoded, the first sixteen characters + * uppercased. That is 64 bits of material spread across 16 bytes. */ + if (strncmp(secret, "sha1:", 5) == 0) { + unsigned char digest[SHA_DIGEST_LENGTH]; + int i; + + secret += 5; + if (SHA1((const unsigned char *) secret, strlen(secret), digest) == NULL) { + return DIMS_SIGN_CRYPTO; + } + + for (i = 0; i < DIMS_SIGN_KEY_BYTES; i++) { + key[i] = (unsigned char) ((i % 2 == 0) + ? hex[digest[i / 2] >> 4] + : hex[digest[i / 2] & 0x0F]); + } + + return DIMS_SIGN_OK; + } + + if (strncmp(secret, "hkdf:", 5) == 0) { + secret += 5; + } + + kdf = EVP_KDF_fetch(NULL, "HKDF", NULL); + if (kdf == NULL) { + return DIMS_SIGN_CRYPTO; + } + + context = EVP_KDF_CTX_new(kdf); + EVP_KDF_free(kdf); + if (context == NULL) { + return DIMS_SIGN_CRYPTO; + } + + *at++ = OSSL_PARAM_construct_utf8_string(OSSL_KDF_PARAM_DIGEST, + (char *) "SHA256", 0); + *at++ = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_KEY, + (void *) (uintptr_t) secret, strlen(secret)); + *at++ = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT, + (void *) (uintptr_t) kdf_salt, sizeof(kdf_salt) - 1); + *at++ = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_INFO, + (void *) (uintptr_t) "", 0); + *at = OSSL_PARAM_construct_end(); + + ok = EVP_KDF_derive(context, key, DIMS_SIGN_KEY_BYTES, params) > 0; + EVP_KDF_CTX_free(context); + + return ok ? DIMS_SIGN_OK : DIMS_SIGN_CRYPTO; +} + +/* -- base64 ------------------------------------------------------------- */ + +static char * +base64_encode(const unsigned char *bytes, int length) +{ + char *out = malloc((size_t) (length + 2) / 3 * 4 + 1); + int written; + + if (out == NULL) { + return NULL; + } + + written = EVP_EncodeBlock((unsigned char *) out, bytes, length); + if (written < 0) { + free(out); + return NULL; + } + + out[written] = '\0'; + + return out; +} + +/* Decodes base64 into a fresh buffer. Returns the byte count, or -1. */ +static int +base64_decode(const char *text, unsigned char **out) +{ + size_t length = strlen(text); + unsigned char *bytes; + int written; + size_t padding = 0; + + /* EVP_DecodeBlock writes three bytes for every four characters, so the + * buffer is sized from the input and the count is corrected for the + * padding the encoder added. */ + if (length == 0 || length % 4 != 0) { + return -1; + } + + bytes = malloc(length / 4 * 3 + 1); + if (bytes == NULL) { + return -1; + } + + written = EVP_DecodeBlock(bytes, (const unsigned char *) text, (int) length); + if (written < 0) { + free(bytes); + return -1; + } + + if (length >= 1 && text[length - 1] == '=') { + padding++; + } + if (length >= 2 && text[length - 2] == '=') { + padding++; + } + + written -= (int) padding; + if (written < 0) { + free(bytes); + return -1; + } + + *out = bytes; + + return written; +} + +/* -- Encrypt ------------------------------------------------------------ */ + +static dims_sign_status +encrypt_gcm(const char *image_url, const unsigned char *key, char **out) +{ + EVP_CIPHER_CTX *context; + unsigned char *buffer; + unsigned char *iv; + unsigned char *ciphertext; + unsigned char *tag; + int length = (int) strlen(image_url); + int total = DIMS_SIGN_GCM_IV_BYTES + length + DIMS_SIGN_GCM_TAG_BYTES; + int written = 0; + int final = 0; + + buffer = malloc((size_t) total); + if (buffer == NULL) { + return DIMS_SIGN_MEMORY; + } + + iv = buffer; + ciphertext = buffer + DIMS_SIGN_GCM_IV_BYTES; + tag = ciphertext + length; + + /* A fresh IV for every call. Two encryptions of one URL under one key + * must not produce one value. */ + if (RAND_bytes(iv, DIMS_SIGN_GCM_IV_BYTES) != 1) { + free(buffer); + return DIMS_SIGN_CRYPTO; + } + + context = EVP_CIPHER_CTX_new(); + if (context == NULL) { + free(buffer); + return DIMS_SIGN_CRYPTO; + } + + if (EVP_EncryptInit_ex(context, EVP_aes_128_gcm(), NULL, NULL, NULL) != 1 || + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, + DIMS_SIGN_GCM_IV_BYTES, NULL) != 1 || + EVP_EncryptInit_ex(context, NULL, NULL, key, iv) != 1 || + EVP_EncryptUpdate(context, ciphertext, &written, + (const unsigned char *) image_url, length) != 1 || + EVP_EncryptFinal_ex(context, ciphertext + written, &final) != 1 || + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_GET_TAG, + DIMS_SIGN_GCM_TAG_BYTES, tag) != 1) { + EVP_CIPHER_CTX_free(context); + free(buffer); + return DIMS_SIGN_CRYPTO; + } + + EVP_CIPHER_CTX_free(context); + + *out = base64_encode(buffer, total); + free(buffer); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +static dims_sign_status +encrypt_ecb(const char *image_url, const unsigned char *key, char **out) +{ + EVP_CIPHER_CTX *context; + unsigned char *buffer; + int length = (int) strlen(image_url); + int written = 0; + int final = 0; + + /* PKCS5 padding adds up to one whole block. */ + buffer = malloc((size_t) length + DIMS_SIGN_AES_BLOCK_BYTES); + if (buffer == NULL) { + return DIMS_SIGN_MEMORY; + } + + context = EVP_CIPHER_CTX_new(); + if (context == NULL) { + free(buffer); + return DIMS_SIGN_CRYPTO; + } + + if (EVP_EncryptInit_ex(context, EVP_aes_128_ecb(), NULL, key, NULL) != 1 || + EVP_EncryptUpdate(context, buffer, &written, + (const unsigned char *) image_url, length) != 1 || + EVP_EncryptFinal_ex(context, buffer + written, &final) != 1) { + EVP_CIPHER_CTX_free(context); + free(buffer); + return DIMS_SIGN_CRYPTO; + } + + EVP_CIPHER_CTX_free(context); + + *out = base64_encode(buffer, written + final); + free(buffer); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +dims_sign_status +dims_sign_eurl_encrypt(const char *image_url, + const unsigned char key[DIMS_SIGN_KEY_BYTES], + dims_sign_cipher cipher, char **out) +{ + if (out == NULL || image_url == NULL || key == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (cipher == DIMS_SIGN_EURL_ECB) { + return encrypt_ecb(image_url, key, out); + } + + return encrypt_gcm(image_url, key, out); +} + +/* -- Decrypt ------------------------------------------------------------ */ + +static dims_sign_status +decrypt_gcm(const unsigned char *bytes, int length, const unsigned char *key, + char **out) +{ + EVP_CIPHER_CTX *context; + const unsigned char *iv = bytes; + const unsigned char *ciphertext = bytes + DIMS_SIGN_GCM_IV_BYTES; + int ciphertext_length = + length - DIMS_SIGN_GCM_IV_BYTES - DIMS_SIGN_GCM_TAG_BYTES; + const unsigned char *tag = ciphertext + ciphertext_length; + char *plaintext; + int written = 0; + int final = 0; + + /* The value holds an IV, at least one byte of ciphertext, and a tag. */ + if (ciphertext_length <= 0) { + return DIMS_SIGN_BAD_EURL; + } + + context = EVP_CIPHER_CTX_new(); + if (context == NULL) { + return DIMS_SIGN_CRYPTO; + } + + plaintext = malloc((size_t) ciphertext_length + 1); + if (plaintext == NULL) { + EVP_CIPHER_CTX_free(context); + return DIMS_SIGN_MEMORY; + } + + if (EVP_DecryptInit_ex(context, EVP_aes_128_gcm(), NULL, NULL, NULL) != 1 || + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, + DIMS_SIGN_GCM_IV_BYTES, NULL) != 1 || + EVP_DecryptInit_ex(context, NULL, NULL, key, iv) != 1 || + EVP_DecryptUpdate(context, (unsigned char *) plaintext, &written, + ciphertext, ciphertext_length) != 1 || + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_TAG, + DIMS_SIGN_GCM_TAG_BYTES, (void *) (uintptr_t) tag) != 1) { + EVP_CIPHER_CTX_free(context); + free(plaintext); + return DIMS_SIGN_BAD_EURL; + } + + /* The tag check happens here. A value someone edited fails. */ + if (EVP_DecryptFinal_ex(context, (unsigned char *) plaintext + written, + &final) != 1) { + EVP_CIPHER_CTX_free(context); + free(plaintext); + return DIMS_SIGN_BAD_EURL; + } + + EVP_CIPHER_CTX_free(context); + + plaintext[written + final] = '\0'; + *out = plaintext; + + return DIMS_SIGN_OK; +} + +static dims_sign_status +decrypt_ecb(const unsigned char *bytes, int length, const unsigned char *key, + char **out) +{ + EVP_CIPHER_CTX *context; + char *plaintext; + int written = 0; + int final = 0; + + /* One AES block is the shortest thing this can decrypt. */ + if (length < DIMS_SIGN_AES_BLOCK_BYTES) { + return DIMS_SIGN_BAD_EURL; + } + + context = EVP_CIPHER_CTX_new(); + if (context == NULL) { + return DIMS_SIGN_CRYPTO; + } + + /* EVP_DecryptUpdate may write up to one block past the input length, and + * the terminator needs a byte of its own. */ + plaintext = malloc((size_t) length + DIMS_SIGN_AES_BLOCK_BYTES + 1); + if (plaintext == NULL) { + EVP_CIPHER_CTX_free(context); + return DIMS_SIGN_MEMORY; + } + + if (EVP_DecryptInit_ex(context, EVP_aes_128_ecb(), NULL, key, NULL) != 1 || + EVP_DecryptUpdate(context, (unsigned char *) plaintext, &written, + bytes, length) != 1 || + EVP_DecryptFinal_ex(context, (unsigned char *) plaintext + written, + &final) != 1) { + EVP_CIPHER_CTX_free(context); + free(plaintext); + return DIMS_SIGN_BAD_EURL; + } + + EVP_CIPHER_CTX_free(context); + + plaintext[written + final] = '\0'; + *out = plaintext; + + return DIMS_SIGN_OK; +} + +dims_sign_status +dims_sign_eurl_decrypt(const char *eurl, + const unsigned char key[DIMS_SIGN_KEY_BYTES], + dims_sign_cipher cipher, char **out) +{ + unsigned char *bytes = NULL; + int length; + dims_sign_status status; + + if (out == NULL || eurl == NULL || key == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + length = base64_decode(eurl, &bytes); + if (length < 0) { + return DIMS_SIGN_BAD_EURL; + } + + if (cipher == DIMS_SIGN_EURL_ECB) { + status = decrypt_ecb(bytes, length, key, out); + } else { + status = decrypt_gcm(bytes, length, key, out); + } + + free(bytes); + + return status; +} diff --git a/sign/src/sign.c b/sign/src/sign.c index f8817b9..c120042 100644 --- a/sign/src/sign.c +++ b/sign/src/sign.c @@ -1255,6 +1255,199 @@ dims_sign_dims4_message(const char *url, const char *secret, const char *prefix, return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; } +/* -- eurl in place of url ------------------------------------------------ */ + +/* + * Copies a signed URL with every url parameter replaced by one eurl. + * + * The eurl goes where the last url was, so the output holds the parameters in + * the order the input gave. Neither name is in the canonical query, so the + * signature still matches. + * + * escaped writes the value percent encoded, which /dims5/ needs and /dims4/ + * does not. + */ +static dims_sign_status +swap_url_for_eurl(const char *signed_url, const char *eurl, int escaped, + char **out) +{ + url_parts parts; + buffer rebuilt; + const char *at; + size_t last = 0; + size_t index = 0; + int written = 0; + char *value = NULL; + + split_url(signed_url, &parts); + if (parts.query == NULL) { + return DIMS_SIGN_BAD_URL; + } + + /* Which token holds the last url. */ + for (at = parts.query; *at != '\0'; index++) { + const char *end = strchr(at, '&'); + size_t length; + + if (end == NULL) { + end = at + strlen(at); + } + + length = (size_t) (end - at); + if (length > 4 && memcmp(at, "url=", 4) == 0) { + last = index; + } + + at = (*end == '\0') ? end : end + 1; + } + + if (escaped) { + dims_sign_status status = dims_sign_escape(eurl, &value); + + if (status != DIMS_SIGN_OK) { + return status; + } + } + + buffer_init(&rebuilt); + buffer_add_bytes(&rebuilt, signed_url, + (size_t) (parts.path - signed_url) + parts.path_length); + buffer_add_char(&rebuilt, '?'); + + index = 0; + for (at = parts.query; *at != '\0'; index++) { + const char *end = strchr(at, '&'); + size_t length; + int is_url; + + if (end == NULL) { + end = at + strlen(at); + } + + length = (size_t) (end - at); + is_url = (length > 4 && memcmp(at, "url=", 4) == 0); + + if (length > 0 && (!is_url || index == last)) { + if (written) { + buffer_add_char(&rebuilt, '&'); + } + + if (is_url) { + buffer_add(&rebuilt, "eurl="); + buffer_add(&rebuilt, escaped ? value : eurl); + } else { + buffer_add_bytes(&rebuilt, at, length); + } + + written = 1; + } + + at = (*end == '\0') ? end : end + 1; + } + + free(value); + + *out = buffer_take(&rebuilt); + + return (*out != NULL) ? DIMS_SIGN_OK : DIMS_SIGN_MEMORY; +} + +dims_sign_status +dims_sign_dims5_eurl_url(const char *url, const char *key, const char *prefix, + char **out) +{ + unsigned char aes[DIMS_SIGN_KEY_BYTES]; + url_parts parts; + char *signed_url = NULL; + char *image_url = NULL; + char *eurl = NULL; + dims_sign_status status; + + if (out == NULL || url == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + status = dims_sign_dims5_url(url, key, prefix, &signed_url); + if (status != DIMS_SIGN_OK) { + return status; + } + + split_url(url, &parts); + + status = read_image_url(parts.query, &image_url); + if (status == DIMS_SIGN_OK) { + status = dims_sign_derive_key(key, aes); + } + if (status == DIMS_SIGN_OK) { + status = dims_sign_eurl_encrypt(image_url, aes, DIMS_SIGN_EURL_GCM, + &eurl); + } + if (status == DIMS_SIGN_OK) { + status = swap_url_for_eurl(signed_url, eurl, 1, out); + } + + free(signed_url); + free(image_url); + free(eurl); + + return status; +} + +dims_sign_status +dims_sign_dims4_eurl_url(const char *url, const char *key, const char *prefix, + dims_sign_cipher cipher, char **out) +{ + unsigned char aes[DIMS_SIGN_KEY_BYTES]; + dims4_fields fields; + char *signed_url = NULL; + char *secret = NULL; + char *eurl = NULL; + dims_sign_status status; + + if (out == NULL || url == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (key == NULL || *key == '\0') { + return DIMS_SIGN_BAD_ARGUMENT; + } + + status = dims_sign_dims4_url(url, key, prefix, &signed_url); + if (status != DIMS_SIGN_OK) { + return status; + } + + status = read_dims4_fields(url, prefix, &fields); + if (status != DIMS_SIGN_OK) { + free(signed_url); + return status; + } + + /* This endpoint reads one derivation whatever the secret looks like. */ + secret = malloc(strlen(key) + 6); + if (secret == NULL) { + status = DIMS_SIGN_MEMORY; + } else { + memcpy(secret, "sha1:", 5); + memcpy(secret + 5, key, strlen(key) + 1); + status = dims_sign_derive_key(secret, aes); + } + + if (status == DIMS_SIGN_OK) { + status = dims_sign_eurl_encrypt(fields.image_url, aes, cipher, &eurl); + } + if (status == DIMS_SIGN_OK) { + status = swap_url_for_eurl(signed_url, eurl, 0, out); + } + + release_dims4_fields(&fields); + free(signed_url); + free(secret); + free(eurl); + + return status; +} + /* -- The rest ----------------------------------------------------------- */ const char * @@ -1273,6 +1466,8 @@ dims_sign_strerror(dims_sign_status status) return "a control character is in a signed field"; case DIMS_SIGN_CRYPTO: return "libcrypto refused"; + case DIMS_SIGN_BAD_EURL: + return "cannot read the eurl value"; } return "unknown"; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 945bb69..cfae646 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -38,7 +38,11 @@ add_executable(dims_test lib/signing.c lib/test.c) +# dims_test links the signing library for the eurl cases only. The library +# encrypts and the module decrypts, so the two sides meet across a request. +# lib/signing.c still signs with code the module does not compile. target_link_libraries(dims_test PRIVATE + moddims_sign PkgConfig::CURL PkgConfig::CRYPTO) diff --git a/test/endurance/sign.c b/test/endurance/sign.c index 0bfc4e5..5c872ea 100644 --- a/test/endurance/sign.c +++ b/test/endurance/sign.c @@ -1,8 +1,11 @@ /* * Signing and encrypting a soak request. * - * The signature comes from libmoddims_sign. The module compiles the same source. - * The encryption is here, because the library does not cover it. + * The signature, the key derivation, and the ECB encryption come from + * libmoddims_sign. The module compiles the same source. + * + * The GCM encryption stays here, because the IV comes from the run's seed and + * a seed reproduces a run. * * Copyright 2026 Jeremy Collins * SPDX-License-Identifier: Apache-2.0 @@ -13,18 +16,11 @@ #include #include -#include -#include -#include -#include #include #include #include -/* The salt src/encryption.c derives with. */ -static const unsigned char dims_kdf_salt[] = "go-dims"; - char * dims_escape(const char *value) { @@ -78,97 +74,25 @@ dims_escape_path(const char *value) return out; } -static char * -to_hex(const unsigned char *bytes, unsigned int length) -{ - static const char hex[] = "0123456789abcdef"; - char *out = malloc((size_t) length * 2 + 1); - unsigned int i; - - if (out == NULL) { - return NULL; - } - - for (i = 0; i < length; i++) { - out[i * 2] = hex[bytes[i] >> 4]; - out[i * 2 + 1] = hex[bytes[i] & 0x0F]; - } - out[length * 2] = '\0'; - - return out; -} int dims_key_hkdf(const char *secret, unsigned char key[16]) { - EVP_KDF *kdf; - EVP_KDF_CTX *context; - OSSL_PARAM params[5]; - OSSL_PARAM *at = params; - int ok; - - if (secret == NULL) { - return 0; - } - - if (strncmp(secret, "hkdf:", 5) == 0) { - secret += 5; - } - - kdf = EVP_KDF_fetch(NULL, "HKDF", NULL); - if (kdf == NULL) { - return 0; - } - - context = EVP_KDF_CTX_new(kdf); - EVP_KDF_free(kdf); - if (context == NULL) { - return 0; - } - - *at++ = OSSL_PARAM_construct_utf8_string(OSSL_KDF_PARAM_DIGEST, - (char *) "SHA256", 0); - *at++ = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_KEY, - (void *) (uintptr_t) secret, strlen(secret)); - *at++ = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT, - (void *) (uintptr_t) dims_kdf_salt, sizeof(dims_kdf_salt) - 1); - *at++ = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_INFO, - (void *) (uintptr_t) "", 0); - *at = OSSL_PARAM_construct_end(); - - ok = EVP_KDF_derive(context, key, 16, params) > 0; - EVP_KDF_CTX_free(context); - - return ok; + return dims_sign_derive_key(secret, key) == DIMS_SIGN_OK; } int dims_key_sha1(const char *secret, unsigned char key[16]) { - unsigned char digest[SHA_DIGEST_LENGTH]; - char *hex; - int i; + char prefixed[256]; - if (secret == NULL) { + if (secret == NULL || strlen(secret) + 6 > sizeof(prefixed)) { return 0; } - SHA1((const unsigned char *) secret, strlen(secret), digest); - - hex = to_hex(digest, SHA_DIGEST_LENGTH); - if (hex == NULL) { - return 0; - } + snprintf(prefixed, sizeof(prefixed), "sha1:%s", secret); - for (i = 0; i < 16; i++) { - char c = hex[i]; - - key[i] = (unsigned char) ((c >= 'a' && c <= 'z') ? c - 'a' + 'A' : c); - } - - free(hex); - - return 1; + return dims_sign_derive_key(prefixed, key) == DIMS_SIGN_OK; } /* Base64 without line breaks. The caller frees. */ @@ -249,38 +173,13 @@ dims_eurl_gcm(const unsigned char key[16], const char *url, dims_rng *rng) char * dims_eurl_ecb(const unsigned char key[16], const char *url) { - EVP_CIPHER_CTX *context; - unsigned char *buffer; - int url_length = (int) strlen(url); - int written = 0; - int final = 0; - char *encoded; - - /* PKCS5 padding adds up to one whole block. */ - buffer = malloc((size_t) url_length + 16); - if (buffer == NULL) { - return NULL; - } - - context = EVP_CIPHER_CTX_new(); - if (context == NULL) { - free(buffer); - return NULL; - } + char *out = NULL; - if (EVP_EncryptInit_ex(context, EVP_aes_128_ecb(), NULL, key, NULL) != 1 || - EVP_EncryptUpdate(context, buffer, &written, - (const unsigned char *) url, url_length) != 1 || - EVP_EncryptFinal_ex(context, buffer + written, &final) != 1) { - EVP_CIPHER_CTX_free(context); - free(buffer); + if (dims_sign_eurl_encrypt(url, key, DIMS_SIGN_EURL_ECB, &out) != + DIMS_SIGN_OK) { return NULL; } - EVP_CIPHER_CTX_free(context); - - encoded = base64(buffer, written + final); - free(buffer); - - return encoded; + return out; } + diff --git a/test/fixtures/signing.tsv b/test/fixtures/signing.tsv index d42d19e..4f9903f 100644 --- a/test/fixtures/signing.tsv +++ b/test/fixtures/signing.tsv @@ -334,3 +334,44 @@ input /img/TEST/xxxxxx/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080 signed /img/TEST/82ea1c/2147483647/resize/100x100/?url=http%3A%2F%2Forigin%3A8080%2Fgrid.png message 2147483647t3stk3yresize/100x100/http://origin:8080/grid.png + +# eurl records go the other way: the signer decrypts input under the key and +# asserts plain. A ciphertext holds a fresh nonce, so the file cannot pin one +# the signer produces. Each suite round trips its own encrypt through its own +# decrypt instead. +# +# cipher is gcm or ecb. The key names the derivation: a sha1: prefix is the +# older path, and anything else is HKDF-SHA256. /dims4/ reads the older path +# whatever the secret looks like. +# +# The eurl-dims5-gcm value came from an independent implementation of the +# scheme. The eurl-dims4-ecb value came from openssl enc -aes-128-ecb. The +# eurl-dims4-gcm value came from this library. + +case eurl-dims5-gcm +endpoint eurl +cipher gcm +key 0123456789abcdef0123456789abcdef +input AAECAwQFBgcICQoLGoQQhvOu+NISeOUF0pjRytnK48uKUhOlRjazksCwy7hyzMKL36y33578wA== +plain http://origin:8080/grid.png + +case eurl-dims4-ecb +endpoint eurl +cipher ecb +key sha1:t3stk3y +input FIsag5hi4ulrQYVM9cXO/IxFSF+7K/OHpQ3ZFaUuMgQ= +plain http://origin:8080/grid.png + +case eurl-dims4-gcm +endpoint eurl +cipher gcm +key sha1:t3stk3y +input tYqVj0xAcV2b2Mhlp3MvYhz8ViZGYFZkVSB4QsjarQ3Qzm4jBUz07tyd64OcjWFZOe+fxJUvEA== +plain http://origin:8080/grid.png + +case eurl-edited-tag +endpoint eurl +cipher gcm +key 0123456789abcdef0123456789abcdef +input AAECAwQFBgcICQoLGoQQXvOu+NISeOUF0pjRytnK48uKUhOlRjazksCwy7hyzMKL36y33578wA== +error bad-eurl diff --git a/test/http/test_signing.c b/test/http/test_signing.c index f8f602b..31a1a3c 100644 --- a/test/http/test_signing.c +++ b/test/http/test_signing.c @@ -14,6 +14,8 @@ */ #include "../lib/common.h" + +#include #include "../lib/fixtures.h" #include "../lib/prometheus.h" @@ -245,8 +247,9 @@ request_fixture(const dims_fixture *f, void *data) char url[DIMS_FIXTURE_FIELD_MAX + 256]; dims_response *response; - /* A record the signer refuses has no URL to send. */ - if (f->has_error) { + /* A record the signer refuses has no URL to send, and an eurl record + * holds a ciphertext rather than a URL. */ + if (f->has_error || dims_fixture_is_eurl(f)) { run->skipped++; return; } @@ -327,6 +330,79 @@ test_every_fixture_url_verifies(void) dims_response_free(after); } +/* -- eurl ---------------------------------------------------------------- */ +/* + * The library encrypts and the module decrypts. A change to the derivation, + * the salt, or the framing on either side shows up here as a refused request. + * + * The signature covers the plaintext image URL, so the server verifies the + * request after it decrypts. + */ + +static void +test_dims5_eurl_url_verifies(void) +{ + char *url = dims_fixture_url("grid.png"); + char plain[2048]; + char *encoded = dims_urlencode(url); + char *signed_url = NULL; + char full[4096]; + dims_response *response; + + snprintf(plain, sizeof(plain), "/dims5/resize/100x100/?url=%s", encoded); + + CHECK_INT(dims_sign_dims5_eurl_url(plain, DIMS_TEST_SIGNING_KEY, NULL, + &signed_url), + DIMS_SIGN_OK, "the library must build the URL"); + + CHECK(signed_url != NULL && strstr(signed_url, "eurl=") != NULL, + "the output holds eurl"); + CHECK(signed_url != NULL && strstr(signed_url, "&url=") == NULL, + "the output holds no url"); + + snprintf(full, sizeof(full), "%s%s", dims5_server(), signed_url); + response = dims_get_absolute(full); + + CHECK_INT(response->status, 200, "an encrypted source on /dims5/"); + + dims_response_free(response); + dims_sign_free(signed_url); + free(encoded); + free(url); +} + +/* + * The /dims4/ default is AES/ECB/PKCS5Padding, which is what + * test/conf/dims-test.conf leaves in place. The value travels undecoded, + * because the module reads that parameter as it appears in the query. + */ +static void +test_dims4_eurl_url_verifies(void) +{ + char *url = dims_fixture_url("grid.png"); + char plain[2048]; + char *encoded = dims_urlencode(url); + char *signed_url = NULL; + dims_response *response; + + snprintf(plain, sizeof(plain), + "/dims4/%s/xxxxxx/%s/resize/100x100/?url=%s", + DIMS_TEST_CLIENT, DIMS_TEST_EXPIRES, encoded); + + CHECK_INT(dims_sign_dims4_eurl_url(plain, DIMS_TEST_SECRET, NULL, + DIMS_SIGN_EURL_ECB, &signed_url), + DIMS_SIGN_OK, "the library must build the URL"); + + response = dims_get(signed_url); + + CHECK_INT(response->status, 200, "an encrypted source on /dims4/"); + + dims_response_free(response); + dims_sign_free(signed_url); + free(encoded); + free(url); +} + const dims_test dims_tests_signing[] = { { "TestSignedUrlValidates", test_signed_url_validates, NULL }, { "TestLegacySignatureMatchesModDims", test_legacy_signature_matches_mod_dims, NULL }, @@ -340,5 +416,7 @@ const dims_test dims_tests_signing[] = { "optimizeResize is never signed" }, { "TestNoQueryStringAnswers", test_no_query_string_answers, NULL }, { "TestEveryFixtureUrlVerifies", test_every_fixture_url_verifies, NULL }, + { "TestDims5EurlUrlVerifies", test_dims5_eurl_url_verifies, NULL }, + { "TestDims4EurlUrlVerifies", test_dims4_eurl_url_verifies, NULL }, DIMS_TEST_END }; diff --git a/test/lib/fixtures.c b/test/lib/fixtures.c index 9b75be0..9e3881d 100644 --- a/test/lib/fixtures.c +++ b/test/lib/fixtures.c @@ -58,6 +58,8 @@ set_field(dims_fixture *f, const char *name, const char *value) unescape(value, f->prefix, sizeof(f->prefix)); } else if (strcmp(name, "key") == 0) { unescape(value, f->key, sizeof(f->key)); + } else if (strcmp(name, "cipher") == 0) { + unescape(value, f->cipher, sizeof(f->cipher)); } else if (strcmp(name, "input") == 0) { unescape(value, f->input, sizeof(f->input)); } else if (strcmp(name, "signed") == 0) { @@ -69,6 +71,9 @@ set_field(dims_fixture *f, const char *name, const char *value) } else if (strcmp(name, "message") == 0) { unescape(value, f->message, sizeof(f->message)); f->has_message = 1; + } else if (strcmp(name, "plain") == 0) { + unescape(value, f->plain, sizeof(f->plain)); + f->has_plain = 1; } else if (strcmp(name, "error") == 0) { unescape(value, f->error, sizeof(f->error)); f->has_error = 1; @@ -149,6 +154,12 @@ dims_fixture_is_dims5(const dims_fixture *f) return strcmp(f->endpoint, "dims5") == 0; } +int +dims_fixture_is_eurl(const dims_fixture *f) +{ + return strcmp(f->endpoint, "eurl") == 0; +} + const char * dims_fixture_prefix(const dims_fixture *f) { diff --git a/test/lib/fixtures.h b/test/lib/fixtures.h index 04ddbd6..6528ecc 100644 --- a/test/lib/fixtures.h +++ b/test/lib/fixtures.h @@ -18,14 +18,17 @@ typedef struct { char endpoint[16]; char prefix[128]; char key[128]; + char cipher[8]; char input[DIMS_FIXTURE_FIELD_MAX]; char expected[DIMS_FIXTURE_FIELD_MAX]; char query[DIMS_FIXTURE_FIELD_MAX]; char message[DIMS_FIXTURE_FIELD_MAX]; + char plain[DIMS_FIXTURE_FIELD_MAX]; char error[32]; int has_expected; int has_query; int has_message; + int has_plain; int has_error; } dims_fixture; @@ -41,6 +44,12 @@ int dims_fixtures_read(void (*visit)(const dims_fixture *, void *), void *data); /* Whether the record is for the /dims5/ endpoint. */ int dims_fixture_is_dims5(const dims_fixture *f); +/* + * Whether the record decrypts an eurl value rather than signing a URL. Such a + * record has cipher, input, and either plain or error. + */ +int dims_fixture_is_eurl(const dims_fixture *f); + /* The prefix to pass to the signer. An empty prefix means the default. */ const char *dims_fixture_prefix(const dims_fixture *f); diff --git a/test/unit/test_fixtures.c b/test/unit/test_fixtures.c index 3080e1d..a008623 100644 --- a/test/unit/test_fixtures.c +++ b/test/unit/test_fixtures.c @@ -29,11 +29,64 @@ error_name(dims_sign_status status) return "bad-field"; case DIMS_SIGN_BAD_ARGUMENT: return "bad-argument"; + case DIMS_SIGN_BAD_EURL: + return "bad-eurl"; default: return "bad-url"; } } +static dims_sign_cipher +cipher_of(const dims_fixture *f) +{ + return (strcmp(f->cipher, "ecb") == 0) ? DIMS_SIGN_EURL_ECB + : DIMS_SIGN_EURL_GCM; +} + +/* + * An eurl record goes the other way: decrypt input under the key and compare + * it with plain. The suite then round trips its own encrypt through its own + * decrypt, because a fresh nonce means the file cannot pin a ciphertext. + */ +static void +check_eurl(const dims_fixture *f) +{ + unsigned char key[DIMS_SIGN_KEY_BYTES]; + char *plain = NULL; + char *again = NULL; + char *back = NULL; + dims_sign_status status; + + CHECK(f->cipher[0] != '\0', "%s: an eurl record needs a cipher", f->name); + CHECK_INT(dims_sign_derive_key(f->key, key), DIMS_SIGN_OK, f->name); + + status = dims_sign_eurl_decrypt(f->input, key, cipher_of(f), &plain); + + if (f->has_error) { + CHECK(status != DIMS_SIGN_OK, "%s: the value must be refused", f->name); + CHECK_STR(error_name(status), f->error, f->name); + dims_sign_free(plain); + return; + } + + if (status != DIMS_SIGN_OK) { + FAIL("%s: %s", f->name, dims_sign_strerror(status)); + return; + } + + CHECK_STR(plain, f->plain, f->name); + dims_sign_free(plain); + + CHECK_INT(dims_sign_eurl_encrypt(f->plain, key, cipher_of(f), &again), + DIMS_SIGN_OK, f->name); + CHECK_INT(dims_sign_eurl_decrypt(again, key, cipher_of(f), &back), + DIMS_SIGN_OK, f->name); + CHECK_STR(back, f->plain, f->name); + + dims_sign_free(again); + dims_sign_free(back); +} + static void check_signed(const dims_fixture *f) { @@ -115,6 +168,11 @@ check_record(const dims_fixture *f, void *data) CHECK(f->name[0] != '\0', "a record needs a case name"); CHECK(f->input[0] != '\0', "%s: a record needs an input", f->name); + if (dims_fixture_is_eurl(f)) { + check_eurl(f); + return; + } + check_signed(f); check_query(f); check_message(f); diff --git a/test/unit/test_sign.c b/test/unit/test_sign.c index 0f992ff..04ea93b 100644 --- a/test/unit/test_sign.c +++ b/test/unit/test_sign.c @@ -459,6 +459,157 @@ test_strerror(void) } } +/* -- eurl ---------------------------------------------------------------- */ + +/* + * The two derivations. The HKDF vector is checked against the value in + * test/compose.yaml, which an independent implementation produced, in + * test_fixtures.c. This checks the shape and the prefixes. + */ +static void +test_derive_key(void) +{ + unsigned char hkdf[DIMS_SIGN_KEY_BYTES]; + unsigned char prefixed[DIMS_SIGN_KEY_BYTES]; + unsigned char sha1[DIMS_SIGN_KEY_BYTES]; + + CHECK_INT(dims_sign_derive_key(TEST_KEY, hkdf), DIMS_SIGN_OK, "hkdf"); + CHECK_INT(dims_sign_derive_key("hkdf:" TEST_KEY, prefixed), DIMS_SIGN_OK, + "an hkdf prefix"); + CHECK(memcmp(hkdf, prefixed, sizeof(hkdf)) == 0, + "the hkdf prefix names the default"); + + /* SHA-1 of t3stk3y is f4fd45f7f87ca8d7..., and the key is the first + * sixteen characters of that, uppercased. */ + CHECK_INT(dims_sign_derive_key("sha1:" TEST_SECRET, sha1), DIMS_SIGN_OK, + "sha1"); + CHECK(memcmp(sha1, "F4FD45F7F87CA8D7", DIMS_SIGN_KEY_BYTES) == 0, + "the sha1 key"); + + CHECK(memcmp(hkdf, sha1, sizeof(hkdf)) != 0, + "the two derivations differ"); + + CHECK_INT(dims_sign_derive_key(NULL, hkdf), DIMS_SIGN_BAD_ARGUMENT, "no secret"); + CHECK_INT(dims_sign_derive_key("", hkdf), DIMS_SIGN_BAD_ARGUMENT, "an empty secret"); +} + +/* A fresh IV every call, and the tag rejects an edited value. */ +static void +test_eurl_gcm(void) +{ + unsigned char key[DIMS_SIGN_KEY_BYTES]; + char *first = NULL; + char *second = NULL; + char *plain = NULL; + + CHECK_INT(dims_sign_derive_key(TEST_KEY, key), DIMS_SIGN_OK, "derive"); + + CHECK_INT(dims_sign_eurl_encrypt("http://origin:8080/grid.png", key, + DIMS_SIGN_EURL_GCM, &first), + DIMS_SIGN_OK, "encrypt"); + CHECK_INT(dims_sign_eurl_encrypt("http://origin:8080/grid.png", key, + DIMS_SIGN_EURL_GCM, &second), + DIMS_SIGN_OK, "encrypt again"); + + CHECK(strcmp(first, second) != 0, "two calls produce two values"); + + CHECK_INT(dims_sign_eurl_decrypt(first, key, DIMS_SIGN_EURL_GCM, &plain), + DIMS_SIGN_OK, "decrypt"); + CHECK_STR(plain, "http://origin:8080/grid.png", "the round trip"); + + dims_sign_free(plain); + plain = NULL; + + /* A byte of the ciphertext, past the IV. */ + first[20] = (first[20] == 'A') ? 'B' : 'A'; + CHECK_INT(dims_sign_eurl_decrypt(first, key, DIMS_SIGN_EURL_GCM, &plain), + DIMS_SIGN_BAD_EURL, "an edited value"); + + dims_sign_free(first); + dims_sign_free(second); +} + +/* ECB has no IV, so one URL under one key produces one value. */ +static void +test_eurl_ecb(void) +{ + unsigned char key[DIMS_SIGN_KEY_BYTES]; + char *first = NULL; + char *second = NULL; + char *plain = NULL; + + CHECK_INT(dims_sign_derive_key("sha1:" TEST_SECRET, key), DIMS_SIGN_OK, + "derive"); + + CHECK_INT(dims_sign_eurl_encrypt("http://origin:8080/grid.png", key, + DIMS_SIGN_EURL_ECB, &first), + DIMS_SIGN_OK, "encrypt"); + CHECK_INT(dims_sign_eurl_encrypt("http://origin:8080/grid.png", key, + DIMS_SIGN_EURL_ECB, &second), + DIMS_SIGN_OK, "encrypt again"); + + CHECK_STR(first, second, "two calls produce one value"); + + CHECK_INT(dims_sign_eurl_decrypt(first, key, DIMS_SIGN_EURL_ECB, &plain), + DIMS_SIGN_OK, "decrypt"); + CHECK_STR(plain, "http://origin:8080/grid.png", "the round trip"); + + dims_sign_free(first); + dims_sign_free(second); + dims_sign_free(plain); +} + +/* A value the decoder cannot read. */ +static void +test_eurl_refuses_a_bad_value(void) +{ + unsigned char key[DIMS_SIGN_KEY_BYTES]; + char *out = untouched; + + CHECK_INT(dims_sign_derive_key(TEST_KEY, key), DIMS_SIGN_OK, "derive"); + + CHECK_INT(dims_sign_eurl_decrypt("!!!!", key, DIMS_SIGN_EURL_GCM, &out), + DIMS_SIGN_BAD_EURL, "not base64"); + CHECK_INT(dims_sign_eurl_decrypt("AAAA", key, DIMS_SIGN_EURL_GCM, &out), + DIMS_SIGN_BAD_EURL, "shorter than an IV and a tag"); + CHECK_INT(dims_sign_eurl_decrypt("AAAA", key, DIMS_SIGN_EURL_ECB, &out), + DIMS_SIGN_BAD_EURL, "shorter than one AES block"); + CHECK_INT(dims_sign_eurl_decrypt("", key, DIMS_SIGN_EURL_GCM, &out), + DIMS_SIGN_BAD_EURL, "an empty value"); + CHECK_INT(dims_sign_eurl_encrypt(NULL, key, DIMS_SIGN_EURL_GCM, &out), + DIMS_SIGN_BAD_ARGUMENT, "no URL"); + + CHECK(out == untouched, "a failure leaves the out parameter untouched"); +} + +/* The signed URL holds eurl in place of url, and the signature is the one the + * plain URL produces. */ +static void +test_eurl_url(void) +{ + char *plain_signed = NULL; + char *encrypted = NULL; + const char *input = "/dims5/resize/100x100/?url=" TEST_IMAGE; + const char *digest; + + CHECK_INT(dims_sign_dims5_url(input, TEST_KEY, NULL, &plain_signed), + DIMS_SIGN_OK, "sign"); + CHECK_INT(dims_sign_dims5_eurl_url(input, TEST_KEY, NULL, &encrypted), + DIMS_SIGN_OK, "sign and encrypt"); + + digest = strstr(plain_signed, "&sig="); + CHECK(digest != NULL, "the plain URL holds a signature"); + CHECK(digest != NULL && strstr(encrypted, digest) != NULL, + "the signature covers the plain image URL"); + + CHECK(strstr(encrypted, "eurl=") != NULL, "the output holds eurl"); + CHECK(strstr(encrypted, "&url=") == NULL && strncmp(encrypted, "/dims5/resize/100x100/?url=", 27) != 0, + "the output holds no url"); + + dims_sign_free(plain_signed); + dims_sign_free(encrypted); +} + const dims_test dims_tests_unit_sign[] = { { "TestSignEscape", test_escape, NULL }, { "TestSignCanonicalQueryOrdersByName", @@ -484,5 +635,10 @@ const dims_test dims_tests_unit_sign[] = { { "TestSignRefusesABadUrl", test_refuses_a_bad_url, NULL }, { "TestSignAllocationContract", test_allocation_contract, NULL }, { "TestSignStrerror", test_strerror, NULL }, + { "TestSignDeriveKey", test_derive_key, NULL }, + { "TestSignEurlGcm", test_eurl_gcm, NULL }, + { "TestSignEurlEcb", test_eurl_ecb, NULL }, + { "TestSignEurlRefusesABadValue", test_eurl_refuses_a_bad_value, NULL }, + { "TestSignEurlUrl", test_eurl_url, NULL }, DIMS_TEST_END };