Hashed SecureField Logic & Password Authentication
Overview
The @SecureField annotation in ValkyrAI supports two encryption types:
- SYMMETRIC - AES-256 encrypted; can be decrypted (requires
VIEW_DECRYPTEDpermission) - HASHED - BCrypt one-way hash; cannot be decrypted (NO
VIEW_DECRYPTEDcheck)
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:
- If input matches the BCrypt pattern (looks like a hash), store it as-is (assume caller knows what they're doing)
- 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?
-
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
-
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
-
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:
-
Hash is NULL in DB: If
dbUser.getPassword()returns null, the check prevents attempting the match (correct behavior) -
Hash is corrupted or empty: If the hash is stored but is malformed or doesn't match BCrypt pattern,
matches()would return false -
Aspect not intercepting: If SecureFieldAspect isn't woven in,
getPassword()might return something other than the database value -
Wrong user record: If the database lookup returns a different Principal than expected, its password hash would be different
-
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
| Aspect | HASHED | SYMMETRIC |
|---|---|---|
| Purpose | Password verification | Data protection |
| Decryptable? | No (one-way hash) | Yes (AES-256) |
| GET Permission Check | None (returns raw hash) | VIEW_DECRYPTED required |
| SET Permission Check | None (aspect doesn't check) | VIEW_DECRYPTED required |
| Masked Value on Deny | N/A | <ENCRYPTED_VALUE/> |
| Use Case | Authentication | PII protection |
| Example Fields | password | firstName, lastName, SSN |