Skip to content

Commit a19c00c

Browse files
committed
Spec compliance: NIST min-length, tag validation, simple masking
- FF1/FF3: enforce NIST minimum domain size (radix^len >= 1,000,000) - Tags: must be specified in policy, no auto-generation, error on missing - Mask: simple patterns (last4, first1, full), not template-based - Zero encryptable chars → error - Tests updated with explicit tags and tag_enabled:false
1 parent d87b440 commit a19c00c

File tree

5 files changed

+70
-42
lines changed

5 files changed

+70
-42
lines changed

src/main/java/io/cyphera/Cyphera.java

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,12 @@ private String protectFpe(String value, Policy policy, boolean ff3) {
133133
}
134134
}
135135

136-
// 2. Encrypt
136+
// 2. Check for zero encryptable chars
137+
if (encryptable.length() == 0) {
138+
throw new IllegalArgumentException("No encryptable characters in input");
139+
}
140+
141+
// 3. Encrypt
137142
String encrypted;
138143
if (ff3) {
139144
encrypted = new FF3(key, new byte[8], alphabet).encrypt(encryptable.toString());
@@ -165,25 +170,28 @@ private String protectMask(String value, Policy policy) {
165170
String pattern = policy.pattern();
166171
if (pattern == null) throw new IllegalArgumentException("Mask policy requires 'pattern'");
167172

168-
// Simple pattern: replace * with *, {last4} with last 4 chars, {first3} with first 3 chars
169-
String result = pattern;
170-
if (result.contains("{last4}")) {
171-
String last4 = value.length() >= 4 ? value.substring(value.length() - 4) : value;
172-
result = result.replace("{last4}", last4);
173-
}
174-
if (result.contains("{last2}")) {
175-
String last2 = value.length() >= 2 ? value.substring(value.length() - 2) : value;
176-
result = result.replace("{last2}", last2);
177-
}
178-
if (result.contains("{first3}")) {
179-
String first3 = value.length() >= 3 ? value.substring(0, 3) : value;
180-
result = result.replace("{first3}", first3);
173+
int len = value.length();
174+
char mask = '*';
175+
176+
switch (pattern) {
177+
case "last4": case "last_4":
178+
return repeat(mask, Math.max(0, len - 4)) + value.substring(Math.max(0, len - 4));
179+
case "last2": case "last_2":
180+
return repeat(mask, Math.max(0, len - 2)) + value.substring(Math.max(0, len - 2));
181+
case "first1": case "first_1":
182+
return (len >= 1 ? value.substring(0, 1) : "") + repeat(mask, Math.max(0, len - 1));
183+
case "first3": case "first_3":
184+
return (len >= 3 ? value.substring(0, 3) : value) + repeat(mask, Math.max(0, len - 3));
185+
case "full":
186+
default:
187+
return repeat(mask, len);
181188
}
182-
if (result.contains("{first1}")) {
183-
String first1 = value.length() >= 1 ? value.substring(0, 1) : value;
184-
result = result.replace("{first1}", first1);
185-
}
186-
return result;
189+
}
190+
191+
private static String repeat(char c, int count) {
192+
StringBuilder sb = new StringBuilder(count);
193+
for (int i = 0; i < count; i++) sb.append(c);
194+
return sb.toString();
187195
}
188196

189197
// -- Internal: Hash protect --

src/main/java/io/cyphera/Policy.java

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,32 +55,14 @@ public static Policy fromMap(String name, Map<String, Object> map) {
5555
String pattern = (String) map.get("pattern");
5656
String algorithm = (String) map.getOrDefault("algorithm", "sha256");
5757

58-
// Auto-generate tag if not provided and tag is enabled
59-
if (tag == null && tagEnabled) {
60-
tag = generateTag(name, tagLength);
58+
// Tag must be provided in policy if tag_enabled is true
59+
if (tagEnabled && (tag == null || tag.isEmpty())) {
60+
throw new IllegalArgumentException("Policy '" + name + "' has tag_enabled=true but no tag specified. The tag must be set in the policy.");
6161
}
6262

6363
return new Policy(name, engine, alphabet, keyRef, tag, tagEnabled, tagLength, pattern, algorithm);
6464
}
6565

66-
// Generate a deterministic tag from the policy name
67-
// Use a simple hash of the name to pick chars from ALPHANUMERIC
68-
private static String generateTag(String name, int length) {
69-
String chars = Alphabets.ALPHANUMERIC;
70-
int hash = 0;
71-
for (int i = 0; i < name.length(); i++) {
72-
hash = 31 * hash + name.charAt(i);
73-
}
74-
StringBuilder sb = new StringBuilder(length);
75-
for (int i = 0; i < length; i++) {
76-
// Mix bits for each position
77-
int h = hash ^ (hash >>> 16) ^ (i * 0x9e3779b9);
78-
if (h < 0) h = -h;
79-
sb.append(chars.charAt(h % chars.length()));
80-
}
81-
return sb.toString();
82-
}
83-
8466
public boolean isReversible() {
8567
return "ff1".equals(engine) || "ff3".equals(engine) || "aes_gcm".equals(engine);
8668
}

src/main/java/io/cyphera/engine/ff1/FF1.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,28 @@ public FF1(byte[] key, byte[] tweak, String alphabet) throws Exception {
3030
}
3131

3232
public String encrypt(String plaintext) throws Exception {
33+
if (plaintext.isEmpty())
34+
throw new IllegalArgumentException("Input must not be empty");
35+
// NIST SP 800-38G: radix^minlen >= 1,000,000
36+
double domainSize = Math.pow(radix, plaintext.length());
37+
if (domainSize < 1_000_000)
38+
throw new IllegalArgumentException("Input too short: " + plaintext.length()
39+
+ " chars with radix " + radix + " (domain size " + (long) domainSize
40+
+ " < 1,000,000 minimum)");
41+
3342
int[] digits = toDigits(plaintext);
3443
int[] result = ff1Encrypt(digits, tweak);
3544
return fromDigits(result);
3645
}
3746

3847
public String decrypt(String ciphertext) throws Exception {
48+
if (ciphertext.isEmpty())
49+
throw new IllegalArgumentException("Input must not be empty");
50+
double domainSize = Math.pow(radix, ciphertext.length());
51+
if (domainSize < 1_000_000)
52+
throw new IllegalArgumentException("Input too short: " + ciphertext.length()
53+
+ " chars with radix " + radix + " (domain size " + (long) domainSize
54+
+ " < 1,000,000 minimum)");
3955
int[] digits = toDigits(ciphertext);
4056
int[] result = ff1Decrypt(digits, tweak);
4157
return fromDigits(result);

src/main/java/io/cyphera/engine/ff3/FF3.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,30 @@ public FF3(byte[] key, byte[] tweak, String alphabet) throws Exception {
3232
}
3333

3434
public String encrypt(String plaintext) throws Exception {
35+
if (plaintext.isEmpty())
36+
throw new IllegalArgumentException("Input must not be empty");
37+
if (plaintext.length() < 2)
38+
throw new IllegalArgumentException("FF3 requires at least 2 characters");
39+
double domainSize = Math.pow(radix, plaintext.length());
40+
if (domainSize < 1_000_000)
41+
throw new IllegalArgumentException("Input too short: " + plaintext.length()
42+
+ " chars with radix " + radix + " (domain size " + (long) domainSize
43+
+ " < 1,000,000 minimum)");
3544
int[] digits = toDigits(plaintext);
3645
int[] result = ff3Encrypt(digits);
3746
return fromDigits(result);
3847
}
3948

4049
public String decrypt(String ciphertext) throws Exception {
50+
if (ciphertext.isEmpty())
51+
throw new IllegalArgumentException("Input must not be empty");
52+
if (ciphertext.length() < 2)
53+
throw new IllegalArgumentException("FF3 requires at least 2 characters");
54+
double domainSize = Math.pow(radix, ciphertext.length());
55+
if (domainSize < 1_000_000)
56+
throw new IllegalArgumentException("Input too short: " + ciphertext.length()
57+
+ " chars with radix " + radix + " (domain size " + (long) domainSize
58+
+ " < 1,000,000 minimum)");
4159
int[] digits = toDigits(ciphertext);
4260
int[] result = ff3Decrypt(digits);
4361
return fromDigits(result);

src/test/java/io/cyphera/CypheraTest.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ private static Map<String, Object> buildConfig() {
1717
Map<String, Object> ssn = new HashMap<>();
1818
ssn.put("engine", "ff1");
1919
ssn.put("key_ref", "demo-key");
20+
ssn.put("tag", "T01");
2021
// defaults: alphabet=alphanumeric, tag_enabled=true, tag_length=3
2122
policies.put("ssn", ssn);
2223

@@ -29,13 +30,15 @@ private static Map<String, Object> buildConfig() {
2930

3031
Map<String, Object> ssnMask = new HashMap<>();
3132
ssnMask.put("engine", "mask");
32-
ssnMask.put("pattern", "***-**-{last4}");
33+
ssnMask.put("pattern", "last4");
34+
ssnMask.put("tag_enabled", false);
3335
policies.put("ssn_mask", ssnMask);
3436

3537
Map<String, Object> ssnHash = new HashMap<>();
3638
ssnHash.put("engine", "hash");
3739
ssnHash.put("algorithm", "sha256");
3840
ssnHash.put("key_ref", "demo-key");
41+
ssnHash.put("tag_enabled", false);
3942
policies.put("ssn_hash", ssnHash);
4043

4144
config.put("policies", policies);
@@ -92,7 +95,7 @@ void protectAndAccessUntaggedDigits() {
9295
void protectMask() {
9396
Cyphera c = Cyphera.fromMap(buildConfig());
9497
String result = c.protect("123-45-6789", "ssn_mask");
95-
assertEquals("***-**-6789", result);
98+
assertEquals("*******6789", result);
9699
}
97100

98101
@Test
@@ -150,6 +153,7 @@ void protectAndAccessAesGcm() {
150153
Map<String, Object> aesPolicy = new HashMap<>();
151154
aesPolicy.put("engine", "aes_gcm");
152155
aesPolicy.put("key_ref", "demo-key");
156+
aesPolicy.put("tag", "T02");
153157
policies.put("ssn_aes", aesPolicy);
154158

155159
Cyphera c = Cyphera.fromMap(config);

0 commit comments

Comments
 (0)