Developer guide
Everything your backend has to implement to talk to SecureChat from a language other than Node or Python, with test vectors and tested examples in PHP and Go.
English translation of 13 · Integración desde otros lenguajes. The Spanish version is the reference; both describe the same protocol.
For businesses whose backend is neither Node nor Python. Here is everything your backend has to implement to talk to SecureChat, taken from the code rather than from intent:
| Source | What it defines |
|---|---|
envelope.js | The v1 sc1 envelope, key wraps, attachments, fingerprints, Ed25519 signatures. Single source of truth |
v1.json | 82 test vectors shared by the app and the SDKs |
Node SDK (securechat-sdk) | The business flow, key file format |
| The server implementation | How the backend verifies your requests and signs its webhooks (§3) |
| API reference (available in the business portal) | /v1 endpoints and events; this guide includes the ones you need |
If your implementation passes the 82 vectors in v1.json (§8), it is compatible with the app.
There are tested examples in PHP and Go in §11.
The minimum you have to build:
- Read your keys (
private.key/public.key, andsigning.keyif you rotate) — §2 - Verify the HMAC signature of every webhook and sign every request to
/v1— §3 - Open the conversation key sealed for you and verify the invitation binding — §4
- Decrypt the user's messages and encrypt your replies — §5
- (Optional) Attachments — §6 · Key rotation — §7
Everything uses libsodium primitives: X25519 (crypto_box_seal),
XChaCha20-Poly1305 IETF, BLAKE2b and Ed25519, plus HMAC-SHA256 for transport. Nothing is
proprietary: you only have to put the pieces together in the exact order.
1. Conventions that apply everywhere
- Unpadded base64url (RFC 4648 §5, alphabet
A–Z a–z 0–9 - _, no=) for all cryptographic material. It is libsodium'sURLSAFE_NO_PADDINGvariant. The decoder must be strict: it rejects=, spaces, line breaks, characters from another alphabet, length% 4 == 1and leftover bits in the last character. Error →INVALID_INPUT - Ids (
conversationId,clientMessageId,companyId):^[A-Za-z0-9_-]{1,128}$. This way the AD separator|cannot appear inside a field. Careful with regexes: in PHP use theDmodifier (or\z); without it,$accepts a trailing\n - Text: strict UTF-8 (no overlongs or surrogates). When decrypting, invalid UTF-8 is
INVALID_INPUT, never text with replacement characters - Numbers in JSON:
v,keyVersion,size,chunkSizeare integers. The platform never emits them as1.0 - Errors: when something does not add up, an error with a code is thrown and the ciphertext or an empty string is never returned as if it were text. The codes (used by the vectors):
| Code | Meaning |
|---|---|
INVALID_INPUT | Invalid format: base64url, key/nonce length, id outside [A-Za-z0-9_-], UTF-8 |
UNSUPPORTED_VERSION | v ≠ 1 or scheme ≠ "sc1" (or attachment meta of another version) |
DECRYPT_FAILED | Authentication failed: wrong key, tampering, impossible length |
KEY_MISMATCH | The wrap opens with your key, but belongs to another conversation or version |
BAD_PADDING | Decrypts fine but the padding is not valid ISO 7816-4 |
SIGNATURE_INVALID | Ed25519 signature that does not verify |
BINDING_INVALID | The user's key is not bound to the invitation secret |
The domain constant: DOMAIN = "SecureChat|v1". Every string that is signed, hashed or used
as AD starts with it.
2. Key files
npx securechat-sdk keygen --out ./keys (or your language's equivalent, §2.3) writes four
files. Each one is a single line of unpadded base64url with a trailing \n. When reading
them, trim() the text before decoding.
| File | Content | Bytes | Permissions | Goes to the portal |
|---|---|---|---|---|
private.key | X25519 secret key (crypto_box_keypair) | 32 | 0600 | Never |
public.key | X25519 public key | 32 | 0644 | Yes: Keys and API |
signing.key | Ed25519 secret key (crypto_sign_keypair) in libsodium format: seed(32) ‖ public(32) | 64 | 0600 | Never |
signing.pub | Ed25519 public key | 32 | 0644 | Yes, next to the previous one |
- The X25519 secret key is libsodium's raw 32 bytes. The public key must be
X25519(secret, 9)(crypto_scalarmult_base); check it when loading - Some libraries expect only the Ed25519 seed (32 bytes): it is the first 32 bytes of
signing.key. Go (ed25519.PrivateKey) and libsodium use all 64 - After
securechat rotate, the previous key is kept asprivate.v<N>.key/public.v<N>.key. Keep them: existing conversations are still sealed for them (§7)
2.1 Fingerprint
fp = BLAKE2b-256( UTF8("SecureChat|v1|fp|") ‖ publicKey ) // crypto_generichash(32, …, no key)
- In the invitation link it goes in base64url (
&fp=…, §4.3) - To show to a person: lowercase hex in 16 groups of 4 separated by spaces
(
7c8b a68b 9c68 …), which is whatkeygenandsecurechat fingerprint public.keyprint
2.2 Portal credentials
| Credential | Format | Use |
|---|---|---|
| API key | sk_live_<base64url of 32 bytes> | Authorization: Bearer … on /v1. Shown only once |
webhookSecret | whsec_<base64url of 32 bytes> | HMAC key in both directions (§3) |
2.3 Generating them without Node
It is plain libsodium: crypto_box_keypair() and crypto_sign_keypair(), encode as unpadded
base64url, one line per file. The PHP example includes Keys::generateKeyFiles($dir) with the
same names and permissions.
3. HMAC signatures: requests to /v1 and webhooks
Both directions use HMAC-SHA256, the same header and the same secret:
X-SecureChat-Signature: t=<unix seconds>,v1=<64-character lowercase hex>
The HMAC key is the full webhookSecret as UTF-8 text, including the whsec_ prefix.
Do not base64-decode it.
3.1 Signing your requests to /v1
canonical = t + "." + METHOD + "." + pathAndQuery + "." + rawBody
v1 = hex( HMAC-SHA256( key = webhookSecret, msg = UTF8(canonical) ) )
| Piece | Rule |
|---|---|
t | floor(now/1000) in seconds. The backend accepts |now − t| ≤ 300 |
METHOD | Uppercase: GET, POST |
pathAndQuery | Exactly what goes in the URL after the host: /v1/conversations?status=active&limit=20. No domain or stage. Sign the same string you send (if you encode the query, sign the encoded version) |
rawBody | The exact bytes of the body. No body (GET) → empty string, and the canonical string ends in . |
Headers of each request (as the Node SDK does):
Authorization: Bearer sk_live_…
X-SecureChat-Signature: t=…,v1=…
Content-Type: application/json ← only if there is a body
Idempotency-Key: <unique> ← on everything that is not GET
Idempotency-Key: up to 128 printable ASCII characters (0x21–0x7e). It is stored for 24 h per business: same key and same request → the same response (Idempotent-Replayed: true); same key with a different request →409 IDEMPOTENCY_CONFLICT. Reuse the key when retrying the same operation (e.g.reply-<eventId>)- POSTs without data (
close,typing,retry) are sent with body{}, like the SDK does - Signature errors:
401 INVALID_SIGNATUREor401 SIGNATURE_EXPIRED(check your clock: NTP) - After rotating the secret in the portal, the previous one stays valid for 24 h for your requests; webhooks are signed with the new one right away
Example with the test secret whsec_TEST-ONLY_0123456789abcdefghijklmnopqrstuvwxyzAB
(from signing.json):
canonical: 1759088531.GET./v1/conversations/cnv_01JG7X8M2K9P3Q4R5S6T7U8V9W.
header: t=1759088531,v1=45778239dec9b466d956d785c3d0874293e7dce42a3d0ad5e5696aebff2336fd
canonical: 1759088531.POST./v1/conversations/cnv_01JG7X8M2K9P3Q4R5S6T7U8V9W/close.{}
header: t=1759088531,v1=32fb08d3f102a916ade197cfb3f57639cd30013f78951d4a369b7c7d1b3f52f4
3.2 Verifying the webhooks you receive
SecureChat sends a POST to your URL (HTTPS) with:
Content-Type: application/json
User-Agent: SecureChat-Webhooks/1.0
X-SecureChat-Signature: t=…,v1=…
X-SecureChat-Event-Id: evt_…
X-SecureChat-Delivery-Id: dlv_…
X-SecureChat-Attempt: 1
canonical = t + "." + rawBody
Steps, in this order (the same ones the Node SDK follows):
- Raw body: read the bytes exactly as they arrived (
php://input,io.ReadAll(r.Body),request.getInputStream()…). If your framework already parsed the JSON and you serialize it again, the bytes change and the signature does not match. It is the most common integration mistake - Parse the header: split on
,; split each part on the first=; trim spaces from key and value. You needt(integer) andv1(exactly^[0-9a-f]{64}$, lowercase). Missing header →SIGNATURE_MISSING; invalid format →SIGNATURE_MALFORMED - Window: reject if
|now − t| > 300s (SIGNATURE_EXPIRED). This prevents replays - Recompute
hex(HMAC-SHA256(webhookSecret, t + "." + rawBody)) - Compare in constant time (
hash_equals,hmac.Equal,MessageDigest.isEqual,CryptographicOperations.FixedTimeEquals,Rack::Utils.secure_compare). Different →SIGNATURE_INVALIDand respond401 - Idempotency by
eventId: delivery is "at least once". If you already processed it, respond200and do nothing - Respond 2xx in less than 10 s and process in a queue. 400/401/403/422 are not retried; 408, 429, 5xx and timeouts are (10 s, 1 min, 5 min, 30 min, 2 h)
Tolerate new fields and event types. The catalog is in the API reference (available in the business portal).
4. The conversation key
Each conversation has a 32-byte symmetric key K that the app generates when redeeming
the invitation. The app seals it twice: for your public key (wrapForCompany) and for its own
(wrapForUser). SecureChat only carries the wraps; it cannot open them.
4.1 Wrap format
{ "keyVersion": 1, "sealed": "base64url…" }
payload = "SCK1" ‖ u32be(keyVersion) ‖ K(32) ‖ UTF8(conversationId)
sealed = crypto_box_seal(payload, recipientPublicKey) // anonymous X25519 + XSalsa20-Poly1305
crypto_box_seal is libsodium's standard sealed box: ephemeral_pk(32) ‖ crypto_box(…)
with nonce BLAKE2b-192(ephemeral_pk ‖ recipient_pk). It adds 48 bytes. Use your library's
function; do not rebuild it by hand unless there is no other option.
4.2 Opening it (openConversationKey)
You receive the wrap in data.conversationKey of every message.created and of
conversation.started, and in wrapForCompany of GET /v1/conversations/{id}.
1. conversationId matches the id regex; keyVersion integer 1..2^31-1 → otherwise, INVALID_INPUT
2. sealed = b64uDecode(wrapped.sealed) → INVALID_INPUT
3. payload = crypto_box_seal_open(sealed, yourPublic, yourSecret) → fails: DECRYPT_FAILED
4. len(payload) ≥ 41 and payload[0..4) == "SCK1" → otherwise, KEY_MISMATCH
5. u32be(payload[4..8)) == wrapped.keyVersion → otherwise, KEY_MISMATCH
6. cid = payload[40..] (valid UTF-8) and cid == expected conversationId → otherwise, KEY_MISMATCH
7. K = payload[8..40)
The conversationId in step 6 is the one from the event (or the one you requested), not
one you take from the payload. That way nobody can move a wrap to another conversation.
Several keys (after rotating): try your list [current, previous, …] in order and move on
to the next one only if the error is DECRYPT_FAILED. A KEY_MISMATCH means the right key
opened something with the wrong context: trying another key does not fix it.
You can cache K by conversationId (the SDK keeps up to 1,000 in memory). Every
message.created event carries the wrap, so a stateless receiver also works.
4.3 Invitation with secret and binding
When you create the invitation (POST /v1/invites → inviteUrl), generate an
inviteSecret of 32 random bytes, store it associated with the conversationId from the
response and give the customer:
<inviteUrl>#s=<b64u(inviteSecret)>&fp=<b64u(fingerprint of your current public.key)>
The fragment (#…) is not sent to the server over HTTP. The app uses fp to check your key
and s to compute the binding, which arrives in conversation.started as data.inviteBinding:
msg = "SecureChat|v1|invite-binding|" + conversationId + "|" + b64u(userPublicKey)
+ "|" + wrapForCompany.keyVersion + "|" + wrapForCompany.sealed
tag = b64u( BLAKE2b-256( key = inviteSecret(32 bytes), msg = UTF8(msg) ) ) // keyed crypto_generichash
userPublicKeycomes fromdata.userPublicKey(decode it and encode it again, or use the string as is if you already validated that it is base64url of 32 bytes);wrapForCompanyisdata.conversationKey, withsealedexactly as it arrives- Compare
b64uDecode(data.inviteBinding)with your tag in constant time. Different →BINDING_INVALID: someone replaced the user's key. Do not trust the conversation - Without
inviteBinding(the customer typed the short code) there is nothing to verify: the conversation stays unverified
4.4 Reinvitation and rewrap (phone change)
POST /v1/conversations/{id}/reinvite creates a new invitation for the same conversation
(generate another inviteSecret and build the link as in §4.3). When the customer redeems it:
conversation.startedarrives withdata.isReinvite: true, the newuserPublicKeyand, inconversationKey, the existing wrap for you- the conversation moves to
awaiting_key(the app does not allow writing) - you open K with your key (§4.2), seal it for the new key and upload it:
wrapForUser = { keyVersion: data.conversationKey.keyVersion,
sealed: b64u(crypto_box_seal("SCK1" ‖ u32be(kv) ‖ K ‖ conversationId, userPublicKey)) }
POST /v1/conversations/{id}/rewrap { "wrapForUser": { "keyVersion": 1, "sealed": "…" } }
The rewrap hands the whole history to that key. Do it only if the binding verified.
Without a binding, confirm with the customer through another channel before resealing (the SDK
returns reason: "REWRAP_REQUIRES_BINDING" and does not reseal).
4.5 Two different "key versions"
| Name | What it versions | Where it appears |
|---|---|---|
Conversation keyVersion | The key K | Wraps, message envelope, AD, conversation.keyVersion. Always 1 today |
Business keyVersion | Your X25519 key | Portal, platform signature, rotation (§7) |
Your replies carry the keyVersion of the wrap you opened
(data.conversationKey.keyVersion or conversation.keyVersion). Any other value →
409 KEY_VERSION_MISMATCH.
5. Messages: envelope v1 sc1
5.1 Fields
{
"v": 1,
"scheme": "sc1",
"keyVersion": 1,
"clientMessageId": "cli_01JG…",
"nonce": "base64url, 24 bytes",
"ciphertext": "base64url"
}
It arrives in data.encryption of message.created and in encryption of each message of
GET /v1/conversations/{id}/messages. It is the same object you send in POST /v1/messages.
5.2 Construction
AD = "SecureChat|v1|msg|" + conversationId + "|" + senderType + "|" + clientMessageId + "|" + keyVersion
padded = UTF8(text) ‖ 0x80 ‖ 0x00… up to the next multiple of 256 (ISO/IEC 7816-4)
nonce = 24 random bytes
ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt(padded, AD, nonce, K) // includes the 16-byte tag at the end
senderType∈user|company;keyVersionin decimal without leading zeros- Example AD:
SecureChat|v1|msg|cnv_01JG7X8M2K9P3Q4R5S6T7U8V9W|user|cli_0001|1 - Padding:
paddedLength(n) = ceil((n + 1) / 256) * 256. There is always at least the0x80byte: 255 bytes of text → 256; 256 bytes → 512; empty text → 256 - Text ≤ 64 KiB (65,536 UTF-8 bytes) before padding
- The AD binds the ciphertext to its context: if someone moves it to another conversation,
swaps the sender, changes the
clientMessageIdor the version, decryption fails
5.3 Decrypting a user message
Exactly in this order (the order decides which code the negative vectors return):
1. v == 1 and scheme == "sc1" → otherwise, UNSUPPORTED_VERSION
2. AD with the EVENT's conversationId, senderType = "user" (fixed),
clientMessageId and keyVersion FROM THE ENVELOPE (validated) → INVALID_INPUT
3. nonce = b64uDecode(nonce), exactly 24 bytes → INVALID_INPUT
4. ct = b64uDecode(ciphertext) → INVALID_INPUT
len(ct) ≥ 272 and (len(ct) − 16) % 256 == 0 → otherwise, DECRYPT_FAILED
5. padded = crypto_aead_xchacha20poly1305_ietf_decrypt(ct, AD, nonce, K) → fails: DECRYPT_FAILED
6. remove padding: skip trailing 0x00; 0x80 must remain; the padding is at most 256 bytes;
len(padded) multiple of 256 → otherwise, BAD_PADDING
7. strict UTF-8 → otherwise, INVALID_INPUT
You set senderType yourself; you do not read it from the event. In a webhook it is always
user. In the history, use the message's own senderType (company for yours), because
that is how whoever wrote it encrypted it.
5.4 Encrypting and sending your reply
clientMessageId = "srv_" + b64u(16 random bytes) // any unique value that matches the id regex
encryption = envelope with senderType = "company", keyVersion = the wrap's, random nonce
POST /v1/messages
{ "conversationId": "cnv_…", "contentType": "text", "encryption": { … } }
POST /v1/messagesis idempotent byclientMessageIdwithin the conversation (and byIdempotency-Key). When retrying, resend the same envelope- Never reuse a nonce with the same K. 24 random bytes from a CSPRNG are enough
- The server validates the shape (
ciphertext≥ 272 bytes,(len − 16) % 256 == 0, max. 66 KB, 24-bytenonce) but cannot read the content. Errors:409 CONVERSATION_PENDING,409 CONVERSATION_AWAITING_KEY,400 INVALID_ENVELOPE,409 KEY_VERSION_MISMATCH,410 USER_DELETED
Other useful endpoints, all signed the same way: GET /v1/conversations[/{id}],
GET /v1/conversations/{id}/messages?limit=&before=&after=, POST …/read
({ "upToMessageId" }), POST …/typing, POST …/close, GET /v1/deliveries. Details in
the API reference (available in the business portal).
6. Attachments
Each file is encrypted with its own key F (32 random bytes) and a nonceBase
(24 random bytes), in 64 KiB chunks. F and the rest of the data travel inside a normal
message encrypted with K (contentType: "attachment"); the server only sees opaque bytes.
6.1 Chunked encryption
CHUNK = 65536
chunks = max(1, ceil(size / CHUNK)) // an empty file is 1 chunk of 0 bytes
nonce_i = nonceBase[0..16) ‖ u64be(i) // i = 0, 1, 2…
AD_i = "SecureChat|v1|att|" + attachmentId + "|" + i + "|" + (i == chunks−1 ? "1" : "0")
ct_i = crypto_aead_xchacha20poly1305_ietf_encrypt(plain_i, AD_i, nonce_i, F) // len(plain_i) + 16
ciphertext = ct_0 ‖ ct_1 ‖ …
attachmentCiphertextLength(size) = size + chunks * 16
digest = b64u( BLAKE2b-256(full plaintext, no key) )
The AD binds each chunk to its file, its position and the end of the file: reordering,
truncating or adding chunks fails. When decrypting: first check that len(ciphertext) == attachmentCiphertextLength(meta.size), decrypt each chunk and finally compare the digest.
Any failure → DECRYPT_FAILED; never hand over a partial file.
6.2 The meta (text of the attachment message)
The text you encrypt with the envelope from §5 is this JSON (same field order as envelope.js):
{
"v": 1, "kind": "attachment", "attachmentId": "att_…", "name": "invoice.pdf",
"mime": "application/pdf", "size": 150000, "key": "b64u(F)", "nonce": "b64u(nonceBase)",
"digest": "b64u(BLAKE2b-256)", "chunkSize": 65536,
"caption": "optional", "width": 1200, "height": 800
}
Validation (validateAttachmentMeta), the same when sending and when receiving:
| Field | Rule | Error |
|---|---|---|
v, kind | 1 and "attachment" | UNSUPPORTED_VERSION |
attachmentId | ^att_[A-Za-z0-9_-]{8,64}$ | INVALID_INPUT |
name | non-empty string after trim(), ≤ 255 UTF-16 code units, no \u0000–\u001f, / or \ | INVALID_INPUT |
mime | ^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}/[a-z0-9][a-z0-9!#$&^_.+-]{0,127}$ | INVALID_INPUT |
size | integer 0 … 26,214,400 (25 MB) | INVALID_INPUT |
chunkSize | exactly 65536 | UNSUPPORTED_VERSION |
key, nonce, digest | base64url of 32, 24 and 32 bytes | INVALID_INPUT |
caption | optional; string ≤ 4000 UTF-16 code units (null = absent) | INVALID_INPUT |
width, height | optional but together; integers 1 … 20000 | INVALID_INPUT |
The JSON is parsed, not compared byte by byte: field order and escaping do not affect compatibility.
6.3 Sending an attachment
POST /v1/conversations/{id}/attachmentswith{ "size": attachmentCiphertextLength(bytes) }(size of the ciphertext, between 16 and 26,220,800) →201 { "attachmentId", "uploadUrl", "method": "PUT", "headers": { … }, "expiresIn": 300 }- Encrypt with that
attachmentId(it goes in the AD of every chunk) PUTtouploadUrlwith the returnedmethodand exactly the returnedheaders(todayContent-Type: application/octet-streamandx-amz-tagging: state=pending) and a body of exactlysizebytes. It is a presigned S3 URL: noAuthorizationand no HMAC signature. Any difference →403from S3- Encrypt
JSON(meta)as a normal reply (§5.4) and send:
{ "conversationId": "cnv_…", "contentType": "attachment",
"attachment": { "attachmentId": "att_…" }, "encryption": { … } }
Specific errors: 409 ATTACHMENT_NOT_UPLOADED, 409 ATTACHMENT_ALREADY_USED. Limit: 1,000
uploads per business per hour.
6.4 Receiving an attachment
message.created with data.contentType: "attachment" additionally carries, in clear,
data.attachment: { attachmentId, size } (size = ciphertext bytes).
- Decrypt
data.encryptionlike any message and parse/validate the meta - Check that
meta.attachmentId == data.attachment.attachmentId(otherwise,KEY_MISMATCH) GET /v1/conversations/{id}/attachments/{attachmentId}(signed) →{ "downloadUrl", "size", "expiresIn": 300 }GET downloadUrlwithout custom headers and decrypt with the meta from the message (§6.1)
An unknown contentType is ignored.
7. Rotating your key and Ed25519 signatures
7.1 Rotating
You generate a new X25519 key and sign the step with your signing.key:
statement = "SecureChat|v1|company-key-rotation|" + companyId + "|" + fromKeyVersion + "|" + b64u(fromPublicKey)
+ "|" + toKeyVersion + "|" + b64u(toPublicKey)
rotationSignature = b64u( Ed25519_sign_detached(UTF8(statement), signing.key) ) // 64 bytes
toKeyVersion > fromKeyVersion(otherwise,INVALID_INPUT);securechat rotateusesfrom + 1- You paste the new public key and
rotationSignatureinto the portal (Keys and API → Rotate) - The app pins your
signingPublicKeyon first contact: a rotation signed by it is shown as a soft notice; without a signature, the notice is prominent - Keep the previous key and add it to your key list (§4.2): existing conversations are still sealed for it
Verifying is the reverse: Ed25519_verify_detached(sig, statement, signing.pub) →
SIGNATURE_INVALID if it does not verify.
7.2 Platform signature (informational)
SecureChat signs your public key and the app checks it with the platform key pinned in the binary. You do not need to implement it, but the vectors include it:
statement = "SecureChat|v1|company-key|" + companyId + "|" + keyVersion + "|" + b64u(publicKey)
7.3 Web inbox agents (signed roster)
If your team answers from the portal inbox, each person has their own X25519 key, generated in
their browser, and the app also seals the conversation key for it. It only does so for the keys
in an agent roster signed with your signing.key: SecureChat cannot slip in a key of its
own.
agents = for each agent, agentKeyId + ":" + b64u(publicKey), sorted by agentKeyId
(byte order) and joined with "," // empty roster → ""
statement = "SecureChat|v1|agent-roster|" + companyId + "|" + rosterVersion + "|" + issuedAt + "|" + agents
signature = b64u( Ed25519_sign_detached(UTF8(statement), signing.key) ) // 64 bytes
Example (vector two-agents):
SecureChat|v1|agent-roster|cmp_8f3a2b1c|3|2026-05-04T15:30:00Z|agk_vectorAAAA0001:loI-7Zl6tCQzkXROAvWMFCCvoHxjGqdbIMB567nwsAk,agk_vectorBBBB0002:BSo3nchkrfA_2psM6_9aytexQxJHIwEh8lGMdjMM2lM
agentKeyId:agk_+ 8 to 64 characters[A-Za-z0-9_-];publicKey: 32 bytes. No repeated ids or keys and at most 1000 agents. The empty roster is valid (no agents: the statement ends with|)rosterVersion: integer from 1 to 2^31-1; each new roster carries the previous one + 1.issuedAt: exactlyYYYY-MM-DDTHH:MM:SSZ(UTC, no milliseconds or offset, a date that exists). Anything else →INVALID_INPUT- Normally the owner signs in the portal (Team → Agents): they load
signing.keyinto the browser, and it is not uploaded. If you prefer to sign on your server, the format is this same one - Verifying:
Ed25519_verify_detached(sig, statement, signing.pub)→SIGNATURE_INVALIDif it does not verify. The app verifies it with thesigningPublicKeyit pinned on first contact and never seals for a roster that does not verify or for a version lower than the last one it saw - The wrap for an agent is a normal wrap (§4.1) sealed for their
publicKey. To give a new agent access to earlier conversations from your server: open K with your key (§4.2), seal it for each new agent in the roster and upload it withPOST /v1/conversations/{id}/agent-wraps
8. How to validate your implementation
8.1 v1.json
Generated with libsodium 1.0.22 (libsodium-wrappers). Test-only keys. All material is
unpadded base64url. Structure:
| Key | Content | Checks |
|---|---|---|
keys.{user,company,recoveryDevice} | seed, publicKey, privateKey | crypto_box_seed_keypair(seed) yields exactly that pair (sk = SHA-512(seed)[0..32), pk = X25519(sk, 9)) · 3 |
keys.platformSigning | Ed25519 seed, publicKey | (for companyKey) |
conversation | conversationId, companyId, keyVersion, key (K) | shared context |
messages[] | name, senderType, clientMessageId, keyVersion, text, nonce, ad, paddedLength, ciphertext, envelope | your AD == ad; encrypting with that nonce yields exactly ciphertext; length = paddedLength + 16; decrypting envelope yields text · 7 (empty, unicode, 255/256 bytes, 3000 bytes) |
negativeMessages[] | conversationId, senderType, envelope, key?, expectError | decrypting must fail with that code · 17 |
wraps[] | recipient (which keys pair to use), wrapped, expectConversationId, expectError? | opening yields K, or fails with the code · 7 |
companyKey | valid platform signature + negative[] | verifies / SIGNATURE_INVALID · 5 |
companyRotation | signingSeed, signingPublicKey, valid rotation + negative[] with expectError | 7 |
agentRoster | signingPublicKey (the one in companyRotation), agentKeys.{agentA,agentB,agentC} (agentKeyId, seed, publicKey, privateKey), valid[] with statement and signature | your statement == statement; signing with companyRotation.signingSeed yields exactly signature; it verifies (also with the agents unordered) · 3 |
agentRoster.negative[] | roster with signature and expectError: changed key, agent added or removed, another version, another date, another company, signed by the platform, tampered signature (→ SIGNATURE_INVALID); repeated id or key, malformed id, impossible date, date with milliseconds, version 0 (→ INVALID_INPUT) | 14 |
agentWraps[] | recipient (which agentRoster.agentKeys pair to use), wrapped, expectConversationId, expectError? | opening yields K; with another agent's key → DECRYPT_FAILED; in another conversation → KEY_MISMATCH · 4 |
attachments | key (F), nonceBase, pattern (byte[i] = (i*31 + 7) & 255) and cases[] with size, attachmentId, meta, ciphertextLength, ciphertextDigest (BLAKE2b-256 of the ciphertext), ciphertext (small ones only) | encrypting the pattern with F and nonceBase yields that digest and that meta; decrypting returns the pattern · 5 |
(derived from attachments) | on three-chunks: flipped byte, truncated last chunk, swapped chunks, another attachmentId, another digest, another size (→ DECRYPT_FAILED) and name ../x (→ INVALID_INPUT) | 7 |
companyKey.fingerprint | b64u fingerprint of companyKey.publicKey | 1 |
inviteBinding | inviteSecret, conversationId, userPublicKey, wrapForCompany, tag | exact tag and it verifies; with the recoveryDevice key instead of the user's → BINDING_INVALID · 2 |
Total: 82. The exact list of checks is in run-vectors.js: port it
as is (the PHP and Go examples do). The negative checks are not optional: a decryptor that
"works" with the positive vectors but accepts a relabeled envelope is not compatible.
If your implementation passes the 82 vectors, it is compatible with the app and the SDKs.
8.2 HMAC signatures
v1.json has no signature vectors. signing.json
(generated with the Node SDK) contains 12 webhooks
(valid, tampered, reserialized, expired, malformed, with the error code the Node SDK gives) and
5 /v1 requests with their canonical string and exact header. 17 cases.
8.3 Cross-test
The cross-test generates keys with the real CLI (keygen + rotate),
encrypts on the "app" side with envelope.js, and checks that PHP and Go decrypt, reply,
reseal, sign rotations and encrypt attachments that the Node SDK accepts. Then it starts each
language's example receiver against a mock /v1 API that verifies the signatures with
the server implementation.
9. Libraries by language
All of them wrap libsodium or implement the same thing. The versions are the latest seen when writing this: check the current one before pinning it.
| Primitive | PHP (ext-sodium, in core since 7.2) | Go (golang.org/x/crypto + stdlib) | Java (Lazysodium, com.goterl:lazysodium-java, latest seen 5.2.0) | .NET (Sodium.Core, latest seen 1.4.1) | Ruby (rbnacl, latest seen 7.1.2) |
|---|---|---|---|---|---|
Open wrap (crypto_box_seal_open) | sodium_crypto_box_seal_open($c, sodium_crypto_box_keypair_from_secretkey_and_publickey($sk, $pk)) → false on failure | box.OpenAnonymous(nil, c, &pk, &sk) (nacl/box) | cryptoBoxSealOpen(m, c, cLen, pk, sk) | SealedPublicKeyBox.Open(c, sk, pk) | RbNaCl::Boxes::Sealed.from_private_key(sk).open(c) |
| Seal (rewrap) | sodium_crypto_box_seal($m, $pk) | box.SealAnonymous(nil, m, &pk, rand.Reader) | cryptoBoxSeal(c, m, mLen, pk) | SealedPublicKeyBox.Create(m, pk) | Sealed.from_public_key(pk).box(m) |
| XChaCha20-Poly1305 IETF | sodium_crypto_aead_xchacha20poly1305_ietf_{encrypt,decrypt}($m, $ad, $nonce, $k) | chacha20poly1305.NewX(k) → Seal/Open | cryptoAeadXChaCha20Poly1305Ietf{Encrypt,Decrypt}(…) | SecretAeadXChaCha20Poly1305.{Encrypt,Decrypt}(m, nonce, k, ad) | RbNaCl::AEAD::XChaCha20Poly1305IETF.new(k).{encrypt,decrypt}(nonce, m, ad) |
| BLAKE2b-256 (fingerprint, digest) / keyed (binding) | sodium_crypto_generichash($m, $key = '', 32) | blake2b.Sum256(m) / blake2b.New(32, key) | cryptoGenericHash(out, 32, m, mLen, key, keyLen) | GenericHash.Hash(m, key /* or null */, 32) | RbNaCl::Hash.blake2b(m, digest_size: 32, key: k) |
| Ed25519 verify / sign | sodium_crypto_sign_verify_detached($sig, $m, $pk) / sodium_crypto_sign_detached($m, $sk64) | ed25519.Verify(pk, m, sig) / ed25519.Sign(sk64, m) (stdlib) | cryptoSignVerifyDetached(sig, m, mLen, pk) / cryptoSignDetached(…) | PublicKeyAuth.VerifyDetached(sig, m, pk) / PublicKeyAuth.SignDetached(m, sk64) | RbNaCl::VerifyKey.new(pk).verify(sig, m) / RbNaCl::SigningKey.new(sk64[0,32]).sign(m) |
| HMAC-SHA256 + comparison | hash_hmac('sha256', …) + hash_equals | crypto/hmac + hmac.Equal | javax.crypto.Mac("HmacSHA256") + MessageDigest.isEqual | HMACSHA256 + CryptographicOperations.FixedTimeEquals | OpenSSL::HMAC.hexdigest("SHA256", …) + Rack::Utils.secure_compare / OpenSSL.fixed_length_secure_compare |
| Unpadded base64url | sodium_bin2base64(…, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING) | base64.RawURLEncoding.Strict() | Base64.getUrlEncoder().withoutPadding() | Utilities.BinaryToBase64(b, Utilities.Base64Variant.UrlSafeNoPadding) | Base64.urlsafe_encode64(b, padding: false) |
| Official example | Yes, tested: downloads | Yes, tested: downloads | No official example yet | No official example yet | No official example yet |
Notes by language:
- PHP: the AEAD and
seal_openfunctions returnfalseon failure, but throwSodiumExceptionon invalid lengths: treat both cases asDECRYPT_FAILED.pack('J')(big-endian u64) requires 64-bit PHP. References: sodium_crypto_box_seal_open, sodium_crypto_aead_xchacha20poly1305_ietf_decrypt, sodium_crypto_generichash, sodium_crypto_sign_verify_detached - Go: pure Go, no cgo.
nacl/boxprovidesSealAnonymous/OpenAnonymous, compatible withcrypto_box_seal. Go's base64 decoder ignores\rand\neven inStrictmode: validate the alphabet first.crypto_box_seed_keypairdoes not exist as such: it issk = SHA-512(seed)[:32],pk = curve25519.X25519(sk, Basepoint). References: nacl/box, chacha20poly1305, blake2b, crypto/ed25519 - Java: Lazysodium (JNA over libsodium) covers everything. Bouncy Castle and the standard JCE provide X25519, Ed25519, BLAKE2b and ChaCha20-Poly1305 with a 12-byte nonce, but not the sealed box or XChaCha20-Poly1305 ready to use: you would have to build them by hand (HChaCha20, BLAKE2b-192 nonce + XSalsa20-Poly1305). We do not recommend it
- .NET: Sodium.Core has every piece, including
SealedPublicKeyBox, but its own README states that it is no longer actively developed. NSec is modern and providesAeadAlgorithm.XChaCha20Poly1305, X25519, Ed25519 and BLAKE2b, but not the sealed box: combining them requires implementingcrypto_box_seal_openby hand - Ruby: rbnacl needs libsodium installed on the system.
SigningKey.newtakes the 32-byte seed (the first 32 bytes ofsigning.key).VerifyKey#verifyraisesRbNaCl::BadSignatureErrorif it does not verify - Any other language: look for a libsodium binding in the official list and pass the 82 vectors
10. Common errors
| Symptom | Usual cause |
|---|---|
401 INVALID_SIGNATURE on all your requests | You sign a different path from the one you send (reordered or re-encoded query), lowercase METHOD, or the signed body is not byte for byte the one sent |
401 SIGNATURE_EXPIRED | Server clock off by more than 300 s |
| All webhooks fail the signature | You verify over re-serialized JSON, or use the secret without the whsec_ prefix |
DECRYPT_FAILED on every message | Wrong senderType in the AD, conversationId from another source, or you decrypted with the old X25519 key without trying the list |
BAD_PADDING / text with garbage at the end | You do not remove the ISO 7816-4 padding, or you remove it by trimming \0 without looking for the 0x80 |
| The app does not show your reply | keyVersion different from the wrap's, repeated clientMessageId (returns the previous message) or no padding (400 INVALID_ENVELOPE) |
403 when uploading an attachment | The exact headers from uploadUrl are missing or the body is not size bytes long |
Id regex accepts cli_1\n | PHP: the D modifier is missing. Go/Java/.NET: use end-of-text anchors |
11. Reference files and examples
Material to validate and port your implementation, exactly as it is in the repository:
| File | What it is |
|---|---|
v1.json | The 82 test vectors (§8.1). Test-only keys |
run-vectors.js | The exact list of vector checks, to port it |
envelope.js | Reference implementation (JavaScript): envelope, wraps, attachments, fingerprints and signatures |
signing.json | The 17 HMAC signature cases (§8.2) |
PHP (PHP ≥ 8.1 with ext-sodium). Everything together in securechat-php.zip, with v1.json and signing.json at the paths the examples expect (cd securechat-php/examples/other-languages/php && php run-vectors.php):
securechat.php: single-file library, no Composerindex.php: webhook receiver in plain PHPrun-vectors.php: runs the vectors; it looks forv1.jsonin../../../packages/crypto/vectors/andsigning.jsonin../fixtures/
Go (Go ≥ 1.26, only golang.org/x/crypto). Everything together in securechat-go.zip, with v1.json and signing.json at the paths the examples expect (cd securechat-go/examples/other-languages/go && go test ./...):
go.mod·go.sum- Package
securechat/:attachments.go,client.go,codec.go,envelope.go,events.go,keys.go,signing.goandvectors_test.go(go test ./...; the vector paths can be changed withSECURECHAT_VECTORSandSECURECHAT_SIGNING_FIXTURES) cmd/webhook/main.go: webhook receiver withnet/http
The examples are reference material: in production, store the processed eventIds and the
inviteSecrets in your database, process webhooks in a queue and serve the receiver over HTTPS.