Repository navigation
What's new
-
X402\Client\KmsWallet(abstract). Owns the three rules fromdocs/kms.md: memoised address derivation from the KMS-providedSubjectPublicKeyInfo, EIP-2 low-s normalisation, and recovery-id derivation via brute force against the expected address. Subclasses provide two thin methods:abstract protected function fetchPublicKeySpki(): string; // binary SPKI DER abstract protected function rawSign(string $digestBytes): string; // binary signature DER
The abstract handles the rest — DER decoding, canonical-s flip,
r ‖ s ‖ vpacking — so a new KMS / HSM / remote-signer integration is ~40 LOC instead of the ~250 LOC adopters previously wrote by hand against the 0.7.xWalletinterface. The address materialises before the sign call so akms:GetPublicKeyfailure surfaces before akms:Signquota is consumed, and the SPKI's 65-byte point is validated against secp256k1 curve membership (catches a misconfigured KMS that returns a syntactically-valid but off-curve payload). -
X402\Client\AwsKmsWallet. First-party AWS KMS subclass. Wireskms:GetPublicKeyandkms:Signwith the parameters AWS requires for EVM signing (MessageType = DIGEST,SigningAlgorithm = ECDSA_SHA_256). Authentication, region selection, retry policy, and the rest of the AWS client configuration live entirely in theAws\Kms\KmsClientinstance you pass in — the class is intentionally policy-free.aws/aws-sdk-phpis incomposer suggest, notrequire— adopters on raw private keys, HD wallets, or other KMS providers don't carry the dep. Add it explicitly when you want the AWS path:composer require aws/aws-sdk-php
Usage:
use Aws\Kms\KmsClient; use X402\Client\AwsKmsWallet; use X402\Client\PayingClient; $wallet = new AwsKmsWallet( kms: new KmsClient(['region' => 'us-east-1', 'version' => 'latest']), keyId: 'arn:aws:kms:us-east-1:123:key/abcd-...', ); $client = new PayingClient(inner: $psr18Client, wallet: $wallet);
The KMS key MUST be
ECC_SECG_P256K1with usageSIGN_VERIFY. Other curves trip a fail-fastInvalidArgumentExceptionon the firstaddress()call thanks to the strict AlgorithmIdentifier check below. -
X402\Support\Asn1DerDecoder. Strict canonical ASN.1 DER parser for the two shapes KMS wallets actually need: ECDSA signatures (SEQUENCE { r INTEGER, s INTEGER }) and secp256k1 SubjectPublicKeyInfo. Rejects:- Wrong outer / inner tags.
- Negative ASN.1 integers (32-byte INTEGER with the high bit set and no positivity pad).
- Non-canonical leading-zero encodings (over-padded INTEGERs).
- INTEGER values exceeding 32 bytes.
- SPKI AlgorithmIdentifier bytes that aren't the canonical
id-ecPublicKey+secp256k1prefix — wrong-curve KMS keys (P-256, P-384, arbitrary OID) surface up-front instead of as a downstream recovery mismatch. - SPKI BIT STRING contents that aren't a 65-byte
0x04-prefixed uncompressed point. - Trailing bytes after the outer SEQUENCE.
- Truncated length prefixes.
Strictness is enforced at the trust boundary rather than papered over downstream — a malformed KMS response is a configuration bug, and the wallet refuses to sign rather than emit a "valid-looking" signature.
-
X402\Schemes\Evm\EcdsaRecovery. Brute-forces the recovery-id byte for a(digest, r, s)triple by recovering the public key under each candidatev ∈ {27, 28}and matching the derived address against the expected signer. Throws when neither candidate resolves — the KMS signature does not belong to the configured public key. Reach for it from any KMS-backedWallet; pure-PHP signers likePrivateKeyWalletget the recovery id directly from simplito. -
Wallet conformance test suite.
tests/Unit/Client/WalletConformanceTest.phpis a dataset-driven harness. EveryWalletimplementation gets a row; every row must satisfy:- EIP-2 canonical low-s for any digest.
- Deterministic signing (RFC 6979) — same digest twice → same signature byte-for-byte.
- Signature recovers to the wallet's address.
v ∈ {27, 28}.- Signature is
0x+ 130 hex chars; address is0x+ 40 hex chars.
New
Walletsubclasses add a row in the dataset; bespoke implementation-specific tests stay focused on implementation-specific edge cases. This is the gap that let the high-s bug below live undetected for six releases — the prior tests only asserted recovery, not canonicality.
Bug fixes
SignatureExporter::toHex65now enforces EIP-2 canonical low-s for every emitted signature.PrivateKeyWalletandHdWalletrelied on simplito'scanonical: trueoption to keepsin the low half of the curve order. That option is silently dropped when called viaKeyPair::sign($enc = false, $options = ['canonical' => true])—EC::sign's first branch swaps the args when$encisn't a string and clobbers the options. ~50% of digests therefore produced high-s signatures that EIP-2-enforcing contracts (USDC'stransferWithAuthorization, OpenZeppelin'sECDSA.recover, and most modern verifiers) reject silently.SignatureExporternow flipsston - sand parity-toggles the recovery byte whens > n / 2, regardless of what the underlying signer emits. Recovery still resolves to the same address; the rawsandvbytes differ for what used to be the high-s half.
Breaking changes
-
Signature
s/vbytes change for ~50% of digests as a consequence of the EIP-2 low-s fix. Adopters who pinned signature output byte-for-byte in their test suite (expect($wallet->signDigest(...))->toBe('0x…')) will need to re-generate those fixtures or replace the assertion with a recovery + low-s check. The wire format is identical; the recovered address is identical; only the canonical-s representation has changed. Marked breaking because byte-output is part of the observed contract for some adopters. -
X402\Support\Asn1DerDecoderrejects non-canonical DER. If you wrote a custom adapter that calleddecodeSignature()against a non-strict-DER blob — over-padded INTEGER, ASN.1-negative INTEGER, wrong AlgorithmIdentifier OID — it now throwsInvalidArgumentExceptioninstead of silently decoding. Real KMSes emit canonical DER, so production paths are unaffected.
Full Changelog: 0.7.0...0.8.0