Repro (12 lines, no imports beyond `crypto`)
```ts
import * as crypto from "crypto";
const key = "secret";
const data = "payload";
console.log("(1) bare literal: ", crypto.createHmac("sha256", key).update(data).digest("hex"));
for (const alg of ["sha256"]) {
console.log("(2) for-of binding: ", crypto.createHmac(alg, key).update(data).digest("hex"));
}
const alg = "sha256";
console.log("(3) const reference: ", crypto.createHmac(alg, key).update(data).digest("hex"));
```
Observed (perry 0.5.1008)
```
(1) bare literal: b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4
(2) for-of binding: ← empty string
(3) const reference: ← empty string
```
Expected (tsx / Node)
All three print the same 64-char hex digest.
Pattern
Same shape as #1074 (jwt.sign `algorithm` option). The codegen for `crypto.createHmac` lowers based on whether the first argument is an inline `Expr::String` — `Expr::Identifier` (const refs), for-loop bindings, and any non-literal expression silently fall through to a path that produces an empty hash.
I'd guess `createHash`, `createCipheriv`, `createDecipheriv`, `createSign`, `createVerify` all share the bug (haven't tested all). Anywhere the runtime dispatches via a name string from the first arg.
Why this is a quiet trap
The chain runs without throwing — `.update(data).digest("hex")` is valid on whatever the broken `createHmac` returned — so callers see `""` back and only notice when a downstream comparison fails (e.g., HMAC-based webhook signature verification returns `false` 100% of the time, or TOTP verification never matches).
How this surfaces in shop-admin
`server/auth/totp.ts::computeTotp`:
```ts
const computeTotp = (secret: Buffer, counter: number): string => {
...
const hex = crypto.createHmac("sha1", secret).update(buf).digest("hex"); // inline literal — works
...
};
```
This call happens to use a literal so it works. PERRY_GAPS.md item B mis-diagnosed the smoke-test failure as a cross-module Buffer issue, but I just confirmed cross-module Buffer round-trips work fine; the real symptom would be: any nearby code that loops over algorithms (e.g., `for (const a of HASH_ALGS) { check(crypto.createHmac(a, k)...) }`) silently produces empties.
The Shopware/Woo webhook verifiers use `crypto.createHmac("sha256", secret).update(body).digest(...)` with inline literals, so they're not bitten — but anyone writing `const a = "sha256"; crypto.createHmac(a, secret)` is.
Suggested fix
Same as #1074: resolve simple compile-time-known const references before the literal-string match. `const a = "sha256"` is statically known. A small symbol-table lookup in the `createHmac` lowering would catch the common case.
Or: add a runtime path that takes the alg string at runtime and dispatches dynamically (slower but correct). Either is better than silent `""`.
Local checkout: perry 0.5.1008.
Repro (12 lines, no imports beyond `crypto`)
```ts
import * as crypto from "crypto";
const key = "secret";
const data = "payload";
console.log("(1) bare literal: ", crypto.createHmac("sha256", key).update(data).digest("hex"));
for (const alg of ["sha256"]) {
console.log("(2) for-of binding: ", crypto.createHmac(alg, key).update(data).digest("hex"));
}
const alg = "sha256";
console.log("(3) const reference: ", crypto.createHmac(alg, key).update(data).digest("hex"));
```
Observed (perry 0.5.1008)
```
(1) bare literal: b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4
(2) for-of binding: ← empty string
(3) const reference: ← empty string
```
Expected (tsx / Node)
All three print the same 64-char hex digest.
Pattern
Same shape as #1074 (jwt.sign `algorithm` option). The codegen for `crypto.createHmac` lowers based on whether the first argument is an inline `Expr::String` — `Expr::Identifier` (const refs), for-loop bindings, and any non-literal expression silently fall through to a path that produces an empty hash.
I'd guess `createHash`, `createCipheriv`, `createDecipheriv`, `createSign`, `createVerify` all share the bug (haven't tested all). Anywhere the runtime dispatches via a name string from the first arg.
Why this is a quiet trap
The chain runs without throwing — `.update(data).digest("hex")` is valid on whatever the broken `createHmac` returned — so callers see `""` back and only notice when a downstream comparison fails (e.g., HMAC-based webhook signature verification returns `false` 100% of the time, or TOTP verification never matches).
How this surfaces in shop-admin
`server/auth/totp.ts::computeTotp`:
```ts
const computeTotp = (secret: Buffer, counter: number): string => {
...
const hex = crypto.createHmac("sha1", secret).update(buf).digest("hex"); // inline literal — works
...
};
```
This call happens to use a literal so it works. PERRY_GAPS.md item B mis-diagnosed the smoke-test failure as a cross-module Buffer issue, but I just confirmed cross-module Buffer round-trips work fine; the real symptom would be: any nearby code that loops over algorithms (e.g., `for (const a of HASH_ALGS) { check(crypto.createHmac(a, k)...) }`) silently produces empties.
The Shopware/Woo webhook verifiers use `crypto.createHmac("sha256", secret).update(body).digest(...)` with inline literals, so they're not bitten — but anyone writing `const a = "sha256"; crypto.createHmac(a, secret)` is.
Suggested fix
Same as #1074: resolve simple compile-time-known const references before the literal-string match. `const a = "sha256"` is statically known. A small symbol-table lookup in the `createHmac` lowering would catch the common case.
Or: add a runtime path that takes the alg string at runtime and dispatches dynamically (slower but correct). Either is better than silent `""`.
Local checkout: perry 0.5.1008.