From f58a0bde76efdff349cc66e88ccae9e28aec8d3b Mon Sep 17 00:00:00 2001
From: Pavlo Kotets <99185488+pkotets@users.noreply.github.com>
Date: Tue, 29 Aug 2023 21:24:22 +0100
Subject: [PATCH] ASN.1 indefinite length support added; Thereby StoreKit
receipts now supported; Got rid of necessity of any math extension
---
CHANGELOG.md | 25 +++++
README.md | 32 ++++--
composer.json | 2 +-
src/ASN1/ASN1Identifier.php | 6 +
src/ASN1/ASN1ValueLength.php | 10 +-
src/ASN1/AbstractASN1Object.php | 63 +++++++++--
src/ASN1/Universal/Integer.php | 78 ++++++-------
src/Math.php | 104 ------------------
src/PKCS7/X501/Name.php | 2 +-
src/ReceiptContainer.php | 4 +
.../AppStoreReceiptVerificationTest.php | 11 +-
tests/Unit/ASN1/ASN1ValueLengthTest.php | 17 +--
12 files changed, 173 insertions(+), 181 deletions(-)
create mode 100644 CHANGELOG.md
delete mode 100644 src/Math.php
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..69c051d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,25 @@
+### [1.4.0] 2023-08-29
+
+**IMPROVEMENTS:**
+
+- ASN.1: indefinite length support added
+- Thereby StoreKit receipts now supported
+- Got rid of necessity of any math extension
+
+### [1.3.0] 2023-08-25
+
+**IMPROVEMENTS:**
+
+- AppStoreServerAPIReceiptExtender: merge strategy changed, now receipt's data won't be overwritten by API response. The reason is that API response doesn't contain `is_trial_period` and `is_in_intro_offer_period`
+
+### [1.2.0] 2023-08-25
+
+**IMPROVEMENTS:**
+
+- Receipt verification fix: receiptCreationDate is now ignored in favor of requestDate
+
+### [1.1.0] 2023-08-23
+
+**IMPROVEMENTS:**
+
+- Receipt verification fix: if no receiptCreationDate found in the receipt, requestDate will be used instead
diff --git a/README.md b/README.md
index 1853fb1..1db18aa 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ This is a ***zero-dependencies\* pure PHP*** App Store receipt verification libr
However, the bridge to `App Store Server API` is implemented as well, so it's possible to go event further and extend receipt data using API.
-* Zero-dependencies means that this library doesn't rely on any third-party library. Ath the same time this library relies on such an essential PHP extensions as `json`, `openssl` and either `bcmath` or `gmp`
+* Zero-dependencies means that this library doesn't rely on any third-party library. At the same time this library relies on such an essential PHP extensions as `json` and `openssl`
# Installation
@@ -17,9 +17,13 @@ Nothing special here, just use composer to install the package:
Parse base64-encoded receipt data and verify it was signed by Apple root certificate:
```
+$appleIncRootCertificate = \Readdle\AppStoreReceiptVerification\Utils::DER2PEM(
+ file_get_contents('https://www.apple.com/appleca/AppleIncRootCertificate.cer')
+);
+
$serializedReceipt = \Readdle\AppStoreReceiptVerification\AppStoreReceiptVerification::verifyReceipt(
$receiptData,
- Utils::DER2PEM(file_get_contents('https://www.apple.com/appleca/AppleIncRootCertificate.cer'))
+ $appleIncRootCertificate
);
```
@@ -34,7 +38,7 @@ try {
'ABC1234DEF',
"-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----"
);
-} catch (WrongEnvironmentException $e) {
+} catch (\Readdle\AppStoreServerAPI\Exception\WrongEnvironmentException $e) {
exit($e->getMessage());
}
@@ -43,11 +47,15 @@ $mergeNewEntries = true;
try {
$extendedReceipt = $receiptExtender->extend($serializedReceipt, $mergeNewEntries);
-} catch (Exception $e) {
+} catch (\Exception $e) {
exit($e->getMessage());
}
```
+# StoreKit receipts
+
+Since version 1.4.0 `StoreKit` receipts are also supported. Such receipts contain very limited amount of data if compare to sandbox/production receipts, and **they could be verified in dev mode (see below) ONLY** (because of absence of certificates chain).
+
# About the content of receipts
Unfortunately, App Store receipts doesn't contain all the information returned by deprecated `App Store Receipt Verification` API inside on them.
@@ -55,18 +63,18 @@ Unfortunately, App Store receipts doesn't contain all the information returned b
At the same time they contain some extra fields which are, probably, not so useful, but as they are there anyway, you'll get them in the result set as well.
The list of missing fields in app receipt:
-- adam_id
+- `adam_id`
The list of missing fields in in-app purchase receipt:
-- app_account_token
-- in_app_ownership_type
-- offer_code_ref_name
-- subscription_group_identifier
+- `app_account_token`
+- `in_app_ownership_type`
+- `offer_code_ref_name`
+- `subscription_group_identifier`
The list of extra fields in app receipt:
-- age_rating
-- opaque_value
-- sha1_hash
+- `age_rating`
+- `opaque_value`
+- `sha1_hash`
# Extending receipts
diff --git a/composer.json b/composer.json
index ec688e9..b9013a7 100644
--- a/composer.json
+++ b/composer.json
@@ -23,7 +23,7 @@
"php"
],
"homepage": "https://github.com/readdle/app-store-receipt-verification",
- "version": "1.3.0",
+ "version": "1.4.0",
"autoload": {
"psr-4": {
"Readdle\\AppStoreReceiptVerification\\": "src/"
diff --git a/src/ASN1/ASN1Identifier.php b/src/ASN1/ASN1Identifier.php
index e4fe2ce..cc5886f 100644
--- a/src/ASN1/ASN1Identifier.php
+++ b/src/ASN1/ASN1Identifier.php
@@ -24,6 +24,7 @@
final class ASN1Identifier
{
+ const TYPE__EOC = 0x00; // special type, means "End-of-contents", see X.690-0207 8.1.3
const TYPE__BOOLEAN = 0x01;
const TYPE__INTEGER = 0x02;
const TYPE__BIT_STRING = 0x03;
@@ -85,6 +86,11 @@ public function __construct(BufferReader $bufferReader)
}
}
+ public function isEOC(): bool
+ {
+ return $this->octet === self::TYPE__EOC;
+ }
+
public function isContextSpecific(): bool
{
// we're interested in 8 & 7 octets, so eliminate others
diff --git a/src/ASN1/ASN1ValueLength.php b/src/ASN1/ASN1ValueLength.php
index 663874b..282e8e8 100644
--- a/src/ASN1/ASN1ValueLength.php
+++ b/src/ASN1/ASN1ValueLength.php
@@ -12,6 +12,7 @@ final class ASN1ValueLength
const IS_RESERVED = 0b11111111;
const SHORT_FORM_MAX = 0b01111111;
+ private bool $isIndefinite = false;
private int $ownLength = 1;
private int $value;
@@ -20,7 +21,9 @@ public function __construct(BufferReader $bufferReader)
$octet = $bufferReader->readOrdinal();
if ($octet === self::IS_INDEFINITE) {
- throw new UnexpectedValueException('ASN.1 indefinite form length is not supported (yet?)');
+ $this->isIndefinite = true;
+ $this->value = 0;
+ return;
}
if ($octet === self::IS_RESERVED) {
@@ -42,6 +45,11 @@ public function __construct(BufferReader $bufferReader)
}
}
+ public function isIndefinite(): bool
+ {
+ return $this->isIndefinite;
+ }
+
public function getOwnLength(): int
{
return $this->ownLength;
diff --git a/src/ASN1/AbstractASN1Object.php b/src/ASN1/AbstractASN1Object.php
index 017f404..2179a25 100644
--- a/src/ASN1/AbstractASN1Object.php
+++ b/src/ASN1/AbstractASN1Object.php
@@ -4,6 +4,7 @@
namespace Readdle\AppStoreReceiptVerification\ASN1;
use DateTimeImmutable;
+use Exception;
use JsonSerializable;
use Readdle\AppStoreReceiptVerification\Buffer;
use Readdle\AppStoreReceiptVerification\BufferPointer;
@@ -48,7 +49,10 @@ abstract protected function setValue($value);
*/
abstract public function getValue();
- public static function fromBufferReader(BufferReader $bufferReader): self
+ /**
+ * @throws Exception
+ */
+ public static function fromBufferReader(BufferReader $bufferReader): ?self
{
$pointer = $bufferReader->createPointer();
$identifier = new ASN1Identifier($bufferReader);
@@ -57,25 +61,60 @@ public static function fromBufferReader(BufferReader $bufferReader): self
$length = new ASN1ValueLength($bufferReader);
$valueLength = $length->getValueLength();
- $totalLength = $identifier->getLength() + $length->getOwnLength() + $valueLength;
- $class = $isConstructed && $isContextSpecific ? ConstructedASN1Object::class : $identifier->getObjectClass();
- /** @var AbstractASN1Object $object */
- $object = new $class($identifier, $pointer, $totalLength);
+ if ($identifier->isEOC()) {
+ if ($valueLength !== 0) {
+ throw new Exception('ASN.1: unexpected EOC identifier with non-zero length');
+ }
+
+ return null;
+ }
if ($isConstructed) {
$toRead = $valueLength;
- $children = [];
+ $isIndefinite = $length->isIndefinite();
+ $value = [];
- while ($toRead > 0) {
+ while ($toRead > 0 || $isIndefinite) {
$child = self::fromBufferReader($bufferReader);
- $toRead -= $child->getLength();
- $children[] = $child;
- }
- $object->setValue($children);
+ if ($child === null) {
+ if ($isIndefinite) {
+ break;
+ }
+
+ throw new Exception("ASN.1: unexpected NULL child in {$identifier->getTypeString()}");
+ }
+
+ $childLength = $child->getLength();
+ $toRead -= $childLength;
+
+ if ($isIndefinite) {
+ $valueLength += $childLength;
+ }
+
+ $value[] = $child;
+ }
} elseif ($valueLength > 0) {
- $object->setValue($bufferReader->readSequence($valueLength));
+ $value = $bufferReader->readSequence($valueLength);
+ } else {
+ $value = null;
+ }
+
+ $totalLength = $identifier->getLength() + $length->getOwnLength() + $valueLength;
+ $class = ($isConstructed && $isContextSpecific) ? ConstructedASN1Object::class : $identifier->getObjectClass();
+ $object = new $class($identifier, $pointer, $totalLength);
+
+ if ($value !== null) {
+ if ((!$object instanceof ConstructedASN1Object) && is_array($value)) {
+ if (count($value) > 1) {
+ throw new Exception('ASN.1: unexpected primitive type with non-primitive value');
+ }
+
+ $value = $value[0]->getValue();
+ }
+
+ $object->setValue($value);
}
return $object;
diff --git a/src/ASN1/Universal/Integer.php b/src/ASN1/Universal/Integer.php
index 5af382b..da8372a 100644
--- a/src/ASN1/Universal/Integer.php
+++ b/src/ASN1/Universal/Integer.php
@@ -5,44 +5,63 @@
use Exception;
use Readdle\AppStoreReceiptVerification\ASN1\AbstractASN1Object;
-use Readdle\AppStoreReceiptVerification\Math;
-use function array_reverse;
-use function chunk_split;
+use function array_map;
use function count;
use function dechex;
+use function floor;
use function join;
use function ord;
-use function strlen;
+use function str_pad;
+use function str_split;
use function strtoupper;
-use function trim;
+
+use const STR_PAD_LEFT;
final class Integer extends AbstractASN1Object
{
- protected string $value;
+ protected string $decValue;
+ protected string $hexValue;
/**
* @throws Exception
*/
protected function setValue($value): void
{
- $length = strlen($value);
-
- if ($length === 1) {
- $this->value = (string) ord($value);
- return;
- }
-
- $this->value = '0';
-
- for ($i = 0; $i < strlen($value); $i++) {
- $this->value = Math::add(Math::mul($this->value, '256'), (string) ord($value[$i]));
- }
+ $hexParts = array_map(
+ fn ($chr) => strtoupper(str_pad(dechex(ord($chr)), 2, '0', STR_PAD_LEFT)),
+ str_split($value)
+ );
+ $this->hexValue = join(' ', $hexParts);
+
+ /** @noinspection SpellCheckingInspection */
+ $decParts = array_map('hexdec', str_split(join($hexParts)));
+ $length = count($hexParts) * 2;
+ $this->decValue = '';
+
+ do {
+ $div = 0;
+ $newLength = 0;
+
+ for ($i = 0; $i < $length; $i++) {
+ $div = $div * 16 + (int) $decParts[$i];
+
+ if ($div >= 10) {
+ $decParts[$newLength++] = floor($div / 10);
+ $div = $div % 10;
+ } elseif ($newLength > 0) {
+ $decParts[$newLength++] = 0;
+ }
+ }
+
+ $length = $newLength;
+ $this->decValue = $div . $this->decValue;
+ } while ($newLength != 0);
}
public function getValue(): string
{
- return $this->value;
+ return $this->decValue;
}
public function getIntValue(): int
@@ -55,28 +74,11 @@ public function getIntValue(): int
*/
public function getHexValue(): string
{
- $int = $this->getValue();
-
- if ($int === '0') {
- return '00';
- }
-
- $hex = [];
-
- while ($int != '0') {
- $hex[] = strtoupper(dechex((int) Math::mod($int, '16')));
- $int = Math::div($int, '16');
- }
-
- if (count($hex) % 2) {
- $hex[] = 0;
- }
-
- return trim(chunk_split(join(array_reverse($hex)), 2, ' '));
+ return $this->hexValue;
}
public function jsonSerialize(): string
{
- return $this->value;
+ return $this->decValue;
}
}
diff --git a/src/Math.php b/src/Math.php
deleted file mode 100644
index 45e24c9..0000000
--- a/src/Math.php
+++ /dev/null
@@ -1,104 +0,0 @@
-getValue();
$attributes[$type->getValue()] = $value->getValue();
diff --git a/src/ReceiptContainer.php b/src/ReceiptContainer.php
index 576c889..b126352 100644
--- a/src/ReceiptContainer.php
+++ b/src/ReceiptContainer.php
@@ -3,6 +3,7 @@
namespace Readdle\AppStoreReceiptVerification;
+use Exception;
use InvalidArgumentException;
use Readdle\AppStoreReceiptVerification\ASN1\AbstractASN1Object;
use Readdle\AppStoreReceiptVerification\PKCS7\AppStore\AppReceipt;
@@ -19,6 +20,9 @@ final class ReceiptContainer
private SignedData $signedData;
private AppReceipt $receipt;
+ /**
+ * @throws Exception
+ */
public function __construct(string $binaryData)
{
$bufferReader = new BufferReader(new Buffer($binaryData));
diff --git a/tests/Functional/AppStoreReceiptVerificationTest.php b/tests/Functional/AppStoreReceiptVerificationTest.php
index 0c9d438..71f585f 100644
--- a/tests/Functional/AppStoreReceiptVerificationTest.php
+++ b/tests/Functional/AppStoreReceiptVerificationTest.php
@@ -15,16 +15,17 @@ public function test(): void
{
$pathToSamples = join(DIRECTORY_SEPARATOR, [__DIR__, '..', 'samples']);
$certificate = Utils::DER2PEM(file_get_contents('https://www.apple.com/appleca/AppleIncRootCertificate.cer'));
+ $filesList = glob($pathToSamples . DIRECTORY_SEPARATOR . 'receipt?*.base64.txt');
- foreach (glob($pathToSamples . DIRECTORY_SEPARATOR . 'receipt?*.base64.txt') as $fullPath) {
- $filename = basename($fullPath);
+ foreach ($filesList as $file) {
+ $filename = basename($file);
if (!preg_match('/receipt(\d+)\.base64\.txt/', $filename, $m)) {
continue;
}
- $base64 = file_get_contents($fullPath);
- // AppStoreReceiptVerification::devMode();
+ $base64 = file_get_contents($file);
+ AppStoreReceiptVerification::devMode();
ob_start();
try {
@@ -37,7 +38,7 @@ public function test(): void
$this->fail("[$filename]: {$e->getMessage()}");
}
- file_put_contents($pathToSamples . DIRECTORY_SEPARATOR . "receipt{$m[1]}.json", ob_get_clean());
+ file_put_contents($pathToSamples . DIRECTORY_SEPARATOR . "receipt$m[1].json", ob_get_clean());
ob_start();
echo json_encode(
diff --git a/tests/Unit/ASN1/ASN1ValueLengthTest.php b/tests/Unit/ASN1/ASN1ValueLengthTest.php
index 63f36b8..dcb56a0 100644
--- a/tests/Unit/ASN1/ASN1ValueLengthTest.php
+++ b/tests/Unit/ASN1/ASN1ValueLengthTest.php
@@ -17,13 +17,6 @@ public function testExceptionOnInvalidData(): void
new ASN1ValueLength($this->createBufferReader(''));
}
- public function testExceptionOnLongForm(): void
- {
- $this->expectException(UnexpectedValueException::class);
- $this->expectExceptionMessage('ASN.1 indefinite form length is not supported (yet?)');
- new ASN1ValueLength($this->createBufferReader(ASN1ValueLength::SHORT_FORM_MAX + 1));
- }
-
public function testExceptionOnReserved(): void
{
$this->expectException(UnexpectedValueException::class);
@@ -37,6 +30,7 @@ public function testOneByteLength(): void
$length = new ASN1ValueLength($this->createBufferReader($lengthValue));
$this->assertEquals($lengthValue, $length->getValueLength());
$this->assertEquals(1, $length->getOwnLength());
+ $this->assertFalse($length->isIndefinite());
}
}
@@ -51,6 +45,15 @@ public function testMultiBytesLength(): void
$length = new ASN1ValueLength($this->createBufferReader($lengthBytes));
$this->assertEquals($value, $length->getValueLength());
$this->assertEquals(count($lengthBytes), $length->getOwnLength());
+ $this->assertFalse($length->isIndefinite());
}
}
+
+ public function testIndefiniteLength(): void
+ {
+ $length = new ASN1ValueLength($this->createBufferReader(ASN1ValueLength::IS_INDEFINITE));
+ $this->assertEquals(0, $length->getValueLength());
+ $this->assertEquals(1, $length->getOwnLength());
+ $this->assertTrue($length->isIndefinite());
+ }
}