Skip to content

Implement RFC 3830 key derivation - #23

Merged
waxspin merged 3 commits into
mainfrom
fix-key-derivation
Oct 7, 2026
Merged

waxspin merged 3 commits into
mainfrom
fix-key-derivation

Conversation

@waxspin

@waxspin waxspin commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Closes #22.

@jcowgill was right on all three points in #22, and I confirmed each against the RFC text before changing anything.

What was wrong

The PRF was not RFC 3830 §4.1.2. It was a counter-mode KDF computing HMAC(key, label || 0x00 || i || len) per block. The RFC specifies a TLS-style feedback construction with input-key splitting:

P(s, label, m) = HMAC(s, A_1||label) || ... || HMAC(s, A_m||label)
    A_0 = label,  A_i = HMAC(s, A_(i-1))
PRF(inkey, label) = P(s_1,label,m) XOR ... XOR P(s_n,label,m)

Side by side on the same input, before the fix:

mykey PRF = d6bb06fe73a35dc5b2ba0ec64e78fd08
RFC   PRF = 437f424dae917437abd62a0443ac0100

The labels were ad-hoc ASCII ("TGK", "AUTH", "SRTP_KEY", …) instead of the RFC layouts, and csb_id was never mixed into any derived key.

The i as u8 counter wrapped, repeating the keystream every 5120 bytes — reachable only well beyond any caller's request (the largest is 32 bytes), and gone entirely now that the construction has no counter.

What changed

  • mikey_prf implements §4.1.2, including the 256-bit input-key split and XOR combination.
  • RFC §4.1.3 and §4.1.4 label layouts and all seven constants. The SRTP master key and salt are now the TEK (0x2AD01C64) and salting key (0x39A2C14B).
  • csb_id is threaded from the MIKEY common header through derive_srtp_keys, derive_auth_key, and derive_enc_key.
  • The PSK message authentication key derives from the pre-shared key per §4.1.4 rather than from the TGK. §4.1.4 is defined as derivation from the envelope/pre-shared key, so this follows from adopting the label; it also means the MAC key is no longer recoverable from a value that travels in the clear.
  • An empty input key is rejected rather than silently yielding an all-zero key — with no blocks to iterate, the XOR accumulator would have stayed zero.

New tests lock the feedback construction against a reference written independently from the RFC text, plus the block splitting and XOR, both label layouts, the constants, and cs_id/csb_id reaching the output. The reference value above was computed before the implementation was written, and the new code reproduces it.

Documentation

New Deviations from RFC 3830 chapter recording every known departure, so this reasoning is written down rather than rediscovered: the non-RFC TGK derivation, X25519 on private-use DH-Group 255 rather than OAKLEY 5, the absent SIGN payload, and the PSK gaps below.

On the DH group specifically — switching to OAKLEY 5 would not buy interoperability. The RFC's DH method is signature-authenticated (SIGNi/SIGNr), and mykey emits no SIGN or CERT payload, so a conformant peer rejects the message regardless of group. OAKLEY 5 is also ~96-bit security against X25519's ~128. IANA reserves DH-Group 241–255 for private use, so 255 is a legitimate code point; no EC group has ever been registered for MIKEY.

The PSK chapter claimed "Mutual authentication: Yes" and "MITM protection: Yes". Neither holds, so I corrected the table and added a warning.

Not addressed here

PSK mode still builds its KEMAC with NULL encryption and puts the TGK in enc_data in the clear, so anyone who can read a PSK-Init recovers the SRTP master key without the PSK. No receive path verifies the KEMAC MAC either. Both need their own issue — the disclosure is exploitable in released 1.0.0, not merely non-interoperable, so it likely warrants a SECURITY.md advisory rather than being folded in here.

Breaking

Every derived key changes value, and derive_srtp_keys / derive_auth_key / derive_enc_key take new arguments. Wire-incompatible with 1.0.0 — both ends of an exchange must upgrade together. This warrants a 2.0.0 release. Cargo.toml is deliberately left at 1.0.0 for cargo-release to bump; the break is recorded in CHANGELOG.md under [Unreleased].

Downstream: rlac pins mykey = "1.0.0" and calls new_psk_init / complete_psk. Those call sites still compile unchanged, but it needs a coordinated bump once this releases.

The PRF was a counter-mode KDF, not the construction in RFC 3830 §4.1.2,
so no key derived by mykey could match another MIKEY implementation.

Replace it with the specified feedback construction:

    P(s, label, m) = HMAC(s, A_1||label) || ... || HMAC(s, A_m||label)
        A_0 = label,  A_i = HMAC(s, A_(i-1))
    PRF(inkey, label) = P(s_1,label,m) XOR ... XOR P(s_n,label,m)

with the input key split into 256-bit blocks. This also removes the
`i as u8` block counter, which wrapped and repeated its keystream after
5120 bytes of output, and the `output_len as u16` truncation.

Reject an empty input key: with no blocks to iterate the XOR accumulator
would have stayed zero and silently returned an all-zero key.

Replace the ad-hoc ASCII labels ("TGK", "AUTH", "SRTP_KEY", ...) with the
RFC label layouts and constants:

    §4.1.3, from a TGK:  constant || cs_id || csb_id || RAND
    §4.1.4, from a PSK:  constant || 0xFF  || csb_id || RAND

The SRTP master key and salt are now the TEK (0x2AD01C64) and salting key
(0x39A2C14B). csb_id was not mixed in at all before; it is now threaded
from the common header through derive_srtp_keys and derive_auth_key.

The PSK message authentication key is now derived from the pre-shared key
per §4.1.4 rather than from the TGK. Deriving it from the TGK meant the
MAC key was recoverable by anyone who could read the TGK, which is sent
in the clear in the KEMAC payload (tracked separately).

BREAKING: every derived key changes value, and derive_srtp_keys,
derive_auth_key and derive_enc_key take new arguments. Wire-incompatible
with mykey 1.0.0 on both sides of an exchange.
Add a "Deviations from RFC 3830" chapter cataloguing every known departure
from the spec, so the reasoning is written down rather than rediscovered:

- the TGK is derived through the PRF, which the RFC does not do in either
  the DH method (TGK = g^(xi*xr)) or the PSK method (TGK is transported)
- DH mode uses X25519 on private-use DH-Group 255 rather than OAKLEY 5,
  and emits no SIGN payload, so it cannot interoperate with a compliant
  implementation regardless of the group
- PSK mode sends the TGK in the clear and never verifies the MAC
- the public-key method is not implemented

Correct the PSK chapter, which claimed mutual authentication and MITM
protection. Neither holds today: anyone who can read a PSK-Init recovers
the SRTP master key without the PSK, and no receive path checks the MAC.

Add a CHANGELOG recording the breaking key-derivation changes, and point
README and the crate docs at the deviations chapter.
Record the gaps found while scoping interoperability work, and update the
chapter's conclusions now that OAKLEY support is planned rather than ruled out.

Wire-format findings, none of them fixed here:

- "Last payload" is 255 in `PayloadType`, where RFC 3830 Table 6.1.b assigns
  0 (and assigns nothing to HDR). Every chain mykey emits therefore terminates
  with an undefined value, and inbound parsing only works because the loop
  also stops on buffer exhaustion — a conformant 0 terminator resolves to
  `Hdr` and errors as `InvalidPayloadType(0)`. Affects every message in every
  mode and is the highest-value interop fix.
- `TimestampType::value_len()` returns 4 for type 1, where Table 6.6 says 64
  bits, so the payload cursor advances four bytes short. Measured: types 0 and
  2 parse correctly, only type 1 is affected, and it fails closed. The variant
  name `NtpShort` is also a misnomer.
- Key data sub-payloads (§6.13) are not implemented; the TGK is written bare.
- The verification message (§6.9) is never built or checked, so PSK mode
  offers no mutual authentication.
- COUNTER is emitted rather than a mandatory NTP timestamp type, and no replay
  handling exists.

Split the TGK section, which previously justified one deviation but described
two. In PSK mode, deriving the TGK instead of transporting it cannot
interoperate, is the reason the TGK travels in the clear, and concentrates all
session freshness in RAND; §3.1 compliance removes it. RFC 4650 requires the
raw DH result, so an authenticated-DH mode must not carry it either. It
survives only for X25519, where RFC 7748 §6.1 advises a KDF and no registered
EC group exists to be conformant with — so there is no compliant alternative.

Note the two RFC 7748 refinements still outstanding for that mode: the public
keys are not bound into the derivation (§6.1), and there is no contributory or
all-zero check (§7). `PinnedPeer::verify` compares all 32 bytes, so the
equivalent-encoding concern in §7 is a robustness gap rather than a known
exploit.

Also rewrite the OAKLEY conclusion from "we do not intend to switch" to the
actual plan — opt-in OAKLEY with X25519 as default, DHHMAC for authenticated
DH, §3.3 an explicit non-goal — and record that MIKEY has no method
negotiation, so multi-method support means explicit configuration rather than
automatic downgrade.
@waxspin
waxspin merged commit 788f1d1 into main Oct 7, 2026
5 checks passed
@waxspin
waxspin deleted the fix-key-derivation branch October 7, 2026 02:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Key derivation does not appear to match RFC 3830

1 participant