Enigma en VHDL

Diagrama de una máquina Enigma

Una máquina Enigma construida con nada más que puertas AND, OR, NOT y cuatro biestables T. Sin operadores aritméticos, sin VHDL de comportamiento, cada ecuación deducida a mano a partir de tablas de verdad y mapas de Karnaugh. Cifra dígitos (del 0 al 9) en lugar de letras, y como la máquina real es autoinversa: vuelves a meter el valor cifrado con los mismos ajustes y te sale el mensaje original.

  • 0-9 alfabeto (BCD de 4 bits)
  • 6 etapas
  • 5 suites de tests
  • 241 comprobaciones, todas pasando
  • 9 años escondido un bug

Cómo funciona, en un dibujo

Cada pulsación empuja un valor a través de seis etapas. El contador es lo que sustituye al tambor giratorio de una Enigma real, avanza uno después de cada pulsación, así que el cableado se va desplazando según escribes. Teclea el dígito 7 diez veces seguidas y obtienes 8, 6, 1, 3, 8, 6, 3, 8, 6, 1. Nunca el mismo valor dos veces seguidas, y nunca un 7.

          ┌─ contador c (0-9), avanza en cada pulsación ─┐
          │                                              │
          ▼                                              ▼
entrada ─► sumador ─► rotor ─► reflector ─► rotor inv. ─► restador ─► salida
          (m+c)%10      R          Ref           Ri        (i−c)%10

Un ejemplo paso a paso

Vamos a cifrar el dígito 3 con el contador en 9, y después metemos el resultado otra vez con el contador en 9 de nuevo:

EtapaCifrandoDescifrando
entrada32
sumador (m+9)%1021
rotor R63
reflector Ref36
rotor inverso Ri12
restador (i−9)%1023

El mismo circuito, los mismos ajustes, y el mensaje vuelve. No hay modo de descifrado en ninguna parte del diseño, esa simetría sale sola de las matemáticas, y es la propiedad sobre la que está montada toda la suite de tests.

La versión corta de la historia

Esto lo escribí en 2017 para una asignatura de Electrónica Digital en la universidad. Se entregó, se presentó, se corrigió, y he ido reabriendo los archivos cada par de años sin sospechar nunca que hubiera nada mal. Nueve años después le escribí un testbench automatizado en condiciones, más que nada por inercia de mi trabajo actual, y resultó que el cifrado estaba roto. Fallaban 80 de las 100 combinaciones de entrada posibles. Las únicas que funcionaban eran las veinte y pico que me había dado por mirar en 2017.

Dos bugs distintos: ocho entradas mal en un mapa de Karnaugh que había deducido a mano, y un restador que faltaba y que impedía que la máquina fuese autoinversa. Los dos están explicados más abajo, junto con el diseño a nivel de puertas en el que estaban escondidos.

Detalles

Todo lo que viene ahora es opcional, abre lo que te dé curiosidad.

Cómo viaja una pulsación por la máquina Las seis etapas y qué le hace cada una al valor

1. El contador, un rotor sin partes móviles

Un rotor de una Enigma real gira físicamente una posición por pulsación, lo que desplaza el cableado un puesto. Aquí eso es un contador módulo 10 hecho con cuatro biestables T. Su valor se le suma a la entrada, lo que produce el mismo efecto de desplazamiento sin que se mueva nada.

2. El sumador: (m + c) mod 10

Desplaza el mensaje según la posición del contador. Si la suma pasa de 9 da la vuelta, así que 8 + 5 sale 3.

3. El rotor, una mezcla fija

Un circuito combinacional que permuta los diez valores posibles. Esto es el cableado interno del tambor del rotor:

in0123456789
R(x)2368097451

4. El reflector, que devuelve la corriente

En la máquina real el reflector rebota la señal eléctrica de vuelta por los rotores siguiendo un camino distinto. Aquí es simplemente el complemento, Ref(x) = 9 − x:

in0123456789
Ref(x)9876543210

Fíjate en que ningún valor se mapea consigo mismo. Esa es la propiedad que define a un reflector de Enigma, y es también la debilidad histórica que permitió a Bletchley Park romper la máquina: una letra nunca puede cifrarse como ella misma, lo que le regala al criptoanalista una restricción en cada intento.

5. El rotor inverso, el camino de vuelta

La corriente sale de vuelta por el mismo cableado, así que este bloque deshace el rotor exactamente, Ri = R⁻¹.

in0123456789
Ri(x)4901782635

6. El restador: (i − c) mod 10

Quita el desplazamiento que metió el sumador. Este es el bloque que faltaba en el diseño original de 2017, y no tenerlo es lo que rompía el cifrado para cualquier posición del contador que no fuese cero.

Por qué cifrar dos veces te devuelve el mensaje La única parte matemática sobre la que se sostiene todo

Llamemos g = Ri ∘ Ref ∘ R a las tres etapas del medio. La máquina entera queda así:

salida(m, c) = ( g((m + c) mod 10) − c ) mod 10

g es una involución, es decir que aplicarla dos veces te deja donde empezaste, g(g(x)) = x. Eso pasa porque el reflector es simétrico y el camino de vuelta deshace exactamente el de ida. Así que si vuelves a meter la salida:

salida(salida(m, c), c)
  = ( g( ((g((m+c)%10) − c)%10 + c) %10 ) − c ) %10
  = ( g( g((m+c)%10) ) − c ) %10        el −c y el +c se cancelan
  = ( (m+c)%10 − c ) %10                porque g es una involución
  = m                                   ✓

Las dos cancelaciones están haciendo trabajo ahí. Quita el restador y la línea del medio deja de colapsar, porque g no es lineal respecto a la suma módulo 10, así que el desplazamiento del contador se cuela por la permutación y ya no vuelve a salir. Eso es el bug 2.

El contador BCD: cuatro biestables T Tabla de estados, ecuaciones de excitación y mapas de Karnaugh

Un biestable T conmuta cuando T = 1 y se queda quieto cuando T = 0, o sea Qsig = Q ⊕ T. Para diseñar el contador escribes el estado al que tiene que ir a continuación, y luego lees las conmutaciones que necesitas para llegar ahí, T = Q ⊕ Qsig.

estadoQ3Q2Q1Q0 sig.T3T2T1T0
0000010001
1000120011
2001030001
3001140111
4010050001
5010160011
6011070001
7011181111
8100090001
9100101001

Los estados del 10 al 15 no ocurren nunca, así que son inespecificados (X). Pueden valer lo que más convenga, y eso deja que los mapas de Karnaugh se simplifiquen mucho más de lo que se simplificarían si no.

T3
Q3Q2\Q1Q000011110
000000
010010
11XXXX
1001XX
T2
Q3Q2\Q1Q000011110
000010
010010
11XXXX
1000XX
T1
Q3Q2\Q1Q000011110
000110
010110
11XXXX
1000XX

T0 vale 1 en todas las celdas, el bit menos significativo conmuta en cada ciclo de reloj. Agrupando los unos de los otros tres mapas sale:

T3 = Q3·Q0 + Q2·Q1·Q0
T2 = Q1·Q0
T1 = Q3'·Q0
T0 = 1

En T1 = Q3'·Q0 es donde vive la vuelta decimal. El bit 1 conmutaría normalmente cada dos cuentas, pero el término Q3' lo mata en el estado 9, así que el contador salta de vuelta a 0 en lugar de seguir hasta 10.

Diseño a nivel de puertas: los mapas de Karnaugh del rotor De una tabla de permutación a cuatro ecuaciones SOP, a mano

El rotor es una tabla de consulta, pero expresada como puertas en vez de como memoria. Cada uno de los cuatro bits de salida es su propia función booleana de los cuatro bits de entrada, así que la tabla de permutación se convierte en cuatro mapas de Karnaugh separados. Escribiendo R(x) en binario:

ins3s2s1s0 outr3r2r1r0
0000020010
1000130011
2001060110
3001181000
4010000000
5010191001
6011070111
7011140100
8100050101
9100110001

Cuatro mapas, uno por bit de salida. Las filas son s3s2 y las columnas s1s0, las dos en código Gray para que las celdas vecinas solo se diferencien en un bit. Ese es el truco que hace que todo esto funcione: si dos celdas adyacentes valen 1 las puedes agrupar, y la variable que cambia entre ellas desaparece del término.

r3
s3s2\s1s000011110
000010
010100
11XXXX
1000XX
r2
s3s2\s1s000011110
000001
010011
11XXXX
1010XX
r1
s3s2\s1s000011110
001101
010001
11XXXX
1000XX
r0
s3s2\s1s000011110
000100
010101
11XXXX
1011XX

Agrupando cada mapa sale la forma de suma de productos que va directa al VHDL:

r3 = s2·s1'·s0 + s2'·s1·s0
r2 = s1·s0' + s2·s1 + s3·s1'·s0'
r1 = s3'·s2'·s1' + s1·s0'
r0 = s1'·s0 + s2·s1·s0' + s3·s2'

Y en el código fuente, una asignación concurrente por bit, cuatro líneas de VHDL que se sintetizan en un par de docenas de puertas:

rot3 <= (sum2 and not sum1 and sum0) or (not sum2 and sum1 and sum0);
rot2 <= (sum1 and not sum0) or (sum2 and sum1) or (sum3 and not sum1 and not sum0);
rot1 <= (not sum3 and not sum2 and not sum1) or (sum1 and not sum0);
rot0 <= (not sum1 and sum0) or (sum2 and sum1 and not sum0) or (sum3 and not sum2);

El reflector y el rotor inverso salen exactamente del mismo proceso. El reflector se simplifica bien porque 9 − x es muy regular:

ref3 = r3'·r2'·r1'
ref2 = r3'·r2'·r1 + r3'·r2·r1'
ref1 = r3'·r1
ref0 = r3'·r0' + r2'·r1'·r0

Haz todo esto a mano para doce bits de salida y tienes alrededor de cien términos que agrupar y transcribir sin equivocarte ni una vez. De ahí salió el bug 1.

El bug que estuvo escondido nueve años Dos en realidad, uno aritmético y otro de arquitectura

El test 1 pasó a la primera, el reflector no tiene puntos fijos en los 100 casos. El test 2 falló estrepitosamente, 80 de 100 combinaciones rompían la propiedad autoinversa. Las veinte que pasaban estaban todas en la posición 0 del contador, que es justo donde estaban mis casos de prueba manuales de 2017.

Bug 1: ocho entradas mal en el sumador

El sumador módulo 10 tenía 8 entradas mal de 100 en sus ecuaciones SOP hechas a mano, tres en el bit 3, cinco en el bit 2, ninguna en los bits 1 y 0. Los mapas de Karnaugh son una técnica visual, y con unos cien términos que agrupar repartidos en cuatro mapas, leer mal alguna celda es casi inevitable. Lo que lo hizo invisible es que los errores solo aparecen cuando el contador no es cero, y con el contador a 0 el sumador es simplemente la identidad, que es el único caso que había mirado nadie.

Bug 2: faltaba un restador

El contador se sumaba a la entrada antes del rotor pero no se restaba nunca de la salida. Una Enigma real desplaza la permutación entera según la posición del rotor, y sumar una constante a la ida no es la misma operación, así que la diferencia no se cancela. Incluso con un sumador perfectamente correcto la máquina no podía ser autoinversa para ningún contador que no fuese cero. El arreglo es un restador módulo 10 después del rotor inverso.

Los dos están arreglados. El sumador y el restador usan procesos compactos con ieee.numeric_std en lugar de ecuaciones deducidas a mano, mientras que el rotor, el reflector y el rotor inverso siguen siendo lógica a nivel de puertas, que es la parte de la que iba el ejercicio en realidad.

La suite de tests automatizados Cinco propiedades, y qué puede cazar cada una

La suite comprueba propiedades en vez de vectores fijos, así que dice algo sobre el diseño en lugar de limitarse a congelar lo que hace hoy:

TestPropiedadCasos
1El reflector nunca mapea un valor consigo mismo100
2decode(encode(m)) == m para toda posición del contador100
3El contador BCD recorre 0-9 y vuelve a 011
4Rotor y reflector son permutaciones, y Ref(x) = 9 − x20
5El rotor inverso invierte de verdad al rotor, R(Ri(y)) == y10

Los tests 3 al 5 vinieron después, y el 3 se justificó solo enseguida. Comprobé la suite rompiendo el diseño a propósito de tres maneras distintas, y un biestable del contador corrupto se coló tranquilamente por delante de los tests 1 y 2. Los dos configuran el mismo contador roto en la pasada de cifrado y en la de descifrado, así que la simetría se sigue cumpliendo perfectamente, solo que se cumple alrededor del valor equivocado. Eso solo lo caza una comprobación independiente de la secuencia de estados del propio contador. Un test de ida y vuelta que es autoconsistente tiene un punto ciego justo donde los dos sentidos comparten un componente, y es algo en lo que no me había parado a pensar.

Metodología

La suite corre en GHDL. El proceso de estímulos pulsa el reloj él mismo, un pulso cada vez, en lugar de usar un generador libre, porque si no un flanco puede caer en mitad de una ventana de medida y el contador avanza a tus espaldas, lo que hacía que los resultados no fuesen deterministas. Las lecturas de bus rechazan 'U' y 'X' directamente en vez de tomarlos como cero sin decir nada, así que un test no puede pasar sobre basura sin inicializar. Aprobar o suspender lo deciden sentencias assert, y la ejecución sale con código distinto de cero si salta alguna.

Test 1: PASSED (100 cases, no fixed points)
Test 2: PASSED (100 cases, all symmetric)
Test 3: PASSED (11 states, 0-9 then wrap to 0)
Test 4: PASSED (20 checks, rotor and reflector are bijections)
Test 5: PASSED (10 values, R(Ri(y)) == y)

  ALL TESTS PASSED
Cómo ejecutarlo tú GHDL, o Docker si prefieres no instalar nada

Todo esto son dos archivos VHDL y un script de PowerShell. GHDL es la única dependencia obligatoria, el visor de formas de onda es opcional.

.\simulate.ps1          # compila y simula -> enigma.vcd
.\simulate.ps1 -test    # ejecuta las cinco suites de tests
.\simulate.ps1 -view    # abre la forma de onda en Surfer o GTKWave
.\simulate.ps1 -clean   # borra los archivos generados

Conclusión

Si hubiera desarrollado los test a la par que el código, los test los hubiéramos cazado rápidamente. Mirar las formas de onda a mano para muy pocos casos se dejó ocho errores aritméticos y un fallo de arquitectura de base, y conocer bien el diseño jugó activamente en mi contra ya que yo sabía cómo se suponía que tenía que verse la forma de onda, así que eso es lo que vi. Aunque revisé de vez en cuando el proyecto durante estos nueve años no ví nada raro.

El proyecto además se apoya en un compromiso que me sigue pareciendo interesante. Las ecuaciones SOP a nivel de puertas son fieles a cómo se enseña y se construye la lógica digital de verdad, y deducirlas a mano es justo de lo que va el ejercicio, pero se vuelven propensas a errores para tantos términos.

Áreas:Electrónica y bajo nivelQA y automatización

VHDLGHDLEnigmaLógica digitalMapas de KarnaughTests automatizados