Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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));
}
Expand Down Expand Up @@ -141,6 +144,7 @@ export function computeSessionAuthHash(
PQTX_SESSION_DOMAIN,
sessionPubkey,
new Uint8Array([sessionAlgoId]),
targetPresent,
targetBytes,
valueCapBytes,
expiryBytes,
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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()`. */
Expand Down
35 changes: 33 additions & 2 deletions tests/session.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading