Part 12 of TETRA End to End, a 14-part deep dive into how GopherTrunk turns one real 25 kHz TETRA carrier into clear recorded voice. Part 11 ended on a split verdict: DMO signalling decoding at 90%+, DMO speech stuck at the CRC chance floor, and a published conclusion — “the voice is encrypted” — that felt reasonable and was wrong. This part is the correction, told in the order it actually happened, because the *shape of the mistake is the valuable part: a one-line shortcut that was perfectly safe in trunked mode, silently lethal in direct mode, and invisible to every test that scrambled and descrambled with the same code. Part 4 planted the warning — colour 0 is not a no-op. Here is where the bill came due.*
TL;DR: The “encrypted” DMO voice was clear all along. The reporter’s CPS codeplug proved it — TEA0, no key, colour code 0 — and the real cause was
DMBurstTCHSpeech/DMBurstTCHSpeechSoftskipping descrambling atcolour==0, a shortcut inherited from TMOtraffic.gowhere an extended colour code is never 0. TETRA scrambling is non-identity at colour 0 (NewScramblerTetra(0)seeds the LFSR to 0xC0000000, §8.2.5.2 eq. 8.42), so a clear colour-0 DNB went into the Viterbi still scrambled — producing the uniform ~1/256 chance floor misread as encryption. The fix descrambles unconditionally; the failing-first regression scrambles the encode side unconditionally too, so colour-0 rounds now catch the asymmetry. Then a second capture showed voice descrambling at colour 3 while the signalling advertises 0 —tch_crc1/269 → 35/269, 70 speech frames, 2.1 s of PCM — soRecoverDMColourCodelearns the traffic colour by maximising CRC-valid TCH/S under a dominance gate, instead of hardcoding a bit offset that one capture cannot pin.
Key takeaways
- “Encrypted” is a conclusion of last resort. A uniform chance floor under every decode hypothesis is also the signature of a systematically wrong transform — scrambling, geometry, interleave. Rule those out with known-clear traffic before reaching for the encryption verdict.
- A shortcut’s safety is contextual.
if colour != 0 { descramble }was provably safe in TMO, where the extended colour code embeds a nonzero MNI. In DMO, where 0 is a common configured value, the same line is a decode killer. - Self-consistent tests pass on both sides of a bug. Round-trips that scrambled and descrambled with the same conditional couldn’t fail. The regression now models the transmitter faithfully — scramble always — so the receiver’s skip has nothing to hide behind.
- When a field can’t be pinned, learn it from the payload. The DM colour’s exact SCH/H bit offset is ambiguous on one capture; hardcoding a guess is the same trap again. Brute-forcing 0..63 against CRC yield is self-verifying.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| The fix | descramble TCH/S unconditionally, colour 0 included | internal/radio/tetra/dmo_decode.go (DMBurstTCHSpeech, DMBurstTCHSpeechSoft) |
| The seed rule | colour 0 seeds the LFSR to 0xC0000000 | internal/radio/framing/scramble_tetra.go (NewScramblerTetra, §8.2.5.2 eq. 8.42) |
| The inherited shortcut | TMO’s colour != 0 guard, safe only in TMO |
internal/radio/tetra/traffic.go (emit) |
| Failing-first regression | encode side scrambles unconditionally | internal/radio/tetra/dmo_decode_test.go (TestDMTCHSpeechRoundTrip) |
| Colour recovery | argmax CRC-valid TCH/S over colours 0..63 | dmo_decode.go (RecoverDMColourCode) |
| Confidence gate | ≥6 CRC-valid and ≥3× the runner-up | dmo_decode.go (dmColourMinCRC, dmColourDominance) |
| Recovery regression | synthetic pin of the recovery + gate | dmo_decode_test.go (TestRecoverDMColourCode) |
| Capture harness | replay, colour override, clear-assertion verdict | cmd/gophertrunk/tetra_dmo_replay_test.go (GT_TETRA_DMO_COLOUR, GT_TETRA_DMO_CLEAR) |
In this post
- The wrong verdict — what the chance floor looked like from inside.
- The evidence that overturned it — a codeplug is ground truth.
- The one-line cause — an inherited shortcut and a non-identity seed.
- Why the tests couldn’t see it — the self-consistent trap, again.
- The second capture’s twist — voice at colour 3, signalling at colour 0.
- Learning the colour from the payload —
RecoverDMColourCodeand its gate.
The wrong verdict
On the first capture, TestTETRADMOReplay decoded DSB signalling at better
than 90% and TCH/S speech at almost exactly 1/256 — the probability that
random class-2 bits pass an 8-bit CRC. Uniform, colourless, hypothesis-proof:
sweeping decode parameters moved nothing. For a professional radio system,
“air-interface encryption” fit that shape, and the issue said so. The error
wasn’t in the observation — it was in the space of hypotheses. A chance floor
means the decoder’s output is uncorrelated with the payload; encryption causes
that, but so does any systematically wrong transform applied to every burst.
The two are indistinguishable from yield alone, which is why the distinction
has to come from outside the decoder.
The evidence that overturned it
It did: the reporter pulled the radios’ CPS codeplug. Channels
DMO_438.900/DMO_438.800, TEA0 — clear, no encryption — Security Class
1, NO_KG, colour code 0. That is exactly the known-clear evidence the
original analysis had said would be required to distinguish the cases, and it
flipped the problem statement completely: a clear transmission at the chance
floor is a decode defect, full stop. The replay harness now encodes that
logic — GT_TETRA_DMO_CLEAR=1 flips its VERDICT line so a persistent floor on
an asserted-clear capture is reported as “a defect to keep chasing, NOT
encryption.”
The one-line cause
With “encrypted” off the table, the transform audit found it fast, and Part 4
readers already know the shape. TETRA’s scrambler is non-identity at colour
0: NewScramblerTetra(0) seeds the LFSR to 0xC0000000 per §8.2.5.2
eq. 8.42 — which is precisely why every BSCH and SCH/S decoder in the codebase
descrambles unconditionally. That is how the DSB signalling on this very
capture decoded: colour-0 descramble applied, CRCs pass. But the DMO voice
path had inherited TMO traffic.go’s optimization:
// internal/radio/tetra/traffic.go (shape) — the TMO shortcut, safe THERE only
if te.colourCode != 0 {
bits = framing.DescrambleTetra(bits, te.colourCode)
}
In TMO this is fine — the extended colour code embeds the network’s MNI and is never 0 in practice. In DMO, 0 is the spec default and a common codeplug value, so a clear colour-0 DNB sailed into the Viterbi still scrambled — random in, random out, ~1/256 through the CRC. The fix deletes the guard on both DMO TCH/S paths:
// internal/radio/tetra/dmo_decode.go (shape) — DMBurstTCHSpeech, fixed
// The descramble is UNCONDITIONAL — including at colour 0. TETRA scrambling
// is non-identity at colour 0 (seed 0xC0000000), so a clear (TEA0) colour-0
// DMO transmitter still scrambles TCH/S (issue #1003).
type5 = framing.DescrambleTetra(type5, colour)
return TCHSpeechFrames(framing.PackBitsMSB(type5))
Why the tests couldn’t see it
The uncomfortable question: an earlier sweep had explicitly tried “with and
without a colour-0 descramble” and measured no difference. How? Because the
synthetic round-trips scrambled and descrambled through the same
conditional — at colour 0 the encoder skipped scrambling and the decoder
skipped descrambling, so both variants round-tripped perfectly. The test was
self-consistent with the bug on both sides — the same failure class as the
class-2 CRC episode in
Part 3
and the SoapyRemote opcode story, dissected as a pattern in
From the Issue Tracker #20.
The repaired regression models the air, not the code under test:
dmo_decode_test.go now scrambles the encode side unconditionally — real
transmitter behaviour — so the colour-0 iterations are failing-first. Verified
both ways: old code decodes 0 frames at colour 0; fixed code decodes the 2
CRC-valid frames the fixture carries.
The second capture’s twist
The operator then recorded a fresh on-air A/B
(10aug_dmo_test_bw144_cs16.raw, 438.9 MHz, replayed with
GT_TETRA_DMO_RATE=144000). Signalling: pristine — dsb_schs_crc=44/45,
distinct frame numbers advancing. Voice at the advertised colour 0:
tch_crc=1/269. Still the floor — but this time the sweep was unambiguous.
At colour 3: tch_crc=35/269, speech_frames=70, 2.1 s of PCM, voice
activity across seconds 1–8 of the capture. Every other colour stayed at the
floor (and the staged LMS equalizer didn’t move it — 35→32 — this was never an
equalization problem). So the signal is neither weak nor encrypted: it is
clear voice whose TCH/S descrambles with a different colour code than the
signalling advertises. The SYNC PDU says 0; the speech says 3.
Learning the colour from the payload
Where does colour 3 live on the air? Not in the SCH/S — that block is always colour-0 scrambled and reads 0 by construction. It is carried in the DSB SCH/H’s DM-SYNC SYSINFO (EN 300 396-3) — but on a single capture where the value is 3, only the field’s two least-significant bits light up, and several candidate bit offsets fit. An empirical scan found no unique 6-bit window. Hardcoding one guess would be the self-consistent trap with a new coat of paint: a wrong offset that happens to read 3 on this capture would decode this capture and silently fail every other network. So the colour is learned from the payload instead:
// internal/radio/tetra/dmo_decode.go (shape) — RecoverDMColourCode
// Picks the colour (0..63) that yields the most CRC-valid speech frames
// across the given DNBs — soft decode with hard fallback, same as production.
for c := 0; c < 64; c++ {
for i := range bursts { /* count CRC-valid TCH/S at colour c */ }
}
confident = best >= dmColourMinCRC && best >= dmColourDominance*max(second, 1)
return uint32(bestC), best, confident
The confidence gate is the honest part: the winner must clear 6 CRC-valid
bursts and beat the runner-up 3×. On the 10aug capture that is 35 vs ≤3 —
no contest. On an encrypted call or a dead capture, nothing clears the gate and
the caller keeps its configured default rather than trusting a chance-floor
winner. TestTETRADMOReplay now auto-recovers when GT_TETRA_DMO_COLOUR is
unset — on 10aug it recovers colour 3 and produces the full 35/70/2.1 s result
with no manual override — and TestRecoverDMColourCode pins the synthetic.
Where this goes next
Offline, DMO now locks, recovers its colour, and decodes clear voice. But “offline” is carrying weight in that sentence: everything so far runs in a replay harness against files. Part 13 wires DMO into the production daemon — a new protocol, a streaming extractor, sticky locks and edge-triggered grants — and immediately pays for a lesson in false-alarm statistics when the first on-air run grants on an empty channel 230 ms after startup.
FAQ
How could professional radios scramble voice with a colour the signalling doesn’t advertise? The honest answer is: unresolved. The SYNC PDU self-consistently reads MNI=0/colour=0 (GopherTrunk’s offsets match osmo-tetra-dmo’s scrambler-init derivation), yet the traffic keystream is colour 3’s. Whether it is a codeplug quirk, a vendor interpretation, or a field GT still mis-locates, the payload is the arbiter — which is exactly why recovery scores CRC yield instead of trusting any parsed field.
Isn’t brute-forcing 64 colours expensive? Bounded and cheap where it matters: 64 candidate decodes over a ~20-burst batch, once. The expensive version — re-running it on every arriving burst — did briefly exist in the live voice chain and starved its own IQ tap; Part 13 covers the cost cap that fixed it.
Why does the gate demand 3× dominance instead of just taking the max? Because a marginal signal produces partial keystream artifacts: several colours rise modestly at once, none dominant. One radio scrambling with one colour cannot produce that pattern — so a non-dominant winner means “signal too poor to trust,” and latching it would misdescramble a later, better transmission. Part 14 shows a real capture where the gate correctly refuses.
Did the LMS equalizer help at all here? No — 35→32, i.e. noise. That is itself diagnostic: the losses on this capture weren’t linear-channel ISI (Part 9’s regime); they were a wrong keystream. An equalizer cannot fix a descramble, and the fact that it didn’t move confirmed the transform hypothesis over the channel hypothesis.
What would this bug have looked like with encryption actually present?
Nearly identical yields — that’s the point. The differences: a codeplug
declaring a key (not TEA0/NO_KG), no colour clearing the dominance gate at
any value, and no colour sweep spike. Clear-but-misdescrambled and encrypted
separate only through evidence outside the decoder plus the sweep’s shape.
Series navigation
Part 12 of 14 · ← Part 11: DMO I — Direct Mode & the DSB/DNB Geometry · Next → Part 13: DMO III — A Production Pipeline: Grid Votes, Grants & Noise