Guide pour les développeurs

Tout ce que votre backend doit implémenter pour communiquer avec SecureChat dans un autre langage que Node ou Python, avec des vecteurs de test et des exemples testés en PHP et Go.

Ce guide technique est disponible en anglais et en espagnol.

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:

SourceWhat it defines
envelope.jsThe v1 sc1 envelope, key wraps, attachments, fingerprints, Ed25519 signatures. Single source of truth
v1.json82 test vectors shared by the app and the SDKs
Node SDK (securechat-sdk)The business flow, key file format
The server implementationHow 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:

  1. Read your keys (private.key/public.key, and signing.key if you rotate) — §2
  2. Verify the HMAC signature of every webhook and sign every request to /v1 — §3
  3. Open the conversation key sealed for you and verify the invitation binding — §4
  4. Decrypt the user's messages and encrypt your replies — §5
  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's URLSAFE_NO_PADDING variant. The decoder must be strict: it rejects =, spaces, line breaks, characters from another alphabet, length % 4 == 1 and 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 the D modifier (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, chunkSize are integers. The platform never emits them as 1.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):
CodeMeaning
INVALID_INPUTInvalid format: base64url, key/nonce length, id outside [A-Za-z0-9_-], UTF-8
UNSUPPORTED_VERSIONv ≠ 1 or scheme ≠ "sc1" (or attachment meta of another version)
DECRYPT_FAILEDAuthentication failed: wrong key, tampering, impossible length
KEY_MISMATCHThe wrap opens with your key, but belongs to another conversation or version
BAD_PADDINGDecrypts fine but the padding is not valid ISO 7816-4
SIGNATURE_INVALIDEd25519 signature that does not verify
BINDING_INVALIDThe 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.

FileContentBytesPermissionsGoes to the portal
private.keyX25519 secret key (crypto_box_keypair)320600Never
public.keyX25519 public key320644Yes: Keys and API
signing.keyEd25519 secret key (crypto_sign_keypair) in libsodium format: seed(32) ‖ public(32)640600Never
signing.pubEd25519 public key320644Yes, 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 as private.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 what keygen and securechat fingerprint public.key print

2.2 Portal credentials

CredentialFormatUse
API keysk_live_<base64url of 32 bytes>Authorization: Bearer … on /v1. Shown only once
webhookSecretwhsec_<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) ) )
PieceRule
tfloor(now/1000) in seconds. The backend accepts |now − t| ≤ 300
METHODUppercase: GET, POST
pathAndQueryExactly 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)
rawBodyThe 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_SIGNATURE or 401 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):

  1. 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
  2. Parse the header: split on ,; split each part on the first =; trim spaces from key and value. You need t (integer) and v1 (exactly ^[0-9a-f]{64}$, lowercase). Missing header → SIGNATURE_MISSING; invalid format → SIGNATURE_MALFORMED
  3. Window: reject if |now − t| > 300 s (SIGNATURE_EXPIRED). This prevents replays
  4. Recompute hex(HMAC-SHA256(webhookSecret, t + "." + rawBody))
  5. Compare in constant time (hash_equals, hmac.Equal, MessageDigest.isEqual, CryptographicOperations.FixedTimeEquals, Rack::Utils.secure_compare). Different → SIGNATURE_INVALID and respond 401
  6. Idempotency by eventId: delivery is "at least once". If you already processed it, respond 200 and do nothing
  7. 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
  • userPublicKey comes from data.userPublicKey (decode it and encode it again, or use the string as is if you already validated that it is base64url of 32 bytes); wrapForCompany is data.conversationKey, with sealed exactly 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.started arrives with data.isReinvite: true, the new userPublicKey and, in conversationKey, 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"

NameWhat it versionsWhere it appears
Conversation keyVersionThe key KWraps, message envelope, AD, conversation.keyVersion. Always 1 today
Business keyVersionYour X25519 keyPortal, 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; keyVersion in 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 the 0x80 byte: 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 clientMessageId or 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/messages is idempotent by clientMessageId within the conversation (and by Idempotency-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-byte nonce) 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:

FieldRuleError
v, kind1 and "attachment"UNSUPPORTED_VERSION
attachmentId^att_[A-Za-z0-9_-]{8,64}$INVALID_INPUT
namenon-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
sizeinteger 0 … 26,214,400 (25 MB)INVALID_INPUT
chunkSizeexactly 65536UNSUPPORTED_VERSION
key, nonce, digestbase64url of 32, 24 and 32 bytesINVALID_INPUT
captionoptional; string ≤ 4000 UTF-16 code units (null = absent)INVALID_INPUT
width, heightoptional but together; integers 1 … 20000INVALID_INPUT

The JSON is parsed, not compared byte by byte: field order and escaping do not affect compatibility.

6.3 Sending an attachment

  1. POST /v1/conversations/{id}/attachments with { "size": attachmentCiphertextLength(bytes) } (size of the ciphertext, between 16 and 26,220,800) → 201 { "attachmentId", "uploadUrl", "method": "PUT", "headers": { … }, "expiresIn": 300 }
  2. Encrypt with that attachmentId (it goes in the AD of every chunk)
  3. PUT to uploadUrl with the returned method and exactly the returned headers (today Content-Type: application/octet-stream and x-amz-tagging: state=pending) and a body of exactly size bytes. It is a presigned S3 URL: no Authorization and no HMAC signature. Any difference → 403 from S3
  4. 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).

  1. Decrypt data.encryption like any message and parse/validate the meta
  2. Check that meta.attachmentId == data.attachment.attachmentId (otherwise, KEY_MISMATCH)
  3. GET /v1/conversations/{id}/attachments/{attachmentId} (signed) → { "downloadUrl", "size", "expiresIn": 300 }
  4. GET downloadUrl without 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 rotate uses from + 1
  • You paste the new public key and rotationSignature into the portal (Keys and API → Rotate)
  • The app pins your signingPublicKey on 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: exactly YYYY-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.key into 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_INVALID if it does not verify. The app verifies it with the signingPublicKey it 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 with POST /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:

KeyContentChecks
keys.{user,company,recoveryDevice}seed, publicKey, privateKeycrypto_box_seed_keypair(seed) yields exactly that pair (sk = SHA-512(seed)[0..32), pk = X25519(sk, 9)) · 3
keys.platformSigningEd25519 seed, publicKey(for companyKey)
conversationconversationId, companyId, keyVersion, key (K)shared context
messages[]name, senderType, clientMessageId, keyVersion, text, nonce, ad, paddedLength, ciphertext, envelopeyour 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?, expectErrordecrypting 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
companyKeyvalid platform signature + negative[]verifies / SIGNATURE_INVALID · 5
companyRotationsigningSeed, signingPublicKey, valid rotation + negative[] with expectError7
agentRostersigningPublicKey (the one in companyRotation), agentKeys.{agentA,agentB,agentC} (agentKeyId, seed, publicKey, privateKey), valid[] with statement and signatureyour 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
attachmentskey (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.fingerprintb64u fingerprint of companyKey.publicKey1
inviteBindinginviteSecret, conversationId, userPublicKey, wrapForCompany, tagexact 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.

PrimitivePHP (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 failurebox.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 IETFsodium_crypto_aead_xchacha20poly1305_ietf_{encrypt,decrypt}($m, $ad, $nonce, $k)chacha20poly1305.NewX(k) → Seal/OpencryptoAeadXChaCha20Poly1305Ietf{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 / signsodium_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 + comparisonhash_hmac('sha256', …) + hash_equalscrypto/hmac + hmac.Equaljavax.crypto.Mac("HmacSHA256") + MessageDigest.isEqualHMACSHA256 + CryptographicOperations.FixedTimeEqualsOpenSSL::HMAC.hexdigest("SHA256", …) + Rack::Utils.secure_compare / OpenSSL.fixed_length_secure_compare
Unpadded base64urlsodium_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 exampleYes, tested: downloadsYes, tested: downloadsNo official example yetNo official example yetNo official example yet

Notes by language:

  • PHP: the AEAD and seal_open functions return false on failure, but throw SodiumException on invalid lengths: treat both cases as DECRYPT_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/box provides SealAnonymous/OpenAnonymous, compatible with crypto_box_seal. Go's base64 decoder ignores \r and \n even in Strict mode: validate the alphabet first. crypto_box_seed_keypair does not exist as such: it is sk = 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 provides AeadAlgorithm.XChaCha20Poly1305, X25519, Ed25519 and BLAKE2b, but not the sealed box: combining them requires implementing crypto_box_seal_open by hand
  • Ruby: rbnacl needs libsodium installed on the system. SigningKey.new takes the 32-byte seed (the first 32 bytes of signing.key). VerifyKey#verify raises RbNaCl::BadSignatureError if it does not verify
  • Any other language: look for a libsodium binding in the official list and pass the 82 vectors

10. Common errors

SymptomUsual cause
401 INVALID_SIGNATURE on all your requestsYou 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_EXPIREDServer clock off by more than 300 s
All webhooks fail the signatureYou verify over re-serialized JSON, or use the secret without the whsec_ prefix
DECRYPT_FAILED on every messageWrong 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 endYou 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 replykeyVersion different from the wrap's, repeated clientMessageId (returns the previous message) or no padding (400 INVALID_ENVELOPE)
403 when uploading an attachmentThe exact headers from uploadUrl are missing or the body is not size bytes long
Id regex accepts cli_1\nPHP: 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:

FileWhat it is
v1.jsonThe 82 test vectors (§8.1). Test-only keys
run-vectors.jsThe exact list of vector checks, to port it
envelope.jsReference implementation (JavaScript): envelope, wraps, attachments, fingerprints and signatures
signing.jsonThe 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 Composer
  • index.php: webhook receiver in plain PHP
  • run-vectors.php: runs the vectors; it looks for v1.json in ../../../packages/crypto/vectors/ and signing.json in ../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 ./...):

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.