Skip to content

Commit 2244d8b

Browse files
committed
feat: align error messages + strict FF3 tweak (cross-SDK consistency)
Adopt the canonical spec error-message text so the .NET SDK matches the rust reference byte-for-byte. Exception types remain ArgumentException; only the message text inside changes. FF3 tweak enforcement was already strict (8 bytes) and FF3-1 was already strict (7 bytes); messages are normalized to the spec-mandated 'invalid tweak length: N (expected M)' form. ToDigits in FF1/FF3 now reports the offending char + position instead of throwing KeyNotFoundException.
1 parent 31b23c8 commit 2244d8b

4 files changed

Lines changed: 55 additions & 23 deletions

File tree

src/Cyphera/Cyphera.cs

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public string Protect(string value, string configurationName)
6666
"ff1" or "ff3" or "ff31" => ProtectFpe(value, configuration),
6767
"mask" => ProtectMask(value, configuration),
6868
"hash" => ProtectHash(value, configuration),
69-
_ => throw new ArgumentException($"Unknown engine: {configuration.Engine}")
69+
_ => throw new ArgumentException($"unknown engine: {configuration.Engine}")
7070
};
7171
}
7272

@@ -80,14 +80,15 @@ public string Access(string protectedValue)
8080
{
8181
if (protectedValue.Length > header.Length && protectedValue.StartsWith(header))
8282
{
83-
var configuration = GetConfiguration(_headerIndex[header]);
83+
var name = _headerIndex[header];
84+
var configuration = GetConfiguration(name);
8485
// Strip the header here so AccessFpe always receives raw headerless ciphertext.
8586
var stripped = protectedValue[header.Length..];
86-
return AccessFpe(stripped, configuration);
87+
return AccessWithConfiguration(stripped, configuration, name);
8788
}
8889
}
8990

90-
throw new ArgumentException("No matching header found");
91+
throw new ArgumentException("no matching header found");
9192
}
9293

9394
// Escape-hatch access for unique situations where the protected value
@@ -102,6 +103,14 @@ public string Access(string protectedValue, string configurationName)
102103
if (configurationName == null)
103104
throw new ArgumentException("Access(value, configurationName) requires a configuration name.");
104105
var configuration = GetConfiguration(configurationName);
106+
return AccessWithConfiguration(protectedValue, configuration, configurationName);
107+
}
108+
109+
private string AccessWithConfiguration(string protectedValue, Configuration configuration, string configurationName)
110+
{
111+
if (configuration.Engine == "mask" || configuration.Engine == "hash")
112+
throw new ArgumentException(
113+
$"cannot reverse '{configurationName}' — {configuration.Engine} is irreversible");
105114
return AccessFpe(protectedValue, configuration);
106115
}
107116

@@ -131,7 +140,7 @@ private string ProtectFpe(string value, Configuration configuration)
131140

132141
var (encryptable, positions, chars) = ExtractPassthroughs(value, alphabet);
133142
if (encryptable.Length == 0)
134-
throw new ArgumentException("No encryptable characters in input");
143+
throw new ArgumentException("no encryptable characters in input");
135144

136145
string encrypted;
137146
if (configuration.Engine == "ff3")
@@ -164,8 +173,10 @@ private string ProtectFpe(string value, Configuration configuration)
164173
// a headerless value.
165174
private string AccessFpe(string protectedValue, Configuration configuration)
166175
{
176+
// Callers (AccessWithConfiguration) have already filtered mask/hash;
177+
// anything else here that isn't an FPE engine is an internal misuse.
167178
if (configuration.Engine != "ff1" && configuration.Engine != "ff3" && configuration.Engine != "ff31")
168-
throw new ArgumentException($"Cannot reverse '{configuration.Engine}' — not reversible");
179+
throw new ArgumentException($"unknown engine: {configuration.Engine}");
169180

170181
var key = ResolveKey(configuration.KeyRef);
171182
var alphabet = configuration.Alphabet;
@@ -198,7 +209,7 @@ private string AccessFpe(string protectedValue, Configuration configuration)
198209
private static string ProtectMask(string value, Configuration configuration)
199210
{
200211
if (string.IsNullOrEmpty(configuration.Pattern))
201-
throw new ArgumentException("Mask configuration requires 'pattern'");
212+
throw new ArgumentException("mask pattern required");
202213

203214
int len = value.Length;
204215
return configuration.Pattern switch
@@ -248,16 +259,16 @@ private string ProtectHash(string value, Configuration configuration)
248259
private Configuration GetConfiguration(string name)
249260
{
250261
if (!_configurations.TryGetValue(name, out var configuration))
251-
throw new ArgumentException($"Unknown configuration: {name}");
262+
throw new ArgumentException($"configuration not found: {name}");
252263
return configuration;
253264
}
254265

255266
private byte[] ResolveKey(string? keyRef)
256267
{
257268
if (string.IsNullOrEmpty(keyRef))
258-
throw new ArgumentException("No key_ref in configuration");
269+
throw new ArgumentException("key error: no key_ref in configuration");
259270
if (!_keys.TryGetValue(keyRef, out var key))
260-
throw new ArgumentException($"Unknown key: {keyRef}");
271+
throw new ArgumentException($"key error: unknown key '{keyRef}'");
261272
return key;
262273
}
263274

@@ -321,7 +332,7 @@ private void LoadKeys(JsonElement root)
321332
}
322333
else
323334
{
324-
throw new ArgumentException($"Key '{name}' must have either 'material' or 'source'");
335+
throw new ArgumentException($"key error: key '{name}' must have either 'material' or 'source'");
325336
}
326337
}
327338
}
@@ -372,12 +383,12 @@ private void LoadConfigurations(JsonElement root)
372383
string? header = p.TryGetProperty("header", out var hv) ? hv.GetString() : null;
373384

374385
if (headerEnabled && string.IsNullOrEmpty(header))
375-
throw new ArgumentException($"Configuration '{kv.Name}' has header_enabled=true but no header specified");
386+
throw new ArgumentException("configuration error: header must be specified");
376387

377388
if (headerEnabled && header != null)
378389
{
379390
if (_headerIndex.ContainsKey(header))
380-
throw new ArgumentException($"Header collision: '{header}' used by both '{_headerIndex[header]}' and '{kv.Name}'");
391+
throw new ArgumentException("configuration error: header collision");
381392
_headerIndex[header] = kv.Name;
382393
}
383394

src/Cyphera/FF1.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public class FF1
1515
public FF1(byte[] key, byte[] tweak, string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz")
1616
{
1717
if (key.Length != 16 && key.Length != 24 && key.Length != 32)
18-
throw new ArgumentException("Key must be 16, 24, or 32 bytes");
18+
throw new ArgumentException($"invalid key length: {key.Length} (expected 16, 24, or 32)");
1919
if (alphabet.Length < 2)
2020
throw new ArgumentException("Alphabet must have >= 2 chars");
2121

@@ -30,7 +30,17 @@ public FF1(byte[] key, byte[] tweak, string alphabet = "0123456789abcdefghijklmn
3030
public string Encrypt(string plaintext) => FromDigits(FF1Encrypt(ToDigits(plaintext), _tweak));
3131
public string Decrypt(string ciphertext) => FromDigits(FF1Decrypt(ToDigits(ciphertext), _tweak));
3232

33-
private int[] ToDigits(string s) => s.Select(c => _charMap[c]).ToArray();
33+
private int[] ToDigits(string s)
34+
{
35+
var d = new int[s.Length];
36+
for (int i = 0; i < s.Length; i++)
37+
{
38+
if (!_charMap.TryGetValue(s[i], out var idx))
39+
throw new ArgumentException($"invalid char '{s[i]}' at position {i}");
40+
d[i] = idx;
41+
}
42+
return d;
43+
}
3444
private string FromDigits(int[] d) => new string(d.Select(i => _alphabet[i]).ToArray());
3545

3646
// NIST SP 800-38G: length >= 2 and radix^length >= 1,000,000.

src/Cyphera/FF3.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ public class FF3
1515
public FF3(byte[] key, byte[] tweak, string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz")
1616
{
1717
if (key.Length != 16 && key.Length != 24 && key.Length != 32)
18-
throw new ArgumentException("Key must be 16, 24, or 32 bytes");
18+
throw new ArgumentException($"invalid key length: {key.Length} (expected 16, 24, or 32)");
1919
if (tweak.Length != 8)
20-
throw new ArgumentException("Tweak must be exactly 8 bytes");
20+
throw new ArgumentException($"invalid tweak length: {tweak.Length} (expected 8)");
2121
if (alphabet.Length < 2)
2222
throw new ArgumentException("Alphabet must have >= 2 chars");
2323

@@ -32,7 +32,17 @@ public FF3(byte[] key, byte[] tweak, string alphabet = "0123456789abcdefghijklmn
3232
public string Encrypt(string plaintext) => FromDigits(FF3Encrypt(ToDigits(plaintext)));
3333
public string Decrypt(string ciphertext) => FromDigits(FF3Decrypt(ToDigits(ciphertext)));
3434

35-
private int[] ToDigits(string s) => s.Select(c => _charMap[c]).ToArray();
35+
private int[] ToDigits(string s)
36+
{
37+
var d = new int[s.Length];
38+
for (int i = 0; i < s.Length; i++)
39+
{
40+
if (!_charMap.TryGetValue(s[i], out var idx))
41+
throw new ArgumentException($"invalid char '{s[i]}' at position {i}");
42+
d[i] = idx;
43+
}
44+
return d;
45+
}
3646
private string FromDigits(int[] d) => new string(d.Select(i => _alphabet[i]).ToArray());
3747

3848
// NIST SP 800-38G: length >= 2, radix^length >= 1,000,000, and
@@ -175,7 +185,7 @@ public class FF31
175185
public FF31(byte[] key, byte[] tweak, string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz")
176186
{
177187
if (tweak.Length != 7)
178-
throw new ArgumentException("FF3-1 tweak must be exactly 7 bytes (56 bits)");
188+
throw new ArgumentException($"invalid tweak length: {tweak.Length} (expected 7)");
179189
_inner = new FF3(key, ExpandTweak(tweak), alphabet);
180190
}
181191

tests/Cyphera.Tests/CypheraClientTests.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public void AccessNonreversibleRaises()
9191
// ssn_mask has header_enabled=false, so Access() can't find a header
9292
// and reports the no-matching-header error.
9393
var ex = Assert.Throws<ArgumentException>(() => c.Access(masked));
94-
Assert.Contains("No matching header", ex.Message);
94+
Assert.Equal("no matching header found", ex.Message);
9595
}
9696

9797
[Fact]
@@ -106,7 +106,7 @@ public void HeaderCollisionRaises()
106106
}";
107107
var doc = JsonDocument.Parse(json);
108108
var ex = Assert.Throws<ArgumentException>(() => Cyphera.FromConfig(doc.RootElement));
109-
Assert.Contains("Header collision", ex.Message);
109+
Assert.Equal("configuration error: header collision", ex.Message);
110110
}
111111

112112
[Fact]
@@ -118,7 +118,7 @@ public void HeaderRequiredRaises()
118118
}";
119119
var doc = JsonDocument.Parse(json);
120120
var ex = Assert.Throws<ArgumentException>(() => Cyphera.FromConfig(doc.RootElement));
121-
Assert.Contains("no header specified", ex.Message);
121+
Assert.Equal("configuration error: header must be specified", ex.Message);
122122
}
123123

124124
[Fact]
@@ -148,7 +148,8 @@ public void TwoArgAccessOnIrreversibleConfigRaises()
148148
// The 2-arg escape hatch is permissive about header_enabled but
149149
// still must refuse mask/hash configurations — those are one-way.
150150
var masked = c.Protect("123-45-6789", "ssn_mask");
151-
Assert.Throws<ArgumentException>(() => c.Access(masked, "ssn_mask"));
151+
var ex = Assert.Throws<ArgumentException>(() => c.Access(masked, "ssn_mask"));
152+
Assert.Equal("cannot reverse 'ssn_mask' — mask is irreversible", ex.Message);
152153
}
153154
}
154155
}

0 commit comments

Comments
 (0)