Skip to main content

Hashed SecureField Logic & Password Authentication

Overview

The @SecureField annotation in ValkyrAI supports two encryption types:

  1. SYMMETRIC - AES-256 encrypted; can be decrypted (requires VIEW_DECRYPTED permission)
  2. HASHED - BCrypt one-way hash; cannot be decrypted (NO VIEW_DECRYPTED check)

Key Insight: No VIEW_DECRYPTED for Hashed Fields

You are absolutely correct - there is NO VIEW_DECRYPTED permission for hashed fields because:

  • Hashed fields are irreversible (one-way BCrypt)
  • You cannot "decrypt" a BCrypt hash - it's not ciphertext, it's a digest
  • The security model is different: instead of "view encrypted value", we "verify against plaintext"

How SecureFieldAspect Works

For HASHED Fields (Password Example)

GET (Retrieve)

@Around(FIELD_GET)
public Object get(ProceedingJoinPoint pjp) throws Throwable {
// ...
if (annotation.encryptionType() == SecureField.EncryptionType.HASHED) {
return pjp.proceed(); // ← RETURNS RAW HASH DIRECTLY, NO PERMISSION CHECK
}
// ... permission gating logic for SYMMETRIC fields ...
}

Behavior: When you call dbUser.getPassword(), the aspect immediately returns the raw BCrypt hash from the database without any permission checks. This is safe because:

  • The hash alone is useless without the plaintext
  • You can't reverse-engineer the original password from the hash
  • Only valid use is to call passwordEncoder.matches(plaintext, hash)

SET (Write)

@Around(FIELD_SET)
public Object set(ProceedingJoinPoint pjp) throws Throwable {
// ...
if (annotation.encryptionType() == SecureField.EncryptionType.HASHED) {
if (BCRYPT_PATTERN.matcher(clearTextValue).matches() && ...) {
// Input is already a BCrypt hash, trust caller
encryptedValue = clearTextValue;
} else {
// Input is plaintext, hash it
String encodedValue = passwordEncoder.encode(clearTextValue);
encryptedValue = encodedValue;
}
}
}

Behavior: When setting a password:

  1. If input matches the BCrypt pattern (looks like a hash), store it as-is (assume caller knows what they're doing)
  2. Otherwise, treat as plaintext and hash it via passwordEncoder.encode()

For SYMMETRIC Fields (e.g., firstName, lastName)

Both GET and SET require VIEW_DECRYPTED permission check via permissionEvaluator.hasPermission(auth, object, "VIEW_DECRYPTED"). If permission denied, returns <ENCRYPTED_VALUE/> instead of the real value.

Password Authentication Flow

Current Login Implementation (AuthController.java)

@PostMapping("/login")
@Transactional(readOnly = true) // Keep persistence context open for SecureField access
public ResponseEntity<Map<String, Object>> login(@RequestBody Login user) {
List<Principal> dpop = principalRepository.findPrincipalByUsername(user.getUsername());
if (!dpop.isEmpty()) {
Principal dbUser = dpop.get(0);

// Step 1: Retrieve the BCrypt hash (aspect returns raw hash, no permission check)
String hashedPassword = dbUser.getPassword();
logger.debug("Retrieved hashed password: {} (null={})",
hashedPassword != null ? "[REDACTED]" : "null",
hashedPassword == null);

if (hashedPassword != null) {
// Step 2: Use BCrypt encoder to verify plaintext against hash
boolean passwordMatches = SecureFieldAspect.passwordEncoder
.matches(user.getPassword(), hashedPassword);

if (passwordMatches) {
// Step 3: Login success
ThorUser<Authority> thx = new ThorUser<>(dbUser);
Map<String, Object> response = initUser(thx);
return ResponseEntity.ok().body(response);
}
}
}
// Login failed
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "access denied");
}

Why No Permission Gate for Password Retrieval?

  1. Security Model: Hashed passwords don't need permission gating because:

    • The hash cannot be reversed to get the plaintext
    • The hash is only useful for verification via matches() operation
    • Exposing the hash to an attacker doesn't expose the password
  2. System Architecture:

    • Symmetric encrypted fields (firstName, lastName) contain sensitive PII that could be misused if disclosed
    • Hashed fields are specifically designed for verification, not retrieval
    • The only valid operation on a hashed field is comparison
  3. Permission Model Difference:

    • SYMMETRIC: Permission gates READ to prevent information disclosure
    • HASHED: Permission gates WRITE to prevent unauthorized password changes (but the aspect doesn't check this)

Critical Detail: @Transactional(readOnly = true)

The login method must have @Transactional(readOnly = true) because:

  • SecureFieldAspect relies on reflection to intercept field access
  • The JPA proxy needs an active transaction/persistence context
  • Without this, Lazy-loaded relationships (like roles) won't be accessible
  • ReadOnly optimizes the transaction for reads only

Connection to Your Login Bug

The password matching failure suggests one of these scenarios:

  1. Hash is NULL in DB: If dbUser.getPassword() returns null, the check prevents attempting the match (correct behavior)

  2. Hash is corrupted or empty: If the hash is stored but is malformed or doesn't match BCrypt pattern, matches() would return false

  3. Aspect not intercepting: If SecureFieldAspect isn't woven in, getPassword() might return something other than the database value

  4. Wrong user record: If the database lookup returns a different Principal than expected, its password hash would be different

  5. AspectJ weaving issue: The aspect must be properly configured in pom.xml and spring-aop must be active

Testing the Logic

A proper unit test should verify:

@Test
void testBCryptPasswordMatching() {
String plaintext = "TestPassword123";
String hash = SecureFieldAspect.passwordEncoder.encode(plaintext);

// Should match correct password
assertTrue(SecureFieldAspect.passwordEncoder.matches(plaintext, hash));

// Should NOT match wrong password
assertFalse(SecureFieldAspect.passwordEncoder.matches("WrongPassword", hash));

// Hash should be different each time (salt)
String hash2 = SecureFieldAspect.passwordEncoder.encode(plaintext);
assertNotEquals(hash, hash2);
assertTrue(SecureFieldAspect.passwordEncoder.matches(plaintext, hash2));
}

Summary

AspectHASHEDSYMMETRIC
PurposePassword verificationData protection
Decryptable?No (one-way hash)Yes (AES-256)
GET Permission CheckNone (returns raw hash)VIEW_DECRYPTED required
SET Permission CheckNone (aspect doesn't check)VIEW_DECRYPTED required
Masked Value on DenyN/A<ENCRYPTED_VALUE/>
Use CaseAuthenticationPII protection
Example FieldspasswordfirstName, lastName, SSN