diff --git a/CHANGELOG.md b/CHANGELOG.md index 2978643..41fc844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ - Bugfix: classify full `fc00::/7` block as private use. - Add (or update) community standards: `SECURITY.md`, `CODE_OF_CONDUCT.md` and `CONTRIBUTING.md` +- Bugfix: detect embedded IPv4 across the entire 6to4 block (`2002::/16`, RFC + 3056 § 2), not just canonical 6to4 addresses with a zeroed 80-bit tail. ## `6.0.0` diff --git a/docs/05-strategies.md b/docs/05-strategies.md index b851dac..d491a5a 100644 --- a/docs/05-strategies.md +++ b/docs/05-strategies.md @@ -10,7 +10,7 @@ for the main three: | Strategy Name | Implementation | Format | |-----------------|---------------------------------|-------------------------------------------| -| 6to4-derived | `Darsyn\IP\Strategy\Derived` | `2002:XXXX:XXXX:0000:0000:0000:0000:0000` | +| 6to4-derived | `Darsyn\IP\Strategy\Derived` | `2002:XXXX:XXXX:????:????:????:????:????` | | IPv4-compatible | `Darsyn\IP\Strategy\Compatible` | `0000:0000:0000:0000:0000:0000:XXXX:XXXX` | | IPv4-mapped | `Darsyn\IP\Strategy\Mapped` | `0000:0000:0000:0000:0000:ffff:XXXX:XXXX` | diff --git a/src/Strategy/Compatible.php b/src/Strategy/Compatible.php index 9a6a602..8d3f73b 100644 --- a/src/Strategy/Compatible.php +++ b/src/Strategy/Compatible.php @@ -7,6 +7,12 @@ use Darsyn\IP\Exception\Strategy as StrategyException; use Darsyn\IP\Util\MbString; +/** + * Embeds an IPv4 address within the IPv4-compatible prefix `::/96`, as defined + * by RFC 4291 ("IP Version 6 Addressing Architecture"), § 2.5.5.1 + * + * Note: this format is deprecated and retained only for backwards compatibility. + */ class Compatible implements EmbeddingStrategyInterface { public function isEmbedded(string $binary): bool diff --git a/src/Strategy/Derived.php b/src/Strategy/Derived.php index d99503a..ef85198 100644 --- a/src/Strategy/Derived.php +++ b/src/Strategy/Derived.php @@ -8,13 +8,28 @@ use Darsyn\IP\Util\Binary; use Darsyn\IP\Util\MbString; +/** + * Embeds an IPv4 address within the 6to4 prefix `2002::/16`, as defined by RFC + * 3056 ("Connection of IPv6 Domains via IPv4 Clouds") § 2. + * + * Note: `isEmbedded()` detects any address within the 6to4 block (`2002::/16`, + * RFC 3056) with the IPv4 address embedded in bits 16-47. The canonical address + * has the remaining 80 bits zeroed, whereas non-canonical addresses embed the + * SLA ID (subnet) and interface ID. + * + * Consequently, version-aware operations on Multi-type IPs treat every 6to4 + * host address as an embedded IPv4 address, even though only the canonical + * form can round-trip through extraction. + * + * N.B. Legacy, but not formally deprecated (only 6to4 anycast was deprecated + * via RFC 7526). + */ class Derived implements EmbeddingStrategyInterface { public function isEmbedded(string $binary): bool { return 16 === MbString::getLength($binary) - && MbString::subString($binary, 0, 2) === Binary::fromHex('2002') - && "\0\0\0\0\0\0\0\0\0\0" === MbString::subString($binary, 6, 10); + && MbString::subString($binary, 0, 2) === Binary::fromHex('2002'); } public function extract(string $binary): string @@ -25,10 +40,20 @@ public function extract(string $binary): string throw new StrategyException\ExtractionException($binary, $this); } + /** + * Warning: packing is not the inverse of extraction for every address that + * `isEmbedded()` accepts. Extraction keeps only the IPv4 address carried + * in bits 16-47 of a 6to4 address. + * The SLA ID and interface ID bits are lost so a direct pass-through + * (extract-pack) reconstructs the canonical Derived address + * (`2002:XXXX:XXXX::`), not the original. + */ public function pack(string $binary): string { if (4 === MbString::getLength($binary)) { - return Binary::fromHex('2002') . $binary . "\0\0\0\0\0\0\0\0\0\0"; + // Zero the SLA ID (subnet) and interface ID fields. + $subnetInterface = "\0\0\0\0\0\0\0\0\0\0"; + return Binary::fromHex('2002') . $binary . $subnetInterface; } throw new StrategyException\PackingException($binary, $this); } diff --git a/src/Strategy/Mapped.php b/src/Strategy/Mapped.php index 4d42094..ff0e56e 100644 --- a/src/Strategy/Mapped.php +++ b/src/Strategy/Mapped.php @@ -8,6 +8,12 @@ use Darsyn\IP\Util\Binary; use Darsyn\IP\Util\MbString; +/** + * Embeds an IPv4 address within the IPv4-mapped prefix `::ffff:0:0/96`, as + * defined by RFC 4291 ("IP Version 6 Addressing Architecture"), § 2.5.5.2 + * + * Reserved by protocol, and not globally reachable (as an IPv6 address). + */ class Mapped implements EmbeddingStrategyInterface { public function isEmbedded(string $binary): bool diff --git a/tests/DataProvider/IPv6.php b/tests/DataProvider/IPv6.php index 61a4a67..00f7c4f 100644 --- a/tests/DataProvider/IPv6.php +++ b/tests/DataProvider/IPv6.php @@ -299,6 +299,7 @@ public static function getCategorizedIpAddresses() '0000:0000:0000:0000:0000:ffff:7f00:a001' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::MAPPED | self::LOOPBACK_MAPPED, '2002::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED, '2002:7f00:1::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED | self::LOOPBACK_DERIVED, + '2002:7f00:1:1::1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED | self::LOOPBACK_DERIVED, '2002:1234:4321:0:00:000:0000::' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::DERIVED, '::7f00:1' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE | self::LOOPBACK_COMPATIBLE, '::12.34.56.78' => self::PUBLIC_USE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE, diff --git a/tests/DataProvider/Strategy/Derived.php b/tests/DataProvider/Strategy/Derived.php index c6ee9bb..56f8529 100644 --- a/tests/DataProvider/Strategy/Derived.php +++ b/tests/DataProvider/Strategy/Derived.php @@ -42,6 +42,18 @@ public static function getValidSequences() ]; } + /** @return list */ + public static function getNonCanonicalSequences() + { + return [ + // [ non-canonical 6to4 binary, embedded IPv4 (bits 16-47), canonical 6to4 binary ]. + // Non-zero SLA ID (subnet), zeroed interface ID. + [pack('H*', '2002c000020112340000000000000000'), pack('H*', 'c0000201'), pack('H*', '2002c000020100000000000000000000')], + // Zeroed SLA ID, non-zero interface ID. + [pack('H*', '2002c00002010000dead00000000beef'), pack('H*', 'c0000201'), pack('H*', '2002c000020100000000000000000000')], + ]; + } + /** @return list */ public static function getInvalidSequences() { diff --git a/tests/Strategy/DerivedTest.php b/tests/Strategy/DerivedTest.php index 33d9ab8..62522f4 100644 --- a/tests/Strategy/DerivedTest.php +++ b/tests/Strategy/DerivedTest.php @@ -88,4 +88,38 @@ public function testSequenceCorrectlyPackedIntoIpBinaryFromIpBinary(string $ipv6 { $this->assertSame($ipv6, $this->strategy->pack($ipv4)); } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Derived::getNonCanonicalSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(DerivedDataProvider::class, 'getNonCanonicalSequences')] + public function testNonCanonical6to4IsEmbedded(string $ipv6, string $ipv4, string $canonical): void + { + $this->assertTrue($this->strategy->isEmbedded($ipv6)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Derived::getNonCanonicalSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(DerivedDataProvider::class, 'getNonCanonicalSequences')] + public function testCorrectSequenceExtractedFromNonCanonical6to4(string $ipv6, string $ipv4, string $canonical): void + { + $this->assertSame($ipv4, $this->strategy->extract($ipv6)); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Derived::getNonCanonicalSequences() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(DerivedDataProvider::class, 'getNonCanonicalSequences')] + public function testNonCanonical6to4CanonicalisedByExtractThenPack(string $ipv6, string $ipv4, string $canonical): void + { + $this->assertNotSame($canonical, $ipv6); + $this->assertSame($canonical, $this->strategy->pack($this->strategy->extract($ipv6))); + } }