diff --git a/CHANGELOG.md b/CHANGELOG.md index 205981a..ca297b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Product release notes: [docs.proofable.me/changelog](https://docs.proofable.me/c ## [Unreleased] +## [0.1.9] - 2026-10-27 + +### Fixed + +- **`verifyPortableProofEnvelope` verifies any CAIP-380 envelope.** An envelope without a `walletAddress` field, such as one from another CAIP-380 implementation or the specification's test vectors, threw an error. The signer is now taken from the envelope's `did:pkh` identifier, and an envelope that carries `walletAddress` verifies exactly as before. + ## [0.1.8] - 2026-10-27 ### Fixed @@ -168,7 +174,8 @@ The `@proofable/sdk` package continues the line published as `@neus/sdk`. The no | [1.1.0](https://www.npmjs.com/package/@neus/sdk/v/1.1.0) | 2026-05-26 | First public SDK release on npm: OAuth-first setup, the CLI, public MCP tools including encrypted Vault secrets, and the Claude Code plugin with the trust workflow skill. | | [1.0.12](https://www.npmjs.com/package/@neus/sdk/v/1.0.12) | 2026-05-26 | First `@neus/sdk` release with the hosted MCP CLI (`neus setup`, `neus auth`, import/export) and the OAuth browser flow. | -[Unreleased]: https://github.com/proofable/sdk/compare/v0.1.8...HEAD +[Unreleased]: https://github.com/proofable/sdk/compare/v0.1.9...HEAD +[0.1.9]: https://github.com/proofable/sdk/compare/v0.1.8...v0.1.9 [0.1.8]: https://github.com/proofable/sdk/compare/v0.1.7...v0.1.8 [0.1.7]: https://github.com/proofable/sdk/compare/v0.1.6...v0.1.7 [0.1.6]: https://github.com/proofable/sdk/compare/v0.1.5...v0.1.6 diff --git a/package-lock.json b/package-lock.json index 8f8d9c8..68b2148 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@proofable/sdk", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@proofable/sdk", - "version": "0.1.8", + "version": "0.1.9", "license": "Apache-2.0", "dependencies": { "@noble/ed25519": "^2.3.0", diff --git a/package.json b/package.json index 36d812c..9e81ee7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@proofable/sdk", - "version": "0.1.8", + "version": "0.1.9", "description": "SDK and CLI for verification gates, reusable proof, and agent permissions.", "bin": { "proofable": "cli/proofable.mjs" diff --git a/test/fixtures/caip-380/minimal-1.json b/test/fixtures/caip-380/minimal-1.json new file mode 100644 index 0000000..bbed306 --- /dev/null +++ b/test/fixtures/caip-380/minimal-1.json @@ -0,0 +1,23 @@ +{ + "did": "did:pkh:eip155:1:0xecdb7c90bc38ef62be0cf42b0e8e82f38cc008e0", + "qHash": "0x9cdd962a4348d3f351f4bb58b053e6d3ab1ea357a6e21e2247c74da4e6c534a4", + "verifierIds": [ + "ownership-basic" + ], + "data": { + "owner": "0xecdb7c90bc38ef62be0cf42b0e8e82f38cc008e0", + "reference": { + "id": "example-1", + "type": "other" + } + }, + "signedMessage": "Portable Proof Verification Request\nWallet: 0xecdb7c90bc38ef62be0cf42b0e8e82f38cc008e0\nChain: 1\nVerifiers: ownership-basic\nData: {\"owner\":\"0xecdb7c90bc38ef62be0cf42b0e8e82f38cc008e0\",\"reference\":{\"id\":\"example-1\",\"type\":\"other\"}}\nTimestamp: 1730000000000", + "signature": "0xff3050cab7c88616059aeeba2b2cc3d9072a9b4d8798f25f77130867a90704eb490f2fe4f805ec2a242b3e7ca4d8c61de32dcf0ba9f70a813e14b7bd01a4587d1b", + "signedTimestamp": 1730000000000, + "chainId": 1, + "signatureMethod": "eip191", + "meta": { + "testKey": "secp256k1 private key = keccak256(UTF-8 \"CAIP-380 test vector eip155-1\"). Test only." + }, + "options": {} +} diff --git a/test/fixtures/caip-380/minimal-solana-1.json b/test/fixtures/caip-380/minimal-solana-1.json new file mode 100644 index 0000000..f86e7a1 --- /dev/null +++ b/test/fixtures/caip-380/minimal-solana-1.json @@ -0,0 +1,23 @@ +{ + "did": "did:pkh:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:9FJUvthcEuD4Ee6Yx2LdhRR37GcoH6CU1j566ax4romY", + "qHash": "0xbad48fd6c0a07b7e20b24be574beadd3c62145c779c42e777ea82589015a034f", + "verifierIds": [ + "ownership-basic" + ], + "data": { + "owner": "9FJUvthcEuD4Ee6Yx2LdhRR37GcoH6CU1j566ax4romY", + "reference": { + "id": "example-solana-1", + "type": "other" + } + }, + "signedMessage": "Portable Proof Verification Request\nWallet: 9FJUvthcEuD4Ee6Yx2LdhRR37GcoH6CU1j566ax4romY\nChain: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp\nVerifiers: ownership-basic\nData: {\"owner\":\"9FJUvthcEuD4Ee6Yx2LdhRR37GcoH6CU1j566ax4romY\",\"reference\":{\"id\":\"example-solana-1\",\"type\":\"other\"}}\nTimestamp: 1730000000000", + "signature": "4zQsekfdUntqvEnxDPnrYrKiXpSCWRC1dufZpt6dm43ggFDCeENaBXc3FdF1mW37DR893do3ETrPS5vAzprkiYMn", + "signedTimestamp": 1730000000000, + "chain": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "signatureMethod": "ed25519", + "meta": { + "testKey": "Ed25519 seed = SHA-256(UTF-8 \"CAIP-380 test vector solana-1\"). Test only." + }, + "options": {} +} diff --git a/test/utils.test.js b/test/utils.test.js index 9f4c25e..d5320ec 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; import { createRequire } from 'module'; import { Wallet } from 'ethers'; import * as ed25519 from '@noble/ed25519'; @@ -164,6 +165,21 @@ describe('Utils', () => { expect(computePortableProofQHash({ ...base, data: { nested: { a: 1, b: 2 } } })).toBe(qHash); }); + it('verifies the CAIP-380 test vectors, which carry no walletAddress', async () => { + // Copies of assets/caip-380/minimal-1.json and minimal-solana-1.json in ChainAgnostic/CAIPs + const vectors = ['minimal-1.json', 'minimal-solana-1.json'].map((name) => + JSON.parse(readFileSync(new URL(`./fixtures/caip-380/${name}`, import.meta.url), 'utf8'))); + for (const vector of vectors) { + expect(computePortableProofQHash(vector)).toBe(vector.qHash); + const message = constructVerificationMessage({ ...vector, walletAddress: vector.data.owner }); + expect(message).toBe(vector.signedMessage); + await expect(verifyPortableProofEnvelope(vector, { now: Date.UTC(2026, 9, 27) })) + .resolves.toMatchObject({ + valid: true, didValid: true, signatureValid: true, qHashValid: true, fresh: false + }); + } + }); + it('verifies a Solana-style Ed25519 envelope offline', async () => { const privateKey = Uint8Array.from({ length: 32 }, (_, index) => index + 1); const walletAddress = bs58.encode(ed25519.getPublicKey(privateKey)); diff --git a/utils.js b/utils.js index 9b1e07b..8afd494 100644 --- a/utils.js +++ b/utils.js @@ -229,6 +229,11 @@ export function computePortableProofQHash(envelope) { return `0x${shake256(canonical, 256)}`; } +function addressFromPkhDid(did) { + if (typeof did !== 'string' || !did.startsWith('did:pkh:')) return null; + return did.slice(did.lastIndexOf(':') + 1) || null; +} + /** * Verify a portable proof without calling Proofable. * EOA and Ed25519 signatures are fully offline. Smart-account signatures return @@ -247,12 +252,20 @@ export async function verifyPortableProofEnvelope(envelope, options = {}) { envelope.qHash.toLowerCase() === computedQHash.toLowerCase(); if (!qHashValid) errors.push('qHash does not match the canonical envelope'); + // CAIP-380 envelopes need not carry walletAddress; the signer is the did:pkh address. + const walletAddress = typeof envelope.walletAddress === 'string' && envelope.walletAddress + ? envelope.walletAddress + : addressFromPkhDid(envelope.did); + if (!walletAddress) { + errors.push('did must be a did:pkh identifier'); + return { valid: false, qHashValid, didValid: false, signatureValid: false, computedQHash, errors }; + } const chainContext = envelope.chain ?? envelope.chainId; - const expectedDid = deriveDid(envelope.walletAddress, chainContext); + const expectedDid = deriveDid(walletAddress, chainContext); const didValid = expectedDid === envelope.did; if (!didValid) errors.push('did does not match walletAddress and chain context'); - const message = constructVerificationMessage(envelope); + const message = constructVerificationMessage({ ...envelope, walletAddress }); const method = String(envelope.signatureMethod || (envelope.chain ? 'ed25519' : 'eip191')).toLowerCase(); let signatureValid = false; let signer = null; @@ -261,16 +274,16 @@ export async function verifyPortableProofEnvelope(envelope, options = {}) { if (method === 'ed25519') { try { const namespace = String(envelope.chain || '').split(':')[0]; - const publicKeyText = namespace === 'near' && envelope.walletAddress.startsWith('ed25519:') - ? envelope.walletAddress.slice(8) - : envelope.walletAddress; + const publicKeyText = namespace === 'near' && walletAddress.startsWith('ed25519:') + ? walletAddress.slice(8) + : walletAddress; const publicKey = bs58.decode(publicKeyText); const signatureText = String(envelope.signature || '').replace(/^ed25519:/, ''); const signature = /^(0x)?[a-fA-F0-9]{128}$/.test(signatureText) ? Uint8Array.from(signatureText.replace(/^0x/, '').match(/.{2}/g).map((byte) => Number.parseInt(byte, 16))) : bs58.decode(signatureText); signatureValid = await ed25519.verify(signature, new TextEncoder().encode(message), publicKey); - signer = signatureValid ? envelope.walletAddress : null; + signer = signatureValid ? walletAddress : null; } catch (error) { errors.push(`Ed25519 signature verification failed: ${error.message}`); } @@ -279,7 +292,7 @@ export async function verifyPortableProofEnvelope(envelope, options = {}) { try { const { verifyMessage } = await import('ethers'); signer = verifyMessage(message, envelope.signature); - signatureValid = signer.toLowerCase() === envelope.walletAddress.toLowerCase(); + signatureValid = signer.toLowerCase() === walletAddress.toLowerCase(); } catch (error) { evmError = error; } @@ -288,13 +301,13 @@ export async function verifyPortableProofEnvelope(envelope, options = {}) { try { const { Contract, hashMessage } = await import('ethers'); const contract = new Contract( - envelope.walletAddress, + walletAddress, ['function isValidSignature(bytes32,bytes) view returns (bytes4)'], options.provider ); const magic = await contract.isValidSignature(hashMessage(message), envelope.signature); signatureValid = String(magic).toLowerCase() === '0x1626ba7e'; - signer = signatureValid ? envelope.walletAddress : signer; + signer = signatureValid ? walletAddress : signer; } catch (error) { evmError = error; }