Upgrade Airlock
Chaque mise à niveau de proxy franchit deux portes : un délai d'attente, puis une autorisation Chainlink CRE que seul un code ayant survécu à une simulation d'attaque peut obtenir.
English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Español · Français · Deutsch · Português (Brasil) · Русский · Tiếng Việt
Points forts · Fonctionnement · Règles · Chainlink CRE · En direct sur Sepolia · Démarrage rapide · Modèle de sécurité
Vue d'ensemble
Upgrade Airlock dote chaque mise à niveau de contrat de deux portes. La porte extérieure est un délai d'attente
mesuré en blocs. La porte intérieure ne s'ouvre qu'avec une autorisation inscrite on-chain. Automatiquement, cela
signifie qu'un workflow Chainlink CRE a attaqué la nouvelle implémentation dans un simulateur en interrogeant quatre
fournisseurs RPC indépendants, qu'au moins deux ont répondu et que toutes les réponses concordent, que l'étape de
consensus du workflow (consensusIdenticalAggregation) s'est accordée sur le verdict et qu'un rapport Clear signé
a été inscrit on-chain. Quand CRE ne peut pas répondre du tout, la clé de l'approbateur peut autoriser à la main, avec
des preuves enregistrées ; le balayeur juge quand même cette proposition tant qu'elle fait partie des 20 ids les plus
récents, et un veto de CRE ou d'un gardien l'emporte. Une implémentation malveillante que la recherche d'attaques
atteint reçoit à la place un Veto signé et ne s'exécute pas, même lorsque l'attaquant détient la clé de mise à
niveau ; le mode strict ajoute une revue humaine du code pour ce qu'une recherche bornée ne peut pas atteindre.
Aujourd'hui, les verdicts viennent de la CLI CRE officielle (cre workflow simulate --broadcast), qui exécute le
workflow sur un serveur ; les contrats et le workflow sont écrits pour un DON, et y passer demande un nouveau
déploiement sur le forwarder de production de Chainlink. Conçu pour ETH Shenzhen 2026 sur Ethereum Sepolia.
[!TIP] Démo en ligne : upgrade-airlock.pages.dev · Vidéo de démo : upgrade-airlock.pages.dev/video (chinois simplifié et anglais)
La salle de crise lit Ethereum Sepolia directement depuis votre navigateur, en lecture seule. Chaque proposition y est représentée comme un sas : la porte extérieure enregistre la proposition, la chambre décompte le délai d'attente, et la porte intérieure reste verrouillée jusqu'à ce qu'une autorisation soit inscrite on-chain (le Clear de Chainlink CRE, ou une autorisation manuelle de l'approbateur, que la salle de crise signale comme telle). Un Veto éjecte la proposition, avec sa règle, son hash de preuve et sa transaction. Une première visite ouvre un sélecteur d'attaques : chaque carte (six attaques et la mise à niveau honnête v2) rejoue la proposition réelle la plus récente de son type sur Sepolia, signalée comme une relecture. La page s'ouvre en chinois simplifié :
?lang=enpasse en anglais et?lang=zh-TWen chinois traditionnel. Les transactions correspondantes (propositions #1 à #12) sont répertoriées dans les preuves de l'exécution en direct.
Points forts
- Fail-closed par construction. Une mise à niveau ne s'exécute qu'avec une autorisation on-chain. Un log manqué, une panne RPC, des fournisseurs en désaccord ou une exécution ratée laissent la proposition non autorisée ou la frappent d'un veto : cela peut retarder une mise à niveau, jamais en laisser passer une. Un veto l'emporte toujours sur une autorisation.
- Une attaque, pas une checklist. Le workflow (sur chaque nœud, dans un déploiement sur un DON) exécute la mise à
niveau contre l'état réel du proxy avec
eth_simulateV1sur quatre fournisseurs RPC. Un inconnu doté de fonds appelle chaque sélecteur du nouveau bytecode, des arguments de type détenteur visent chaque nouvelle fonction, et l'offre totale, la propriété et les soldes sont comparés avant et après. Au moins deux fournisseurs doivent répondre, et toutes les réponses doivent concorder. - Des verdicts liés à la proposition. L'étape de consensus identique du workflow s'accorde sur
{verdict, rule}. Le rapport porte une attestation EIP-712 liée au contenu exact de la proposition (proposalDigest), à la chaîne, au récepteur et à un bloc d'échéance, ainsi qu'un hash de preuve que chacun peut recalculer à partir des données de la chaîne. - Les logs manqués sont rattrapés. Un balayeur cron examine les 20 propositions les plus récentes toutes les 60 secondes et juge celles que le déclencheur de log a manquées, y compris les mises à niveau que l'approbateur a autorisées manuellement ; son veto l'emporte. Une proposition repoussée sous les 20 ids les plus récents n'est plus jugée : elle reste sans autorisation, sauf si l'approbateur l'a autorisée manuellement (la fenêtre de balayage).
- En direct sur Ethereum Sepolia. La CLI CRE officielle a exécuté le workflow sur un serveur et répondu on-chain à chaque proposition (douze au 2026-10-25) : une mise à niveau honnête autorisée puis exécutée, onze attaques bloquées par veto (R1, R2, R3, R3B, R7, R9). Tous les contrats sont vérifiés sur Sourcify.
- Testé de bout en bout. La CI exécute 117 tests Foundry, 118 tests unitaires du workflow et 387 des 394 vérifications d'autotest de la salle de crise (les 7 autres ont besoin de la sortie de compilation de forge et de cast, que le job de CI n'a pas) ; un script de bout en bout sur fork pilote la boucle complète à travers le simulateur CRE.
Le problème
Avec un proxy upgradeable, tout le protocole repose sur la clé de mise à niveau. Dès que cette clé fuit, l'attaque est courte : déployer une implémentation malveillante, faire pointer le proxy dessus, minter ou vider les fonds. C'est déjà arrivé à de nombreuses reprises :
- PAID Network, mars 2021. Les clés du propriétaire du proxy ont remplacé le code audité du token par une version dotée de fonctions burn et mint, et l'attaquant a minté environ 59,5 millions de PAID. (post-mortem de CertiK)
- Ankr, décembre 2022. Avec la clé compromise du déployeur, l'attaquant a remplacé l'implémentation d'aBNBc par une version qui mintait sans contrôle d'autorisation : 60 000 milliards d'aBNBc. (rapport d'Ankr)
- Radiant Capital, octobre 2024. Un attaquant contrôlant 3 des 11 signataires du multisig s'est emparé du fournisseur des pools de prêt (lending pool provider) et a mis à niveau leur implémentation, siphonnant plus de 53 millions de dollars. (rekt.news)
Un timelock se contente de faire attendre l'attaquant, et personne n'est tenu de surveiller pendant ce temps. De longs délais bloquent aussi les correctifs d'urgence. Upgrade Airlock fait travailler l'attente : une mise à niveau ne peut pas s'exécuter tant qu'une vérification indépendante (décentralisée une fois déployée sur un DON CRE) ne l'a pas validée, et cette vérification s'exécute automatiquement sur chaque proposition.
Fonctionnement
sequenceDiagram
actor Owner as Clé du propriétaire (supposée compromise)
participant Admin as AirlockAdmin
participant CRE as Workflow Chainlink CRE
participant RPC as 4 fournisseurs RPC
participant Fwd as KeystoneForwarder
participant Recv as AirlockReceiver
actor Anyone as N'importe qui
Owner->>Admin: proposeUpgrade(newImpl, data)
Note over Admin: Porte extérieure : délai d'attente de 25 blocs, environ 5 minutes sur Sepolia
Admin-->>CRE: log Proposed (EVM log trigger), ou le balayeur cron toutes les 60 s
CRE->>Admin: lectures EVM au bloc de la proposition
loop Sur chaque nœud (un dans le simulateur, chaque nœud du DON une fois déployé), pour chaque fournisseur
CRE->>RPC: eth_getCode, puis la simulation d'attaque eth_simulateV1
RPC-->>CRE: verdict et règle
end
Note over CRE: Quorum : au moins 2 réponses, toutes identiques, sinon Veto R6<br/>consensus identique sur le verdict et la règle<br/>hash de preuve et attestation EIP-712
CRE->>Fwd: writeReport
Fwd->>Recv: onReport(metadata, report)
alt Tous les contrôles réussis
Recv->>Admin: clear(id, evidenceHash)
else Une règle échoue
Recv->>Admin: veto(id, ruleHash) et attachReason(id, evidenceHash)
end
Anyone->>Admin: execute(id)
Note over Admin: Porte intérieure : délai écoulé, autorisée, sans veto, codehash inchangé
Admin->>Admin: ProxyAdmin.upgradeAndCall(proxy, newImpl, data)
- Proposer.
AirlockAdmindétient leProxyAdmind'OpenZeppelin et leOwnabledu token. La clé du propriétaire peut uniquement proposer : elle n'est jamais gardien, approbateur ni oracle d'autorisation. Chaque proposition (une mise à niveau, ou une modification du sas lui-même : gardiens, délai, cible de bris de glace) reçoitreadyBlock = block.number + delayBlocks. La cible d'une mise à niveau ou du bris de glace doit être du code de contrat ordinaire : un compte délégué EIP-7702, dont la clé pourrait le rediriger après le verdict, est refusé (DelegatedAccount). - Juger. Le déclencheur de log CRE réagit à
Proposed. Une modification du sas est jugée à partir de lectures on-chain (R7). Une mise à niveau est d'abord soumise aux règles statiques (R5, R8), puis à la simulation d'attaque, ancrée au bloc de la proposition : le slot d'implémentation ERC-1967 du proxy est remplacé par le nouveau code, et une nouvelle adresse inconnue (dérivée du hash du bloc de la proposition) est approvisionnée comme un véritable utilisateur, en tokens venant d'un second détenteur et en ETH. Un détenteur effectue un transfert, l'inconnu appelletransferFromsans allowance, puis chaque sélecteur trouvé dans le nouveau bytecode avec cinq formes d'arguments. Chaque sélecteur hors de l'interface standard du token est aussi appelé avec des arguments de type détenteur : le détenteur approvisionné dans chacun des quatre premiers emplacements d'argument, le montant de test dans un autre, l'inconnu ou le proxy dans les autres. L'offre, le propriétaire et chaque solde surveillé sont lus avant et après, le solde du détenteur juste après chaque appel de type détenteur, et les événements émis par chaque sonde sont vérifiés (R1 à R4, R3B). Enfin, le nouveau bytecode est analysé à la recherche d'opcodes qui ne se comportent pas de la même façon sur un bloc réel et en simulation (R9). - S'accorder. Chaque nœud (chaque nœud du DON dans un déploiement sur un DON ; aujourd'hui, le simulateur de la
CLI) exécute la simulation sur quatre fournisseurs. Au moins deux doivent répondre et toutes les réponses doivent
donner le même verdict ; dans tout autre cas, c'est un veto (R6). L'étape de consensus s'accorde ensuite sur un
{verdict, rule}identique. - Transmettre. Le workflow calcule le hash d'un JSON de preuve canonique, signe un verdict EIP-712 avec sa clé
d'attesteur (un secret CRE) et écrit le rapport via le forwarder.
AirlockReceivervérifie l'expéditeur, la fraîcheur et la signature, ainsi que le lien du verdict avec cette proposition précise (proposalDigest), puis appelleclear, ouvetoetattachReason, surAirlockAdmin. - Exécuter. N'importe qui peut appeler
execute(id)une fois le délai écoulé. L'appel échoue avecTooEarlypendant le délai,NotClearedsans autorisation,ProposalVetoedaprès un veto, etCodehashChangedlorsque la cible ne contient plus le code qui a été proposé et jugé. Un veto l'emporte toujours sur une autorisation. - Balayer. Un déclencheur cron revérifie les propositions les plus récentes toutes les 60 secondes et juge chaque
proposition en attente à laquelle CRE n'a pas encore répondu (
oracleJudged) : un log manqué par le déclencheur, un événementProposedtrop volumineux pour être livré, ou une mise à niveau que l'approbateur a autorisée manuellement. Son veto l'emporte sur cette autorisation.
Toute défaillance va dans le même sens. Un log manqué, une panne RPC, des fournisseurs en désaccord ou une
exécution ratée laissent la proposition sans autorisation ou la frappent d'un veto : la mise à niveau est retardée,
jamais laissée passer. Lorsque CRE ne peut pas répondre du tout, la clé d'approbateur peut autoriser manuellement une
mise à niveau en consignant des preuves ; le balayeur juge tout de même cette mise à niveau dès que CRE répond à
nouveau, et un veto de CRE ou d'un gardien l'emporte toujours. breakGlass() permet au propriétaire ou à n'importe quel
gardien de basculer immédiatement le proxy vers une implémentation en pause préenregistrée. Sortir de l'état de pause
est une mise à niveau qui franchit les deux portes ; une proposition autorisée n'expire jamais, si bien qu'une mise à
niveau autorisée plus tôt peut aussi mettre fin à la pause (limites connues).
Plus de détails : docs/design.md (modèle de menace, machine à états de l'autorisation, liaison des rapports, budgets) et cre/README.md (le workflow).
Règles
ruleHash = keccak256("GUARD/<ID>"), enregistré on-chain avec chaque veto.
| Règle | Veto si | Décidé par |
|---|---|---|
R1 TOTAL_SUPPLY |
un appel de l'inconnu modifie totalSupply |
simulation d'attaque |
R2 OWNER |
un appel de l'inconnu modifie owner() |
simulation d'attaque |
R3 TRANSFER_CONSERVATION |
un transfert de X par un détenteur ne déplace pas exactement X, ou modifie le solde de quelqu'un d'autre | simulation d'attaque |
R3B THIRD_PARTY_FUNDS |
un tiers déplace les tokens d'un détenteur : le transferFrom sans allowance de l'inconnu approvisionné réussit, ou l'une de ses sondes déplace les tokens de quelqu'un d'autre, minte, brûle ou transfère la propriété, ou laisse le solde du détenteur plus bas, même sans événement ou lorsqu'un appel ultérieur l'annule |
simulation d'attaque |
R4 TRANSFER_LIVENESS |
un simple transfert d'un détenteur échoue | simulation d'attaque |
R5 STRICT_CODEHASH |
le mode strict est activé et l'approbateur n'a pas approuvé le codehash | lectures on-chain |
R6 SIM_FAILURE |
la simulation ne peut pas trancher : fournisseurs en désaccord, trop peu de réponses, code non concordant, revert d'une fonction view ou de l'approvisionnement de l'inconnu, ou sondes qui ne tiennent pas dans les requêtes configurées | simulation et quorum |
R7 CONFIG_GUARD |
un changement de gardien, un délai raccourci ou une nouvelle cible de bris de glace sans pré-approbation de l'approbateur | lectures on-chain |
R8 INIT_DATA |
une mise à niveau embarque des données d'initialisation que l'approbateur n'a pas approuvées conjointement avec le codehash | lectures on-chain |
R9 NEW_OPCODES |
le nouveau code contient un opcode de contexte de bloc ou de transaction (BASEFEE, TIMESTAMP, NUMBER, ORIGIN, GAS, etc.), un appel externe, un create ou un selfdestruct que la liste d'autorisation fixe de la configuration (vide par défaut) ne couvre pas, sauf si l'approbateur a approuvé ce codehash exact | bytecode |
R9 bloque le contournement de la simulation. EvilBaseFeeImpl se comporte comme v1 tant que
block.basefee == 0, ce que voit un appel eth_simulateV1, et permet à n'importe qui de minter sur un bloc réel. Tous
les invariants simulés passent ; son opcode BASEFEE déclenche le veto. La liste d'autorisation ne dépend jamais du
code que le proxy exécute aujourd'hui : une approbation couvre donc un seul codehash et aucune mise à niveau
ultérieure. L'analyse n'ignore la queue de métadonnées du compilateur que lorsqu'il s'agit d'une map CBOR solc exacte
qu'aucune instruction ne peut atteindre ; tout le reste est analysé comme du code. Approuver un codehash l'exempte de
R9 et tolère la troncature des sondes (R6), rien de plus : toutes les autres règles s'appliquent toujours.
R3B bloque le siphonnage discret. EvilDrainImpl, c'est v1 plus rescue(from, to, amount), sans contrôle
d'accès. L'offre, le propriétaire, les transferts et les opcodes sont ceux de v1, donc R1 à R4 et R9 passent tous.
Comme rescue n'est pas une fonction standard de token, l'inconnu l'appelle aussi avec des arguments de type
détenteur ; l'appel avec le détenteur comme from déplace les tokens du détenteur, et R3B y oppose son veto. Un
siphonnage qui se cache derrière « réservé aux appelants qui détiennent des tokens ou de l'ETH » est atteint lui aussi,
car l'inconnu détient les deux.
Mode automatique et mode strict
Le mode automatique, par défaut, laisse CRE autoriser ou bloquer seul chaque mise à niveau au moyen d'une recherche d'attaque bornée : un appelant externe approvisionné, chaque sélecteur du nouveau code, et le détenteur dans chacun des quatre premiers emplacements d'argument de chaque nouvelle fonction. Le mode strict (R5) ajoute une revue humaine : chaque nouveau codehash d'implémentation doit d'abord être approuvé par la clé d'approbateur, et la simulation d'attaque s'exécute toujours. Le mode automatique convient à un token qui veut voir ses mises à niveau de routine autorisées en quelques minutes ; le mode strict intègre une revue de code à l'autorisation (les deux modes).
Chainlink CRE
CRE est la couche d'orchestration d'Upgrade Airlock : le workflow est la seule voie automatisée pour franchir la porte
intérieure, et il prend en charge chaque étape, du déclencheur on-chain jusqu'au verdict on-chain. Il est écrit avec le SDK
TypeScript de CRE (@chainlink/cre-sdk 1.23.0) et compilé en WASM avec la CLI CRE v1.37.0.
| Technologie Chainlink | Où | Rôle ici |
|---|---|---|
| Exécution du workflow CRE | cre/veto/main.ts#L1-L9, cre/veto/workflow.yaml, cre/project.yaml |
Deux handlers, cibles sepolia et sepolia-fork |
| EVM log trigger | cre/veto/workflow.ts#L202-L211, #L193-L198, #L54-L65 |
AirlockAdmin.Proposed à LATEST : une proposition est jugée dès son apparition, tout un délai d'attente avant qu'elle puisse s'exécuter |
| Cron trigger | cre/veto/workflow.ts#L200, #L209, cre/veto/airlock.ts#L323-L389 |
Le balayeur : les 20 ids les plus récents, chaque minute |
Lecture EVM (callContract) |
cre/veto/workflow.ts#L114-L122, #L137-L142, cre/veto/chain.ts |
Proposition, autorisation, oracleJudged, approbations et soldes, regroupés via Multicall3 au bloc de la proposition ou à LATEST |
Lecture EVM (headerByNumber) |
cre/veto/workflow.ts#L125-L136 |
Le dernier bloc, et le hash du bloc de la proposition qui sert de graine à l'inconnu simulé |
| Capacité HTTP en mode nœud (node mode) | cre/veto/workflow.ts#L167-L189, #L80-L94, cre/veto/judge.ts#L157-L220 |
Chaque nœud envoie à chaque fournisseur eth_getCode, puis une ou deux requêtes eth_simulateV1 (chacune sous la limite de 120 kb par requête) |
consensusIdenticalAggregation |
cre/veto/workflow.ts#L184-L188, quorum dans cre/veto/judge.ts#L244-L277 |
Consensus identique sur {verdict, rule} (52 octets au plus ; chaque nœud du DON une fois déployé) |
| Secrets | cre/veto/workflow.ts#L160-L166, cre/secrets.yaml |
La clé d'attesteur EIP-712, récupérée une fois par exécution |
Report + writeReport |
cre/veto/workflow.ts#L143-L149, cre/veto/airlock.ts#L229-L282, cre/veto/report.ts |
runtime.report (ECDSA, keccak256) et evm.writeReport, puis une lecture de la proposition pour confirmer le résultat |
Forwarder + IReceiver |
src/cre/AirlockReceiver.sol#L88-L120, #L156-L169, src/cre/IReceiver.sol, script/DeployEth.s.sol#L142 |
onReport n'accepte que le forwarder configuré et vérifie l'id et le propriétaire du workflow lorsqu'ils sont configurés |
| Chain selectors | cre/veto/workflow.ts#L100-L104 |
ethereum-testnet-sepolia |
| CLI CRE | cre/setup.sh, cre/scripts/fork-e2e.sh, scripts/sepolia/airlock.sh, scripts/sepolia/sweep.sh |
cre workflow build, cre workflow simulate --broadcast |
Fonctionnement actuel du workflow. Le workflow s'exécute sur Ethereum Sepolia avec la commande
cre workflow simulate --broadcast de la CLI CRE. Le listener (scripts/sepolia/airlock.sh) maintient le
déclencheur de log en mode écoute, le balayeur (scripts/sepolia/sweep.sh) exécute le
handler cron chaque minute, et chaque Clear et chaque Veto est une véritable transaction Sepolia passant par le
MockKeystoneForwarder Sepolia de Chainlink
0x15fC6ae953E024d975e77382eEeC56A9101f9F88.
AirlockReceiver est prêt tel quel pour un déploiement sur un DON : déployez-le avec FORWARDER pointant vers le
KeystoneForwarder de production et WORKFLOW_ID / WORKFLOW_OWNER renseignés, et onReport vérifie en plus les
métadonnées du workflow signées par le DON. DeployEth refuse le MockKeystoneForwarder, ouvert à tous, à moins que les
deux ne soient renseignés ou que ALLOW_MOCK_FORWARDER=1 l'autorise explicitement
(script/DeployEth.s.sol#L96-L104).
Conçu pour les limites par exécution de CRE. Au plus 15 lectures de chaîne (lots Multicall3 sous la limite de charge utile de 5 kb), 12 appels HTTP sur 15 avec quatre fournisseurs, une seule simulation d'attaque par exécution, et une valeur de consensus de 52 octets au plus. Les budgets sont détaillés dans docs/design.md.
En direct sur Sepolia
Ethereum Sepolia, chain id 11155111. Déployé avec script/DeployEth.s.sol dans les
blocs 11950684–11950699 (13 transactions ; AirlockAdmin au bloc 11950697) ; tous les contrats sont vérifiés sur Sourcify. Propriétaire, approbateur, gardien d'urgence
et attesteur sont quatre clés différentes. Le délai d'attente est de 25 blocs.
| Contrat | Adresse |
|---|---|
| AirlockAdmin | 0x28e7aAC40560804F480e454bF9C12F50C83b700B |
| AirlockReceiver | 0xE40D463116F4Aa24d5E79776a65ED0f1b088c670 |
| Proxy DemoBTC (dBTC) | 0x88Cc569265faDAEAc2a16B69f35E10B2158e4b71 |
| ProxyAdmin | 0x51a3fE6AD22BF7753574B7b7FD56aE9a65bd593E |
| DemoBTC v1 | 0x8f7d6453d77B3f3b008CBE4A144d82c5742eB108 |
| DemoBTC v2 (mise à niveau honnête) | 0x4D959f981C01E3B81ac6C9A20E84816Fe5f6Bce3 |
| EvilMintImpl | 0x01502c4af90c27f53B2E1293941670C6B761CAAA |
| EvilOwnerImpl | 0xBe849DE8e0a4D9b0010ba1468f94Eeb24A42d488 |
| EvilFeeImpl | 0xB95Ffa7b970B1a73488e2fC32B787fA536F12AF5 |
| EvilBaseFeeImpl | 0xE1AAa4E88DB9cc7ceCca14722eE2874D834e0310 |
| EvilDrainImpl | 0x1d98D64BC5204AE139B5A0747855841Fa5333b6d |
| PausedImpl (cible de bris de glace) | 0xb6573006fB607E96966d0F578B96AAB25413F5BE |
| Chainlink MockKeystoneForwarder | 0x15fC6ae953E024d975e77382eEeC56A9101f9F88 |
Le premier passage : sept propositions, sept verdicts on-chain (les passages suivants, #8 à #12, sont listés dans
docs/evidence/sepolia-live.md). Le bloc indiqué dans chaque lien est celui dans lequel la transaction a été
incluse ; (+n) indique combien de blocs après la proposition le verdict CRE est arrivé.
| Carte | Proposition | Verdict CRE | Tx de proposition | Tx de rapport | Ensuite |
|---|---|---|---|---|---|
v2 : mise à niveau honnête |
#4 | Clear | 11950745 | 11950747 (+2) | execute 11950772, version() = "2" |
evil-mint : n'importe qui peut minter |
#1 | Veto R1 | 11950735 | 11950736 (+1) | execute échoue avec ProposalVetoed |
evil-owner : n'importe qui peut devenir propriétaire |
#6 | Veto R2 | 11950788 | 11950790 (+2) | execute échoue avec ProposalVetoed |
evil-fee : chaque transfert est ponctionné de 10 % |
#7 | Veto R3 | 11950792 | 11950794 (+2) | execute échoue avec ProposalVetoed |
evil-basefee : le mint s'ouvre sur un bloc réel |
#3 | Veto R9 | 11950742 | 11950743 (+1) | execute échoue avec ProposalVetoed |
evil-drain : n'importe qui déplace les tokens d'un détenteur |
#2 | Veto R3B | 11950737 | 11950740 (+3) | execute échoue avec ProposalVetoed |
remove-receiver : retirer CRE des gardiens |
#5 | Veto R7 | 11950749 | 11950750 (+1) | execute échoue avec ProposalVetoed |
Chacun des verdicts ci-dessus provient de la CLI CRE officielle exécutant ce workflow sur un serveur (le listener du
déclencheur de log et le balayeur toutes les 60 secondes, scripts/sepolia/systemd/) ; les
lignes de log du workflow lui-même figurent dans docs/evidence/sepolia-live.md.
Démarrage rapide
Prérequis
- Foundry (
forge,cast,anvil),jqet Node 22. - Un compte CRE pour le simulateur (
cre login), utilisé par l'exécution de bout en bout sur fork et par le kit opérateur. cre/setup.shinstalle la CLI CRE v1.37.0 (épinglée par SHA-256) et bun 1.4.2 danscre/.tools.
Compiler et tester
git clone https://gitlab.com/jiapunk/upgrade-airlock.git # ou https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
cd upgrade-airlock
# contrats (OpenZeppelin et forge-std sont embarqués dans lib/)
forge build # récupère une seule fois le solc 0.8.37 épinglé
forge test --offline # 117 tests
# le workflow CRE
cre/setup.sh
export PATH="$PWD/cre/.tools/bin:$PWD/cre/.tools/node_modules/.bin:$PATH"
(cd cre/veto && bun test && bun run typecheck) # 118 tests : règles, sondes, bytecode, rapports, budgets, handlers
# la salle de crise
(cd web && npm ci && npm run selftest) # 394 vérifications : vue en direct et sélecteur d'attaques, relecture hors ligne, API opérateur
De bout en bout sur un fork de Sepolia
cre login # une seule fois ; le simulateur nécessite un compte CRE
cre/scripts/fork-e2e.sh # un fork anvil local de Sepolia : rien n'est envoyé sur un réseau réel
cre/scripts/fork-e2e.sh déploie la pile sur le fork, exécute le workflow dans le
simulateur CRE avec --broadcast et vérifie chaque résultat on-chain :
- Déclencheur de log : veto R1 pour EvilMint, R2 pour EvilOwner, R3 pour EvilFee, R9 pour EvilBaseFee (le veto
désigne
BASEFEE), R3B pour EvilDrain (le veto désignerescue, toutes les autres règles passant) ; sept autres variantes de siphonnage (test/fixtures/DrainVariants.sol) bloquées par R3B seule : arguments réordonnés, garde sur l'adresse du token, siphonnage ouvert uniquement aux appelants détenant des tokens ou de l'ETH, siphonnage sans événementTransfer, ettransferFromqui ignore l'allowance pour les détenteurs de tokens ; la v2 honnête autorisée puis exécutée après le délai ; le retrait du récepteur des gardiens bloqué par R7 ; un délai raccourci, pré-approuvé par l'approbateur, autorisé. - Balayeur : une mise à niveau que personne n'a jugée échoue avec
NotClearedaprès le délai, un passage du balayeur l'autorise, puis elle s'exécute ; deux propositions autorisées manuellement par l'approbateur sont jugées au passage suivant : EvilDrain reçoit un veto R3B qui l'emporte sur l'autorisation, et un délai allongé est également autorisé par CRE ; une mise à niveau avec 6 KB de données d'initialisation reçoit un veto R8 au passage d'après. - Preuves : l'exécution de chaque proposition bloquée échoue avec
ProposalVetoed, et le hash de preuve de chaque verdict est recalculé à partir des données de la chaîne (cre/veto/tools/check-evidence.ts).
Kit opérateur Sepolia
scripts/sepolia/ fait tourner la démo en direct. Les clés sont conservées hors du dépôt,
une clé hexadécimale par fichier dans KEY_DIR (owner.key, attester.key, sender.key,
approver.key, emergency.key, plus un sweeper.key facultatif) ; les scripts
ne les affichent jamais, et deploy.sh refuse de s'exécuter si propriétaire, approbateur, gardien d'urgence et
attesteur ne sont pas quatre clés différentes. Chaque script peut aussi cibler un fork local avec FORK=<port>, ce qui
permet de répéter la démo sans rien dépenser.
scripts/sepolia/deploy.sh # 13 transactions, écrit deployments/sepolia.json (FORCE=1 remplace un déploiement existant)
bun scripts/sepolia/make-config.mjs # la config du workflow, 4 fournisseurs, quorum 2
scripts/sepolia/fund-sender.sh 0.05 # de l'ETH pour les transactions de rapport
scripts/sepolia/airlock.sh # terminal A : le listener CRE (log trigger)
scripts/sepolia/propose.sh v2 # terminal B : puis evil-mint, evil-owner, evil-fee, evil-basefee, evil-drain, remove-receiver
scripts/sepolia/execute.sh <id> # exécute une proposition autorisée après son délai, affiche le revert sinon
scripts/sepolia/status.sh # chaque proposition : verdict, jugée par CRE ou non, règle, preuve, txs
sweep.sh exécute une fois le balayeur cron, clear.sh est l'autorisation manuelle de l'approbateur, fork.sh
démarre, sauvegarde et restaure un fork de Sepolia, probe-rpcs.ts vérifie chaque fournisseur avec le code de jugement
du workflow lui-même, et systemd/ fait tourner le listener et le balayeur sur un serveur Linux.
Salle de crise
cd web
npm ci
npm run dev # http://127.0.0.1:5173/
npm run build:airlock # synchronise deployments/sepolia.json dans la page, construit web/dist-airlock
La page lit Sepolia directement ; elle ne détient aucune clé et ne signe ni n'envoie jamais rien. Elle présente chaque
proposition comme un sas : le délai d'attente, l'autorisation CRE, le veto avec sa règle et sa preuve. Les visiteurs
choisissent l'une des sept cartes (six attaques et la mise à niveau honnête v2) et regardent une relecture, signalée
comme telle, de la proposition réelle la plus récente de ce type sur Sepolia (ou la proposition en direct tant qu'elle
est dans son délai d'attente) : voir le sélecteur d'attaques. Pour
la personne qui présente, il existe un mode opérateur (web/operator/, web/functions/) : en loopback uniquement sur
le serveur de développement, protégé par jeton sur Cloudflare Pages, où il reste désactivé tant que les deux secrets
OWNER_KEY et OPERATOR_TOKEN n'existent pas. Il envoie l'une des mêmes sept propositions fixes via POST /api/operator/propose, signée côté serveur avec la clé du propriétaire (la conception suppose déjà que cette clé
fuite : elle ne peut que proposer), et la page d'un visiteur ne l'appelle jamais : voir le mode
opérateur.
| Paramètre d'URL | Effet |
|---|---|
?mode=airlock |
Par défaut. Vue en direct d'Ethereum Sepolia, en lecture seule |
?mode=offline |
Rejoue une exécution enregistrée sans aucune connexion réseau |
?lang=en, ?lang=zh-TW, ?lang=zh-CN |
Langue de l'interface (par défaut zh-CN ; L passe de l'une à l'autre) |
?rpc=http://127.0.0.1:<port> |
Lit plutôt un fork local de Sepolia, pour les répétitions |
?op=1 |
Mode opérateur : demande le jeton d'opérateur (réservé à la personne qui présente) |
Appuyez sur P en mode direct pour rejouer les propositions les plus récentes à partir de leurs événements on-chain,
et sur 1 à 7 pour rejouer une carte du sélecteur.
Structure du dépôt
src/AirlockAdmin.sol les deux portes : propositions, délai d'attente, veto des gardiens, autorisation, execute, breakGlass
src/cre/AirlockReceiver.sol récepteur des rapports CRE : forwarder, métadonnées du workflow, attestation EIP-712, liaison au digest
src/cre/IReceiver.sol interface de récepteur Chainlink CRE
src/DemoBTC.sol, DemoBTCV2.sol token de démo (dBTC) v1 et la v2 honnête
src/PausedImpl.sol cible de bris de glace : toute modification de solde est annulée (revert)
src/attack/ EvilMintImpl, EvilOwnerImpl, EvilFeeImpl, EvilBaseFeeImpl, EvilDrainImpl
script/DeployEth.s.sol déploiement (13 transactions) avec vérification des rôles, du forwarder et du câblage post-déploiement
test/ tests Foundry ; fixtures/DrainVariants.sol contient les sept variantes de siphonnage
cre/veto/ le workflow CRE : workflow.ts (capacités), airlock.ts (handlers), judge.ts (règles,
quorum), simulate.ts (eth_simulateV1), bytecode.ts (sélecteurs, R9), report.ts
(rapport, EIP-712, preuves), chain.ts (budget de lectures), keys.ts, rules.ts, config.ts
cre/veto/test/ tests unitaires Bun
cre/veto/tools/ check-evidence.ts recalcule chaque hash de preuve on-chain
cre/scripts/fork-e2e.sh de bout en bout sur un fork anvil de Sepolia
cre/setup.sh CLI CRE et bun épinglés
scripts/sepolia/ kit opérateur de la démo en direct, avec des unités systemd pour un serveur
web/ salle de crise (Vite, TypeScript, viem)
web/operator/, web/functions/ mode opérateur : sept propositions fixes signées côté serveur (serveur de dev, Pages Functions)
deployments/sepolia.json le déploiement en direct
docs/design.md modèle de menace, machine à états de l'autorisation, liaison des rapports, budgets
docs/evidence/ l'exécution en direct enregistrée
.github/workflows/ci.yml vérifications des contrats, du workflow et de la salle de crise
Modèle de sécurité
Upgrade Airlock part du principe que la clé du propriétaire sera compromise, et il est conçu pour que cette clé ne puisse jamais, à elle seule, exécuter une mise à niveau.
| Rôle | Détenu par | Peut |
|---|---|---|
| Propriétaire | une clé | proposer des mises à niveau et des modifications du sas (gardiens, délai, cible de bris de glace) ; breakGlass |
| Approbateur | une clé distincte | autoriser manuellement quand CRE ne peut pas répondre ; approuver codehashes, données d'initialisation et modifications du sas (approveCodehash) |
| Gardien d'urgence | une clé distincte | opposer un veto à une proposition en attente ; breakGlass |
| Attesteur | un secret CRE, avec sa propre clé | signer les verdicts EIP-712 qu'accepte AirlockReceiver |
| AirlockReceiver | le récepteur des rapports CRE | oracle d'autorisation (clear) et gardien (veto, attachReason) |
| N'importe qui | appeler execute sur une proposition qui a franchi les deux portes |
Les rôles ne se recoupent jamais. Le constructeur d'AirlockAdmin refuse un propriétaire qui serait aussi
l'approbateur, un gardien ou l'oracle d'autorisation (RoleOverlap), et DeployEth ne déploie que si propriétaire,
approbateur, gardien d'urgence et attesteur sont quatre adresses différentes.
Une clé de propriétaire compromise peut proposer. Chaque proposition attend la fin du délai au vu de tous pendant
que CRE la juge, et requiert une autorisation que le propriétaire ne peut pas s'accorder lui-même. Le propriétaire peut
aussi tirer le frein d'urgence (breakGlass), qui ne fait que basculer le proxy vers l'implémentation en pause
préenregistrée ; en sortir est une mise à niveau qui franchit les deux portes, et une mise à niveau autorisée plus tôt
mais jamais exécutée peut aussi y mettre fin.
Cette même clé ne peut pas :
- exécuter sans autorisation :
AirlockAdminn'a aucun chemin d'appel générique, et la seule façon de changer l'implémentation estexecutesur une mise à niveau autorisée (oubreakGlassvers l'implémentation en pause) ; - affaiblir d'abord le sas : retirer le récepteur CRE, raccourcir le délai ou remplacer la cible de bris de glace sont aussi des propositions, et R7 y oppose son veto sans pré-approbation de l'approbateur ;
- remplacer le code après le verdict :
executerevérifie le codehash de la cible (CodehashChanged), et les comptes délégués EIP-7702 sont refusés (DelegatedAccount) ; - falsifier ou réutiliser un verdict :
AirlockReceivern'accepte un rapport que s'il provient du forwarder configuré, est signé par l'attesteur, arrive au plus tard à sonvalidUntilBlocket est lié auproposalDigestde la proposition sur cette chaîne et ce récepteur.
L'autorisation manuelle de l'approbateur est un recours pour les cas où CRE ne peut pas répondre : le balayeur juge tout de même cette proposition tant qu'elle figure parmi les 20 ids les plus récents, et un veto de CRE ou d'un gardien l'emporte. Le modèle de menace complet se trouve dans docs/design.md.
Ce sont des contrats de démonstration pour testnet, avec des limites connues, chacune fixée par un test qui passe : les autorisations n'expirent jamais, la clé du propriétaire ne peut pas être changée (une clé volée peut remettre le jeton en pause après chaque reprise) et un seul gardien malveillant peut le maintenir en pause. Elles sont listées dans docs/design.md.
Technologies utilisées
- Solidity 0.8.37 et Foundry, avec OpenZeppelin Contracts 5.7.0 (
ProxyAdmin,TransparentUpgradeableProxy,EIP712,ECDSA) - Chainlink CRE : le SDK TypeScript (
@chainlink/cre-sdk1.23.0) et la CLI CRE v1.37.0, sur Bun 1.4.2 - viem pour l'encodage ABI et les lectures de chaîne, dans le workflow et la salle de crise
- Vite et TypeScript pour la salle de crise
Outils d'IA
Upgrade Airlock a été développé avec Claude Code sous la direction de l'auteur : les contrats, le workflow CRE, les tests, les scripts, la salle de crise et ces documents ont été écrits et vérifiés avec cet outil.
Licence
MIT © 2026 jiapunk
Source
| 1 | <div align="center"> |
| 2 | |
| 3 | # Upgrade Airlock |
| 4 | |
| 5 | **Chaque mise à niveau de proxy franchit deux portes : un délai d'attente, puis une autorisation Chainlink CRE que seul un code ayant survécu à une simulation d'attaque peut obtenir.** |
| 6 | |
| 7 | [English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Español](README.es.md) · **Français** · [Deutsch](README.de.md) · [Português (Brasil)](README.pt-BR.md) · [Русский](README.ru.md) · [Tiếng Việt](README.vi.md) |
| 8 | |
| 9 | [](https://gitlab.com/jiapunk/upgrade-airlock) |
| 10 | [](LICENSE) |
| 11 | [](https://upgrade-airlock.pages.dev) |
| 12 | [](https://upgrade-airlock.pages.dev/video/) |
| 13 | [](https://sepolia.etherscan.io/address/0x28e7aAC40560804F480e454bF9C12F50C83b700B) |
| 14 | [](#chainlink-cre) |
| 15 | |
| 16 | [Points forts](#highlights) · [Fonctionnement](#how-it-works) · [Règles](#rules) · [Chainlink CRE](#chainlink-cre) · [En direct sur Sepolia](#live-on-sepolia) · [Démarrage rapide](#quick-start) · [Modèle de sécurité](#security-model) |
| 17 | |
| 18 | </div> |
| 19 | |
| 20 | ## Vue d'ensemble |
| 21 | |
| 22 | Upgrade Airlock dote chaque mise à niveau de contrat de deux portes. La **porte extérieure** est un délai d'attente |
| 23 | mesuré en blocs. La **porte intérieure** ne s'ouvre qu'avec une autorisation inscrite on-chain. Automatiquement, cela |
| 24 | signifie qu'un workflow Chainlink CRE a attaqué la nouvelle implémentation dans un simulateur en interrogeant quatre |
| 25 | fournisseurs RPC indépendants, qu'au moins deux ont répondu et que toutes les réponses concordent, que l'étape de |
| 26 | consensus du workflow (`consensusIdenticalAggregation`) s'est accordée sur le verdict et qu'un rapport **Clear** signé |
| 27 | a été inscrit on-chain. Quand CRE ne peut pas répondre du tout, la clé de l'approbateur peut autoriser à la main, avec |
| 28 | des preuves enregistrées ; le balayeur juge quand même cette proposition tant qu'elle fait partie des 20 ids les plus |
| 29 | récents, et un veto de CRE ou d'un gardien l'emporte. Une implémentation malveillante que la recherche d'attaques |
| 30 | atteint reçoit à la place un **Veto** signé et ne s'exécute pas, même lorsque l'attaquant détient la clé de mise à |
| 31 | niveau ; le mode strict ajoute une revue humaine du code pour ce qu'une recherche bornée ne peut pas atteindre. |
| 32 | Aujourd'hui, les verdicts viennent de la CLI CRE officielle (`cre workflow simulate --broadcast`), qui exécute le |
| 33 | workflow sur un serveur ; les contrats et le workflow sont écrits pour un DON, et y passer demande un nouveau |
| 34 | déploiement sur le forwarder de production de Chainlink. Conçu pour ETH Shenzhen 2026 sur Ethereum Sepolia. |
| 35 | |
| 36 | > [!TIP] |
| 37 | > **Démo en ligne : [upgrade-airlock.pages.dev](https://upgrade-airlock.pages.dev)** · **Vidéo de démo : |
| 38 | > [upgrade-airlock.pages.dev/video](https://upgrade-airlock.pages.dev/video/)** (chinois simplifié et anglais) |
| 39 | > |
| 40 | > La salle de crise lit Ethereum Sepolia directement depuis votre navigateur, en lecture seule. Chaque proposition y |
| 41 | > est représentée comme un sas : la **porte extérieure** enregistre la proposition, la **chambre** décompte le délai |
| 42 | > d'attente, et la **porte intérieure** reste verrouillée jusqu'à ce qu'une autorisation soit inscrite on-chain (le |
| 43 | > Clear de Chainlink CRE, ou une autorisation manuelle de l'approbateur, que la salle de crise signale comme telle). |
| 44 | > Un Veto éjecte la proposition, avec sa règle, son hash de preuve et sa transaction. Une première visite ouvre un |
| 45 | > sélecteur d'attaques : chaque carte (six attaques et la mise à niveau honnête v2) rejoue la proposition réelle la |
| 46 | > plus récente de son type sur Sepolia, signalée comme une relecture. La page s'ouvre en chinois simplifié : |
| 47 | > [`?lang=en`](https://upgrade-airlock.pages.dev/?lang=en) passe en anglais et |
| 48 | > [`?lang=zh-TW`](https://upgrade-airlock.pages.dev/?lang=zh-TW) en chinois traditionnel. Les transactions |
| 49 | > correspondantes (propositions #1 à #12) sont répertoriées dans [les preuves de l'exécution en |
| 50 | > direct](docs/evidence/sepolia-live.md). |
| 51 | |
| 52 | ## <a id="highlights"></a>Points forts |
| 53 | |
| 54 | - **Fail-closed par construction.** Une mise à niveau ne s'exécute qu'avec une autorisation on-chain. Un log manqué, une |
| 55 | panne RPC, des fournisseurs en désaccord ou une exécution ratée laissent la proposition non autorisée ou la frappent |
| 56 | d'un veto : cela peut retarder une mise à niveau, jamais en laisser passer une. Un veto l'emporte toujours sur une |
| 57 | autorisation. |
| 58 | - **Une attaque, pas une checklist.** Le workflow (sur chaque nœud, dans un déploiement sur un DON) exécute la mise à |
| 59 | niveau contre l'état réel du proxy avec `eth_simulateV1` sur quatre fournisseurs RPC. Un inconnu doté de fonds appelle chaque sélecteur du nouveau bytecode, |
| 60 | des arguments de type détenteur visent chaque nouvelle fonction, et l'offre totale, la propriété et les soldes sont |
| 61 | comparés avant et après. Au moins deux fournisseurs doivent répondre, et toutes les réponses doivent concorder. |
| 62 | - **Des verdicts liés à la proposition.** L'étape de consensus identique du workflow s'accorde sur `{verdict, rule}`. Le rapport porte une |
| 63 | attestation EIP-712 liée au contenu exact de la proposition (`proposalDigest`), à la chaîne, au récepteur et à un |
| 64 | bloc d'échéance, ainsi qu'un hash de preuve que chacun peut recalculer à partir des données de la chaîne. |
| 65 | - **Les logs manqués sont rattrapés.** Un balayeur cron examine les 20 propositions les plus récentes toutes les 60 |
| 66 | secondes et juge celles que le déclencheur de log a manquées, y compris les mises à niveau que l'approbateur a |
| 67 | autorisées manuellement ; son veto l'emporte. Une proposition repoussée sous les 20 ids les plus récents n'est plus |
| 68 | jugée : elle reste sans autorisation, sauf si l'approbateur l'a autorisée manuellement |
| 69 | ([la fenêtre de balayage](docs/design.md#the-sweep-window)). |
| 70 | - **En direct sur Ethereum Sepolia.** La CLI CRE officielle a exécuté le workflow sur un serveur et répondu on-chain à |
| 71 | chaque proposition (douze au 2026-10-25) : une mise à niveau honnête autorisée puis exécutée, onze attaques bloquées |
| 72 | par veto (R1, R2, R3, R3B, R7, R9). Tous les contrats sont vérifiés sur Sourcify. |
| 73 | - **Testé de bout en bout.** La [CI](.github/workflows/ci.yml) exécute 117 tests Foundry, 118 tests unitaires du |
| 74 | workflow et 387 des 394 vérifications d'autotest de la salle de crise (les 7 autres ont besoin de la sortie de |
| 75 | compilation de forge et de cast, que le job de CI n'a pas) ; un script de bout en bout sur fork pilote la boucle |
| 76 | complète à travers le simulateur CRE. |
| 77 | |
| 78 | ## Le problème |
| 79 | |
| 80 | Avec un proxy upgradeable, tout le protocole repose sur la clé de mise à niveau. Dès que cette clé fuit, l'attaque est |
| 81 | courte : déployer une implémentation malveillante, faire pointer le proxy dessus, minter ou vider les fonds. C'est déjà |
| 82 | arrivé à de nombreuses reprises : |
| 83 | |
| 84 | - **PAID Network, mars 2021.** Les clés du propriétaire du proxy ont remplacé le code audité du token par une version |
| 85 | dotée de fonctions burn et mint, et l'attaquant a minté environ 59,5 millions de PAID. |
| 86 | ([post-mortem de CertiK](https://www.certik.com/blog/paid-network-post-mortem)) |
| 87 | - **Ankr, décembre 2022.** Avec la clé compromise du déployeur, l'attaquant a remplacé l'implémentation d'aBNBc par une |
| 88 | version qui mintait sans contrôle d'autorisation : 60 000 milliards d'aBNBc. |
| 89 | ([rapport d'Ankr](https://www.ankr.com/blog/the-abnbc-token-report/)) |
| 90 | - **Radiant Capital, octobre 2024.** Un attaquant contrôlant 3 des 11 signataires du multisig s'est emparé du |
| 91 | fournisseur des pools de prêt (lending pool provider) et a mis à niveau leur implémentation, siphonnant plus de |
| 92 | 53 millions de dollars. |
| 93 | ([rekt.news](https://rekt.news/radiant-capital-rekt2)) |
| 94 | |
| 95 | Un timelock se contente de faire attendre l'attaquant, et personne n'est tenu de surveiller pendant ce temps. De longs |
| 96 | délais bloquent aussi les correctifs d'urgence. Upgrade Airlock fait travailler l'attente : une mise à niveau ne peut |
| 97 | pas s'exécuter tant qu'une vérification indépendante (décentralisée une fois déployée sur un DON CRE) ne l'a pas |
| 98 | validée, et cette vérification |
| 99 | s'exécute automatiquement sur chaque proposition. |
| 100 | |
| 101 | ## <a id="how-it-works"></a>Fonctionnement |
| 102 | |
| 103 | ```mermaid |
| 104 | sequenceDiagram |
| 105 | actor Owner as Clé du propriétaire (supposée compromise) |
| 106 | participant Admin as AirlockAdmin |
| 107 | participant CRE as Workflow Chainlink CRE |
| 108 | participant RPC as 4 fournisseurs RPC |
| 109 | participant Fwd as KeystoneForwarder |
| 110 | participant Recv as AirlockReceiver |
| 111 | actor Anyone as N'importe qui |
| 112 | |
| 113 | Owner->>Admin: proposeUpgrade(newImpl, data) |
| 114 | Note over Admin: Porte extérieure : délai d'attente de 25 blocs, environ 5 minutes sur Sepolia |
| 115 | Admin-->>CRE: log Proposed (EVM log trigger), ou le balayeur cron toutes les 60 s |
| 116 | CRE->>Admin: lectures EVM au bloc de la proposition |
| 117 | loop Sur chaque nœud (un dans le simulateur, chaque nœud du DON une fois déployé), pour chaque fournisseur |
| 118 | CRE->>RPC: eth_getCode, puis la simulation d'attaque eth_simulateV1 |
| 119 | RPC-->>CRE: verdict et règle |
| 120 | end |
| 121 | Note over CRE: Quorum : au moins 2 réponses, toutes identiques, sinon Veto R6<br/>consensus identique sur le verdict et la règle<br/>hash de preuve et attestation EIP-712 |
| 122 | CRE->>Fwd: writeReport |
| 123 | Fwd->>Recv: onReport(metadata, report) |
| 124 | alt Tous les contrôles réussis |
| 125 | Recv->>Admin: clear(id, evidenceHash) |
| 126 | else Une règle échoue |
| 127 | Recv->>Admin: veto(id, ruleHash) et attachReason(id, evidenceHash) |
| 128 | end |
| 129 | Anyone->>Admin: execute(id) |
| 130 | Note over Admin: Porte intérieure : délai écoulé, autorisée, sans veto, codehash inchangé |
| 131 | Admin->>Admin: ProxyAdmin.upgradeAndCall(proxy, newImpl, data) |
| 132 | ``` |
| 133 | |
| 134 | 1. **Proposer.** `AirlockAdmin` détient le `ProxyAdmin` d'OpenZeppelin et le `Ownable` du token. La clé du |
| 135 | propriétaire peut uniquement proposer : elle n'est jamais gardien, approbateur ni oracle d'autorisation. Chaque |
| 136 | proposition (une mise à niveau, ou une modification du sas lui-même : gardiens, délai, cible de bris de glace) |
| 137 | reçoit `readyBlock = block.number + delayBlocks`. La cible d'une mise à niveau ou du bris de glace doit être du code |
| 138 | de contrat ordinaire : un compte délégué EIP-7702, dont la clé pourrait le rediriger après le verdict, est refusé |
| 139 | (`DelegatedAccount`). |
| 140 | 2. **Juger.** Le déclencheur de log CRE réagit à `Proposed`. Une modification du sas est jugée à partir de lectures |
| 141 | on-chain (R7). Une mise à niveau est d'abord soumise aux règles statiques (R5, R8), puis à la simulation d'attaque, |
| 142 | ancrée au bloc de la proposition : le slot d'implémentation ERC-1967 du proxy est remplacé par le nouveau code, et |
| 143 | une nouvelle adresse inconnue (dérivée du hash du bloc de la proposition) est approvisionnée comme un véritable |
| 144 | utilisateur, en tokens venant d'un second détenteur et en ETH. Un détenteur effectue un transfert, |
| 145 | l'inconnu appelle `transferFrom` sans allowance, puis chaque sélecteur trouvé dans le nouveau bytecode avec cinq |
| 146 | formes d'arguments. Chaque sélecteur hors de l'interface standard du token est aussi appelé avec des arguments de |
| 147 | type détenteur : le détenteur approvisionné dans chacun des quatre premiers emplacements d'argument, le montant de |
| 148 | test dans un autre, l'inconnu ou le proxy dans les autres. L'offre, le propriétaire et chaque solde surveillé sont |
| 149 | lus avant et après, le solde du détenteur juste après chaque appel de type détenteur, et les événements émis par |
| 150 | chaque sonde sont vérifiés (R1 à R4, R3B). Enfin, le nouveau bytecode est analysé à la recherche d'opcodes qui ne |
| 151 | se comportent pas de la même façon sur un bloc réel et en simulation (R9). |
| 152 | 3. **S'accorder.** Chaque nœud (chaque nœud du DON dans un déploiement sur un DON ; aujourd'hui, le simulateur de la |
| 153 | CLI) exécute la simulation sur quatre fournisseurs. Au moins deux doivent répondre et toutes les réponses doivent |
| 154 | donner le même verdict ; dans tout autre cas, c'est un veto (R6). L'étape de consensus s'accorde ensuite sur un |
| 155 | `{verdict, rule}` identique. |
| 156 | 4. **Transmettre.** Le workflow calcule le hash d'un JSON de preuve canonique, signe un verdict EIP-712 avec sa clé |
| 157 | d'attesteur (un secret CRE) et écrit le rapport via le forwarder. `AirlockReceiver` vérifie l'expéditeur, la |
| 158 | fraîcheur et la signature, ainsi que le lien du verdict avec cette proposition précise (`proposalDigest`), puis |
| 159 | appelle `clear`, ou `veto` et `attachReason`, sur `AirlockAdmin`. |
| 160 | 5. **Exécuter.** N'importe qui peut appeler `execute(id)` une fois le délai écoulé. L'appel échoue avec `TooEarly` |
| 161 | pendant le délai, `NotCleared` sans autorisation, `ProposalVetoed` après un veto, et `CodehashChanged` lorsque la |
| 162 | cible ne contient plus le code qui a été proposé et jugé. Un veto l'emporte toujours sur une autorisation. |
| 163 | 6. **Balayer.** Un déclencheur cron revérifie les propositions les plus récentes toutes les 60 secondes et juge chaque |
| 164 | proposition en attente à laquelle CRE n'a pas encore répondu (`oracleJudged`) : un log manqué par le déclencheur, un |
| 165 | événement `Proposed` trop volumineux pour être livré, ou une mise à niveau que l'approbateur a autorisée |
| 166 | manuellement. Son veto l'emporte sur cette autorisation. |
| 167 | |
| 168 | **Toute défaillance va dans le même sens.** Un log manqué, une panne RPC, des fournisseurs en désaccord ou une |
| 169 | exécution ratée laissent la proposition sans autorisation ou la frappent d'un veto : la mise à niveau est retardée, |
| 170 | jamais laissée passer. Lorsque CRE ne peut pas répondre du tout, la clé d'approbateur peut autoriser manuellement une |
| 171 | mise à niveau en consignant des preuves ; le balayeur juge tout de même cette mise à niveau dès que CRE répond à |
| 172 | nouveau, et un veto de CRE ou d'un gardien l'emporte toujours. `breakGlass()` permet au propriétaire ou à n'importe quel |
| 173 | gardien de basculer immédiatement le proxy vers une implémentation en pause préenregistrée. Sortir de l'état de pause |
| 174 | est une mise à niveau qui franchit les deux portes ; une proposition autorisée n'expire jamais, si bien qu'une mise à |
| 175 | niveau autorisée plus tôt peut aussi mettre fin à la pause ([limites connues](docs/design.md#known-limitations)). |
| 176 | |
| 177 | Plus de détails : [docs/design.md](docs/design.md) (modèle de menace, machine à états de l'autorisation, liaison des |
| 178 | rapports, budgets) et [cre/README.md](cre/README.md) (le workflow). |
| 179 | |
| 180 | ## <a id="rules"></a>Règles |
| 181 | |
| 182 | `ruleHash = keccak256("GUARD/<ID>")`, enregistré on-chain avec chaque veto. |
| 183 | |
| 184 | | Règle | Veto si | Décidé par | |
| 185 | |---|---|---| |
| 186 | | R1 `TOTAL_SUPPLY` | un appel de l'inconnu modifie `totalSupply` | simulation d'attaque | |
| 187 | | R2 `OWNER` | un appel de l'inconnu modifie `owner()` | simulation d'attaque | |
| 188 | | R3 `TRANSFER_CONSERVATION` | un transfert de X par un détenteur ne déplace pas exactement X, ou modifie le solde de quelqu'un d'autre | simulation d'attaque | |
| 189 | | R3B `THIRD_PARTY_FUNDS` | un tiers déplace les tokens d'un détenteur : le `transferFrom` sans allowance de l'inconnu approvisionné réussit, ou l'une de ses sondes déplace les tokens de quelqu'un d'autre, minte, brûle ou transfère la propriété, ou laisse le solde du détenteur plus bas, même sans événement ou lorsqu'un appel ultérieur l'annule | simulation d'attaque | |
| 190 | | R4 `TRANSFER_LIVENESS` | un simple transfert d'un détenteur échoue | simulation d'attaque | |
| 191 | | R5 `STRICT_CODEHASH` | le mode strict est activé et l'approbateur n'a pas approuvé le codehash | lectures on-chain | |
| 192 | | R6 `SIM_FAILURE` | la simulation ne peut pas trancher : fournisseurs en désaccord, trop peu de réponses, code non concordant, revert d'une fonction view ou de l'approvisionnement de l'inconnu, ou sondes qui ne tiennent pas dans les requêtes configurées | simulation et quorum | |
| 193 | | R7 `CONFIG_GUARD` | un changement de gardien, un délai raccourci ou une nouvelle cible de bris de glace sans pré-approbation de l'approbateur | lectures on-chain | |
| 194 | | R8 `INIT_DATA` | une mise à niveau embarque des données d'initialisation que l'approbateur n'a pas approuvées conjointement avec le codehash | lectures on-chain | |
| 195 | | R9 `NEW_OPCODES` | le nouveau code contient un opcode de contexte de bloc ou de transaction (BASEFEE, TIMESTAMP, NUMBER, ORIGIN, GAS, etc.), un appel externe, un create ou un selfdestruct que la liste d'autorisation fixe de la configuration (vide par défaut) ne couvre pas, sauf si l'approbateur a approuvé ce codehash exact | bytecode | |
| 196 | |
| 197 | **R9 bloque le contournement de la simulation.** `EvilBaseFeeImpl` se comporte comme v1 tant que |
| 198 | `block.basefee == 0`, ce que voit un appel `eth_simulateV1`, et permet à n'importe qui de minter sur un bloc réel. Tous |
| 199 | les invariants simulés passent ; son opcode `BASEFEE` déclenche le veto. La liste d'autorisation ne dépend jamais du |
| 200 | code que le proxy exécute aujourd'hui : une approbation couvre donc un seul codehash et aucune mise à niveau |
| 201 | ultérieure. L'analyse n'ignore la queue de métadonnées du compilateur que lorsqu'il s'agit d'une map CBOR solc exacte |
| 202 | qu'aucune instruction ne peut atteindre ; tout le reste est analysé comme du code. Approuver un codehash l'exempte de |
| 203 | R9 et tolère la troncature des sondes (R6), rien de plus : toutes les autres règles s'appliquent toujours. |
| 204 | |
| 205 | **R3B bloque le siphonnage discret.** `EvilDrainImpl`, c'est v1 plus `rescue(from, to, amount)`, sans contrôle |
| 206 | d'accès. L'offre, le propriétaire, les transferts et les opcodes sont ceux de v1, donc R1 à R4 et R9 passent tous. |
| 207 | Comme `rescue` n'est pas une fonction standard de token, l'inconnu l'appelle aussi avec des arguments de type |
| 208 | détenteur ; l'appel avec le détenteur comme `from` déplace les tokens du détenteur, et R3B y oppose son veto. Un |
| 209 | siphonnage qui se cache derrière « réservé aux appelants qui détiennent des tokens ou de l'ETH » est atteint lui aussi, |
| 210 | car l'inconnu détient les deux. |
| 211 | |
| 212 | ### Mode automatique et mode strict |
| 213 | |
| 214 | Le **mode automatique**, par défaut, laisse CRE autoriser ou bloquer seul chaque mise à niveau au moyen d'une |
| 215 | recherche d'attaque bornée : un appelant externe approvisionné, chaque sélecteur du nouveau code, et le détenteur dans |
| 216 | chacun des quatre premiers emplacements d'argument de chaque nouvelle fonction. Le **mode strict** (R5) ajoute une revue |
| 217 | humaine : chaque nouveau codehash d'implémentation doit d'abord être approuvé par la clé d'approbateur, et la simulation |
| 218 | d'attaque s'exécute toujours. Le mode automatique convient à un token qui veut voir ses mises à niveau de routine |
| 219 | autorisées en quelques minutes ; le mode strict intègre une revue de code à l'autorisation |
| 220 | ([les deux modes](docs/design.md#two-modes-automatic-and-strict)). |
| 221 | |
| 222 | ## Chainlink CRE |
| 223 | |
| 224 | CRE est la couche d'orchestration d'Upgrade Airlock : le workflow est la seule voie automatisée pour franchir la porte |
| 225 | intérieure, et il prend en charge chaque étape, du déclencheur on-chain jusqu'au verdict on-chain. Il est écrit avec le SDK |
| 226 | TypeScript de CRE (`@chainlink/cre-sdk` 1.23.0) et compilé en WASM avec la CLI CRE v1.37.0. |
| 227 | |
| 228 | | Technologie Chainlink | Où | Rôle ici | |
| 229 | |---|---|---| |
| 230 | | Exécution du workflow CRE | [`cre/veto/main.ts#L1-L9`](cre/veto/main.ts#L1-L9), [`cre/veto/workflow.yaml`](cre/veto/workflow.yaml), [`cre/project.yaml`](cre/project.yaml) | Deux handlers, cibles `sepolia` et `sepolia-fork` | |
| 231 | | EVM log trigger | [`cre/veto/workflow.ts#L202-L211`](cre/veto/workflow.ts#L202-L211), [`#L193-L198`](cre/veto/workflow.ts#L193-L198), [`#L54-L65`](cre/veto/workflow.ts#L54-L65) | `AirlockAdmin.Proposed` à `LATEST` : une proposition est jugée dès son apparition, tout un délai d'attente avant qu'elle puisse s'exécuter | |
| 232 | | Cron trigger | [`cre/veto/workflow.ts#L200`](cre/veto/workflow.ts#L200), [`#L209`](cre/veto/workflow.ts#L209), [`cre/veto/airlock.ts#L323-L389`](cre/veto/airlock.ts#L323-L389) | Le balayeur : les 20 ids les plus récents, chaque minute | |
| 233 | | Lecture EVM (`callContract`) | [`cre/veto/workflow.ts#L114-L122`](cre/veto/workflow.ts#L114-L122), [`#L137-L142`](cre/veto/workflow.ts#L137-L142), [`cre/veto/chain.ts`](cre/veto/chain.ts) | Proposition, autorisation, `oracleJudged`, approbations et soldes, regroupés via Multicall3 au bloc de la proposition ou à `LATEST` | |
| 234 | | Lecture EVM (`headerByNumber`) | [`cre/veto/workflow.ts#L125-L136`](cre/veto/workflow.ts#L125-L136) | Le dernier bloc, et le hash du bloc de la proposition qui sert de graine à l'inconnu simulé | |
| 235 | | Capacité HTTP en mode nœud (node mode) | [`cre/veto/workflow.ts#L167-L189`](cre/veto/workflow.ts#L167-L189), [`#L80-L94`](cre/veto/workflow.ts#L80-L94), [`cre/veto/judge.ts#L157-L220`](cre/veto/judge.ts#L157-L220) | Chaque nœud envoie à chaque fournisseur `eth_getCode`, puis une ou deux requêtes `eth_simulateV1` (chacune sous la limite de 120 kb par requête) | |
| 236 | | `consensusIdenticalAggregation` | [`cre/veto/workflow.ts#L184-L188`](cre/veto/workflow.ts#L184-L188), quorum dans [`cre/veto/judge.ts#L244-L277`](cre/veto/judge.ts#L244-L277) | Consensus identique sur `{verdict, rule}` (52 octets au plus ; chaque nœud du DON une fois déployé) | |
| 237 | | Secrets | [`cre/veto/workflow.ts#L160-L166`](cre/veto/workflow.ts#L160-L166), [`cre/secrets.yaml`](cre/secrets.yaml) | La clé d'attesteur EIP-712, récupérée une fois par exécution | |
| 238 | | Report + `writeReport` | [`cre/veto/workflow.ts#L143-L149`](cre/veto/workflow.ts#L143-L149), [`cre/veto/airlock.ts#L229-L282`](cre/veto/airlock.ts#L229-L282), [`cre/veto/report.ts`](cre/veto/report.ts) | `runtime.report` (ECDSA, keccak256) et `evm.writeReport`, puis une lecture de la proposition pour confirmer le résultat | |
| 239 | | Forwarder + `IReceiver` | [`src/cre/AirlockReceiver.sol#L88-L120`](src/cre/AirlockReceiver.sol#L88-L120), [`#L156-L169`](src/cre/AirlockReceiver.sol#L156-L169), [`src/cre/IReceiver.sol`](src/cre/IReceiver.sol), [`script/DeployEth.s.sol#L142`](script/DeployEth.s.sol#L142) | `onReport` n'accepte que le forwarder configuré et vérifie l'id et le propriétaire du workflow lorsqu'ils sont configurés | |
| 240 | | Chain selectors | [`cre/veto/workflow.ts#L100-L104`](cre/veto/workflow.ts#L100-L104) | `ethereum-testnet-sepolia` | |
| 241 | | CLI CRE | [`cre/setup.sh`](cre/setup.sh), [`cre/scripts/fork-e2e.sh`](cre/scripts/fork-e2e.sh), [`scripts/sepolia/airlock.sh`](scripts/sepolia/airlock.sh), [`scripts/sepolia/sweep.sh`](scripts/sepolia/sweep.sh) | `cre workflow build`, `cre workflow simulate --broadcast` | |
| 242 | |
| 243 | **Fonctionnement actuel du workflow.** Le workflow s'exécute sur Ethereum Sepolia avec la commande |
| 244 | `cre workflow simulate --broadcast` de la CLI CRE. Le listener ([`scripts/sepolia/airlock.sh`](scripts/sepolia/airlock.sh)) maintient le |
| 245 | déclencheur de log en mode écoute, le balayeur ([`scripts/sepolia/sweep.sh`](scripts/sepolia/sweep.sh)) exécute le |
| 246 | handler cron chaque minute, et chaque Clear et chaque Veto est une véritable transaction Sepolia passant par le |
| 247 | MockKeystoneForwarder Sepolia de Chainlink |
| 248 | [`0x15fC6ae953E024d975e77382eEeC56A9101f9F88`](https://docs.chain.link/cre/guides/workflow/using-evm-client/forwarder-directory-ts). |
| 249 | `AirlockReceiver` est prêt tel quel pour un déploiement sur un DON : déployez-le avec `FORWARDER` pointant vers le |
| 250 | KeystoneForwarder de production et `WORKFLOW_ID` / `WORKFLOW_OWNER` renseignés, et `onReport` vérifie en plus les |
| 251 | métadonnées du workflow signées par le DON. `DeployEth` refuse le MockKeystoneForwarder, ouvert à tous, à moins que les |
| 252 | deux ne soient renseignés ou que `ALLOW_MOCK_FORWARDER=1` l'autorise explicitement |
| 253 | ([`script/DeployEth.s.sol#L96-L104`](script/DeployEth.s.sol#L96-L104)). |
| 254 | |
| 255 | **Conçu pour les limites par exécution de CRE.** Au plus 15 lectures de chaîne (lots Multicall3 sous la limite de |
| 256 | charge utile de 5 kb), 12 appels HTTP sur 15 avec quatre fournisseurs, une seule simulation d'attaque par exécution, et |
| 257 | une valeur de consensus de 52 octets au plus. Les budgets sont détaillés dans |
| 258 | [docs/design.md](docs/design.md#budgets). |
| 259 | |
| 260 | ## <a id="live-on-sepolia"></a>En direct sur Sepolia |
| 261 | |
| 262 | Ethereum Sepolia, chain id 11155111. Déployé avec [`script/DeployEth.s.sol`](script/DeployEth.s.sol) dans les |
| 263 | blocs 11950684–11950699 (13 transactions ; AirlockAdmin au bloc 11950697) ; tous les contrats sont vérifiés sur Sourcify. Propriétaire, approbateur, gardien d'urgence |
| 264 | et attesteur sont quatre clés différentes. Le délai d'attente est de 25 blocs. |
| 265 | |
| 266 | | Contrat | Adresse | |
| 267 | |---|---| |
| 268 | | AirlockAdmin | [`0x28e7aAC40560804F480e454bF9C12F50C83b700B`](https://sepolia.etherscan.io/address/0x28e7aAC40560804F480e454bF9C12F50C83b700B) | |
| 269 | | AirlockReceiver | [`0xE40D463116F4Aa24d5E79776a65ED0f1b088c670`](https://sepolia.etherscan.io/address/0xE40D463116F4Aa24d5E79776a65ED0f1b088c670) | |
| 270 | | Proxy DemoBTC (dBTC) | [`0x88Cc569265faDAEAc2a16B69f35E10B2158e4b71`](https://sepolia.etherscan.io/address/0x88Cc569265faDAEAc2a16B69f35E10B2158e4b71) | |
| 271 | | ProxyAdmin | [`0x51a3fE6AD22BF7753574B7b7FD56aE9a65bd593E`](https://sepolia.etherscan.io/address/0x51a3fE6AD22BF7753574B7b7FD56aE9a65bd593E) | |
| 272 | | DemoBTC v1 | [`0x8f7d6453d77B3f3b008CBE4A144d82c5742eB108`](https://sepolia.etherscan.io/address/0x8f7d6453d77B3f3b008CBE4A144d82c5742eB108) | |
| 273 | | DemoBTC v2 (mise à niveau honnête) | [`0x4D959f981C01E3B81ac6C9A20E84816Fe5f6Bce3`](https://sepolia.etherscan.io/address/0x4D959f981C01E3B81ac6C9A20E84816Fe5f6Bce3) | |
| 274 | | EvilMintImpl | [`0x01502c4af90c27f53B2E1293941670C6B761CAAA`](https://sepolia.etherscan.io/address/0x01502c4af90c27f53B2E1293941670C6B761CAAA) | |
| 275 | | EvilOwnerImpl | [`0xBe849DE8e0a4D9b0010ba1468f94Eeb24A42d488`](https://sepolia.etherscan.io/address/0xBe849DE8e0a4D9b0010ba1468f94Eeb24A42d488) | |
| 276 | | EvilFeeImpl | [`0xB95Ffa7b970B1a73488e2fC32B787fA536F12AF5`](https://sepolia.etherscan.io/address/0xB95Ffa7b970B1a73488e2fC32B787fA536F12AF5) | |
| 277 | | EvilBaseFeeImpl | [`0xE1AAa4E88DB9cc7ceCca14722eE2874D834e0310`](https://sepolia.etherscan.io/address/0xE1AAa4E88DB9cc7ceCca14722eE2874D834e0310) | |
| 278 | | EvilDrainImpl | [`0x1d98D64BC5204AE139B5A0747855841Fa5333b6d`](https://sepolia.etherscan.io/address/0x1d98D64BC5204AE139B5A0747855841Fa5333b6d) | |
| 279 | | PausedImpl (cible de bris de glace) | [`0xb6573006fB607E96966d0F578B96AAB25413F5BE`](https://sepolia.etherscan.io/address/0xb6573006fB607E96966d0F578B96AAB25413F5BE) | |
| 280 | | Chainlink MockKeystoneForwarder | [`0x15fC6ae953E024d975e77382eEeC56A9101f9F88`](https://sepolia.etherscan.io/address/0x15fC6ae953E024d975e77382eEeC56A9101f9F88) | |
| 281 | |
| 282 | Le premier passage : sept propositions, sept verdicts on-chain (les passages suivants, #8 à #12, sont listés dans |
| 283 | [docs/evidence/sepolia-live.md](docs/evidence/sepolia-live.md)). Le bloc indiqué dans chaque lien est celui dans lequel la transaction a été |
| 284 | incluse ; `(+n)` indique combien de blocs après la proposition le verdict CRE est arrivé. |
| 285 | |
| 286 | | Carte | Proposition | Verdict CRE | Tx de proposition | Tx de rapport | Ensuite | |
| 287 | |---|---|---|---|---|---| |
| 288 | | `v2` : mise à niveau honnête | #4 | Clear | [11950745](https://sepolia.etherscan.io/tx/0x3cd96ac220391e2b404d35591d1a96808b75256e7c685a414e2ca5a63bc54145) | [11950747 (+2)](https://sepolia.etherscan.io/tx/0x2c02d2a23016a72509900eb7ce92dabc06b0dd93a2394d076a62d687ce467efb) | `execute` [11950772](https://sepolia.etherscan.io/tx/0x01913c1854b9862763af74ddacd7be23a7f55232af7334714aa8213aa7021890), `version()` = "2" | |
| 289 | | `evil-mint` : n'importe qui peut minter | #1 | Veto R1 | [11950735](https://sepolia.etherscan.io/tx/0x862c5fdf826a8fef09fe2ff278d3e58191f93c267dbdd38ad9aaa270f39937b0) | [11950736 (+1)](https://sepolia.etherscan.io/tx/0xb0d74e25e2528fb9aad7d21de202dac288cd256048f2986b5214280fda4b84f6) | `execute` échoue avec `ProposalVetoed` | |
| 290 | | `evil-owner` : n'importe qui peut devenir propriétaire | #6 | Veto R2 | [11950788](https://sepolia.etherscan.io/tx/0x32eb22039bd43602e0406b1a8f9a02c5cbcdb5cf2ef1c389919a45d05820da25) | [11950790 (+2)](https://sepolia.etherscan.io/tx/0x49451bee7a2f3351b18992d94b5f89b9ff89cf53be65a937400ab95fb79be551) | `execute` échoue avec `ProposalVetoed` | |
| 291 | | `evil-fee` : chaque transfert est ponctionné de 10 % | #7 | Veto R3 | [11950792](https://sepolia.etherscan.io/tx/0xe06924c2e3961ab27d2803c0654b783fc06e52107345bda9bfeff391053fcad3) | [11950794 (+2)](https://sepolia.etherscan.io/tx/0x01f6055a683e0dabc8f34ae094e5daa5a81ddb11c687aa9c5ac6e30c7a7f3d3b) | `execute` échoue avec `ProposalVetoed` | |
| 292 | | `evil-basefee` : le mint s'ouvre sur un bloc réel | #3 | Veto R9 | [11950742](https://sepolia.etherscan.io/tx/0x3c29c118ca395ca92500abcaae6126244924fbd8e14f846af57b70f113cad6e1) | [11950743 (+1)](https://sepolia.etherscan.io/tx/0xb6db2d832112384287e5a7ccdd086127931360900d09c80645c21eac2bc165db) | `execute` échoue avec `ProposalVetoed` | |
| 293 | | `evil-drain` : n'importe qui déplace les tokens d'un détenteur | #2 | Veto R3B | [11950737](https://sepolia.etherscan.io/tx/0x0e52369921040eb0c901ac3a077aff762d370b4efd9503a52c91360d56366b16) | [11950740 (+3)](https://sepolia.etherscan.io/tx/0x40bbe374a8cee47a94da977f973cf66a67573c12bc84117483fcbd11430f90cb) | `execute` échoue avec `ProposalVetoed` | |
| 294 | | `remove-receiver` : retirer CRE des gardiens | #5 | Veto R7 | [11950749](https://sepolia.etherscan.io/tx/0xda2bd1c9dc5f7770da3926358b8da3ffe5a1cd3154b499cafae440387eebb934) | [11950750 (+1)](https://sepolia.etherscan.io/tx/0xaa2f598bdcc7f46727e5456528521e50ac5c1d8e423805042cbf2d10a4c4cdac) | `execute` échoue avec `ProposalVetoed` | |
| 295 | |
| 296 | Chacun des verdicts ci-dessus provient de la CLI CRE officielle exécutant ce workflow sur un serveur (le listener du |
| 297 | déclencheur de log et le balayeur toutes les 60 secondes, [`scripts/sepolia/systemd/`](scripts/sepolia/systemd)) ; les |
| 298 | lignes de log du workflow lui-même figurent dans [`docs/evidence/sepolia-live.md`](docs/evidence/sepolia-live.md). |
| 299 | |
| 300 | ## <a id="quick-start"></a>Démarrage rapide |
| 301 | |
| 302 | ### Prérequis |
| 303 | |
| 304 | - [Foundry](https://getfoundry.sh) (`forge`, `cast`, `anvil`), `jq` et Node 22. |
| 305 | - Un compte CRE pour le simulateur (`cre login`), utilisé par l'exécution de bout en bout sur fork et par le kit |
| 306 | opérateur. |
| 307 | - [`cre/setup.sh`](cre/setup.sh) installe la CLI CRE v1.37.0 (épinglée par SHA-256) et bun 1.4.2 dans `cre/.tools`. |
| 308 | |
| 309 | ### Compiler et tester |
| 310 | |
| 311 | ```bash |
| 312 | git clone https://gitlab.com/jiapunk/upgrade-airlock.git # ou https://upgrade-airlock.pages.dev/source/upgrade-airlock.git |
| 313 | cd upgrade-airlock |
| 314 | |
| 315 | # contrats (OpenZeppelin et forge-std sont embarqués dans lib/) |
| 316 | forge build # récupère une seule fois le solc 0.8.37 épinglé |
| 317 | forge test --offline # 117 tests |
| 318 | |
| 319 | # le workflow CRE |
| 320 | cre/setup.sh |
| 321 | export PATH="$PWD/cre/.tools/bin:$PWD/cre/.tools/node_modules/.bin:$PATH" |
| 322 | (cd cre/veto && bun test && bun run typecheck) # 118 tests : règles, sondes, bytecode, rapports, budgets, handlers |
| 323 | |
| 324 | # la salle de crise |
| 325 | (cd web && npm ci && npm run selftest) # 394 vérifications : vue en direct et sélecteur d'attaques, relecture hors ligne, API opérateur |
| 326 | ``` |
| 327 | |
| 328 | ### De bout en bout sur un fork de Sepolia |
| 329 | |
| 330 | ```bash |
| 331 | cre login # une seule fois ; le simulateur nécessite un compte CRE |
| 332 | cre/scripts/fork-e2e.sh # un fork anvil local de Sepolia : rien n'est envoyé sur un réseau réel |
| 333 | ``` |
| 334 | |
| 335 | [`cre/scripts/fork-e2e.sh`](cre/scripts/fork-e2e.sh) déploie la pile sur le fork, exécute le workflow dans le |
| 336 | simulateur CRE avec `--broadcast` et vérifie chaque résultat on-chain : |
| 337 | |
| 338 | - **Déclencheur de log :** veto R1 pour EvilMint, R2 pour EvilOwner, R3 pour EvilFee, R9 pour EvilBaseFee (le veto |
| 339 | désigne `BASEFEE`), R3B pour EvilDrain (le veto désigne `rescue`, toutes les autres règles passant) ; sept autres |
| 340 | variantes de siphonnage ([`test/fixtures/DrainVariants.sol`](test/fixtures/DrainVariants.sol)) bloquées par R3B |
| 341 | seule : arguments réordonnés, garde sur l'adresse du token, siphonnage ouvert uniquement aux appelants détenant des |
| 342 | tokens ou de l'ETH, siphonnage sans événement `Transfer`, et `transferFrom` qui ignore l'allowance pour les |
| 343 | détenteurs de tokens ; la v2 honnête autorisée puis exécutée après le délai ; le retrait du récepteur des gardiens |
| 344 | bloqué par R7 ; un délai raccourci, pré-approuvé par l'approbateur, autorisé. |
| 345 | - **Balayeur :** une mise à niveau que personne n'a jugée échoue avec `NotCleared` après le délai, un passage du |
| 346 | balayeur l'autorise, puis elle s'exécute ; deux propositions autorisées manuellement par l'approbateur sont jugées au |
| 347 | passage suivant : EvilDrain reçoit un veto R3B qui l'emporte sur l'autorisation, et un délai allongé est également |
| 348 | autorisé par CRE ; une mise à niveau avec 6 KB de données d'initialisation reçoit un veto R8 au passage d'après. |
| 349 | - **Preuves :** l'exécution de chaque proposition bloquée échoue avec `ProposalVetoed`, et le hash de preuve de chaque |
| 350 | verdict est recalculé à partir des données de la chaîne |
| 351 | ([`cre/veto/tools/check-evidence.ts`](cre/veto/tools/check-evidence.ts)). |
| 352 | |
| 353 | ### Kit opérateur Sepolia |
| 354 | |
| 355 | [`scripts/sepolia/`](scripts/sepolia/README.md) fait tourner la démo en direct. Les clés sont conservées hors du dépôt, |
| 356 | une clé hexadécimale par fichier dans `KEY_DIR` (`owner.key`, `attester.key`, `sender.key`, |
| 357 | `approver.key`, `emergency.key`, plus un `sweeper.key` facultatif) ; les scripts |
| 358 | ne les affichent jamais, et `deploy.sh` refuse de s'exécuter si propriétaire, approbateur, gardien d'urgence et |
| 359 | attesteur ne sont pas quatre clés différentes. Chaque script peut aussi cibler un fork local avec `FORK=<port>`, ce qui |
| 360 | permet de répéter la démo sans rien dépenser. |
| 361 | |
| 362 | ```bash |
| 363 | scripts/sepolia/deploy.sh # 13 transactions, écrit deployments/sepolia.json (FORCE=1 remplace un déploiement existant) |
| 364 | bun scripts/sepolia/make-config.mjs # la config du workflow, 4 fournisseurs, quorum 2 |
| 365 | scripts/sepolia/fund-sender.sh 0.05 # de l'ETH pour les transactions de rapport |
| 366 | scripts/sepolia/airlock.sh # terminal A : le listener CRE (log trigger) |
| 367 | scripts/sepolia/propose.sh v2 # terminal B : puis evil-mint, evil-owner, evil-fee, evil-basefee, evil-drain, remove-receiver |
| 368 | scripts/sepolia/execute.sh <id> # exécute une proposition autorisée après son délai, affiche le revert sinon |
| 369 | scripts/sepolia/status.sh # chaque proposition : verdict, jugée par CRE ou non, règle, preuve, txs |
| 370 | ``` |
| 371 | |
| 372 | `sweep.sh` exécute une fois le balayeur cron, `clear.sh` est l'autorisation manuelle de l'approbateur, `fork.sh` |
| 373 | démarre, sauvegarde et restaure un fork de Sepolia, `probe-rpcs.ts` vérifie chaque fournisseur avec le code de jugement |
| 374 | du workflow lui-même, et `systemd/` fait tourner le listener et le balayeur sur un serveur Linux. |
| 375 | |
| 376 | ### Salle de crise |
| 377 | |
| 378 | ```bash |
| 379 | cd web |
| 380 | npm ci |
| 381 | npm run dev # http://127.0.0.1:5173/ |
| 382 | npm run build:airlock # synchronise deployments/sepolia.json dans la page, construit web/dist-airlock |
| 383 | ``` |
| 384 | |
| 385 | La page lit Sepolia directement ; elle ne détient aucune clé et ne signe ni n'envoie jamais rien. Elle présente chaque |
| 386 | proposition comme un sas : le délai d'attente, l'autorisation CRE, le veto avec sa règle et sa preuve. Les visiteurs |
| 387 | choisissent l'une des sept cartes (six attaques et la mise à niveau honnête v2) et regardent une relecture, signalée |
| 388 | comme telle, de la proposition réelle la plus récente de ce type sur Sepolia (ou la proposition en direct tant qu'elle |
| 389 | est dans son délai d'attente) : voir [le sélecteur d'attaques](web/README.md#attack-picker-what-a-visitor-sees). Pour |
| 390 | la personne qui présente, il existe un mode opérateur (`web/operator/`, `web/functions/`) : en loopback uniquement sur |
| 391 | le serveur de développement, protégé par jeton sur Cloudflare Pages, où il reste désactivé tant que les deux secrets |
| 392 | `OWNER_KEY` et `OPERATOR_TOKEN` n'existent pas. Il envoie l'une des mêmes sept propositions fixes via `POST |
| 393 | /api/operator/propose`, signée côté serveur avec la clé du propriétaire (la conception suppose déjà que cette clé |
| 394 | fuite : elle ne peut que proposer), et la page d'un visiteur ne l'appelle jamais : voir [le mode |
| 395 | opérateur](web/README.md#operator-mode-real-attacks-on-sepolia). |
| 396 | |
| 397 | | Paramètre d'URL | Effet | |
| 398 | |---|---| |
| 399 | | `?mode=airlock` | Par défaut. Vue en direct d'Ethereum Sepolia, en lecture seule | |
| 400 | | `?mode=offline` | Rejoue une exécution enregistrée sans aucune connexion réseau | |
| 401 | | `?lang=en`, `?lang=zh-TW`, `?lang=zh-CN` | Langue de l'interface (par défaut `zh-CN` ; `L` passe de l'une à l'autre) | |
| 402 | | `?rpc=http://127.0.0.1:<port>` | Lit plutôt un fork local de Sepolia, pour les répétitions | |
| 403 | | `?op=1` | Mode opérateur : demande le jeton d'opérateur (réservé à la personne qui présente) | |
| 404 | |
| 405 | Appuyez sur `P` en mode direct pour rejouer les propositions les plus récentes à partir de leurs événements on-chain, |
| 406 | et sur `1` à `7` pour rejouer une carte du sélecteur. |
| 407 | |
| 408 | ## Structure du dépôt |
| 409 | |
| 410 | ``` |
| 411 | src/AirlockAdmin.sol les deux portes : propositions, délai d'attente, veto des gardiens, autorisation, execute, breakGlass |
| 412 | src/cre/AirlockReceiver.sol récepteur des rapports CRE : forwarder, métadonnées du workflow, attestation EIP-712, liaison au digest |
| 413 | src/cre/IReceiver.sol interface de récepteur Chainlink CRE |
| 414 | src/DemoBTC.sol, DemoBTCV2.sol token de démo (dBTC) v1 et la v2 honnête |
| 415 | src/PausedImpl.sol cible de bris de glace : toute modification de solde est annulée (revert) |
| 416 | src/attack/ EvilMintImpl, EvilOwnerImpl, EvilFeeImpl, EvilBaseFeeImpl, EvilDrainImpl |
| 417 | script/DeployEth.s.sol déploiement (13 transactions) avec vérification des rôles, du forwarder et du câblage post-déploiement |
| 418 | test/ tests Foundry ; fixtures/DrainVariants.sol contient les sept variantes de siphonnage |
| 419 | cre/veto/ le workflow CRE : workflow.ts (capacités), airlock.ts (handlers), judge.ts (règles, |
| 420 | quorum), simulate.ts (eth_simulateV1), bytecode.ts (sélecteurs, R9), report.ts |
| 421 | (rapport, EIP-712, preuves), chain.ts (budget de lectures), keys.ts, rules.ts, config.ts |
| 422 | cre/veto/test/ tests unitaires Bun |
| 423 | cre/veto/tools/ check-evidence.ts recalcule chaque hash de preuve on-chain |
| 424 | cre/scripts/fork-e2e.sh de bout en bout sur un fork anvil de Sepolia |
| 425 | cre/setup.sh CLI CRE et bun épinglés |
| 426 | scripts/sepolia/ kit opérateur de la démo en direct, avec des unités systemd pour un serveur |
| 427 | web/ salle de crise (Vite, TypeScript, viem) |
| 428 | web/operator/, web/functions/ mode opérateur : sept propositions fixes signées côté serveur (serveur de dev, Pages Functions) |
| 429 | deployments/sepolia.json le déploiement en direct |
| 430 | docs/design.md modèle de menace, machine à états de l'autorisation, liaison des rapports, budgets |
| 431 | docs/evidence/ l'exécution en direct enregistrée |
| 432 | .github/workflows/ci.yml vérifications des contrats, du workflow et de la salle de crise |
| 433 | ``` |
| 434 | |
| 435 | ## <a id="security-model"></a>Modèle de sécurité |
| 436 | |
| 437 | Upgrade Airlock part du principe que la clé du propriétaire sera compromise, et il est conçu pour que cette clé ne |
| 438 | puisse jamais, à elle seule, exécuter une mise à niveau. |
| 439 | |
| 440 | | Rôle | Détenu par | Peut | |
| 441 | |---|---|---| |
| 442 | | Propriétaire | une clé | proposer des mises à niveau et des modifications du sas (gardiens, délai, cible de bris de glace) ; `breakGlass` | |
| 443 | | Approbateur | une clé distincte | autoriser manuellement quand CRE ne peut pas répondre ; approuver codehashes, données d'initialisation et modifications du sas (`approveCodehash`) | |
| 444 | | Gardien d'urgence | une clé distincte | opposer un `veto` à une proposition en attente ; `breakGlass` | |
| 445 | | Attesteur | un secret CRE, avec sa propre clé | signer les verdicts EIP-712 qu'accepte `AirlockReceiver` | |
| 446 | | AirlockReceiver | le récepteur des rapports CRE | oracle d'autorisation (`clear`) et gardien (`veto`, `attachReason`) | |
| 447 | | N'importe qui | | appeler `execute` sur une proposition qui a franchi les deux portes | |
| 448 | |
| 449 | Les rôles ne se recoupent jamais. Le constructeur d'`AirlockAdmin` refuse un propriétaire qui serait aussi |
| 450 | l'approbateur, un gardien ou l'oracle d'autorisation (`RoleOverlap`), et `DeployEth` ne déploie que si propriétaire, |
| 451 | approbateur, gardien d'urgence et attesteur sont quatre adresses différentes. |
| 452 | |
| 453 | **Une clé de propriétaire compromise peut proposer.** Chaque proposition attend la fin du délai au vu de tous pendant |
| 454 | que CRE la juge, et requiert une autorisation que le propriétaire ne peut pas s'accorder lui-même. Le propriétaire peut |
| 455 | aussi tirer le frein d'urgence (`breakGlass`), qui ne fait que basculer le proxy vers l'implémentation en pause |
| 456 | préenregistrée ; en sortir est une mise à niveau qui franchit les deux portes, et une mise à niveau autorisée plus tôt |
| 457 | mais jamais exécutée peut aussi y mettre fin. |
| 458 | |
| 459 | **Cette même clé ne peut pas :** |
| 460 | |
| 461 | - exécuter sans autorisation : `AirlockAdmin` n'a aucun chemin d'appel générique, et la seule façon de changer |
| 462 | l'implémentation est `execute` sur une mise à niveau autorisée (ou `breakGlass` vers l'implémentation en pause) ; |
| 463 | - affaiblir d'abord le sas : retirer le récepteur CRE, raccourcir le délai ou remplacer la cible de bris de glace sont |
| 464 | aussi des propositions, et R7 y oppose son veto sans pré-approbation de l'approbateur ; |
| 465 | - remplacer le code après le verdict : `execute` revérifie le codehash de la cible (`CodehashChanged`), et les comptes |
| 466 | délégués EIP-7702 sont refusés (`DelegatedAccount`) ; |
| 467 | - falsifier ou réutiliser un verdict : `AirlockReceiver` n'accepte un rapport que s'il provient du forwarder configuré, |
| 468 | est signé par l'attesteur, arrive au plus tard à son `validUntilBlock` et est lié au `proposalDigest` de la |
| 469 | proposition sur cette chaîne et ce récepteur. |
| 470 | |
| 471 | L'autorisation manuelle de l'approbateur est un recours pour les cas où CRE ne peut pas répondre : le balayeur juge |
| 472 | tout de même cette proposition tant qu'elle figure parmi les 20 ids les plus récents, et un veto de CRE ou d'un |
| 473 | gardien l'emporte. Le modèle de menace complet se trouve dans [docs/design.md](docs/design.md#threat-model). |
| 474 | |
| 475 | Ce sont des contrats de démonstration pour testnet, avec des limites connues, chacune fixée par un test qui passe : les |
| 476 | autorisations n'expirent jamais, la clé du propriétaire ne peut pas être changée (une clé volée peut remettre le jeton |
| 477 | en pause après chaque reprise) et un seul gardien malveillant peut le maintenir en pause. Elles sont listées dans |
| 478 | [docs/design.md](docs/design.md#known-limitations). |
| 479 | |
| 480 | ## Technologies utilisées |
| 481 | |
| 482 | - **Solidity 0.8.37** et **Foundry**, avec OpenZeppelin Contracts 5.7.0 (`ProxyAdmin`, |
| 483 | `TransparentUpgradeableProxy`, `EIP712`, `ECDSA`) |
| 484 | - **Chainlink CRE** : le SDK TypeScript (`@chainlink/cre-sdk` 1.23.0) et la CLI CRE v1.37.0, sur Bun 1.4.2 |
| 485 | - **viem** pour l'encodage ABI et les lectures de chaîne, dans le workflow et la salle de crise |
| 486 | - **Vite** et TypeScript pour la salle de crise |
| 487 | |
| 488 | ## Outils d'IA |
| 489 | |
| 490 | Upgrade Airlock a été développé avec [Claude Code](https://claude.com/claude-code) sous la direction de l'auteur : les |
| 491 | contrats, le workflow CRE, les tests, les scripts, la salle de crise et ces documents ont été écrits et vérifiés avec |
| 492 | cet outil. |
| 493 | |
| 494 | ## Licence |
| 495 | |
| 496 | [MIT](LICENSE) © 2026 jiapunk |