Skip to content

Commit 4a3b5b3

Browse files
committed
Add JWT authentication on top of SIWE contract auth
1 parent fd5b128 commit 4a3b5b3

40 files changed

Lines changed: 5666 additions & 310 deletions

.env.example

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,51 @@ ACCOUNTING_GAS_LIMIT=500000
3030
# Background service configuration
3131
DEPOSIT_POLL_INTERVAL=1
3232
WITHDRAWAL_POLL_INTERVAL=12
33+
34+
# =============================================================================
35+
# JWT Authentication Configuration
36+
# =============================================================================
37+
38+
# JWT token expiration time in hours (default: 12)
39+
# JWT_EXPIRY_HOURS=12
40+
41+
# Refresh token expiration time in days (default: 7)
42+
# JWT_REFRESH_EXPIRY_DAYS=7
43+
44+
# JWT issuer claim - identifies who issued the token (default: flexvaults)
45+
# JWT_ISSUER=flexvaults
46+
47+
# JWT audience claim - identifies intended recipients (default: flexvaults)
48+
# JWT_AUDIENCE=flexvaults
49+
50+
# =============================================================================
51+
# SIWE / AuthToken Configuration
52+
# =============================================================================
53+
54+
# SIWE domain for authentication (required)
55+
# This must match the domain in client SIWE messages (e.g., "localhost:5173" or "flexvaults.com")
56+
SIWE_DOMAIN=localhost:5173
57+
58+
# Directory for storing auth tokens and nonces (default: .auth_tokens)
59+
# Uses SQLite via diskcache for process-safe storage
60+
# AUTH_TOKEN_STORAGE_DIR=.auth_tokens
61+
62+
# SIWE nonce expiration time in seconds (default: 300 = 5 minutes)
63+
# Bounds the window for SIWE message replay at the API level
64+
# SIWE_NONCE_EXPIRY_SECONDS=300
65+
66+
# Auth token validity period in seconds (default: 86400 = 24 hours)
67+
# This is the lifetime of SIWE-based auth tokens for contract view calls
68+
# AUTH_TOKEN_VALIDITY_SECONDS=86400
69+
70+
# =============================================================================
71+
# TEE / ROFL Key Management
72+
# =============================================================================
73+
74+
# TODO: Remove DISABLE_ROFL_KEYS when Sapphire localnet e2e tests are available.
75+
# Set to "1" to disable ROFL-derived keys (for local development/testing)
76+
# When disabled:
77+
# - JWT signing uses randomly generated Ed25519 keys (not persistent across restarts)
78+
# - AuthToken encryption uses deterministic test key (bytes32(1))
79+
# In production (ROFL TEE), keys are derived from TEE-bound seeds for persistence
80+
# DISABLE_ROFL_KEYS=1

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ run:
1010
uv run python -m src.main
1111

1212
test:
13-
uv run pytest
13+
DISABLE_ROFL_KEYS=1 uv run pytest
1414

1515
lint:
1616
uv run ruff check src test

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ dependencies = [
1919
"oasis-rofl-client>=0.2.0",
2020
"oasis-sapphire-py>=0.4.0",
2121
"cachetools>=5.0.0",
22+
"siwe>=4.3.0",
23+
"PyJWT>=2.8.0",
24+
"cryptography>=41.0.0",
25+
"diskcache>=5.6.3",
2226
]
2327

2428
[project.optional-dependencies]

solidity/bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

solidity/contracts/auth/AccountingSiweAuth.sol

Lines changed: 29 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
// SPDX-License-Identifier: Apache-2.0
22
pragma solidity ^0.8.20;
33

4-
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
5-
64
import {SignatureRSV, A13e} from "@oasisprotocol/sapphire-contracts/contracts/auth/A13e.sol";
7-
import {
8-
ParsedSiweMessage,
9-
SiweParser
10-
} from "@oasisprotocol/sapphire-contracts/contracts/SiweParser.sol";
115
import {Sapphire} from "@oasisprotocol/sapphire-contracts/contracts/Sapphire.sol";
6+
import {Subcall} from "@oasisprotocol/sapphire-contracts/contracts/Subcall.sol";
127

138
/// @title AuthToken structure for SIWE-based authentication
149
struct AuthToken {
@@ -31,29 +26,28 @@ struct AuthToken {
3126
* The authentication logic (login/authMsgSender) is only supported on Sapphire chains.
3227
*/
3328
contract AccountingSiweAuth is A13e {
34-
string internal _domain;
3529
bytes32 private _authTokenEncKey;
36-
uint256 private constant DEFAULT_VALIDITY = 24 hours;
30+
bytes21 private _roflAppId;
3731

3832
error SiweAuth_UnsupportedChain();
39-
error SiweAuth_ChainIdMismatch();
40-
error SiweAuth_DomainMismatch();
41-
error SiweAuth_AddressMismatch();
42-
error SiweAuth_NotBeforeInFuture();
4333
error SiweAuth_Expired();
34+
error SiweAuth_NotAuthorizedRofl();
35+
error SiweAuth_LoginDisabled();
4436

45-
constructor(string memory inDomain) {
46-
_domain = inDomain;
37+
constructor(bytes21 inRoflAppId) {
38+
_roflAppId = inRoflAppId;
4739

48-
if (_isSapphireChainId(block.chainid)) {
49-
_authTokenEncKey = bytes32(Sapphire.randomBytes(32, ""));
50-
} else {
40+
// TODO: Remove non-Sapphire fallback when Sapphire localnet e2e tests are available.
41+
// This allows deployment on Hardhat/local networks for unit testing.
42+
if (!_isSapphireChainId(block.chainid)) {
5143
// Deterministic key for non-Sapphire local test networks (e.g., Hardhat).
5244
// Authentication is not expected to be used off-Sapphire.
45+
// On Sapphire, the ROFL service will set the key via setAuthTokenEncKey().
5346
_authTokenEncKey = bytes32(uint256(1));
5447
}
5548
}
5649

50+
// TODO: Remove when Sapphire localnet e2e tests are available.
5751
function _isSapphireChainId(uint256 chainId) private pure returns (bool) {
5852
return chainId == 0x5afe || chainId == 0x5aff || chainId == 0x5afd;
5953
}
@@ -64,73 +58,30 @@ contract AccountingSiweAuth is A13e {
6458
}
6559
}
6660

67-
function login(string calldata siweMsg, SignatureRSV calldata sig)
61+
/// @notice Set the AuthToken encryption key. Can only be called by the authorized ROFL app.
62+
/// @dev The ROFL app ID is set at deployment. Only that app can call this function.
63+
/// @param newKey The 32-byte Deoxys-II encryption key.
64+
function setAuthTokenEncKey(bytes32 newKey) external {
65+
if (Subcall.getRoflAppId() != _roflAppId) {
66+
revert SiweAuth_NotAuthorizedRofl();
67+
}
68+
_authTokenEncKey = newKey;
69+
}
70+
71+
/// @notice Login is disabled - use the REST API instead.
72+
/// @dev This function is kept for A13e interface compatibility but always reverts.
73+
/// The REST service generates and encrypts AuthTokens directly.
74+
function login(string calldata, SignatureRSV calldata)
6875
external
69-
view
76+
pure
7077
override
7178
returns (bytes memory)
7279
{
73-
_requireSapphire();
74-
75-
AuthToken memory b;
76-
77-
// Derive the user's address from the signature.
78-
bytes memory eip191msg = abi.encodePacked(
79-
"\x19Ethereum Signed Message:\n",
80-
Strings.toString(bytes(siweMsg).length),
81-
siweMsg
82-
);
83-
address addr = ecrecover(
84-
keccak256(eip191msg),
85-
uint8(sig.v),
86-
sig.r,
87-
sig.s
88-
);
89-
b.userAddr = addr;
90-
91-
ParsedSiweMessage memory p = SiweParser.parseSiweMsg(bytes(siweMsg));
92-
93-
if (p.chainId != block.chainid) {
94-
revert SiweAuth_ChainIdMismatch();
95-
}
96-
97-
if (keccak256(p.schemeDomain) != keccak256(bytes(_domain))) {
98-
revert SiweAuth_DomainMismatch();
99-
}
100-
b.domain = string(p.schemeDomain);
101-
102-
if (p.addr != addr) {
103-
revert SiweAuth_AddressMismatch();
104-
}
105-
106-
if (
107-
p.notBefore.length != 0 &&
108-
block.timestamp <= SiweParser.timestampFromIso(p.notBefore)
109-
) {
110-
revert SiweAuth_NotBeforeInFuture();
111-
}
112-
113-
if (p.expirationTime.length != 0) {
114-
b.validUntil = SiweParser.timestampFromIso(p.expirationTime);
115-
} else {
116-
b.validUntil = block.timestamp + DEFAULT_VALIDITY;
117-
}
118-
if (block.timestamp >= b.validUntil) {
119-
revert SiweAuth_Expired();
120-
}
121-
122-
b.statement = string(p.statement);
123-
124-
b.resources = new string[](p.resources.length);
125-
for (uint256 i = 0; i < p.resources.length; i++) {
126-
b.resources[i] = string(p.resources[i]);
127-
}
128-
129-
return Sapphire.encrypt(_authTokenEncKey, 0, abi.encode(b), "");
80+
revert SiweAuth_LoginDisabled();
13081
}
13182

132-
function domain() public view returns (string memory) {
133-
return _domain;
83+
function roflAppId() public view returns (bytes21) {
84+
return _roflAppId;
13485
}
13586

13687
function authMsgSender(bytes memory token)
@@ -166,10 +117,6 @@ contract AccountingSiweAuth is A13e {
166117
);
167118
AuthToken memory b = abi.decode(authTokenEncoded, (AuthToken));
168119

169-
if (keccak256(bytes(b.domain)) != keccak256(bytes(_domain))) {
170-
revert SiweAuth_DomainMismatch();
171-
}
172-
173120
if (b.validUntil < block.timestamp) {
174121
revert SiweAuth_Expired();
175122
}

solidity/contracts/interfaces/IAccountingSiweAuth.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ pragma solidity ^0.8.20;
33

44
interface IAccountingSiweAuth {
55
function authSender(bytes calldata token) external view returns (address);
6+
function setAuthTokenEncKey(bytes32 newKey) external;
7+
function roflAppId() external view returns (bytes21);
68
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.20;
3+
4+
import {AuthToken} from "../auth/AccountingSiweAuth.sol";
5+
6+
/**
7+
* @title MockAuthTokenDecrypt
8+
* @notice Test helper for verifying AuthToken ABI encoding compatibility between Python and Solidity.
9+
* @dev This contract allows testing the ABI decoding of AuthTokens without requiring Sapphire's
10+
* encryption. Used to verify that Python's eth_abi encoding matches Solidity's abi.decode.
11+
*
12+
* The real AccountingSiweAuth uses Sapphire.decrypt() which is only available on Sapphire.
13+
* This mock skips decryption and decodes tokens directly, testing only the struct format.
14+
*
15+
* TODO: Remove this mock when Sapphire localnet e2e tests are available.
16+
*/
17+
contract MockAuthTokenDecrypt {
18+
uint256 private constant DEFAULT_VALIDITY = 24 hours;
19+
20+
error SiweAuth_Expired();
21+
error SiweAuth_InvalidTokenFormat();
22+
23+
constructor() {}
24+
25+
/**
26+
* @notice Decode an ABI-encoded AuthToken and return the user address.
27+
* @dev This simulates what authMsgSender() does after decryption on Sapphire.
28+
* @param encodedToken ABI-encoded AuthToken (NOT encrypted, for testing only)
29+
* @return userAddr The user address from the token
30+
*/
31+
function decodeAuthToken(bytes calldata encodedToken) external view returns (address userAddr) {
32+
AuthToken memory token = _decodeAndValidate(encodedToken);
33+
return token.userAddr;
34+
}
35+
36+
/**
37+
* @notice Decode and return all fields of an AuthToken for testing.
38+
* @param encodedToken ABI-encoded AuthToken
39+
*/
40+
function decodeAuthTokenFull(
41+
bytes calldata encodedToken
42+
)
43+
external
44+
view
45+
returns (
46+
string memory tokenDomain,
47+
address userAddr,
48+
uint256 validUntil,
49+
string memory statement,
50+
string[] memory resources
51+
)
52+
{
53+
AuthToken memory token = _decodeAndValidate(encodedToken);
54+
return (token.domain, token.userAddr, token.validUntil, token.statement, token.resources);
55+
}
56+
57+
/**
58+
* @notice Try to decode a token and return success/failure.
59+
* @dev Useful for testing invalid token formats without reverting.
60+
*/
61+
function tryDecodeAuthToken(bytes calldata encodedToken) external view returns (bool success, address userAddr) {
62+
try this.decodeAuthToken(encodedToken) returns (address addr) {
63+
return (true, addr);
64+
} catch {
65+
return (false, address(0));
66+
}
67+
}
68+
69+
/**
70+
* @notice Decode and validate an AuthToken.
71+
* @dev Performs expiry validation, matching AccountingSiweAuth behavior.
72+
*/
73+
function _decodeAndValidate(bytes calldata encodedToken) internal view returns (AuthToken memory) {
74+
if (encodedToken.length == 0) {
75+
revert SiweAuth_InvalidTokenFormat();
76+
}
77+
78+
// Decode the AuthToken struct
79+
AuthToken memory token = abi.decode(encodedToken, (AuthToken));
80+
81+
// Validate token hasn't expired
82+
if (token.validUntil < block.timestamp) {
83+
revert SiweAuth_Expired();
84+
}
85+
86+
return token;
87+
}
88+
89+
/**
90+
* @notice Encode an AuthToken to bytes for test vector generation.
91+
* @dev Generates ABI-encoded AuthToken that Python should produce identically.
92+
*/
93+
function encodeAuthToken(
94+
string calldata tokenDomain,
95+
address userAddr,
96+
uint256 validUntil,
97+
string calldata statement,
98+
string[] calldata resources
99+
) external pure returns (bytes memory) {
100+
AuthToken memory token = AuthToken({
101+
domain: tokenDomain,
102+
userAddr: userAddr,
103+
validUntil: validUntil,
104+
statement: statement,
105+
resources: resources
106+
});
107+
return abi.encode(token);
108+
}
109+
}

solidity/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"@nomicfoundation/hardhat-toolbox": "^4.0.0",
2828
"@nomicfoundation/hardhat-verify": "^2.0.2",
2929
"@openzeppelin/hardhat-upgrades": "^3.0.0",
30-
"@oasisprotocol/sapphire-contracts": "^0.2.14",
30+
"@oasisprotocol/sapphire-contracts": "0.2.15",
3131
"@oasisprotocol/sapphire-hardhat": "^2.22.2",
3232
"@oasisprotocol/sapphire-paratime": "^2.3.0",
3333
"@typechain/ethers-v6": "^0.5.1",
@@ -71,6 +71,7 @@
7171
"dependencies": {
7272
"@openzeppelin/contracts": "~5.4.0",
7373
"@openzeppelin/contracts-upgradeable": "~5.4.0",
74+
"bech32": "^1.1.4",
7475
"cbor": "^10.0.11",
7576
"solidity-rlp": "^2.0.8"
7677
}

solidity/tasks/deploy.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { task } from "hardhat/config";
2+
import { parseRoflAppId } from "./utils/rofl";
23

34
task("deploy")
45
.addParam("shoyubashi", "The address of the ShoyuBashi oracle")
56
.addParam("provethverifier", "The address of the ProvethVerifier contract")
6-
.addParam("domain", "The SIWE domain for authenticated view calls")
7+
.addParam("roflappid", "The ROFL app ID (hex 0x... or bech32 rofl1...)")
78
.setAction(async (args, hre) => {
89
const [deployer] = await hre.ethers.getSigners();
910

11+
// Parse ROFL app ID (supports hex and bech32 formats)
12+
const roflAppIdHex = parseRoflAppId(args.roflappid);
13+
1014
// Deploy AccountingSiweAuth
1115
const AccountingSiweAuth = await hre.ethers.getContractFactory("AccountingSiweAuth");
12-
const siweAuth = await AccountingSiweAuth.deploy(args.domain, {
16+
const siweAuth = await AccountingSiweAuth.deploy(roflAppIdHex, {
1317
gasLimit: 10000000
1418
});
1519
await siweAuth.waitForDeployment();

0 commit comments

Comments
 (0)