Skip to content

Commit 1fb1926

Browse files
committed
Fix passthrough/tag logic: encrypt first, passthroughs back, then tag
1 parent 1ae4a87 commit 1fb1926

1 file changed

Lines changed: 63 additions & 175 deletions

File tree

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

Lines changed: 63 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,11 @@ public String protect(String value, String policyName) {
8686
* If untagged, the policyName must be provided.
8787
*/
8888
public String access(String protectedValue) {
89-
// Try to find a tag match
89+
// Tag is just the first N chars of the string
9090
for (Map.Entry<String, Policy> e : tagIndex.entrySet()) {
9191
String tag = e.getKey();
92-
Policy policy = e.getValue();
93-
94-
// Extract first N encryptable chars and check if they match the tag
95-
String extracted = extractTag(protectedValue, policy.alphabet(), tag.length());
96-
if (tag.equals(extracted)) {
97-
return accessWithPolicy(protectedValue, policy, true);
92+
if (protectedValue.length() > tag.length() && protectedValue.startsWith(tag)) {
93+
return accessWithPolicy(protectedValue, e.getValue(), true);
9894
}
9995
}
10096
throw new IllegalArgumentException("No matching tag found. Use access(value, policyName) for untagged values.");
@@ -109,78 +105,55 @@ public String access(String protectedValue, String policyName) {
109105
return accessWithPolicy(protectedValue, policy, policy.tagEnabled());
110106
}
111107

112-
// -- Internal: FF1 protect --
113-
114-
private String protectFf1(String value, Policy policy) {
115-
try {
116-
byte[] key = keyProvider.resolve(policy.keyRef());
117-
String alphabet = policy.alphabet();
118-
119-
// Separate encryptable chars from passthrough chars
120-
StringBuilder encryptable = new StringBuilder();
121-
boolean[] isPassthrough = new boolean[value.length()];
122-
char[] passthroughChars = new char[value.length()];
123-
124-
for (int i = 0; i < value.length(); i++) {
125-
char c = value.charAt(i);
126-
if (alphabet.indexOf(c) >= 0) {
127-
encryptable.append(c);
128-
isPassthrough[i] = false;
129-
} else {
130-
isPassthrough[i] = true;
131-
passthroughChars[i] = c;
132-
}
133-
}
134-
135-
FF1 ff1 = new FF1(key, new byte[0], alphabet);
136-
String encrypted = ff1.encrypt(encryptable.toString());
137-
138-
// Prepend tag to encrypted chars
139-
String taggedCipher = policy.tagEnabled() ? policy.tag() + encrypted : encrypted;
140-
141-
// Reinsert passthrough chars at original positions
142-
return reinsertPassthrough(taggedCipher, isPassthrough, passthroughChars, value.length(),
143-
policy.tagEnabled() ? policy.tagLength() : 0);
144-
} catch (IllegalArgumentException e) {
145-
throw e;
146-
} catch (Exception e) {
147-
throw new RuntimeException("FF1 encryption failed: " + e.getMessage(), e);
148-
}
149-
}
108+
// -- Internal: FPE protect (FF1 / FF3) --
150109

151-
// -- Internal: FF3 protect --
110+
private String protectFf1(String value, Policy policy) { return protectFpe(value, policy, false); }
111+
private String protectFf3(String value, Policy policy) { return protectFpe(value, policy, true); }
152112

153-
private String protectFf3(String value, Policy policy) {
113+
private String protectFpe(String value, Policy policy, boolean ff3) {
154114
try {
155115
byte[] key = keyProvider.resolve(policy.keyRef());
156116
String alphabet = policy.alphabet();
157117

118+
// 1. Strip passthroughs, record positions
119+
int[] ptPositions = new int[value.length()];
120+
char[] ptChars = new char[value.length()];
121+
int ptCount = 0;
158122
StringBuilder encryptable = new StringBuilder();
159-
boolean[] isPassthrough = new boolean[value.length()];
160-
char[] passthroughChars = new char[value.length()];
161-
162123
for (int i = 0; i < value.length(); i++) {
163124
char c = value.charAt(i);
164125
if (alphabet.indexOf(c) >= 0) {
165126
encryptable.append(c);
166127
} else {
167-
isPassthrough[i] = true;
168-
passthroughChars[i] = c;
128+
ptPositions[ptCount] = i;
129+
ptChars[ptCount] = c;
130+
ptCount++;
169131
}
170132
}
171133

172-
// FF3 requires 8-byte tweak -- use zeroes as default
173-
FF3 ff3 = new FF3(key, new byte[8], alphabet);
174-
String encrypted = ff3.encrypt(encryptable.toString());
134+
// 2. Encrypt
135+
String encrypted;
136+
if (ff3) {
137+
encrypted = new FF3(key, new byte[8], alphabet).encrypt(encryptable.toString());
138+
} else {
139+
encrypted = new FF1(key, new byte[0], alphabet).encrypt(encryptable.toString());
140+
}
175141

176-
String taggedCipher = policy.tagEnabled() ? policy.tag() + encrypted : encrypted;
142+
// 3. Reinsert passthroughs at original positions
143+
StringBuilder withPt = new StringBuilder(encrypted);
144+
for (int i = 0; i < ptCount; i++) {
145+
withPt.insert(ptPositions[i], ptChars[i]);
146+
}
177147

178-
return reinsertPassthrough(taggedCipher, isPassthrough, passthroughChars, value.length(),
179-
policy.tagEnabled() ? policy.tagLength() : 0);
148+
// 4. Prepend tag
149+
if (policy.tagEnabled()) {
150+
return policy.tag() + withPt.toString();
151+
}
152+
return withPt.toString();
180153
} catch (IllegalArgumentException e) {
181154
throw e;
182155
} catch (Exception e) {
183-
throw new RuntimeException("FF3 encryption failed: " + e.getMessage(), e);
156+
throw new RuntimeException("FPE encryption failed: " + e.getMessage(), e);
184157
}
185158
}
186159

@@ -259,142 +232,57 @@ private String accessWithPolicy(String protectedValue, Policy policy, boolean ha
259232
}
260233

261234
private String accessFf1(String protectedValue, Policy policy, boolean hasTag) {
262-
try {
263-
byte[] key = keyProvider.resolve(policy.keyRef());
264-
String alphabet = policy.alphabet();
265-
266-
// Extract encryptable chars (skip passthrough)
267-
StringBuilder encryptable = new StringBuilder();
268-
for (int i = 0; i < protectedValue.length(); i++) {
269-
char c = protectedValue.charAt(i);
270-
if (alphabet.indexOf(c) >= 0) {
271-
encryptable.append(c);
272-
}
273-
}
274-
275-
String cipherChars = encryptable.toString();
276-
277-
// Strip tag if present
278-
if (hasTag && policy.tag() != null) {
279-
cipherChars = cipherChars.substring(policy.tagLength());
280-
}
281-
282-
FF1 ff1 = new FF1(key, new byte[0], alphabet);
283-
String decrypted = ff1.decrypt(cipherChars);
284-
285-
// Reinsert passthrough chars at their original positions (minus tag offset)
286-
return reinsertPassthroughForAccess(decrypted, protectedValue, alphabet,
287-
hasTag ? policy.tagLength() : 0);
288-
} catch (IllegalArgumentException e) {
289-
throw e;
290-
} catch (Exception e) {
291-
throw new RuntimeException("FF1 decryption failed: " + e.getMessage(), e);
292-
}
235+
return accessFpe(protectedValue, policy, hasTag, false);
293236
}
294237

295238
private String accessFf3(String protectedValue, Policy policy, boolean hasTag) {
239+
return accessFpe(protectedValue, policy, hasTag, true);
240+
}
241+
242+
private String accessFpe(String protectedValue, Policy policy, boolean hasTag, boolean ff3) {
296243
try {
297244
byte[] key = keyProvider.resolve(policy.keyRef());
298245
String alphabet = policy.alphabet();
299246

247+
// 1. Strip tag (first N chars of the full string)
248+
String withoutTag = hasTag ? protectedValue.substring(policy.tagLength()) : protectedValue;
249+
250+
// 2. Strip passthroughs, record positions
251+
int[] ptPositions = new int[withoutTag.length()];
252+
char[] ptChars = new char[withoutTag.length()];
253+
int ptCount = 0;
300254
StringBuilder encryptable = new StringBuilder();
301-
for (int i = 0; i < protectedValue.length(); i++) {
302-
char c = protectedValue.charAt(i);
255+
for (int i = 0; i < withoutTag.length(); i++) {
256+
char c = withoutTag.charAt(i);
303257
if (alphabet.indexOf(c) >= 0) {
304258
encryptable.append(c);
259+
} else {
260+
ptPositions[ptCount] = i;
261+
ptChars[ptCount] = c;
262+
ptCount++;
305263
}
306264
}
307265

308-
String cipherChars = encryptable.toString();
309-
if (hasTag && policy.tag() != null) {
310-
cipherChars = cipherChars.substring(policy.tagLength());
266+
// 3. Decrypt
267+
String decrypted;
268+
if (ff3) {
269+
decrypted = new FF3(key, new byte[8], alphabet).decrypt(encryptable.toString());
270+
} else {
271+
decrypted = new FF1(key, new byte[0], alphabet).decrypt(encryptable.toString());
311272
}
312273

313-
FF3 ff3 = new FF3(key, new byte[8], alphabet);
314-
String decrypted = ff3.decrypt(cipherChars);
274+
// 4. Reinsert passthroughs at original positions
275+
StringBuilder result = new StringBuilder(decrypted);
276+
for (int i = 0; i < ptCount; i++) {
277+
result.insert(ptPositions[i], ptChars[i]);
278+
}
315279

316-
return reinsertPassthroughForAccess(decrypted, protectedValue, alphabet,
317-
hasTag ? policy.tagLength() : 0);
280+
return result.toString();
318281
} catch (IllegalArgumentException e) {
319282
throw e;
320283
} catch (Exception e) {
321-
throw new RuntimeException("FF3 decryption failed: " + e.getMessage(), e);
322-
}
323-
}
324-
325-
// -- Passthrough helpers --
326-
327-
/**
328-
* Extract the first N encryptable characters from a value (skipping passthrough chars).
329-
*/
330-
private String extractTag(String value, String alphabet, int tagLength) {
331-
StringBuilder sb = new StringBuilder(tagLength);
332-
for (int i = 0; i < value.length() && sb.length() < tagLength; i++) {
333-
if (alphabet.indexOf(value.charAt(i)) >= 0) {
334-
sb.append(value.charAt(i));
335-
}
336-
}
337-
return sb.toString();
338-
}
339-
340-
/**
341-
* Reinsert passthrough characters from the original value into the cipher output.
342-
* The output is longer than the input by tagLength chars (the tag adds characters).
343-
*/
344-
private String reinsertPassthrough(String cipherChars, boolean[] isPassthrough,
345-
char[] passthroughChars, int origLen, int tagExtra) {
346-
int totalLen = origLen + tagExtra;
347-
StringBuilder result = new StringBuilder(totalLen);
348-
int cipherIdx = 0;
349-
350-
// Build the output: passthrough chars stay in their original positions,
351-
// cipher chars fill the rest, extra tag chars extend the end
352-
for (int i = 0; i < origLen; i++) {
353-
if (isPassthrough[i]) {
354-
result.append(passthroughChars[i]);
355-
} else {
356-
if (cipherIdx < cipherChars.length()) {
357-
result.append(cipherChars.charAt(cipherIdx++));
358-
}
359-
}
360-
}
361-
// Append remaining cipher chars (from the tag extension)
362-
while (cipherIdx < cipherChars.length()) {
363-
result.append(cipherChars.charAt(cipherIdx++));
364-
}
365-
366-
return result.toString();
367-
}
368-
369-
/**
370-
* For access: reconstruct plaintext with passthrough chars from the protected value.
371-
* The protected value is longer than the original by tagLength.
372-
*/
373-
private String reinsertPassthroughForAccess(String decrypted, String protectedValue,
374-
String alphabet, int tagExtra) {
375-
// The original had (protectedValue.length - tagExtra) total chars
376-
int origLen = protectedValue.length() - tagExtra;
377-
StringBuilder result = new StringBuilder(origLen);
378-
int decIdx = 0;
379-
int encCount = 0; // count of encryptable chars seen
380-
381-
// Walk the protected value, skip tag chars, preserve passthroughs
382-
for (int i = 0; i < protectedValue.length() && result.length() < origLen; i++) {
383-
char c = protectedValue.charAt(i);
384-
if (alphabet.indexOf(c) >= 0) {
385-
encCount++;
386-
if (encCount <= tagExtra) {
387-
continue; // skip tag chars
388-
}
389-
if (decIdx < decrypted.length()) {
390-
result.append(decrypted.charAt(decIdx++));
391-
}
392-
} else {
393-
result.append(c); // passthrough
394-
}
284+
throw new RuntimeException("FPE decryption failed: " + e.getMessage(), e);
395285
}
396-
397-
return result.toString();
398286
}
399287

400288
private static String bytesToHex(byte[] bytes) {

0 commit comments

Comments
 (0)