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
| Channel | Authenticated by | Integrity / replay | Notes |
|---|---|---|---|
Magento → Conviro events (/v1/api/magento/events) | Conviro API key (Bearer), scope magento:module (connect-minted) or kb:write | HMAC-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 key | TLS | Only from admin actions and cron; never from a storefront request. |
| Magento → Conviro connect exchange | one-time code (32 bytes, stored as SHA-256, 5 min, spent before it is checked) + PKCE S256 verifier + bound store URL and return URL | TLS; throttled 10 / min per client | The only unauthenticated Magento route on Conviro. |
| Conviro → Magento REST | OAuth 1.0a HMAC-SHA256 with the integration's four credentials (legacy: Bearer token) | Magento's nonce/timestamp checks | Least-privilege ACL (Permissions). |
Conviro → Magento webhooks (/conviro/webhook/receive) | HMAC-SHA256 t=,v1= with the receive secret, ±300 s | replay key per signature, 1 day | Tenant 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 it | Only 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 Postgres | The 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 call | Own ACL leaf Conviro_AiChat::api_customer_orders (Permissions). |
Secrets
| Secret | Where | Protection |
|---|---|---|
| Conviro API key | Magento core_config_data conviro_aichat/general/api_key | Magento 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 secrets | conviro_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 secrets | Magento's own oauth_consumer / oauth_token tables | Magento-managed; the module reads them only through Magento's OAuth service, never with SQL. |
| OAuth credentials and webhook secret at Conviro | tenant_integrations.credentials | Encrypted (encryptJsonSecrets); set to {} on disconnect. |
| Connect code | magento_connect_codes.code_hash | SHA-256 only; the PKCE verifier never leaves the Magento admin session. |
| Commerce e-mail hash key | Conviro server environment COMMERCE_EMAIL_HASH_KEY, else derived from the app secret | HMAC 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_hash | Never 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:
- 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/connectwith the S256 challenge, the admin callback URL, the store URL and versions. - The approval page validates state (16–128 base64url), challenge (43 chars;
plainrefused), andreturn_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, auditedmagento.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 leadingwww.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. - Approve stores one row (code hash, workspace, chatbot, approver, challenge, URLs, versions, 5-minute expiry) and redirects to
return_url?code=…&state=…. - The callback (a GET that needs the admin session) checks
statein 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) == challengein constant time, and the URL binding. Every refusal is400 invalid_grantand audited. - The minted key carries only the
magento:modulescope (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: itsregister-storefor 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_disconnectedotherwise — 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
- Inbound webhook payloads are never logged (
conviro_webhook_logkeeps event name, direction, HTTP status and an error code). Requests that fail the signature check are aggregated — one row per reason and minute with ahitscount — and the "no webhook secret configured" warning is written at most once a minute, so an unauthenticated flood adds neither table rows nor log lines per request. - Backend ingest does not log event payloads; SLI labels never contain a tenant id, store URL, SKU, order number or e-mail.
- The diagnostics bundle is built from allow-listed aggregates, then redacted (Diagnostics bundle).
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
- One Ed25519 key pair per Magento installation, generated by the module (
sodium_crypto_sign_keypair) the first time signed identity is switched on for a website. The secret key is stored encrypted (conviro_aichat/identity/signing_key); an installation id (UUID) is generated once (conviro_aichat/identity/installation_id). - The public key is registered with
POST /api/magento/identity-keysusing the module's API key (scopemagento:moduleonly, bound to the store URL like register-store).kid= base64url of the first 16 bytes of SHA-256(public key). Conviro refuses the key when another workspace holds an active integration for the same store URL (409 store_url_in_another_workspace), when the store is disconnected, or when the kid belongs to another key. - Nothing secret crosses the network, unlike the webhook secrets (which Conviro issues and both sides hold). Rotating one never touches the other, and neither is derived from the API key.
- Rotation: Rotate identity key (setup page, needs
Conviro_AiChat::setup) orbin/magento conviro:identity:rotate-keyregisters a new pair; Conviro keeps accepting the previous key for 300 seconds, then no more. Shoppers already verified with the old key keep that verification for its remaining life (at most 15 minutes). - Reset identity (Phase 5; hardened to the owner's decision of 2026-09-27, branch
feat/magento-1.5.0-reset, unit-tested; backend routePOST /api/magento/identity-keys/reseton the same branch, not deployed): the emergency rotation of this store's identity bridge only.- Who: an admin holding
Conviro_AiChat::identity_resetandConviro_AiChat::setup; roles saved before 1.5.0 get an explicit deny for the new resource on upgrade (no inheritance from Conviro Setup). - Confirmation: an impact page (conviro/identity/resetPreview) issues a confirmation valid 10 minutes — an HMAC over the admin user id, the installation id, the expiry and a random id, keyed with a secret derived from Magento's crypt key; neither id is in the token. The reset POST needs it, the form key and the typed word
RESET; it is spent by the attempt's audit row (a UNIQUE column), so a replayed or double-submitted POST is refused. - Scope: Conviro revokes the keys and principals and expires the open challenges of the ONE integration the module's API key registered (the repository call has no every-integration form). The OAuth connection, webhook secrets and endpoints, orders, the outbox, other integrations of the workspace and other workspaces are not touched; the module writes only identity settings, its binding table and its audit table.
- Never deleted before Conviro confirms: a resetRequestId and a
reset_pendingmark are stored before the call; while pending nothing signs (the bootstrap answersnot_available). Only a 2xx echoing that request id and the store's integration deletes the old key, revokes the live bindings (identity_reset, checked on every verified read) and registers a new pair. An ambiguous outcome keeps the old key unused and the reset pending; the retry reuses the id and Conviro applies an id once per integration (its audit row, written in the revocation's transaction, is the idempotency record) — a late duplicate never revokes the new key. A definitive refusal on the first attempt changes nothing; on a retry it keeps the reset pending. If the new key's registration fails nothing signs until Save Config registers one. - Audit: the module's
conviro_identity_reset_audit(admin id + username, UTC time, scope codes, request id, outcome, counts, 8-character key id prefixes; 365 days) and Conviro'smagento.identity.reset(request id,magento_admin:<id>, counts). No key, token or customer data in either.
- Who: an admin holding
- Revocation: a Disconnect, a store-URL change, an inactive integration and a 1.4.x registration of the same store revoke every key of the integration and every principal minted with them. Reconnecting does not bring old keys back.
- The admin card shows the key id (public) and its age; it warns at 180 days. The module's diagnostics bundle does not read the key's path (a unit test fails if a class that builds it does), carries only the first 8 characters of the key id, and its redactor knows every spelling of the key (unit-tested 2026-09-27; the same check on a real store,
diagnostics.sh, has not run — open items below).
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:
- 404 unless signed identity is on for the website and the widget renders in that store view; a POST is 404.
- 400 unless
X-Requested-With: XMLHttpRequest,Sec-Fetch-Siteabsent orsame-origin,Sec-Fetch-Destabsent orempty,Originabsent or the store view's secure base URL origin (its unsecure one only if that is https), and both parameters are 43 base64url characters. A<script>,<img>,<iframe>or another site'sfetchcannot use it; no CORS header is ever sent. - 401
not_logged_infor a guest; 429 after 10 signatures a minute for one Magento session. - The customer id comes from Magento's customer session and the store from
StoreManager— acustomerIdorstoreCodein the query is ignored. - Every answer, errors included:
Cache-Control: no-store, no-cache, must-revalidate, max-age=0, private,Pragma: no-cache,Expires: 0,Vary: Cookie,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer. Even if a CDN rule cached an answer anyway, it would be useless to anyone else: it is bound to one shopper's nonce and session hash and to the holder secret below, and the loader refuses an answer whose nonce is not the one it sent (bootstrap_cached). Behaviour behind a real CDN or Varnish has not been tested. - It is the only class of the module allowed to read the customer session; the cacheable widget block still carries no customer data (the 1.4.0 FPC gates, extended).
- The loader calls it only on a signed-in page and only after the shopper opens the chat — never on page load.
Holder proof, replay and the identity token
- Before the challenge the widget draws a random 32-byte holder secret in memory and sends only its SHA-256; the exchange must present the secret. An assertion or nonce that leaked through a log or proxy cannot be redeemed by someone who merely knows the chat id.
- A challenge lives 120 seconds by Conviro's clock and is spent by one conditional UPDATE in Postgres (nonce + session + holder, not yet spent, not expired;
jtiunique). Redis is never in that decision: without Redis, rate limits fall back to a per-process limiter and replay is still refused; without Postgres the exchange fails closed (the guest lookup still works). - Rate limits: challenge and exchange 10 per 5 minutes per chat and 60 a minute per IP; revoke 30 per 5 minutes per chat.
- The exchange returns a random 32-byte identity token; Conviro keeps only its SHA-256. Private reads need the widget token and this token, and the principal behind them is re-checked on every request (not expired or revoked, key not revoked, integration active and still announcing the feature, platform flag on) — nothing is cached between requests.
- A principal lives 15 minutes, absolute, at most 5 per chat (one per tab). A different customer in the same chat revokes the others in the same transaction (
account_switched).
Revocation — the time limit is never the only one
| Layer | Trigger | Effect |
|---|---|---|
| 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 bindings | customer_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 read | GET /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 |
| Conviro | platform flag off, key revoked, Disconnect, store-URL change, integration inactive | principals 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
- Read uncached, only through the principal's own integration row (never "the workspace's first Magento store"), 5-second timeout, at most 20 reads per 5 minutes per shopper and 120 a minute per store. A store error is shown as unavailable, never as an empty list.
- Order cards are never stored. A free-text answer about a verified shopper's orders is stored sealed (
pt1., AES-256-GCM, key derived from the server secret, bound to that customer's scope):chat_messages.content, the live inbox andmessage.newwebhooks get a placeholder; only the same customer's widget gets the text back. No key configured → the text is not kept at all. - A reply built from verified order data is never read from or written to the AI reply cache, and the debug prompt log keeps no history, reply or order block for it. The model sees earlier sealed answers only as their placeholder.
- Order and page pointers handed to the widget (
or1.,oc1.) are AES-256-GCM tokens bound to workspace, integration, chat, customer and store view for one hour — pointers, not permissions: every use re-runs both checks above. - Verified order reads never reach WhatsApp, Instagram or API channels; a shopper who moves to another channel is not verified there.
- The staff handoff context is the selected order only, masked (no item names, tracking numbers or customer data), shown only on the session detail, kept 30 days.
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
- The security review of 1.4.0 (2026-09-25): the backend, widget, web and AI action findings are fixed on
feat/magento-10-10; the module PHP findings are the next slice (SF-2). State per finding: the plan's Status block. - Legacy per-event endpoints (≤ 1.3.x modules) accept unsigned requests unless Require Signed Webhooks = Yes, and their signature has no timestamp; the legacy bearer shim is still registered. Both wait for plan open decision 6. A 1.4.0 store has Require Signed Webhooks = Yes by default (the upgrade turns a saved No into Yes once), and tells Conviro so when it registers.
- Event types that are idempotent by key (catalog, inventory, customer updates, order status) still use the Redis gate, which fails open without Redis; the side-effecting types use a database receipt.
- The order-lookup brute-force counters are kept in Redis and in Postgres (
commerce_order_lookup_failures, migration 0230,e3bbbe72, 2026-09-25): with Redis down the Postgres copy still counts and still locks. Only with both stores failing is the guard open (the checkout e-mail is still required, and a write still needs the e-mailed code or staff approval). (Corrected 2026-09-27: this line said "Redis only" after 0230 had shipped.) - Order-action writes are refused unless the merchant grants the ACL and the action policy allows them; RMA / B2B writes on Adobe Commerce are untested.
- 1.5.0 signed identity (unreleased, 2026-09-27): run end to end on Magento 2.4.7-p4, 2.4.7-p9 (also on the Hyvä theme) and 2.4.8-p5 against the real Conviro API and a real browser, behind the built-in page cache and behind Varnish 7.5; not behind a CDN, not on Hyvä Checkout. The Postgres specs (schema; two concurrent exchanges → exactly one success) pass on PostgreSQL 16. The signing key never reaching the diagnostics bundle is proven on a real store (
diagnostics.sh). The retention sweep deletes spent challenges and principals 7 days after expiry (tested against Postgres). When a workspace holds a chatbot-specific and a workspace-wide Magento row for the same store, the key and the exchange can pick different rows and the bridge fails closed for those chatbots (open risk R-21). The widget's/initstill re-issues a session token to anyone who knows a chat or visitor id; 1.5.0 does not rely on it alone (holder secret + identity token), and fixing it is a separate task.