Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions modules/topology_hiding/CALLID_CODEC.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions modules/topology_hiding/CONTACT_GROWTH.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions modules/topology_hiding/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
include ../../Makefile.defs
auto_gen=
NAME=topology_hiding.so
LIBS+=-lcrypto

include ../../Makefile.modules
64 changes: 62 additions & 2 deletions modules/topology_hiding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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""*

Expand All @@ -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_""*

Expand All @@ -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)


Expand Down Expand Up @@ -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
All documentation files (i.e. .md extension) are licensed under the Creative Common License 4.0
24 changes: 24 additions & 0 deletions modules/topology_hiding/test/opensips.cfg
Original file line number Diff line number Diff line change
@@ -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;
}
Loading