Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- Add NAT64 (RFC 6052 § 2.2) embedding strategy, with named constructors for
the Well-known Prefix (`64:ff9b::/96`), operator Network-specific Prefixes,
and the RFC 8215 Local-use prefix (`64:ff9b:1::/48`).
- Add `Teredo` embedding strategy for `2001::/32` (according to RFC 4380 § 4).

## `6.0.0`

Expand Down
8 changes: 4 additions & 4 deletions docs/05-strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ When using version 4 and version 6 addresses interchangeably (via the
that both versions are stored as 16-byte binary sequences.

Unfortunately there are several different strategies for embedding a version 4
address into version 6, so this library offers various strategy implementations
for the main four:
address into version 6, so this library offers various strategy implementations:

> In the formats below, `X` marks the embedded version 4 address, and `?` marks
> bits that play no part in detection/extraction.

| Strategy Name | Implementation | Format |
|-----------------|---------------------------------|-------------------------------------------|
| 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` |
| 6to4-derived | `Darsyn\IP\Strategy\Derived` | `2002:XXXX:XXXX:????:????:????:????:????` |
| NAT64 | `Darsyn\IP\Strategy\Nat64` | (see below) |
| Teredo | `Darsyn\IP\Strategy\Teredo` | `2001:0000:????:????:????:????:XXXX:XXXX` |
| IPv4-compatible | `Darsyn\IP\Strategy\Compatible` | `0000:0000:0000:0000:0000:0000:XXXX:XXXX` |

Each embedding strategy implements the
`Darsyn\IP\Strategy\EmbeddingStrategyInterface` which defines methods to:
Expand Down
63 changes: 63 additions & 0 deletions src/Strategy/Teredo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Darsyn\IP\Strategy;

use Darsyn\IP\Exception\Strategy as StrategyException;
use Darsyn\IP\Util\Binary;
use Darsyn\IP\Util\MbString;

/**
* Extracts an IPv4 address from the Teredo prefix `2001::/32`, as defined by
* RFC 4380 ("Teredo: Tunneling IPv6 over UDP through NATs"), § 4.
*
* Note: `isEmbedded()` detects any address within the Teredo prefix
* (`2001::/32`, RFC 4380 § 2.6) with the IPv4 address embedded in the last 32
* bits, obfuscated by XOR'ing with 0xFFFFFFFF. The canonical address has bits
* 32-95 zeroed, whereas the non-canonical form embeds the tunnel server's IPv4
* address (bits 32-63), flags (bits 64-79), and the client's obfuscated UDP
* port (bits 80-95, XOR'd with 0xFFFF).
*
* Consequently, version-aware operations on Multi-type IPs using this strategy
* treat every Teredo address as an embedded IPv4 address, even though only the
* canonical form can round-trip through extraction.
*/
class Teredo implements EmbeddingStrategyInterface
{
private const INVERSE_MASK = "\xff\xff\xff\xff";

public function isEmbedded(string $binary): bool
{
return 16 === MbString::getLength($binary)
&& MbString::subString($binary, 0, 4) === Binary::fromHex('20010000');
}

public function extract(string $binary): string
{
if (16 === MbString::getLength($binary)) {
// The client's public IPv4 sits in the last 4 bytes XOR'd with 0xFFFFFFFF.
return MbString::subString($binary, 12, 4) ^ self::INVERSE_MASK;
}
throw new StrategyException\ExtractionException($binary, $this);
}

/**
* Warning: packing is not the inverse of extraction for every address that
* `isEmbedded()` accepts. Extraction keeps only the client's public IPv4
* address carried (obfuscated) in bits 96-127.
* The tunnel server's IPv4 address, the flags, and the obfuscated UDP port
* are lost so a direct pass-through (extract-pack) reconstructs the
* canonical Teredo address (`2001::XXXX:XXXX`), not the original.
*/
public function pack(string $binary): string
{
if (4 === MbString::getLength($binary)) {
// Zero the server/flags/port fields.
$serverFlagsPort = Binary::fromHex('0000000000000000');
// Re-obfuscate the client IPv4 (XOR 0xFFFFFFFF).
return Binary::fromHex('20010000') . $serverFlagsPort . ($binary ^ self::INVERSE_MASK);
}
throw new StrategyException\PackingException($binary, $this);
}
}
1 change: 1 addition & 0 deletions tests/DataProvider/IPv6.php
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ public static function getCategorizedIpAddresses()
'::7f00:1' => self::GLOBALLY_REACHABLE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE | self::LOOPBACK_COMPATIBLE,
'::12.34.56.78' => self::GLOBALLY_REACHABLE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE,
'0::000:0000:b12:cab' => self::GLOBALLY_REACHABLE_V6 | self::UNICAST_GLOBAL | self::COMPATIBLE,
'2001:0:4136:e378:8000:63bf:80ff:fffe' => self::UNICAST_OTHER | self::TEREDO | self::LOOPBACK_TEREDO,
'1cc9:7d7f:2a9f:cabd:9186:2be5:bef1:6a54' => self::GLOBALLY_REACHABLE_V6 | self::UNICAST_GLOBAL,
'b638:cc70:716:c4d4:f69c:4ee3:6c65:a0b2' => self::GLOBALLY_REACHABLE_V6 | self::UNICAST_GLOBAL,
'140c:12f1:6e6f:c0bb:980e:3816:3e52:1193' => self::GLOBALLY_REACHABLE_V6 | self::UNICAST_GLOBAL,
Expand Down
11 changes: 7 additions & 4 deletions tests/DataProvider/IpDataProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ interface IpDataProviderInterface
public const MAPPED = 1 << 23;
public const DERIVED = 1 << 24;
public const COMPATIBLE = 1 << 25;
public const LOOPBACK_MAPPED = 1 << 26;
public const LOOPBACK_COMPATIBLE = 1 << 27;
public const LOOPBACK_DERIVED = 1 << 28;
public const TEREDO = 1 << 26;
public const LOOPBACK_MAPPED = 1 << 27;
public const LOOPBACK_COMPATIBLE = 1 << 28;
public const LOOPBACK_DERIVED = 1 << 29;
public const LOOPBACK_TEREDO = 1 << 30;

// Combinations
public const GLOBALLY_REACHABLE = 0
Expand All @@ -48,7 +50,8 @@ interface IpDataProviderInterface
public const LOOPBACK_EMBEDDED = 0
| self::LOOPBACK_MAPPED
| self::LOOPBACK_COMPATIBLE
| self::LOOPBACK_DERIVED;
| self::LOOPBACK_DERIVED
| self::LOOPBACK_TEREDO;
public const MULTICAST = 0
| self::MULTICAST_IPV4
| self::MULTICAST_INTERFACE_LOCAL
Expand Down
9 changes: 9 additions & 0 deletions tests/DataProvider/Multi.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,15 @@ public static function getDerivedLoopbackIpAddresses()
);
}

/** @return list<array{string, bool}> */
public static function getTeredoLoopbackIpAddresses()
{
return array_merge(
IPv4::getLoopbackIpAddresses(),
IPv6::getCategoryOfIpAddresses(IPv6::LOOPBACK | IPv6::LOOPBACK_TEREDO)
);
}

/** @return list<array{string, bool}> */
public static function getMulticastIpAddresses()
{
Expand Down
83 changes: 83 additions & 0 deletions tests/DataProvider/Strategy/Teredo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Darsyn\IP\Tests\DataProvider\Strategy;

class Teredo
{
/** @return list<array{string, bool}> */
public static function getValidIpAddresses()
{
$valid = array_map(static function (array $row) {
$row[1] = true;
return $row;
}, self::getValidSequences());
$invalid = array_map(static function (array $row) {
$row[1] = false;
return $row;
}, self::getInvalidSequences());
return array_merge($valid, $invalid);
}

/** @return list<array{string}> */
public static function getInvalidIpAddresses()
{
return [
[pack('H*', '20010db8000000000a608a2e037073')],
[pack('H*', '20010db8000000000a608a2e0370734556')],
['12345678901234567'],
['123456789012345'],
];
}

/**
* The client's public IPv4 address sits in the last 4 bytes XOR'd with
* `0xffffffff`, so the extracted sequence is the bitwise NOT of the last
* 4 bytes of the IPv6 address.
*
* @return list<array{string, string}>
*/
public static function getValidSequences()
{
return [
// Well-known worked example: server `65.54.227.120`, port `40000`, client `192.0.2.45`.
[pack('H*', '200100004136e378800063bf3ffffdd2'), pack('H*', 'c000022d')],
[pack('H*', '200100004136e378800063bf80fffffe'), pack('H*', '7f000001')],
[pack('H*', '200100004136e378800063bfedcba987'), pack('H*', '12345678')],
// Canonical all-zero server, flags, and port.
[pack('H*', '20010000000000000000000080fffffe'), pack('H*', '7f000001')],
];
}

/**
* `pack()` reconstructs only the canonical Teredo form (zeroed server,
* flags, and UDP port). These pairs map an IPv4 address to the address
* `pack()` produces, not necessarily the address it was extracted from (see
* getValidSequences).
*
* @return list<array{string, string}>
*/
public static function getValidPackSequences()
{
return [
[pack('H*', 'c000022d'), pack('H*', '2001000000000000000000003ffffdd2')],
[pack('H*', '7f000001'), pack('H*', '20010000000000000000000080fffffe')],
[pack('H*', '12345678'), pack('H*', '200100000000000000000000edcba987')],
];
}

/** @return list<array{string}> */
public static function getInvalidSequences()
{
return [
// Benchmarking (2001:2::/48), just outside the Teredo /32.
[pack('H*', '20010002000000000000000000000001')],
// Documentation (2001:db8::/32).
[pack('H*', '20010db8000000000a608a2e03707334')],
[pack('H*', '00000000000000000000ffff7f000001')],
[pack('H*', '0064ff9b00000000000000007f000001')],
[pack('H*', '20027f00000100000000000000000000')],
];
}
}
91 changes: 91 additions & 0 deletions tests/Strategy/TeredoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace Darsyn\IP\Tests\Strategy;

use Darsyn\IP\Strategy\Teredo;
use Darsyn\IP\Tests\DataProvider\Strategy\Teredo as TeredoDataProvider;
use PHPUnit\Framework\Attributes as PHPUnit;
use PHPUnit\Framework\TestCase;

class TeredoTest extends TestCase
{
/** @var \Darsyn\IP\Strategy\EmbeddingStrategyInterface $strategy */
private $strategy;

/** @before */
#[PHPUnit\Before]
protected function setUpWithoutReturnDeclaration(): void
{
$this->strategy = new Teredo();
}

/**
* @test
* @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getInvalidIpAddresses()
*/
#[PHPUnit\Test]
#[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getInvalidIpAddresses')]
public function testIsEmbeddedReturnsFalseForAStringOtherThan16BytesLong(string $value): void
{
$this->assertFalse($this->strategy->isEmbedded($value));
}

/**
* @test
* @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getValidIpAddresses()
*/
#[PHPUnit\Test]
#[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getValidIpAddresses')]
public function testIsEmbedded(string $value, bool $isEmbedded): void
{
$this->assertSame($isEmbedded, $this->strategy->isEmbedded($value));
}

/**
* @test
* @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getInvalidIpAddresses()
*/
#[PHPUnit\Test]
#[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getInvalidIpAddresses')]
public function testExceptionIsThrownWhenTryingToExtractFromStringsNot16Bytes(string $value): void
{
$this->expectException(\Darsyn\IP\Exception\Strategy\ExtractionException::class);
$this->strategy->extract($value);
}

/**
* @test
* @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getValidSequences()
*/
#[PHPUnit\Test]
#[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getValidSequences')]
public function testCorrectSequenceExtractedFromIpBinary(string $ipv6, string $ipv4): void
{
$this->assertSame($ipv4, $this->strategy->extract($ipv6));
}

/**
* @test
* @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getInvalidIpAddresses()
*/
#[PHPUnit\Test]
#[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getInvalidIpAddresses')]
public function testExceptionIsThrownWhenTryingToPackStringsNot4Bytes(string $value): void
{
$this->expectException(\Darsyn\IP\Exception\Strategy\PackingException::class);
$this->strategy->pack($value);
}

/**
* @test
* @dataProvider \Darsyn\IP\Tests\DataProvider\Strategy\Teredo::getValidPackSequences()
*/
#[PHPUnit\Test]
#[PHPUnit\DataProviderExternal(TeredoDataProvider::class, 'getValidPackSequences')]
public function testSequenceCorrectlyPackedIntoIpBinaryFromIpBinary(string $ipv4, string $ipv6): void
{
$this->assertSame($ipv6, $this->strategy->pack($ipv4));
}
}
12 changes: 12 additions & 0 deletions tests/Version/MultiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,18 @@ public function testIsLoopbackDerived(string $value, bool $isLoopback): void
$this->assertSame($isLoopback, $ip->isLoopback());
}

/**
* @test
* @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getTeredoLoopbackIpAddresses()
*/
#[PHPUnit\Test]
#[PHPUnit\DataProviderExternal(MultiDataProvider::class, 'getTeredoLoopbackIpAddresses')]
public function testIsLoopbackTeredo(string $value, bool $isLoopback): void
{
$ip = IP::factory($value, new Strategy\Teredo());
$this->assertSame($isLoopback, $ip->isLoopback());
}

/**
* @test
* @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getMulticastIpAddresses()
Expand Down