Skip to content

Commit 33d4626

Browse files
committed
feat: strict FF3/FF3-1 tweak — no silent zero-fill (matches NIST + BC + rust)
Configuration now parses the optional hex 'tweak' field and exposes it to the Client. FPE dispatch in protect/access requires an exact-length tweak for FF3 (8 bytes) and FF3-1 (7 bytes); missing or wrong length throws with the canonical spec message. FF1 stays permissive — empty tweak is still fine per NIST SP 800-38G. Silent zero-fill is gone — any two configs that omit a tweak no longer share an FPE keyspace under the same key.
1 parent 4180be7 commit 33d4626

2 files changed

Lines changed: 79 additions & 6 deletions

File tree

src/Cyphera.php

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,21 @@ private function __construct(array $config)
5555
$this->headerIndex[$header] = $name;
5656
}
5757

58+
$tweakHex = $cfg['tweak'] ?? null;
59+
$tweak = (is_string($tweakHex) && $tweakHex !== '') ? hex2bin($tweakHex) : null;
60+
if ($tweakHex !== null && $tweak === false) {
61+
throw new \InvalidArgumentException("configuration error: invalid hex tweak in '{$name}'");
62+
}
63+
5864
$this->configurations[$name] = [
65+
'name' => $name,
5966
'engine' => $cfg['engine'] ?? 'ff1',
6067
'alphabet' => self::resolveAlphabet($cfg['alphabet'] ?? null),
6168
'key_ref' => $cfg['key_ref'] ?? null,
6269
'header' => $header,
6370
'header_enabled' => $headerEnabled,
6471
'header_length' => (int)($cfg['header_length'] ?? 3),
72+
'tweak' => $tweak,
6573
'pattern' => $cfg['pattern'] ?? null,
6674
'algorithm' => $cfg['algorithm'] ?? 'sha256',
6775
];
@@ -163,6 +171,22 @@ private function accessWithConfiguration(string $protectedValue, array $configur
163171

164172
// ── FPE ──
165173

174+
/**
175+
* Require an exact-length tweak for FF3 / FF3-1. Missing or wrong-length
176+
* tweaks are a hard error — no silent zero-fill. FF1 tweak is optional
177+
* per NIST SP 800-38G and is handled separately.
178+
*/
179+
private static function requireTweak(array $configuration, int $expectedLen, string $label): string
180+
{
181+
$tweak = $configuration['tweak'] ?? null;
182+
if (!is_string($tweak) || strlen($tweak) !== $expectedLen) {
183+
throw new \InvalidArgumentException(
184+
"configuration '{$configuration['name']}' is missing required 'tweak' ({$label} needs {$expectedLen} bytes)"
185+
);
186+
}
187+
return $tweak;
188+
}
189+
166190
private static bool $ff3Warned = false;
167191

168192
/** Emit the FF3 deprecation warning to stderr, once per process. */
@@ -187,11 +211,11 @@ private function protectFpe(string $value, array $configuration): string
187211

188212
if ($configuration['engine'] === 'ff3') {
189213
$this->warnFf3Deprecated();
190-
$cipher = new FF3($key, str_repeat("\x00", 8), $alphabet);
214+
$cipher = new FF3($key, self::requireTweak($configuration, 8, 'FF3'), $alphabet);
191215
} elseif ($configuration['engine'] === 'ff31') {
192-
$cipher = new FF31($key, str_repeat("\x00", 7), $alphabet);
216+
$cipher = new FF31($key, self::requireTweak($configuration, 7, 'FF3-1'), $alphabet);
193217
} else {
194-
$cipher = new FF1($key, '', $alphabet);
218+
$cipher = new FF1($key, $configuration['tweak'] ?? '', $alphabet);
195219
}
196220
$encrypted = $cipher->encrypt($encryptable);
197221

@@ -224,11 +248,11 @@ private function accessFpe(string $rawCiphertext, array $configuration): string
224248

225249
if ($configuration['engine'] === 'ff3') {
226250
$this->warnFf3Deprecated();
227-
$cipher = new FF3($key, str_repeat("\x00", 8), $alphabet);
251+
$cipher = new FF3($key, self::requireTweak($configuration, 8, 'FF3'), $alphabet);
228252
} elseif ($configuration['engine'] === 'ff31') {
229-
$cipher = new FF31($key, str_repeat("\x00", 7), $alphabet);
253+
$cipher = new FF31($key, self::requireTweak($configuration, 7, 'FF3-1'), $alphabet);
230254
} else {
231-
$cipher = new FF1($key, '', $alphabet);
255+
$cipher = new FF1($key, $configuration['tweak'] ?? '', $alphabet);
232256
}
233257
$decrypted = $cipher->decrypt($encryptable);
234258

tests/CypheraTest.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,53 @@ public function testTwoArgAccessOnIrreversibleConfigurationRaises(): void
129129
$this->expectExceptionMessage("cannot reverse 'ssn_mask' — mask is irreversible");
130130
$c->access($masked, 'ssn_mask');
131131
}
132+
133+
// ── Strict FF3 / FF3-1 tweak (no silent zero-fill) ──
134+
135+
public function testFf3MissingTweakRaises(): void
136+
{
137+
$c = Cyphera::fromConfig([
138+
'configurations' => [
139+
'ff3_no_tweak' => ['engine' => 'ff3', 'alphabet' => 'digits', 'key_ref' => 'k', 'header' => 'T03'],
140+
],
141+
'keys' => ['k' => ['material' => '2B7E151628AED2A6ABF7158809CF4F3C']],
142+
]);
143+
$this->expectException(\InvalidArgumentException::class);
144+
$this->expectExceptionMessage("configuration 'ff3_no_tweak' is missing required 'tweak' (FF3 needs 8 bytes)");
145+
$c->protect('123456789', 'ff3_no_tweak');
146+
}
147+
148+
public function testFf31MissingTweakRaises(): void
149+
{
150+
$c = Cyphera::fromConfig([
151+
'configurations' => [
152+
'ff31_no_tweak' => ['engine' => 'ff31', 'alphabet' => 'digits', 'key_ref' => 'k', 'header' => 'T04'],
153+
],
154+
'keys' => ['k' => ['material' => '2B7E151628AED2A6ABF7158809CF4F3C']],
155+
]);
156+
$this->expectException(\InvalidArgumentException::class);
157+
$this->expectExceptionMessage("configuration 'ff31_no_tweak' is missing required 'tweak' (FF3-1 needs 7 bytes)");
158+
$c->protect('123456789', 'ff31_no_tweak');
159+
}
160+
161+
public function testFf3WithExplicitTweakRoundtrips(): void
162+
{
163+
$c = Cyphera::fromConfig([
164+
'configurations' => [
165+
'ff3_ok' => ['engine' => 'ff3', 'alphabet' => 'digits', 'key_ref' => 'k', 'header' => 'T05', 'tweak' => 'D8E7920AFA330A73'],
166+
],
167+
'keys' => ['k' => ['material' => '2B7E151628AED2A6ABF7158809CF4F3C']],
168+
]);
169+
$protected = $c->protect('123456789', 'ff3_ok');
170+
$this->assertNotSame('123456789', $protected);
171+
$this->assertSame('123456789', $c->access($protected));
172+
}
173+
174+
public function testFf1MissingTweakStillWorks(): void
175+
{
176+
// FF1 tweak stays optional per NIST SP 800-38G.
177+
$c = self::createClient();
178+
$protected = $c->protect('123456789', 'ssn'); // ssn is ff1 with no tweak
179+
$this->assertSame('123456789', $c->access($protected));
180+
}
132181
}

0 commit comments

Comments
 (0)