ADR 0001 — Authentication model#

Status: Accepted (Phase 0, 2026-07-03) · Amended for real password model (Phase 1, 2026-07-14) · Algorithm corrected against staging (Phase 1, 2026-09-04)

Context#

The legacy Cube 1.x uses ASP.NET Forms cookie authentication backed by AspnetUsers / AspnetMembership (classic ASP.NET Membership schema). We need a token-based scheme that works for browser SPAs, desktop clients, and service-to-service calls, and that can coexist with the legacy system during the migration.

Decision#

  • Issue JWT access tokens signed with HS256 for development and lower environments; RS256 upgrade path via Jwt:SigningKey swap when a KMS-backed key is available.
  • One central Auth API endpoint set under /api/v1/auth/ (login, me, permissions).
  • Passwords are verified through IPasswordHasher, which dispatches on the stored format:
    • $2* prefix → BCrypt (new users / rehashed users, cost factor 11).
    • Otherwise → the row’s PasswordFormat column decides:
      • 2 (Encrypted, the current legacy default) → verified via the ported SqlMembershipPasswordVerifier.
      • 1 (Hashed) → SHA1(salt+password), for any historical rows using it.
      • 0 (Clear) → rejected outright (should not exist in production).
  • Legacy verification is gated by FeatureFlags:UseLegacyPasswordHash so we can force a full cutover.
  • Claims carried on the token (see ADR 0005 for the full mapping):
    • sub, unique_name, jti (standard),
    • role — permission strings from AspNetRoles where IsPermission=1,
    • grp — group GUIDs from aspmnx_GroupUsers,
    • mod_admin — module keys from aspnet_Profile admin flags,
    • itar — aspnet_Profile.IsItarAuthorized.

Confirmed password model (Phase 1)#

The legacy system uses SqlMembershipProvider with passwordFormat="Encrypted" in Framework 2.0 compatibility mode. Passwords are AES-encrypted using the ASP.NET machineKey decryptionKey from Web.config / machine.config.

The PasswordSalt column is not used in Encrypted mode — it exists on aspnet_Membership for the Hashed-mode (PasswordFormat=1) code path. Confirmed against a real staging row (2026-09-04):

  • PasswordSalt length: 24 base64 chars → 16 bytes — standard membership salt, but referenced only when PasswordFormat=1.
  • Password length: 64 base64 chars → 48 bytes — three AES blocks. This precisely matches 16 random-prefix + N password bytes padded to the next AES block boundary. For a 9-character password (18 bytes UTF-16 LE): 16 + 18 = 34 bytes → PKCS7 pad to 48 bytes → 48-byte ciphertext.

Encrypted-mode algorithm (Framework 2.0 compat, signData=false):

plaintext = randomPrefix(16) || password_UTF16LE
stored    = Base64( AES-256-CBC-encrypt(decryptionKey, plaintext, iv=zeros, PKCS7) )

The 16-byte random prefix is inside the encrypted payload, not a CBC IV — repeated encryption of the same password produces different ciphertext because CBC cascades the random first block. The AES operation itself uses a zero IV.

Verification flow in Cube 2.0:

  1. Look up aspnet_Users by LoweredUserName.
  2. Load AspnetMembership.Password, PasswordSalt, PasswordFormat.
  3. If Password starts with $2 → BCrypt verify.
  4. Else if PasswordFormat == 2 → AES-256-CBC decrypt the base64 payload with decryptionKey and a zero IV → strip the first 16 bytes of the recovered plaintext → constant-time compare the remaining bytes to Encoding.Unicode.GetBytes(submittedPassword).
  5. Else if PasswordFormat == 1 → Base64(SHA1(saltBytes || passwordBytes_UTF16LE)) compare.
  6. On success and stored format is not BCrypt: rehash-on-login — BCrypt the plaintext and upsert into MnxUserAuth (per ADR 0006) inside the same transaction that returns the token.

Why the earlier “HMAC then encrypt hash” wording was wrong#

The 2026-07-14 draft of this ADR described the algorithm as HMACSHA1(validationKey, salt||pwd) → AES-encrypt(hash). That was drawn from Framework 4.5+ MembershipPasswordCompatibilityMode.Framework40, which does involve an HMAC. But the staging DB rows are Framework 2.0 legacy — the byte layout confirmed this: a Framework 4.0 signed-and-encrypted payload for the same password would be 88 base64 chars, not 64. PasswordHasher.VerifyEncrypted implements the Framework 2.0 algorithm because that’s what our data uses. If a future site turns out to use Framework 4.0 mode, we add a second code path keyed on a config flag.

The machine-key values live as secrets in Machinekey:ValidationKey and Machinekey:DecryptionKey (env vars Machinekey__ValidationKey / Machinekey__DecryptionKey). Production values are held by ops — dev values match manexcloud/newSite/Web.config for local testing against seeded rows.

Consequences#

  • Refresh tokens are not in scope for Phase 0 or Phase 1. Access token lifetime defaults to 60 min.
  • Legacy password verification requires the machine key as a secret. Ops rotates it out-of-band; the app reads it via configuration.
  • Rehash-on-login means the AspnetMembership.Password column ends up mixed-format (BCrypt for rehashed rows, AES-encrypted for cold rows) during migration. The PasswordFormat column tells verification which path to take.
  • Once every user has logged in at least once post-cutover, we can flip FeatureFlags:UseLegacyPasswordHash = false and stop reading the machine key at runtime.
  • Group / role / permission population is finalized in ADR 0005. Phase 0’s empty-array stubs are replaced in Phase 1.
  • The Web.config machine key in manexcloud/newSite/Web.config is a dev/staging placeholder (confirmed with the team). Production machineKey lives on prod IIS and is fetched separately.