Security model

What protects each channel between a Magento store and Conviro, what is stored where, and what is still open. Read against d3472ce2 (2026-09-24); updated 2026-09-25 for the security review of 1.4.0 (release gate R3, snapshot a7087fed, four areas: API / Magento backend, attribution / widget / routes, module PHP, AI actions / trust / flows). Its findings and their state are in the plan's Status block (docs/plans/CONVIRO_MAGENTO_10_10_PLAN.md); the items below are what the code does and what the local test stack measured. The section Signed customer identity describes 1.5.0 (unreleased), read on 2026-09-27 from branch feat/magento-1.5.0-customer-hub and its module / UI branches; it is covered by unit tests and, on Magento 2.4.7-p4, by end-to-end runs against the real Conviro API (Supported versions).

Trust boundaries

ChannelAuthenticated byIntegrity / replayNotes
Magento → Conviro events (/v1/api/magento/events)Conviro API key (Bearer), scope magento:module (connect-minted) or kb:writeHMAC-SHA256 v1 over timestamp.body with the event secret, ±300 s; event-id idempotency 7 days (a database receipt for side-effecting types)Signature always required on this endpoint.
Magento → Conviro admin calls (tenant-info, register-store, …)Conviro API keyTLSOnly from admin actions and cron; never from a storefront request.
Magento → Conviro connect exchangeone-time code (32 bytes, stored as SHA-256, 5 min, spent before it is checked) + PKCE S256 verifier + bound store URL and return URLTLS; throttled 10 / min per clientThe only unauthenticated Magento route on Conviro.
Conviro → Magento RESTOAuth 1.0a HMAC-SHA256 with the integration's four credentials (legacy: Bearer token)Magento's nonce/timestamp checksLeast-privilege ACL (Permissions).
Conviro → Magento webhooks (/conviro/webhook/receive)HMAC-SHA256 t=,v1= with the receive secret, ±300 sreplay key per signature, 1 dayTenant id checked; unknown events refused.
Browser → Conviro (widget)widget public key—See Headless / PWA and Privacy.
Browser → Magento identity bootstrap (GET /conviro/identity/bootstrap, 1.5.0)Magento's own storefront session (same origin)answer never cached; single-use nonce and session hash signed into itOnly the module's loader calls it; refused unless XHR, same-origin fetch metadata and the store's own Origin (below).
Browser → Conviro identity routes (/v1/widget/identity/challenge, /exchange, /revoke, 1.5.0)widget session token plus the holder secret (exchange)Ed25519 signature by the store's key; challenge spent once in PostgresThe identity token the exchange returns is required, with the widget token, on every private read.
Conviro → Magento verified order reads (GET /V1/conviro/identity/bindings/:bindingId/orders[/:orderId], 1.5.0)OAuth 1.0a of the pinned integration (never the legacy bearer)Magento re-checks the binding on every callOwn ACL leaf Conviro_AiChat::api_customer_orders (Permissions).

Secrets

SecretWhereProtection
Conviro API keyMagento core_config_data conviro_aichat/general/api_keyMagento Encrypted backend model (encrypted with the store's crypt/key). Never rendered back (obscure field). After a disconnect that Conviro could not be told about, the key is kept encrypted in the flag table only for the cron retry, and dropped when the retry ends or the store reconnects.
Signing secretsconviro_aichat/general/event_secret + receive_secret (one per direction, 1.4.0), or the shared webhook_secret (≤ 1.3.x / older backend)Encrypted; written by the module, never shown; deleted on Disconnect. Conviro rotates them when another store URL or API key registers — see Architecture (link corrected 2026-09-27: it pointed at a heading that does not exist).
OAuth consumer / token secretsMagento's own oauth_consumer / oauth_token tablesMagento-managed; the module reads them only through Magento's OAuth service, never with SQL.
OAuth credentials and webhook secret at Convirotenant_integrations.credentialsEncrypted (encryptJsonSecrets); set to {} on disconnect.
Connect codemagento_connect_codes.code_hashSHA-256 only; the PKCE verifier never leaves the Magento admin session.
Commerce e-mail hash keyConviro server environment COMMERCE_EMAIL_HASH_KEY, else derived from the app secretHMAC key; a per-row key id shows a rotation.
Identity signing key (Ed25519 secret key, 1.5.0)Magento core_config_data conviro_aichat/identity/signing_key (default scope)Encrypted with the store's crypt/key; created only when Enable signed customer identity is switched on for a website; never a form field, never rendered, never sent anywhere. Separate from the webhook secrets and the API key: its own path, algorithm and lifecycle.
Identity public keys at Conviro (1.5.0)widget_identity_keys (public key, kid, installation id, store URL, state)Public by nature: a database or backup leak at Conviro cannot sign an identity.
Identity token (1.5.0)the widget's memory only; at Conviro its SHA-256 in widget_identity_principals.token_hashNever in a URL, cookie, local / session storage or analytics; the x-widget-identity header is redacted from Conviro's request logs.

Browser-mediated connect

AiChat/Service/Setup/ConnectHandshake.php and api/magento/magento-connect.service.ts:

  1. Magento keeps state (24 random bytes) and a PKCE verifier (48 random bytes) in the admin session for 10 minutes and sends the browser to {app_url}/dashboard/integrations/magento/connect with the S256 challenge, the admin callback URL, the store URL and versions.
  2. The approval page validates state (16–128 base64url), challenge (43 chars; plain refused), and return_url / store_url (https — http only for loopback — no user info, no fragment) before showing anything. It shows the store host and, prominently, the host the browser will return to. A return host that is not the store's host or a subdomain of it is refused — on the page (Approve disabled, no "continue anyway") and by the API (400 return_host_mismatch, audited magento.connect.refused, no code stored) — so a connect link crafted by someone else cannot send a code to their server (security review, release gate R3). A leading www. of the store host is ignored; without a public-suffix list, sibling subdomains (shop.example.com → admin.example.com) are refused: open the wizard on the store's host or a subdomain of it. It never redirects on Cancel. Owners/admins only; throttled 20 / min.
  3. Approve stores one row (code hash, workspace, chatbot, approver, challenge, URLs, versions, 5-minute expiry) and redirects to return_url?code=…&state=….
  4. The callback (a GET that needs the admin session) checks state in constant time and spends the handshake whatever happens next, then exchanges {code, codeVerifier, storeUrl, returnUrl} server to server. Conviro spends the code first with one conditional UPDATE (a race cannot use it twice; a wrong verifier burns it), then checks expiry, S256(verifier) == challenge in constant time, and the URL binding. Every refusal is 400 invalid_grant and audited.
  5. The minted key carries only the magento:module scope (exactly the routes the module calls — it cannot list or read conversations through the public API or write knowledge-base items), is limited to the chatbot chosen on the approval page, and is bound to the approved store URL: its register-store for any other store is refused (403 key_bound_to_other_store). Every earlier key the connect flow minted for the workspace expires within 24 hours. A disconnected store is registered again only with a key created after the disconnect (409 store_disconnected otherwise — no silent reconnect). A disconnect from the dashboard revokes the connect-minted keys.

Measured on the local stack (setup-wizard.sh, against the mock): a replayed callback, a state mismatch, a reused code and a wrong verifier are all refused and the stored key is unchanged.

Store URL guard (SSRF)

Every credentialed call from Conviro to a store goes through checkMagentoStoreUrl (api/magento/magento-http.ts): static allow-list, https only, the host must resolve to public addresses, and a DNS error refuses the call. A refusal says why (cleartext → set the secure Base URL; unresolved; private / invalid). The documented TLS-terminator case still works: the transport is https and only the OAuth signature is computed over http.

Since the security review (2026-09-25): addresses are classified with Node's net.BlockList — every private, loopback, link-local (all of fe80::/10), site-local, ULA, CGNAT, benchmarking, documentation, multicast and reserved range, and the IPv4-mapped / compatible / NAT64 IPv6 forms; the IPv6 rules apply to IP literals and resolved addresses only, never to host names. And the request itself goes through a pinned connection: the socket connects only to an address the guard classified at connect time, so a DNS answer that changes after the check (rebinding) cannot reach an internal address.

Full-page cache privacy

The widget block is on every page and is cacheable, and Magento's full-page cache varies on customer group, not customer. So the template never renders customer identity server-side: the logged-in customer is resolved in the browser — the name from Magento's customer section, the id and e-mail from the module's own customer-data section conviro-customer, read from mage-cache-storage (or, when Magento has not stored them yet, once from the store's customer/section/load). The cached page only says whether it is the guest or the logged-in variant (the same HTTP context Magento's page cache and its own Sign In / Sign Out link vary on), so a guest's page makes no identity request at all; /rest/V1/customers/me is not called any more (1.4.0, security review R3 M6). 1.3.4 and earlier rendered name, e-mail and order history into cached HTML — which is why the backend's security minimum is 1.3.5. Gates: Test/Unit/Block/WidgetPrivacyTest.php and dev/tests/fpc-privacy.sh (two logged-in customers, one FPC context, on the home, a product and a category page, the second visitor's page served from the full-page cache), both failing on the 1.3.4 code.

No inline script, no nonce (1.4.0, security review R3 M9). Through 1.4.0's pre-releases the widget loader was an inline script carrying a CSP nonce — but the block is full-page cached, so the nonce was generated once and frozen into every cached copy of the page (and a nonce in the header makes browsers ignore 'unsafe-inline' for the store's other inline scripts). The loader is now a static file (view/frontend/web/js/widget-loader.js, allowed by script-src 'self'), its settings a <script type="application/json"> data block (never executed; values JSON-encoded with JSON_HEX_*). Magento's storefront CSP is report-only by default; the Conviro hosts are in etc/csp_whitelist.xml. The admin cards (not cached) keep a per-response nonce.

Storefront and checkout isolation

Observers only INSERT into the outbox; no storefront, checkout or admin request waits on Conviro. Measured: with Conviro down a real order is placed (264 ms) and its events are delivered later, signed and once (outbox-e2e.sh).

Logging and diagnostics

Admin surface

All admin actions are POST with Magento's form key (the diagnostics download is GET and needs an admin session), behind Conviro_AiChat::config or Conviro_AiChat::setup. Measured: a GET to a POST controller runs nothing; the diagnostics download is not served without an admin session.

Version security floor

Conviro publishes minimumSecure (currently 1.3.5) at /api/magento/module-version. Below it the admin shows SECURITY UPDATE REQUIRED (critical admin notice, Integration Health Action required).

Signed customer identity (1.5.0, unreleased)

What protects the feature on Signed customer identity and My orders. Design record: docs/adr/ADR-IDENTITY-001-magento-verified-customer-identity.md (threat table: ADR-IDENTITY-001-threat-model.md). Everything is off until both the store and Conviro switch it on.

Why the storefront's customer data is not trusted

The widget greets a signed-in shopper with the first name from Magento's customer section and passes the section's id and e-mail to Conviro (1.4.0 behaviour, Full-page cache privacy). That data is supplied by the browser and unsigned, and the widget's session token proves only that the caller knows a chat id. So none of it authorises anything: a verified shopper exists at Conviro only as a principal row created by a successful exchange of a signature your store made.

Identity keys — separate from webhook secrets

The assertion

Compact JWS, header exactly {"alg":"EdDSA","typ":"conviro-id+jwt","kid":…} — any other algorithm (none, HS*, RS*, ES*) or key-carrying header (jku, jwk, x5u, x5c, crit, b64, zip) is refused; at most 2048 bytes; duplicate JSON keys refused; verified with Node's crypto directly. Claims: issuer (magento:<installation id>), audience, the Magento customer id (sub, digits only), workspace, integration, website / store view id and code, the widget-session hash, the nonce, a binding id, a random jti, and iat / nbf / exp with 60 seconds of life (30 seconds of clock leeway). No name, e-mail, group, address, session id or token. Conviro checks, in order: widget token, Origin, rate limit, size and syntax, header, the key (looked up only inside the widget's workspace), signature, audience / issuer / version, workspace = key = chatbot, integration = key = the chatbot's integration (active, announced by a ≥ 1.5.0 module, merchant switch on), the store view against the integration's store mapping, the time window, the session hash, then spends the challenge. A refusal tells the browser only expired, invalid, replayed or not_available; the exact check goes to a counter without personal data.

The bootstrap request

GET /conviro/identity/bootstrap?n=<nonce>&wsh=<session hash> (Controller/Identity/Bootstrap.php) is a JSON controller, not a page:

Holder proof, replay and the identity token

Revocation — the time limit is never the only one

LayerTriggerEffect
Browser (loader + widget)the storefront's customer section empties or changes (sign-out, another customer, another tab, back / forward cache)the widget drops the token and every order card first, then calls POST /v1/widget/identity/revoke: all principals of that chat revoked
Magento bindingscustomer_logout (the session's bindings); customer_delete_after (all of the customer's — Phase 5: then every row of the customer is deleted, after the event is queued) and a password change / reset (all of the customer's); a signature for another customer on the same session (the previous customer's)binding rows revoked; the signed event identity.bindings_revoked (Event reference) makes Conviro revoke the principals minted with them — within about a minute (the delivery cron)
Every order readGET /V1/conviro/identity/bindings/:bindingId/orders…Magento re-checks the binding (exists, not revoked, not expired — 16 minutes —, same customer, same store view, customer not locked, switches on) and answers 403 otherwise; Conviro then revokes the principal and the widget shows signed out / connection expired
Conviroplatform flag off, key revoked, Disconnect, store-URL change, integration inactiveprincipals refused by the per-request check, and revoked

A Magento session that simply times out emits no event: that case is bounded by the 16-minute binding and the 15-minute principal.

Verified order data at Conviro

What this does not protect against

Script injected into your storefront (XSS) already controls the shopper's Magento session and shares the widget's page, so it could ask for a signature and read that shopper's orders — the same as stealing their Magento session. The bridge adds no new power to it: read-only, own orders only, 15 minutes, no personal data in the assertion, no Magento credential in the browser. Keep your storefront's Content Security Policy tight.

Known open items