Skip to content

Commit 15ecb92

Browse files
authored
Problem: Solana wallet couln't be used to control the VM (#700)
* Improve documentation for auth decorator * Problem: Solana wallet couln't be used to control the VM * Add new dep to debian package * Fix mypy error not related to PR Had do ignore since mypy expect a DataClassIstance that don't exist * Document solana support for operator endpoints * mod: Move signature checking for all chain in a function
1 parent de4a0ff commit 15ecb92

File tree

6 files changed

+188
-58
lines changed

6 files changed

+188
-58
lines changed

doc/operator_auth.md

+59-49
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,59 @@
11
Authentication protocol for VM owner
22
=======================================
33

4-
This custom protocol allows a user (owner of a VM) to securely authenticate to a CRN, using their Ethereum wallet.
5-
This scheme was designed in a way that's convenient to be integrated in the console web page.
4+
This custom protocol allows a user (owner of a VM) to securely authenticate to a CRN, using their Ethereum or Solana
5+
wallet. This scheme was designed in a way that's convenient to be integrated into the console web page.
66

7-
It allows the user to control their VM. e.g : stop reboot, view their log, etc
7+
It allows the user to control their VM. e.g: stop, reboot, view their log, etc.
88

99
## Motivations
1010

1111
This protocol ensures secure authentication between a blockchain wallet owner and an aleph.im compute node.
1212

13-
Signing operations is typically gated by prompts requiring manual approval for each operation.
14-
With hardware wallets, users are prompted both by the software on their device and the hardware wallet itself.
13+
Signing operations are typically gated by prompts requiring manual approval for each operation. With hardware wallets,
14+
users are prompted both by the software on their device and the hardware wallet itself.
1515

1616
## Overview
1717

18-
The client generates a [JSON Web Key](https://www.rfc-editor.org/rfc/rfc7517) (JWK) key pair and signs the public key with their Ethereum account. The signed public key is sent
19-
in the `X-SignedPubKey` header. The client also signs the operation payload with the private JWK, sending it in the
20-
`X-SignedOperation` header. The server verifies both the public key and payload signatures, ensuring the request's
21-
integrity and authenticity. If validation fails (e.g., expired key or invalid signature), the server returns a 401
22-
Unauthorized error.
18+
The client generates a [JSON Web Key](https://www.rfc-editor.org/rfc/rfc7517) (JWK) key pair and signs the public key
19+
with their Ethereum or Solana account. The signed public key is sent in the `X-SignedPubKey` header. The client also
20+
signs the operation payload with the private JWK, sending it in the `X-SignedOperation` header. The server verifies both
21+
the public key and payload signatures, ensuring the request's integrity and authenticity. If validation fails (e.g.,
22+
expired key or invalid signature), the server returns a 401 Unauthorized error.
2323

24-
Support for Solana wallets is planned in the near future.
2524

2625
## Authentication Method for HTTP Endpoints
2726

2827
Two custom headers are added to each authenticated request:
2928

30-
* X-SignedPubKey: This contains the public key and its associated metadata (such as the sender’s address and expiration
31-
date), along with a signature that ensures its authenticity.
32-
* X-SignedOperation: This includes the payload of the operation and its cryptographic signature, ensuring that the
29+
- **X-SignedPubKey**: This contains the public key and its associated metadata (such as the sender’s address, chain, and
30+
expiration date), along with a signature that ensures its authenticity.
31+
- **X-SignedOperation**: This includes the payload of the operation and its cryptographic signature, ensuring that the
3332
operation itself has not been tampered with.
3433

35-
### 1. Generate and Sign Public Key
34+
### 1. Generate an ephemeral keys and Sign Public Key
3635

37-
A new JWK is generated using elliptic curve cryptography (EC, P-256).
36+
An ephemeral key pair (as JWK) is generated using elliptic curve cryptography (EC, P-256).
3837

3938
The use of a temporary JWK key allows the user to delegate limited control to the console without needing to sign every
40-
individual request with their Ethereum wallet. This is crucial for improving the user experience, as constantly signing
41-
each operation would be cumbersome and inefficient. By generating a temporary key, the user can provide permission for a
42-
set period of time (until the key expires), enabling the console to perform actions like stopping or rebooting the VM on
43-
their behalf. This maintains security while streamlining interactions with the console, as the server verifies each
44-
operation using the temporary key without requiring ongoing involvement from the user's wallet.
39+
individual request with their Ethereum or Solana wallet. This is crucial for improving the user experience, as
40+
constantly signing each operation would be cumbersome and inefficient. By generating a temporary key, the user can
41+
provide permission for a set period of time (until the key expires), enabling the console to perform actions like
42+
stopping or rebooting the VM on their behalf. This maintains security while streamlining interactions with the console,
43+
as the server verifies each operation using the temporary key without requiring ongoing involvement from the user's
44+
wallet.
4545

4646
The generated public key is converted into a JSON structure with additional metadata:
47-
* `pubkey`: The public key information.
48-
* `alg`: The signing algorithm, ECDSA.
49-
* `domain`: The domain for which the key is valid.
50-
* `address`: The Ethereum address of the sender, binding the public key to this identity.
51-
* `expires`: The expiration time of the key.
5247

53-
Example
48+
- **`pubkey`**: The public key information.
49+
- **`alg`**: The signing algorithm, ECDSA.
50+
- **`domain`**: The domain for which the key is valid.
51+
- **`address`**: The wallet address of the sender, binding the temporary key to this identity.
52+
- **`chain`**: Indicates the blockchain used for signing (`ETH` or `SOL`). Defaults to `ETH`.
53+
- **`expires`**: The expiration time of the key.
54+
55+
Example:
56+
5457
```json
5558
{
5659
"pubkey": {
@@ -62,12 +65,13 @@ Example
6265
"alg": "ECDSA",
6366
"domain": "localhost",
6467
"address": "0x8Dd070629F107e7946dD68BDcb8ABE8475F47B0E",
68+
"chain": "ETH",
6569
"expires": "2010-12-26T17:05:55Z"
6670
}
6771
```
6872

69-
This public key is signed using the Ethereum account to ensure its authenticity. The resulting signature is
70-
combined with the public key into a payload and sent as the `X-SignedPubKey` header.
73+
This public key is signed using either the Ethereum or Solana account, depending on the `chain` parameter. The resulting
74+
signature is combined with the public key into a payload and sent as the `X-SignedPubKey` header.
7175

7276
### 2. Sign Operation Payload
7377

@@ -83,7 +87,7 @@ integrity can be verified through signing. Below are the fields included:
8387
- **`domain`**: (string) The domain associated with the request. This ensures the request is valid for the intended
8488
CRN. (e.g., `localhost`).
8589

86-
Example
90+
Example:
8791

8892
```json
8993
{
@@ -97,55 +101,61 @@ Example
97101
It is sent serialized as a hex string.
98102

99103
#### Signature
100-
This payload is serialized in JSON, signed, and sent in the `X-SignedOperation` header to ensure the integrity and authenticity
101-
of the request.
102104

103-
* The operation payload (containing details such as time, method, path, and domain) is serialized and converted into a byte array.
104-
* The JWK (private key) is used to sign this operation payload, ensuring its integrity. This signature is then included in the X-SignedOperation header.
105105

106+
- The operation payload (containing details such as time, method, path, and domain) is JSON serialized and converted into a
107+
hex string.
108+
- The ephemeral key (private key) is used to sign this operation payload, ensuring its integrity. This signature is then included
109+
in the `X-SignedOperation` header.
110+
111+
### 3. Include Authentication Headers
106112

107-
### 3. Include authentication Headers
108-
These two headers are to be added to the HTTP Request:
113+
These two headers are to be added to the HTTP request:
109114

110-
1. **`X-SignedPubKey` Header:**
111-
- This header contains the public key payload and the signature of the public key generated by the Ethereum account.
115+
1. **`X-SignedPubKey` Header**:
116+
- This header contains the public key payload and the signature of the public key generated by the Ethereum or
117+
Solana account.
112118

113119
Example:
120+
114121
```json
115122
{
116-
"payload": "<hexadecimal string of the public key payload>",
117-
"signature": "<Ethereum signed public key>"
123+
"payload": "<hexadecimal string of the public key payload>",
124+
"signature": "<Ethereum or Solana signed public key>"
118125
}
119126
```
120127

121-
2. **`X-SignedOperation` Header:**
128+
2. **`X-SignedOperation` Header**:
122129
- This header contains the operation payload and the signature of the operation payload generated using the private
123130
JWK.
124131

125132
Example:
133+
126134
```json
127135
{
128-
"payload": "<hexadecimal string of the operation payload>",
129-
"signature": "<JWK signed operation payload>"
136+
"payload": "<hexadecimal string of the operation payload>",
137+
"signature": "<JWK signed operation payload>"
130138
}
131139
```
132140

133141
### Expiration and Validation
134142

135143
- The public key has an expiration date, ensuring that keys are not used indefinitely.
136-
- Both the public key and the operation signature are validated for authenticity and integrity at the server side.
144+
- Both the public key and the operation signature are validated for authenticity and integrity at the server side,
145+
taking into account the specified blockchain (Ethereum or Solana).
137146
- Requests failing verification or expired keys are rejected with `401 Unauthorized` status, providing an error message
138147
indicating the reason.
139148

140-
### WebSocket Authentication Protocol
149+
## WebSocket Authentication Protocol
141150

142151
In the WebSocket variant of the authentication protocol, the client establishes a connection and authenticates through
143-
an initial message that includes their Ethereum-signed identity, ensuring secure communication.
152+
an initial message that includes their Ethereum or Solana-signed identity, ensuring secure communication.
153+
154+
Due to web browsers not allowing custom HTTP headers in WebSocket connections, the two headers are sent in one JSON
155+
packet, under the `auth` key.
144156

145-
Due to web browsers not allowing custom HTTP headers in WebSocket connections,
146-
the two header are sent in one json packet, under the `auth` key.
157+
Example authentication packet:
147158

148-
Example authentication packet
149159
```json
150160
{
151161
"auth": {

packaging/Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ debian-package-code:
1515
cp ../examples/instance_message_from_aleph.json ./aleph-vm/opt/aleph-vm/examples/instance_message_from_aleph.json
1616
cp -r ../examples/data ./aleph-vm/opt/aleph-vm/examples/data
1717
mkdir -p ./aleph-vm/opt/aleph-vm/examples/volumes
18-
pip3 install --target ./aleph-vm/opt/aleph-vm/ 'aleph-message==0.4.9' 'eth-account==0.10' 'sentry-sdk==1.31.0' 'qmp==1.1.0' 'aleph-superfluid~=0.2.1' 'sqlalchemy[asyncio]>=2.0' 'aiosqlite==0.19.0' 'alembic==1.13.1' 'aiohttp_cors==0.7.0' 'pyroute2==0.7.12' 'python-cpuid==0.1.0'
18+
pip3 install --target ./aleph-vm/opt/aleph-vm/ 'aleph-message==0.4.9' 'eth-account==0.10' 'sentry-sdk==1.31.0' 'qmp==1.1.0' 'aleph-superfluid~=0.2.1' 'sqlalchemy[asyncio]>=2.0' 'aiosqlite==0.19.0' 'alembic==1.13.1' 'aiohttp_cors==0.7.0' 'pyroute2==0.7.12' 'python-cpuid==0.1.0' 'solathon==1.0.2'
1919
python3 -m compileall ./aleph-vm/opt/aleph-vm/
2020

2121
debian-package-resources: firecracker-bins vmlinux download-ipfs-kubo target/bin/sevctl

pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ classifiers = [
2727
"Topic :: System :: Distributed Computing",
2828
]
2929
dynamic = [ "version" ]
30+
31+
# Upon adding or updating dependencies, update `packaging/Makefile` for the Debian package
3032
dependencies = [
3133
"aiodns==3.1",
3234
"aiohttp==3.9.5",
@@ -53,6 +55,7 @@ dependencies = [
5355
"schedule==1.2.1",
5456
"sentry-sdk==1.31",
5557
"setproctitle==1.3.3",
58+
"solathon==1.0.2",
5659
"sqlalchemy[asyncio]>=2",
5760
"systemd-python==235",
5861
]

src/aleph/vm/orchestrator/views/authentication.py

+50-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Functions for authentications
22
3-
See /doc/operator_auth.md for the explaination of how the operator authentication works.
3+
See /doc/operator_auth.md for the explanation of how the operator authentication works.
44
55
Can be enabled on an endpoint using the @require_jwk_authentication decorator
66
"""
@@ -16,11 +16,14 @@
1616
import cryptography.exceptions
1717
import pydantic
1818
from aiohttp import web
19+
from aleph_message.models import Chain
1920
from eth_account import Account
2021
from eth_account.messages import encode_defunct
2122
from jwcrypto import jwk
2223
from jwcrypto.jwa import JWA
24+
from nacl.exceptions import BadSignatureError
2325
from pydantic import BaseModel, ValidationError, root_validator, validator
26+
from solathon.utils import verify_signature
2427

2528
from aleph.vm.conf import settings
2629

@@ -37,7 +40,7 @@ def is_token_still_valid(datestr: str):
3740
return expiry_datetime > current_datetime
3841

3942

40-
def verify_wallet_signature(signature, message, address):
43+
def verify_eth_wallet_signature(signature, message, address):
4144
"""
4245
Verifies a signature issued by a wallet
4346
"""
@@ -46,6 +49,21 @@ def verify_wallet_signature(signature, message, address):
4649
return computed_address.lower() == address.lower()
4750

4851

52+
def check_wallet_signature_or_raise(address, chain, payload, signature):
53+
if chain == Chain.SOL:
54+
try:
55+
verify_signature(address, signature, payload.hex())
56+
except BadSignatureError:
57+
msg = "Invalid signature"
58+
raise ValueError(msg)
59+
elif chain == "ETH":
60+
if not verify_eth_wallet_signature(signature, payload.hex(), address):
61+
msg = "Invalid signature"
62+
raise ValueError(msg)
63+
else:
64+
raise ValueError("Unsupported chain")
65+
66+
4967
class SignedPubKeyPayload(BaseModel):
5068
"""This payload is signed by the wallet of the user to authorize an ephemeral key to act on his behalf."""
5169

@@ -55,6 +73,12 @@ class SignedPubKeyPayload(BaseModel):
5573
# alg: Literal["ECDSA"]
5674
address: str
5775
expires: str
76+
chain: Chain = Chain.ETH
77+
78+
def check_chain(self, v: Chain):
79+
if v not in (Chain.ETH, Chain.SOL):
80+
raise ValueError("Chain not supported")
81+
return v
5882

5983
@property
6084
def json_web_key(self) -> jwk.JWK:
@@ -89,12 +113,10 @@ def check_expiry(cls, values) -> dict[str, bytes]:
89113
@root_validator(pre=False, skip_on_failure=True)
90114
def check_signature(cls, values) -> dict[str, bytes]:
91115
"""Check that the signature is valid"""
92-
signature: bytes = values["signature"]
116+
signature: list = values["signature"]
93117
payload: bytes = values["payload"]
94118
content = SignedPubKeyPayload.parse_raw(payload)
95-
if not verify_wallet_signature(signature, payload.hex(), content.address):
96-
msg = "Invalid signature"
97-
raise ValueError(msg)
119+
check_wallet_signature_or_raise(content.address, content.chain, payload, signature)
98120
return values
99121

100122
@property
@@ -208,6 +230,7 @@ def verify_signed_operation(signed_operation: SignedOperation, signed_pubkey: Si
208230
async def authenticate_jwk(request: web.Request) -> str:
209231
"""Authenticate a request using the X-SignedPubKey and X-SignedOperation headers."""
210232
signed_pubkey = get_signed_pubkey(request)
233+
211234
signed_operation = get_signed_operation(request)
212235
if signed_operation.content.domain != settings.DOMAIN_NAME:
213236
logger.debug(f"Invalid domain '{signed_operation.content.domain}' != '{settings.DOMAIN_NAME}'")
@@ -236,6 +259,26 @@ async def authenticate_websocket_message(message) -> str:
236259
def require_jwk_authentication(
237260
handler: Callable[[web.Request, str], Coroutine[Any, Any, web.StreamResponse]]
238261
) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
262+
"""A decorator to enforce JWK-based authentication for HTTP requests.
263+
264+
The decorator ensures that the incoming request includes valid authentication headers
265+
(as per the VM owner authentication protocol) and provides the authenticated wallet address (`authenticated_sender`)
266+
to the handler. The handler can then use this address to verify access to the requested resource.
267+
268+
Args:
269+
handler (Callable[[web.Request, str], Coroutine[Any, Any, web.StreamResponse]]):
270+
The request handler function that will receive the `authenticated_sender` (the authenticated wallet address)
271+
as an additional argument.
272+
273+
Returns:
274+
Callable[[web.Request], Awaitable[web.StreamResponse]]:
275+
A wrapped handler that verifies the authentication and passes the wallet address to the handler.
276+
277+
Note:
278+
Refer to the "Authentication protocol for VM owner" documentation for detailed information on the authentication
279+
headers and validation process.
280+
"""
281+
239282
@functools.wraps(handler)
240283
async def wrapper(request):
241284
try:
@@ -247,7 +290,7 @@ async def wrapper(request):
247290
logging.exception(e)
248291
raise
249292

250-
# authenticated_sender is the authenticted wallet address of the requester (as a string)
293+
# authenticated_sender is the authenticate wallet address of the requester (as a string)
251294
response = await handler(request, authenticated_sender)
252295
return response
253296

src/aleph/vm/utils/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def to_json(o: Any) -> dict | str:
7575
elif hasattr(o, "dict"): # Pydantic
7676
return o.dict()
7777
elif is_dataclass(o):
78-
return dataclass_as_dict(o)
78+
return dataclass_as_dict(o) # type: ignore
7979
else:
8080
return str(o)
8181

0 commit comments

Comments
 (0)