diff --git a/CMakeLists.txt b/CMakeLists.txt index 902d167..ccfc6f9 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 @@ -121,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/docs/docs/clients/c.md b/docs/docs/clients/c.md new file mode 100644 index 0000000..d0b1f0c --- /dev/null +++ b/docs/docs/clients/c.md @@ -0,0 +1,142 @@ +# 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. + +## 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 | +|---|---| +| `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_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. + +## 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 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..91f088c --- /dev/null +++ b/docs/docs/clients/dims-sign.md @@ -0,0 +1,98 @@ +# 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] [--eurl] + [--cipher gcm|ecb] [--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 +``` + +## 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 +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..36e9dc2 --- /dev/null +++ b/docs/docs/clients/index.md @@ -0,0 +1,35 @@ +# 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 and the `eurl` ciphers | +| [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. + +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. + +## 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 new file mode 100644 index 0000000..7e678db --- /dev/null +++ b/sign/CMakeLists.txt @@ -0,0 +1,72 @@ +# 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 src/eurl.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() + +# 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) + +# -- 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/sign/include/dims_sign.h b/sign/include/dims_sign.h new file mode 100644 index 0000000..883e8b1 --- /dev/null +++ b/sign/include/dims_sign.h @@ -0,0 +1,312 @@ +/* + * 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/" + +/* 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 */ + 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_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); + +/* + * 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); + +/* + * 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. + * + * 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/dims_sign_cli.c b/sign/src/dims_sign_cli.c new file mode 100644 index 0000000..bacf37a --- /dev/null +++ b/sign/src/dims_sign_cli.c @@ -0,0 +1,758 @@ +/* + * 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" + " [--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" + " 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" + " --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" + " 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, + int eurl, dims_sign_cipher cipher) +{ + char *out = NULL; + 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); + } + + 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 *cipher; + char *input; + char *plain; + char *error; +} record; + +static void +release_record(record *r) +{ + free(r->name); + 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)); +} + +/* \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; + } + + /* 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, + 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, "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) { + /* 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; + dims_sign_cipher cipher = DIMS_SIGN_EURL_GCM; + int chose_cipher = 0; + int eurl = 0; + 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, "--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) { + 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); + } + + /* /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); + + 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/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 new file mode 100644 index 0000000..c120042 --- /dev/null +++ b/sign/src/sign.c @@ -0,0 +1,1480 @@ +/* + * The mod_dims signing rules. + * + * Copyright 2026 Jeremy Collins + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "dims_sign.h" +#include "dims_sign_internal.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; +} + +/* 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) +{ + buffer message; + size_t i; + + 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); + } + + 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; + } + + 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; +} + +/* 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; + const char *rest; + size_t rest_length; + char *at; + + memset(fields, 0, sizeof(*fields)); + + if (url == NULL) { + return DIMS_SIGN_BAD_ARGUMENT; + } + + if (prefix == NULL || *prefix == '\0') { + prefix = DIMS_SIGN_DIMS4_PREFIX; + } + + split_url(url, &fields->parts); + + status = after_prefix(&fields->parts, prefix, &rest, &rest_length); + if (status != DIMS_SIGN_OK) { + return status; + } + + status = split_dims4_path(rest, rest_length, &fields->path); + if (status != DIMS_SIGN_OK) { + return status; + } + + 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 = fields->commands; *at != '\0'; at++) { + if (*at == ' ') { + *at = '+'; + } + } + + status = read_image_url(fields->parts.query, &fields->image_url); + if (status != DIMS_SIGN_OK) { + 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 = fields->image_url; *at != '\0'; at++) { + if (*at == '+') { + *at = ' '; + } + } + + status = read_keys(fields->parts.query, &fields->keys, &fields->key_count); + if (status != DIMS_SIGN_OK) { + release_dims4_fields(fields); + return status; + } + + fields->expires = copy_range(fields->path.expires, + fields->path.expires_length); + if (fields->expires == NULL) { + release_dims4_fields(fields); + return DIMS_SIGN_MEMORY; + } + + 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) (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, fields.path.signature_length); + buffer_add_char(&signed_url, '/'); + buffer_add_bytes(&signed_url, fields.path.expires, + fields.path.expires_length); + buffer_add_char(&signed_url, '/'); + buffer_add_bytes(&signed_url, fields.path.commands, + fields.path.commands_length); + + if (fields.parts.query != NULL) { + buffer_add_char(&signed_url, '?'); + 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; +} + +/* -- 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 * +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"; + case DIMS_SIGN_BAD_EURL: + return "cannot read the eurl value"; + } + + return "unknown"; +} + +void +dims_sign_free(char *string) +{ + free(string); +} 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 fe6f9d7..cfae646 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 @@ -37,11 +38,17 @@ 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) -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) @@ -109,11 +116,13 @@ add_executable(dims_unit_test unit/test_netguard.c unit/test_overlay_cache.c unit/test_param.c - unit/test_signature.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,9 +153,11 @@ 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 PkgConfig::APR PkgConfig::APRUTIL PkgConfig::CURL @@ -154,7 +165,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/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..5c872ea 100644 --- a/test/endurance/sign.c +++ b/test/endurance/sign.c @@ -1,9 +1,11 @@ /* * 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, 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 @@ -11,29 +13,34 @@ #include "soak.h" +#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"; - -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 +58,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,147 +74,25 @@ 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) -{ - 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; -} - -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]) { - 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; - } - - for (i = 0; i < 16; i++) { - char c = hex[i]; - - key[i] = (unsigned char) ((c >= 'a' && c <= 'z') ? c - 'a' + 'A' : c); - } - - free(hex); + snprintf(prefixed, sizeof(prefixed), "sha1:%s", secret); - return 1; + return dims_sign_derive_key(prefixed, key) == DIMS_SIGN_OK; } /* Base64 without line breaks. The caller frees. */ @@ -288,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/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]); diff --git a/test/fixtures/signing.tsv b/test/fixtures/signing.tsv new file mode 100644 index 0000000..4f9903f --- /dev/null +++ b/test/fixtures/signing.tsv @@ -0,0 +1,377 @@ +# 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 + + +# 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 0e051ad..31a1a3c 100644 --- a/test/http/test_signing.c +++ b/test/http/test_signing.c @@ -15,6 +15,10 @@ #include "../lib/common.h" +#include +#include "../lib/fixtures.h" +#include "../lib/prometheus.h" + #define COMMANDS "resize/100x100" #define SIGNED_COMMANDS "resize/100x100/" @@ -204,6 +208,201 @@ 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, and an eurl record + * holds a ciphertext rather than a URL. */ + if (f->has_error || dims_fixture_is_eurl(f)) { + 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); +} + +/* -- 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 }, @@ -216,5 +415,8 @@ 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 }, + { "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 new file mode 100644 index 0000000..9e3881d --- /dev/null +++ b/test/lib/fixtures.c @@ -0,0 +1,167 @@ +/* + * 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, "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) { + 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, "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; + } 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; +} + +int +dims_fixture_is_eurl(const dims_fixture *f) +{ + return strcmp(f->endpoint, "eurl") == 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..6528ecc --- /dev/null +++ b/test/lib/fixtures.h @@ -0,0 +1,56 @@ +/* + * 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 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; + +/* 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); + +/* + * 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); + +#endif diff --git a/test/unit/main.c b/test/unit/main.c index a9f23c6..3c7a79f 100644 --- a/test/unit/main.c +++ b/test/unit/main.c @@ -25,7 +25,8 @@ 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_signature[]; +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[]; @@ -42,7 +43,8 @@ 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_signature.c", dims_tests_unit_signature }, + { "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..a008623 --- /dev/null +++ b/test/unit/test_fixtures.c @@ -0,0 +1,246 @@ +/* + * 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"; + 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) +{ + 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); + + if (dims_fixture_is_eurl(f)) { + check_eurl(f); + return; + } + + 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 +}; diff --git a/test/unit/test_sign.c b/test/unit/test_sign.c new file mode 100644 index 0000000..04ea93b --- /dev/null +++ b/test/unit/test_sign.c @@ -0,0 +1,644 @@ +/* + * 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]); + } +} + +/* -- 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", + 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 }, + { "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 +}; 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 -};