Files
admin/_docs/02_document/tests/security-tests.md
T
Oleksandr Bezdieniezhnykh a77b3f8a59 [AZ-529] [AZ-530] Cycle-2 documentation refresh
Refreshes _docs/02_document/ to reflect the cycle-2 auth-modernization
+ CMMC hardening landings (AZ-531..AZ-538). Authoritative source for
the ripple set is ripple_log_cycle2.md.

Covered:
- architecture.md (section 1 rewritten, ADRs 6-9 added)
- data_model.md (sessions, audit_events, user columns, migrations)
- system-flows.md (F1 rewritten; F11-F17 added; F2/F7/F9 minor)
- module-layout.md (cycle-2 sub-component table)
- diagrams/flows/flow_login.md (dual-token + MFA)
- components/{01_data_layer,03_auth_and_security,05_admin_api}
- modules/ (12 new, 8 modified — full Argon2id/ES256/MFA/refresh
  /mission/session/audit/jwks rollup)
- tests/{blackbox,security,traceability-matrix}

Step 13 (Update Docs) output for cycle 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 09:22:53 +03:00

402 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Security Tests
### NFT-SEC-01: Unauthenticated Access to Protected Endpoints
**Summary**: All protected endpoints reject requests without JWT token.
**Traces to**: AC-18
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | GET /users (no JWT) | HTTP 401 |
| 2 | POST /resources/{folder} upload (no JWT) | HTTP 401 |
| 3 | GET /resources/list/{folder} (no JWT) | HTTP 401 |
| 4 | PUT /users/{email}/set-role/{role} (no JWT) | HTTP 401 |
| 5 | DELETE /users/{email} (no JWT) | HTTP 401 |
| 6 | POST /classes (no JWT) | HTTP 401 |
**Pass criteria**: All remaining protected endpoints return HTTP 401 for unauthenticated requests.
> Earlier revisions of this scenario also covered `POST /resources/get`, `POST /resources/check`, and `GET /resources/get-installer`. Those endpoints were removed (AZ-197 / cycle 2) and now return 404 — see FT-N-15 (AZ-197 routes) and FT-N-16 (cycle-2 routes) in `blackbox-tests.md`.
---
### NFT-SEC-02: Non-Admin Access to Admin Endpoints
**Summary**: Non-ApiAdmin users cannot access admin-only endpoints.
**Traces to**: AC-9
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Login as Operator role user | HTTP 200, JWT token |
| 2 | POST /users (register) with Operator JWT | HTTP 403 |
| 3 | PUT /users/role with Operator JWT | HTTP 403 |
| 4 | PUT /users/enable with Operator JWT | HTTP 403 |
| 5 | DELETE /users with Operator JWT | HTTP 403 |
**Pass criteria**: All admin endpoints return HTTP 403 for non-admin users
---
### NFT-SEC-03: Password Not Returned in User List
**Summary**: User list endpoint does not expose password hashes.
**Traces to**: AC-17
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | GET /users with ApiAdmin JWT | HTTP 200, JSON array |
| 2 | Inspect each user object in response | No `passwordHash` or `password` field present |
**Pass criteria**: Password hash is never included in API responses
---
### NFT-SEC-04: Expired JWT Token Rejection
**Summary**: Expired JWT tokens are rejected.
**Traces to**: AC-4, AC-18
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Craft a JWT with `exp` set to past timestamp (same signing key) | Token string |
| 2 | GET /users with expired JWT | HTTP 401 |
**Pass criteria**: Expired token returns HTTP 401
---
### NFT-SEC-05: Encryption Key Uniqueness — OBSOLETE (cycle 2, 2026-05-14)
The `POST /resources/get/{dataFolder?}` endpoint that this test exercised was removed along with `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` and `ResourcesService.GetEncryptedResource`. Per-user resource encryption is no longer part of the system. ID retained for traceability stability; do not regenerate the spec body until a full `/test-spec` rerun.
---
### NFT-SEC-06: Disabled User Cannot Login
**Summary**: A disabled user account cannot authenticate.
**Traces to**: AC-9
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Register user, disable via PUT /users/enable | HTTP 200 |
| 2 | Attempt POST /login with disabled user credentials | HTTP 409 or HTTP 403 |
**Pass criteria**: Disabled user cannot obtain a JWT token
---
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
The scenarios below were appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the security-only / cryptography-invariant ACs in cycle 2. Functional flows live in `blackbox-tests.md` under the matching task. Numbering continues from NFT-SEC-06.
### NFT-SEC-07: New User Hashes Use Argon2id (AZ-536)
**Summary**: A freshly-registered user's `password_hash` is in Argon2id PHC format with parameters at or above the configured floor.
**Traces to**: AZ-536 AC-1
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /users (ApiAdmin JWT) registering `freshuser@azaion.com` with a known password | HTTP 200 |
| 2 | Read `users.password_hash` for `freshuser@azaion.com` directly from Postgres | Value starts with `$argon2id$v=19$m=` |
| 3 | Parse the PHC string parameters | `m ≥ 65536`, `t ≥ 3`, `p ≥ 1` |
**Pass criteria**: All new users land in Argon2id PHC format with at least the configured cost parameters; no SHA-384 base64 strings written for new accounts.
---
### NFT-SEC-08: Argon2id Verify Has No Remotely Observable Timing Leak (AZ-536)
**Summary**: `VerifyPassword` is constant-time across wrong passwords of various lengths; timing variance does not leak information about the candidate password.
**Traces to**: AZ-536 AC-5
**Preconditions**:
- User with Argon2id-hashed password
- Test environment with low concurrency (this test is sensitive to host noise — if it intermittently trips, widen the bound or warm Argon2 with a non-test login first; see cycle-2 carry-forward F6)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login with a wrong 8-char password, sample N=20 timings | Each → HTTP 409 WrongPassword |
| 2 | POST /login with a wrong 64-char password, sample N=20 timings | Each → HTTP 409 WrongPassword |
| 3 | Compute median of each sample; compare | `|median_8 median_64| / median_8 < 0.20` (within 20% of each other — Argon2id cost dominates string-comparison cost) |
**Pass criteria**: Wrong-password verify time is dominated by Argon2id cost, not by string-length-dependent comparison; no exploitable timing channel.
---
### NFT-SEC-09: Per-IP Rate Limit Returns 429 (AZ-537)
**Summary**: 11 `/login` requests from the same client IP within 60 s force the 11th into HTTP 429 with a `Retry-After` header.
**Traces to**: AZ-537 AC-1
**Preconditions**:
- Rate-limit `Auth:RateLimit:PerIp` set to 10 / 60 s sliding (the test env value)
- Test client preserves source IP across requests (E2E container-shared-IP caveat applies — see test_run_report cycle 2 skip note for the legitimate environment-mismatch skip)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login × 10 from the same IP within 5 s (any mix of right/wrong passwords) | HTTP 200 / HTTP 409 (within budget) |
| 2 | POST /login as the 11th request inside the 60 s window | HTTP 429; response includes `Retry-After` header (integer seconds) |
**Pass criteria**: 11th request inside the window is rejected with 429 + Retry-After. (Legitimate environment-mismatch skip in shared-IP container envs — verified by ASP.NET Core RateLimiter unit tests + manual probe documented in AZ-537 spec.)
---
### NFT-SEC-10: Per-Account Rate Limit Returns 429 (AZ-537)
**Summary**: 6 `/login` requests for the same email from 6 different IPs within 5 min force the 6th into HTTP 429.
**Traces to**: AZ-537 AC-2
**Preconditions**:
- Rate-limit `Auth:RateLimit:PerAccount` set to 5 / 5 min sliding
- Test ability to spoof / vary the source IP per request (e.g. via `X-Forwarded-For` if the app trusts a known forwarder, or a multi-host test fixture)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login for `alice@azaion.com` from IPs 1..5 within 1 min (any mix of right/wrong passwords) | HTTP 200 / HTTP 409 (within budget) |
| 2 | POST /login for `alice@azaion.com` from IP 6 inside the 5 min window | HTTP 429; `Retry-After` present |
**Pass criteria**: Per-account partition triggers independently of per-IP partition.
---
### NFT-SEC-11: Account Lockout Returns 423 Even For Correct Password (AZ-537)
**Summary**: Once `failed_login_count` hits the lockout threshold, the account returns HTTP 423 Locked even for subsequent correct-password attempts until `lockout_until` passes.
**Traces to**: AZ-537 AC-3
**Preconditions**:
- `Auth:Lockout:MaxAttempts = 10` (default)
- User `bob@azaion.com` with Argon2id-hashed password
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login for `bob@azaion.com` with wrong password × 10 (across IPs / within rate budget) | First 9 → HTTP 409 WrongPassword; 10th → HTTP 423 Locked OR final 409 followed by lockout flag |
| 2 | Read `users.lockout_until` and `users.failed_login_count` for `bob` | `lockout_until > now()`; counter at threshold |
| 3 | POST /login for `bob` with correct password immediately after | HTTP 423 Locked (lockout precedes credential check) |
**Pass criteria**: Lockout state takes precedence over correct credentials within the lockout window; counter persists across IPs (per-account, not per-IP).
---
### NFT-SEC-12: Lockout Is Audit-Logged (AZ-537)
**Summary**: When NFT-SEC-11 fires the lockout transition, an audit-log row is written with the email, source IP, and timestamp.
**Traces to**: AZ-537 AC-6
**Preconditions**:
- Audit log infrastructure online (verified by existing logging tests)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Trigger NFT-SEC-11 against `bob@azaion.com` from IP `203.0.113.7` | Lockout fires |
| 2 | Query the audit log for entries with `event = 'login_lockout'` since the test start | At least one row with `email = 'bob@azaion.com'`, `ip = '203.0.113.7'`, `timestamp` within ± 5 s of the lockout trigger |
**Pass criteria**: Each lockout produces a `login_lockout` audit entry with the security-relevant fields.
---
### NFT-SEC-13: HTTP CORS Origin Is Rejected (AZ-538)
**Summary**: A browser preflight from the cleartext `http://admin.azaion.com` origin must NOT receive an `Access-Control-Allow-Origin` header (CORS denies the request).
**Traces to**: AZ-538 AC-1
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | OPTIONS /login with `Origin: http://admin.azaion.com`, `Access-Control-Request-Method: POST` | HTTP 204 OR 200; response has NO `Access-Control-Allow-Origin` header |
**Pass criteria**: HTTP origin gets no ACAO header — browser-side fetch with credentials will fail in any compliant browser.
---
### NFT-SEC-14: HSTS Header Present in Production (AZ-538)
**Summary**: When `ASPNETCORE_ENVIRONMENT=Production`, every HTTPS response includes a strict `Strict-Transport-Security` header.
**Traces to**: AZ-538 AC-3
**Preconditions**:
- Admin container running with `ASPNETCORE_ENVIRONMENT=Production`
- Note: the default test harness runs `Development`; this test must be run with the production env override OR is the legitimate environment-mismatch skip documented in cycle-2 test_run_report
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | GET https://admin.azaion.com/health/live (or any HTTPS endpoint) | HTTP 200; response header `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` |
**Pass criteria**: Production responses always carry HSTS with the documented directives.
---
### NFT-SEC-15: HTTP Request Redirects to HTTPS in Production (AZ-538)
**Summary**: When `ASPNETCORE_ENVIRONMENT=Production`, a cleartext HTTP request returns HTTP 307 to the same path on HTTPS.
**Traces to**: AZ-538 AC-4
**Preconditions**: Same as NFT-SEC-14
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | GET http://admin.azaion.com/health/live | HTTP 307; `Location: https://admin.azaion.com/health/live` |
**Pass criteria**: HTTP traffic is redirected at the protocol layer, not silently served.
---
### NFT-SEC-16: Refresh-Token Reuse Kills the Session Family (AZ-531)
**Summary**: If a previously-rotated refresh token is presented again, the entire `sessions` family chain (parent + all descendants) is marked `revoked_reason='reuse_detected'` and every refresh in that family stops working.
**Traces to**: AZ-531 AC-3
**Preconditions**:
- A session family with refresh R1 rotated to R2 (per FT-P-31)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /token/refresh with R1 (already rotated) | HTTP 401 |
| 2 | Query `sessions` for the family | Every row in the family has `revoked_at` set; `revoked_reason = 'reuse_detected'` |
| 3 | POST /token/refresh with R2 | HTTP 401 (R2 also dead — family-wide kill) |
**Pass criteria**: Reuse detection kills the entire family, not just the reused refresh.
---
### NFT-SEC-17: Refresh Tokens Are Opaque, Not JWT (AZ-531)
**Summary**: Refresh tokens issued by /login or /token/refresh are not JWTs; the persisted form is the SHA-256 hash; the raw value never appears in logs.
**Traces to**: AZ-531 AC-5
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login → capture `refresh_token` R | R is a non-empty string ≥ 43 chars (base64url of 32 bytes) |
| 2 | Attempt to parse R as a JWT (split on `.` and base64url-decode the segments) | Parse fails — R does not split into a JWT header/payload/signature shape |
| 3 | Read the matching `sessions.refresh_hash` column directly from Postgres | Length 32 bytes (SHA-256 raw or base64-encoded), value ≠ R |
| 4 | Grep API logs (Serilog output) for the literal R | No match (raw refresh value never logged) |
**Pass criteria**: Refresh tokens are opaque, hashed at rest, and never logged in raw form.
---
### NFT-SEC-18: Admin Tokens Are Signed With ES256 + kid (AZ-532)
**Summary**: An access token returned by /login has `alg=ES256` and a `kid` matching one of the active JWKS keys.
**Traces to**: AZ-532 AC-1
**Preconditions**:
- Admin running with at least one ES256 keypair loaded from `secrets/jwt_signing_key.pem`
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /login with valid credentials | HTTP 200, dual tokens |
| 2 | Decode the access token's JOSE header | `alg == "ES256"`, `kid` non-empty |
| 3 | GET /.well-known/jwks.json | The same `kid` appears in the returned `keys` array |
**Pass criteria**: Tokens are signed asymmetrically and carry the `kid` discriminator needed for rotation.
---
### NFT-SEC-19: JWKS Endpoint Never Exposes Private Material (AZ-532)
**Summary**: The JWKS payload contains only public components; no `d`, `p`, `q`, `dp`, `dq`, or `qi` field appears.
**Traces to**: AZ-532 AC-4
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | GET /.well-known/jwks.json | HTTP 200, JSON body |
| 2 | Inspect every entry in `keys` for forbidden private-material fields | None of `d`, `p`, `q`, `dp`, `dq`, `qi` is present |
**Pass criteria**: Public-key set strictly excludes any private scalar (EC) or RSA private primes.
---
### NFT-SEC-20: alg-Confusion Attack Is Rejected (AZ-532)
**Summary**: A forged token with `alg=HS256` (where the signature is computed using the public key as the HMAC secret) is rejected by every protected endpoint, because `TokenValidationParameters.ValidAlgorithms` pins ES256 only.
**Traces to**: AZ-532 AC-5
**Preconditions**:
- Test fixture able to construct a forged JWT given the public key
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Build a JWT with header `{ "alg":"HS256","typ":"JWT","kid":"<active-kid>" }`; payload claims valid; signature = HMAC-SHA256(publicKeyBytes, signingInput) | Forged token string |
| 2 | GET /users with the forged token | HTTP 401 |
**Pass criteria**: Algorithm-confusion forgery is rejected; verifier does not silently downgrade to HS256.
---
### NFT-SEC-21: Mission Token Requires MFA Step-Up (AZ-533 + AZ-534)
**Summary**: After AZ-534 ships, `POST /sessions/mission` MUST reject access tokens whose `amr` does not include `mfa`. Caller gets 403 with a step-up message.
**Traces to**: AZ-533 AC-6
**Preconditions**:
- AZ-534 already landed (it has — cycle 2 batch 4)
- Caller holds an access token with `amr=["pwd"]` (e.g. legacy session, or a service account that doesn't enroll MFA)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | POST /sessions/mission with the `amr=["pwd"]` access token + a valid mission body | HTTP 403; response body contains `"mission tokens require step-up MFA"` |
**Pass criteria**: Mission-class tokens cannot be minted without MFA in the access-token `amr` chain.
> Note: cycle-2 follow-up F1 in `_docs/03_implementation/implementation_report_auth_modernization_cycle2.md` calls out that `/sessions/mission` enforcement of `amr=mfa` is the small wire-up still pending after AZ-534 shipped (the AC was deferred during AZ-533, then re-opened under F1). Until F1 lands, this scenario is the spec contract; the matching test may be marked Pending in the SUT.
---
### NFT-SEC-22: TOTP Secret Is Encrypted at Rest (AZ-534)
**Summary**: The `users.mfa_secret` column never holds plaintext base32; only ciphertext.
**Traces to**: AZ-534 AC-6
**Preconditions**:
- An enrolled user from FT-P-39
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Read `users.mfa_secret` for the enrolled user directly from Postgres | Value is non-empty |
| 2 | Try to base32-decode the value as if it were a 32-char TOTP secret | Decode either fails OR yields material that does NOT round-trip to a working TOTP code |
| 3 | Confirm the value is the output of `IDataProtector.Protect(<plaintext base32>)` (length ≫ 32 chars; format-prefixed) | Matches `IDataProtector` ciphertext shape |
**Pass criteria**: `mfa_secret` is stored encrypted; reading the DB row alone does not yield a usable TOTP secret. (Operational note: production must set `DataProtection:KeysFolder` for the `IDataProtector` to outlive container restarts — see cycle-2 carry-forward F3.)