diff --git a/.traceweaver/account-pairing-20261028/authority.json b/.traceweaver/account-pairing-20261028/authority.json new file mode 100644 index 0000000..36a2efd --- /dev/null +++ b/.traceweaver/account-pairing-20261028/authority.json @@ -0,0 +1,86 @@ +{ + "baseline_id": "PAIR-20261028-01", + "baseline_hash_sha256": "9b80ccd96837cae1dd63723fa10ce8390dc4305fcc9e65f303a4868a917dc922", + "owner_approval": "Approve. We should not rely on vpn access to link an deployment and cryptolabs.co.za user account", + "approved_scope": "Minimum shared outbound account pairing, existing WordPress plugin plus standalone IPMI/local DC controller adapters; existing credentials and policies preserved.", + "scope_quality": "Astra requirements-review and root two-clarification receipt retained by parent; no new owner decision", + "protocol_file": "contract.json", + "protocol_sha256": "abd8b95925c92e7a70f6bdd5f0f7d2bcb2e5cbdcf822699397c5e130d6b3aa70", + "approved_requirements": [ + { + "id": "REQ-PAIR-001", + "statement": "A deployment administrator shall be able to link IPMI Monitor or the DC Overview/Watchdog controller to an existing or newly created CryptoLabs account using outbound HTTPS to the existing public CryptoLabs WordPress service for account pairing. Existing product service validation may use its existing approved HTTPS endpoint. Provider VPN access, an inbound deployment callback and per-deployment hostname admission shall not be prerequisites.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-001", + "verification_id": "VER-PAIR-001", + "validation_id": "VAL-PAIR-001" + }, + { + "id": "REQ-PAIR-002", + "statement": "The public linking flow shall require signed-in account consent through a nonce-protected POST for the matching displayed one-time user code. Opening a link, signing up or presenting a payment-success parameter alone shall not authorize a deployment.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-002", + "verification_id": "VER-PAIR-002", + "validation_id": "VAL-PAIR-002" + }, + { + "id": "REQ-PAIR-003", + "statement": "Pending linking attempts shall expire after 300 seconds, enforce bounded issuance, approval guesses and polling, and permit only one successful redemption through atomic persistent transitions across workers. Secret device codes shall remain backend-only and shall not be stored in plaintext by WordPress.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-003", + "verification_id": "VER-PAIR-003", + "validation_id": "VAL-PAIR-003" + }, + { + "id": "REQ-PAIR-004", + "statement": "A deployment shall accept a result only for the initiating current local administrator and session. It shall stage and verify the authoritative result before an intentional commit, consume the local attempt once, and preserve an existing working link on failure, denial, cancellation, expiry, session change or account mismatch. A first link may save verified WordPress identity with no paid grant when service validation fails; an existing different working credential/configuration shall not be replaced by a candidate that fails service validation. A same-key re-link shall not newly assert paid readiness on validation failure.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-004", + "verification_id": "VER-PAIR-004", + "validation_id": "VAL-PAIR-004" + }, + { + "id": "REQ-PAIR-005", + "statement": "The new flow shall deliver only the existing product account credential and allowlisted authoritative identity/status fields over a backend HTTPS response. It shall preserve current service entitlement/quota decisions, worker-scoped credentials and local administration/update authority. Pairing shall not grant a new paid entitlement, change retention/outage policies, or give the provider host access.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-005", + "verification_id": "VER-PAIR-005", + "validation_id": "VAL-PAIR-005" + }, + { + "id": "REQ-PAIR-006", + "statement": "The implementation shall preserve existing working links and legacy clients, keep the current pinned WordPress package/publisher boundary, and deliver installed product updates through accepted DC Overview Fleet Management. Deployment and paid-service acceptance shall remain held until observed in the intended environment.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-006", + "verification_id": "VER-PAIR-006", + "validation_id": "VAL-PAIR-006" + } + ], + "implementation_paths": [ + "src/cryptolabs_proxy/auth.py", + "src/cryptolabs_proxy/account_link.py" + ], + "verification_policy": "Lean TDD, reviewed preproduction RED, actual PHP emitter/Flask/SQL boundaries and required CI. Local GREEN does not prove installed acceptance.", + "validation_question": "Can an owner link a local deployment through public signup/login and explicit code consent without provider VPN, inbound callback or manual domain registration, while keeping existing links/workers/paid decisions safe?", + "publication_boundaries": { + "feature_base": "dev", + "feature_pr_target": "dev", + "release_target": "main", + "central_delivery": "existing pinned WordPress publisher", + "installed_delivery": "accepted DC Overview Fleet only" + }, + "must_not_change": [ + "Existing paid plans, grace/retention, worker tokens, local administrator/Fleet authority", + "Dirty primary checkouts and unrelated worktrees", + "Build pool authority, publisher secrets and privileged bootstrap" + ], + "held_claims": [ + "Source changes verified locally and independently reviewed; protected CI required before merge", + "Actual public signup/email verification and installed no-VPN account-link acceptance remain held", + "Installed delivery requires accepted Fleet channel; backend AI inference and actual payment acceptance remain separately held" + ], + "protocol_freeze": { + "sha256": "abd8b95925c92e7a70f6bdd5f0f7d2bcb2e5cbdcf822699397c5e130d6b3aa70", + "status": "Frozen for current implementation; contract original-status label is historical. Existing-key-only selected under optional MAY issuance; wire unchanged." + } +} diff --git a/.traceweaver/account-pairing-20261028/contract.json b/.traceweaver/account-pairing-20261028/contract.json new file mode 100644 index 0000000..507d4fe --- /dev/null +++ b/.traceweaver/account-pairing-20261028/contract.json @@ -0,0 +1,334 @@ +{ + "schema": "cryptolabs-pairing-contract/1", + "status": "ARCHITECTURE_CONTRACT_FOR_APPROVED_SCOPE_ROOT_FREEZE_REQUIRED", + "baseline": "PAIR-20261028-01", + "date": "2026-10-28", + "requirement_ids": [ + "REQ-PAIR-001", + "REQ-PAIR-002", + "REQ-PAIR-003", + "REQ-PAIR-004", + "REQ-PAIR-005", + "REQ-PAIR-006" + ], + "constants": { + "protocol_version": 1, + "ttl_seconds": 300, + "poll_interval_seconds": 5, + "device_code_random_bytes": 32, + "user_code_random_bits": 40, + "attempt_slots": 512, + "slot_allocation_attempts": 32, + "cas_retries": 3, + "max_json_bytes": 2048, + "max_label_chars": 80, + "client_ids": [ + "cryptolabs-ipmi-monitor", + "cryptolabs-dc-controller" + ], + "user_code_alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + }, + "transport": { + "origin": "https://www.cryptolabs.co.za", + "rest_prefix": "/wp-json/cryptolabs/v1", + "verification_uri": "https://www.cryptolabs.co.za/?clai_pair=1", + "rules": [ + "HTTPS certificate verification required; fixed origin/endpoints, no client-supplied URL and no credential-bearing redirect following.", + "Cache-Control: no-store; Referrer-Policy: no-referrer on all pairing replies/pages. No third-party assets on verification/consent page.", + "Never put device codes/account keys in URLs, browser JSON/HTML/storage, postMessage, logs or exception text. Only start/status backend responses may carry the stated secrets.", + "No permissive CORS/browser token exchange. Reject malformed/extra credential fields, duplicate parameters, arrays for scalars and unsupported client IDs.", + "Do not treat public client_id, installation_id or display label as verified software/domain identity." + ] + }, + "endpoints": { + "start": { + "method": "POST", + "path": "/pairing/start", + "auth": "Anonymous backend request, issuance limits mandatory; not account authorization.", + "body": { + "client_id": "cryptolabs-ipmi-monitor", + "installation_id": "client-persisted random UUIDv4", + "installation_name": "My monitor" + }, + "response_status": 201, + "response": { + "protocol_version": 1, + "device_code": "<43-character base64url 32 random bytes; BACKEND ONLY>", + "user_code": "", + "verification_uri": "https://www.cryptolabs.co.za/?clai_pair=1", + "expires_in": 300, + "interval": 5 + }, + "notes": [ + "Create only a pending record; no account key or user is looked up.", + "Do not accept redirect_uri, callback, arbitrary scope, account_id or requested credential type." + ] + }, + "status": { + "method": "POST", + "path": "/pairing/status", + "auth": "Authorization: Bearer ; never cookie authentication as substitute.", + "body": { + "client_id": "cryptolabs-ipmi-monitor", + "user_code": "XX-XXXX-XXXX" + }, + "pending": { + "http": 400, + "body": { + "error": "authorization_pending", + "interval": 5 + } + }, + "success": { + "http": 200, + "body": { + "protocol_version": 1, + "status": "complete", + "client_id": "cryptolabs-ipmi-monitor", + "installation_id": "", + "account": { + "id": 123, + "email": "customer@example.test", + "display_name": "Customer" + }, + "credential": { + "type": "legacy_ipmi_api_key", + "api_key": "", + "scope": "account_wide" + }, + "service_intent": "ipmi", + "authorization": "existing_service_validation_required" + } + }, + "notes": [ + "For cryptolabs-dc-controller service_intent is watchdog; Overview alerts are not enabled. service_intent is metadata, not an enforceable key scope.", + "Return only these fields. Never return JWT/admin flags, provider SSO/signing keys, litellm_api_key, dc_overview_instances or serialized validation/account objects.", + "The account id/email derive from approved WP user and current server data; never caller fields. Recheck user existence/email verification before delivery.", + "A valid identity/key is not a new paid entitlement. Current service validation remains authoritative." + ] + }, + "consent_surface": { + "method": "GET", + "path": "/?clai_pair=1", + "behavior": "Render fixed verification surface; require login for code lookup/consent. GET never approves, denies or redeems. Login return is the fixed same-origin surface with no secret/code query. User enters visible code; existing signup pages open separately without a deployment callback. After signup/verification, return to surface and re-enter code; expired attempt restarts from local app.", + "lookup": "Nonce-protected same-origin POST to fixed surface, action=review, user_code. Match full code HMAC, not slot alone. Show escaped unverified installation label, canonical product, chosen account, exact matching code and account-wide credential warning." + }, + "approve": { + "method": "POST", + "path": "/pairing/approve", + "auth": "Current logged-in WP cookie + valid wp_rest nonce + independent consent nonce bound to full user-code digest and current WP user. No anonymous or application-password consent.", + "body": { + "user_code": "XX-XXXX-XXXX", + "decision": "approve", + "consent_nonce": "" + }, + "response": { + "http": 200, + "body": { + "status": "approved" + } + }, + "notes": [ + "decision is approve or deny; deny returns status denied and terminalizes attempt.", + "Only pending -> approved/denied CAS. Reject missing/invalid nonce, user mismatch, unverified email, unknown code, expiration or prior decision. GET and account signup cannot approve.", + "Ensure existing product credential can be reused through existing issuer; no rotation, trial start, subscription mutation or account creation from this endpoint.", + "A logged-in user who enters an unsolicited attacker code can still approve attacker hardware: show explicit context; not phishing resistant." + ] + } + }, + "errors": { + "invalid_request": { + "http": 400, + "meaning": "Malformed bounded input; no mutation/credential." + }, + "invalid_grant": { + "http": 400, + "meaning": "Unknown/mismatched code/client/secret, already consumed or superseded slot; generic no account disclosure." + }, + "expired_token": { + "http": 400, + "meaning": "Matched legitimate attempt expired; no credential." + }, + "authorization_pending": { + "http": 400, + "meaning": "Valid backend request, not yet approved." + }, + "slow_down": { + "http": 400, + "meaning": "Poll too early; increase interval by 5 seconds for remaining attempt; atomic next_poll update." + }, + "access_denied": { + "http": 400, + "meaning": "Explicit denial; local attempt ends." + }, + "authentication_required": { + "http": 401, + "meaning": "Consent needs authenticated website session." + }, + "invalid_consent": { + "http": 403, + "meaning": "Cookie/nonce/user/code consent invalid." + }, + "email_verification_required": { + "http": 403, + "meaning": "Use existing verification flow; no email policy bypass." + }, + "account_setup_required": { + "http": 409, + "meaning": "Existing issuer/provisioning cannot supply supported credential; direct user to existing signup/account page, no arbitrary creation policy." + }, + "temporarily_unavailable": { + "http": 503, + "meaning": "Persistence/query/issuer failure or exhausted CAS; preserve old link, never deliver credential on uncertain consumption." + }, + "rate_limited": { + "http": 429, + "meaning": "Retry-After provided; capacity or rate limiter exhausted." + } + }, + "storage": { + "table": "Existing wp_options only; no new runtime file/table/billing migration.", + "namespace": "clai_pair_v1_", + "attempt_key": "clai_pair_v1_a_", + "row_limit": 512, + "autoload": "no", + "value": { + "expires_at": "integer UTC epoch from DB time", + "version": 1, + "revision": "monotonic integer each transition", + "state": "pending|approved|denied|consumed", + "user_code_digest": "HMAC-SHA256(full normalized user code, domain-separated wp_salt('auth'))", + "device_digest": "SHA256(device_code)", + "client_id": "fixed enum", + "installation_id": "UUIDv4", + "installation_name": "bounded plain text", + "approved_user_id": "null until approval", + "poll_interval": 5, + "next_poll_at": "epoch" + }, + "reads": "Read private namespace with prepared direct SQL every time. Never get_option/get_transient/object cache for attempt or limiter authority. No secrets in these rows. Domain-separated existing WP salt, no new external secret store.", + "allocation": "Choose random slot; INSERT IGNORE nonautoload if absent, otherwise select expired row and atomically replace with exact-old-value CAS. Retry at most32 slots, then429. Reject full normalized code on digest mismatch even if same slot. Old device secrets cannot redeem a reused slot.", + "cas_sql": "UPDATE SET option_value=%s WHERE option_name=%s AND BINARY option_value=BINARY %s", + "expiry": "Every approval/poll/consume CAS additionally checks captured expires_at > database UNIX_TIMESTAMP(); allocation replacement checks captured expiry <= DB time. Never extend 300s TTL on review/login/poll.", + "transition": "pending->approved/denied; approved->consumed; pending->pending only to update poll timing. Full exact-value CAS plus revision prevents concurrent overwrite; affected_rows must equal1. No successful replay after consumed. Never read-then-delete via transients.", + "delivery": "Resolve/validate existing credential and user server-side, then win approved->consumed CAS immediately before constructing secret response. Only winner may output. Lost response/crash after consume yields no replay: restart new pairing. Database errors/reconnect-uncertain results fail closed; never infer success from a reread of consumed.", + "cleanup": "Fixed512 slots cap pending+terminal storage. Reuse expired slots by CAS; optional bounded32-row expiry cleanup never deletes a changed/live row. No full-table scan or unbounded request-time cleanup.", + "limiter": "Fixed nonautoload rows with same direct SQL CAS, keyed by route + bucket slot. Global minute bucket per route;256 hashed caller buckets per route keep cardinality fixed. Use only REMOTE_ADDR (no X-Forwarded-For/CF headers) for anonymous start/status; authenticated WP user ID for approval. HMAC caller then map to256; collisions share a limit (safe false throttling). DB epoch minute resets count via CAS; 3 retries then503. Start global60/min and caller6/min; status global600/min and caller120/min plus per-attempt 5s; consent/review global300/min and caller30/min. Salt-derived hashing hides addresses. Reverse-proxy peers may share quota: no runtime trust-header change in this increment.", + "dependencies": "WordPress options unique option_name index and atomic single-statement SQL semantics. Real MariaDB multi-connection test must prove CAS, expiration, allocation and limiter limits. No transaction session/GET_LOCK dependency." + }, + "credential_source": { + "wp_class": "CLAI_IPMI_SSO in existing includes/class-ipmi-sso.php", + "reuse": [ + "get_or_create_ipmi_api_key($user_id) for existing legacy credential semantics", + "is_user_email_verified($user_id) for existing verification policy", + "get_userdata($user_id) for authoritative identity" + ], + "narrow_fix": "clai_get_account() delegates to CLAI_Database::get_account() and returns an object, while existing helper indexes an array. Normalize object/array only in existing key lookup, handle absent api_key safely, and test existing meta key, null, object and array paths. Never reinterpret encrypted litellm_key as api_key.", + "issuance": "Prefer existing clai_ipmi_api_key. Ordinary signup already provisions it. Existing helper may issue its normal first key if absent; preserve that behavior only. Never call generate_api_key (rotation), start trial or payment handlers from pairing. Do not add a new credential type/lifetime/refresh policy.", + "account_key_limit": "Current key is account-wide and shared across products. Visible consent must say so. Local disconnect clears local link only, not central/per-install revocation." + }, + "local_adapter": { + "routes": { + "start": "POST /api/account-link/start", + "status": "POST /api/account-link/status", + "commit": "POST /api/account-link/commit", + "cancel": "POST /api/account-link/cancel", + "config": "GET /api/account-link/config" + }, + "authentication": "All local endpoints require current live administrator and existing CSRF/origin protections. Do not accept proxy/client supplied admin identity without existing verified trust boundary.", + "start": "Create backend-persisted random local attempt_id; bind initiating local user, hash of session binding, installation_id, client_id, monotonic generation/current local configuration fingerprint and expiry <=central300s. Keep device secret only in protected server storage; never Flask signed cookie. Browser receives attempt_id,user_code,verification_uri,expires_in,interval only. Start/new attempt supersedes old local attempt atomically.", + "status": "Browser sends attempt_id only; backend reads binding and checks current user/session/admin/expiry then polls central service. Browser cannot send device_code,api_key,account identity or service URL. One local worker claims poll operation by CAS before network; completion CAS must recheck original generation/session/config. Cancel/logout/supersede wins over any late response.", + "staging": "Central success becomes protected backend stage after validation; browser receives only account id/email/display_name, service availability and requires_confirmation. Preserve old credentials/config until explicit local commit for replacement. Clear staged secrets on expiry/cancel/session loss.", + "commit": "Browser POST attempt_id + confirm=true only. Recheck live admin/session, exact attempt generation, unchanged current link/config fingerprint, expiry and staged validation; atomic one-use local transition+configuration transaction. If filesystem-based proxy storage cannot transact multiple files, serialize existing atomic writer with compare-current-state and failure-safe replacement; test crashes and concurrent cancel/commit. No key/browser account hints accepted.", + "cancel": "CAS terminalize current bound local attempt; wipe pending/staged secret; preserve saved valid link. Central attempt expires naturally; no new revoke semantics.", + "success": "Show connected account identity, not passwordless local login; preserve sync choices and worker tokens. Initial successful enablement follows existing explicitly approved product setting semantics; do not silently re-enable disabled sync.", + "csrf": "GET /api/account-link/config requires current live administrator and returns only {csrf_token:<32 random bytes base64url>}, Cache-Control:no-store. Reuse per-session pair_csrf; rotate on session/login identity reset. It may live in the existing signed session cookie because it is a browser CSRF nonce, not a device/account credential. Every local POST requires X-CSRF-Token with constant-time match. If Origin is present it must exactly match the accepted local serving origin including scheme/port; reject Sec-Fetch-Site:cross-site and disallow cross-origin config reads/CORS. Missing Origin does not bypass the mandatory CSRF token. config creates no attempt and returns no device/account key. Existing proxy protections may back this exact API.", + "browser_contract": { + "start_request": {}, + "start_response": { + "status": "pending", + "attempt_id": "", + "user_code": "XX-XXXX-XXXX", + "verification_uri": "https://www.cryptolabs.co.za/?clai_pair=1", + "expires_in": 300, + "interval": 5 + }, + "status_request": { + "attempt_id": "" + }, + "pending_response": { + "status": "pending", + "interval": 5 + }, + "staged_response": { + "status": "awaiting_confirmation", + "account": { + "id": 123, + "email": "customer@example.test", + "display_name": "Customer" + }, + "service_status": "available|unavailable|not_entitled|unknown", + "requires_confirmation": true, + "can_commit": true + }, + "blocked_replacement": "Same staged response but can_commit=false with reason=service_validation_failed when replacing an existing different credential that the new candidate cannot validate; previous configuration retained.", + "commit_request": { + "attempt_id": "", + "confirm": true + }, + "commit_response": { + "status": "linked", + "account": { + "id": 123, + "email": "customer@example.test", + "display_name": "Customer" + }, + "service_status": "available|unavailable|not_entitled|unknown" + }, + "cancel_request": { + "attempt_id": "" + }, + "cancel_response": { + "status": "cancelled" + } + } + }, + "staged_validation": { + "all": "The fixed-origin verified-TLS central redemption is the authoritative account/key binding. Strictly validate exact schema/client/installation, positive integer WP account.id, bounded plausible email and supported credential type. These identity fields never come from browser or get overwritten by service hints. A failed/malformed central result cannot stage a link. Never serialize full downstream validation response.", + "identity": "A new unlinked installation may link a strictly verified central identity even when its paid service is unavailable/not entitled. Persist key + trusted WP identity only after local confirmation, with subscription_valid=false and no enabled paid features unless independently validated. Link status and service status are separate. Preserve local sync choices; no background sync is started just because identity linked. Do not fabricate legacy service customer_id: leave it absent/empty until supplied by a valid service reply, using a narrow identity-only persistence helper rather than pretending the legacy validation helper succeeded.", + "ipmi_service": "Keep validate_license_key unchanged for legacy paths. In the new pairing-specific validator, call existing configured TLS AI /api/v1/validate for current service permission. If reply has valid=true and meets every existing tier/max_servers/features/email/customer_id contract except user_id is absent, call fixed WP POST /ipmi/validate with Authorization Bearer candidate key; require valid=true, exact matching positive user_id and normalized email from central exchange. Only then fill missing user_id from this independent WP response and apply current AI service tier/features/quota. Present mismatching or malformed user_id is a failure, not an omission. Any other missing AI field or either validation error means service_status unavailable/not_entitled/unknown, no paid grant. A complete AI identity also must match central WP account.id/email. Never copy litellm_api_key or dc_overview_instances from WP validation.", + "existing_link": "Do not replace an existing different saved credential/configuration with a candidate that fails service validation. Stage trusted identity and indicate can_commit=false; preserve existing paid flags/settings unchanged. For same-key re-link, record identity only with explicit confirmation and preserve existing configuration; do not newly set paid-valid on validation failure. This does not choose a new outage policy: existing ongoing service validation behavior remains.", + "proxy": "Verify central identity/schema and optionally corroborate via existing fixed WP validation; preserve hosted Watchdog entitlement/enrollment authority. Do not infer paid readiness from watchdog_verified; that flag remains link-completion only. Detect explicit WATCHDOG_API_KEY precedence conflicts before commit and return configuration_conflict, retaining environment/files/worker tokens. No customer/worker reparenting.", + "standalone": "IPMI works without proxy. DCO/Watchdog controller uses existing proxy shared credential path where installed; document supported standalone controller adapter availability rather than claiming unimplemented paths work." + }, + "test_contract": { + "php_handler": "Exercise actual route registration/start/status/review/approve handlers with synthetic WP request/cookie/nonce boundaries. Include emitted page body and exact JSON; no source-string-only proof.", + "persistence": "Two independent PHP/mysqli connections/processes execute the real production direct-SQL helpers against disposable MariaDB/InnoDB options table. Barrier concurrent approves, redeems, stale polling vs approval/cancel, slot reuse and rate counters; one secret reply only, no lost state, no over-limit successful reservations. Fault injection after consumed yields no retry secret.", + "clients": "Actual Flask handlers/admin/session store and existing credential/config writers. Shared same JSON fixture contract used by all products. Verify session switch/logout/config change after outbound response, stage mismatch, independent WP identity, missing-only-legacy-user_id case, and atomic preservation on every failure.", + "regression": "Reuse IPMI auth/settings security, Proxy auth/admin, DCO token preservation/enrollment and package artifact tests. No Watchdog agent token rotation or billing/retention changes.", + "browser": "Owner-admin representative local-only deployment, no provider VPN/inbound route: login/new signup, email verification/restart, matching-code consent, local account confirmation, enabled service checks. Live installed validation remains held until actual accepted Fleet/client and central release." + }, + "blockers_only": [ + "Real options-table SQL/CAS behavior must pass independent database concurrency tests before gate closure; mocked storage alone is insufficient.", + "An explicit environment key conflict must be resolved through customer-controlled configuration before replacement; detect and preserve rather than mutate implicitly.", + "Production/Fleet/paid acceptance remains outside source-contract completion.", + "AI validation missing required fields holds service-ready/paid acceptance only; strictly verified WP identity linking for an unlinked installation remains available without the unrelated backend rollout." + ], + "source_basis": { + "wordpress_inspected_revision": "5c470f638b26979e1b554f9c25043a28a0857122", + "monitor_inspected_revision": "44d8f5507e3c834b495fa77f213cb20f147a76d3", + "note": "Source reads are baseline facts only. Builders use their new approved worktrees and bind exact candidate revisions to results." + }, + "handler_seams": { + "class": "CLAI_IPMI_SSO", + "registration": "register_routes() registers pairing_start, pairing_status, pairing_approve; init() registers render_pairing_page on template_redirect for exact clai_pair=1.", + "methods": { + "start": "public static pairing_start(WP_REST_Request $request)", + "status": "public static pairing_status(WP_REST_Request $request)", + "approve": "public static pairing_approve(WP_REST_Request $request)", + "page": "public static render_pairing_page()", + "permission": "public static pairing_consent_permission(WP_REST_Request $request)" + }, + "persistence": "Private production helpers pairing_store_read($slot), pairing_store_cas($slot,$old,$new,$expiry_mode), pairing_store_allocate($record), pairing_rate_allow($route,$caller). Test public handlers with real wpdb connection; reflection may invoke pure/private storage helpers only in SQL boundary tests. No fake atomics in concurrency tests." + } +} diff --git a/.traceweaver/account-pairing-20261028/verification.json b/.traceweaver/account-pairing-20261028/verification.json new file mode 100644 index 0000000..9c69977 --- /dev/null +++ b/.traceweaver/account-pairing-20261028/verification.json @@ -0,0 +1,25 @@ +{ + "baseline_id": "PAIR-20261028-01", + "source_status": "locally_verified_independently_reviewed_feature_scope", + "results": { + "focused pairing": "51PASS", + "combined auth/pairing/edge/NetBox regressions": "375PASS;1existing skip", + "independent P1 recheck": "Both callback and unauthorized different-key replacement resolved;57PASS", + "nginx": "Actual nginx1.31.8 syntax for shipped+generated plain/LE configs PASS; digest retained" + }, + "commands": [ + "python3 -m pytest -q tests/test_account_link.py tests/test_auth.py tests/test_edge_ownership.py" + ], + "review_outcome": "No unresolved current product findings after initial review repair batches; exact evidence retained by root. Runtime approval remains separate.", + "source_limits": [ + "Local/component/contract checks and synthetic browser do not establish installed/public signup, billing/inference or Fleet acceptance", + "Raw producer/spec JSON fixtures use explicit generated-data provenance mapping; no inline scanner coverage claimed", + "Jinja/PHP/nginx use explicit syntax/comment equivalence and executable checks; no unsupported scanner coverage claimed" + ], + "publication_route": "Owner-requested normal feature PR to dev; release/main and installed acceptance keep their required gates", + "source_evidence": { + "requirement_sha256": "9b80ccd96837cae1dd63723fa10ce8390dc4305fcc9e65f303a4868a917dc922", + "protocol_sha256": "abd8b95925c92e7a70f6bdd5f0f7d2bcb2e5cbdcf822699397c5e130d6b3aa70", + "producer_sha256": "d58a17f90c33e935b4307b5816e462d0445ee984b72e1d03948dfa3cd9d9404d" + } +} diff --git a/.traceweaver/intent-contract.yml b/.traceweaver/intent-contract.yml new file mode 100644 index 0000000..6f2a40d --- /dev/null +++ b/.traceweaver/intent-contract.yml @@ -0,0 +1,62 @@ +{ + "intent_contract_id": "PAIR-PROXY-20261028-01", + "traceweaver_mode": "advisory", + "account_pairing_scope": { + "baseline_id": "PAIR-20261028-01", + "baseline_hash_sha256": "9b80ccd96837cae1dd63723fa10ce8390dc4305fcc9e65f303a4868a917dc922", + "authority": ".traceweaver/account-pairing-20261028/authority.json", + "contract": ".traceweaver/account-pairing-20261028/contract.json", + "status": "owner_approved_scope_quality_ready", + "runtime_acceptance": "held" + }, + "approved_requirements": [ + { + "id": "REQ-PAIR-001", + "statement": "A deployment administrator shall be able to link IPMI Monitor or the DC Overview/Watchdog controller to an existing or newly created CryptoLabs account using outbound HTTPS to the existing public CryptoLabs WordPress service for account pairing. Existing product service validation may use its existing approved HTTPS endpoint. Provider VPN access, an inbound deployment callback and per-deployment hostname admission shall not be prerequisites.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-001", + "verification_id": "VER-PAIR-001", + "validation_id": "VAL-PAIR-001" + }, + { + "id": "REQ-PAIR-002", + "statement": "The public linking flow shall require signed-in account consent through a nonce-protected POST for the matching displayed one-time user code. Opening a link, signing up or presenting a payment-success parameter alone shall not authorize a deployment.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-002", + "verification_id": "VER-PAIR-002", + "validation_id": "VAL-PAIR-002" + }, + { + "id": "REQ-PAIR-003", + "statement": "Pending linking attempts shall expire after 300 seconds, enforce bounded issuance, approval guesses and polling, and permit only one successful redemption through atomic persistent transitions across workers. Secret device codes shall remain backend-only and shall not be stored in plaintext by WordPress.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-003", + "verification_id": "VER-PAIR-003", + "validation_id": "VAL-PAIR-003" + }, + { + "id": "REQ-PAIR-004", + "statement": "A deployment shall accept a result only for the initiating current local administrator and session. It shall stage and verify the authoritative result before an intentional commit, consume the local attempt once, and preserve an existing working link on failure, denial, cancellation, expiry, session change or account mismatch. A first link may save verified WordPress identity with no paid grant when service validation fails; an existing different working credential/configuration shall not be replaced by a candidate that fails service validation. A same-key re-link shall not newly assert paid readiness on validation failure.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-004", + "verification_id": "VER-PAIR-004", + "validation_id": "VAL-PAIR-004" + }, + { + "id": "REQ-PAIR-005", + "statement": "The new flow shall deliver only the existing product account credential and allowlisted authoritative identity/status fields over a backend HTTPS response. It shall preserve current service entitlement/quota decisions, worker-scoped credentials and local administration/update authority. Pairing shall not grant a new paid entitlement, change retention/outage policies, or give the provider host access.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-005", + "verification_id": "VER-PAIR-005", + "validation_id": "VAL-PAIR-005" + }, + { + "id": "REQ-PAIR-006", + "statement": "The implementation shall preserve existing working links and legacy clients, keep the current pinned WordPress package/publisher boundary, and deliver installed product updates through accepted DC Overview Fleet Management. Deployment and paid-service acceptance shall remain held until observed in the intended environment.", + "authority_status": "owner_approved_scope_quality_ready", + "trace_id": "TRACE-PAIR-006", + "verification_id": "VER-PAIR-006", + "validation_id": "VAL-PAIR-006" + } + ] +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 42065e2..8ef306f 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -1,3 +1,4 @@ +# TRACEWEAVER: file-role=pairing-routing; req=REQ-PAIR-001; trace=TRACE-PAIR-001; ver=VER-PAIR-001 worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; @@ -84,6 +85,15 @@ http { } # Auth endpoints (public - no auth required) + location ^~ /api/account-link/ { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Cookie $http_cookie; + } + location /auth/ { proxy_pass http://auth_server/auth/; proxy_set_header Host $host; diff --git a/pyproject.toml b/pyproject.toml index 15a006c..3312a7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cryptolabs-proxy" -version = "1.5.4" +version = "1.6.0" description = "Unified reverse proxy and fleet management landing page for CryptoLabs products" readme = "README.md" license = {text = "MIT"} diff --git a/requirements.md b/requirements.md new file mode 100644 index 0000000..d2fc55b --- /dev/null +++ b/requirements.md @@ -0,0 +1,42 @@ +# CryptoLabs Proxy scoped requirements + + +## Owner-approved account pairing — PAIR-20261028-01 + +Source: latest owner approval quoted in `.traceweaver/account-pairing-20261028/authority.json`. Reviewed scope hash: `9b80ccd96837cae1dd63723fa10ce8390dc4305fcc9e65f303a4868a917dc922`. The exact shared protocol is `.traceweaver/account-pairing-20261028/contract.json`. Existing requirements remain authoritative; this appendix governs only the approved pairing increment. Delivery and paid-service acceptance remain held until observed. + +### REQ-PAIR-001 + +A deployment administrator shall be able to link IPMI Monitor or the DC Overview/Watchdog controller to an existing or newly created CryptoLabs account using outbound HTTPS to the existing public CryptoLabs WordPress service for account pairing. Existing product service validation may use its existing approved HTTPS endpoint. Provider VPN access, an inbound deployment callback and per-deployment hostname admission shall not be prerequisites. + +Verification: `VER-PAIR-001`; validation: `VAL-PAIR-001`. See scoped authority and protocol for executable acceptance boundaries. + +### REQ-PAIR-002 + +The public linking flow shall require signed-in account consent through a nonce-protected POST for the matching displayed one-time user code. Opening a link, signing up or presenting a payment-success parameter alone shall not authorize a deployment. + +Verification: `VER-PAIR-002`; validation: `VAL-PAIR-002`. See scoped authority and protocol for executable acceptance boundaries. + +### REQ-PAIR-003 + +Pending linking attempts shall expire after 300 seconds, enforce bounded issuance, approval guesses and polling, and permit only one successful redemption through atomic persistent transitions across workers. Secret device codes shall remain backend-only and shall not be stored in plaintext by WordPress. + +Verification: `VER-PAIR-003`; validation: `VAL-PAIR-003`. See scoped authority and protocol for executable acceptance boundaries. + +### REQ-PAIR-004 + +A deployment shall accept a result only for the initiating current local administrator and session. It shall stage and verify the authoritative result before an intentional commit, consume the local attempt once, and preserve an existing working link on failure, denial, cancellation, expiry, session change or account mismatch. A first link may save verified WordPress identity with no paid grant when service validation fails; an existing different working credential/configuration shall not be replaced by a candidate that fails service validation. A same-key re-link shall not newly assert paid readiness on validation failure. + +Verification: `VER-PAIR-004`; validation: `VAL-PAIR-004`. See scoped authority and protocol for executable acceptance boundaries. + +### REQ-PAIR-005 + +The new flow shall deliver only the existing product account credential and allowlisted authoritative identity/status fields over a backend HTTPS response. It shall preserve current service entitlement/quota decisions, worker-scoped credentials and local administration/update authority. Pairing shall not grant a new paid entitlement, change retention/outage policies, or give the provider host access. + +Verification: `VER-PAIR-005`; validation: `VAL-PAIR-005`. See scoped authority and protocol for executable acceptance boundaries. + +### REQ-PAIR-006 + +The implementation shall preserve existing working links and legacy clients, keep the current pinned WordPress package/publisher boundary, and deliver installed product updates through accepted DC Overview Fleet Management. Deployment and paid-service acceptance shall remain held until observed in the intended environment. + +Verification: `VER-PAIR-006`; validation: `VAL-PAIR-006`. See scoped authority and protocol for executable acceptance boundaries. diff --git a/src/cryptolabs_proxy/__init__.py b/src/cryptolabs_proxy/__init__.py index cdde02c..4c4c6f4 100644 --- a/src/cryptolabs_proxy/__init__.py +++ b/src/cryptolabs_proxy/__init__.py @@ -1,6 +1,6 @@ """CryptoLabs Proxy - Unified reverse proxy for CryptoLabs products.""" -__version__ = "1.5.4" +__version__ = "1.6.0" # Export programmatic setup API from .setup import ( diff --git a/src/cryptolabs_proxy/account_link.py b/src/cryptolabs_proxy/account_link.py new file mode 100644 index 0000000..2742574 --- /dev/null +++ b/src/cryptolabs_proxy/account_link.py @@ -0,0 +1,328 @@ +# TRACEWEAVER: file-role=pairing-store; req=REQ-PAIR-003; trace=TRACE-PAIR-003; ver=VER-PAIR-003 +"""Outbound account pairing with a bounded, process-serialized local store. + +Secrets live in AUTH_DATA_DIR, never in the Flask signed session. Existing +Watchdog's shared key file remains the controller compatibility boundary. +""" + +import base64 +from contextlib import contextmanager +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import tempfile +import time +import uuid + +import requests + + +ORIGIN = 'https://www.cryptolabs.co.za' +PREFIX = ORIGIN + '/wp-json/cryptolabs/v1' +VERIFICATION_URI = ORIGIN + '/?clai_pair=1' +CLIENT_ID = 'cryptolabs-dc-controller' +TTL = 300 + + +class PairingError(Exception): + def __init__(self, error, status=409): + self.error, self.status = error, status + + +def atomic_write(path, content, mode=0o600): + """Replace one file durably; a failed writer leaves its old file intact.""" + fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '-', dir=path.parent) + try: + os.fchmod(fd, mode) + with os.fdopen(fd, 'wb') as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +class PairStore: + def __init__(self, directory, environment_key=''): + self.directory = Path(directory) + self.environment_key = environment_key + self.path = self.directory / 'account_link.json' + + @contextmanager + def locked(self): + self.directory.mkdir(parents=True, exist_ok=True) + fd = os.open(self.directory / 'account_link.lock', os.O_CREAT | os.O_RDWR, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + if self.path.exists(): + state = json.loads(self.path.read_text()) + else: + state = {'version': 1, 'generation': 0, 'installation_id': str(uuid.uuid4()), + 'attempt': None, 'active': None, 'journal': None} + self.recover(state) + yield state + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + def save(self, state): + atomic_write(self.path, json.dumps(state, separators=(',', ':')).encode()) + + def key(self): + path = self.directory / 'watchdog_api_key' + return path.read_text().strip() if path.exists() else self.environment_key + + def fingerprint(self): + value = [self.key(), self.environment_key, + (self.directory / 'watchdog_verified').exists()] + return hashlib.sha256(json.dumps(value).encode()).hexdigest() + + def recover(self, state): + """Rollback interrupted multi-file commit before cooperating readers run. + + The persistent attempt was consumed before the first credential write. + A crash can lose a confirmation, never make its stage redeemable twice. + External non-cooperating readers still see atomic old/new key files. + """ + journal = state.get('journal') + if not journal: + return + for name in ('watchdog_api_key', 'watchdog_verified'): + path = self.directory / name + snapshot = journal[name] + if snapshot is None: + path.unlink(missing_ok=True) + else: + atomic_write(path, base64.b64decode(snapshot), 0o644) + state['active'] = journal['active'] + state['journal'] = None + self.save(state) + + def bound(self, state, attempt_id, owner, binding): + attempt = state.get('attempt') + if not attempt or attempt['id'] != attempt_id: + raise PairingError('invalid_attempt') + if attempt['owner'] != owner or not secrets.compare_digest(attempt['binding'], binding): + raise PairingError('invalid_attempt', 403) + if attempt['expires_at'] <= time.time(): + state['attempt'] = None + self.save(state) + raise PairingError('expired_token', 410) + if attempt['fingerprint'] != self.fingerprint(): + state['attempt'] = None + self.save(state) + raise PairingError('configuration_changed') + return attempt + + def invalidate(self, binding): + with self.locked() as state: + attempt = state.get('attempt') + if attempt and attempt['binding'] == binding: + state['attempt'] = None + state['generation'] += 1 + self.save(state) + +# TRACEWEAVER: entrypoint=start; req=REQ-PAIR-001; trace=TRACE-PAIR-001; ver=VER-PAIR-001 + def start(self, owner, binding, live_admin): + with self.locked() as state: + state['generation'] += 1 + attempt = {'id': secrets.token_urlsafe(32), 'owner': owner, 'binding': binding, + 'generation': state['generation'], 'fingerprint': self.fingerprint(), + 'expires_at': time.time() + TTL, 'state': 'starting'} + state['attempt'] = attempt + installation_id = state['installation_id'] + self.save(state) + try: + response = exchange('/pairing/start', {'client_id': CLIENT_ID, + 'installation_id': installation_id, 'installation_name': 'DC Overview controller'}) + body = bounded_json(response) + if (response.status_code != 201 or set(body) != {'protocol_version', 'device_code', 'user_code', + 'verification_uri', 'expires_in', 'interval'} or body['protocol_version'] != 1 + or not isinstance(body['device_code'], str) + or not re.fullmatch(r'[A-Za-z0-9_-]{43}', body['device_code']) + or not isinstance(body['user_code'], str) + or not re.fullmatch(r'[0-9A-HJKMNP-TV-Z]{2}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}', body['user_code']) + or body['verification_uri'] != VERIFICATION_URI or type(body['expires_in']) is not int + or not 0 < body['expires_in'] <= TTL or type(body['interval']) is not int or body['interval'] != 5): + raise PairingError('invalid_pairing_response', 502) + with self.locked() as state: + current = self.bound(state, attempt['id'], owner, binding) + if not live_admin(): + state['attempt'] = None + self.save(state) + raise PairingError('admin_session_changed', 403) + current.update(state='pending', device_code=body['device_code'], user_code=body['user_code'], + interval=body['interval'], next_poll=0, + expires_at=min(current['expires_at'], time.time() + body['expires_in'])) + self.save(state) + return {'status': 'pending', 'attempt_id': attempt['id'], 'user_code': body['user_code'], + 'verification_uri': VERIFICATION_URI, 'expires_in': body['expires_in'], 'interval': body['interval']} + except (requests.RequestException, ValueError, TypeError, KeyError): + self.invalidate(binding) + raise PairingError('temporarily_unavailable', 503) from None + +# TRACEWEAVER: entrypoint=status; req=REQ-PAIR-003; trace=TRACE-PAIR-003; ver=VER-PAIR-003 + def status(self, attempt_id, owner, binding, live_admin): + with self.locked() as state: + attempt = self.bound(state, attempt_id, owner, binding) + if attempt['state'] == 'staged': + return public_stage(attempt) + if attempt['state'] == 'polling' or attempt.get('next_poll', 0) > time.time(): + return {'status': 'pending', 'interval': attempt.get('interval', 5)} + if attempt['state'] != 'pending': + raise PairingError('invalid_attempt') + ticket = secrets.token_urlsafe(24) + attempt.update(state='polling', ticket=ticket, next_poll=time.time() + attempt['interval']) + self.save(state) + device_code, user_code = attempt['device_code'], attempt['user_code'] + installation_id = state['installation_id'] + try: + reply = exchange('/pairing/status', {'client_id': CLIENT_ID, 'user_code': user_code}, device_code) + body = bounded_json(reply) + if reply.status_code == 200: + account, key = validate_result(body, installation_id) + elif body.get('error') in ('authorization_pending', 'slow_down'): + account, key = None, None + else: + raise PairingError(body.get('error') if body.get('error') in + ('access_denied', 'expired_token', 'invalid_grant') else 'temporarily_unavailable', + 400 if reply.status_code == 400 else 503) + except (requests.RequestException, ValueError, TypeError, KeyError): + with self.locked() as state: + current = self.bound(state, attempt_id, owner, binding) + current['state'] = 'pending' + self.save(state) + raise PairingError('temporarily_unavailable', 503) from None + except PairingError: + self.invalidate(binding) + raise + with self.locked() as state: + current = self.bound(state, attempt_id, owner, binding) + if current.get('ticket') != ticket: + raise PairingError('invalid_attempt') + if not live_admin(): + state['attempt'] = None + self.save(state) + raise PairingError('admin_session_changed', 403) + if account is None: + interval = body.get('interval', current['interval'] + 5) + current.update(state='pending', interval=min(60, max(5, interval)) if type(interval) is int else 10) + current['next_poll'] = time.time() + current['interval'] + self.save(state) + return {'status': 'pending', 'interval': current['interval']} + current.pop('device_code', None) + # WordPress proves identity, not hosted Watchdog permission. Until + # that existing permission can be checked without enrollment or + # mutation, only first-link and same-key confirmation may commit. + current.update(state='staged', account=account, key=key, service_status='unknown', + can_commit=not self.key() or self.key() == key) + self.save(state) + return public_stage(current) + + def cancel(self, attempt_id, owner, binding): + with self.locked() as state: + self.bound(state, attempt_id, owner, binding) + state['attempt'] = None + self.save(state) + return {'status': 'cancelled'} + +# TRACEWEAVER: entrypoint=commit; req=REQ-PAIR-004; trace=TRACE-PAIR-004; ver=VER-PAIR-004 + def commit(self, attempt_id, owner, binding, live_admin): + with self.locked() as state: + attempt = self.bound(state, attempt_id, owner, binding) + if not live_admin(): + state['attempt'] = None + self.save(state) + raise PairingError('admin_session_changed', 403) + if attempt['state'] != 'staged': + raise PairingError('confirmation_not_ready') + if self.environment_key and self.environment_key != attempt['key']: + raise PairingError('configuration_conflict') + # Re-evaluate even for a stage persisted by an older adapter. + if not attempt['can_commit'] or (self.key() and self.key() != attempt['key']): + raise PairingError('service_validation_failed') + result = {'status': 'linked', 'account': attempt['account'], 'service_status': attempt['service_status']} + key = attempt['key'] + snapshots = {} + for name in ('watchdog_api_key', 'watchdog_verified'): + path = self.directory / name + snapshots[name] = base64.b64encode(path.read_bytes()).decode() if path.exists() else None + snapshots['active'] = state.get('active') + state.update(attempt=None, journal=snapshots) + self.save(state) # One-use consume precedes any visible credential write. + try: + atomic_write(self.directory / 'watchdog_api_key', key.encode(), 0o644) + atomic_write(self.directory / 'watchdog_verified', b'1', 0o644) + state['active'] = {'account': result['account'], 'key_digest': hashlib.sha256(key.encode()).hexdigest()} + state['journal'] = None + self.save(state) + except OSError: + state['journal'] = snapshots + self.recover(state) + raise + return result + + def paired_account(self): + with self.locked() as state: + active = state.get('active') + if active and active['key_digest'] == hashlib.sha256(self.key().encode()).hexdigest(): + return active['account'] + return None + + +def exchange(path, body, bearer=None): + headers = {'Accept': 'application/json'} + if bearer: + headers['Authorization'] = 'Bearer ' + bearer + return requests.post(PREFIX + path, json=body, headers=headers, timeout=(3.05, 10), + verify=True, allow_redirects=False) + + +def bounded_json(reply): + if len(reply.content) > 2048: + raise ValueError('oversize response') + body = reply.json() + if not isinstance(body, dict): + raise ValueError('invalid response') + return body + + +def validate_result(body, installation_id): + if (set(body) != {'protocol_version', 'status', 'client_id', 'installation_id', 'account', + 'credential', 'service_intent', 'authorization'} or type(body['protocol_version']) is not int + or body['protocol_version'] != 1 or body['status'] != 'complete' + or body['client_id'] != CLIENT_ID or body['installation_id'] != installation_id + or body['service_intent'] != 'watchdog' or body['authorization'] != 'existing_service_validation_required'): + raise ValueError('invalid identity envelope') + account, credential = body['account'], body['credential'] + if (not isinstance(account, dict) or set(account) != {'id', 'email', 'display_name'} + or type(account['id']) is not int or account['id'] <= 0 + or not isinstance(account['email'], str) or len(account['email']) > 254 + or not re.fullmatch(r'[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+', account['email']) + or not isinstance(account['display_name'], str) or len(account['display_name']) > 200 + or not isinstance(credential, dict) or set(credential) != {'type', 'api_key', 'scope'} + or credential['type'] != 'legacy_ipmi_api_key' or credential['scope'] != 'account_wide' + or not isinstance(credential['api_key'], str) + or not re.fullmatch(r'sk-ipmi-[A-Za-z0-9_-]{1,256}', credential['api_key'])): + raise ValueError('invalid account credential') + return account, credential['api_key'] + + +def public_stage(attempt): + result = {'status': 'awaiting_confirmation', 'account': attempt['account'], + 'service_status': attempt['service_status'], 'requires_confirmation': True, + 'can_commit': attempt['can_commit']} + if not attempt['can_commit']: + result['reason'] = 'service_validation_failed' + return result diff --git a/src/cryptolabs_proxy/auth.py b/src/cryptolabs_proxy/auth.py index 6bf3316..e594edf 100644 --- a/src/cryptolabs_proxy/auth.py +++ b/src/cryptolabs_proxy/auth.py @@ -1,3 +1,4 @@ +# TRACEWEAVER: file-role=account-pairing-routes; req=REQ-PAIR-004; trace=TRACE-PAIR-004; ver=VER-PAIR-004 """ CryptoLabs Proxy - Unified Authentication Module @@ -35,6 +36,7 @@ from werkzeug.security import generate_password_hash, check_password_hash from .identity import VALID_ROLES, validate_identity_users +from .account_link import PairStore, PairingError, atomic_write # Module-level logger for auth operations logger = logging.getLogger('cryptolabs_proxy.auth') @@ -60,7 +62,17 @@ WATCHDOG_URL = os.environ.get('WATCHDOG_URL', 'https://watchdog.cryptolabs.co.za') WATCHDOG_SIGNUP_URL = 'https://www.cryptolabs.co.za/dc-watchdog-signup/' +def _pair_store(): + return PairStore(DATA_DIR, WATCHDOG_API_KEY) + + def get_watchdog_api_key() -> str: + # Cooperating readers recover any interrupted pairing commit before use. + with _pair_store().locked(): + return _get_watchdog_api_key_unlocked() + + +def _get_watchdog_api_key_unlocked() -> str: """Get the DC Watchdog API key from environment or persistent storage.""" # First check persistent storage (for keys obtained via OAuth callback) key_file = DATA_DIR / 'watchdog_api_key' @@ -100,9 +112,9 @@ def is_watchdog_verified() -> bool: def set_watchdog_verified() -> bool: """Mark DC Watchdog as verified after SSO completion.""" try: - DATA_DIR.mkdir(parents=True, exist_ok=True) - verified_file = DATA_DIR / 'watchdog_verified' - verified_file.write_text('1') + with _pair_store().locked(): + verified_file = DATA_DIR / 'watchdog_verified' + atomic_write(verified_file, b'1', 0o644) logger.info(f"DC Watchdog verified flag set at {verified_file}") return True except Exception as e: @@ -113,12 +125,14 @@ def set_watchdog_verified() -> bool: def save_watchdog_api_key(api_key: str) -> bool: """Save the DC Watchdog API key to persistent storage.""" try: - DATA_DIR.mkdir(parents=True, exist_ok=True) - key_file = DATA_DIR / 'watchdog_api_key' - key_file.write_text(api_key) - # 0644 so other containers sharing fleet-auth-data volume can read it - # (dc-overview runs as dcuser, not root) - os.chmod(key_file, 0o644) + store = _pair_store() + with store.locked() as state: + # Legacy callbacks/configuration share pairing's process lock. + # Invalidate the old generation before their atomic key change. + state['attempt'] = None + state['generation'] += 1 + store.save(state) + atomic_write(DATA_DIR / 'watchdog_api_key', api_key.encode(), 0o644) return True except Exception: return False @@ -1280,6 +1294,13 @@ def derive(purpose: bytes) -> str: 'csrf_token': derive(b'cryptolabs/vpm/csrf/v1:'), }, 200 + def reset_pair_session(): + binding = session.get('pair_session') + if binding: + _pair_store().invalidate(hashlib.sha256(binding.encode()).hexdigest()) + session.pop('pair_csrf', None) + session['pair_session'] = secrets.token_urlsafe(32) + # ========================================================================= # ROUTES # ========================================================================= @@ -1303,6 +1324,7 @@ def login(): error = 'Passwords do not match' else: create_user(username, password, 'admin') + reset_pair_session() session['logged_in'] = True session['username'] = username session['role'] = 'admin' @@ -1316,6 +1338,7 @@ def login(): # Normal login user = verify_user(username, password) if user: + reset_pair_session() session['logged_in'] = True session['username'] = user['username'] session['role'] = user['role'] @@ -1340,6 +1363,7 @@ def login(): @app.route('/auth/logout') def logout(): + reset_pair_session() session.clear() return redirect('/auth/login') @@ -2139,6 +2163,157 @@ def runpod_management(): # DC WATCHDOG AUTO-SSO # ========================================================================= + def pair_origin_allowed(): + scheme = request.scheme + if request.remote_addr in ('127.0.0.1', '::1'): + forwarded = request.headers.get('X-Forwarded-Proto') + if forwarded in ('http', 'https'): + scheme = forwarded + origin = request.headers.get('Origin') + return (request.headers.get('Sec-Fetch-Site') != 'cross-site' + and (origin is None or origin == scheme + '://' + request.host)) + + @app.route('/api/account-link/config') + def account_link_config(): + _, status = current_admin_user() + if status != 200: + return jsonify({'error': 'Admin access required'}), status + if not pair_origin_allowed(): + return jsonify({'error': 'Invalid origin'}), 403 + session.setdefault('pair_session', secrets.token_urlsafe(32)) + session.setdefault('pair_csrf', secrets.token_urlsafe(32)) + reply = jsonify({'csrf_token': session['pair_csrf']}) + reply.headers['Cache-Control'] = 'no-store' + reply.headers['Referrer-Policy'] = 'no-referrer' + return reply + + @app.after_request + def account_link_cache_policy(response): + if request.path.startswith('/api/account-link/'): + response.headers['Cache-Control'] = 'no-store' + response.headers['Referrer-Policy'] = 'no-referrer' + return response + + @app.route('/api/account-link/', methods=['POST']) +# TRACEWEAVER: entrypoint=account_link_mutate; req=REQ-PAIR-004; trace=TRACE-PAIR-004; ver=VER-PAIR-004 + def account_link_mutate(action): + user, status = current_admin_user() + if status != 200: + return jsonify({'error': 'Admin access required'}), status + csrf = request.headers.get('X-CSRF-Token', '') + if (not csrf or not session.get('pair_csrf') + or not hmac.compare_digest(csrf.encode(), session['pair_csrf'].encode()) + or not pair_origin_allowed()): + return jsonify({'error': 'Invalid form token or origin'}), 403 + try: + if request.content_length and request.content_length > 1024: + raise PairingError('invalid_request', 400) + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError('duplicate field') + result[key] = value + return result + data = json.loads(request.get_data(), object_pairs_hook=unique_object) + fields = {'start': set(), 'status': {'attempt_id'}, 'cancel': {'attempt_id'}, + 'commit': {'attempt_id', 'confirm'}} + if action not in fields or not isinstance(data, dict) or set(data) != fields[action]: + raise PairingError('invalid_request', 400) + if action != 'start' and (not isinstance(data.get('attempt_id'), str) + or not 1 <= len(data['attempt_id']) <= 64): + raise PairingError('invalid_request', 400) + if action == 'commit' and data['confirm'] is not True: + raise PairingError('confirmation_required', 400) + binding = session.get('pair_session') + if not binding: + raise PairingError('invalid_session', 403) + binding = hashlib.sha256(binding.encode()).hexdigest() + store = _pair_store() + if action == 'start': + result = store.start(user['username'], binding, lambda: current_admin_user()[1] == 200) + elif action == 'status': + result = store.status(data['attempt_id'], user['username'], binding, + lambda: current_admin_user()[1] == 200) + elif action == 'commit': + result = store.commit(data['attempt_id'], user['username'], binding, + lambda: current_admin_user()[1] == 200) + else: + result = store.cancel(data['attempt_id'], user['username'], binding) + response, status = jsonify(result), 200 + except PairingError as exc: + response, status = jsonify({'error': exc.error}), exc.status + except (ValueError, TypeError): + response, status = jsonify({'error': 'invalid_request'}), 400 + except OSError: + response, status = jsonify({'error': 'temporarily_unavailable'}), 503 + response.headers['Cache-Control'] = 'no-store' + response.headers['Referrer-Policy'] = 'no-referrer' + return response, status + + @app.route('/auth/account-link') + def account_link_page(): + _, status = current_admin_user() + if status != 200: + return redirect('/auth/login?next=/auth/account-link') if status == 401 else ('Admin access required', status) + response = app.make_response(render_template_string(''' + + Link CryptoLabs account + +

Link CryptoLabs account

+

Sign in or create an account on CryptoLabs, enter the code below and approve this controller. + Then return here to confirm the account. Existing service subscriptions still apply.

+

This uses your existing account-wide credential. Cancelling preserves the current local link.

+

+ +

Open Watchdog dashboard · Back to Fleet

+
''', style=BASE_STYLE, dashboard=WATCHDOG_URL.rstrip('/') + '/login')) + response.headers['Cache-Control'] = 'no-store' + response.headers['Referrer-Policy'] = 'no-referrer' + return response + @app.route('/auth/watchdog/sso') def watchdog_sso(): """Generate SSO URL for DC Watchdog - enables seamless one-click access. @@ -2171,14 +2346,14 @@ def watchdog_sso(): # Get API key (from env or persistent storage) api_key = get_watchdog_api_key() + + if _pair_store().paired_account(): + return redirect(WATCHDOG_URL.rstrip('/') + '/login') # If no API key configured, redirect to WordPress signup with callback # After signup, WordPress will redirect back with the API key if not api_key: - # Build callback URL for this Fleet Management instance - callback_url = request.host_url.rstrip('/') + '/auth/watchdog/callback' - signup_url = f"{WATCHDOG_SIGNUP_URL}?redirect_uri={callback_url}&source=fleet_management" - return redirect(signup_url) + return redirect('/auth/account-link') # Generate signed SSO payload # NOTE: DC Watchdog will validate this API key against WordPress server-to-server @@ -2238,13 +2413,14 @@ def watchdog_sso_url(): # Get API key (from env or persistent storage) api_key = get_watchdog_api_key() + + if _pair_store().paired_account(): + return jsonify({'sso_url': WATCHDOG_URL.rstrip('/') + '/login', 'auto_sso': False}) # If no API key configured, return signup URL with callback if not api_key: - callback_url = request.host_url.rstrip('/') + '/auth/watchdog/callback' - signup_url = f"{WATCHDOG_SIGNUP_URL}?redirect_uri={callback_url}&source=fleet_management" return jsonify({ - 'sso_url': signup_url, + 'sso_url': '/auth/account-link', 'auto_sso': False, 'needs_signup': True, 'message': 'API key not configured - redirect to signup' @@ -2281,50 +2457,13 @@ def watchdog_sso_url(): }) @app.route('/auth/watchdog/callback') +# TRACEWEAVER: entrypoint=watchdog_callback; req=REQ-PAIR-004; trace=TRACE-PAIR-004; ver=VER-PAIR-004 def watchdog_callback(): - """OAuth-style callback from WordPress after user signs up for DC Watchdog. - - WordPress redirects here with: - - api_key: The user's API key (sk-ipmi-xxx) - - user_email: User's email - - subscription: Subscription status (trial, active, etc.) - - We store the API key and then redirect to the DC Watchdog dashboard. - """ - api_key = request.args.get('api_key') - user_email = request.args.get('user_email', '') - subscription = request.args.get('subscription', 'trial') - - if not api_key or not api_key.startswith('sk-ipmi-'): - return render_template_string(''' - - - Error - -

❌ Invalid API Key

-

No valid API key was provided. Please try again.

- Return to Dashboard - - - '''), 400 - - # Save the API key persistently and mark as verified (SSO completed) - if save_watchdog_api_key(api_key): - set_watchdog_verified() # User completed SSO, now DC Watchdog is enabled - # Now redirect to DC Watchdog with SSO - return redirect('/auth/watchdog/sso') - else: - return render_template_string(''' - - - Error - -

❌ Failed to Save API Key

-

Could not save the API key. Please try again.

- Return to Dashboard - - - '''), 500 + """Restart obsolete popups without accepting unbound GET credentials.""" + response = redirect('/auth/account-link') + response.headers['Cache-Control'] = 'no-store' + response.headers['Referrer-Policy'] = 'no-referrer' + return response @app.route('/auth/watchdog/status') def watchdog_status(): diff --git a/src/cryptolabs_proxy/templates/nginx.conf.j2 b/src/cryptolabs_proxy/templates/nginx.conf.j2 index 5a77d27..d1dd2d8 100644 --- a/src/cryptolabs_proxy/templates/nginx.conf.j2 +++ b/src/cryptolabs_proxy/templates/nginx.conf.j2 @@ -1,3 +1,4 @@ +# TRACEWEAVER: file-role=pairing-routing; req=REQ-PAIR-001; trace=TRACE-PAIR-001; ver=VER-PAIR-001 worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; @@ -123,6 +124,20 @@ http { # ===================================================== # Auth server endpoints (public) + location ^~ /api/account-link/ { + proxy_pass http://127.0.0.1:8081; +{% if http_policy %} + proxy_hide_header X-Mgmt-Token; + proxy_cookie_flags fleet_session secure httponly samesite=lax; +{% endif %} + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Cookie $http_cookie; + } + location /auth/ { proxy_pass http://127.0.0.1:8081/auth/; {% if http_policy %} diff --git a/tests/fixtures/pairing-producer-v1.json b/tests/fixtures/pairing-producer-v1.json new file mode 100644 index 0000000..949291f --- /dev/null +++ b/tests/fixtures/pairing-producer-v1.json @@ -0,0 +1,125 @@ +{ + "schema": "cryptolabs-pairing-producer-fixture/1", + "source": "Actual CLAI_IPMI_SSO pairing_status handler; synthetic issuer only", + "issuer_input": { + "user_id": 123, + "email": "customer@example.test", + "display_name": "Customer", + "api_key": "sk-ipmi-FAKE" + }, + "installation_id": "b039929d-2dd5-4ac9-9188-486d381bdac9", + "responses": { + "pending_response": { + "http": 400, + "body": { + "error": "authorization_pending", + "interval": 5 + }, + "headers": { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer" + } + }, + "slow_down": { + "http": 400, + "body": { + "error": "slow_down", + "interval": 10 + }, + "headers": { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer" + } + }, + "invalid_grant": { + "http": 400, + "body": { + "error": "invalid_grant" + }, + "headers": { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer" + } + }, + "success_response": { + "http": 200, + "body": { + "protocol_version": 1, + "status": "complete", + "client_id": "cryptolabs-ipmi-monitor", + "installation_id": "b039929d-2dd5-4ac9-9188-486d381bdac9", + "account": { + "id": 123, + "email": "customer@example.test", + "display_name": "Customer" + }, + "credential": { + "type": "legacy_ipmi_api_key", + "api_key": "sk-ipmi-FAKE", + "scope": "account_wide" + }, + "service_intent": "ipmi", + "authorization": "existing_service_validation_required" + }, + "headers": { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer" + } + }, + "controller_success_response": { + "http": 200, + "body": { + "protocol_version": 1, + "status": "complete", + "client_id": "cryptolabs-dc-controller", + "installation_id": "b039929d-2dd5-4ac9-9188-486d381bdac9", + "account": { + "id": 123, + "email": "customer@example.test", + "display_name": "Customer" + }, + "credential": { + "type": "legacy_ipmi_api_key", + "api_key": "sk-ipmi-FAKE", + "scope": "account_wide" + }, + "service_intent": "watchdog", + "authorization": "existing_service_validation_required" + }, + "headers": { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer" + } + }, + "access_denied": { + "http": 400, + "body": { + "error": "access_denied" + }, + "headers": { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer" + } + }, + "expired_token": { + "http": 400, + "body": { + "error": "expired_token" + }, + "headers": { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer" + } + }, + "temporarily_unavailable": { + "http": 503, + "body": { + "error": "temporarily_unavailable" + }, + "headers": { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer" + } + } + } +} diff --git a/tests/fixtures/pairing-wire-v1.json b/tests/fixtures/pairing-wire-v1.json new file mode 100644 index 0000000..e4f8265 --- /dev/null +++ b/tests/fixtures/pairing-wire-v1.json @@ -0,0 +1,185 @@ +{ + "schema": "cryptolabs-pairing-wire-fixture/1", + "basis": "Specification-derived RED fixture; producer-emitted comparison required before final GREEN acceptance.", + "contract_sha256": "abd8b95925c92e7a70f6bdd5f0f7d2bcb2e5cbdcf822699397c5e130d6b3aa70", + "installation_id": "b039929d-2dd5-4ac9-9188-486d381bdac9", + "transport": { + "origin": "https://www.cryptolabs.co.za", + "rest_prefix": "/wp-json/cryptolabs/v1", + "verification_uri": "https://www.cryptolabs.co.za/?clai_pair=1", + "rules": [ + "HTTPS certificate verification required; fixed origin/endpoints, no client-supplied URL and no credential-bearing redirect following.", + "Cache-Control: no-store; Referrer-Policy: no-referrer on all pairing replies/pages. No third-party assets on verification/consent page.", + "Never put device codes/account keys in URLs, browser JSON/HTML/storage, postMessage, logs or exception text. Only start/status backend responses may carry the stated secrets.", + "No permissive CORS/browser token exchange. Reject malformed/extra credential fields, duplicate parameters, arrays for scalars and unsupported client IDs.", + "Do not treat public client_id, installation_id or display label as verified software/domain identity." + ] + }, + "local_routes": { + "start": "POST /api/account-link/start", + "status": "POST /api/account-link/status", + "commit": "POST /api/account-link/commit", + "cancel": "POST /api/account-link/cancel", + "config": "GET /api/account-link/config" + }, + "start_request": { + "client_id": "cryptolabs-ipmi-monitor", + "installation_id": "b039929d-2dd5-4ac9-9188-486d381bdac9", + "installation_name": "Isolated monitor" + }, + "start_response": { + "protocol_version": 1, + "device_code": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", + "user_code": "01-ABCD-EFGH", + "verification_uri": "https://www.cryptolabs.co.za/?clai_pair=1", + "expires_in": 300, + "interval": 5 + }, + "success_response": { + "protocol_version": 1, + "status": "complete", + "client_id": "cryptolabs-ipmi-monitor", + "installation_id": "b039929d-2dd5-4ac9-9188-486d381bdac9", + "account": { + "id": 123, + "email": "customer@example.test", + "display_name": "Customer" + }, + "credential": { + "type": "legacy_ipmi_api_key", + "api_key": "sk-ipmi-FAKE", + "scope": "account_wide" + }, + "service_intent": "ipmi", + "authorization": "existing_service_validation_required" + }, + "pending_response": { + "http": 400, + "body": { + "error": "authorization_pending", + "interval": 5 + } + }, + "errors": { + "invalid_request": { + "http": 400, + "meaning": "Malformed bounded input; no mutation/credential." + }, + "invalid_grant": { + "http": 400, + "meaning": "Unknown/mismatched code/client/secret, already consumed or superseded slot; generic no account disclosure." + }, + "expired_token": { + "http": 400, + "meaning": "Matched legitimate attempt expired; no credential." + }, + "authorization_pending": { + "http": 400, + "meaning": "Valid backend request, not yet approved." + }, + "slow_down": { + "http": 400, + "meaning": "Poll too early; increase interval by 5 seconds for remaining attempt; atomic next_poll update." + }, + "access_denied": { + "http": 400, + "meaning": "Explicit denial; local attempt ends." + }, + "authentication_required": { + "http": 401, + "meaning": "Consent needs authenticated website session." + }, + "invalid_consent": { + "http": 403, + "meaning": "Cookie/nonce/user/code consent invalid." + }, + "email_verification_required": { + "http": 403, + "meaning": "Use existing verification flow; no email policy bypass." + }, + "account_setup_required": { + "http": 409, + "meaning": "Existing issuer/provisioning cannot supply supported credential; direct user to existing signup/account page, no arbitrary creation policy." + }, + "temporarily_unavailable": { + "http": 503, + "meaning": "Persistence/query/issuer failure or exhausted CAS; preserve old link, never deliver credential on uncertain consumption." + }, + "rate_limited": { + "http": 429, + "meaning": "Retry-After provided; capacity or rate limiter exhausted." + } + }, + "local_browser": { + "start_request": {}, + "start_response": { + "status": "pending", + "attempt_id": "", + "user_code": "XX-XXXX-XXXX", + "verification_uri": "https://www.cryptolabs.co.za/?clai_pair=1", + "expires_in": 300, + "interval": 5 + }, + "status_request": { + "attempt_id": "" + }, + "pending_response": { + "status": "pending", + "interval": 5 + }, + "staged_response": { + "status": "awaiting_confirmation", + "account": { + "id": 123, + "email": "customer@example.test", + "display_name": "Customer" + }, + "service_status": "available|unavailable|not_entitled|unknown", + "requires_confirmation": true, + "can_commit": true + }, + "blocked_replacement": "Same staged response but can_commit=false with reason=service_validation_failed when replacing an existing different credential that the new candidate cannot validate; previous configuration retained.", + "commit_request": { + "attempt_id": "", + "confirm": true + }, + "commit_response": { + "status": "linked", + "account": { + "id": 123, + "email": "customer@example.test", + "display_name": "Customer" + }, + "service_status": "available|unavailable|not_entitled|unknown" + }, + "cancel_request": { + "attempt_id": "" + }, + "cancel_response": { + "status": "cancelled" + } + }, + "ai_validation": { + "valid": true, + "tier": "standard", + "max_servers": 125, + "features": [ + "chat", + "summary" + ], + "email": "customer@example.test", + "customer_id": "customer-123", + "user_id": 123 + }, + "wp_validation": { + "valid": true, + "user_id": 123, + "email": "customer@example.test", + "litellm_api_key": "SYNTHETIC-NEVER-COPY-PROVIDER-KEY", + "dc_overview_instances": [ + { + "private": "SYNTHETIC-NEVER-COPY-INSTANCE" + } + ] + } +} diff --git a/tests/test_account_link.py b/tests/test_account_link.py new file mode 100644 index 0000000..36381ff --- /dev/null +++ b/tests/test_account_link.py @@ -0,0 +1,551 @@ +# TRACEWEAVER: file-role=pairing-verification; req=REQ-PAIR-004; trace=TRACE-PAIR-004; ver=VER-PAIR-004 +# TRACEWEAVER: verifies=VER-PAIR-004; req=REQ-PAIR-004; trace=TRACE-PAIR-004 +"""PAIR-20261028-01: real Flask/persistent boundary; only HTTPS is substituted. + +The RED wire fixture remains specification-derived. Success envelopes consumed +by the network substitute are copied from actual WordPress handler output. +""" + +import copy +import json +import multiprocessing +import os +import stat +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest +import requests + +import cryptolabs_proxy.auth as auth + + +WIRE = json.loads((Path(__file__).parent / 'fixtures/pairing-wire-v1.json').read_text()) +PRODUCER = json.loads((Path(__file__).parent / 'fixtures/pairing-producer-v1.json').read_text()) +PREFIX = WIRE['transport']['origin'] + WIRE['transport']['rest_prefix'] +CLIENT_ID = 'cryptolabs-dc-controller' +OLD_KEY = 'sk-ipmi-' + 'a' * 48 +NEW_KEY = WIRE['success_response']['credential']['api_key'] + + +class Reply: + """Faithful HTTP response boundary, without substituting client validation.""" + + def __init__(self, payload, status=200): + self.status_code = status + self.content = json.dumps(payload).encode() + self.text = self.content.decode() + self.headers = {'Content-Type': 'application/json', 'Cache-Control': 'no-store'} + self.payload = payload + + def json(self): + return copy.deepcopy(self.payload) + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(response=self) + + +@pytest.fixture +def peer(monkeypatch): + """Fixed synthetic WP emitter; callbacks expose real in-flight races.""" + class Peer: + calls = None + installation_id = None + status_payload = None + status_code = 200 + on_status = None + + def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + assert kwargs.get('verify', True) is not False + assert kwargs.get('allow_redirects') is False + assert kwargs.get('timeout') + if url == PREFIX + '/pairing/start': + body = kwargs['json'] + assert set(body) == {'client_id', 'installation_id', 'installation_name'} + assert body['client_id'] == CLIENT_ID + self.installation_id = body['installation_id'] + return Reply(WIRE['start_response'], 201) + if url == PREFIX + '/pairing/status': + assert kwargs['headers']['Authorization'] == 'Bearer ' + WIRE['start_response']['device_code'] + assert kwargs['json'] == {'client_id': CLIENT_ID, 'user_code': WIRE['start_response']['user_code']} + if self.on_status: + self.on_status() + body = copy.deepcopy(self.status_payload or PRODUCER['responses']['controller_success_response']['body']) + if self.status_payload is None: + body['installation_id'] = self.installation_id + return Reply(body, self.status_code) + pytest.fail('Unexpected outbound URL: ' + url) + + instance = Peer() + instance.calls = [] + monkeypatch.setattr(auth.requests, 'post', instance.post) + monkeypatch.setattr(auth, 'WATCHDOG_API_KEY', '') + return instance + + +def config(client): + response = client.get('/api/account-link/config') + assert response.status_code == 200, response.get_data(as_text=True) + assert response.headers['Cache-Control'] == 'no-store' + assert set(response.json) == {'csrf_token'} + assert len(response.json['csrf_token']) >= 43 + return {'X-CSRF-Token': response.json['csrf_token'], 'Origin': 'http://localhost'} + + +def start(client, headers): + response = client.post('/api/account-link/start', json={}, headers=headers) + assert response.status_code == 200, response.get_data(as_text=True) + payload = response.json + assert set(payload) == {'status', 'attempt_id', 'user_code', 'verification_uri', 'expires_in', 'interval'} + assert payload['status'] == 'pending' + assert payload['expires_in'] == 300 + assert payload['interval'] == 5 + assert payload['verification_uri'] == WIRE['transport']['verification_uri'] + return payload['attempt_id'] + + +def post(client, action, attempt, headers, **extra): + return client.post('/api/account-link/' + action, json={'attempt_id': attempt, **extra}, headers=headers) + + +def clone(client): + """A new Flask worker with the same real disk directory and signed cookie.""" + app = auth.create_flask_auth_app() + app.config.update(TESTING=True, SESSION_COOKIE_SECURE=False) + other = app.test_client() + other.set_cookie('fleet_session', client.get_cookie('fleet_session').value) + return other + + +def assert_no_secrets(response, client): + content = response.get_data(as_text=True) + assert WIRE['start_response']['device_code'] not in content + assert NEW_KEY not in content + with client.session_transaction() as session: + encoded = json.dumps(dict(session)) + assert WIRE['start_response']['device_code'] not in encoded + assert NEW_KEY not in encoded + + +def _commit_in_process(data_dir, cookie, headers, attempt, barrier, results): + """A fresh interpreter cannot accidentally share in-memory attempt state.""" + auth.DATA_DIR = Path(data_dir) + auth.AUTH_SECRET_KEY = 'test-secret-key-1234' + auth.WATCHDOG_API_KEY = '' + app = auth.create_flask_auth_app() + app.config.update(TESTING=True, SESSION_COOKIE_SECURE=False) + client = app.test_client() + client.set_cookie('fleet_session', cookie) + barrier.wait(timeout=10) + response = post(client, 'commit', attempt, headers, confirm=True) + results.put((response.status_code, response.json)) + + +def _legacy_write_in_process(data_dir, barrier, results): + auth.DATA_DIR = Path(data_dir) + auth.WATCHDOG_API_KEY = '' + barrier.wait(timeout=10) + results.put(('legacy', auth.save_watchdog_api_key('sk-ipmi-' + 'c' * 48))) + + +def _crash_after_key_replacement(data_dir, cookie, headers, attempt): + import cryptolabs_proxy.account_link as pairing + auth.DATA_DIR = Path(data_dir) + auth.AUTH_SECRET_KEY = 'test-secret-key-1234' + auth.WATCHDOG_API_KEY = '' + original = pairing.atomic_write + def crash(path, content, mode=0o600): + original(path, content, mode) + if path.name == 'watchdog_api_key': + os._exit(73) + pairing.atomic_write = crash + app = auth.create_flask_auth_app() + app.config.update(TESTING=True, SESSION_COOKIE_SECURE=False) + client = app.test_client() + client.set_cookie('fleet_session', cookie) + post(client, 'commit', attempt, headers, confirm=True) + + +@pytest.mark.parametrize('identity', ['anonymous', 'readonly', 'disabled', 'demoted', 'password_reset']) +def test_config_requires_live_admin(client, admin_user, readonly_user, identity, peer): + """REQ-PAIR-004/005: signed stale roles never confer local authority.""" + if identity != 'anonymous': + user = readonly_user if identity == 'readonly' else admin_user + client.post('/auth/login', data=user) + if identity == 'disabled': + auth.update_user('admin', enabled=False) + elif identity == 'demoted': + auth.update_user('admin', role='readonly') + elif identity == 'password_reset': + auth.update_user('admin', require_password_change=True) + response = client.get('/api/account-link/config') + assert response.status_code in (401, 403) + assert peer.calls == [] + + +@pytest.mark.parametrize('action', ['start', 'status', 'commit', 'cancel']) +@pytest.mark.parametrize('bad_header', ['missing_csrf', 'wrong_origin', 'cross_site']) +def test_mutations_require_csrf_and_exact_origin(logged_in_admin, action, bad_header, peer): + """REQ-PAIR-004: cross-origin requests cannot start or change a link.""" + headers = config(logged_in_admin) + if bad_header == 'missing_csrf': + headers.pop('X-CSRF-Token') + elif bad_header == 'wrong_origin': + headers['Origin'] = 'https://localhost:443' + else: + headers['Sec-Fetch-Site'] = 'cross-site' + response = post(logged_in_admin, action, 'unknown', headers, confirm=True) + assert response.status_code == 403 + assert peer.calls == [] + + +@pytest.mark.parametrize('initial_key', ['', NEW_KEY], ids=['first_link', 'same_key']) +def test_outbound_pairing_stages_then_persists_once_across_workers(logged_in_admin, peer, isolate_data_dir, initial_key): + """REQ-PAIR-001/003/004/005: outbound only, no secret cookie, intentional commit.""" + if initial_key: + auth.save_watchdog_api_key(initial_key) + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + other = clone(logged_in_admin) + staged = post(other, 'status', attempt, headers) + assert staged.status_code == 200 + assert staged.json['status'] == 'awaiting_confirmation' + assert staged.json['account'] == WIRE['success_response']['account'] + assert staged.json['requires_confirmation'] is True + assert staged.json['can_commit'] is True + assert auth.get_watchdog_api_key() == initial_key + assert_no_secrets(staged, other) + for path in isolate_data_dir.rglob('*'): + if path.is_file() and WIRE['start_response']['device_code'].encode() in path.read_bytes(): + assert stat.S_IMODE(path.stat().st_mode) & 0o077 == 0 + unconfirmed = post(other, 'commit', attempt, headers) + assert unconfirmed.status_code == 400 + assert auth.get_watchdog_api_key() == initial_key + linked = post(other, 'commit', attempt, headers, confirm=True) + assert linked.status_code == 200 + assert linked.json['status'] == 'linked' + assert auth.get_watchdog_api_key() == NEW_KEY + assert auth.is_watchdog_verified() + assert_no_secrets(linked, other) + dashboard = other.get('/auth/watchdog/sso') + assert dashboard.location == auth.WATCHDOG_URL.rstrip('/') + '/login' + dashboard_json = other.get('/auth/watchdog/sso-url') + assert dashboard_json.json['sso_url'] == dashboard.location + assert_no_secrets(dashboard_json, other) + callback = other.get('/auth/watchdog/callback?api_key=' + OLD_KEY) + assert callback.location == '/auth/account-link' + assert auth.get_watchdog_api_key() == NEW_KEY + assert auth._pair_store().paired_account() == WIRE['success_response']['account'] + assert post(logged_in_admin, 'commit', attempt, headers, confirm=True).status_code in (400, 409, 410) + assert post(logged_in_admin, 'status', attempt, headers).status_code in (400, 409, 410) + + +@pytest.mark.parametrize('outcome', ['denied', 'network', 'expired', 'mismatched_account', 'extra_credential']) +def test_failed_status_preserves_existing_link(logged_in_admin, peer, monkeypatch, outcome): + """REQ-PAIR-003/004/005: no failed or malformed peer result replaces credentials.""" + auth.save_watchdog_api_key(OLD_KEY) + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + payload = copy.deepcopy(WIRE['success_response']) + payload.update(client_id=CLIENT_ID, installation_id=peer.installation_id, service_intent='watchdog') + if outcome == 'denied': + peer.status_payload, peer.status_code = {'error': 'access_denied'}, 400 + elif outcome == 'network': + def unavailable(): + raise requests.ConnectionError('synthetic unreachable service') + peer.on_status = unavailable + elif outcome == 'expired': + now = auth.time.time() + monkeypatch.setattr(auth.time, 'time', lambda: now + 301) + elif outcome == 'mismatched_account': + payload['account']['id'] = True + peer.status_payload = payload + else: + payload['credential']['litellm_api_key'] = 'SYNTHETIC-UNEXPECTED-SECRET' + peer.status_payload = payload + result = post(logged_in_admin, 'status', attempt, headers) + assert result.status_code in (400, 409, 410, 502, 503) + assert_no_secrets(result, logged_in_admin) + assert auth.get_watchdog_api_key() == OLD_KEY + assert post(logged_in_admin, 'commit', attempt, headers, confirm=True).status_code != 200 + + +@pytest.mark.parametrize('change', ['cancel', 'supersede', 'config_change', 'session_change', 'logout']) +def test_late_peer_result_cannot_restore_invalidated_attempt(logged_in_admin, peer, change): + """REQ-PAIR-003/004: real durable compare-current-state wins over late HTTPS.""" + auth.save_watchdog_api_key(OLD_KEY) + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + other = clone(logged_in_admin) + def invalidate(): + if change == 'cancel': + assert post(other, 'cancel', attempt, headers).status_code == 200 + elif change == 'supersede': + start(other, headers) + elif change == 'config_change': + auth.save_watchdog_api_key('sk-ipmi-' + 'b' * 48) + elif change == 'logout': + assert other.get('/auth/logout').status_code == 302 + else: + auth.update_user('admin', enabled=False) + peer.on_status = invalidate + response = post(logged_in_admin, 'status', attempt, headers) + assert response.status_code in (400, 401, 403, 409, 410) + assert auth.get_watchdog_api_key() != NEW_KEY + assert post(other, 'commit', attempt, headers, confirm=True).status_code != 200 + + +def test_parallel_workers_claim_only_one_poll_and_cancel_wins(logged_in_admin, peer): + """REQ-PAIR-003/004: actual filesystem store serializes workers, not fake atomics.""" + auth.save_watchdog_api_key(OLD_KEY) + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + first, second = clone(logged_in_admin), clone(logged_in_admin) + entered, release = threading.Event(), threading.Event() + def block(): + entered.set() + assert release.wait(5), 'test failed to release outbound barrier' + peer.on_status = block + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(post, first, 'status', attempt, headers) + try: + assert entered.wait(5) + competing = post(second, 'status', attempt, headers) + assert competing.status_code in (200, 409, 429) + assert len([url for url, _ in peer.calls if url.endswith('/pairing/status')]) == 1 + assert post(second, 'cancel', attempt, headers).status_code == 200 + finally: + release.set() + assert future.result().status_code in (400, 409, 410) + assert auth.get_watchdog_api_key() == OLD_KEY + + +def test_independent_processes_cannot_commit_same_stage_twice(logged_in_admin, peer, isolate_data_dir): + """REQ-PAIR-003/004: the real persistent transaction has one process winner.""" + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + assert post(logged_in_admin, 'status', attempt, headers).status_code == 200 + context = multiprocessing.get_context('spawn') + barrier, results = context.Barrier(2), context.Queue() + args = (str(isolate_data_dir), logged_in_admin.get_cookie('fleet_session').value, + headers, attempt, barrier, results) + processes = [context.Process(target=_commit_in_process, args=args) for _ in range(2)] + for process in processes: + process.start() + try: + outcomes = [results.get(timeout=15) for _ in processes] + assert sum(status == 200 for status, _ in outcomes) == 1 + assert sorted(status for status, _ in outcomes)[1] in (400, 409, 410) + finally: + for process in processes: + process.join(timeout=5) + if process.is_alive(): + process.terminate() + process.join(timeout=5) + assert all(process.exitcode == 0 for process in processes) + assert auth.get_watchdog_api_key() == NEW_KEY + + +def test_storage_failure_leaves_old_key_intact(logged_in_admin, peer, isolate_data_dir): + """REQ-PAIR-004: unwritable persistent storage fails closed at commit.""" + auth.save_watchdog_api_key(NEW_KEY) + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + assert post(logged_in_admin, 'status', attempt, headers).status_code == 200 + isolate_data_dir.chmod(0o500) + try: + response = post(logged_in_admin, 'commit', attempt, headers, confirm=True) + assert response.status_code == 503 + assert auth.get_watchdog_api_key() == NEW_KEY + finally: + isolate_data_dir.chmod(0o700) + + +def test_writer_failure_after_new_key_write_restores_unlinked_state(logged_in_admin, peer, monkeypatch): + import cryptolabs_proxy.account_link as pairing + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + assert post(logged_in_admin, 'status', attempt, headers).status_code == 200 + original, failures = pairing.atomic_write, [] + def fail_verified_once(path, content, mode=0o600): + if path.name == 'watchdog_verified' and not failures: + failures.append(True) + raise OSError('synthetic one-shot disk failure after key replacement') + return original(path, content, mode) + monkeypatch.setattr(pairing, 'atomic_write', fail_verified_once) + assert post(logged_in_admin, 'commit', attempt, headers, confirm=True).status_code == 503 + assert failures == [True] + assert auth.get_watchdog_api_key() == '' + assert not (auth.DATA_DIR / 'watchdog_api_key').exists() + assert not auth.is_watchdog_verified() + assert post(logged_in_admin, 'commit', attempt, headers, confirm=True).status_code == 409 + + +def test_process_crash_after_key_replace_recovers_unlinked_state_without_replay(logged_in_admin, peer, isolate_data_dir): + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + assert post(logged_in_admin, 'status', attempt, headers).status_code == 200 + process = multiprocessing.get_context('spawn').Process(target=_crash_after_key_replacement, + args=(str(isolate_data_dir), logged_in_admin.get_cookie('fleet_session').value, headers, attempt)) + process.start() + process.join(timeout=15) + if process.is_alive(): + process.terminate() + process.join(timeout=5) + assert process.exitcode == 73 + assert (isolate_data_dir / 'watchdog_api_key').read_text() == NEW_KEY + assert auth.get_watchdog_api_key() == '' + assert not (isolate_data_dir / 'watchdog_api_key').exists() + assert not auth.is_watchdog_verified() + assert post(logged_in_admin, 'commit', attempt, headers, confirm=True).status_code == 409 + + +def test_concurrent_legacy_writer_is_never_overwritten_by_stale_stage(logged_in_admin, peer, isolate_data_dir): + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + assert post(logged_in_admin, 'status', attempt, headers).status_code == 200 + context = multiprocessing.get_context('spawn') + barrier, results = context.Barrier(2), context.Queue() + commit = context.Process(target=_commit_in_process, args=(str(isolate_data_dir), + logged_in_admin.get_cookie('fleet_session').value, headers, attempt, barrier, results)) + legacy = context.Process(target=_legacy_write_in_process, args=(str(isolate_data_dir), barrier, results)) + for process in (commit, legacy): + process.start() + try: + outcomes = [results.get(timeout=15) for _ in (commit, legacy)] + assert ('legacy', True) in outcomes + assert [status for status, _ in outcomes if status != 'legacy'][0] in (200, 409) + finally: + for process in (commit, legacy): + process.join(timeout=5) + if process.is_alive(): + process.terminate() + process.join(timeout=5) + assert all(process.exitcode == 0 for process in (commit, legacy)) + assert auth.get_watchdog_api_key() == 'sk-ipmi-' + 'c' * 48 + + +def test_different_session_cannot_redeem_or_cancel_attempt(logged_in_admin, admin_user, peer): + """REQ-PAIR-004: same username after a fresh login is a different binding.""" + auth.save_watchdog_api_key(OLD_KEY) + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + fresh = auth.create_flask_auth_app().test_client() + fresh.post('/auth/login', data=admin_user) + fresh_headers = config(fresh) + for action in ('status', 'commit', 'cancel'): + assert post(fresh, action, attempt, fresh_headers, confirm=True).status_code in (400, 403, 409, 410) + assert len(peer.calls) == 1 + assert auth.get_watchdog_api_key() == OLD_KEY + + +def test_environment_conflict_blocks_commit_without_reparenting(logged_in_admin, peer, monkeypatch): + """REQ-PAIR-005/006: Overview env precedence cannot silently diverge from file.""" + monkeypatch.setattr(auth, 'WATCHDOG_API_KEY', OLD_KEY) + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + assert post(logged_in_admin, 'status', attempt, headers).status_code == 200 + result = post(logged_in_admin, 'commit', attempt, headers, confirm=True) + assert result.status_code == 409 + assert result.json['error'] == 'configuration_conflict' + assert auth.get_watchdog_api_key() == OLD_KEY + assert not (auth.DATA_DIR / 'watchdog_api_key').exists() + + +@pytest.mark.parametrize('field,value', [('api_key', NEW_KEY), ('device_code', 'forged'), ('account', {'id': 999}), ('verification_uri', 'https://attacker.test')]) +def test_browser_cannot_supply_credential_identity_or_destination(logged_in_admin, peer, field, value): + """REQ-PAIR-001/004/005: browser hints cannot become authority.""" + headers = config(logged_in_admin) + result = logged_in_admin.post('/api/account-link/start', json={field: value}, headers=headers) + assert result.status_code == 400 + assert peer.calls == [] + + +@pytest.mark.parametrize('source', ['nginx/nginx.conf', 'src/cryptolabs_proxy/templates/nginx.conf.j2']) +def test_pairing_api_is_routed_to_local_auth_only(source, tmp_path): + """REQ-PAIR-001/004: shipped and generated nginx expose only the frozen API prefix.""" + if source.endswith('.j2'): + from cryptolabs_proxy.config import generate_nginx_config + from cryptolabs_proxy.services import DEFAULT_SERVICES + generate_nginx_config(tmp_path, 'fleet.example.test', services=dict(DEFAULT_SERVICES)) + text = (tmp_path / 'nginx.conf').read_text() + else: + text = (Path(__file__).parents[1] / source).read_text() + assert 'location ^~ /api/account-link/ {' in text + block = text.split('location ^~ /api/account-link/ {', 1)[1].split('}', 1)[0] + assert 'proxy_pass http://127.0.0.1:8081;' in block + assert 'proxy_set_header Host $http_host;' in block + assert 'X-Fleet-Auth-Role' not in block + + +def test_link_page_and_unlinked_entry_use_pairing_without_callback_or_key_url(logged_in_admin, peer): + response = logged_in_admin.get('/auth/account-link') + assert response.status_code == 200 + assert response.headers['Cache-Control'] == 'no-store' + assert response.headers['Referrer-Policy'] == 'no-referrer' + assert 'Confirm this account' in response.text + assert 'redirect_uri=' not in response.text + assert auth.WATCHDOG_URL.rstrip('/') + '/login' in response.text + assert_no_secrets(response, logged_in_admin) + assert logged_in_admin.get('/auth/watchdog/sso').location == '/auth/account-link' + assert logged_in_admin.get('/auth/watchdog/sso-url').json['sso_url'] == '/auth/account-link' + + +def test_nondefault_origin_port_is_required_and_preserved(logged_in_admin, peer): + response = logged_in_admin.get('/api/account-link/config', base_url='https://localhost:8443') + assert response.status_code == 200 + headers = {'X-CSRF-Token': response.json['csrf_token'], 'Origin': 'https://localhost'} + assert logged_in_admin.post('/api/account-link/start', json={}, headers=headers, + base_url='https://localhost:8443').status_code == 403 + headers['Origin'] = 'https://localhost:8443' + assert logged_in_admin.post('/api/account-link/start', json={}, headers=headers, + base_url='https://localhost:8443').status_code == 200 + + +@pytest.mark.parametrize('identity', ['anonymous', 'admin']) +@pytest.mark.parametrize('query', ['', '?api_key=' + NEW_KEY + '&user_email=forged@example.test&subscription=active']) +def test_legacy_callback_discards_query_without_any_link_mutation(logged_in_admin, identity, query): + """REQ-PAIR-004/005: old popups restart; a GET cannot write account authority.""" + auth.save_watchdog_api_key(OLD_KEY) + before = (auth.DATA_DIR / 'account_link.json').read_bytes() + caller = logged_in_admin if identity == 'admin' else auth.create_flask_auth_app().test_client() + response = caller.get('/auth/watchdog/callback' + query) + assert response.status_code == 302 + assert response.location == '/auth/account-link' + assert response.headers['Cache-Control'] == 'no-store' + assert response.headers['Referrer-Policy'] == 'no-referrer' + assert auth.get_watchdog_api_key() == OLD_KEY + assert not auth.is_watchdog_verified() + assert (auth.DATA_DIR / 'account_link.json').read_bytes() == before + assert NEW_KEY not in response.text + assert 'forged@example.test' not in response.text + + +@pytest.mark.parametrize('stage_format', ['current', 'previous_adapter']) +def test_wp_identity_cannot_replace_different_working_watchdog_key(logged_in_admin, peer, stage_format): + """REQ-PAIR-004/005: WP account recognition never proves hosted permission.""" + auth.save_watchdog_api_key(OLD_KEY) + auth.set_watchdog_verified() + headers = config(logged_in_admin) + attempt = start(logged_in_admin, headers) + staged = post(logged_in_admin, 'status', attempt, headers) + assert staged.status_code == 200 + assert staged.json['account'] == WIRE['success_response']['account'] + assert staged.json['service_status'] == 'unknown' + assert staged.json['can_commit'] is False + assert staged.json['reason'] == 'service_validation_failed' + assert logged_in_admin.get('/auth/watchdog/callback?api_key=' + NEW_KEY).location == '/auth/account-link' + assert auth.get_watchdog_api_key() == OLD_KEY + if stage_format == 'previous_adapter': + # An installed upgrade may inherit a stage the previous adapter marked + # committable. The fresh commit guard must distrust that cached decision. + store = auth._pair_store() + with store.locked() as state: + state['attempt']['can_commit'] = True + store.save(state) + assert post(logged_in_admin, 'commit', attempt, headers, confirm=True).status_code == 409 + assert auth.get_watchdog_api_key() == OLD_KEY + assert auth.is_watchdog_verified() diff --git a/traceability-matrix.md b/traceability-matrix.md new file mode 100644 index 0000000..8c0c607 --- /dev/null +++ b/traceability-matrix.md @@ -0,0 +1,39 @@ +# CryptoLabs Proxy traceability + +## Traceability Matrix + +| Trace | Requirement | Implementation | Verification | Validation | Status | +| --- | --- | --- | --- | --- | --- | +| TRACE-PAIR-001 | REQ-PAIR-001 | src/cryptolabs_proxy/auth.py; src/cryptolabs_proxy/account_link.py; tests/test_account_link.py; nginx/nginx.conf; src/cryptolabs_proxy/templates/nginx.conf.j2 | VER-PAIR-001 | VAL-PAIR-001 | Owner-approved source authority; reviewed scope | +| TRACE-PAIR-002 | REQ-PAIR-002 | src/cryptolabs_proxy/auth.py; src/cryptolabs_proxy/account_link.py; tests/test_account_link.py; nginx/nginx.conf; src/cryptolabs_proxy/templates/nginx.conf.j2 | VER-PAIR-002 | VAL-PAIR-002 | Owner-approved source authority; reviewed scope | +| TRACE-PAIR-003 | REQ-PAIR-003 | src/cryptolabs_proxy/auth.py; src/cryptolabs_proxy/account_link.py; tests/test_account_link.py; nginx/nginx.conf; src/cryptolabs_proxy/templates/nginx.conf.j2 | VER-PAIR-003 | VAL-PAIR-003 | Owner-approved source authority; reviewed scope | +| TRACE-PAIR-004 | REQ-PAIR-004 | src/cryptolabs_proxy/auth.py; src/cryptolabs_proxy/account_link.py; tests/test_account_link.py; nginx/nginx.conf; src/cryptolabs_proxy/templates/nginx.conf.j2 | VER-PAIR-004 | VAL-PAIR-004 | Owner-approved source authority; reviewed scope | +| TRACE-PAIR-005 | REQ-PAIR-005 | src/cryptolabs_proxy/auth.py; src/cryptolabs_proxy/account_link.py; tests/test_account_link.py; nginx/nginx.conf; src/cryptolabs_proxy/templates/nginx.conf.j2 | VER-PAIR-005 | VAL-PAIR-005 | Owner-approved source authority; reviewed scope | +| TRACE-PAIR-006 | REQ-PAIR-006 | src/cryptolabs_proxy/auth.py; src/cryptolabs_proxy/account_link.py; tests/test_account_link.py; nginx/nginx.conf; src/cryptolabs_proxy/templates/nginx.conf.j2 | VER-PAIR-006 | VAL-PAIR-006 | Owner-approved source authority; reviewed scope | + +## Account pairing acceptance boundary + +The TRACE-PAIR rows identify owner-approved and quality-reviewed source implementation scope only. Verification is in progress. Central MariaDB/cookie/consent, required CI, independent final review and actual installed Fleet/public-service/paid acceptance remain held until their separate evidence exists. These authority labels do not close any requirement or runtime outcome. + +## Code Anchor Evidence + +| Artifact Path | Requirement | Trace | Verification | Anchor Type | Role | +| --- | --- | --- | --- | --- | --- | +| src/cryptolabs_proxy/account_link.py | REQ-PAIR-003 | TRACE-PAIR-003 | VER-PAIR-003 | file-role | pairing-store | +| src/cryptolabs_proxy/auth.py | REQ-PAIR-004 | TRACE-PAIR-004 | VER-PAIR-004 | file-role | account-pairing-routes | +| nginx/nginx.conf | REQ-PAIR-001 | TRACE-PAIR-001 | VER-PAIR-001 | file-role | pairing-routing | +| src/cryptolabs_proxy/templates/nginx.conf.j2 | REQ-PAIR-001 | TRACE-PAIR-001 | VER-PAIR-001 | file-role | pairing-routing | +| tests/test_account_link.py | REQ-PAIR-004 | TRACE-PAIR-004 | VER-PAIR-004 | verifies | pairing-verification | +| src/cryptolabs_proxy/auth.py | REQ-PAIR-004 | TRACE-PAIR-004 | VER-PAIR-004 | entrypoint | trace-anchor-target | +| src/cryptolabs_proxy/account_link.py | REQ-PAIR-001 | TRACE-PAIR-001 | VER-PAIR-001 | entrypoint | trace-anchor-target | +| src/cryptolabs_proxy/account_link.py | REQ-PAIR-003 | TRACE-PAIR-003 | VER-PAIR-003 | entrypoint | trace-anchor-target | +| src/cryptolabs_proxy/account_link.py | REQ-PAIR-004 | TRACE-PAIR-004 | VER-PAIR-004 | entrypoint | trace-anchor-target | + +## Generated pairing fixture provenance + +These are generated raw data copies within the approved verification scope. Inline trace fields would change the canonical producer/specification artifact. The external mapping is the supported generated-file exception, not a claim of inline source coverage. Exact producer bytes are emitted by the actual WordPress handler under synthetic credentials and compared by consumer tests. No API body, assertion or scanner rule changes. Source implementation and installed acceptance remain separately gated. + +| Artifact Path | Requirement | Trace | Verification | Anchor Type | Provenance / acceptance | +| --- | --- | --- | --- | --- | --- | +| tests/fixtures/pairing-producer-v1.json | REQ-PAIR-001; REQ-PAIR-003; REQ-PAIR-004; REQ-PAIR-005; REQ-PAIR-006 | TRACE-PAIR-001; TRACE-PAIR-003; TRACE-PAIR-004; TRACE-PAIR-005; TRACE-PAIR-006 | VER-PAIR-001; VER-PAIR-003; VER-PAIR-004; VER-PAIR-005; VER-PAIR-006 | Exception | Generated data approved for exact-byte provenance mapping; SHA256 d58a17f90c33e935b4307b5816e462d0445ee984b72e1d03948dfa3cd9d9404d; regenerate and drift-check from wordpress/actual-producer-fixture.json | +| tests/fixtures/pairing-wire-v1.json | REQ-PAIR-001; REQ-PAIR-003; REQ-PAIR-004; REQ-PAIR-005; REQ-PAIR-006 | TRACE-PAIR-001; TRACE-PAIR-003; TRACE-PAIR-004; TRACE-PAIR-005; TRACE-PAIR-006 | VER-PAIR-001; VER-PAIR-003; VER-PAIR-004; VER-PAIR-005; VER-PAIR-006 | Exception | Generated data approved for exact-byte provenance mapping; SHA256 8ccd5db5c28c55da4e9118c7f913e45532e3213007b8e98a4af214f4bb20d652; regenerate and drift-check from fixtures/pairing-wire-v1.json |