On this page
Security Architecture
Artery's security posture rests on three claims:
- Artery cannot withdraw your funds. All credentials we hold or transit grant trade-only authority.
- Stored credentials are encrypted under KMS. Disk compromise alone is insufficient to use them.
- Every signing operation is audited. Logs hash request payloads so we can prove what happened without leaking what was signed.
Crypto modules
| Module | Algorithm | Used for |
|---|---|---|
@artery/signing.hmac | HMAC-SHA256 | Webhook verification, API key fingerprinting |
@artery/signing.rsa | RSA-PSS (SHA-256, salt 32) | Kalshi request signing |
@artery/signing.eip712 | EIP-712 typed data via viem | HL approveAgent, Polymarket Safe Module |
@artery/signing.ecdsa | secp256k1 with v normalization (0/1 → 27/28) | Hyperliquid order signing |
The v normalization is required because viem returns v ∈ {0, 1} while
Hyperliquid's mainnet expects v ∈ {27, 28}. The signing helper handles it
once; callers never see raw v.
API key model
POST /keys → { plaintext: "art_live_<uuid>.<32B-base64url>" }
Server-side:
plaintext = art_live_<uuid>.<secret>
stored = scrypt(secret, salt = uuid, N = 2^14, r = 8, p = 1)
hash_check = constant-time compare(scrypt(input_secret), stored)
Properties:
- The plaintext is shown once. Lose it → create a new key.
scrypt-64is memory-hard — brute-forcing one stored hash on a leaked DB costs ~16 MB of RAM per attempt.- The
<uuid>part is the lookup key, soscryptis per-record.
KMS envelope encryption (Kalshi PEM)
The only credential we persist is a Kalshi RSA PEM. It's stored under envelope encryption:
┌─────────────────────────────────────────────────────┐
│ Cloud KMS (or LocalKmsClient in dev) │
│ ┌────────────────────┐ │
│ │ Master KEK │ never leaves KMS HSM │
│ └────────────────────┘ │
└────────▲────────────────────┬───────────────────────┘
│ │
wrap(DEK) unwrap(wrappedDEK)
│ │
┌────────┴───────────┐ ┌──────┴──────────────┐
│ encrypt PEM │ │ decrypt PEM │
│ AES-256-GCM │ │ AES-256-GCM │
│ DEK = randomBytes │ │ DEK in memory only │
│ 32 │ │ for one signing call│
└────────────────────┘ └─────────────────────┘
Stored bytes per credential:
| Field | Type | Purpose |
|---|---|---|
ciphertext | bytes | AES-GCM(PEM) |
iv | 12 bytes | GCM nonce |
tag | 16 bytes | GCM auth tag |
wrappedDek | bytes | KMS-wrapped DEK |
kmsKeyId | string | which KMS key was used (for rotation) |
Decryption flow on a Kalshi sign call:
tsconst dek = await kms.decrypt(record.wrappedDek); // KMS API call
const pem = aesGcm(dek).decrypt(record.ciphertext, record.iv, record.tag);
const sig = await rsaPssSign(pem, payload);
zeroize(dek);
zeroize(pem);
return sig;KMS is the only authority that can unwrap DEKs. A full database leak yields ciphertext + wrapped DEK only. Without KMS access, the PEM is not recoverable.
Local development KMS
For dev, LocalKmsClient reads a 32-byte master key from
KMS_LOCAL_MASTER_KEY_HEX env var and performs the wrap/unwrap in-process:
bashexport KMS_LOCAL_MASTER_KEY_HEX=$(openssl rand -hex 32)
pnpm --filter @artery/api devturbo.json lists this var in globalPassThroughEnv so subprocess tasks
inherit it.
The local master key must persist across restarts (otherwise existing ciphertexts become
unreadable). Store in your secret manager (pass, 1password, etc.) and source on shell init.
Provider credential paths
| Path | Held | Encrypted at rest | Reaches Artery |
|---|---|---|---|
| User master wallet | Never | n/a | Never |
| HL agent private key | Per-request header | n/a (transient) | Yes — used and dropped |
| Polymarket Safe owner | Per-request header | n/a (transient) | Yes — used and dropped |
| Kalshi RSA PEM | Persisted | KMS envelope (AES-256-GCM + KMS-wrap) | Yes |
Audit log
Every signing operation emits a structured record:
json{
"ts": "2026-05-08T12:00:00.000Z",
"level": "info",
"event": "credential.sign",
"userId": "u-1",
"provider": "kalshi",
"endpoint": "POST /trade-api/v2/portfolio/orders",
"payloadHash": "sha256:c2a4…",
"requestId": "req_01HMPK…"
}The payloadHash lets auditors prove which payload was signed without
storing the payload itself.
Threat model summary
| Attacker capability | Recoverable secrets |
|---|---|
| Read DB | None (ciphertext + wrappedDEK only) |
| Read DB + read KMS audit | None (audit is read-only on KMS API calls, no key material) |
| Compromise one process | DEK in memory for that process during one signing op (millisecond window) |
| Compromise KMS | All Kalshi PEMs decryptable |
| Compromise KMS + DB | All Kalshi PEMs decryptable; HL agent keys still safe (per-request only) |
Withdrawals require master wallet signatures. No level of Artery compromise enables withdrawals.
See also
- Passthrough credentials — non-custodial model
- Authentication — API key scopes
- Kalshi provider — RSA-PSS signing