docs: update clef basics (introduction and tutorial pages) (#25280)

* initial commit for clef docs

* add diagram and finish 1st draft intro pg

* uodate css to resize img

* more on tutorial page

* finish drafting more sections in tutorial.md

* finish 1st draft of tutorial page

* finish Intro and Tutorial pages

* + line about ext/int api, clarify 'unlocking'
This commit is contained in:
Joseph Cook 2022-07-18 19:42:12 +01:00 committed by GitHub
parent f7b51243ce
commit e4f4ecc275
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 720 additions and 178 deletions

View File

@ -65,7 +65,7 @@ collections:
permalink: docs/:collection/:slug
caption: Clef
sidebar_index: 7
frontpage: _clef/Tutorial.md
frontpage: _clef/Introduction.md
vulnerabilities:
output: true
permalink: docs/:collection/:slug

208
docs/_clef/Introduction.md Normal file
View File

@ -0,0 +1,208 @@
---
title: Introduction to Clef
sort_key: A
---
{:toc}
- this will be removed by the toc
## What is Clef?
Clef is a tool for **signing transactions and data** in a secure local environment.
t is intended to become a more composable and secure replacement for Geth's built-in
account management. Clef decouples key management from Geth itself, meaning it can be
used as an independent, standalone key management and signing application, or it
can be integrated into Geth. This provides a more flexible modular tool compared to
Geth's account manager. Clef can be used safely in situations where access to Ethereum is
via a remote and/or untrusted node because signing happens locally, either manually or
automatically using custom rulesets. The separation of Clef from the node itself enables it
to run as a daemon on the same machine as the client software, on a secure usb-stick like
[USB armory](https://inversepath.com/usbarmory), or even a separate VM in a
[QubesOS](https://www.qubes-os.org/) type setup.
## Installing and starting Clef
Clef comes bundled with Geth and can be built along with Geth and the other bundled tools using:
`make all`
However, Clef is not bound to Geth and can be built on its own using:
`make clef`
Once built, Clef must be initialized. This includes storing some data, some of which is sensitive
(such as passwords, account data, signing rules etc). Initializing Clef takes that data and
encrypts it using a user-defined password.
`clef init`
```terminal
WARNING!
Clef is an account management tool. It may, like any software, contain bugs.
Please take care to
- backup your keystore files,
- verify that the keystore(s) can be opened with your password.
Clef is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
PURPOSE. See the GNU General Public License for more details.
Enter 'ok' to proceed:
> ok
The master seed of clef will be locked with a password.
Please specify a password. Do not forget this password!
Password:
Repeat password:
A master seed has been generated into /home/martin/.clef/masterseed.json
This is required to be able to store credentials, such as:
* Passwords for keystores (used by rule engine)
* Storage for JavaScript auto-signing rules
* Hash of JavaScript rule-file
You should treat 'masterseed.json' with utmost secrecy and make a backup of it!
* The password is necessary but not enough, you need to back up the master seed too!
* The master seed does not contain your accounts, those need to be backed up separately!
```
## Security model
One of the major benefits of Clef is that it is decoupled from the client software,
meaning it can be used by users and dapps to sign data and transactions in a secure,
local environment and send the signed packet to an arbitrary Ethereum entry-point, which
might include, for example, an untrusted remote node. Alternatively, Clef can simply be
used as a standalone, composable signer that can be a backend component for decentralized
applications. This requires a secure architecture that separates cryptographic operations
from user interactions and internal/external communication.
The security model of Clef is as follows:
* A self-contained binary controls all cryptographic operations including encryption,
decryption and storage of keystore files, and signing data and transactions.
* A well defined, deliberately minimal "external" API is used to communicate with the
Clef binary - Clef considers this external traffic to be UNTRUSTED. This means Clef
does not accept any credentials and does not recognize authority of requests received
over this channel. Clef listens on `http.addr:http.port` or `ipcpath` - the same as Geth -
and expects messages to be formatted using the [JSON-RPC 2.0 standard](https://www.jsonrpc.org/specification).
Some of the external API calls require some user interaction (manual approve/deny)- if it is
not received responses can be delayed indefinitely.
* Clef communicates with the process that invoked the binary using stin/stout. The process
invoking the binary is usually the native console-based user interface (UI) but there is
also an API that enables communication with an external UI. This has to be enabled using `--stdio-ui`
at startup. This channel is considered TRUSTED and is used to pass approvals and passwords between
the user and Clef.
* Clef does not store keys - the user is responsible for securely storing and backing up keyfiles.
Clef does store account passwords in its encrypted vault if they are explicitly provided to
Clef by the user to enable automatic account unlocking.
The external API never handles any sensitive data directly, but it can be used to request Clef to
sign some data or a transaction. It is the internal API that controls signing and triggers requests for
manual approval (automatic approves actions that conform to attested rulesets) and passwords.
The general flow for a basic transaction-signing operation using Clef and an Ethereum node such as
Geth is as follows:
![Clef signing logic](/static/images/clef_sign_flow.png)
In the case illustrated in the schematic above, Geth would be started with `--signer <addr>:<port>` and
would relay requests to `eth.sendTransaction`. Text in `mono` font positioned along arrows shows the objects
passed between each component.
Most users use Clef by manually approving transactions through the UI as in the schematic above, but it is also
possible to configure Clef to sign transactions without always prompting the user. This requires defining the
precise conditions under which a transaction will be signed. These conditions are known as `Rules` and they are
small Javascript snippets that are *attested* by the user by injecting the snippet's hash into Clef's secure
whitelist. Clef is then started with the rule file, so that requests that satisfy the conditions in the whitelisted
rule files are automatically signed. This is covered in detail on the [Rules page](/docs/_clef/Rules.md).
## Basic usage
Clef is started on the command line using the `clef` command. Clef can be configured by providing flags and
commands to `clef` on startup. The full list of command line options is available [below](#command-line-options).
Frequently used options include `--keystore` and `--chainid` which configure the path to an existing keystore
and a network to connect to. These options default to `$HOME/.ethereum/keystore` and `1` (corresponding to
Ethereum Mainnet) respectively. The following code snippet starts Clef, providing a custom path to an existing
keystore and connecting to the Goerli testnet:
```sh
clef --keystore /my/keystore --chainid 5
```
On starting Clef, the following welcome messgae is displayed in the terminal:
```terminal
WARNING!
Clef is an account management tool. It may, like any software, contain bugs.
Please take care to
- backup your keystore files,
- verify that the keystore(s) can be opened with your password.
Clef is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY.
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
PURPOSE. See the GNU General Public License for more details.
Enter 'ok' to proceed:
>
```
Requests requiring account access or signing now require explicit consent in this terminal.
Activities such as sending transactions via a local Geth node's attached Javascript console or
RPC will now hang indefinitely, awaiting approval in this terminal.
A much more detailed Clef tutorial is available on the [Tutorial page](/docs/clef/tutorial).
## Command line options
```sh
COMMANDS:
init Initialize the signer, generate secret storage
attest Attest that a js-file is to be used
setpw Store a credential for a keystore file
delpw Remove a credential for a keystore file
newaccount Create a new account
gendoc Generate documentation about json-rpc format
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--loglevel value log level to emit to the screen (default: 4)
--keystore value Directory for the keystore (default: "$HOME/.ethereum/keystore")
--configdir value Directory for Clef configuration (default: "$HOME/.clef")
--chainid value Chain id to use for signing (1=mainnet, 3=Ropsten, 4=Rinkeby, 5=Goerli) (default: 1)
--lightkdf Reduce key-derivation RAM & CPU usage at some expense of KDF strength
--nousb Disables monitoring for and managing USB hardware wallets
--pcscdpath value Path to the smartcard daemon (pcscd) socket file (default: "/run/pcscd/pcscd.comm")
--http.addr value HTTP-RPC server listening interface (default: "localhost")
--http.vhosts value Comma separated list of virtual hostnames from which to accept requests (server enforced). Accepts '*' wildcard. (default: "localhost")
--ipcdisable Disable the IPC-RPC server
--ipcpath value Filename for IPC socket/pipe within the datadir (explicit paths escape it)
--http Enable the HTTP-RPC server
--http.port value HTTP-RPC server listening port (default: 8550)
--signersecret value A file containing the (encrypted) master seed to encrypt Clef data, e.g. keystore credentials and ruleset hash
--4bytedb-custom value File used for writing new 4byte-identifiers submitted via API (default: "./4byte-custom.json")
--auditlog value File used to emit audit logs. Set to "" to disable (default: "audit.log")
--rules value Path to the rule file to auto-authorize requests with
--stdio-ui Use STDIN/STDOUT as a channel for an external UI. This means that an STDIN/STDOUT is used for RPC-communication with a e.g. a graphical user interface, and can be used when Clef is started by an external process.
--stdio-ui-test Mechanism to test interface between Clef and UI. Requires 'stdio-ui'.
--advanced If enabled, issues warnings instead of rejections for suspicious requests. Default off
--suppress-bootwarn If set, does not show the warning during boot
```
## Summary
Clef is an external key management and signer tool that comes bundled with Geth but can either be used
as a backend account manager and signer for Geth or as a completely separate standalone application. Being
modular and composable it can be used as a component in decentralized applications or to sign data and
transactions in untrusted environments. Clef is intended to eventually replace Geth's built-in account
management tools.

View File

@ -3,9 +3,20 @@ title: Tutorial
sort_key: A
---
This page provides a step-by-step walkthrough tutorial demonstrating some common uses of Clef. This
includes manual approvals and automated rules. Clef is presented both as a standalone general signer
with requests made via RPC and also as a backend signer for Geth.
{:toc}
- this will be removed by the toc
## Initializing Clef
First things first, Clef needs to store some data itself. Since that data might be sensitive (passwords, signing rules, accounts), Clef's entire storage is encrypted. To support encrypting data, the first step is to initialize Clef with a random master seed, itself too encrypted with your chosen password:
First things first, Clef needs to store some data itself. Since that data might be sensitive
(passwords, signing rules, accounts), Clef's entire storage is encrypted. To support encrypting data,
the first step is to initialize Clef with a random master seed, itself too encrypted with your chosen
password:
```text
$ clef init
@ -46,158 +57,302 @@ You should treat 'masterseed.json' with utmost secrecy and make a backup of it!
## Remote interactions
Clef is capable of managing both key-file based accounts as well as hardware wallets. To evaluate clef, we're going to point it to our Rinkeby testnet keystore and specify the Rinkeby chain ID for signing (Clef doesn't have a backing chain, so it doesn't know what network it runs on).
This tutorial will use Clef with Geth on the Goerli testnet. The accounts used will be in the
Goerli keystore with the path `~/go-ethereum/goerli-data/keystore`. The tutorial assumes there
are two accounts in this keystore. Instructions for creating accounts can be found on the
[Account managament page](/docs/interface/managing-your-accounts). Note that Clef can also interact
with hardware wallets, although that is not demonstrated here.
```text
$ clef --keystore ~/.ethereum/rinkeby/keystore --chainid 4
Clef should be started before Geth, otherwise Geth will complain that it cannot find a Clef
instance to connect to. Clef should be started with the correct `chainid` for Goerli. Clef
itself does not connect to a blockchain, but the `chainID` parameter is included in the data
that is aggregated to form a signature. Clef also needs a path to the correct keystore passed to
the `--keystore` command. A custom path to the config directory can also be provided. This is where the
`ipc` file will be saved which is needed to connect Clef to Geth:
INFO [07-01|11:00:46.385] Starting signer chainid=4 keystore=$HOME/.ethereum/rinkeby/keystore light-kdf=false advanced=false
```sh
clef --keystore ~/go-ethereum/goerli-data/keystore --configdir ~/go-ethereum/goerli-data/clef --chainid=5
```
The following logs will be displayed in the console:
```terminal
INFO [07-01|11:00:46.385] Starting signer chainid=4 keystore= go-ethereum/goerli-data/keystore light-kdf=false advanced=false
DEBUG[07-01|11:00:46.389] FS scan times list=3.521941ms set=9.017µs diff=4.112µs
DEBUG[07-01|11:00:46.391] Ledger support enabled
DEBUG[07-01|11:00:46.391] Trezor support enabled via HID
DEBUG[07-01|11:00:46.391] Trezor support enabled via WebUSB
INFO [07-01|11:00:46.391] Audit logs configured file=audit.log
DEBUG[07-01|11:00:46.392] IPC registered namespace=account
INFO [07-01|11:00:46.392] IPC endpoint opened url=$HOME/.clef/clef.ipc
INFO [07-01|11:00:46.392] IPC endpoint opened url=go-ethereum/goerli-data/clef/clef.ipc
------- Signer info -------
* intapi_version : 7.0.0
* extapi_version : 6.0.0
* intapi_version : 7.0.1
* extapi_version : 6.1.0
* extapi_http : n/a
* extapi_ipc : $HOME/.clef/clef.ipc
* extapi_ipc : go-ethereum/goerli-data/clef/clef.ipc
```
By default, Clef starts up in CLI (Command Line Interface) mode. Arbitrary remote processes may *request* account interactions (e.g. sign a transaction), which the user will need to individually *confirm*.
Clef starts up in CLI (Command Line Interface) mode by default. Arbitrary remote
processes may *request* account interactions (e.g. sign a transaction), which the user
can individually *confirm* or *deny*.
To test this out, we can *request* Clef to list all account via its *External API endpoint*:
The code snippet below shows a request made to Clef via its *External API endpoint* using
[NetCat](http://netcat.sourceforge.net/). The request invokes the
["account_list"](/docs/_clef/apis#accountlist) endpoint which lists the accounts in the keystore.
This command should be run in a new terminal.
```text
```sh
echo '{"id": 1, "jsonrpc": "2.0", "method": "account_list"}' | nc -U ~/.clef/clef.ipc
```
This will prompt the user within the Clef CLI to confirm or deny the request:
The terminal used to send the command will now hang. This is because the process is awaiting
confirmation from Clef. Switching to the Clef console reveals Clef's prompt to the user to
confirm or deny the request:
```text
```terminal
-------- List Account request--------------
A request has been made to list all accounts.
You can select which accounts the caller can see
[x] 0xD9C9Cd5f6779558b6e0eD4e6Acf6b1947E7fA1F3
URL: keystore://$HOME/.ethereum/rinkeby/keystore/UTC--2017-04-14T15-15-00.327614556Z--d9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3
URL: keystore://go-ethereum/goerli-data/keystore/UTC--2017-04-14T15-15-00.327614556Z--d9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3
[x] 0x086278A6C067775F71d6B2BB1856Db6E28c30418
URL: keystore://$HOME/.ethereum/rinkeby/keystore/UTC--2018-02-06T22-53-11.211657239Z--086278a6c067775f71d6b2bb1856db6e28c30418
URL: keystore://go-ethereum/goerli-data/keystore/UTC--2018-02-06T22-53-11.211657239Z--086278a6c067775f71d6b2bb1856db6e28c30418
-------------------------------------------
Request context:
NA -> NA -> NA
NA - ipc - NA
Additional HTTP header data, provided by the external caller:
User-Agent:
Origin:
Approve? [y/N]:
>
```
Depending on whether we approve or deny the request, the original NetCat process will get:
Depending on whether the request is approved or denied, the NetCat process in the other terminal
will receive one of the following responses:
```text
```terminal
{"jsonrpc":"2.0","id":1,"result":["0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3","0x086278a6c067775f71d6b2bb1856db6e28c30418"]}
```
or
```terminal
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"Request denied"}}
```
Apart from listing accounts, you can also *request* creating a new account; signing transactions and data; and recovering signatures. You can find the available methods in the Clef [External API Spec](https://github.com/ethereum/go-ethereum/tree/master/cmd/clef#external-api-1) and the [External API Changelog](https://github.com/ethereum/go-ethereum/blob/master/cmd/clef/extapi_changelog.md).
Apart from listing accounts, you can also *request* creating a new account, signing transactions
and data or recovering signatures. The available methods are documented in the Clef
[External API Spec](https://github.com/ethereum/go-ethereum/tree/master/cmd/clef#external-api-1)
and the [External API Changelog](https://github.com/ethereum/go-ethereum/blob/master/cmd/clef/extapi_changelog.md).
*Note, the number of things you can do from the External API is deliberately small to limit
the power of remote calls as much as possible! Clef has an
[Internal API](https://github.com/ethereum/go-ethereum/tree/master/cmd/clef#ui-api-1)
too for the UI (User Interface) which is much richer and can support custom interfaces on top.
But that's out of scope here.*
The example above used Clef completely independently of Geth. However, by defining Clef as
the signer when Geth is started imposes Clef's `request - confirm - result` pattern to any
interaction with the local Geth node that touches accounts, including requests made using
RPC or an attached Javascript console. To demonstrate this, Geth can be started,
with Clef as the signer:
```sh
geth --goerli --datadir goerli-data --signer=goerli-data/clef/clef.ipc
```
With Geth running, open a new terminal and attach a Javascript console:
```sh
geth attach goerli-data/geth.ipc
```
A simple request to list the accounts in the keystore will cause the Javascript console to hang.
```js
eth.accounts
```
Switching to the Clef terminal reveals that this is because the request is awaiting explicit
confirmation from the user. The log is identical to the one shown above, when the same request
for account information was made to Clef via Netcat:
```terminal
-------- List Account request--------------
A request has been made to list all accounts.
You can select which accounts the caller can see
[x] 0xD9C9Cd5f6779558b6e0eD4e6Acf6b1947E7fA1F3
URL: keystore://go-ethereum/goerli-data/keystore/UTC--2017-04-14T15-15-00.327614556Z--d9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3
[x] 0x086278A6C067775F71d6B2BB1856Db6E28c30418
URL: keystore://go-ethereum/goerli-data/keystore/UTC--2018-02-06T22-53-11.211657239Z--086278a6c067775f71d6b2bb1856db6e28c30418
-------------------------------------------
Request context:
NA - ipc - NA
Additional HTTP header data, provided by the external caller:
User-Agent:
Origin:
Approve? [y/N]:
```
In this mode, the user is required to manually confirm every action that touches account data,
including querying accounts, signing and sending transactions.
The example below shows an ether transaction between the two accounts in the keystore
using `eth.sendTransaction` in the attached Javascript console.
```js
// this command requires 2x approval in Clef because it loads account data via eth.accounts[0]
// and eth.accounts[1]
var tx = {from: eth.accounts[0], to: eth.accounts[1], value: web3.toWei(0.1, "ether")}
// then send the transaction
eth.sendTransaction(tx)
```
This example demonstrates the power of Clef much more clearly than the account-listing example.
In the Clef terminal, all the details of the transaction are presented to the user so that they
can be reviewed before being confirmed. This gives the user an opportunity to review the fine
details and make absolutely sure they really want to sign the transaction. `eth.sendTransaction`
returns the following confirmation prompt in the Clef terminal:
```terminal
-------- Transaction request----------------
to: 0x086278A6C067775F71d6B2BB1856Db6E28c30418
from: 0xD9C9Cd5f6779558b6e0eD4e6Acf6b1947E7fA1F3 [chksum ok]
value: 100000000000000000 wei
gas: 0x5208 (21000)
maxFeePerGas: 1500000016 wei
maxPriorityFeePerGas: 1500000000 wei
nonce: 0x0 (0)
chainid: 0x5
Accesslist
Request context:
NA - ipc - NA
Additional HTTP header data, provided by the external caller:
User-Agent: ""
Origin: ""
---------------------------------------------
Approve? [y/N]
```
Approving this transaction causes Clef to prompt the user to provide the password for
the sender account. Providing the password enables the transaction to be signed and sent to
Geth for broadcasting to the network. The details of the signed transaction are displayed
in the console. Account passwords can also be stored in Clef's encrypted vault so that they
do not have to be manually entered - [more on this below](#account-passwords).
*Note, the number of things you can do from the External API is deliberately small, since we want to limit the power of remote calls by as much as possible! Clef has an [Internal API](https://github.com/ethereum/go-ethereum/tree/master/cmd/clef#ui-api-1) too for the UI (User Interface) which is much richer and can support custom interfaces on top. But that's out of scope here.*
## Automatic rules
For most users, manually confirming every transaction is the way to go. However, there are cases when it makes sense to set up some rules which permit Clef to sign a transaction without prompting the user. One such example would be running a signer on Rinkeby or other PoA networks.
For most users, manually confirming every transaction is the right way to use Clef because a
human-in-the-loop can review every action. However, there are cases when it makes sense to
set up some rules which permit Clef to sign a transaction without prompting the user.
For starters, we can create a rule file that automatically permits anyone to list our available accounts without user confirmation. The rule file is a tiny JavaScript snippet that you can program however you want:
For example, well defined rules such as:
* Auto-approve transactions with Uniswap v2, with value between 0.1 and 0.5 ETH
per 24h period
* Auto-approve transactions to address `0xD9C9Cd5f6779558b6e0eD4e6Acf6b1947E7fA1F3`
as long as gas < 44k and gasPrice < 80Gwei
can be encoded and intepreted by Clef's built-in ruleset engine.
### Rule files
Rules are implemented as Javascript code in `js` files. The ruleset engine includes the
same methods as the JSON_RPC defined in the [UI Protocol](/docs/_clef/datatypes.md).
The following code snippet demonstrates a rule file that approves a transaction if it
satisfies the following conditions:
* the recipient is `0xae967917c465db8578ca9024c205720b1a3651a9`
* the value is less than 50000000000000000 wei (0.05 ETH)
and approves account listing if:
* the request has arrived via ipc
```js
function ApproveListing() {
//ancillary function for formatting numbers
function asBig(str) {
if (str.slice(0, 2) == "0x") {
return new BigNumber(str.slice(2), 16)
}
return new BigNumber(str)
}
// Approve transactions to a certain contract if value is below a certain limit
function ApproveTx(req) {
var limit = big.Newint("0xb1a2bc2ec50000")
var value = asBig(req.transaction.value);
if (req.transaction.to.toLowerCase() == "0xae967917c465db8578ca9024c205720b1a3651a9")
&& value.lt(limit)) {
return "Approve"
}
else{
return "Reject"
}
}
// Approve listings if request made from IPC
function ApproveListing(req){
if (req.metadata.scheme == "ipc"){ return "Approve"}
}
// returning nothing passes the decision to the next UI for manual assessment
```
Of course, Clef isn't going to just accept and run arbitrary scripts you give it, that would be dangerous if someone changes your rule file! Instead, you need to explicitly *attest* the rule file, which entails injecting its hash into Clef's secure store.
There are three possible outcomes to this ruleset that are handled in different ways:
```text
$ sha256sum rules.js
645b58e4f945e24d0221714ff29f6aa8e860382ced43490529db1695f5fcc71c rules.js
| Return value | Action |
| ----------- | ----------- |
| "Approve" | Auto-approve request |
| "Reject" | Auto-approve request |
| Error | Pass decision to UI for manual approval |
| Unexpected value | Pass decision to UI for manual approval |
| Nothing | Pass decision to UI for manual approval |
$ clef attest 645b58e4f945e24d0221714ff29f6aa8e860382ced43490529db1695f5fcc71c
Decrypt master seed of clef
Password:
INFO [07-01|13:25:03.290] Ruleset attestation updated sha256=645b58e4f945e24d0221714ff29f6aa8e860382ced43490529db1695f5fcc71c
### Attestations
Clef will not just accept and run arbitrary scripts - that would create an attack vector because a malicious party could
change the rule file. Instead, the user explicitly *attests* to a rule file, which involves injecting the file's SHA256
hash into Clef's secure store. The following code snippet shows how to calculate a SHA256 hash for a file named `rules.js`
and pass it to Clef. Note that Clef will prompt the user to provide the master password because the Clef store has to
be decrypted in order to add the attestation to it.
```sh
# calculate hash
sha256sum rules.js
# attest to rules.js in Clef
clef attest 645b58e4f945e24d0221714ff29f6aa8e860382ced43490529db1695f5fcc71c
```
At this point, we can start Clef with the rule file:
Once this attestation has been added to the Clef store, it can be used to automatically approve
interactions that satisfy the conditions encoded in `rules.js` in Clef.
```text
$ clef --keystore ~/.ethereum/rinkeby/keystore --chainid 4 --rules rules.js
INFO [07-01|13:39:49.726] Rule engine configured file=rules.js
INFO [07-01|13:39:49.726] Starting signer chainid=4 keystore=$HOME/.ethereum/rinkeby/keystore light-kdf=false advanced=false
DEBUG[07-01|13:39:49.726] FS scan times list=35.15µs set=4.251µs diff=2.766µs
DEBUG[07-01|13:39:49.727] Ledger support enabled
DEBUG[07-01|13:39:49.727] Trezor support enabled via HID
DEBUG[07-01|13:39:49.727] Trezor support enabled via WebUSB
INFO [07-01|13:39:49.728] Audit logs configured file=audit.log
DEBUG[07-01|13:39:49.728] IPC registered namespace=account
INFO [07-01|13:39:49.728] IPC endpoint opened url=$HOME/.clef/clef.ipc
------- Signer info -------
* intapi_version : 7.0.0
* extapi_version : 6.0.0
* extapi_http : n/a
* extapi_ipc : $HOME/.clef/clef.ipc
### Account passwords
The rules described in `rules.js` above require access to the accounts in the Clef keystore which
are protected by user-defined passwords. The signer therefore requires access to these passwords
in order to automatically unlock the keystore and sign data and transactions using the accounts.
This is done using `clef setpw`, passing the account address as the sole argument:
```sh
clef setpw 0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3
```
Any account listing *request* will now be auto-approved by the rule file:
```text
$ echo '{"id": 1, "jsonrpc": "2.0", "method": "account_list"}' | nc -U ~/.clef/clef.ipc
{"jsonrpc":"2.0","id":1,"result":["0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3","0x086278a6c067775f71d6b2bb1856db6e28c30418"]}
```
## Under the hood
While doing the operations above, these files have been created:
```text
$ ls -laR ~/.clef/
$HOME/.clef/:
total 24
drwxr-x--x 3 user user 4096 Jul 1 13:45 .
drwxr-xr-x 102 user user 12288 Jul 1 13:39 ..
drwx------ 2 user user 4096 Jul 1 13:25 02f90c0603f4f2f60188
-r-------- 1 user user 868 Jun 28 13:55 masterseed.json
$HOME/.clef/02f90c0603f4f2f60188:
total 12
drwx------ 2 user user 4096 Jul 1 13:25 .
drwxr-x--x 3 user user 4096 Jul 1 13:45 ..
-rw------- 1 user user 159 Jul 1 13:25 config.json
$ cat ~/.clef/02f90c0603f4f2f60188/config.json
{"ruleset_sha256":{"iv":"SWWEtnl+R+I+wfG7","c":"I3fjmwmamxVcfGax7D0MdUOL29/rBWcs73WBILmYK0o1CrX7wSMc3y37KsmtlZUAjp0oItYq01Ow8VGUOzilG91tDHInB5YHNtm/YkufEbo="}}
```
In `$HOME/.clef`, the `masterseed.json` file was created, containing the master seed. This seed was then used to derive a few other things:
- **Vault location**: in this case `02f90c0603f4f2f60188`.
- If you use a different master seed, a different vault location will be used that does not conflict with each other (e.g. `clef --signersecret /path/to/file`). This allows you to run multiple instances of Clef, each with its own rules (e.g. mainnet + testnet).
- **`config.json`**: the encrypted key/value storage for configuration data, currently only containing the key `ruleset_sha256`, the attested hash of the automatic rules to use.
## Advanced rules
In order to make more useful rules - like signing transactions - the signer needs access to the passwords needed to unlock keys from the keystore. You can inject an unlock password via `clef setpw`.
```text
$ clef setpw 0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3
which displays the following in the terminal:
```terminal
Please enter a password to store for this address:
Password:
Repeat password:
@ -207,7 +362,115 @@ Password:
INFO [07-01|14:05:56.031] Credential store updated key=0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3
```
Now let's update the rules to make use of the new credentials:
Note that Clef does not really 'unlock' an account, it just abstracts the process of providing the
password away from the end-user in specific, predefined scenarios. If an account password
exists in the Clef vault and the rule evaluates to "Approve" then Clef decrypts the password,
uses it to decrypt the key, does the requested signing and then re-locks the account.
### Implementing rules
Clef can be instructed to run an attested rule file simply by passing the path to `rules.js`
to the `--rules` flag:
```sh
clef --keystore go-ethereum/goerli-data/ --configdir go-ethereum/goerli-data/clef --chainid 5 --rules rules.js
```
The following logs will be displayed in the terminal:
```
INFO [07-01|13:39:49.726] Rule engine configured file=rules.js
INFO [07-01|13:39:49.726] Starting signer chainid=5 keystore=$go-ethereum/goerli-data/ light-kdf=false advanced=false
DEBUG[07-01|13:39:49.726] FS scan times list=35.15µs set=4.251µs diff=2.766µs
DEBUG[07-01|13:39:49.727] Ledger support enabled
DEBUG[07-01|13:39:49.727] Trezor support enabled via HID
DEBUG[07-01|13:39:49.727] Trezor support enabled via WebUSB
INFO [07-01|13:39:49.728] Audit logs configured file=audit.log
DEBUG[07-01|13:39:49.728] IPC registered namespace=account
INFO [07-01|13:39:49.728] IPC endpoint opened url=go-ethereum/goerli-data/clef/clef.ipc
------- Signer info -------
* intapi_version : 7.0.0
* extapi_version : 6.0.0
* extapi_http : n/a
* extapi_ipc : go-ethereum/goerli-data/clef/clef.ipc
```
Any request that satisfies the ruleset will now be auto-approved by the rule file, for example
the following request to sign a transaction made using the Geth Javascript console
(note that the password for account `0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3`
has already been provided to `setpw` and the recipient and value comply with the rules in `rules.js`):
```js
var tx = {to: "0xae967917c465db8578ca9024c205720b1a3651a9", from: "0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3", value: web3.toWei(0.01, "ether")}
eth.sendTransaction(tx)
```
By contrast, the following transactions *do not* satisfy the rules in `rules.js`:
```js
// violate maximum transaction value condition
var tx = {to: "0xae967917c465db8578ca9024c205720b1a3651a9", from: "0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3", value: web3.toWei(1, "ether")}
eth.sendTransaction(tx)
```
```js
// violate recipient condition
var tx = {to: "0xae967917c465db8578ca9024c205720b1a3651a9", from: "0xd4c4bb7d6889453c6c6ea3e9eab3c4177b4fbcc3", value: web3.toWei(0.01, "ether")}
eth.sendTransaction(tx)
```
These latter two transactions, that do not satisfy the encoded rules in `rules.js`, are not automatically approved, but instead pass the
decision back to the UI for manual approval by the user.
### Summary of basic usage
To summarize, the steps required to run Clef with an automated ruleset that requires account access is as follows:
**1)** Define rules as Javascript and save as a `.js` file, e.g. `rules.js`
**2)** Calculate hash of rule file using `sha256sum rules.js`
**3)** Attest the rules in Clef using `clef attest <hash>`
**4)** Set account passwords in Clef using `clef --setpw <address>`
**5)** Start Clef with rule file enabled using `clef --keystore <path-to-keystore> --chainid <chainID> --rules rules.js`
**6)** Make requests directly to Clef using the external API or connect to Geth by passing `--signer=<path to clef.ipc>` at Geth startup
## More rules
Since rules are defined as Javascript code, rulesets of arbitrary complexity can be created and they can
impose conditions on any part of a transaction, not only the recipient and value.
A simple example is implementing a "whitelist" of recipients where transactions that have those
accounts in the `to` field are automatically signed (for example perhaps transactions between
a user's own accounts might be whitelisted):
```js
function ApproveTx(r) {
if (r.transaction.to.toLowerCase() == "0xd4c4bb7d6889453c6c6ea3e9eab3c4177b4fbcc3") {
return "Approve"
}
if (r.transaction.to.toLowerCase() == "0xae967917c465db8578ca9024c205720b1a3651a9") {
return "Reject"
}
// Otherwise goes to manual processing
}
```
In addition to addresses and values, other properties of a request can also be incorporated
into a ruleset. The example below demonstrates a ruleset for `approve_signData` imposing
the following conditions on a transaction's sender and message data.
1. The sender must be `0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3`
2. The transaction message must include the text `wen-merge`, which is `77656E2D6D65726765` in hex.
If these conditions are satisfied then the transaction is auto-approved (assuming the password for
`0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3` has been provided to `setpw`).
```js
function ApproveListing() {
@ -216,7 +479,7 @@ function ApproveListing() {
function ApproveSignData(req) {
if (req.address.toLowerCase() == "0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3") {
if (req.messages[0].value.indexOf("bazonk") >= 0) {
if (req.messages[0].value.indexOf("wen-merge") >= 0) {
return "Approve"
}
return "Reject"
@ -225,134 +488,198 @@ function ApproveSignData(req) {
}
```
In this example:
This file should be saved as a `.js` file, hashed and attested in Clef:
- Any requests to sign data with the account `0xd9c9...` will be:
- Auto-approved if the message contains `bazonk`,
- Auto-rejected if the message does not contain `bazonk`,
- Any other requests will be passed along for manual confirmation.
```sh
sha256sum rules.js
```
*Note, to make this example work, please use you own accounts. You can create a new account either via Clef or the traditional account CLI tools. If the latter was chosen, make sure both Clef and Geth use the same keystore by specifying `--keystore path/to/your/keystore` when running Clef.*
which returns:
Attest the new rule file so that Clef will accept loading it:
```terminal
84d9e70aa30d0e5ffb3c4b376c9490f428390a196bfdc1d36770ffd2bbe66845 rules.js
```
```text
$ sha256sum rules.js
f163a1738b649259bb9b369c593fdc4c6b6f86cc87e343c3ba58faee03c2a178 rules.js
then:
$ clef attest f163a1738b649259bb9b369c593fdc4c6b6f86cc87e343c3ba58faee03c2a178
```sh
clef attest 84d9e70aa30d0e5ffb3c4b376c9490f428390a196bfdc1d36770ffd2bbe66845
```
which returns:
```terminal
Decrypt master seed of clef
Password:
INFO [07-01|14:11:28.509] Ruleset attestation updated sha256=f163a1738b649259bb9b369c593fdc4c6b6f86cc87e343c3ba58faee03c2a178
INFO [07-01|14:11:28.509] Ruleset attestation updated sha256=84d9e70aa30d0e5ffb3c4b376c9490f428390a196bfdc1d36770ffd2bbe66845
```
Restart Clef with the new rules in place:
Then, Clef can be restarted with the new rules in place:
```sh
clef --keystore go-ethereum/goerli-data/clef --configdir go-ethereum/goerli-data/clef --chainid 5 --rules rules.js
```
$ clef --keystore ~/.ethereum/rinkeby/keystore --chainid 4 --rules rules.js
```terminal
INFO [07-01|14:12:41.636] Rule engine configured file=rules.js
INFO [07-01|14:12:41.636] Starting signer chainid=4 keystore=$HOME/.ethereum/rinkeby/keystore light-kdf=false advanced=false
INFO [07-01|14:12:41.636] Starting signer chainid=5 keystore=go-ethereum/goerli-data/clef/keystore light-kdf=false advanced=false
DEBUG[07-01|14:12:41.636] FS scan times list=46.722µs set=4.47µs diff=2.157µs
DEBUG[07-01|14:12:41.637] Ledger support enabled
DEBUG[07-01|14:12:41.637] Trezor support enabled via HID
DEBUG[07-01|14:12:41.638] Trezor support enabled via WebUSB
INFO [07-01|14:12:41.638] Audit logs configured file=audit.log
DEBUG[07-01|14:12:41.638] IPC registered namespace=account
INFO [07-01|14:12:41.638] IPC endpoint opened url=$HOME/.clef/clef.ipc
INFO [07-01|14:12:41.638] IPC endpoint opened url=go-ethereum/goerli-data/clef/clef.ipc
------- Signer info -------
* intapi_version : 7.0.0
* extapi_version : 6.0.0
* extapi_http : n/a
* extapi_ipc : $HOME/.clef/clef.ipc
* extapi_ipc : go-ethereum/goerli-data/clef/clef.ipc
```
Then test signing, once with `bazonk` and once without:
Finally, a request can be submitted to test that the rules are being applied as expected.
Here, Clef is used independently of Geth by making a request via RPC, but the same logic
would be imposed if the request was made via a connected Geth node. Some arbitrary text
will be included in the message data that includes the term `wen-merge`. The plaintext
`clefdemotextthatincludeswen-merge` is `636c656664656d6f7465787474686174696e636c7564657377656e2d6d65726765`
when represented as a hexadecimal string. This can be passed as data to an `account_signData`
request as follows:
```sh
echo '{"id": 1, "jsonrpc":"2.0", "method":"account_signData", "params":["data/plain", "0x636c656664656d6f7465787474686174696e636c7564657377656e2d6d65726765"]}' | nc -U ~/go-ethereum.goerli-data/clef/clef.ipc
```
$ echo '{"id": 1, "jsonrpc":"2.0", "method":"account_signData", "params":["data/plain", "0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3", "0x202062617a6f6e6b2062617a2067617a0a"]}' | nc -U ~/.clef/clef.ipc
This will be automatically signed, returning a result that looks like the following:
```terminal
{"jsonrpc":"2.0","id":1,"result":"0x4f93e3457027f6be99b06b3392d0ebc60615ba448bb7544687ef1248dea4f5317f789002df783979c417d969836b6fda3710f5bffb296b4d51c8aaae6e2ac4831c"}
```
$ echo '{"id": 1, "jsonrpc":"2.0", "method":"account_signData", "params":["data/plain", "0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3", "0x2020626f6e6b2062617a2067617a0a"]}' | nc -U ~/.clef/clef.ipc
Alternatively, a request that does not include the phrase `wen-merge` will not automatically approve. For example, the following request passes the hexadecimal
string representing the plaintext `clefdemotextwithoutspecialtext`:
```sh
echo '{"id": 1, "jsonrpc":"2.0", "method":"account_signData", "params":["data/plain", "0x636c656664656d6f74657874776974686f75747370656369616c74657874"]}' | nc -U ~/go-ethereum.goerli-data/clef/clef.ipc
```
This returns a `Request denied` message as follows:
```terminal
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"Request denied"}}
```
Meanwhile, in the Clef output log you can see:
Meanwhile, in the output logs in the Clef terminal you can see:
```text
INFO [02-21|14:42:41] Op approved
INFO [02-21|14:42:56] Op rejected
```
The signer also stores all traffic over the external API in a log file. The last 4 lines shows the two requests and their responses:
The signer also stores all traffic over the external API in a log file.
The last 4 lines shows the two requests and their responses:
```text
$ tail -n 4 audit.log
t=2019-07-01T15:52:14+0300 lvl=info msg=SignData api=signer type=request metadata="{\"remote\":\"NA\",\"local\":\"NA\",\"scheme\":\"NA\",\"User-Agent\":\"\",\"Origin\":\"\"}" addr="0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3 [chksum INVALID]" data=0x202062617a6f6e6b2062617a2067617a0a content-type=data/plain
t=2019-07-01T15:52:14+0300 lvl=info msg=SignData api=signer type=response data=4f93e3457027f6be99b06b3392d0ebc60615ba448bb7544687ef1248dea4f5317f789002df783979c417d969836b6fda3710f5bffb296b4d51c8aaae6e2ac4831c error=nil
t=2019-07-01T15:52:23+0300 lvl=info msg=SignData api=signer type=request metadata="{\"remote\":\"NA\",\"local\":\"NA\",\"scheme\":\"NA\",\"User-Agent\":\"\",\"Origin\":\"\"}" addr="0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3 [chksum INVALID]" data=0x2020626f6e6b2062617a2067617a0a content-type=data/plain
t=2019-07-01T15:52:23+0300 lvl=info msg=SignData api=signer type=response data= error="Request denied"
t=2022-07-01T15:52:14+0300 lvl=info msg=SignData api=signer type=request metadata="{\"remote\":\"NA\",\"local\":\"NA\",\"scheme\":\"NA\",\"User-Agent\":\"\",\"Origin\":\"\"}" addr="0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3 [chksum INVALID]" data=0x202062617a6f6e6b2062617a2067617a0a content-type=data/plain
t=2022-07-01T15:52:14+0300 lvl=info msg=SignData api=signer type=response data=0x636c656664656d6f7465787474686174696e636c7564657377656e2d6d65726765 error=nil
t=2022-07-01T15:52:23+0300 lvl=info msg=SignData api=signer type=request metadata="{\"remote\":\"NA\",\"local\":\"NA\",\"scheme\":\"NA\",\"User-Agent\":\"\",\"Origin\":\"\"}" addr="0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3 [chksum INVALID]" data=0x636c656664656d6f74657874776974686f75747370656369616c74657874 content-type=data/plain
t=2022-07-01T15:52:23+0300 lvl=info msg=SignData api=signer type=response data= error="Request denied"
```
For more details on writing automatic rules, please see the [rules spec](https://github.com/ethereum/go-ethereum/blob/master/cmd/clef/rules.md).
More examples, including a ruleset for a rate-limited window, are available on the [Clef Github][rate-limited-window-example]
and on the [Rules page](/docs/clef/rules).
## Under the hood
The examples on this page have provided step-by-step instructions for verious operations using Clef.
However, they have not provided much detail as to what is happening under the hood.
This section will provide some more details about how Clef organizes itself locally.
Initializing Clef with a master password and providing an account password to `clef setpw`
and attesting a ruleset creates the following files in the directory `~/.clef/`
(this path is independent of the paths provided to `--keystore` and `--configdir` on startup):
```terminal
# displayed using $ ls -laR ~/.clef/
/home/user/.clef/:
total 24
drwxr-x--x 3 user user 4096 Jul 1 13:45 .
drwxr-xr-x 102 user user 12288 Jul 1 13:39 ..
drwx------ 2 user user 4096 Jul 1 13:25 02f90c0603f4f2f60188
-r-------- 1 user user 868 Jun 28 13:55 masterseed.json
/home/user/.clef/02f90c0603f4f2f60188:
total 12
drwx------ 2 user user 4096 Jul 1 13:25 .
drwxr-x--x 3 user user 4096 Jul 1 13:45 ..
-rw------- 1 user user 159 Jul 1 13:25 config.json
-rw------- 1 user user 115 Jul 1 13:35 credentials.json
```
The file `masterseed.json` includes a json object containing the masterseed which was used to derive
the vault directory (in this case `02f90c0603f4f2f60188`). The vault is encrypted using a password
which is also derived from the masterseed. Inside the vault are two subdirectories:
`credentials.json`
`config.json`
Inside `credentials.json` are the confidential `ksp` data (standing for "keystore pass" - these
are the account passwords used to unlock the keystore).
The `config.json` file contains encrypted key/value pairs for configuration data. Usually
this is only the `sha256` hashes of any attested rulesets.
Vault locations map uniquely to masterseeds so that multiple instances of Clef can co-exist
each with their own attested rules and their own set of keystore passwords. This is useful for,
for example, maintaining separate setups for Mainnet and testnets.
The contents of each of these json files can be viewed using `cat` and should look something
like the following:
For `config.json`:
```sh
cat ~/.clef/02f90c0603f4f2f60188/config.json
```
```terminal
{"ruleset_sha256":{"iv":"SWWEtnl+R+I+wfG7","c":"I3fjmwmamxVcfGax7D0MdUOL29/rBWcs73WBILmYK0o1CrX7wSMc3y37KsmtlZUAjp0oItYq01Ow8VGUOzilG91tDHInB5YHNtm/YkufEbo="}}
```
and for `credentials.json`:
```sh
cat ~/.clef/02f90c0603f4f2f60188/config.json
```
```terminal
{"0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3": {"iv": "6SC062CfaUW8uSqH","c":"C+S5kaJyrarrxrAESs4EmPjL5zmg5tRh0Q=="}}
```
## Geth integration
Of course, as awesome as Clef is, it's not feasible to interact with it via JSON RPC by hand. Long term, we're hoping to convince the general Ethereum community to support Clef as a general signer (it's only 3-5 methods), thus allowing your favorite DApp, Metamask, MyCrypto, etc to request signatures directly.
This tutorial has bounced back and forth between demonstrating Clef as a standalone tool by making
'manual` JSON RPC requests from the terminal and integrating it as a backend singer for Geth.
Using Clef for account management is considered best practise for Geth users because of the additional
security benefits it offers over and above what it offered by Geth's built-in accounts module. Clef is
far more flexible and composable than Geth's built-in account management tool and can interface directly
with hardware wallets, while Apps and wallets can request signatures directly from Clef.
Until then however, we're trying to pave the way via Geth. Geth v1.9.0 has built in support via `--signer <API endpoint>` for using a local or remote Clef instance as an account backend!
Ultimately, the goal is to deprecate Geth's account management tools completely and replace them with
Clef. Until then, users are simply encouraged to choose to use Clef as an optional backend signer for Geth.
In addition to the examples on this page, the [Getting started tutorial](/docs/_getting-started/index.md)
also demonstrates Clef/Geth integration.
We can try this by running Clef with our previous rules on Rinkeby (for now it's a good idea to allow auto-listing accounts, since Geth likes to retrieve them once in a while).
```text
$ clef --keystore ~/.ethereum/rinkeby/keystore --chainid 4 --rules rules.js
```
## Summary
In a different window we can start Geth, list our accounts, even list our wallets to see where the accounts originate from:
This page includes step-by-step instructions for basic and intermediate uses of Clef, including using
it as a standalone app and a backend signer for Geth. Further information is available on our other
Clef pages, including [Introduction](/docs/clef/introduction), [Setup](/docs/clef/setup),
[Rules](/docs/clef/rules), [Communication Datatypes](/docs/clef/datatypes) and [Communication APIs](/docs/clef/apis).
Also see the [Clef Github](https://github.com/ethereum/go-ethereum/tree/master/cmd/clef) for further reading.
```text
$ geth --rinkeby --signer=~/.clef/clef.ipc console
> eth.accounts
["0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3", "0x086278a6c067775f71d6b2bb1856db6e28c30418"]
> personal.listWallets
[{
accounts: [{
address: "0xd9c9cd5f6779558b6e0ed4e6acf6b1947e7fa1f3",
url: "extapi://$HOME/.clef/clef.ipc"
}, {
address: "0x086278a6c067775f71d6b2bb1856db6e28c30418",
url: "extapi://$HOME/.clef/clef.ipc"
}],
status: "ok [version=6.0.0]",
url: "extapi://$HOME/.clef/clef.ipc"
}]
> eth.sendTransaction({from: eth.accounts[0], to: eth.accounts[0]})
```
Lastly, when we requested a transaction to be sent, Clef prompted us in the original window to approve it:
```text
--------- Transaction request-------------
to: 0xD9C9Cd5f6779558b6e0eD4e6Acf6b1947E7fA1F3
from: 0xD9C9Cd5f6779558b6e0eD4e6Acf6b1947E7fA1F3 [chksum ok]
value: 0 wei
gas: 0x5208 (21000)
gasprice: 1000000000 wei
nonce: 0x2366 (9062)
Request context:
NA -> NA -> NA
Additional HTTP header data, provided by the external caller:
User-Agent:
Origin:
-------------------------------------------
Approve? [y/N]:
> y
```
:boom:
*Note, if you enable the external signer backend in Geth, all other account management is disabled. This is because long term we want to remove account management from Geth.*
[rate-limited-window-example]:https://github.com/ethereum/go-ethereum/blob/master/cmd/clef/rules.md#example-1-ruleset-for-a-rate-limited-window

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -75,3 +75,10 @@ img[alt="Remix"]{
margin-top: 2rem;
margin-bottom: 2rem;
}
img[alt="Clef signing logic"]{
width: 800px;
margin-top: 2rem;
margin-bottom: 2rem;
}