Authenticate to Linux servers using wallet signatures. No passwords, no SSH keys — just your wallet.
| Chain | Wallet | Verification | Package |
|---|---|---|---|
| EVM | MetaMask | Built-in secp256k1 ecrecover | libpam-web3 + libpam-web3-evm |
| OPNet | OPWallet | ML-DSA-44/65/87 (post-quantum) | libpam-web3 + libpam-web3-opnet |
| Cardano | Eternl, Nami, Lace | Ed25519 COSE_Sign1 (CIP-30) | libpam-web3 + libpam-web3-cardano |
New chains can be added as plugins — see Plugin Interface.
User Server
| |
|──── SSH login ────────────────> |
| |
|<── OTP code + signing URL ───── | "Code: 847293"
| | "Sign at: https://host:34206?session=..."
| |
| (opens URL, connects wallet, |
| signs message in browser) |
| |
| |── auth-svc writes .sig file
|──── press Enter ──────────────> |
| |── verify signature
| |── match wallet against GECOS
| |
|<── LOGIN SUCCESS ────────────── |
The signing URL port is derived deterministically from the chain name (1024 + crc32(chain) % 64511), so no URL configuration is needed.
# Install Rust and PAM dev headers
sudo apt install libpam0g-dev
# Build core + all checked-out backends
./build.sh
# Or build core + specific backends
./build.sh --with-backends=evm
# Or core only (manual paste mode, no signing page)
./build.sh --with-backends=none# Core PAM module (always required)
sudo dpkg -i packaging/libpam-web3_*.deb
# Chain backend (pick one or more)
sudo dpkg -i plugins/evm/packaging/libpam-web3-evm_*.deb
sudo dpkg -i plugins/opnet/packaging/libpam-web3-opnet_*.deb
sudo dpkg -i plugins/cardano/packaging/libpam-web3-cardano_*.debEdit /etc/pam_web3/config.toml:
[machine]
id = "my-server"
secret_key = "0x..." # openssl rand -hex 32
[auth]
otp_length = 6
otp_ttl_seconds = 300# EVM
useradd -m -c "wallet=0xAbCd...1234" alice
# Cardano
useradd -m -c "wallet=addr1q9x...7f3k" bob
# OPNet
useradd -m -c "wallet=0x8a3f...beef" carolAdd to /etc/pam.d/sshd (before @include common-auth):
auth [success=2 default=ignore] pam_succeed_if.so user != web3user
auth [success=1 default=die] pam_web3.so
@include common-auth
In /etc/ssh/sshd_config:
KbdInteractiveAuthentication yes
UsePAM yes
Restart: sudo systemctl restart sshd
libpam-web3 (core)
├── PAM module (pam_web3.so) — OTP, sessions, GECOS, plugin dispatch
├── Built-in EVM verifier — secp256k1 ecrecover
└── Plugin interface — subprocess-based chain dispatch
plugins/evm/ — Signing page + auth-svc (port 63108)
plugins/opnet/ — Plugin + auth-svc (port 32448)
plugins/cardano/ — Plugin + auth-svc (port 34206)
Each chain backend is a self-contained HTTPS server that:
- Serves the signing page at
GET / - Handles auth callbacks at
POST /auth/callback/:session_id - Writes
.sigfiles for PAM to read - Uses TLS certs generated by the core package's postinst
- Listens on a port derived from
crc32(chain_name)— no config needed
AI agents can authenticate programmatically without a browser — the wallet signature is just cryptography.
from eth_account import Account
from eth_account.messages import encode_defunct
import paramiko, re
def ssh_with_wallet(host, username, private_key_hex):
transport = paramiko.Transport((host, 22))
transport.connect(username=username)
def auth_handler(title, instructions, prompts):
responses = []
for prompt, echo in prompts:
match = re.search(r'Code: (\d+).*?Machine: (\S+)', prompt, re.DOTALL)
if match:
otp, machine_id = match.groups()
message = f"Authenticate to {machine_id} with code: {otp}"
signable = encode_defunct(text=message)
signed = Account.sign_message(signable, private_key=private_key_hex)
responses.append(signed.signature.hex())
else:
responses.append('')
return responses
transport.auth_interactive(username, auth_handler)const { Client } = require('ssh2');
const { Wallet } = require('ethers');
const wallet = new Wallet('0x<private_key>');
const conn = new Client();
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
const responses = prompts.map(p => {
const match = p.prompt.match(/Code: (\d+).*?Machine: (\S+)/s);
if (match) {
const [, otp, machineId] = match;
return wallet.signMessageSync(`Authenticate to ${machineId} with code: ${otp}`);
}
return '';
});
finish(responses);
});
conn.connect({ host: 'server.example.com', username: 'myuser', tryKeyboard: true });cast wallet sign "Authenticate to my-server with code: 123456" --private-key $WALLET_KEY
# Paste the output into the SSH promptwallet=0x1234...abcd,nft=5
wallet=ADDRESS— required for authentication (case-insensitive match, chain-agnostic)nft=TOKEN_ID— optional metadata- Can include other fields:
Alice,wallet=0x...,nft=5
| Feature | Description |
|---|---|
| No password storage | Wallets are public addresses, no secrets stored |
| Replay protection | OTP bound to machine ID + timestamp + HMAC |
| Time-limited | OTP codes expire (default 5 minutes) |
| Cryptographic verification | secp256k1 ecrecover (EVM), ML-DSA (OPNet), Ed25519 (Cardano) |
| Fail-secure | Any error results in authentication denial |
| Memory-safe | Core module written in Rust |
| Zero-config signing URLs | Port derived from chain name, TLS from postinst |
See Plugin Interface Specification. In short:
- Create
plugins/{chain}/with a verification plugin, auth-svc, and signing page - Port is
1024 + (crc32("{chain}") % 64511)— no coordination needed ./build.sh --with-backends={chain}builds it
MIT