Onboarding a Regular User
How a regular (human) user registers their device using an admin-issued invitation and establishes a persistent authenticated session.
Overview
Regular users are onboarded via an invitation flow. An admin creates the user account and issues an invitation containing one-time credentials. The user then uses those credentials to register their own session keys and receive a permanent device identity.
This is distinct from API user onboarding, where keys are submitted upfront during account creation. Here, the server issues temporary credentials first, and the user exchanges them for their own long-lived session keys.
How the invitation flow works
Admin Server User
│ │ │
│── POST /users (invite) ──────▶ │
│◀─ invite_string │ │
│ oneTimePrivateKey │ │
│ deviceUuid │ │
│ serverPublicKeys │ │
│ │ │
│── share invitation ──────────────-───────────────────────▶│
│ │ │
│ │ generate session key pairs│
│ │ │
│ │◀── POST /account-management/authorize
│ │ (invite_string, new pubkeys, password)
│ │ │
│ │── session credentials ───▶│
│ │ user_id, device_uuid │What you receive in an invitation
| Field | Type | Description |
|---|---|---|
invite_string | string | One-time token issued by the server |
oneTimePrivateKey | hex string | Temporary ECDSA private key for the authorization request |
deviceUuid | string | Temporary device UUID tied to the invitation |
server_rsa_pub_key_pem_b64 | base64 string | Server RSA public key (base64-encoded PEM) |
server_ecdsa_pub_key | hex string | Server ECDSA public key |
Step-by-step
Step 1 — Generate your session key pairs
Before authorizing, generate two key pairs that will identify your device going forward. These are your permanent session credentials and never leave your environment.
import { p256 } from '@noble/curves/nist'
import forge from 'node-forge'
import { Buffer } from 'buffer'
// ECDSA P-256 — used for signing future requests
const sessionPrivateKey = p256.utils.randomSecretKey()
const sessionPublicKeyHex = Buffer.from(p256.getPublicKey(sessionPrivateKey, true)).toString('hex')
// RSA 2048 — used for encrypting/decrypting payloads
const rsaKeyPair = forge.pki.rsa.generateKeyPair({ bits: 2048, e: 0x10001 })Step 2 — Decode the server public key
The server RSA public key in the invitation is base64-encoded. Decode it before passing it to the SDK:
const serverRsaPubKeyPem = forge.pki.publicKeyToPem(
forge.pki.publicKeyFromPem(
Buffer.from(invitation.server_rsa_pub_key_pem_b64, 'base64').toString('utf-8'),
),
)Step 3 — Create a one-time SDK instance
Use the invitation's one-time private key and device UUID to build a temporary Blockstream instance. This instance is only used for the authorization request — it is not your session client.
import { Blockstream } from '@blockstream/ecs-js-sdk'
const oneTimeClient = new Blockstream(
forge.pki.privateKeyToPem(rsaKeyPair.privateKey), // freshly generated RSA private key
new Uint8Array(Buffer.from(invitation.oneTimePrivateKey, 'hex')), // one-time ECDSA key
invitation.deviceUuid,
serverRsaPubKeyPem,
Buffer.from(invitation.server_ecdsa_pub_key, 'hex'),
)Step 4 — Authorize and register your device
Send the authorization request with your new session public keys. The server will bind them to your user account and return your permanent device credentials.
import { v4 as uuidv4 } from 'uuid'
const response = await executeBroadcast(
{
action: 'add',
resource: '/account-management/authorize',
details: {
invite_string: invitation.invite_string,
device_name: `${uuidv4()}-device`,
device_signature_pubkey: sessionPublicKeyHex,
device_encryption_pubkey: forge.pki.publicKeyToPem(rsaKeyPair.publicKey),
password: password,
},
},
oneTimeClient,
)Note on
password: If a password is set here, all future devices registered to this account will require it. Leave it empty if your account does not use password protection.
Step 5 — Store your session credentials
On success, the server returns your permanent user_id and device_uuid. Store these alongside your session private keys — they are required to initialize the SDK on every subsequent request.
if (response.status !== 'success') {
throw new Error(response.message ?? 'Authorization failed')
}
const session = {
userId: response.details.user_id,
deviceUuid: response.details.device.device_uuid,
sessionPrivateKeys: {
ecdsa: Buffer.from(sessionPrivateKey).toString('hex'),
rsa: forge.pki.privateKeyToPem(rsaKeyPair.privateKey),
},
serverPublicKeys: {
rsa: serverRsaPubKeyPem,
ecdsa: invitation.server_ecdsa_pub_key,
},
}You can now initialize a permanent Blockstream instance with these credentials for all future requests.
Proposal state
If the server returns status: "pending" instead of "success", the authorization was submitted as a proposal and requires approval from another authorized user before the device is registered. Handle this case in your application flow:
if (response.status === 'pending') {
// Inform the user that approval is required
console.log('Awaiting approval for proposal:', response.details.proposal_id)
}Security notes
- The one-time private key from the invitation is single-use — discard it after authorization.
- Store session private keys in a secure location (encrypted storage, secrets manager). They cannot be recovered from the server.
- Each device has a unique key pair. To add another device, request a new invitation from an admin.
Making Requests
How to construct, encrypt, and send requests using the SDK — single requests and batches.
API User Onboarding Workflow
This guide describes the workflow for onboarding API users to the Custody Engine system. API users are programmatic clients that authenticate using cryptographic key pairs rather than traditional username/password credentials.