diff --git a/src/session.ts b/src/session.ts index bd078c7..6fd28bf 100644 --- a/src/session.ts +++ b/src/session.ts @@ -45,14 +45,14 @@ import type { SignerAdapter } from "./signer.js"; /** * Session key authorization domain separator (matches Rust `PQTX_SESSION_DOMAIN`). - * `b"PQTX_SESSION_V1\0"` — 16 bytes. + * `b"PQTX_SESSION_V2\0"` — 16 bytes. */ export const PQTX_SESSION_DOMAIN = new Uint8Array([ 0x50, 0x51, 0x54, 0x58, // PQTX 0x5f, 0x53, 0x45, 0x53, // _SES 0x53, 0x49, 0x4f, 0x4e, // SION - 0x5f, 0x56, 0x31, 0x00, // _V1\0 -]); // b"PQTX_SESSION_V1\0" + 0x5f, 0x56, 0x32, 0x00, // _V2\0 +]); // b"PQTX_SESSION_V2\0" /** Maximum session public-key size accepted by Shell Chain nodes. */ export const MAX_SESSION_PUBKEY_BYTES = 4 * 1024; @@ -84,7 +84,7 @@ export interface SessionKeyConfig { /** * Compute the session key authorization hash that the root key must sign. * - * `auth_hash = blake3(PQTX_SESSION_V1\0(16B) || session_pubkey || session_algo(1B) || target(32B|zero) || value_cap(32B BE) || expiry_block(8B BE) || chain_id(8B BE))` + * `auth_hash = blake3(PQTX_SESSION_V2\0(16B) || session_pubkey || session_algo(1B) || target_present(1B) || target(32B|zero) || value_cap(32B BE) || expiry_block(8B BE) || chain_id(8B BE))` * * This hash binds the session key to a specific chain, expiry, value cap, * and optional target, preventing unauthorized delegation. @@ -106,9 +106,12 @@ export function computeSessionAuthHash( throw new RangeError(`sessionAlgoId must fit in one byte, got ${sessionAlgoId}`); } - // Target: 32 bytes (address) or 32 zero bytes (no restriction). + // Target: explicit presence byte plus 32 bytes. The presence byte keeps an + // unrestricted authorization distinct from one restricted to the zero address. + const targetPresent = new Uint8Array([0]); const targetBytes = new Uint8Array(32); if (config.target !== null && config.target !== undefined) { + targetPresent[0] = 1; const addrBytes = shellAddressToBytes(config.target); targetBytes.set(addrBytes.slice(0, 32)); } @@ -141,6 +144,7 @@ export function computeSessionAuthHash( PQTX_SESSION_DOMAIN, sessionPubkey, new Uint8Array([sessionAlgoId]), + targetPresent, targetBytes, valueCapBytes, expiryBytes, diff --git a/src/types.ts b/src/types.ts index 35d8209..d3a3129 100644 --- a/src/types.ts +++ b/src/types.ts @@ -174,7 +174,7 @@ export const AA_SESSION_KEY_GAS_SURCHARGE = 20_000; * * 1. `session_pubkey` and `session_algo` are authorized by the root key's * `root_signature` over - * `auth_hash = blake3(PQTX_SESSION_V1\0 (16B) || session_pubkey || session_algo(1B) || target(32B|zero) || value_cap(32B BE) || expiry_block(8B BE) || chain_id(8B BE))`. + * `auth_hash = blake3(PQTX_SESSION_V2\0 (16B) || session_pubkey || session_algo(1B) || target_present(1B) || target(32B|zero) || value_cap(32B BE) || expiry_block(8B BE) || chain_id(8B BE))`. * 2. The transaction is signed by `session_pubkey` via `session_signature`. * 3. `expiry_block` must be > current block at validation time. * 4. Σ(inner_call.value) ≤ `value_cap`. @@ -206,7 +206,7 @@ export interface SessionAuth { expiry_block: number; /** * Root account's PQ signature over the session key authorization hash. - * `auth_hash = blake3(PQTX_SESSION_V1\0 (16B) || session_pubkey || session_algo(1B) || target(32B|zero) || value_cap || expiry_block || chain_id)`. + * `auth_hash = blake3(PQTX_SESSION_V2\0 (16B) || session_pubkey || session_algo(1B) || target_present(1B) || target(32B|zero) || value_cap || expiry_block || chain_id)`. */ root_signature: number[]; /** Session key's PQ signature over the tx `sender_signing_hash()`. */ diff --git a/tests/session.test.mjs b/tests/session.test.mjs index 8fe4d50..0108752 100644 --- a/tests/session.test.mjs +++ b/tests/session.test.mjs @@ -88,15 +88,46 @@ test("session-2: computeSessionAuthHash produces 32-byte BLAKE3 output", () => { // Different expiry must produce different hash const hashDifferentExpiry = computeSessionAuthHash(session.publicKey, session.algoId, { ...config, expiryBlock: 2000 }); assert.notDeepEqual(hash, hashDifferentExpiry, "different expiryBlock must produce different hash"); + + const zeroTarget = `0x${"00".repeat(32)}`; + const hashZeroTarget = computeSessionAuthHash(session.publicKey, session.algoId, { + ...config, + target: zeroTarget, + }); + assert.notDeepEqual(hash, hashZeroTarget, "unrestricted and zero-address targets must differ"); }); test("session-2b: PQTX_SESSION_DOMAIN is 16 bytes matching spec", () => { assert.equal(PQTX_SESSION_DOMAIN.length, 16, "domain must be 16 bytes"); - // b"PQTX_SESSION_V1\0" - const expected = Buffer.from("PQTX_SESSION_V1\0"); + // b"PQTX_SESSION_V2\0" + const expected = Buffer.from("PQTX_SESSION_V2\0"); assert.deepEqual(Buffer.from(PQTX_SESSION_DOMAIN), expected, "domain bytes mismatch"); }); +test("session-2c: target-presence hashes match Shell Chain vectors", () => { + const sessionPubkey = new Uint8Array(32).fill(0x11); + const base = { + chainId: 1337n, + expiryBlock: 500, + valueCap: 100n, + target: null, + }; + const unrestricted = computeSessionAuthHash(sessionPubkey, 1, base); + const zeroTarget = computeSessionAuthHash(sessionPubkey, 1, { + ...base, + target: `0x${"00".repeat(32)}`, + }); + + assert.equal( + Buffer.from(unrestricted).toString("hex"), + "3fceca0ef7542e4933a956618d2f94b663fb72be319e015a09f960409ed8e4f7", + ); + assert.equal( + Buffer.from(zeroTarget).toString("hex"), + "43faa3987b61c88a5b0f758024f09fa549b10ec62979a7ba5c4ce6ff5ee65088", + ); +}); + // ── Vector 3: createSessionAuth produces valid structure ───────────────────── test("session-3: createSessionAuth returns well-formed SessionAuth", async () => {