Explicación de UUIDv5

Explicación de UUIDv5

Introducción

Estaba reflexionando sobre cómo dar soporte para la etiqueta <podcast:guid> del namespace podcasting 2.0 en mi Generador de fuentes RSS para podcasts, y estaba leyendo la documentación. Primero, descubro que la guid es una cadena de texto UUIDv5. Investigo un poco, y descubro que UUIDv5 significa Universally Unique Identifier version 5 (Identidicador Único Universal versión 5), también conocido como GUID (o Globally Unique IDentifier, Identificador Único Global). Okay, tiene sentido, la documentación de la etiqueta menciona que su propósito es identificar el podcast. También proporcionan dos herramientas distintas para generar estos tipos de cadenas en línea: UUID Tools y RSS Blue, y compruebo que, con los mismos parámetros (incluyendo el namespace de podcast, con un UUID de ead4c236-bf58-58c6-a2c6-a6b28d128cb6) devuelven el mismo resultado. Trato de inspeccionar ambas páginas para ver el código que usan, pero está todo ofuscado o envuelto en lo que creo que son capas de callbacks de jQuery. Empiezo a buscar código ya hecho, y solo encuentro paquetes de npm (no aplicables porque estoy haciendo esto en JS de navegador, no node, que usa tipos y API's distintas en este caso específico) y una implementación muy incompleta en C en la especificación. Hablando de la especificación, no menciona algunos detalles de gran importancia, pero reiteró varias veces el orden de bytes (o endianness) necesario de los datos para el algoritmo múltiples veces, de forma super útil /j. Acabé encontrando un paquetito corto de npm que ya no puedo encontrar, me estudié el código y acabé adaptándolo a lo que necesitaba. Las partes más ingeniosas del código, como usar padStart(), o aplicar una regular expression para filtrar el namespace, son gracias a ese paquete. Así que, para cualquiera que esté intentando implementar UUIDv5's en JS de navegador, aquí está el código, y para aquellos que quieran entender el estándar o implementarlo en otros lenguajes, también tenéis una explicación detallada:

La explicación

Los UUIDv5's son deterministas, lo que quiere decir que, como hemos mencionado antes, las mismas entradas siempre producirán las mismas salidas. Los UUIDs de versión 4 no lo son, por ejemplo, dependen del momento en el que se generan. Podría argumentarse que son también deterministas (y por tanto probablemente toda la computación) y que el tiempo es otra entrada, pero esa es una conversación que no tendremos hoy. Esta es la "anatomía de bytes" de cualquier UUID.

0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          time_low                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|       time_mid                |         time_hi_and_version   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|clk_seq_hi_res |  clk_seq_low  |         node (0-1)            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         node (2-5)                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Cuando conoces el aspecto de una UUID (parecido a a5de3ad2-5d30-5c05-aa56-30c24b857264), empezarás a ver v4's y v5's en todas partes, ya que no solo se usan en podcasts, al menos esa fue mi experiencia. Para saber qué versión de UUID estás viendo, mira al segundo grupo, y el primer número debería chivártelo, por ejemplo, la anterior era una v5 (a5de3ad2-5d30-5c05-aa56-30c24b857264).

La version 5 requiere 2 entradas, un namespace (hay también namespaces estándar), y la cadena que quieres codificar. Como ejemplo vamos a generar una guid para un podcast cuya fuente RSS reside en https://media.example.com/feed.xml/. Esto quiere decir que nuestro namespace es "ead4c236-bf58-58c6-a2c6-a6b28d128cb6" y nuestra cadena será "media.example.com/feed.xml" (con el esquema de protocolo y barras finales eliminadas, como manda la especificación podcast 2.0). Usaremos arrays (vectores), para poder controlar el orden de los bytes, y necesitaremos lógica de bits, así que asegúrate de conocer esa sintaxys en el lenguaje que hayas escogido.

Nota de "accessibilidad": el código se renderiza con la fuente Cascadia Code, que es de código abierto y hecha por Microsoft, diseñada para usarse en la IDE Visual Studio, con ligaduras, lo que hace que algunos caracteres aparezcan de forma distinta. Por ejemplo, !== se vuelve !==, y => se convierte en =>. Me resulta útil para leer (especialmente código en JS), pero si a tí no, siéntete libre de copiar el código y pegarlo en otro lugar con una fuente distinta sin ligaduras, y los caracteres se mostrarán de forma normal.

  1. Lidiando con el namespace

    Primero necesitamos procesar el namespace. Toma la cadena de texto, elimina o ignora los guiones, tómala par a par de caracteres, y traduce cada par a su valor hexadecimal; toma esos valores y guárdalos en un array. En la siguiente función se usa una regular expression en el método replace() del array para separar los pares, y una función toma cada uno (llamado hex, de hexadecimal) y los mete en parseInt() para hacer la traducción de una cadena de texto representando un byte hexadecimal a su valor como número entero o int/integer (puedes copiarla con un botón que aparece cuando pones el mouse sobre el código en ordenador o presionándolo en móvil):

    function uuidToBytes(uuid) {
      let bytes = [];
      uuid.replace(/[a-fA-F0-9]{2}/g, function(hex) { bytes.push( parseInt(hex, 16) ) });
      return bytes;
    }
  2. Lidiando con la cadena de entrada o "nombre"

    Si solo miras el código, puede que este paso parezca equivalente a lo que acabamos de hacer con el namespace, pero no te confundas, amigo mío. Esta vez necesitamos tomar la entrada y traducir cada caracter a su código en UTF-16, y después guardar esos valores en un array. Hay una función muy conveniente en JavaScript que nos permite hacer esto, charCodeAt(). Aquí inicializo el array de forma distinta, pero no te asustes:

    function stringToBytes(str) {
      let bytes = new Array(str.length);
      for(let i = 0; i < str.length; i++) { bytes[i] = str.charCodeAt(i) }
      return bytes;
    }
  3. SHA-1 y "la promesa"

    Okay, ahora tienes ambas entradas parseadas como necesitas para el siguiente paso, plicar el algoritmo de encriptación/"digestión" sha-1. Para esto, JS vuelve a ayudarnos (no iba a implementarlo yo mismo, No sé mucho de criptografía) con window.crypto.subtle.digest(). Este método devuelve un objeto Promise (promesa) que resuelve a la cadena encriptada. Simplemente le tenemos que pasar la concatenación del namespace con el "nombre" como argumento. Este método en particular, en JS, necesita que le especifiquemos 'SHA-1' como primer argumento para elegir el algoritmo, y precisa que el argumento a encriptar sea un buffer. Así es como podemos obtener nuestro resultado de sha-1:

    crypto.subtle.digest('SHA-1', new Uint8Array(namespace.concat(name)) )
  4. Obteniendo nuestro hash de vuelta

    Vamos a llamar al resultado del anterior paso hash. Ahora necesitamos convertirlo en un array de bytes manipulable. Esto implica reducir nuestro resultado a los primeros 16 bytes, porque sha-1 devuelve más bytes de los que necesitmos, y en nuestro caso en JS, convertir ese buffer en un array que podamos manipular (porque los buffers no nos son útiles para los que queremos hacer a continuación):

    let bytes = Array.from( new Uint8Array(hash, 0, 16) );
  5. Lógica de bytes

    Para cumplir con la especificación de UUIDv5, necesitamos modificar algunos bits. Específicamente, necesitamos convertir la primera mitad del byte 5 a un 5 en binario (0b0101) para especificar la versión, y poner los dos primeros bits del byte 7 a 0b10. Podemos hacer cada una de estas operaciones con una OR y una AND a nivel de bits:

    bytes[6] = (bytes[6] & 0x0f) | 0x50;
    bytes[8] = (bytes[8] & 0x3f) | 0x80;

    Para poner el 5, ponemos a cerp la primera mitad del byte haciendo AND a nivel de bits con 0x0f (0b0000.1111), y luego poniéndolo a 5 con una OR con 0x50 (0b0101.0000). Para poner el 0b10, hacemos algo similar. Primero ponemos a cero los bits con una and AND con 0x3f (0b0011.1111) y luego los modificamos con una OR con 0x80 (0b1000.0000) (Se han añadido puntos a la notación binaria para que sea más legible).

  6. Yyyy, ¡de array de bytes vamos a la cadena final!

    Por último, toma el array de bytes manipulado, y convierte cada byte en un caracter UTF-16 (al revés de lo que hemos hecho en el paso 1, ¡completamos el ciclo!). Pon guiones después de los caracteres 7, 11, 15 y 19. Para hacer esto en JS, podemos usar el útil método map() para aplicar la función toString() a cada elemento del array. Esto nos puede dejar con valores singulares en vez de pares, por ejemplo 'b' en vez de '0b', así que usamos padStart() para detectar y corregir esos casos de una tacada. Después de eso, join('') simplemente convierte el array de caracteres en un objeto string (cadena de texto). Para añadir los guiones, uso una template literal (o plantilla de literales, traducido toscamente):

    let uuidS = bytes.map( v => v.toString(16).padStart(2,'0') ).join('');
    uuidS = `${uuidS.substring(0,8)}-${uuidS.substring(8,12)}-${uuidS.substring(12,16)}-${uuidS.substring(16,20)}-${uuidS.substring(20)}`;

Todo junto, tus 3 funciones para alcanzar el éxito:

Aquí están todos los pasos que dimos, compilados en 3 funciones con un poco de gestión de errores adicional:

function uuidToBytes(uuid) {
  let bytes = [];
  uuid.replace(/[a-fA-F0-9]{2}/g, function(hex){ bytes.push(parseInt(hex, 16)) });
  return bytes;
}
function stringToBytes(str) {
  let bytes = new Array(str.length);
  for(let i = 0; i < str.length; i++){bytes[i] = str.charCodeAt(i);}
  return bytes;
}
function v5(name, namespace) {
  if(typeof name === 'string') name = stringToBytes(name);
  if(typeof namespace === 'string') namespace = uuidToBytes(namespace);
  if(!Array.isArray(name)) throw TypeError('name must be an array of bytes');
  if(!Array.isArray(namespace) || namespace.length !== 16) throw TypeError('namespace must be uuid string or an Array of 16 byte values');
  return crypto.subtle.digest( 'SHA-1', new Uint8Array(namespace.concat(name)) ).then( hash => {
    let bytes = Array.from(new Uint8Array(hash, 0, 16));
    bytes[6] = (bytes[6] & 0x0f) | 0x50;
    bytes[8] = (bytes[8] & 0x3f) | 0x80;
    let uuidS = bytes.map(v => v.toString(16).padStart(2,'0')).join('');
    uuidS = `${uuidS.substring(0,8)}-${uuidS.substring(8,12)}-${uuidS.substring(12,16)}-${uuidS.substring(16,20)}-${uuidS.substring(20)}`;
    return uuidS;
  }).catch(e=>console.error(e));
}

Ten en cuenta que como crypto devuelve una Promise, he usado la sintaxis de then para hacer los asos siguientes y devolver el resultado final. Para usar el código, solo necesitas pegar las funciones anteriores en algún lado e invocar/llamar

v5('media.example.com/feed.xml','ead4c236-bf58-58c6-a2c6-a6b28d128cb6').then(res=>{ })

y la cadena estará disponible dentro del then() como res. También existe la sintaxis de async/await, así que usa lo que prefieras.

Puedes comprobarlo tú mismo, pero tanto este código como las herramientas en línea mencionadas devuelven a5de3ad2-5d30-5c05-aa56-30c24b857264 para nuestras entradas de ejemplo. ¡Puedes aprovechar esto para comprobar si tu implementación está funcionando correctamente!

Este tema ha tocado bastantes ramas y técnicas de programación, así que espero que te haya resultado interesante o útil de alguna forma. ¡Gracias por leerme!