# 02 — Crypto Contract (BINDING — must match exactly)

The apps verify tokens offline. If the server deviates from **any** of these, tokens fail to validate
and no one can activate. This is the same contract already running live.

---

## Signature

| Parameter | Value |
|-----------|-------|
| Algorithm | RSA (2048‑bit) |
| Scheme | **RSASSA‑PSS** |
| Hash | **SHA‑256** |
| MGF | MGF1 with **SHA‑256** |
| Salt length | **32** bytes (= hash length; matches .NET `RSASignaturePadding.Pss`) |

## Token format

```
token = base64url(payloadJson) + "." + base64url(signature)
```
- `payloadJson` = compact JSON of the claims (see `01-API-REFERENCE.md`).
- The signature is computed over the **ASCII bytes of the base64url‑payload string** (i.e. over
  `base64url(payloadJson)`), **not** over the raw JSON. This detail matters — get it wrong and every
  token fails.
- base64url = URL‑safe base64 (`-`/`_`), **no padding**.

## Reference: signing (Node.js — the live-proven logic)

```js
import crypto from 'node:crypto';
const b64url = (buf) => Buffer.from(buf).toString('base64url');

function signToken(payload, PRIVATE_KEY_PEM) {
  const p = b64url(JSON.stringify(payload));                 // base64url(payload)
  const sig = crypto.sign('sha256', Buffer.from(p), {         // sign the base64url STRING bytes
    key: PRIVATE_KEY_PEM,
    padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
    saltLength: 32,
  });
  return p + '.' + b64url(sig);
}
```

## Reference: signing (PHP / Laravel — use phpseclib v3)

> PHP's native `openssl_sign` **cannot** do PSS with a fixed salt length reliably — **use phpseclib v3**.
> `composer require phpseclib/phpseclib:~3.0`

```php
use phpseclib3\Crypt\RSA;
use phpseclib3\Crypt\PublicKeyLoader;

function b64url(string $bin): string {
    return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}

function signToken(array $payload, string $privatePem): string {
    $p = b64url(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
    /** @var RSA\PrivateKey $key */
    $key = PublicKeyLoader::load($privatePem)
        ->withPadding(RSA::SIGNATURE_PSS)
        ->withHash('sha256')
        ->withMGFHash('sha256')
        ->withSaltLength(32);
    $sig = $key->sign($p);                 // sign the base64url-payload string bytes
    return $p . '.' . b64url($sig);
}
```

## Reference: verify your private key matches the app-trusted public key

Before going live (or after any server change) prove the server signs tokens the apps accept:

```php
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\RSA;

$spkiB64 = trim(file_get_contents('keys/public_app_trusted.b64'));   // from this pack
$pub = PublicKeyLoader::load(base64_decode($spkiB64))
    ->withPadding(RSA::SIGNATURE_PSS)->withHash('sha256')
    ->withMGFHash('sha256')->withSaltLength(32);

[$p, $sigB64] = explode('.', $token);                 // a token your server just issued
$sig = base64_decode(strtr($p2 = $sigB64, '-_', '+/') . str_repeat('=', (4 - strlen($sigB64) % 4) % 4));
var_dump($pub->verify($p, $sig));                      // MUST be true
```
If this prints `false`, your **private key does not match the shipped apps** — do not deploy; recover
the correct private key (the one already on the live server).

## Key format & hashing (identical everywhere — admin, shop, activate)

- **Key string:** `CONV-XXXXX-XXXXX-XXXXX` — 4 groups, Crockford Base32 alphabet
  (`0-9 A-Z` minus `I L O U`). `CONV-` prefix is cosmetic.
- **Stored:** only the **hash**, never the raw key.
  ```
  key_hash = sha256_hex( uppercase( preg_replace('/[^A-Za-z0-9]/', '', key) ) )
  ```
- Generation + hashing must use the **same normalization** in the admin UI, the shop webhook, and
  `/api/activate`, or a valid key won't match its stored hash.

```php
function hashKey(string $key): string {
    return hash('sha256', strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $key)));
}
```

## The keypair itself

- 2048‑bit RSA. The **live private key already exists on the server** and matches
  `keys/public_app_trusted.b64` (the value embedded in the shipped apps).
- **Do not regenerate.** If you ever must: generate with
  `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out license_private.pem`,
  export SPKI public as base64, embed the new public key in a **new app release**, and re‑ship all apps.
- Private key file: perms `600`, outside web root, **never** committed.
