Skip to content

Commit

Permalink
ASN.1 indefinite length support added; Thereby StoreKit receipts now …
Browse files Browse the repository at this point in the history
…supported; Got rid of necessity of any math extension
  • Loading branch information
pkotets committed Aug 29, 2023
1 parent 81c6373 commit f58a0bd
Show file tree
Hide file tree
Showing 12 changed files with 173 additions and 181 deletions.
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
32 changes: 20 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<sub>* 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`</sub>
<sub>* 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`</sub>

# Installation

Expand All @@ -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
);
```

Expand All @@ -34,7 +38,7 @@ try {
'ABC1234DEF',
"-----BEGIN PRIVATE KEY-----\n<base64-encoded private key goes here>\n-----END PRIVATE KEY-----"
);
} catch (WrongEnvironmentException $e) {
} catch (\Readdle\AppStoreServerAPI\Exception\WrongEnvironmentException $e) {
exit($e->getMessage());
}
Expand All @@ -43,30 +47,34 @@ $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.

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

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
6 changes: 6 additions & 0 deletions src/ASN1/ASN1Identifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/ASN1/ASN1ValueLength.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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) {
Expand All @@ -42,6 +45,11 @@ public function __construct(BufferReader $bufferReader)
}
}

public function isIndefinite(): bool
{
return $this->isIndefinite;
}

public function getOwnLength(): int
{
return $this->ownLength;
Expand Down
63 changes: 51 additions & 12 deletions src/ASN1/AbstractASN1Object.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Readdle\AppStoreReceiptVerification\ASN1;

use DateTimeImmutable;
use Exception;
use JsonSerializable;
use Readdle\AppStoreReceiptVerification\Buffer;
use Readdle\AppStoreReceiptVerification\BufferPointer;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
78 changes: 40 additions & 38 deletions src/ASN1/Universal/Integer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
}
Loading

0 comments on commit f58a0bd

Please sign in to comment.