A signature over a server challenge creates the developer record.
Docs · Portal
Passkey setup.
Portal access is passkey-gated.
The NukezAgent portal at portal.nukez.xyz/agent-portal is the browser view of your agent subscription and its storage. Your keypair proves who you are once, during provisioning; a passkey then carries that identity into the browser. No password, no email, and no recovery questions are involved at any point.
One biometric tap replaces the keypair for browser access.
Storage writes are authorized by the keypair, never by the passkey.
Identity
Start from a keypair, not an account
There is no sign-up form. An Ed25519 or secp256k1 keypair is the identity, and only its private half can answer the challenge that creates your developer record.
# Prerequisites# Python 3.11+, and a browser with passkey support (Touch ID, Face ID, security key)# Ed25519 (Solana): pip install httpx pynacl base58# secp256k1 (EVM): pip install httpx eth-account # Ed25519 keypair - 64-byte JSON array, the Solana CLI keypair formatpython3 -c "from nacl.signing import SigningKeyimport json, base58sk = SigningKey.generate()key_bytes = list(bytes(sk) + bytes(sk.verify_key))with open('nukez_key.json', 'w') as f: json.dump(key_bytes, f)print('Public key:', base58.b58encode(bytes(sk.verify_key)).decode())" # secp256k1 keypair - address plus private keypython3 -c "from eth_account import Accountimport jsonacct = Account.create()with open('nukez_evm_key.json', 'w') as f: json.dump({'address': acct.address, 'private_key': acct.key.hex()}, f, indent=2)print('Address:', acct.address)" # Either curve works end to end. The public key becomes your Nukez identity.# The private key never leaves this machine; it only signs challenge strings.
Code notes
Portal enrollment starts from a keypair, not an email address. The public key is the identity that the service issues a challenge against, and the private key is the only thing that can answer it. Either curve is accepted: an Ed25519 key in the 64-byte Solana JSON array format, or a secp256k1 EVM key stored as an address and private key. The portal itself lives at https://portal.nukez.xyz/agent-portal and is backed by the service at https://agent.nukez.xyz. If you already hold a keypair, skip this step and go straight to the challenge.
The two algorithms differ only in signature encoding: Ed25519 signatures are base58-encoded, and secp256k1 signatures are 0x-prefixed hex produced by an EIP-191 personal_sign.
Provisioning
Prove control of the private key
Two calls against the front door — request a challenge, return a signature — create the developer record and hand back a portal URL with a registration token already in it.
import json, httpx, base58from nacl.signing import SigningKey SERVICE = "https://agent.nukez.xyz" key_bytes = json.load(open("nukez_key.json"))sk = SigningKey(bytes(key_bytes[:32]))pubkey = base58.b58encode(bytes(sk.verify_key)).decode() client = httpx.Client(timeout=30) # 1. Request a challenge -> {"nonce": ..., "message": ..., "expires_at": ...}challenge = client.post( f"{SERVICE}/v1/provision/challenge", json={"pubkey": pubkey}).json()# message: "nukez-provision:9f3c1a7e5b204d8c4b2e1f6a3d7c9e05:1785931200" - 120s # 2. Sign the message string exactly as returned, base58-encode the signaturesignature = base58.b58encode(sk.sign(challenge["message"].encode()).signature).decode() # 3. Exchange the signature for a developer recordresult = client.post(f"{SERVICE}/v1/provision/verify", json={ "pubkey": pubkey, "nonce": challenge["nonce"], "signature": signature,}).json()print(json.dumps(result, indent=2)) # {# "developer_id": "453e7d09-70b8-4b3a-a54b-f33698f68e22",# "status": "pending_payment",# "owner_pubkey": "BhBeSkwKyqysZstzkqdf4qAcYfS9r27wEMmouvSVfp1U",# "registration_token": "abc123...",# "portal_url": "https://portal.nukez.xyz/agent-portal?register_token=abc123..."# }
Code notes
Provisioning is a two-call challenge-response against https://agent.nukez.xyz. POST /v1/provision/challenge returns a message to sign; POST /v1/provision/verify checks the signature, creates the developer record, and returns a ready-made portal_url with a register_token embedded in the query string. Nothing about the passkey exists yet at this point: the keypair is what proves who you are, and the portal URL is the bridge from that proof to a browser credential.
status is pending_payment until storage is purchased through /v1/service/request and /v1/service/confirm. The registration_token is issued regardless, so a passkey can be registered before the account becomes active.
Credential
Create the passkey in one tap
Opening that URL is the whole registration step. The browser runs the WebAuthn ceremony and the private credential never leaves the device or security key.
$ open "https://portal.nukez.xyz/agent-portal?register_token=abc123..." # The portal reads register_token from the query string and renders the# registration gate instead of the Connect button, then fires the prompt. # POST /v1/portal/auth/passkey/register/options# Authorization: Bearer abc123... (the register_token, not a session token){ "rp": { "id": "nukez.xyz", "name": "Nukez" }, "user": { "id": "NDUzZTdkMDktNzBiOC00YjNhLWE1NGItZjMzNjk4ZjY4ZTIy", "name": "BhBeSkwK...uvSVfp1U", "displayName": "Nukez Developer" }, "challenge": "s0mUXo9rQ2h...base64url...", "authenticatorSelection": { "residentKey": "required", "userVerification": "required" }, "attestation": "none", "excludeCredentials": []}# abridged - pubKeyCredParams and timeout are also returned # The browser calls navigator.credentials.create() with those options.# The authenticator creates the credential behind one biometric check;# the private half never leaves the device or security key. # POST /v1/portal/auth/passkey/register/complete# Authorization: Bearer abc123...# body: id, rawId, type, response.attestationObject, response.clientDataJSON# -> {# "status": "registered",# "credential_id": "hZ1rP8s...",# "session_token": "kQ7xVn2...",# "developer_id": "453e7d09-70b8-4b3a-a54b-f33698f68e22",# "expires_at": 1786017600# }
Code notes
Opening portal_url is the entire registration step. The portal reads register_token from the query string and renders the registration gate instead of the Connect button, then calls the options endpoint, runs the WebAuthn ceremony, and posts the result back. The credential is created with resident key REQUIRED, so it is discoverable and can be found later without a username, and user verification REQUIRED, so a biometric or PIN check is mandatory on every use. The relying party id is nukez.xyz and the expected origin is https://portal.nukez.xyz.
After a successful registration the portal removes register_token from the address bar. If the ceremony is dismissed the view reports that passkey creation was cancelled and offers a retry button, which reuses the same token while it is still within its ten-minute window.
Recovery
Mint a new token when the first expires
Registration tokens last ten minutes. Signing a second challenge with the same keypair issues a fresh one without repeating provisioning.
# The register_token is valid for 10 minutes. After that the portal reports# "Invalid or expired registration token" (401) and no prompt appears.# Mint a fresh one without repeating provisioning. import json, httpxfrom eth_account import Accountfrom eth_account.messages import encode_defunct SERVICE = "https://agent.nukez.xyz" evm = json.load(open("nukez_evm_key.json"))account = Account.from_key(evm["private_key"])pubkey = account.address client = httpx.Client(timeout=30) # 1. Portal challenge. 404 = this pubkey was never provisioned.# 403 = the developer record exists but its status is not "active" yet.challenge = client.post( f"{SERVICE}/v1/portal/auth/challenge", json={"pubkey": pubkey}).json()# {"challenge": "nukez-stepup:portal_registration:4d1a...:1785931200",# "expires_at": 1785931320} # 2. secp256k1: EIP-191 personal_sign, 0x-prefixed hexsigned = account.sign_message(encode_defunct(challenge["challenge"].encode()))signature = signed.signature.hex()if not signature.startswith("0x"): signature = "0x" + signature# Ed25519 equivalent for the same string:# signature = base58.b58encode(sk.sign(challenge["challenge"].encode()).signature).decode() # 3. Trade the signature for a new registration_tokenresult = client.post(f"{SERVICE}/v1/portal/auth/verify-keypair", json={ "pubkey": pubkey, "challenge": challenge["challenge"], "signature": signature,}).json()# {"status": "verified", "registration_token": "def456...", "expires_at": 1785931800} print("https://portal.nukez.xyz/agent-portal?register_token=" + result["registration_token"])
Code notes
The register_token in portal_url lives for ten minutes. Once it lapses the options call answers 401 and the prompt never fires. Rather than re-running provisioning, prove control of the same keypair again through the portal challenge pair: POST /v1/portal/auth/challenge returns a fresh string to sign, and POST /v1/portal/auth/verify-keypair exchanges the signature for a new registration_token. This route requires the developer record to be active already, because the challenge call answers 403 while the status is still pending_payment. This example signs with a secp256k1 EVM key to show the other half of the signing contract.
This path re-mints portal registration only. It does not create a second developer record, and it does not replace or revoke the passkey already registered on another device.
Session
Sign in on later visits
With a passkey enrolled the bare portal URL is enough. The assertion identifies you with no username, and the session lives in localStorage with an explicit expiry.
$ open https://portal.nukez.xyz/agent-portal # No register_token in the URL, so the portal renders the Connect button. # POST /v1/portal/auth/passkey/login/options (no Authorization header){ "rpId": "nukez.xyz", "challenge": "Rk9xTb4...base64url...", "userVerification": "required", "allowCredentials": []}# abridged - timeout is also returned # navigator.credentials.get() runs with those options. The credential is# discoverable, so the authenticator returns userHandle - the developer_id# written into user.id at registration. That is how the server identifies# you with no username, no email, and no credential id list to match against. # POST /v1/portal/auth/passkey/login/complete# body: id, rawId, type, response.authenticatorData, response.clientDataJSON,# response.signature, response.userHandle# -> {# "status": "authenticated",# "session_token": "kQ7xVn2...",# "developer_id": "453e7d09-70b8-4b3a-a54b-f33698f68e22",# "expires_at": 1786017600# } localStorage nukez_session_token kQ7xVn2... sent as Authorization: Bearer on portal calls nukez_developer_id 453e7d09-70b8-4b3a-a54b-f33698f68e22 nukez_session_expires 1786017600 unix seconds; default session TTL is 86400 # Every session read compares expires_at against the clock and clears all three# keys when it has passed. Any 401 from a portal endpoint clears them too.
Code notes
After registration the portal URL needs no parameters. With no register_token present the gate renders the Connect button, which requests authentication options and calls navigator.credentials.get(). Because the credential is discoverable, the options carry an empty credential id list and the authenticator identifies you from the passkey it already holds. A successful assertion returns a bearer session token that the portal keeps in localStorage and attaches to every subsequent portal request.
If Connect reports that no passkey was found, either this browser holds no credential for nukez.xyz or the account has none registered yet. Minting a fresh registration token through /v1/portal/auth/challenge and /v1/portal/auth/verify-keypair only works once the developer record is active; before that the challenge call answers 403 and provisioning or payment has to finish first.
Passkeys are not signing keys.
A passkey authenticates a browser session against the portal. It cannot sign a storage envelope, and it grants no authority at the gateway. For the model where several signing keys sit under one portal identity, read the multi-keypair guide.
