-
Notifications
You must be signed in to change notification settings - Fork 4.5k
Description
Is your feature request related to a problem? Please describe.
When generating a TOTP code, all that's returned in the data container is just
{
"code": 000000
}This is particularly unhelpful in definitively knowing how long that code is valid for.
Sure, one may do a GET/read on /<totp>/keys/:name and get the period, BUT:
- That's an unnecessary round-trip
- It requires permission to read the TOTP configuration, which is a separate ACL grant from simply generating a code
- It only gives a general idea of length of validity; for something time-sensitive like TOTP (...it being the first
T), this is generally tricky - especially for shorter periods. - It is ambiguous. The requester can base it against local UNIX time, but that doesn't account for neither the duration of the request/processing/response nor the length of code generation factored in, which are unknown to the client. The client can fetch the period and make an assumption based on the
dateresponse header, but this is going to lead to some wildly wrong assumptions in some cases. (e.g. Vault generates a code at time step TN(+29s), it takes 2 seconds to get to the client, assuming a 30s period the client is now going to determine validity based on TN+1(+1s). That's an entirely wrong time step. Some verifiers are pretty strict.)
Describe the solution you'd like
Instead of:
{
"code": 000000
}do e.g.:
{
"code": 000000,
"generated": 1766084776.3819246,
"expiry": 1766084790.0,
"period": "30s"
}Where:
generated: A UNIX Epoch timestamp in seconds as afloat64, with nanosecond precision[0][1] of right before (or right after) the code was generated.expiry: A UNIX Epoch timestamp[1] in seconds as afloat64, with nanosecond precision of the moment the code expires (essentially the end of the current time step, in RFC 6238 lingo).period: Atime.Duration.String()string of the TOTP configuration's period. (Atime.Duration.String()output is directly parseable bytime.ParseDuration.)
[0] The quick and dirty would be
something like this.
var t time.Time
var epochNano float64
t = time.Now()
epochNano = float64(t.UnixNano()) / float64(time.Second)But don't do that. Do [1] instead.
[1] Of course, the JSON encoder will truncate a direct float64to ten-millionths - in other words, in this case a tenth of a microsecond.
Easily fixable (and actually gets us more accurate nanoseconds than [0] does).
import (
"strconv"
"strings"
"time"
)
type (
NanoEpoch time.Time
)
func (n *NanoEpoch) MarshalJSON() (b []byte, err error) {
var t time.Time
if n == nil {
return
}
t = time.Time(*n)
b = []byte(fmt.Sprintf("%d.%d", t.Unix(), t.Nanosecond()))
return
}
func (n *NanoEpoch) UnmarshalJSON(b []byte) (err error) {
var spl []string
var sec int64
var nsec int64
var t time.Time
if len(b) == 0 {
return
}
spl = strings.SplitN(string(b), ".", 2)
// Theoretically should always be true. But alas, better safe than sorry.
if spl[0] != "" {
if sec, err = strconv.ParseInt(spl[0], 10, 64); err != nil {
return
}
}
if len(spl) == 2 {
if nsec, err = strconv.ParseInt(spl[1], 10, 64); err != nil {
return
}
}
t = time.Unix(sec, nsec)
*n = NanoEpoch(t)
return
}Describe alternatives you've considered
As laid out above, currently the only way of doing this is/are:
- Fetch the period from the TOTP's configuration (which is even assuming the entity has permission to read it)
- Deriving the time step on the client's end using the
Dateheader - Hope and pray it's not near/on a time step boundary and your network latency is less than the remaining time in the time step, because you don't even know if you need to get another TOTP in the new time step or not currently
Explain any additional use-cases
Giving indication of the "lifetime" of a TOTP is pretty standard for all TOTP generators. Hell, the web UI does it; I was frankly floored that the API didn't whatsoever.
Additional context
The RFC doesn't specify any sort of recommendation or requirement for this case, because the assumption is always:
<PROVER> =(TOTP CODE)=> <VERIFIER>
and not
<PROVER> =(REQUEST)=> <GENERATOR>
<GENERATOR> =(TOTP CODE)=> <PROVER>
<PROVER> =(TOTP CODE)=> <VERIFIER>
so there's a lot of additional considerations that need to be considered in addition to RFC 6238 § 5.2 and RFC 6238 § 6.
Obligatory legal
I attest that any and all source code provided in this post was written by myself, Brent Saner.
Any and all source code shared in this post is released by the author to the public under an
MIT License
Copyright 2025 Brent Saner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
and may be used in this or any other project/software with no obligations, provisos, or the like other than what the above license specifies.
Explicit contribution release/agreement and/or relicensing is available upon request if required.
(INTERNAL TRACKING X-REF: openbao/openbao#2233)