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

18 KiB
Raw Blame History

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 `

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.)