diff --git a/modules/topology_hiding/CALLID_CODEC.md b/modules/topology_hiding/CALLID_CODEC.md new file mode 100644 index 00000000000..b0b27d8c8bf --- /dev/null +++ b/modules/topology_hiding/CALLID_CODEC.md @@ -0,0 +1,99 @@ +# Call-ID codec wire format + +This document fixes the ordering and interpretation of the symbols used by +`th_callid_encode_scheme`. These details are part of the wire format and must +not be changed for an existing scheme name. + +## `xor-word64` + +The wire value is: + +```text +th_callid_prefix || word64(call_id XOR repeated(th_callid_passwd)) +``` + +This is the historical OpenSIPS representation and is byte-identical to +OpenSIPS 3.6 releases which predate the scheme selector. + +## `ff1-alnum62` + +The wire value is: + +```text +th_callid_prefix || marker || payload +``` + +The configured prefix must contain only ASCII alphanumeric characters. The +one-byte, cleartext marker identifies version 1 and the payload domain: + +| Marker | Meaning | +|---|---| +| `A` | The input contained only radix-62 symbols and was encrypted directly. | +| `B` | The input was ranked in the RFC 3261 Call-ID language, converted to radix 62 and encrypted. | +| `C` | The input used the legacy XOR/word64 fallback. | + +The radix-62 alphabet, in numerical order, is: + +```text +ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 +``` + +FF1 uses AES-256, ten rounds, an empty tweak and the forward AES +transformation, following NIST SP 800-38G Revision 1 second public draft. All +length and radix calculations use integer arithmetic. Inputs to FF1 must have +at least four radix-62 digits, giving a domain larger than one million. + +The AES key is the first and only 32-byte HKDF-SHA-256 output block, with: + +```text +IKM = the exact th_callid_passwd bytes +salt = "OpenSIPS topology_hiding FF1 key v1" +info = "Call-ID ff1-alnum62" +``` + +### Structured Call-ID ranking + +RFC 3261 defines `callid = word [ "@" word ]`. The `@` is structural rather +than an unrestricted alphabet member: it may occur at most once and may not be +the first or last character. + +The ordered radix-85 `word` alphabet is: + +```text +ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.!%*_+`'~()<>:\"/[]?{} +``` + +For a Call-ID of total length `n`, values without `@` occupy the first `85^n` +ranks. Values with `@` follow, grouped by the separator's zero-based position +`p`, where `1 <= p <= n-2`. After removing `@`, the remaining symbols form the +radix-85 number `W`: + +```text +rank(no @) = W +rank(@) = 85^n + (p - 1) * 85^(n - 1) + W +domain(n) = 85^n + max(n - 2, 0) * 85^(n - 1) +``` + +The rank is represented by the shortest fixed-width radix-62 string capable of +representing `domain(n)`. Leading zero digits are retained. Since the encoded +width grows strictly with `n`, the decoder recovers `n` from that width, then +unranks the value and restores `@` in its original position. + +### Bounds and fallback + +FF1 processing is bounded at 4096 plaintext characters. Native radix-62 +ciphertexts have the same bound, while structured ciphertexts may contain up +to 4411 payload characters due to conversion from the larger radix-85 domain. +Longer, shorter-than-domain, or visible non-compliant inputs use marker `C` +and the historical XOR/word64 codec. Control characters are rejected rather +than passed through the fallback. A decoder rejects unknown markers, +non-radix-62 ciphertext, invalid structured ranks, unsafe fallback plaintext +and malformed word64 padding. + +When `th_callid_loop_protection` is enabled, the internal loop tag separator +is outside the RFC Call-ID alphabet. Such values deliberately use marker `C` +and the legacy fallback, so this combination does not receive the FF1 length +reduction. + +The format provides confidentiality but not integrity. Scheme, password or +prefix changes require active dialogs to be drained. diff --git a/modules/topology_hiding/CONTACT_GROWTH.md b/modules/topology_hiding/CONTACT_GROWTH.md new file mode 100644 index 00000000000..fccc2823f15 --- /dev/null +++ b/modules/topology_hiding/CONTACT_GROWTH.md @@ -0,0 +1,46 @@ +# No-dialog Contact growth finding + +The OpenSIPS 3.6 no-dialog topology-hiding path has the same nested growth +pattern as the historical Call-ID codec, but its payload is genuinely binary +and requires a separate wire-format design. + +`build_encoded_contact_suffix()` serializes four native `short` lengths, the +route set, the complete incoming Contact URI, flags and the receiving socket. +It XORs that binary record and encodes it as word64 or word32. On a chain, the +incoming Contact URI already contains the previous hop's `thinfo` value, so +each new binary record contains the complete prior encoded record. + +The module unit test reproduces five no-dialog layers using an initial +25-character Contact URI, no Record-Route set, flags `0`, the default `thinfo` +parameter and a 14-character socket string. The URI lengths are: + +| Layer | 0 | 1 | 2 | 3 | 4 | 5 | +|---|---:|---:|---:|---:|---:|---:|---:| +| Contact URI length | 25 | 90 | 178 | 294 | 450 | 658 | + +Even this minimal case exceeds 255 characters at the third topology-hiding +hop. Route sets and longer advertised sockets make it grow sooner. + +## Scope of a separate fix + +A Call-ID radix codec cannot be reused because the Contact record begins with +binary length fields and includes flags and socket data. A separate design +should version the Contact wire format and make the chain a list of independently +encoded per-hop segments: + +1. Preserve an already-versioned upstream segment list instead of embedding it + inside the next hop's binary plaintext. +2. Encode only the current hop's route set, previous Contact base URI, flags and + bind address as a new segment. +3. Append the new segment with an unambiguous URI-safe separator, allowing the + reverse path to pop exactly one local segment. +4. Retain the current nested representation as the default and as fallback for + stock/malformed input. +5. Specify bounds and validate all decoded lengths before pointer advancement; + avoid native-endian `short` fields in the versioned format. +6. Test mixed old/new chains, route sets, passed URI/header parameters, both + base encodings, requests and replies, and lengths through at least ten hops. + +This work should be developed and reviewed independently from the Call-ID +change because it alters the no-dialog state envelope rather than a single +opaque string codec. diff --git a/modules/topology_hiding/Makefile b/modules/topology_hiding/Makefile index 1863e2f4d89..8fa2ff6b5d3 100644 --- a/modules/topology_hiding/Makefile +++ b/modules/topology_hiding/Makefile @@ -3,5 +3,6 @@ include ../../Makefile.defs auto_gen= NAME=topology_hiding.so +LIBS+=-lcrypto include ../../Makefile.modules diff --git a/modules/topology_hiding/README.md b/modules/topology_hiding/README.md index 98fd16a4444..5371f9f6a91 100644 --- a/modules/topology_hiding/README.md +++ b/modules/topology_hiding/README.md @@ -36,7 +36,8 @@ The following libraries or applications must be installed before running OpenSIPS with this module loaded: -- *None* +- *OpenSSL libcrypto*. It provides the AES primitive used by the optional + `ff1-alnum62` Call-ID encoding scheme. ### Exported Parameters @@ -47,6 +48,12 @@ OpenSIPS with this module loaded: The string password that will be used for encoding/decoding the callid in case of topology_hiding with callid mangling. +When `th_callid_encode_scheme` is `ff1-alnum62`, this value is converted to an +AES-256 key using HKDF-SHA-256 with a fixed, versioned, module-specific context. +The default password is rejected for that scheme. Use a high-entropy secret of +at least 32 characters. Changing the password while dialogs are active prevents +those dialogs' masked Call-IDs from being decoded. + *Default value is ""OpenSIPS""* @@ -63,6 +70,11 @@ modparam("topology_hiding", "th_callid_passwd", "my_topo_hiding_secret") The prefix that will be used for detecting callids which have been encoded by the dialog topology hiding. Make sure to change this value in case your SIP path contains multiple OpenSIPS boxes with topology hiding. +`ff1-alnum62` requires a non-empty prefix containing only ASCII letters and +digits. The prefix remains visible and is included in the input masked by the +next topology-hiding hop, so punctuation in it would defeat the alphanumeric +wire format. + *Default value is ""DLGCH_""* @@ -74,6 +86,54 @@ modparam("topology_hiding", "th_callid_prefix", "MYCALLIDPREFIX_") ``` +#### th_callid_encode_scheme (string) + + +Selects the codec used to mask Call-IDs. Possible values are: + + +- *xor-word64* - the historical repeating-key XOR and OpenSIPS `word64` + encoding. This is the default and remains wire-compatible with previous + releases. +- *ff1-alnum62* - AES-256 FF1 over an alphanumeric alphabet. A compliant SIP + Call-ID containing punctuation or `@` is first mapped reversibly into radix + 62. The first hop may grow by approximately 8%; subsequent upgraded hops add + only their prefix and one format-marker character. + + +The FF1 wire marker distinguishes native radix-62 input, structured SIP `word` +input and the legacy fallback. Input which is not compliant with the SIP +Call-ID grammar, is too short for FF1, or is above the FF1 processing bound is +encoded using the legacy codec for that layer. This preserves interoperability +without allowing malformed input into the format-preserving cipher. Visible +non-compliant characters are supported by the fallback, while control +characters are rejected to prevent unsafe reconstructed SIP headers. FF1 +plaintext is limited to 4096 characters; structured radix-85 input may produce +up to 4411 radix-62 payload characters and remains decodable at that boundary. + +When `th_callid_loop_protection` is enabled, its internal separator is outside +the RFC Call-ID alphabet, so the combined Call-ID and loop tag deliberately use +the legacy fallback. Loop protection remains functional, but does not receive +the FF1 length reduction. + +The FF1 construction does not authenticate the Call-ID. A wrong password may +therefore decode to plausible text. Drain active dialogs before changing the +scheme, password or prefix; decoding with previous settings is not attempted. + + +*Default value is "xor-word64"* + + +```opensips title="Enable radix-preserving Call-ID masking" +... +modparam("topology_hiding", "th_callid_encode_scheme", "ff1-alnum62") +modparam("topology_hiding", "th_callid_prefix", "TH1") +modparam("topology_hiding", "th_callid_passwd", + "replace-with-at-least-32-random-characters") +... +``` + + #### th_passed_contact_uri_params (string) @@ -455,4 +515,4 @@ NULL will be returned if there is no topology hiding dialog for the request or i ### License -All documentation files (i.e. .md extension) are licensed under the Creative Common License 4.0 \ No newline at end of file +All documentation files (i.e. .md extension) are licensed under the Creative Common License 4.0 diff --git a/modules/topology_hiding/test/opensips.cfg b/modules/topology_hiding/test/opensips.cfg new file mode 100644 index 00000000000..8fc29c3347c --- /dev/null +++ b/modules/topology_hiding/test/opensips.cfg @@ -0,0 +1,24 @@ +log_level = 2 +stderror_enabled = yes +syslog_enabled = no + +udp_workers = 1 +auto_aliases = no +enable_asserts = true +abort_on_assert = true + +socket = udp:localhost:5059 + +mpath = "modules/" + +loadmodule "proto_udp.so" +loadmodule "tm.so" +loadmodule "topology_hiding.so" +modparam("topology_hiding", "th_callid_encode_scheme", "ff1-alnum62") +modparam("topology_hiding", "th_callid_prefix", "TH") +modparam("topology_hiding", "th_callid_passwd", + "0123456789abcdef0123456789abcdef") + +route { + exit; +} diff --git a/modules/topology_hiding/test/test.c b/modules/topology_hiding/test/test.c new file mode 100644 index 00000000000..a5066517f8a --- /dev/null +++ b/modules/topology_hiding/test/test.c @@ -0,0 +1,395 @@ +/* + * Unit tests for topology_hiding Call-ID masking. + * + * Copyright (C) 2026 OpenSIPS Project + */ + +#include +#include + +#include "../../../mem/mem.h" +#include "../../../parser/msg_parser.h" +#include "../topo_hiding_codec.h" +#include "../topo_hiding_logic.h" + +static int only_alnum(const str *value) +{ + int i; + + for (i = 0; i < value->len; i++) + if (!((value->s[i] >= 'A' && value->s[i] <= 'Z') || + (value->s[i] >= 'a' && value->s[i] <= 'z') || + (value->s[i] >= '0' && value->s[i] <= '9'))) + return 0; + return 1; +} + +static int contains_text(const str *value, const char *text) +{ + int text_len = strlen(text); + int i; + + for (i = 0; i <= value->len - text_len; i++) + if (!memcmp(value->s + i, text, text_len)) + return 1; + return 0; +} + +static void test_nist_ff1_vector(void) +{ + static const unsigned char key[] = { + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, + 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c + }; + static const unsigned char plain[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + static const unsigned char cipher[] = {2, 4, 3, 3, 4, 7, 7, 4, 8, 4}; + unsigned char result[sizeof(plain)]; + + ok(th_callid_codec_test_ff1(key, 128, plain, sizeof(plain), 10, + result, 0) == 0 && !memcmp(result, cipher, sizeof(cipher)), + "FF1 matches NIST sample #1 encryption"); + ok(th_callid_codec_test_ff1(key, 128, cipher, sizeof(cipher), 10, + result, 1) == 0 && !memcmp(result, plain, sizeof(plain)), + "FF1 matches NIST sample #1 decryption"); +} + +static void test_codec_round_trips(void) +{ + static const char ff1_wire_fixture[] = "THAoUf1xHrYxj4QPqHP"; + str prefix = str_init("TH"); + str password = str_init("0123456789abcdef0123456789abcdef"); + str cases[] = { + str_init("0123456789abcdef"), + str_init("f81d4fae-7dec-11d0-a765-00a0c91e6bf6@biloxi.com"), + str_init("A!%*_+`'~()<>:\\\"/[]?{}@z"), + str_init("abc"), + str_init("a"), + str_init("non-compliant#call-id"), + }; + str encoded = STR_NULL, decoded = STR_NULL; + int i; + + ok(th_callid_codec_init(TH_CALLID_ENC_FF1_ALNUM62, + &password, &prefix) == 0, "initialize ff1-alnum62 codec"); + for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + ok(th_callid_codec_encode(&cases[i], &encoded) == 0, + "encode Call-ID case %d", i); + if (i == 0) + ok(encoded.s[prefix.len] == 'A', "select native radix-62 mode"); + else if (i == 1 || i == 2 || i == 3) + ok(encoded.s[prefix.len] == 'B', "select structured word mode %d", i); + else + ok(encoded.s[prefix.len] == 'C', "select legacy fallback mode %d", i); + ok(th_callid_codec_is_encoded(&encoded), + "recognize encoded Call-ID case %d", i); + if (i == 0) + ok(encoded.len == sizeof(ff1_wire_fixture) - 1 && + !memcmp(encoded.s, ff1_wire_fixture, + sizeof(ff1_wire_fixture) - 1), + "FF1 KDF and wire representation remain stable"); + ok(th_callid_codec_decode(&encoded, &decoded) == 0 && + decoded.len == cases[i].len && + !memcmp(decoded.s, cases[i].s, cases[i].len), + "round-trip Call-ID case %d", i); + pkg_free(encoded.s); + pkg_free(decoded.s); + encoded = decoded = STR_NULL; + } + + encoded = str_init("THZnot-a-codec-payload"); + ok(!th_callid_codec_is_encoded(&encoded), "reject an unknown wire marker"); + encoded = str_init("THCA"); + ok(th_callid_codec_decode(&encoded, &decoded) < 0, + "reject a truncated legacy fallback payload"); +} + +static void test_word_domain_lengths(void) +{ + static const char symbols[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.!%*_+`'~()<>:\\\"/[]?{}"; + str prefix = str_init("TH"); + str password = str_init("0123456789abcdef0123456789abcdef"); + char input_buf[256]; + str input, encoded = STR_NULL, decoded = STR_NULL; + int len, i, ok_all = 1; + + if (th_callid_codec_init(TH_CALLID_ENC_FF1_ALNUM62, + &password, &prefix) < 0) { + ok(0, "initialize exhaustive word-domain codec"); + return; + } + for (len = 1; len <= sizeof(input_buf); len++) { + for (i = 0; i < len; i++) + input_buf[i] = symbols[(i * 37 + len) % 85]; + if (len >= 3) + input_buf[len / 2] = '@'; + input.s = input_buf; + input.len = len; + if (th_callid_codec_encode(&input, &encoded) < 0 || + th_callid_codec_decode(&encoded, &decoded) < 0 || + decoded.len != input.len || + memcmp(decoded.s, input.s, input.len)) { + ok_all = 0; + if (encoded.s) pkg_free(encoded.s); + if (decoded.s) pkg_free(decoded.s); + break; + } + pkg_free(encoded.s); + pkg_free(decoded.s); + encoded = decoded = STR_NULL; + } + ok(ok_all, "round-trip structured Call-IDs at every length from 1 to 256"); +} + +static void test_chain_growth(void) +{ + str prefix = str_init("TH"); + str password = str_init("0123456789abcdef0123456789abcdef"); + str original = str_init("f81d4fae-7dec-11d0-a765@biloxi.com"); + str current = original, next = STR_NULL; + int first_len = 0, i; + + ok(th_callid_codec_init(TH_CALLID_ENC_FF1_ALNUM62, + &password, &prefix) == 0, "initialize chain codec"); + for (i = 0; i < 10; i++) { + ok(th_callid_codec_encode(¤t, &next) == 0, + "encode chain layer %d", i + 1); + if (i == 0) + first_len = next.len; + else + ok(next.len == first_len + i * (prefix.len + 1), + "chain layer %d grows additively", i + 1); + ok(only_alnum(&next), "chain layer %d is alphanumeric", i + 1); + if (current.s != original.s) + pkg_free(current.s); + current = next; + next = STR_NULL; + } + + for (i = 0; i < 10; i++) { + ok(th_callid_codec_decode(¤t, &next) == 0, + "decode chain layer %d", 10 - i); + pkg_free(current.s); + current = next; + next = STR_NULL; + } + ok(current.len == original.len && + !memcmp(current.s, original.s, original.len), + "ten-layer chain restores the original Call-ID"); + pkg_free(current.s); +} + +static void test_malformed_payloads(void) +{ + str prefix = str_init("TH"); + str password = str_init("0123456789abcdef0123456789abcdef"); + char buffer[260]; + str encoded, decoded = STR_NULL; + int len, i, rc, ok_all = 1; + + if (th_callid_codec_init(TH_CALLID_ENC_FF1_ALNUM62, + &password, &prefix) < 0) { + ok(0, "initialize malformed-payload codec"); + return; + } + memcpy(buffer, "TH", 2); + for (len = 1; len <= 256; len++) { + buffer[2] = "ABC"[len % 3]; + for (i = 0; i < len; i++) + buffer[3 + i] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + [(i * 29 + len * 11) % 62]; + encoded.s = buffer; + encoded.len = len + 3; + rc = th_callid_codec_decode(&encoded, &decoded); + if (rc == 0) { + if (!decoded.s || decoded.len <= 0) + ok_all = 0; + pkg_free(decoded.s); + decoded = STR_NULL; + } + } + ok(ok_all, "handle malformed and wrong-key-like payloads through length 256"); +} + +static void test_raw_message_integration(void) +{ + static const char *requests[] = { + "BYE sip:bob@example.com SIP/2.0", + "INVITE sip:bob@example.com SIP/2.0", + }; + static const char *methods[] = {"BYE", "INVITE"}; + str prefix = str_init("TH"); + str password = str_init("0123456789abcdef0123456789abcdef"); + char message[2048]; + char *encoded_buffer; + str data; + int len, i; + + ok(th_callid_codec_init(TH_CALLID_ENC_FF1_ALNUM62, + &password, &prefix) == 0, "initialize raw-message codec"); + for (i = 0; i < 2; i++) { + len = snprintf(message, sizeof(message), + "%s\r\n" + "Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK1\r\n" + "From: ;tag=caller\r\n" + "To: ;tag=callee\r\n" + "Call-ID: raw-integration-1@example.com\r\n" + "CSeq: 2 %s\r\n" + "Content-Length: 0\r\n\r\n", requests[i], methods[i]); + data.s = message; + data.len = len; + ok(th_test_encode_callid_raw(&data) == 0 && + contains_text(&data, "Call-ID: THB"), + "encode raw in-dialog %s", methods[i]); + encoded_buffer = data.s; + ok(topo_callid_pre_raw(&data, NULL) == 0 && + contains_text(&data, "Call-ID: raw-integration-1@example.com"), + "decode raw in-dialog %s", methods[i]); + pkg_free(encoded_buffer); + pkg_free(data.s); + } + + len = snprintf(message, sizeof(message), + "SIP/2.0 200 OK\r\n" + "Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK1\r\n" + "From: ;tag=caller\r\n" + "To: ;tag=callee\r\n" + "Call-ID: raw-integration-1@example.com\r\n" + "CSeq: 2 BYE\r\n" + "Content-Length: 0\r\n\r\n"); + data.s = message; + data.len = len; + ok(th_test_encode_callid_raw(&data) == 0 && + contains_text(&data, "Call-ID: THB"), "encode raw SIP reply"); + encoded_buffer = data.s; + ok(topo_callid_pre_raw(&data, NULL) == 0 && + contains_text(&data, "Call-ID: raw-integration-1@example.com"), + "decode raw SIP reply"); + pkg_free(encoded_buffer); + pkg_free(data.s); + + /* Initial requests, including CANCEL, deliberately retain a peer's layer. */ + len = snprintf(message, sizeof(message), + "CANCEL sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK1\r\n" + "From: ;tag=caller\r\n" + "To: \r\n" + "Call-ID: raw-integration-1@example.com\r\n" + "CSeq: 1 CANCEL\r\n" + "Content-Length: 0\r\n\r\n"); + data.s = message; + data.len = len; + ok(th_test_encode_callid_raw(&data) == 0, "encode raw CANCEL"); + encoded_buffer = data.s; + ok(topo_callid_pre_raw(&data, NULL) == 0 && data.s == encoded_buffer && + contains_text(&data, "Call-ID: THB"), + "preserve the encoded layer on initial-request CANCEL"); + pkg_free(encoded_buffer); +} + +static void test_legacy_compatibility(void) +{ + static const char expected[] = + "DLGCH_KUhUCmcvMTZiRwELMGRhYitASA9kf2V+f0AEXjBwYTZ5EgNYEys5PyAIDEAwJj0-"; + str prefix = str_init("DLGCH_"); + str password = str_init("OpenSIPS"); + str original = str_init("f81d4fae-7dec-11d0-a765-00a0c91e6bf6@biloxi.com"); + str encoded = STR_NULL, decoded = STR_NULL; + + ok(th_callid_codec_init(TH_CALLID_ENC_XOR_WORD64, + &password, &prefix) == 0, "initialize legacy codec"); + ok(th_callid_codec_encode(&original, &encoded) == 0, + "encode with legacy codec"); + ok(encoded.len == sizeof(expected) - 1 && + !memcmp(encoded.s, expected, sizeof(expected) - 1), + "legacy output remains byte-identical"); + ok(th_callid_codec_decode(&encoded, &decoded) == 0 && + decoded.len == original.len && + !memcmp(decoded.s, original.s, original.len), + "legacy codec remains reversible"); + pkg_free(encoded.s); + pkg_free(decoded.s); +} + +static int build_contact_message(struct sip_msg *msg, char *buffer, int size, + const str *contact, struct socket_info *socket) +{ + int len; + + len = snprintf(buffer, size, + "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK1\r\n" + "From: ;tag=1\r\n" + "To: \r\n" + "Call-ID: contact-growth@example.com\r\n" + "CSeq: 1 INVITE\r\n" + "Contact: <%.*s>\r\n" + "Content-Length: 0\r\n\r\n", contact->len, contact->s); + if (len < 0 || len >= size) + return -1; + memset(msg, 0, sizeof(*msg)); + msg->buf = buffer; + msg->len = len; + if (parse_msg(buffer, len, msg) < 0 || parse_headers(msg, HDR_EOH_F, 0) < 0) + return -1; + msg->rcv.bind_address = socket; + return 0; +} + +static void test_contact_growth_reproduction(void) +{ + char message[8192], uri_buf[8192]; + char *suffix = NULL; + str contact = str_init("sip:alice@192.0.2.10:5060"); + struct socket_info socket; + struct sip_msg msg; + int lengths[6] = {0}; + int suffix_len, previous_len, hop, over_255 = 0, ok_all = 1; + + memset(&socket, 0, sizeof(socket)); + socket.sock_str = str_init("127.0.0.1:5060"); + previous_len = contact.len; + lengths[0] = contact.len; + for (hop = 1; hop <= 5; hop++) { + if (build_contact_message(&msg, message, sizeof(message), &contact, + &socket) < 0 || + th_test_build_encoded_contact_suffix(&msg, &suffix_len, &suffix) < 0 || + suffix_len <= previous_len || + 4 + socket.sock_str.len + suffix_len - 1 >= sizeof(uri_buf)) { + ok_all = 0; + free_sip_msg(&msg); + break; + } + memcpy(uri_buf, "sip:", 4); + memcpy(uri_buf + 4, socket.sock_str.s, socket.sock_str.len); + memcpy(uri_buf + 4 + socket.sock_str.len, suffix, suffix_len - 1); + contact.s = uri_buf; + contact.len = 4 + socket.sock_str.len + suffix_len - 1; + if (contact.len > 255 && !over_255) + over_255 = hop; + previous_len = contact.len; + lengths[hop] = contact.len; + pkg_free(suffix); + suffix = NULL; + free_sip_msg(&msg); + } + ok(ok_all && over_255 == 3, + "no-dialog Contact nesting exceeds 255 characters within five hops"); + diag("Contact growth reproduction crossed 255 characters at hop %d", over_255); + diag("Contact URI lengths by layer: %d, %d, %d, %d, %d, %d", + lengths[0], lengths[1], lengths[2], lengths[3], lengths[4], lengths[5]); + if (suffix) + pkg_free(suffix); +} + +void mod_tests(void) +{ + test_nist_ff1_vector(); + test_codec_round_trips(); + test_word_domain_lengths(); + test_chain_growth(); + test_malformed_payloads(); + test_raw_message_integration(); + test_legacy_compatibility(); + test_contact_growth_reproduction(); +} diff --git a/modules/topology_hiding/th_no_dlg_logic.c b/modules/topology_hiding/th_no_dlg_logic.c index 62a8a0456d5..81d5cac87e9 100644 --- a/modules/topology_hiding/th_no_dlg_logic.c +++ b/modules/topology_hiding/th_no_dlg_logic.c @@ -891,6 +891,15 @@ static char* build_encoded_contact_suffix_legacy(struct sip_msg* msg, str rr_set return NULL; } +#ifdef UNIT_TESTS +int th_test_build_encoded_contact_suffix(struct sip_msg *msg, int *suffix_len, + char **suffix) +{ + *suffix = build_encoded_contact_suffix_legacy(msg, STR_NULL, suffix_len, 0); + return *suffix ? 0 : -1; +} +#endif + static int th_binary_encode_record_route(rr_t *record_route, rr_t **out_rr, int encode_self) { struct sip_uri rr_uri = { 0 }, rr_uri_r2 = { 0 }; const struct socket_info *rr_sock = NULL; diff --git a/modules/topology_hiding/topo_hiding_codec.c b/modules/topology_hiding/topo_hiding_codec.c new file mode 100644 index 00000000000..f3a9cba66a6 --- /dev/null +++ b/modules/topology_hiding/topo_hiding_codec.c @@ -0,0 +1,880 @@ +/* + * Radix-preserving Call-ID codec for the topology_hiding module. + * + * Copyright (C) 2026 OpenSIPS Project + * + * This file is part of opensips, a free SIP server. + * + * opensips is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include +#include + +#include +#include +#include +#include + +#include "../../dprint.h" +#include "../../mem/mem.h" +#include "../../ut.h" +#include "topo_hiding_codec.h" + +#define TH_FF1_RADIX 62 +#define TH_WORD_RADIX 85 +#define TH_FF1_MIN_LEN 4 +#define TH_CALLID_MAX_PLAIN_LEN 4096 +/* ceil(log_62(domain(4096))) for the structured radix-85 Call-ID domain. */ +#define TH_CALLID_MAX_STRUCTURED_PAYLOAD_LEN 4411 + +/* The marker encodes both the wire version and the input domain. */ +#define TH_FF1_MARK_NATIVE 'A' +#define TH_FF1_MARK_WORD 'B' +#define TH_FF1_MARK_LEGACY 'C' + +static const unsigned char alnum62[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +static const unsigned char word85[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.!%*_+`'~()<>:\\\"/[]?{}"; + +static signed short alnum62_idx[256]; +static signed short word85_idx[256]; +static enum th_callid_encode_scheme callid_scheme; +static str callid_password; +static str callid_prefix; +static unsigned char ff1_key[32]; +static int ff1_key_bits; + +static int init_alphabet(signed short idx[256], const unsigned char *symbols, + int radix) +{ + int i; + + for (i = 0; i < 256; i++) + idx[i] = -1; + for (i = 0; i < radix; i++) { + if (idx[symbols[i]] >= 0) { + LM_ERR("duplicate symbol in Call-ID alphabet\n"); + return -1; + } + idx[symbols[i]] = i; + } + + return 0; +} + +static int hkdf_ff1_key(const str *password, unsigned char key[32]) +{ + static const unsigned char salt[] = + "OpenSIPS topology_hiding FF1 key v1"; + static const unsigned char info[] = + "Call-ID ff1-alnum62\x01"; + unsigned char prk[EVP_MAX_MD_SIZE]; + unsigned int prk_len, key_len; + + if (!HMAC(EVP_sha256(), salt, sizeof(salt) - 1, + (unsigned char *)password->s, password->len, prk, &prk_len)) + return -1; + if (!HMAC(EVP_sha256(), prk, prk_len, info, sizeof(info) - 1, + key, &key_len)) { + OPENSSL_cleanse(prk, sizeof(prk)); + return -1; + } + OPENSSL_cleanse(prk, sizeof(prk)); + + return key_len == 32 ? 0 : -1; +} + +int th_callid_codec_init(enum th_callid_encode_scheme scheme, + const str *password, const str *prefix) +{ + unsigned char key[32]; + int i; + + if (!password || !password->s || password->len <= 0 || + !prefix || !prefix->s || prefix->len <= 0) { + LM_ERR("Call-ID password and prefix must not be empty\n"); + return -1; + } + + if (init_alphabet(alnum62_idx, alnum62, TH_FF1_RADIX) < 0 || + init_alphabet(word85_idx, word85, TH_WORD_RADIX) < 0) + return -1; + + OPENSSL_cleanse(ff1_key, sizeof(ff1_key)); + ff1_key_bits = 0; + callid_scheme = scheme; + callid_password = *password; + callid_prefix = *prefix; + + if (scheme == TH_CALLID_ENC_XOR_WORD64) + return 0; + if (scheme != TH_CALLID_ENC_FF1_ALNUM62) { + LM_ERR("unknown Call-ID encode scheme %d\n", scheme); + return -1; + } + + for (i = 0; i < prefix->len; i++) { + if (alnum62_idx[(unsigned char)prefix->s[i]] < 0) { + LM_ERR("ff1-alnum62 requires an alphanumeric th_callid_prefix\n"); + return -1; + } + } + if (password->len == 8 && !memcmp(password->s, "OpenSIPS", 8)) { + LM_ERR("ff1-alnum62 cannot be used with the default th_callid_passwd\n"); + return -1; + } + if (password->len < 32) + LM_WARN("ff1-alnum62 should use a high-entropy password of at least 32 characters\n"); + + if (hkdf_ff1_key(password, key) < 0) { + OPENSSL_cleanse(key, sizeof(key)); + LM_ERR("failed to initialize the FF1 AES key\n"); + return -1; + } + memcpy(ff1_key, key, sizeof(ff1_key)); + ff1_key_bits = 256; + OPENSSL_cleanse(key, sizeof(key)); + + return 0; +} + +void th_callid_codec_cleanup(void) +{ + OPENSSL_cleanse(ff1_key, sizeof(ff1_key)); + ff1_key_bits = 0; +} + +static int bn_from_digits(const unsigned char *digits, int len, int radix, + BIGNUM *number) +{ + int i; + + BN_zero(number); + for (i = 0; i < len; i++) { + if (!BN_mul_word(number, radix) || !BN_add_word(number, digits[i])) + return -1; + } + return 0; +} + +static int bn_to_digits(const BIGNUM *number, int len, int radix, + unsigned char *digits) +{ + BIGNUM *tmp; + BN_ULONG rem; + int i; + + tmp = BN_dup(number); + if (!tmp) + return -1; + for (i = len - 1; i >= 0; i--) { + rem = BN_div_word(tmp, radix); + if (rem == (BN_ULONG)-1) + goto error; + digits[i] = (unsigned char)rem; + } + if (!BN_is_zero(tmp)) + goto error; + BN_free(tmp); + return 0; + +error: + BN_free(tmp); + return -1; +} + +static int bn_pow_word(BIGNUM *out, int radix, int exponent) +{ + int i; + + if (!BN_one(out)) + return -1; + for (i = 0; i < exponent; i++) + if (!BN_mul_word(out, radix)) + return -1; + return 0; +} + +static int bn_to_fixed_bytes(const BIGNUM *number, unsigned char *out, int len) +{ + int bytes = BN_num_bytes(number); + + if (bytes > len) + return -1; + memset(out, 0, len - bytes); + if (bytes && BN_bn2bin(number, out + len - bytes) != bytes) + return -1; + return 0; +} + +static int ff1_encrypt_block(EVP_CIPHER_CTX *ctx, const unsigned char *input, + unsigned char output[16]) +{ + int out_len; + + return EVP_EncryptUpdate(ctx, output, &out_len, input, 16) == 1 && + out_len == 16 ? 0 : -1; +} + +static int ff1_cbc_mac(EVP_CIPHER_CTX *ctx, const unsigned char *data, int len, + unsigned char out[16]) +{ + const EVP_CIPHER *cipher; + unsigned char block[16] = {0}; + int i, j; + + cipher = ff1_key_bits == 128 ? EVP_aes_128_ecb() : + ff1_key_bits == 192 ? EVP_aes_192_ecb() : EVP_aes_256_ecb(); + if (EVP_EncryptInit_ex(ctx, cipher, NULL, ff1_key, NULL) != 1 || + EVP_CIPHER_CTX_set_padding(ctx, 0) != 1) + return -1; + + for (i = 0; i < len; i += 16) { + for (j = 0; j < 16; j++) + block[j] ^= data[i + j]; + if (ff1_encrypt_block(ctx, block, block) < 0) + return -1; + } + memcpy(out, block, 16); + return 0; +} + +static int ff1_crypt(const unsigned char *input, int len, int radix, + unsigned char *output, int decrypt) +{ + unsigned char p[16] = {0}; + unsigned char r[16], block[16]; + unsigned char *a = NULL, *b_digits = NULL, *c = NULL, *tmp; + unsigned char *pq = NULL, *s = NULL; + BIGNUM *pow_v = NULL, *modulus = NULL, *anum = NULL, *y = NULL; + BN_CTX *bn_ctx = NULL; + EVP_CIPHER_CTX *cipher_ctx = NULL; + int u, v, b, d, qpad, qlen, pq_len; + int alen, blen, m, round, i, j, take; + int rc = -1; + + if (len < TH_FF1_MIN_LEN) + return -1; + u = len / 2; + v = len - u; + + pow_v = BN_new(); + modulus = BN_new(); + anum = BN_new(); + y = BN_new(); + bn_ctx = BN_CTX_new(); + cipher_ctx = EVP_CIPHER_CTX_new(); + if (!pow_v || !modulus || !anum || !y || !bn_ctx || !cipher_ctx || + bn_pow_word(pow_v, radix, v) < 0 || + !BN_sub_word(pow_v, 1)) + goto done; + b = (BN_num_bits(pow_v) + 7) / 8; + d = 4 * ((b + 3) / 4) + 4; + qpad = (16 - ((b + 1) % 16)) % 16; + qlen = qpad + 1 + b; + pq_len = 16 + qlen; + + a = pkg_malloc(len); + b_digits = pkg_malloc(len); + c = pkg_malloc(len); + pq = pkg_malloc(pq_len); + s = pkg_malloc(d); + if (!a || !b_digits || !c || !pq || !s) + goto done; + memcpy(a, input, u); + memcpy(b_digits, input + u, v); + alen = u; + blen = v; + + p[0] = 1; + p[1] = 2; + p[2] = 1; + p[3] = (radix >> 16) & 0xff; + p[4] = (radix >> 8) & 0xff; + p[5] = radix & 0xff; + p[6] = 10; + p[7] = u & 0xff; + p[8] = (len >> 24) & 0xff; + p[9] = (len >> 16) & 0xff; + p[10] = (len >> 8) & 0xff; + p[11] = len & 0xff; + /* P[12..15] is the empty tweak length. */ + memcpy(pq, p, 16); + + for (round = decrypt ? 9 : 0; + decrypt ? round >= 0 : round < 10; + decrypt ? round-- : round++) { + m = (round % 2 == 0) ? u : v; + memset(pq + 16, 0, qlen); + pq[16 + qpad] = round; + + if (bn_from_digits(decrypt ? a : b_digits, + decrypt ? alen : blen, radix, anum) < 0 || + bn_to_fixed_bytes(anum, pq + pq_len - b, b) < 0) + goto done; + if (ff1_cbc_mac(cipher_ctx, pq, pq_len, r) < 0) + goto done; + + take = d < 16 ? d : 16; + memcpy(s, r, take); + for (i = 16, j = 1; i < d; i += 16, j++) { + memcpy(block, r, 16); + block[12] ^= (j >> 24) & 0xff; + block[13] ^= (j >> 16) & 0xff; + block[14] ^= (j >> 8) & 0xff; + block[15] ^= j & 0xff; + if (ff1_encrypt_block(cipher_ctx, block, block) < 0) + goto done; + take = d - i < 16 ? d - i : 16; + memcpy(s + i, block, take); + } + if (!BN_bin2bn(s, d, y) || bn_pow_word(modulus, radix, m) < 0) + goto done; + + if (!decrypt) { + if (bn_from_digits(a, alen, radix, anum) < 0 || + !BN_mod_add(anum, anum, y, modulus, bn_ctx) || + bn_to_digits(anum, m, radix, c) < 0) + goto done; + tmp = a; + a = b_digits; + alen = blen; + b_digits = c; + blen = m; + c = tmp; + } else { + if (bn_from_digits(b_digits, blen, radix, anum) < 0 || + !BN_mod_sub(anum, anum, y, modulus, bn_ctx) || + bn_to_digits(anum, m, radix, c) < 0) + goto done; + tmp = b_digits; + b_digits = a; + blen = alen; + a = c; + alen = m; + c = tmp; + } + } + + memcpy(output, a, alen); + memcpy(output + alen, b_digits, blen); + rc = 0; + +done: + if (a) pkg_free(a); + if (b_digits) pkg_free(b_digits); + if (c) pkg_free(c); + if (pq) pkg_free(pq); + if (s) pkg_free(s); + BN_free(pow_v); + BN_free(modulus); + BN_free(anum); + BN_free(y); + BN_CTX_free(bn_ctx); + EVP_CIPHER_CTX_free(cipher_ctx); + return rc; +} + +#ifdef UNIT_TESTS +int th_callid_codec_test_ff1(const unsigned char *key, int key_bits, + const unsigned char *input, int len, int radix, + unsigned char *output, int decrypt) +{ + if (key_bits != 128 && key_bits != 192 && key_bits != 256) + return -1; + memcpy(ff1_key, key, key_bits / 8); + ff1_key_bits = key_bits; + return ff1_crypt(input, len, radix, output, decrypt); +} +#endif + +static int chars_to_digits(const str *input, const signed short idx[256], + unsigned char *digits) +{ + int i; + + for (i = 0; i < input->len; i++) { + digits[i] = idx[(unsigned char)input->s[i]]; + if (digits[i] == (unsigned char)-1) + return -1; + } + return 0; +} + +static void digits_to_chars(const unsigned char *digits, int len, + const unsigned char *symbols, char *output) +{ + int i; + + for (i = 0; i < len; i++) + output[i] = symbols[digits[i]]; +} + +static int is_native_alnum(const str *input) +{ + int i; + + for (i = 0; i < input->len; i++) + if (alnum62_idx[(unsigned char)input->s[i]] < 0) + return 0; + return 1; +} + +/* Rank the RFC 3261 callid = word [ "@" word ] language for a fixed length. */ +static int rank_word_callid(const str *input, BIGNUM *rank, BIGNUM *domain) +{ + BIGNUM *word_count = NULL, *suffix_count = NULL, *digits_num = NULL; + unsigned char *digits = NULL; + int at = -1, digits_len = 0, i, rc = -1; + + if (input->len <= 0) + return -1; + digits = pkg_malloc(input->len); + word_count = BN_new(); + suffix_count = BN_new(); + digits_num = BN_new(); + if (!digits || !word_count || !suffix_count || !digits_num) + goto done; + + for (i = 0; i < input->len; i++) { + if (input->s[i] == '@') { + if (at >= 0 || i == 0 || i == input->len - 1) + goto done; + at = i; + continue; + } + if (word85_idx[(unsigned char)input->s[i]] < 0) + goto done; + digits[digits_len++] = word85_idx[(unsigned char)input->s[i]]; + } + + if (bn_pow_word(word_count, TH_WORD_RADIX, input->len) < 0 || + bn_pow_word(suffix_count, TH_WORD_RADIX, input->len - 1) < 0) + goto done; + if (!BN_copy(domain, word_count)) + goto done; + if (input->len > 2) { + if (!BN_mul_word(suffix_count, input->len - 2) || + !BN_add(domain, domain, suffix_count)) + goto done; + } + + if (bn_from_digits(digits, digits_len, TH_WORD_RADIX, digits_num) < 0) + goto done; + if (at < 0) { + if (!BN_copy(rank, digits_num)) + goto done; + } else { + if (bn_pow_word(suffix_count, TH_WORD_RADIX, input->len - 1) < 0 || + !BN_mul_word(suffix_count, at - 1) || + !BN_add(rank, word_count, suffix_count) || + !BN_add(rank, rank, digits_num)) + goto done; + } + rc = 0; + +done: + if (digits) pkg_free(digits); + BN_free(word_count); + BN_free(suffix_count); + BN_free(digits_num); + return rc; +} + +static int radix62_len_for_domain(const BIGNUM *domain) +{ + BIGNUM *capacity; + int len = 0; + + capacity = BN_new(); + if (!capacity || !BN_one(capacity)) { + BN_free(capacity); + return -1; + } + while (BN_cmp(capacity, domain) < 0) { + if (!BN_mul_word(capacity, TH_FF1_RADIX) || len == INT_MAX) { + BN_free(capacity); + return -1; + } + len++; + } + BN_free(capacity); + return len; +} + +static int encode_rank62(const str *input, unsigned char **digits, int *len) +{ + BIGNUM *rank = NULL, *domain = NULL; + int rc = -1; + + rank = BN_new(); + domain = BN_new(); + if (!rank || !domain || rank_word_callid(input, rank, domain) < 0) + goto done; + *len = radix62_len_for_domain(domain); + if (*len < 0) + goto done; + *digits = pkg_malloc(*len); + if (!*digits || bn_to_digits(rank, *len, TH_FF1_RADIX, *digits) < 0) + goto done; + rc = 0; + +done: + if (rc < 0 && *digits) { + pkg_free(*digits); + *digits = NULL; + } + BN_free(rank); + BN_free(domain); + return rc; +} + +static int domain_for_word_len(int len, BIGNUM *domain) +{ + BIGNUM *extra = NULL; + int rc = -1; + + if (len <= 0 || bn_pow_word(domain, TH_WORD_RADIX, len) < 0) + return -1; + if (len <= 2) + return 0; + extra = BN_new(); + if (!extra || bn_pow_word(extra, TH_WORD_RADIX, len - 1) < 0 || + !BN_mul_word(extra, len - 2) || !BN_add(domain, domain, extra)) + goto done; + rc = 0; +done: + BN_free(extra); + return rc; +} + +static int word_len_for_radix62_len(int radix_len, BIGNUM *domain) +{ + int low = 1, high = radix_len; + int n, encoded_len; + + while (low <= high) { + n = low + (high - low) / 2; + if (domain_for_word_len(n, domain) < 0) + return -1; + encoded_len = radix62_len_for_domain(domain); + if (encoded_len == radix_len) + return n; + if (encoded_len < radix_len) + low = n + 1; + else + high = n - 1; + } + return -1; +} + +static int decode_rank62(const unsigned char *digits, int len, str *output) +{ + BIGNUM *rank = NULL, *domain = NULL, *word_count = NULL; + BIGNUM *position = NULL, *remainder = NULL, *span = NULL; + unsigned char *word_digits = NULL; + BN_ULONG at_offset; + BN_CTX *bn_ctx = NULL; + int word_len, digits_len, digit_pos, i, at = -1, rc = -1; + + rank = BN_new(); + domain = BN_new(); + word_count = BN_new(); + position = BN_new(); + remainder = BN_new(); + span = BN_new(); + bn_ctx = BN_CTX_new(); + if (!rank || !domain || !word_count || !position || !remainder || !span || + !bn_ctx || + bn_from_digits(digits, len, TH_FF1_RADIX, rank) < 0) + goto done; + word_len = word_len_for_radix62_len(len, domain); + if (word_len < 0 || BN_cmp(rank, domain) >= 0 || + bn_pow_word(word_count, TH_WORD_RADIX, word_len) < 0) + goto done; + + if (BN_cmp(rank, word_count) < 0) { + digits_len = word_len; + if (!BN_copy(remainder, rank)) + goto done; + } else { + digits_len = word_len - 1; + if (bn_pow_word(span, TH_WORD_RADIX, digits_len) < 0 || + !BN_sub(position, rank, word_count) || + !BN_div(position, remainder, position, span, bn_ctx)) + goto done; + at_offset = BN_get_word(position); + if (at_offset == (BN_ULONG)-1 || at_offset >= (BN_ULONG)(word_len - 2)) + goto done; + at = (int)at_offset + 1; + } + + word_digits = pkg_malloc(digits_len); + output->s = pkg_malloc(word_len); + if (!word_digits || !output->s || + bn_to_digits(remainder, digits_len, TH_WORD_RADIX, word_digits) < 0) + goto done; + for (i = 0; i < word_len; i++) { + if (i == at) + output->s[i] = '@'; + else { + digit_pos = at >= 0 && i > at ? i - 1 : i; + if (digit_pos >= digits_len) + goto done; + output->s[i] = word85[word_digits[digit_pos]]; + } + } + output->len = word_len; + rc = 0; + +done: + if (rc < 0 && output->s) { + pkg_free(output->s); + output->s = NULL; + output->len = 0; + } + if (word_digits) pkg_free(word_digits); + BN_free(rank); + BN_free(domain); + BN_free(word_count); + BN_free(position); + BN_free(remainder); + BN_free(span); + BN_CTX_free(bn_ctx); + return rc; +} + +static int legacy_encode(const str *input, str *output, int include_prefix) +{ + unsigned char *masked; + int i, payload_len, offset = include_prefix ? callid_prefix.len : 0; + + payload_len = calc_word64_encode_len(input->len); + if (payload_len < 0 || offset > INT_MAX - payload_len) + return -1; + output->len = offset + payload_len; + output->s = pkg_malloc(output->len); + masked = pkg_malloc(input->len); + if (!output->s || !masked) + goto error; + if (include_prefix) + memcpy(output->s, callid_prefix.s, callid_prefix.len); + for (i = 0; i < input->len; i++) + masked[i] = input->s[i] ^ callid_password.s[i % callid_password.len]; + word64encode((unsigned char *)output->s + offset, masked, input->len); + pkg_free(masked); + return 0; + +error: + if (output->s) pkg_free(output->s); + if (masked) pkg_free(masked); + output->s = NULL; + output->len = 0; + return -1; +} + +static int safe_legacy_plaintext(const str *input) +{ + int i; + unsigned char c; + + /* Keep compatibility for visible non-RFC characters, but never allow a + * decoded fallback value to inject or truncate a SIP header. */ + for (i = 0; i < input->len; i++) { + c = input->s[i]; + if (c < 0x20 || c == 0x7f) + return 0; + } + return input->len > 0; +} + +static int legacy_decode(const str *input, str *output) +{ + int i, max_len; + unsigned char c; + + if (input->len < 4 || input->len % 4) + return -1; + for (i = 0; i < input->len; i++) { + c = input->s[i]; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '+' || c == '.') + continue; + if (c != '-' || i < input->len - 2) + return -1; + } + if (input->s[input->len - 2] == '-' && input->s[input->len - 1] != '-') + return -1; + + max_len = calc_max_word64_decode_len(input->len); + output->s = pkg_malloc(max_len); + if (!output->s) + return -1; + output->len = word64decode((unsigned char *)output->s, + (unsigned char *)input->s, input->len); + for (i = 0; i < output->len; i++) + output->s[i] ^= callid_password.s[i % callid_password.len]; + if (!safe_legacy_plaintext(output)) { + pkg_free(output->s); + output->s = NULL; + output->len = 0; + return -1; + } + return 0; +} + +int th_callid_codec_is_encoded(const str *callid) +{ + char marker; + + if (!callid || callid->len <= callid_prefix.len || + memcmp(callid->s, callid_prefix.s, callid_prefix.len)) + return 0; + if (callid_scheme == TH_CALLID_ENC_XOR_WORD64) + return 1; + if (callid->len <= callid_prefix.len + 1) + return 0; + marker = callid->s[callid_prefix.len]; + return marker == TH_FF1_MARK_NATIVE || marker == TH_FF1_MARK_WORD || + marker == TH_FF1_MARK_LEGACY; +} + +int th_callid_codec_encode(const str *callid, str *encoded) +{ + unsigned char *plain_digits = NULL, *cipher_digits = NULL; + str legacy = STR_NULL; + int i, payload_len = 0, rc = -1; + char marker; + + encoded->s = NULL; + encoded->len = 0; + if (callid_scheme == TH_CALLID_ENC_XOR_WORD64) { + if (!safe_legacy_plaintext(callid)) + return -1; + return legacy_encode(callid, encoded, 1); + } + + if (callid->len >= TH_FF1_MIN_LEN && + callid->len <= TH_CALLID_MAX_PLAIN_LEN && is_native_alnum(callid)) { + marker = TH_FF1_MARK_NATIVE; + payload_len = callid->len; + plain_digits = pkg_malloc(payload_len); + if (!plain_digits || chars_to_digits(callid, alnum62_idx, plain_digits) < 0) + goto done; + } else if (callid->len <= TH_CALLID_MAX_PLAIN_LEN && + encode_rank62(callid, &plain_digits, &payload_len) == 0 && + payload_len >= TH_FF1_MIN_LEN) { + marker = TH_FF1_MARK_WORD; + } else { + if (plain_digits) { + pkg_free(plain_digits); + plain_digits = NULL; + } + marker = TH_FF1_MARK_LEGACY; + if (!safe_legacy_plaintext(callid)) + goto done; + if (legacy_encode(callid, &legacy, 0) < 0) + goto done; + payload_len = legacy.len; + } + + if (callid_prefix.len > INT_MAX - 1 - payload_len) + goto done; + encoded->len = callid_prefix.len + 1 + payload_len; + encoded->s = pkg_malloc(encoded->len); + if (!encoded->s) + goto done; + memcpy(encoded->s, callid_prefix.s, callid_prefix.len); + encoded->s[callid_prefix.len] = marker; + + if (marker == TH_FF1_MARK_LEGACY) { + memcpy(encoded->s + callid_prefix.len + 1, legacy.s, legacy.len); + } else { + cipher_digits = pkg_malloc(payload_len); + if (!cipher_digits || ff1_crypt(plain_digits, payload_len, TH_FF1_RADIX, + cipher_digits, 0) < 0) + goto done; + for (i = 0; i < payload_len; i++) + encoded->s[callid_prefix.len + 1 + i] = alnum62[cipher_digits[i]]; + } + rc = 0; + +done: + if (plain_digits) pkg_free(plain_digits); + if (cipher_digits) pkg_free(cipher_digits); + if (legacy.s) pkg_free(legacy.s); + if (rc < 0 && encoded->s) { + pkg_free(encoded->s); + encoded->s = NULL; + encoded->len = 0; + } + return rc; +} + +int th_callid_codec_decode(const str *encoded, str *callid) +{ + str payload; + unsigned char *cipher_digits = NULL, *plain_digits = NULL; + char marker; + int rc = -1; + + callid->s = NULL; + callid->len = 0; + if (!th_callid_codec_is_encoded(encoded)) + return -1; + payload.s = encoded->s + callid_prefix.len; + payload.len = encoded->len - callid_prefix.len; + if (callid_scheme == TH_CALLID_ENC_XOR_WORD64) + return legacy_decode(&payload, callid); + + marker = payload.s[0]; + payload.s++; + payload.len--; + if (marker == TH_FF1_MARK_LEGACY) + return legacy_decode(&payload, callid); + if (payload.len < TH_FF1_MIN_LEN || + (marker == TH_FF1_MARK_NATIVE && + payload.len > TH_CALLID_MAX_PLAIN_LEN) || + (marker == TH_FF1_MARK_WORD && + payload.len > TH_CALLID_MAX_STRUCTURED_PAYLOAD_LEN)) + return -1; + + cipher_digits = pkg_malloc(payload.len); + plain_digits = pkg_malloc(payload.len); + if (!cipher_digits || !plain_digits || + chars_to_digits(&payload, alnum62_idx, cipher_digits) < 0 || + ff1_crypt(cipher_digits, payload.len, TH_FF1_RADIX, + plain_digits, 1) < 0) + goto done; + + if (marker == TH_FF1_MARK_NATIVE) { + callid->s = pkg_malloc(payload.len); + if (!callid->s) + goto done; + digits_to_chars(plain_digits, payload.len, alnum62, callid->s); + callid->len = payload.len; + } else if (marker == TH_FF1_MARK_WORD) { + if (decode_rank62(plain_digits, payload.len, callid) < 0) + goto done; + if (callid->len > TH_CALLID_MAX_PLAIN_LEN) + goto done; + } else { + goto done; + } + rc = 0; + +done: + if (cipher_digits) pkg_free(cipher_digits); + if (plain_digits) pkg_free(plain_digits); + if (rc < 0 && callid->s) { + pkg_free(callid->s); + callid->s = NULL; + callid->len = 0; + } + return rc; +} diff --git a/modules/topology_hiding/topo_hiding_codec.h b/modules/topology_hiding/topo_hiding_codec.h new file mode 100644 index 00000000000..6693df11b78 --- /dev/null +++ b/modules/topology_hiding/topo_hiding_codec.h @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 OpenSIPS Project + * + * This file is part of opensips, a free SIP server. + * + * opensips is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#ifndef _TOPO_HIDING_CODEC_H_ +#define _TOPO_HIDING_CODEC_H_ + +#include "../../str.h" + +enum th_callid_encode_scheme { + TH_CALLID_ENC_XOR_WORD64 = 0, + TH_CALLID_ENC_FF1_ALNUM62 +}; + +int th_callid_codec_init(enum th_callid_encode_scheme scheme, + const str *password, const str *prefix); +void th_callid_codec_cleanup(void); +int th_callid_codec_is_encoded(const str *callid); +int th_callid_codec_encode(const str *callid, str *encoded); +int th_callid_codec_decode(const str *encoded, str *callid); + +#ifdef UNIT_TESTS +int th_callid_codec_test_ff1(const unsigned char *key, int key_bits, + const unsigned char *input, int len, int radix, + unsigned char *output, int decrypt); +#endif + +#endif diff --git a/modules/topology_hiding/topo_hiding_logic.c b/modules/topology_hiding/topo_hiding_logic.c index dcb5d7f75eb..88446d25ca2 100644 --- a/modules/topology_hiding/topo_hiding_logic.c +++ b/modules/topology_hiding/topo_hiding_logic.c @@ -27,13 +27,12 @@ #include "../../ut.h" #include "topo_hiding_logic.h" #include "th_no_dlg_logic.h" +#include "topo_hiding_codec.h" extern int force_dialog; extern struct rr_binds rr_api; extern struct tm_binds tm_api; extern struct dlg_binds dlg_api; -extern str topo_hiding_prefix; -extern str topo_hiding_seed; extern int th_loop_protection; static struct th_ct_params *th_param_list=NULL; @@ -729,7 +728,6 @@ static int dlg_th_decode_callid(struct sip_msg *msg) { struct lump *del; str new_callid; - int i,max_size; char *q; str enc_tag, *tag; @@ -738,20 +736,11 @@ static int dlg_th_decode_callid(struct sip_msg *msg) return -1; } - max_size = calc_max_word64_decode_len(msg->callid->body.len - topo_hiding_prefix.len); - new_callid.s = pkg_malloc(max_size); - if (new_callid.s==NULL) { - LM_ERR("No more pkg\n"); + if (th_callid_codec_decode(&msg->callid->body, &new_callid) < 0) { + LM_ERR("failed to decode Call-ID\n"); return -1; } - new_callid.len = word64decode((unsigned char *)(new_callid.s), - (unsigned char *)(msg->callid->body.s + topo_hiding_prefix.len), - msg->callid->body.len - topo_hiding_prefix.len); - - for (i=0;icallid->body.len; append_tag = th_loop_protection && tag; if (append_tag) len += tag->len + 1; - old_callid = pkg_malloc(len); - if (!old_callid) { + plain.s = pkg_malloc(len); + if (!plain.s) { LM_ERR("Failed to allocate old callid\n"); return NULL; } + plain.len = len; - memcpy(old_callid, msg->callid->body.s, msg->callid->body.len); - for (j=0;jcallid->body.len;j++) - old_callid[j] = msg->callid->body.s[j] ^ topo_hiding_seed.s[j%topo_hiding_seed.len]; + memcpy(plain.s, msg->callid->body.s, msg->callid->body.len); if (append_tag) { - old_callid[msg->callid->body.len] = - TH_FROM_TAG_SEP ^ topo_hiding_seed.s[j++%topo_hiding_seed.len]; - for (i = 0; i < tag->len; i++, j++) - old_callid[j] = - tag->s[i] ^ topo_hiding_seed.s[j%topo_hiding_seed.len]; - } - - *enc_len = calc_word64_encode_len(len) + topo_hiding_prefix.len; - new_callid = pkg_malloc(*enc_len); - if (new_callid==NULL) { - LM_ERR("Failed to allocate new callid\n"); - pkg_free(old_callid); - return NULL; + plain.s[msg->callid->body.len] = TH_FROM_TAG_SEP; + memcpy(plain.s + msg->callid->body.len + 1, tag->s, tag->len); } - memcpy(new_callid,topo_hiding_prefix.s,topo_hiding_prefix.len); - - word64encode((unsigned char *)(new_callid+topo_hiding_prefix.len), old_callid, len); - pkg_free(old_callid); - return new_callid; + if (th_callid_codec_encode(&plain, &encoded) < 0) { + LM_ERR("failed to encode Call-ID\n"); + pkg_free(plain.s); + return NULL; + } + pkg_free(plain.s); + *enc_len = encoded.len; + return encoded.s; } static int dlg_th_encode_callid(struct sip_msg *msg) @@ -886,12 +865,7 @@ static int dlg_th_needs_decoding(struct sip_msg *msg) return 0; } - if (msg->callid->body.len > topo_hiding_prefix.len && - memcmp(msg->callid->body.s,topo_hiding_prefix.s, - topo_hiding_prefix.len) == 0) - return 1; - - return 0; + return th_callid_codec_is_encoded(&msg->callid->body); } static inline char *dlg_th_rebuild_req(struct sip_msg *msg,int *len) @@ -950,6 +924,36 @@ static int dlg_th_callid_pre_parse(struct sip_msg *msg,int want_from) return -1; } +#ifdef UNIT_TESTS +int th_test_encode_callid_raw(str *data) +{ + struct sip_msg msg; + char *rebuilt; + int rebuilt_len; + + memset(&msg, 0, sizeof(msg)); + msg.buf = data->s; + msg.len = data->len; + if (dlg_th_callid_pre_parse(&msg, 1) < 0 || + dlg_th_encode_callid(&msg) < 0) + goto error; + if (msg.first_line.type == SIP_REQUEST) + rebuilt = dlg_th_rebuild_req(&msg, &rebuilt_len); + else + rebuilt = dlg_th_rebuild_rpl(&msg, &rebuilt_len); + if (!rebuilt) + goto error; + data->s = rebuilt; + data->len = rebuilt_len; + free_sip_msg(&msg); + return 0; + +error: + free_sip_msg(&msg); + return -1; +} +#endif + int topo_callid_pre_raw(str *data, struct sip_msg* foo) { struct sip_msg msg; diff --git a/modules/topology_hiding/topo_hiding_logic.h b/modules/topology_hiding/topo_hiding_logic.h index f97c636b39b..c1e14605e39 100644 --- a/modules/topology_hiding/topo_hiding_logic.h +++ b/modules/topology_hiding/topo_hiding_logic.h @@ -52,4 +52,10 @@ void th_loaded_callback(struct dlg_cell *dlg, int type, struct dlg_cb_params *_params); char *th_get_encoded_callid(struct sip_msg *msg, str *tag, int *enc_len); +#ifdef UNIT_TESTS +int th_test_build_encoded_contact_suffix(struct sip_msg *msg, int *suffix_len, + char **suffix); +int th_test_encode_callid_raw(str *data); +#endif + #endif diff --git a/modules/topology_hiding/topology_hiding.c b/modules/topology_hiding/topology_hiding.c index 36beabc84b3..17619ef9beb 100644 --- a/modules/topology_hiding/topology_hiding.c +++ b/modules/topology_hiding/topology_hiding.c @@ -29,6 +29,7 @@ #include "topo_hiding_logic.h" #include "th_no_dlg_logic.h" +#include "topo_hiding_codec.h" #include "../../trim.h" #include "../../ut.h" @@ -44,6 +45,7 @@ str topo_hiding_prefix = str_init("DLGCH_"); str topo_hiding_seed = str_init("OpenSIPS"); str th_contact_encode_scheme = str_init("base64"); +str th_callid_encode_scheme = str_init("xor-word64"); str th_contact_caller_var = str_init("_th_contact_caller_username_var_"); str th_contact_callee_var = str_init("_th_contact_callee_username_var_"); @@ -53,6 +55,8 @@ str th_use_param = DEFAULT_PARAM; int auto_route_on_trusted_socket = 1; int th_ct_enc_scheme; +int th_callid_enc_scheme; +static char *callid_buf = NULL; /* Global buffer for decoded routes */ str decoded_uris[MAX_ENCODED_SIP_URIS]; @@ -99,6 +103,7 @@ static const param_export_t params[] = { { "th_passed_contact_params", STR_PARAM, &topo_hiding_ct_hdr_params.s }, { "th_callid_passwd", STR_PARAM, &topo_hiding_seed.s }, { "th_callid_prefix", STR_PARAM, &topo_hiding_prefix.s }, + { "th_callid_encode_scheme", STR_PARAM, &th_callid_encode_scheme.s }, { "th_contact_encode_scheme", STR_PARAM, &th_contact_encode_scheme.s }, { "th_contact_caller_username_var", STR_PARAM, &th_contact_caller_var.s }, { "th_contact_callee_username_var", STR_PARAM, &th_contact_callee_var.s }, @@ -178,6 +183,22 @@ static int mod_init(void) /* param handling */ topo_hiding_prefix.len = strlen(topo_hiding_prefix.s); topo_hiding_seed.len = strlen(topo_hiding_seed.s); + th_callid_encode_scheme.len = strlen(th_callid_encode_scheme.s); + if (!str_strcmp(&th_callid_encode_scheme, const_str("xor-word64"))) + th_callid_enc_scheme = TH_CALLID_ENC_XOR_WORD64; + else if (!str_strcmp(&th_callid_encode_scheme, const_str("ff1-alnum62"))) + th_callid_enc_scheme = TH_CALLID_ENC_FF1_ALNUM62; + else { + LM_ERR("Unsupported value for 'th_callid_encode_scheme' modparam! " + "Use 'xor-word64' or 'ff1-alnum62'\n"); + goto error; + } + if (th_callid_codec_init(th_callid_enc_scheme, &topo_hiding_seed, + &topo_hiding_prefix) < 0) + goto error; + if (th_callid_enc_scheme == TH_CALLID_ENC_FF1_ALNUM62 && th_loop_protection) + LM_WARN("th_callid_loop_protection adds an internal separator and " + "therefore uses the legacy Call-ID fallback encoding\n"); if (topo_hiding_ct_params.s) { topo_hiding_ct_params.len = strlen(topo_hiding_ct_params.s); topo_parse_passed_ct_params(&topo_hiding_ct_params); @@ -250,6 +271,11 @@ static int mod_init(void) static void mod_destroy(void) { + th_callid_codec_cleanup(); + if (callid_buf) { + pkg_free(callid_buf); + callid_buf = NULL; + } th_free_param_passwords(); } @@ -383,7 +409,6 @@ int w_topology_hiding_match(struct sip_msg *req, void *seq_match_mode_val) return 1; } -static char *callid_buf=NULL; static int pv_topo_callee_callid(struct sip_msg *msg, pv_param_t *param, pv_value_t *res) { struct dlg_cell *dlg;