امضای درخواست
کتابخانهی رومو این کار را خودش انجام میدهد. این صفحه برای وقتی است که میخواهید خودتان امضا بسازید یا خطای 401 را پیدا کنید.
هر درخواست با کلید خصوصی خودتان امضا میشود.
هدرها
بخش «هدرها»| هدر | مقدار |
|---|---|
X-Custody-Key-Id |
شناسهی کلید، UUID با حروف کوچک |
X-Custody-Timestamp |
زمان فعلی به ثانیهی یونیکس، عدد دهدهی بی صفر اول |
X-Custody-Nonce |
رشتهی تصادفی یکبارمصرف، 16 تا 64 نویسه از A-Z a-z 0-9 _ - |
X-Custody-Signature |
امضای Ed25519 با base64url بی = |
رشتهی امضا
بخش «رشتهی امضا»نه خط، به این ترتیب، با \n به هم وصل میشوند. پس از خط نهم \n نمیآید.
custody.api.request.v1<key_id><timestamp><nonce><METHOD><path><query><sha256 of body, lower-case hex><Idempotency-Key, or empty>| خط | قانون |
|---|---|
| METHOD | با حروف بزرگ، مثل GET یا POST |
| path | مسیر، دقیقاً همان که در درخواست میرود، مثل /v1/transactions |
| query | هر چه بعد از ? میآید، بایت به بایت همان که میفرستید. اگر query ندارید، خط خالی |
| بدنه | sha256 همان بایتهایی که میفرستید. بدنهی خالی هم هش دارد: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
| Idempotency-Key | اگر نیست، خط آخر خالی است و رشته به \n ختم میشود |
شرطها
بخش «شرطها»- پنجرهی زمانی: زمان درخواست باید در 5 دقیقهی زمان سرور باشد، پیش یا پس. ساعت سرور را با NTP هماهنگ نگه دارید.
- nonce: برای هر درخواست تازه و تصادفی باشد، مثلاً 18 بایت تصادفی به base64url. nonce تکراری یک کلید رد میشود.
- همهی خطاهای احراز هویت یک پاسخ دارند:
401با{"error":"unauthenticated"}. این شامل امضای غلط، کلید ناشناخته یا باطل یا منقضی، IP غیرمجاز، زمان بیرون از پنجره و nonce تکراری است. دو استثنا: بدنهی بزرگتر از حد، پس از درست بودن امضا،413میگیرد؛ و اگر رومو موقتاً نتواند کلید یا nonce را بسنجد503میگیرید و دوباره میفرستید. - بدنه: فقط JSON، حداکثر 16 KiB. فیلد ناشناخته
400میگیرد. - TLS: فقط TLS 1.3.
بردار آزمون
پیش از وصل شدن، پیادهسازی خودتان را با این بردارها بسنجید. کد این سایت با همین بردارها آزموده میشود. کلید، کلید آزمون RFC 8032 (بردار 1) است و هرگز به رومو کار نمیکند.
private seed (hex) 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60public key (hex) d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511akey_id 01926e60-0000-7000-8000-000000000001timestamp 1790726400nonce n0nce-AAAAAAAAAAAAAA_zGET /v1/whoami، بی query و بی بدنه
بخش «GET /v1/whoami، بی query و بی بدنه»خط هفتم (query) خالی است. خط نهم هم خالی است، پس رشته پس از هش یک \n دارد.
custody.api.request.v101926e60-0000-7000-8000-0000000000011790726400n0nce-AAAAAAAAAAAAAA_zGET/v1/whoami
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855signature CL-8PYt7avP809S0E91_W16zCkf7aQ2kAnSCogANpyDePJPw67Td5EPe_Nam83cAEi6DwGfM531-iVQk_B2LDQPOST /v1/transactions با Idempotency-Key
بخش «POST /v1/transactions با Idempotency-Key»بدنه دقیقاً {"amount":"1"} است و کلید withdrawal-1.
custody.api.request.v101926e60-0000-7000-8000-0000000000011790726400n0nce-AAAAAAAAAAAAAA_zPOST/v1/transactions
cdb8de78f10f4776de95629e660ff8a5aba9ab22166d96ce73e50d0e3acfc6dcwithdrawal-1signature 8N9H7CbO38qFuBimj2bNCpNo2jb9DhfnA24TswZGO03eq6tXfghS9kQEQa6vv88lZnCrI0yW2K5iTg25uG_6Awکلاینت آماده
این کدها با بردارهای بالا آزموده شدهاند و نمونههای مرجع API از همینها استفاده میکنند. هر چهار تا این متغیرها را میخوانند: ROMU_KEY_ID، ROMU_KEY_FILE (پیشفرض romu-api-key.pem) و ROMU_BASE_URL (پیشفرض نشانی sandbox است؛ برای تولید https://api.romu.io را بگذارید). فایلها: romu.ts، romu.go، romu.py و romu-curl.sh.
#!/usr/bin/env bash# One signed request to Romu with curl and OpenSSL 3.# Usage: ./romu-curl.sh METHOD PATH[?QUERY] [BODY] [IDEMPOTENCY_KEY]set -euo pipefail
: "${ROMU_KEY_ID:?set ROMU_KEY_ID to your key_id}"key_file=${ROMU_KEY_FILE:-romu-api-key.pem}base_url=${ROMU_BASE_URL:-"https://api.romu.ir"}
method=$1target=$2body=${3:-}idempotency_key=${4:-}
path=${target%%\?*}query=""if [[ $target == *\?* ]]; then query=${target#*\?}; fi
timestamp=$(date +%s)nonce=$(openssl rand -base64 18 | tr '+/' '-_' | tr -d '=')body_hash=$(printf '%s' "$body" | openssl dgst -sha256 -r | cut -d' ' -f1)
# Nine lines joined with \n, no newline after the last one.message=$(mktemp)trap 'rm -f "$message"' EXITprintf 'custody.api.request.v1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s' \ "$ROMU_KEY_ID" "$timestamp" "$nonce" "$method" "$path" "$query" "$body_hash" "$idempotency_key" >"$message"
signature=$(openssl pkeyutl -sign -inkey "$key_file" -rawin -in "$message" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
args=(-sS -X "$method" "$base_url$target" -H "X-Custody-Key-Id: $ROMU_KEY_ID" -H "X-Custody-Timestamp: $timestamp" -H "X-Custody-Nonce: $nonce" -H "X-Custody-Signature: $signature")if [[ -n $idempotency_key ]]; then args+=(-H "Idempotency-Key: $idempotency_key"); fiif [[ -n $body ]]; then args+=(-H "Content-Type: application/json" --data-raw "$body"); fi
curl "${args[@]}"// Romu client: every request is signed for you (Node 24 or later, node:crypto only).import { createHash, createPrivateKey, randomBytes, sign, type KeyObject } from "node:crypto";import { readFileSync } from "node:fs";
const BASE_URL = process.env.ROMU_BASE_URL ?? "https://api.romu.ir";const KEY_ID = process.env.ROMU_KEY_ID ?? "";const KEY_FILE = process.env.ROMU_KEY_FILE ?? "romu-api-key.pem";
type Signed = { keyId: string; timestamp: number; nonce: string; method: string; path: string; query: string; body: Uint8Array; idempotencyKey: string;};
// stringToSign: nine lines joined with "\n", no newline after the last one.export function stringToSign(r: Signed): string { const bodyHash = createHash("sha256").update(r.body).digest("hex"); return ["custody.api.request.v1", r.keyId, String(r.timestamp), r.nonce, r.method, r.path, r.query, bodyHash, r.idempotencyKey].join("\n");}
export function signature(message: string, key: KeyObject): string { return sign(null, Buffer.from(message, "utf8"), key).toString("base64url");}
type Options = { query?: string; json?: unknown; idempotencyKey?: string };
// call sends one signed request. The query goes out exactly as it was signed.export async function call(method: string, path: string, { query = "", json, idempotencyKey = "" }: Options = {}) { const key = createPrivateKey(readFileSync(KEY_FILE)); const body = json === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(json), "utf8"); const timestamp = Math.floor(Date.now() / 1000); const nonce = randomBytes(18).toString("base64url"); const headers: Record<string, string> = { "X-Custody-Key-Id": KEY_ID, "X-Custody-Timestamp": String(timestamp), "X-Custody-Nonce": nonce, "X-Custody-Signature": signature(stringToSign({ keyId: KEY_ID, timestamp, nonce, method, path, query, body, idempotencyKey }), key), }; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; if (json !== undefined) headers["Content-Type"] = "application/json"; const res = await fetch(`${BASE_URL}${path}${query ? `?${query}` : ""}`, { method, headers, body: json === undefined ? undefined : body }); return { status: res.status, body: await res.json() };}
// romu is the short form: romu.get("/v1/whoami").export const romu = { get: (path: string, options?: Options) => call("GET", path, options), post: (path: string, options?: Options) => call("POST", path, options), put: (path: string, options?: Options) => call("PUT", path, options), patch: (path: string, options?: Options) => call("PATCH", path, options),};// Package romu is the Romu client: every request is signed for you (standard library only).package romu
import ( "bytes" "crypto/ed25519" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/pem" "errors" "io" "net/http" "os" "strconv" "strings" "time")
// Client holds your key_id and private key.type Client struct { BaseURL string KeyID string Key ed25519.PrivateKey}
// NewClient reads the PEM file made by openssl genpkey.func NewClient(baseURL, keyID, keyFile string) (*Client, error) { raw, err := os.ReadFile(keyFile) if err != nil { return nil, err } block, _ := pem.Decode(raw) if block == nil { return nil, errors.New("romu: no PEM block in the key file") } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } key, ok := parsed.(ed25519.PrivateKey) if !ok { return nil, errors.New("romu: the key is not Ed25519") } return &Client{BaseURL: baseURL, KeyID: keyID, Key: key}, nil}
// StringToSign is nine lines joined with "\n", no newline after the last one.func StringToSign(keyID string, timestamp int64, nonce, method, path, query string, body []byte, idempotencyKey string) string { sum := sha256.Sum256(body) return strings.Join([]string{"custody.api.request.v1", keyID, strconv.FormatInt(timestamp, 10), nonce, method, path, query, hex.EncodeToString(sum[:]), idempotencyKey}, "\n")}
// Get sends one signed GET.func (c *Client) Get(path, query string) (int, []byte, error) { return c.Do(http.MethodGet, path, query, nil, "")}
// Do sends one signed request. The query goes out exactly as it was signed.func (c *Client) Do(method, path, query string, body []byte, idempotencyKey string) (int, []byte, error) { random := make([]byte, 18) if _, err := rand.Read(random); err != nil { return 0, nil, err } nonce := base64.RawURLEncoding.EncodeToString(random) timestamp := time.Now().Unix() message := StringToSign(c.KeyID, timestamp, nonce, method, path, query, body, idempotencyKey)
target := c.BaseURL + path if query != "" { target += "?" + query } req, err := http.NewRequest(method, target, bytes.NewReader(body)) if err != nil { return 0, nil, err } req.Header.Set("X-Custody-Key-Id", c.KeyID) req.Header.Set("X-Custody-Timestamp", strconv.FormatInt(timestamp, 10)) req.Header.Set("X-Custody-Nonce", nonce) req.Header.Set("X-Custody-Signature", base64.RawURLEncoding.EncodeToString(ed25519.Sign(c.Key, []byte(message)))) if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } if len(body) > 0 { req.Header.Set("Content-Type", "application/json") } res, err := http.DefaultClient.Do(req) if err != nil { return 0, nil, err } defer res.Body.Close() out, err := io.ReadAll(res.Body) return res.StatusCode, out, err}"""Romu client: every request is signed for you (Python 3.10 or later; pip install cryptography)."""import base64import hashlibimport json as jsonlibimport osimport secretsimport timeimport urllib.errorimport urllib.request
from cryptography.hazmat.primitives.serialization import load_pem_private_key
BASE_URL = os.environ.get("ROMU_BASE_URL", "https://api.romu.ir")KEY_ID = os.environ.get("ROMU_KEY_ID", "")KEY_FILE = os.environ.get("ROMU_KEY_FILE", "romu-api-key.pem")
def string_to_sign(key_id, timestamp, nonce, method, path, query, body, idempotency_key): """Nine lines joined with a newline, no newline after the last one.""" body_hash = hashlib.sha256(body).hexdigest() return "\n".join(["custody.api.request.v1", key_id, str(timestamp), nonce, method, path, query, body_hash, idempotency_key])
def b64url(raw): return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def call(method, path, query="", json=None, idempotency_key=""): """Send one signed request. The query goes out exactly as it was signed.""" with open(KEY_FILE, "rb") as f: key = load_pem_private_key(f.read(), password=None) body = b"" if json is None else jsonlib.dumps(json, separators=(",", ":")).encode() timestamp = int(time.time()) nonce = secrets.token_urlsafe(18) message = string_to_sign(KEY_ID, timestamp, nonce, method, path, query, body, idempotency_key) headers = { "X-Custody-Key-Id": KEY_ID, "X-Custody-Timestamp": str(timestamp), "X-Custody-Nonce": nonce, "X-Custody-Signature": b64url(key.sign(message.encode())), } if idempotency_key: headers["Idempotency-Key"] = idempotency_key if json is not None: headers["Content-Type"] = "application/json" url = BASE_URL + path + ("?" + query if query else "") req = urllib.request.Request(url, data=body if json is not None else None, headers=headers, method=method) try: with urllib.request.urlopen(req) as res: return res.status, jsonlib.load(res) except urllib.error.HTTPError as err: return err.code, jsonlib.load(err)
def get(path, **options): return call("GET", path, **options)
def post(path, **options): return call("POST", path, **options)
def put(path, **options): return call("PUT", path, **options)
def patch(path, **options): return call("PATCH", path, **options)در Go کلاینت را یک بار میسازید:
client, err := romu.NewClient("https://api.romu.ir", os.Getenv("ROMU_KEY_ID"), "romu-api-key.pem")اگر کلید لو رفت
بخش «اگر کلید لو رفت»کلید را در کنسول باطل کنید: «باطل کردن» و بعد «بله، باطل کنید». این کار فوری و برای همیشه است و تأیید نمیخواهد. بعد کلید تازه بسازید.