P25 End to End, Part 9: Encryption Signalling — Flags, Metadata & Policy

Part 9 of P25 End to End, a 14-part deep dive that follows North America’s dominant trunking protocol through GopherTrunk — from a raw C4FM carrier to recorded, named, multi-site voice. Part 8 followed an IMBE voice call from LDU superframes to a WAV on disk. This part is about the calls that should never make that trip: where “encrypted” actually lives on a P25 carrier — one bit on the grant, and a 96-bit Encryption Sync buried inside the voice stream — and the policy machinery that decides what a scarce voice tuner, and your disk, should do about a call no scanner can decode.

TL;DR: P25 signals encryption in two places GopherTrunk reads. The grant’s Service Options octet carries a protected bit (ServiceOptions.Encrypted) — one bit, known before voice starts. The metadata arrives only in-call: the Encryption Sync — a 72-bit Message Indicator, Algorithm ID and Key ID — rides every LDU2 under a Hamming(10,6,3) inner FEC and an outer RS(24,16,9) (phase1.ParseEncryptionSync). ALGID 0x80 means clear; anything else names a cipher (0x84 = AES-256, 0xAA = ADP/RC4). On Phase 2 the same triple rides the MAC_PTT message at key-up (issue #813). Per-system encrypted_calls policy — follow / metadata / ignore (issue #711) — decides whether an encrypted call keeps its voice SDR, and recordings.skip_encrypted (issue #607) keeps undecodable audio off the disk. GopherTrunk identifies encryption; it does not break it.

Key takeaways

  • Encryption is signalled twice, at different layers. The grant says that a call is protected, before allocation; only the traffic channel says how. Everything downstream is built around that gap: events that backfill, policies that re-evaluate, recordings that abort.
  • The metadata is FEC’d like it matters — and gated like it lies. An Algorithm ID that survives both FEC layers but lands outside the TIA-102 registry is provably a mis-decode (p25.AlgorithmKnown, issue #924); a plausible-but-wrong key label is worse than none.
  • Policy is per system, and configured keys trump policy. follow / metadata / ignore are set per trunking.systems[] entry; a call whose Key ID matches a configured encryption_keys entry is always followed.
  • Identify, never decrypt. GopherTrunk surfaces ALGID/KID/MI the way SDRtrunk and OP25 do. Known-key decode exists only for DMR ARC4; on P25 there is no decryption and — everywhere — no key recovery of any kind.

Cheat sheet

Concern What it does Where it lives
Grant protected bit “this call is encrypted”, known at grant time phase1.ServiceOptions.Encrypted (opcodes.go)
LDU2 Encryption Sync MI + ALGID + KID; Hamming(10,6,3) + RS(24,16,9) internal/radio/p25/phase1/encryption_sync.go (ParseEncryptionSync)
Phase 2 key-up sync the ES triple riding MAC_PTT (slot type, not opcode) internal/radio/p25/phase2/mac_standard.go (AsPushToTalk)
Algorithm registry ALGID → mnemonic + the out-of-set gate internal/radio/p25/algorithm.go (AlgorithmName, AlgorithmKnown)
In-call event path composer → bus → engine backfills the live Grant events.KindCallEncryption (trunking.CallEncryption)
Tuner policy follow / metadata / ignore, per system internal/trunking/encryptedmode.go (EncryptedMode)
Recording guard never write, or abort-and-delete mid-call internal/voice/recorder.go (Options.SkipEncrypted)

In this post

  • Two flags, two layers — the one-bit grant flag vs the 96-bit in-call word.
  • The Encryption Sync word — MI, ALGID, KID and the FEC under them.
  • Phase 2: four layers between a flag and its metadata — the postmortem behind the design.
  • From burst to policy — the event path, and what follow/metadata/ignore do.
  • What GopherTrunk won’t do — identification vs decryption vs key recovery.

Two flags, two layers

A P25 voice grant (Part 3) carries an 8-bit Service Options octet; bit 0x40 is the protected flag:

Signal Where When What it tells you
Service Options bit 0x40 grant TSBK / MAC grant, control channel before the voice SDR is allocated encrypted yes/no — nothing else
Encryption Sync (Phase 1) every LDU2 on the traffic channel mid-call, repeating MI (72 bits) + ALGID + KID
MAC_PTT ES (Phase 2) key-up message on the traffic channel each talk spurt the same triple (issue #813)

The protected bit is the early warning: Grant.Encrypted is set before the voice pool ever binds a tuner, which is what lets the ignore policy drop a grant without spending anything on it. But one bit is all it is — no algorithm, no key, and on compressed Phase 2 grants not even the bit.

The spec also front-loads the full MI/ALGID/KID triple into the Header Data Unit at key-up. GopherTrunk names the HDU in its DUID zoo (DUIDHeader, Part 2) and deliberately leaves it undecoded: a trunked tune-in arrives after key-up — grant, retune, DDC settle, frame lock — so a one-shot header at call start is exactly the frame a scanner misses. The same triple repeats in every LDU2, one per 360 ms superframe, and that repeating copy is the one the pipeline is built on.

The Encryption Sync word

The LDU2 donates the six 40-bit slots an LDU1 spends on Link Control (Part 8) to an Encryption Sync word, under the identical inner FEC — 24 codewords of shortened Hamming(10,6,3) — plus an outer Reed-Solomon layer:

// internal/radio/p25/phase1/encryption_sync.go (shape)
type EncryptionSync struct {
    MessageIndicator [9]byte // 72-bit per-call crypto sync vector (the IV)
    AlgorithmID      uint8   // 0x80 = clear; 0x81 DES-OFB, 0x84 AES-256, …
    KeyID            uint16  // which key in the radio's keyset
}

func (e EncryptionSync) Encrypted() bool { return e.AlgorithmID != AlgorithmClear }

func ParseEncryptionSync(blocks [LDULCESBlockCount][]byte) (EncryptionSync, int, error) {
    data, innerErrs, err := lcInnerDecode(blocks) // 24 × Hamming(10,6,3)
    /* … */
    info, rsErrs, rerr := framing.DecodeRS24_16(cw[:]) // outer RS(24,16,9), t=4
    if rerr != nil {
        return EncryptionSync{}, innerErrs, ErrEncryptionSyncUncorrectable
    }
    /* … octets 0-8 → MI, octet 9 → ALGID, octets 10-11 → KID … */
}

Note what the clear value is not: zero. A clear call advertises ALGID 0x80, so “encrypted” means AlgorithmID != 0x80 — a detail that bites any decoder treating the field as a boolean. The IDs come from the TIA-102.AACE-A registry, mirrored in internal/radio/p25/algorithm.go: 0x81 DES-OFB, 0x84 AES-256, 0x85 AES-128, 0xAA ADP/RC4 (the “cheap encryption” you meet most often), 0x9F DES-XL among others (full table).

The two FEC layers earn their keep under marginal SNR, but they hide a trap: an RS decode can report success while “correcting” to garbage when more errors survive the inner layer than t=4 can honestly fix. A bit-error smears the Algorithm ID roughly uniformly across 0x00–0xFF — surfaced, that’s indistinguishable from a real key downstream. So both phases gate on the registry (p25.AlgorithmKnown, issue #924): an out-of-set ALGID is dropped with a debug line (composer: p25p1 dropping out-of-set encryption sync) rather than published. The set tracks AlgorithmName, so a genuinely new algorithm is admitted the moment it gets a name.

Phase 2: four layers between a flag and its metadata

Phase 2 (Part 7) tells the same two-layer story with MAC PDUs. The protected bit rides the grant’s Service Options as on Phase 1 — and also the in-call GROUP_VOICE_CHANNEL_USER PDU, which matters because Phase 2 grants often arrive compressed, with no source and no service options; the traffic channel resolves both mid-call via a KindCallSourceUpdate the engine folds onto the live call.

The metadata took longer to find. GopherTrunk’s first model put ALGID/KID/MI in a standalone Encryption Sync MAC opcode (AsEncryptionSync) — and on real systems the triple doesn’t ride there. It rides the MAC_PTT message at key-up, identified by its slot type rather than any opcode byte (AsPushToTalk, issue #813, offsets cross-checked against SDRtrunk). Getting to that answer was the subject of From the Issue Tracker Part 3: a system that flagged every encrypted call correctly but never named an algorithm or key, because a fictional MAC opcode, a missing carrier loop, a 48-bit sync constant in a 40-bit field and a swapped dibit map were stacked on top of each other — four layers between a flag and its metadata.

Two design rules survive from that postmortem: the working-model layouts are confined to one file each, so a spec correction is a one-file change; and the protected bit flags encryption independently of the metadata parse, so a wrong layout degrades to “encrypted true, alg/key zero” instead of failing silently.

From burst to policy

The full path a discovered-encrypted call takes through the daemon:

control channel (early) traffic channel (mid-call) grant TSBK / MAC grant SVC options bit 0x40 → Grant.Encrypted LDU2 ES / Phase 2 MAC_PTT MI + ALGID + KID, Hamming + RS composer: ParseEncryptionSync AlgorithmKnown gate (#924) → bus event KindCallEncryption trunking engine backfills ALGID/KID onto the live Grant · applies policy recorder: skip_encrypted never open, or abort-and-delete call log + SSE/API alg/key columns, E(alg=…,key=…) flag encrypted_calls policy follow · metadata (1.5 s) · ignore configured key ⇒ always follow
Two signals — one early and thin, one late and rich — converge on the engine, which owns the policy decision everything downstream inherits.

The Phase 1 composer publishes a KindCallEncryption event when an LDU2’s ES survives both FEC layers and the registry gate — deduplicated, since ALGID/KID rarely change within a call. The engine backfills the bound ActiveCall.Grant and republishes with the call’s identity — why a grant log line grows a suffix mid-call: E(alg=0x84,key=0x01A3) — and both values persist into the call log, naming which key a recorded call would need. What the engine then does is the per-system encrypted_calls policy (issue #711), Trunking Engine Part 11’s subject:

  • follow (the default and the zero value — pre-existing configs see no change): hold the voice SDR for the full call, like a clear one.
  • metadata: follow just long enough to harvest what the traffic channel gives away — talker alias, source RID, the ES itself — then release. The window is metadata_follow_ms (default 1500 ms); on Phase 2 the release short-circuits the moment the talker alias completes.
  • ignore: never spend a tuner. A flagged grant is dropped before allocation (dropping encrypted grant (encrypted_calls mode: ignore)); encryption discovered mid-call tears the call down with the dedicated end reason encrypted — a tuner freed by policy, not a decode failure. Emergency grants bypass the policy entirely.

Two exemptions cut across all modes. A call whose Key ID matches a configured trunking.systems[].encryption_keys entry is always followed (Engine.keyConfigured disarms any pending release). And the recorder has its own guard: recordings.skip_encrypted (issue #607) refuses to open a session for a flagged grant, and when encryption surfaces mid-stream it closes and deletes the in-progress WAV/raw files and suppresses the CallComplete event, so upload feeds and webhooks never see the partial — Recording & Streaming Part 7 walks that abort machinery.

What GopherTrunk won’t do

The honest boundary: GopherTrunk identifies encryption — algorithm, key ID, message indicator — exactly as SDRtrunk does, and performs no key recovery of any kind. Known-key decryption exists today only for DMR ARC4 “Enhanced Privacy”, configured under encryption_keys by an operator authorized to hold the key; on P25 a configured key buys the policy exemption (the call is followed and recorded), not decryption.

What the identification path does feed is analysis of the signalling itself: every encrypted LDU2’s MI (the IV) and its still-encrypted IMBE frames can be handed to the crypto-frame capture sink (internal/voice/cryptocap) for offline keystream-reuse analysis — the Crypto Lab workflow. The bridge captures every encrypted superframe, not one per call — distinct ciphertexts matter even when ALGID/KID never change — and it is off by default, the same opt-in discipline as every diagnostic tap in this series.

How the gap shaped the Go code

  • Events backfill instead of blocking. The grant publishes immediately; KindCallEncryption and KindCallSourceUpdate patch the live call as the traffic channel fills the gap — no consumer waits for metadata that may never come.
  • Gates prefer omission to plausible garbage. AlgorithmKnown and ErrEncryptionSyncUncorrectable drop low-confidence values — a wrong key label sends an operator chasing the wrong thing.
  • Policy state lives in one enum with a safe zero. EncryptedFollow is the zero value and the parse fallback, so a config typo can never silently stop following calls.

Where this goes next

Encryption was the last per-call layer. Part 10 climbs above the call entirely: the WACN / System / RFSS / Site identity ladder, the broadcasts that carry it in TSBK and multi-block AMBT forms, and how GopherTrunk votes a system’s identity out of noisy frames into a neighbour map you can roam by.

FAQ

How does GopherTrunk know a P25 call is encrypted? Twice over: the grant’s Service Options octet carries a protected bit (known before the call is even followed), and the traffic channel repeats the full Encryption Sync — MI, Algorithm ID, Key ID — in every LDU2 on Phase 1, or in the MAC_PTT key-up message on Phase 2. ALGID 0x80 is clear; anything else names a cipher.

Can GopherTrunk decrypt encrypted P25 calls? No. It surfaces which algorithm and key a call uses, and performs no key recovery. Known-key decryption exists for DMR ARC4 only; on P25 a configured key exempts the call from the encrypted-call policy but the audio is not decrypted.

Why do some encrypted calls show no algorithm or key? Either the call ended before an LDU2/MAC_PTT landed (the grant flag alone carries no metadata), or the ES word failed its gates — GopherTrunk omits ALGID/KID it cannot trust, because an RS layer “successfully” correcting to an out-of-registry Algorithm ID is a known mis-decode signature (#924).

What does encrypted_calls: metadata actually buy me? Alias, source and key intelligence without the tuner cost: the engine follows an encrypted call just long enough to capture the traffic channel’s metadata, then releases the voice SDR. On a system where a third of the traffic is encrypted, that’s the difference between clear calls queueing for tuners and not.

Should I set recordings.skip_encrypted: true? If you never hold keys, probably: it keeps unplayable audio off the disk and out of your upload feeds, gating at grant time and mid-call. Leave it false if a downstream consumer wants the call events regardless — the suppressed CallComplete also suppresses call webhooks (issue #897) — or if you deliberately archive ciphertext for cryptolab-style analysis.

Series navigation

Part 9 of 14 · ← Part 8: IMBE Voice — From LDU to WAV · Next → Part 10: Sites, WACNs & Roaming a Multi-Site System