Skip to content

Commit 6620670

Browse files
committed
feat: 2-arg Access errors on header_enabled=true configurations
Per design decision 2026-05-17, the two-argument Access(value, name) is for header_enabled=false configurations only — it treats the input as raw headerless ciphertext. For headered configurations, the header itself identifies the configuration, so Access(value) is the right call. Previously Access(value, name) silently stripped the header on the explicit path, which made the API ambiguous (callers couldn't tell whether the value they passed had a header or not). Now it throws ArgumentException so callers can fix the call site instead of seeing garbage decrypts. Implementation: Access(value, name) now checks HeaderEnabled and throws before dispatching. AccessByHeader strips the header itself and passes raw ciphertext to AccessFpe, which no longer takes an explicitConfiguration flag and always assumes its input is headerless. The strip therefore happens exactly once and only on the header path. Added TwoArgAccessOnHeaderedConfigRaises; README clarifies that the two-arg form is only valid for header_enabled=false configurations. 36 tests pass.
1 parent 7418155 commit 6620670

3 files changed

Lines changed: 33 additions & 8 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ var encrypted = c.Protect("123-45-6789", "ssn");
4141
// Access (header-based, no configuration name needed)
4242
var decrypted = c.Access(encrypted);
4343
// → "123-45-6789"
44+
45+
// For configurations with header_enabled=false, name the configuration explicitly:
46+
// var decrypted = c.Access(encrypted, "ssn_unheadered");
47+
// The two-arg form is only valid for header_enabled=false configurations —
48+
// calling it on a headered configuration throws ArgumentException because the
49+
// header itself identifies which configuration to use.
4450
```
4551

4652
## Engines

src/Cyphera/Cyphera.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ public string Access(string protectedValue, string? configurationName = null)
7676
if (configurationName != null)
7777
{
7878
var configuration = GetConfiguration(configurationName);
79-
return AccessFpe(protectedValue, configuration, explicitConfiguration: true);
79+
if (configuration.HeaderEnabled)
80+
throw new ArgumentException(
81+
$"configuration '{configurationName}' has header_enabled=true; use Access(value) — the header identifies the configuration. The two-arg form is for header_enabled=false configurations only.");
82+
return AccessFpe(protectedValue, configuration);
8083
}
8184

8285
return AccessByHeader(protectedValue);
@@ -90,7 +93,9 @@ public string AccessByHeader(string protectedValue)
9093
if (protectedValue.Length > header.Length && protectedValue.StartsWith(header))
9194
{
9295
var configuration = GetConfiguration(_headerIndex[header]);
93-
return AccessFpe(protectedValue, configuration);
96+
// Strip the header here so AccessFpe always receives raw headerless ciphertext.
97+
var stripped = protectedValue[header.Length..];
98+
return AccessFpe(stripped, configuration);
9499
}
95100
}
96101

@@ -127,19 +132,18 @@ private string ProtectFpe(string value, Configuration configuration, bool isFF3)
127132
return result;
128133
}
129134

130-
private string AccessFpe(string protectedValue, Configuration configuration, bool explicitConfiguration = false)
135+
// Always assumes `protectedValue` is raw headerless ciphertext. Callers on the
136+
// header path strip the header before calling; the explicit-name path is only
137+
// valid for header_enabled=false configurations, so no header is present.
138+
private string AccessFpe(string protectedValue, Configuration configuration)
131139
{
132140
if (configuration.Engine != "ff1" && configuration.Engine != "ff3")
133141
throw new ArgumentException($"Cannot reverse '{configuration.Engine}' — not reversible");
134142

135143
var key = ResolveKey(configuration.KeyRef);
136144
var alphabet = configuration.Alphabet;
137145

138-
var withoutHeader = protectedValue;
139-
if (!explicitConfiguration && configuration.HeaderEnabled && configuration.Header != null)
140-
withoutHeader = protectedValue[configuration.Header.Length..];
141-
142-
var (encryptable, positions, chars) = ExtractPassthroughs(withoutHeader, alphabet);
146+
var (encryptable, positions, chars) = ExtractPassthroughs(protectedValue, alphabet);
143147

144148
string decrypted;
145149
if (configuration.Engine == "ff3")

tests/Cyphera.Tests/CypheraClientTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,5 +134,20 @@ public void CrossLanguageVector()
134134
var result = c.Protect("123-45-6789", "ssn");
135135
Assert.Equal("T01i6J-xF-07pX", result);
136136
}
137+
138+
// ── New error condition: 2-arg Access on headered config ──
139+
140+
[Fact]
141+
public void TwoArgAccessOnHeaderedConfigRaises()
142+
{
143+
var c = CreateClient();
144+
var protected_ = c.Protect("123-45-6789", "ssn");
145+
// ssn has header_enabled=true; Access(value, "ssn") must error rather
146+
// than silently return garbage. Callers should use Access(value) so
147+
// the header identifies the configuration.
148+
var ex = Assert.Throws<ArgumentException>(() => c.Access(protected_, "ssn"));
149+
Assert.Contains("header_enabled=true", ex.Message);
150+
Assert.Contains("ssn", ex.Message);
151+
}
137152
}
138153
}

0 commit comments

Comments
 (0)