- Is it safe to simply append a hex or base64 representation of these
bytes to a url and email it to a user? Or does exposing the raw,
unmodified bytes expose my system's CSPRNG behavior to unwanted
scrutiny by bad guys?
According to the Kerckhoffs's principle, you should suppose that an attacker knows what algorithm was used for encoding. That's why it doesn't matter if you use unmodified bytes or encode them using hex or base-64 format. Use encoding that you feel more comfortable with.
By the way, you cannot use any bytes. For instance, you cannot use byte with value 32, because it represents a space and needs to be encoded by "%20". There are also other bytes that are not allowed in the URL. That's why I'd suggest you to use some encoding that converts an array of bytes into a valid. The standard base-64 mapping requires, that the sequence of bits 111111 should be represented by "/". Having that in the URL will lead to other meaning of URL. To prevent it, you will have to escape it to "%2F". This may be lost somewhere. Thus it may be better to use hex representation of the generated random bytes.
- If it is unsafe, would a SHA1 hash of the raw random bytes suffice
to conceal my server's CSPRNG behavior while still serving its
purpose as a password reset token?
A SHA1 hash consists of 20 bytes. If your token is longer than 20 bytes, then applying SHA1 you effectively reduces the entropy.
- How many random bytes should such a token have if I want it to be
valid for an hour? For 24 hours?
Limit the number of password reset requests from a single IP per hour or per second. For instance, allow not more than 1 000 password reset requests per hour per IP.
Then it depends on what threats you consider. If you expect that an attacker can use a single IP, then max. 1 000 tokens can be tested within an hour. This is ~2^10. Suppose you want to have probability to guess a token 1 to 1 000 000, which is approx. 1 to 2^20. Thus the token should consist of 30 bits, which means 4 bytes. If token is valid 24 hours, then 24 times more tokens can be tested, in our case 24 000, which is ~2^15. Thus for the same probability you would need a token of 35 bits, which is 5 bytes.
If you expect that your attacker can be some bot network that consists of 10 000 000 computers, which is ~2^23, then 1-hour tokens should consist of 10 + 20 + 23 = 53 bits = 7 bytes.
Depending on what probability and what number of requests per hour per IP you consider as acceptable, you will get other numbers.
What else to consider?
In case you send tokens as a text that users need to type in manually, it makes sense to think about user experience and try to keep tokens short. But if you send a link that includes a token, then don't hesitate to make tokens longer.