Skip to content

Commit 3f663f5

Browse files
committed
fix: align rust signal hashing
1 parent 2765341 commit 3f663f5

7 files changed

Lines changed: 253 additions & 22 deletions

File tree

js/packages/core/src/__tests__/pure-crypto.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ describe("hashSignal (pure JS)", () => {
7171
"0x001c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36dea",
7272
);
7373
});
74+
75+
it("should hash address-shaped signals as raw address bytes", () => {
76+
const signal = "0x3df41d9d0ba00d8fbe5a9896bb01efc4b3787b7c";
77+
const rawAddressBytes = hexToBytes(signal.slice(2));
78+
const utf8AddressString = new TextEncoder().encode(signal);
79+
80+
expect(hashSignal(signal)).toBe(hashSignal(rawAddressBytes));
81+
expect(hashSignal(signal)).toBe(
82+
"0x008c8aee1da6dd63c3fd6c420eab6a55882e7f1a3f97b27853e75eb8ffc8032f",
83+
);
84+
expect(hashSignal(signal)).not.toBe(hashSignal(utf8AddressString));
85+
});
7486
});
7587

7688
// Rust ref: https://github.com/worldcoin/world-id-protocol/blob/0008eab1efe200e572f27258793f9be5cb32858b/crates/primitives/src/rp.rs#L95-L105

js/packages/core/src/__tests__/smoke.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import {
1515
isNode,
1616
IDKitErrorCodes,
1717
signRequest,
18+
hashSignal,
1819
} from "../index";
20+
import { initIDKit, WasmModule } from "../lib/wasm";
1921

2022
const TEST_SESSION_ID = `session_${"11".repeat(64)}` as const;
2123
const TEST_SESSION_CONFIG = {
@@ -150,6 +152,41 @@ describe("IDKitRequest API", () => {
150152
// });
151153
// await expect(builder.constraints({ any: [] })).rejects.toThrow();
152154
// });
155+
156+
it("should hash address-shaped legacy preset signals as raw bytes in WASM payloads", async () => {
157+
await initIDKit();
158+
159+
const signal = "0x3df41d9d0ba00d8fbe5a9896bb01efc4b3787b7c";
160+
const utf8SignalHash = hashSignal(new TextEncoder().encode(signal));
161+
const rawAddressSignalHash = hashSignal(signal);
162+
const rpContext = new WasmModule.RpContextWasm(
163+
"rp_1234567890abcdef",
164+
"0x0000000000000000000000000000000000000000000000000000000000000001",
165+
1_700_000_000n,
166+
1_700_003_600n,
167+
"0x" + "00".repeat(64) + "1b",
168+
);
169+
const builder = new WasmModule.IDKitBuilder(
170+
"app_test",
171+
"test-action",
172+
rpContext,
173+
null,
174+
null,
175+
true,
176+
null,
177+
null,
178+
"production",
179+
);
180+
181+
const result = builder.nativePayloadFromPreset(orbLegacy({ signal })) as {
182+
payload: { signal: string };
183+
legacy_signal_hash: string;
184+
};
185+
186+
expect(rawAddressSignalHash).not.toBe(utf8SignalHash);
187+
expect(result.payload.signal).toBe(rawAddressSignalHash);
188+
expect(result.legacy_signal_hash).toBe(rawAddressSignalHash);
189+
});
153190
});
154191

155192
describe("Enums", () => {

rust/core/src/bridge.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,9 @@ pub fn build_request_payload(
415415
})
416416
.transpose()?;
417417

418-
// For backwards compatibility we hash the signal
418+
// Legacy v3 payloads send only the signal hash. `Signal::from_string`
419+
// intentionally mirrors JS `hashSignal`, including decoding valid `0x`
420+
// strings as bytes so address signals match `abi.encodePacked(address)`.
419421
let legacy_signal_hash =
420422
crate::crypto::hash_signal(&Signal::from_string(params.legacy_signal.clone()));
421423

@@ -1664,6 +1666,56 @@ mod tests {
16641666
assert!(payload_bridge.get("timestamp").is_none());
16651667
}
16661668

1669+
#[test]
1670+
fn test_legacy_payload_hashes_address_shaped_signal_as_raw_bytes() {
1671+
let address = "0x3df41d9d0ba00d8fbe5a9896bb01efc4b3787b7c";
1672+
let address_bytes = hex::decode(address.strip_prefix("0x").unwrap()).unwrap();
1673+
let expected = crate::crypto::hash_signal(&Signal::from_bytes(address_bytes));
1674+
let utf8_hash = crate::crypto::hash_signal(&Signal::from_bytes(address.as_bytes()));
1675+
1676+
assert_ne!(expected, utf8_hash);
1677+
let app_id = AppId::new("app_test").unwrap();
1678+
let sig = "0x".to_string() + &"00".repeat(64) + "1b";
1679+
let rp_context = RpContext::new(
1680+
"rp_1234567890abcdef",
1681+
"0x0000000000000000000000000000000000000000000000000000000000000001",
1682+
1_700_000_000,
1683+
1_700_003_600,
1684+
&sig,
1685+
)
1686+
.unwrap();
1687+
1688+
let params = BridgeConnectionParams {
1689+
app_id,
1690+
kind: RequestKind::Uniqueness {
1691+
action: "my-action".to_string(),
1692+
},
1693+
constraints: None,
1694+
rp_context,
1695+
action_description: None,
1696+
legacy_verification_level: VerificationLevel::Orb,
1697+
legacy_signal: address.to_string(),
1698+
bridge_url: None,
1699+
allow_legacy_proofs: false,
1700+
1701+
override_connect_base_url: None,
1702+
return_to: None,
1703+
environment: None,
1704+
};
1705+
1706+
let cached = CachedSignalHashes::compute(&params);
1707+
assert_eq!(cached.legacy_signal_hash, expected);
1708+
1709+
let bridge_payload = build_request_payload(&params, false).unwrap();
1710+
assert_eq!(bridge_payload["signal"], expected);
1711+
1712+
let native_payload = build_request_payload(&params, true).unwrap();
1713+
assert_eq!(native_payload["signal"], expected);
1714+
1715+
let native_v1_payload = build_native_v1_payload(&params).unwrap();
1716+
assert_eq!(native_v1_payload["signal"], expected);
1717+
}
1718+
16671719
fn sample_connection(return_to: Option<String>) -> BridgeConnection {
16681720
BridgeConnection {
16691721
bridge_url: BridgeUrl::default(),

rust/core/src/crypto.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,14 @@ pub fn hash_signal_abi<V: alloy_sol_types::SolValue>(signal: &V) -> U256 {
158158
/// Hashes a signal using keccak256 hash
159159
///
160160
/// Takes a `Signal` (either string or bytes) and returns the keccak256 hash,
161-
/// shifted right by 8 bits, formatted as a hex string with 0x prefix
161+
/// shifted right by 8 bits, formatted as a hex string with 0x prefix.
162+
/// String signals intentionally use the same `0x` decoding semantics as the
163+
/// JS `hashSignal` helper: valid non-empty even-length hex strings are hashed
164+
/// as raw bytes, and all other strings are hashed as UTF-8 text.
162165
#[must_use]
163166
pub fn hash_signal(signal: &crate::Signal) -> String {
164-
let hash = hash_to_field(signal.as_bytes());
167+
let input = signal.hash_input_bytes();
168+
let hash = hash_to_field(input.as_ref());
165169
format!("{hash:#066x}")
166170
}
167171

@@ -275,6 +279,20 @@ mod tests {
275279
assert_eq!(hashed_bytes.len(), 66);
276280
}
277281

282+
#[test]
283+
fn test_hash_signal_decodes_prefixed_hex_strings() {
284+
use crate::Signal;
285+
286+
let signal = "0x3df41d9d0ba00d8fbe5a9896bb01efc4b3787b7c";
287+
let address_bytes = hex::decode(signal.strip_prefix("0x").unwrap()).unwrap();
288+
let expected = hash_signal(&Signal::from_bytes(address_bytes));
289+
let utf8_hash = hash_signal(&Signal::from_bytes(signal.as_bytes()));
290+
291+
assert_ne!(expected, utf8_hash);
292+
assert_eq!(hash_signal(&Signal::String(signal.to_string())), expected);
293+
assert_eq!(hash_signal(&Signal::from_string(signal)), expected);
294+
}
295+
278296
#[test]
279297
fn test_base64_encode_decode() {
280298
let input = b"Hello, World!";

rust/core/src/preset.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,4 +247,24 @@ mod tests {
247247
_ => panic!("expected deviceLegacy constraints to be a single orb item"),
248248
}
249249
}
250+
251+
#[test]
252+
fn orb_legacy_preset_decodes_address_shaped_signal_as_bytes() {
253+
let address = "0x3df41d9d0ba00d8fbe5a9896bb01efc4b3787b7c";
254+
let preset = Preset::orb_legacy(Some(address.to_string()));
255+
let (constraints, verification_level, legacy_signal) = preset.to_bridge_params();
256+
257+
assert_eq!(verification_level, VerificationLevel::Orb);
258+
assert_eq!(legacy_signal, Some(address.to_string()));
259+
260+
match constraints {
261+
ConstraintNode::Item(orb) => {
262+
assert_eq!(orb.credential_type, CredentialType::ProofOfHuman);
263+
let signal = orb.signal.expect("expected signal");
264+
assert!(matches!(signal, Signal::Bytes(_)));
265+
assert_eq!(signal.as_bytes().len(), 20);
266+
}
267+
_ => panic!("expected orbLegacy constraints to be a single orb item"),
268+
}
269+
}
250270
}

0 commit comments

Comments
 (0)