VerifyClaimCode returns true if code matches the derived code for (userID, workspaceID) at the current OR previous time bucket — the sliding 5–10 minute lifetime described in IDEA-1517 §4. Constant-time compare prevents timing oracles from leaking which of the two derivations failed (which would re
(secret []byte, userID, workspaceID, code string, at time.Time)
| 125 | // Empty code, empty userID, or empty workspaceID always return false — |
| 126 | // no need to even derive in those cases. |
| 127 | func VerifyClaimCode(secret []byte, userID, workspaceID, code string, at time.Time) bool { |
| 128 | if code == "" || userID == "" || workspaceID == "" || len(secret) < 16 { |
| 129 | return false |
| 130 | } |
| 131 | if len(code) != claimCodeDigits { |
| 132 | return false |
| 133 | } |
| 134 | bucket := at.UTC().Unix() / claimBucketSeconds |
| 135 | current := deriveClaimCodeForBucket(secret, userID, workspaceID, bucket) |
| 136 | previous := deriveClaimCodeForBucket(secret, userID, workspaceID, bucket-1) |
| 137 | codeBytes := []byte(code) |
| 138 | // ConstantTimeCompare returns 1 on match. OR the two results so |
| 139 | // either matching bucket passes; subtle's compare doesn't panic |
| 140 | // on equal-length operands so length-mismatch isn't a concern |
| 141 | // here (we already gated on claimCodeDigits above). |
| 142 | return subtle.ConstantTimeCompare(codeBytes, []byte(current)) == 1 || |
| 143 | subtle.ConstantTimeCompare(codeBytes, []byte(previous)) == 1 |
| 144 | } |