Ethereum: de historia a labs

Artículo 22 de 48

46%

completado al llegar aquí

Sistemas distribuidosNota de investigación13 min de lectura

Access control: owner, roles, multisig y timelocks

Autorización en contratos Ethereum: Ownable y Ownable2Step, roles con AccessControl, multisig con umbral y TimelockController para separar operación diaria de cambios de política.

Ruta Ethereum: 0/48

En una API REST, la autorización suele vivir en un middleware que consulta un servicio de identidad. Si la política cambia, alguien despliega una configuración nueva y el equipo de operaciones puede revertirla. En un contrato Ethereum, la autorización es código público que compara direcciones, y la "configuración" es storage que solo ciertas direcciones pueden cambiar. No hay consola de administración por encima: quien controla esas direcciones controla el contrato.

Este capítulo recorre las herramientas habituales, de la más simple a la más gobernada: un owner, roles con permisos acotados, un multisig que exige varias firmas y un timelock que obliga a esperar antes de ejecutar. La idea central es separar dos caminos: la operación diaria, rápida y limitada, y el cambio de política, lento y visible.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Titular de un rol

Una EOA, un multisig o un timelock cuya dirección pasa el chequeo de msg.sender.

Evidencia

Eventos RoleGranted y CallScheduled

Cada cambio de permisos y cada operación programada queda en los logs.

Estado

Mapa de roles en storage

Quién tiene qué rol y quién administra cada rol.

Preguntas guía

  • Si mañana se filtra una clave privada de tu equipo, ¿qué puede hacer con ella y durante cuánto tiempo?
  • ¿Qué diferencia hay entre tener un rol y poder otorgarlo a otros?
  • ¿Por qué msg.sender y no tx.origin para autorizar?
  • ¿Qué gana un usuario cuando el admin es un timelock y no una cuenta común?
  • ¿Cómo listarías hoy todas las direcciones con poder sobre tu sistema?

Modelo mínimo: owner, onlyOwner y transferencias en dos pasos

El patrón más simple es un owner: una dirección que puede ejecutar tareas administrativas. Ownable de OpenZeppelin fija el owner inicial en el constructor y agrega el modificador onlyOwner, transferOwnership y renounceOwnership. Renunciar deja todas las funciones onlyOwner sin nadie que pueda llamarlas, para siempre.

El error operativo más común es transferir el owner a una dirección equivocada, por ejemplo una que nadie controla. Ownable2Step lo evita: el owner actual propone y el nuevo owner debe llamar acceptOwnership para que el cambio tenga efecto. Es el mismo principio que un cambio de DNS con verificación: el receptor demuestra que existe antes de recibir el control.

Hay un detalle del lenguaje que vale fijar ahora. La autorización se hace con msg.sender, la dirección que llamó directamente a la función. tx.origin es la cuenta que firmó la transacción original. La documentación de Solidity dice "nunca uses tx.origin para autorización": si el owner es engañado para llamar a un contrato malicioso, ese contrato puede llamar a tu wallet y tx.origin seguirá siendo el owner.

Grafo de permisos: roles, admins y mínimo privilegio

Un owner único mezcla tareas con riesgos muy distintos: pagar una factura, cambiar un límite, actualizar la lógica. El control de acceso basado en roles separa esas tareas. En AccessControl, cada rol es un identificador bytes32, normalmente keccak256("NOMBRE_ROLE"), y el modificador onlyRole revierte si quien llama no lo tiene. OpenZeppelin lo presenta como aplicación del principio de mínimo privilegio.

La parte que más se pasa por alto es el admin de cada rol. Tener un rol no permite otorgarlo; eso lo decide el rol admin asociado. Por defecto, el admin de todos los roles es DEFAULT_ADMIN_ROLE, que además es admin de sí mismo. Quien lo tiene puede darse cualquier rol. OpenZeppelin advierte ese riesgo y ofrece AccessControlDefaultAdminRules, que limita el admin a una sola cuenta y exige una transferencia en dos pasos con demora.

Por eso conviene dibujar los permisos como un grafo, no como una lista de modificadores. Cada arista dice "esta dirección puede llamar esta función" o "este rol puede otorgar este otro". La pregunta de auditoría es: ¿qué nodo, si se compromete, alcanza a todos los demás? AccessControl básico no enumera miembros en cadena; para reconstruir el grafo se procesan los eventos RoleGranted y RoleRevoked, o se usa la extensión AccessControlEnumerable.

Este tesoro separa un rol operativo, PAYER_ROLE, de la política, maxPayment, que solo cambia el admin. Compila con Solidity 0.8.26 y OpenZeppelin Contracts 5.4; es ilustrativo:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
 
contract Treasury is AccessControl {
    bytes32 public constant PAYER_ROLE = keccak256("PAYER_ROLE");
    uint256 public maxPayment;
 
    event Paid(address indexed to, uint256 amount);
 
    constructor(address admin, uint256 initialMax) {
        _grantRole(DEFAULT_ADMIN_ROLE, admin); // admin: idealmente un timelock
        maxPayment = initialMax;
    }
 
    receive() external payable {}
 
    // Operación diaria: rol acotado, monto acotado.
    function pay(address payable to, uint256 amount) external onlyRole(PAYER_ROLE) {
        require(amount <= maxPayment, "over limit");
        (bool ok, ) = to.call{value: amount}("");
        require(ok, "transfer failed");
        emit Paid(to, amount);
    }
 
    // Cambio de política: solo el admin.
    function setMaxPayment(uint256 newMax) external onlyRole(DEFAULT_ADMIN_ROLE) {
        maxPayment = newMax;
    }
}

Multisig: varias firmas para una sola dirección

Un rol crítico en manos de una EOA, una cuenta controlada por una sola clave privada, tiene un único punto de falla. Un multisig es un contrato que actúa como cuenta y solo ejecuta una transacción si la aprueban al menos k de sus n dueños. En Safe, un multisig de código abierto para Ethereum, el contrato guarda la lista de owners y un threshold, que debe ser mayor que cero y no superar la cantidad de owners.

OpenZeppelin recuerda que un contrato puede ser owner de otro: el owner de tu tesoro puede ser un Safe con un umbral de 2 de 3, por ejemplo. Para un equipo de custodia, el umbral es una decisión de diseño con dos riesgos opuestos: un umbral bajo facilita el robo con pocas claves, y uno alto facilita el bloqueo si se pierden claves. Ninguno de los dos se arregla después sin la aprobación del propio multisig.

Un multisig reduce el riesgo de una clave robada, pero no el de una decisión mala tomada rápido. Si los firmantes aprueban algo dañino, o son engañados para firmar un payload que no entienden, el contrato lo ejecuta en la misma transacción. Ahí entra el timelock.

Estado de una operación en el timelock: programar, esperar, ejecutar

TimelockController es un contrato que se convierte en owner o admin de otros y obliga a que cada operación pase por tres estados: programada, lista y ejecutada. Quien tiene PROPOSER_ROLE llama schedule con el destino, el calldata y una demora; quien tiene EXECUTOR_ROLE llama execute cuando la demora mínima (minDelay) pasó; quien tiene CANCELLER_ROLE puede cancelar mientras tanto.

El objetivo, en palabras de OpenZeppelin, es dar a los usuarios tiempo para revisar una operación de mantenimiento y salir del sistema si lo consideran conveniente. Ni Ownable ni AccessControl protegen contra un administrador que ataca su propio sistema; la demora sí da una ventana.

Figura 1

Dos caminos de autorización: política lenta, operación acotada

Fuentes:

[1][3]
Dos caminos de autorización: política lenta, operación acotada
Cómo leer esta figura

Diagrama en dos filas separadas por una línea punteada. Fila superior, camino lento para cambiar la política: un multisig de k de n firmantes llama schedule en un TimelockController con minDelay; el timelock llama execute sobre el Treasury, donde tiene DEFAULT_ADMIN_ROLE. Debajo del timelock: espera mayor o igual a minDelay, ventana para revisar o salir. Debajo del Treasury: grantRole, setMaxPayment, upgrades. Fila inferior, camino rápido para operar dentro de la política: una clave operativa con PAYER_ROLE llama pay(to, amount) sin demora y con límite, donde amount debe ser menor o igual a maxPayment. Nota: si la clave se filtra, el daño queda acotado por maxPayment hasta que el admin revoque.

Visual original de TrautsLab. Arriba, un multisig programa una operación en el TimelockController, que solo la ejecuta sobre el tesoro después de minDelay. Abajo, una clave operativa con PAYER_ROLE paga sin demora, pero nunca por encima de maxPayment.

El constructor recibe minDelay, la lista de proposers (que también reciben el rol de canceller), la lista de executors y un admin opcional. La documentación recomienda pasar address(0) como admin, o renunciarlo después de la configuración, para que el timelock se administre a sí mismo y hasta cambiar minDelay deba pasar por la demora. El test siguiente, ejecutado con Foundry 1.5.1, comprueba que ejecutar antes de tiempo revierte y que después de dos días funciona:

solidity
// Fragmento del test: el timelock es DEFAULT_ADMIN_ROLE del Treasury.
function test_PolicyChangeWaitsForDelay() public {
    bytes memory data = abi.encodeCall(treasury.setMaxPayment, (50 ether));
    vm.prank(multisig);
    timelock.schedule(address(treasury), 0, data, bytes32(0), bytes32(0), 2 days);
 
    vm.prank(multisig);
    vm.expectRevert(); // todavía no está lista
    timelock.execute(address(treasury), 0, data, bytes32(0), bytes32(0));
 
    vm.warp(block.timestamp + 2 days);
    vm.prank(multisig);
    timelock.execute(address(treasury), 0, data, bytes32(0), bytes32(0));
    assertEq(treasury.maxPayment(), 50 ether);
}

Una advertencia de la misma documentación: si el timelock se administra a sí mismo y se pierden las cuentas con rol de proposer o executor, el timelock y todo lo que controla quedan bloqueados para siempre.

Riesgo operacional: separar operar de cambiar las reglas

Con estas piezas se puede escribir una política concreta. La operación diaria la hace una clave caliente con un rol estrecho y límites en el propio contrato. Los cambios de política, los otorgamientos de roles y los upgrades pasan por un multisig que propone a un timelock. Si la clave operativa se filtra, el daño máximo está acotado por el contrato hasta que el admin revoque el rol.

Comparación

Quién debería tener cada poder

Precaución

EOA como owner

Una clave privada controla todas las funciones administrativas.

Ideal para
Prototipos y redes de prueba.
Ojo con
Una filtración entrega el contrato completo, sin demora.

Multisig como admin

k de n firmantes aprueban cada operación.

Ideal para
Fondos de equipo y respuestas a incidentes.
Ojo con
Firmantes engañados ejecutan en el acto; el umbral define robo frente a bloqueo.
Recomendado

Multisig que propone a un timelock

Las operaciones quedan públicas y esperan minDelay antes de ejecutarse.

Ideal para
Upgrades, cambios de parámetros y otorgamiento de roles en producción.
Ojo con
Respuesta lenta a emergencias; requiere un rol de pausa separado y acotado.
La pregunta no es qué patrón es mejor, sino qué daño puede hacer cada dirección si se compromete.

Para contratos con muchas funciones restringidas, OpenZeppelin ofrece AccessManager, que centraliza en un solo contrato qué rol puede llamar cada función de cada contrato, con demoras de ejecución por cuenta y la posibilidad de cerrar un contrato entero durante un incidente. Cualquiera sea la herramienta, el entregable de una revisión es el mismo: una tabla de direcciones con poder, qué pueden hacer, con qué demora y quién las vigila.

Conexiones dentro de la serie

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    OpenZeppelin, "Access Control," OpenZeppelin Contracts 5.x Documentation.

    Documentation siteID: OZ-ACCESS-CONTROL-5XAbrir fuente (abre en otra pestaña)

    Ownable, Ownable2Step, AccessControl, admin de roles, DEFAULT_ADMIN_ROLE, enumeración de roles, TimelockController y AccessManager.

  2. [2]

    OpenZeppelin, "Access Control," OpenZeppelin Contracts 5.x API Reference.

    Documentation siteID: OZ-ACCESS-API-5XAbrir fuente (abre en otra pestaña)

    API de Ownable, Ownable2Step (pendingOwner, acceptOwnership), AccessControl y AccessControlDefaultAdminRules.

  3. [3]

    OpenZeppelin, "Governance," OpenZeppelin Contracts 5.x API Reference.

    Documentation siteID: OZ-GOVERNANCE-API-5XAbrir fuente (abre en otra pestaña)

    TimelockController: constructor(minDelay, proposers, executors, admin), schedule, execute, cancel, getMinDelay, updateDelay y estados de operación.

  4. [4]

    Solidity Team, "Security Considerations: tx.origin," Solidity Documentation.

    Documentation siteID: SOLIDITY-TX-ORIGINAbrir fuente (abre en otra pestaña)

    Regla: nunca usar tx.origin para autorización, con ejemplo de wallet atacable mediante un contrato intermedio.

  5. [5]

    S. George and R. Meissner, "OwnerManager.sol," Safe Smart Account source code, GitHub repository safe-global/safe-smart-account.

    Source codeID: SAFE-OWNER-MANAGERAbrir fuente (abre en otra pestaña)

    Gestión de owners y threshold; validaciones de que el umbral sea mayor que cero y no supere el número de owners.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

Rutas vecinas

Si este tema te abrió hambre, sigue por aquí.

Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.