For reading and verification only. You may not copy, use, modify or redistribute this code. Full notice on the main page.
tunnel/auth.go
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/sha256" |
| 7 | "fmt" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // Device authentication of protocol v2 (the contract, section 3): the node verifies the device's session JWT offline against the control |
| 15 | // plane's keys, makes the device prove it holds the session's key (a DPoP proof over the NODE's URL and a nonce the node just made), and then asks |
| 16 | // the control plane what the device is allowed: the host, the status and how long. Nothing a device sends names a host. |
| 17 | |
| 18 | // A token problem is one closed word. The words and their order are those of the contracts' verifiers (tests run every golden vector through |
| 19 | // the functions below). |
| 20 | const ( |
| 21 | tokMalformed = "malformed_token" |
| 22 | tokBadAlg = "bad_alg" |
| 23 | tokUnknownKID = "unknown_kid" |
| 24 | tokBadSignature = "bad_signature" |
| 25 | tokWrongType = "wrong_type" |
| 26 | tokWrongIssuer = "wrong_issuer" |
| 27 | tokWrongAudience = "wrong_audience" |
| 28 | tokWrongKey = "wrong_key" |
| 29 | tokWrongSubject = "wrong_subject" |
| 30 | tokWrongHTM = "wrong_htm" |
| 31 | tokWrongHTU = "wrong_htu" |
| 32 | tokWrongATH = "wrong_ath" |
| 33 | tokWrongNonce = "wrong_nonce" |
| 34 | tokBadClaims = "bad_claims" |
| 35 | tokBadLifetime = "bad_lifetime" |
| 36 | tokNotYetValid = "not_yet_valid" |
| 37 | tokExpired = "expired" |
| 38 | tokStaleProof = "stale_proof" |
| 39 | tokReplayedJTI = "replayed_jti" |
| 40 | ) |
| 41 | |
| 42 | type tokenError struct{ word string } |
| 43 | |
| 44 | func (e *tokenError) Error() string { return e.word } |
| 45 | |
| 46 | func tokFail(word string) error { return &tokenError{word} } |
| 47 | |
| 48 | // tokenWord is the closed word of a token error ("" when err is nil). |
| 49 | func tokenWord(err error) string { |
| 50 | if err == nil { |
| 51 | return "" |
| 52 | } |
| 53 | if te, ok := err.(*tokenError); ok { |
| 54 | return te.word |
| 55 | } |
| 56 | return tokMalformed |
| 57 | } |
| 58 | |
| 59 | // errFor turns a token word into the ERR a refused HELLO, RENEW or PROOF is answered with, as the reference node of the contracts does: a clock that |
| 60 | // is off is clock_skew (the device shows it), a malformed token is bad_hello, a token for another audience or an expired one is auth, and every other |
| 61 | // word is the fallback (auth for a session, bad_proof for a proof) because nothing else may be told to a device that failed. |
| 62 | func errFor(word, fallback string) string { |
| 63 | switch word { |
| 64 | case tokMalformed: |
| 65 | return codeBadHello |
| 66 | case tokNotYetValid, tokStaleProof: |
| 67 | return codeClockSkew |
| 68 | case tokWrongAudience, tokExpired: |
| 69 | return codeAuth |
| 70 | } |
| 71 | return fallback |
| 72 | } |
| 73 | |
| 74 | type jws struct { |
| 75 | header, claims map[string]any |
| 76 | signature []byte |
| 77 | signingInput []byte |
| 78 | } |
| 79 | |
| 80 | // splitJWS splits and decodes a compact JWS. Any structural fault is malformed_token: ASCII only, three dot-separated base64url segments with no |
| 81 | // padding and canonical encoding, header and claims JSON objects without a repeated key and without a fraction, none of the headers that change |
| 82 | // how a signature is read (crit, b64, jku, x5u, x5c). An empty signature part is allowed here (alg none then fails as bad_alg, the more useful word). |
| 83 | func splitJWS(token string, maxBytes int) (*jws, error) { |
| 84 | if len(token) > maxBytes { |
| 85 | return nil, tokFail(tokMalformed) |
| 86 | } |
| 87 | for i := 0; i < len(token); i++ { |
| 88 | if token[i] > 0x7f { |
| 89 | return nil, tokFail(tokMalformed) |
| 90 | } |
| 91 | } |
| 92 | parts := strings.Split(token, ".") |
| 93 | if len(parts) != 3 { |
| 94 | return nil, tokFail(tokMalformed) |
| 95 | } |
| 96 | decode := func(text string) (map[string]any, bool) { |
| 97 | raw, err := b64Decode(text) |
| 98 | if err != nil { |
| 99 | return nil, false |
| 100 | } |
| 101 | value, err := parseJSON(string(raw)) |
| 102 | if err != nil { |
| 103 | return nil, false |
| 104 | } |
| 105 | object, ok := value.(map[string]any) |
| 106 | return object, ok |
| 107 | } |
| 108 | header, ok1 := decode(parts[0]) |
| 109 | claims, ok2 := decode(parts[1]) |
| 110 | if !ok1 || !ok2 { |
| 111 | return nil, tokFail(tokMalformed) |
| 112 | } |
| 113 | var signature []byte |
| 114 | if parts[2] != "" { |
| 115 | raw, err := b64Decode(parts[2]) |
| 116 | if err != nil { |
| 117 | return nil, tokFail(tokMalformed) |
| 118 | } |
| 119 | signature = raw |
| 120 | } |
| 121 | for _, name := range []string{"crit", "b64", "jku", "x5u", "x5c"} { |
| 122 | if _, present := header[name]; present { |
| 123 | return nil, tokFail(tokMalformed) |
| 124 | } |
| 125 | } |
| 126 | return &jws{header, claims, signature, []byte(parts[0] + "." + parts[1])}, nil |
| 127 | } |
| 128 | |
| 129 | func verifyEd25519(key ed25519.PublicKey, signature, input []byte) bool { |
| 130 | return len(signature) == ed25519.SignatureSize && len(key) == ed25519.PublicKeySize && ed25519.Verify(key, input, signature) |
| 131 | } |
| 132 | |
| 133 | func claimInt(claims map[string]any, key string) (int64, bool) { |
| 134 | f, ok := claims[key].(float64) |
| 135 | if !ok || f != float64(int64(f)) { |
| 136 | return 0, false |
| 137 | } |
| 138 | return int64(f), true |
| 139 | } |
| 140 | |
| 141 | func claimString(claims map[string]any, key string) (string, bool) { |
| 142 | s, ok := claims[key].(string) |
| 143 | return s, ok |
| 144 | } |
| 145 | |
| 146 | // epochClaim reads an epoch-seconds claim in the contracts' range. |
| 147 | func epochClaim(claims map[string]any, key string) (int64, bool) { |
| 148 | n, ok := epochOK(claims[key]) |
| 149 | return n, ok |
| 150 | } |
| 151 | |
| 152 | // SessionClaims are the claims of a verified device session the node keeps for the life of a connection. |
| 153 | type SessionClaims struct { |
| 154 | Subject string // the device id |
| 155 | JTI string |
| 156 | Generation int64 |
| 157 | IssuedAt int64 |
| 158 | ExpiresAt int64 |
| 159 | KeyX string // the session's bound key (cnf.jwk.x): the key a proof must be signed with |
| 160 | } |
| 161 | |
| 162 | func epochSeconds(t time.Time) float64 { return float64(t.UnixNano()) / 1e9 } |
| 163 | |
| 164 | // publicJWKOK is the contracts' public JWK: OKP, Ed25519, an x of 43 canonical characters, no private part. |
| 165 | func publicJWKX(value any) (string, bool) { |
| 166 | jwk, ok := value.(map[string]any) |
| 167 | if !ok { |
| 168 | return "", false |
| 169 | } |
| 170 | x, hasX := jwk["x"].(string) |
| 171 | if _, private := jwk["d"]; private || jwk["kty"] != "OKP" || jwk["crv"] != "Ed25519" || !hasX { |
| 172 | return "", false |
| 173 | } |
| 174 | if _, ok := publicKeyX(x); !ok { |
| 175 | return "", false |
| 176 | } |
| 177 | return x, true |
| 178 | } |
| 179 | |
| 180 | // verifySession verifies a device session offline: the signature before any claim is trusted, then the audience (the node accepts only |
| 181 | // all-tunnel: the relay's and the control plane's audiences and a missing audience are refused), the lifetime (exactly the policy's session TTL) and the clock within skew. |
| 182 | // The words, in the order the checks fail: malformed_token, bad_alg, unknown_kid, bad_signature, wrong_type, wrong_issuer, wrong_audience, |
| 183 | // bad_claims, bad_lifetime, not_yet_valid, expired. |
| 184 | func verifySession(token string, now time.Time, keys KeySource, skew time.Duration) (*SessionClaims, error) { |
| 185 | if len(token) > policyData.Protocol.JWTMaxBytes { |
| 186 | return nil, tokFail(tokMalformed) |
| 187 | } |
| 188 | j, err := splitJWS(token, policyData.Protocol.JWTMaxBytes) |
| 189 | if err != nil { |
| 190 | return nil, err |
| 191 | } |
| 192 | if j.header["alg"] != "EdDSA" { |
| 193 | return nil, tokFail(tokBadAlg) |
| 194 | } |
| 195 | kid, isString := j.header["kid"].(string) |
| 196 | if !isString || !reKID.MatchString(kid) { |
| 197 | return nil, tokFail(tokUnknownKID) |
| 198 | } |
| 199 | key, known := keys.Key(kid) |
| 200 | if !known { |
| 201 | return nil, tokFail(tokUnknownKID) |
| 202 | } |
| 203 | if !verifyEd25519(key, j.signature, j.signingInput) { |
| 204 | return nil, tokFail(tokBadSignature) |
| 205 | } |
| 206 | c := j.claims |
| 207 | if j.header["typ"] != "device-session" || c["typ"] != "device-session" { |
| 208 | return nil, tokFail(tokWrongType) |
| 209 | } |
| 210 | if ver, ok := claimInt(c, "ver"); !ok || ver != int64(policyData.Protocol.Version) { |
| 211 | return nil, tokFail(tokWrongType) |
| 212 | } |
| 213 | if c["iss"] != policyData.Identity.Issuer { |
| 214 | return nil, tokFail(tokWrongIssuer) |
| 215 | } |
| 216 | if c["aud"] != policyData.Identity.AudTunnel { |
| 217 | return nil, tokFail(tokWrongAudience) |
| 218 | } |
| 219 | sub, subOK := claimString(c, "sub") |
| 220 | jti, jtiOK := claimString(c, "jti") |
| 221 | iat, iatOK := epochClaim(c, "iat") |
| 222 | exp, expOK := epochClaim(c, "exp") |
| 223 | generation, genOK := claimInt(c, "generation") |
| 224 | x, cnfOK := "", false |
| 225 | if cnf, ok := c["cnf"].(map[string]any); ok && len(cnf) == 1 { |
| 226 | if jwk, ok := cnf["jwk"].(map[string]any); ok && len(jwk) == 3 { |
| 227 | x, cnfOK = publicJWKX(jwk) |
| 228 | } |
| 229 | } |
| 230 | if !subOK || !reDeviceID.MatchString(sub) || !jtiOK || !reJTI.MatchString(jti) || !iatOK || !expOK || !genOK || generation < 1 || generation > 1<<53-1 || !cnfOK { |
| 231 | return nil, tokFail(tokBadClaims) |
| 232 | } |
| 233 | if exp-iat != int64(policyData.Authorization.SessionTTLS) { |
| 234 | return nil, tokFail(tokBadLifetime) |
| 235 | } |
| 236 | t := epochSeconds(now) |
| 237 | if float64(iat) > t+skew.Seconds() { |
| 238 | return nil, tokFail(tokNotYetValid) |
| 239 | } |
| 240 | if float64(exp) <= t { |
| 241 | return nil, tokFail(tokExpired) |
| 242 | } |
| 243 | return &SessionClaims{Subject: sub, JTI: jti, Generation: generation, IssuedAt: iat, ExpiresAt: exp, KeyX: x}, nil |
| 244 | } |
| 245 | |
| 246 | func sha256B64(data []byte) string { |
| 247 | sum := sha256.Sum256(data) |
| 248 | return b64Encode(sum[:]) |
| 249 | } |
| 250 | |
| 251 | // nodeProofURL is the htu a proof must carry: https://<node host>[:<port> when it is not 443]/all-tunnel/2. |
| 252 | func nodeProofURL(host string, port int) string { |
| 253 | u := "https://" + host |
| 254 | if port != 443 { |
| 255 | u += ":" + strconv.Itoa(port) |
| 256 | } |
| 257 | return u + policyData.Protocol.ProofHTUPath |
| 258 | } |
| 259 | |
| 260 | // proofClaims is what a verified proof leaves the node: the jti, to be spent once. |
| 261 | type proofClaims struct { |
| 262 | JTI string |
| 263 | IssuedAt int64 |
| 264 | } |
| 265 | |
| 266 | // verifyProof verifies the DPoP proof of the session the connection said it holds: signed by the session's bound key, for this session (ath), |
| 267 | // this node's URL (htu), this exchange (the nonce the node made) and this moment (iat within the policy's proof TTL). The jti is NOT spent here: the |
| 268 | // caller spends it (replayCache.spend) so that a replay is refused even between two connections. Words, in order: malformed_token, bad_alg, |
| 269 | // wrong_type, wrong_key, bad_signature, wrong_subject, wrong_audience, wrong_htm, wrong_htu, wrong_ath, wrong_nonce, bad_claims, not_yet_valid, |
| 270 | // stale_proof. |
| 271 | func verifyProof(proof string, now time.Time, sessionToken string, session *SessionClaims, nonce, htu string, skew time.Duration) (*proofClaims, error) { |
| 272 | if len(proof) > policyData.Protocol.JWTMaxBytes { |
| 273 | return nil, tokFail(tokMalformed) |
| 274 | } |
| 275 | j, err := splitJWS(proof, policyData.Protocol.JWTMaxBytes) |
| 276 | if err != nil { |
| 277 | return nil, err |
| 278 | } |
| 279 | if j.header["alg"] != "EdDSA" { |
| 280 | return nil, tokFail(tokBadAlg) |
| 281 | } |
| 282 | if j.header["typ"] != "dpop+jwt" { |
| 283 | return nil, tokFail(tokWrongType) |
| 284 | } |
| 285 | x, ok := publicJWKX(j.header["jwk"]) |
| 286 | if !ok { |
| 287 | return nil, tokFail(tokMalformed) |
| 288 | } |
| 289 | if x != session.KeyX { |
| 290 | return nil, tokFail(tokWrongKey) |
| 291 | } |
| 292 | key, _ := publicKeyX(x) |
| 293 | if !verifyEd25519(key, j.signature, j.signingInput) { |
| 294 | return nil, tokFail(tokBadSignature) |
| 295 | } |
| 296 | c := j.claims |
| 297 | switch { |
| 298 | case c["sub"] != session.Subject: |
| 299 | return nil, tokFail(tokWrongSubject) |
| 300 | case c["aud"] != policyData.Identity.AudTunnel: |
| 301 | return nil, tokFail(tokWrongAudience) |
| 302 | case c["htm"] != policyData.Protocol.ProofHTM: |
| 303 | return nil, tokFail(tokWrongHTM) |
| 304 | case c["htu"] != htu: |
| 305 | return nil, tokFail(tokWrongHTU) |
| 306 | case c["ath"] != sha256B64([]byte(sessionToken)): |
| 307 | return nil, tokFail(tokWrongATH) |
| 308 | case c["nonce"] != nonce: |
| 309 | return nil, tokFail(tokWrongNonce) |
| 310 | } |
| 311 | jti, jtiOK := claimString(c, "jti") |
| 312 | iat, iatOK := epochClaim(c, "iat") |
| 313 | if !jtiOK || !reJTI.MatchString(jti) || !iatOK { |
| 314 | return nil, tokFail(tokBadClaims) |
| 315 | } |
| 316 | t := epochSeconds(now) |
| 317 | if float64(iat) > t+skew.Seconds() { |
| 318 | return nil, tokFail(tokNotYetValid) |
| 319 | } |
| 320 | if float64(iat+int64(policyData.Authorization.ProofTTLS)) <= t { |
| 321 | return nil, tokFail(tokStaleProof) |
| 322 | } |
| 323 | return &proofClaims{JTI: jti, IssuedAt: iat}, nil |
| 324 | } |
| 325 | |
| 326 | // replayCache remembers the proofs the node has accepted until they could not verify any more (a proof is single use for the policy's proof TTL, |
| 327 | // plus the skew a fast clock is allowed). A session's jti is NOT single use: a cached session reconnects. The cache is bounded: when it is full of |
| 328 | // live entries a new proof is refused (the connection gets bad_proof), never admitted unchecked. |
| 329 | type replayCache struct { |
| 330 | mu sync.Mutex |
| 331 | seen map[string]time.Time // jti -> when it can be forgotten |
| 332 | limit int |
| 333 | } |
| 334 | |
| 335 | func newReplayCache(limit int) *replayCache { |
| 336 | return &replayCache{seen: map[string]time.Time{}, limit: limit} |
| 337 | } |
| 338 | |
| 339 | // spend records a proof jti issued at iat. It reports false when the jti was already spent (or the cache is full of live entries). |
| 340 | func (r *replayCache) spend(jti string, iat int64, now time.Time, skew time.Duration) bool { |
| 341 | forget := time.Unix(iat, 0).Add(secs(policyData.Authorization.ProofTTLS) + skew) |
| 342 | r.mu.Lock() |
| 343 | defer r.mu.Unlock() |
| 344 | if until, dup := r.seen[jti]; dup && now.Before(until) { |
| 345 | return false |
| 346 | } |
| 347 | if len(r.seen) >= r.limit { |
| 348 | for id, until := range r.seen { |
| 349 | if !now.Before(until) { |
| 350 | delete(r.seen, id) |
| 351 | } |
| 352 | } |
| 353 | if len(r.seen) >= r.limit { |
| 354 | return false |
| 355 | } |
| 356 | } |
| 357 | r.seen[jti] = forget |
| 358 | return true |
| 359 | } |
| 360 | |
| 361 | func (r *replayCache) size() int { |
| 362 | r.mu.Lock() |
| 363 | defer r.mu.Unlock() |
| 364 | return len(r.seen) |
| 365 | } |
| 366 | |
| 367 | // ControlPlane is the node's side of POST /v1/tunnel/authorize: what the control plane decides for a device whose session the node verified. The |
| 368 | // request never carries a host name; the answer's host, status and valid_until are what the node relays. The real client (node assertion, retry, |
| 369 | // concurrency cap, grace) is the control-plane piece; the node core is written against this interface and tested with a fake. |
| 370 | type ControlPlane interface { |
| 371 | Authorize(ctx context.Context, req AuthorizeRequest) (AuthorizeResponse, error) |
| 372 | } |
| 373 | |
| 374 | // AuthorizeRequest is authorize-request.schema.json. |
| 375 | type AuthorizeRequest struct { |
| 376 | DeviceID string |
| 377 | SessionJTI string |
| 378 | Reason string // "hello" or "renew" |
| 379 | Node string |
| 380 | } |
| 381 | |
| 382 | func (r AuthorizeRequest) toMap() map[string]any { |
| 383 | return map[string]any{"v": 1, "device_id": r.DeviceID, "session_jti": r.SessionJTI, "reason": r.Reason, "node": r.Node} |
| 384 | } |
| 385 | |
| 386 | // AuthorizeResponse is the 200 answer of authorize (authorize-response.schema.json): the node uses Host, Status, ValidUntil, ServerTime and the |
| 387 | // Policy windows; the rest travels to the pieces that need it. |
| 388 | type AuthorizeResponse struct { |
| 389 | DeviceID string |
| 390 | Label string |
| 391 | Host string |
| 392 | Status string // active, gated or paused |
| 393 | Plan string // paid or trial |
| 394 | ValidUntil int64 // epoch seconds |
| 395 | ServerTime int64 |
| 396 | Policy okPolicy |
| 397 | Access string // the signed access statement, when the answer has one |
| 398 | Nodes any // the signed list of nodes, opaque to this piece |
| 399 | E2EWave int |
| 400 | E2EKill bool |
| 401 | CopyE2EE bool |
| 402 | } |
| 403 | |
| 404 | func (a AuthorizeResponse) toMap() map[string]any { |
| 405 | m := map[string]any{"ok": true, "v": 1, "device_id": a.DeviceID, "label": a.Label, "host": a.Host, "status": a.Status, "plan": a.Plan, |
| 406 | "valid_until": a.ValidUntil, "server_time": a.ServerTime, "policy": a.Policy.toMap(), "e2e_wave": a.E2EWave, "e2e_kill": a.E2EKill, |
| 407 | "copy_e2ee": a.CopyE2EE} |
| 408 | if a.Nodes != nil { |
| 409 | m["nodes"] = a.Nodes |
| 410 | } |
| 411 | if a.Access != "" { |
| 412 | m["access"] = a.Access |
| 413 | } |
| 414 | return m |
| 415 | } |
| 416 | |
| 417 | // ControlError is a refusal of authorize: the HTTP status and the closed word of the error body (license_inactive, device_revoked, ...). A transport |
| 418 | // failure or a timeout is any other error: the node answers control_unavailable. |
| 419 | type ControlError struct { |
| 420 | Status int |
| 421 | Code string |
| 422 | } |
| 423 | |
| 424 | func (e *ControlError) Error() string { return fmt.Sprintf("authorize: %d %s", e.Status, e.Code) } |
| 425 | |
| 426 | // errForControl is the ERR word of a failed authorize. |
| 427 | func errForControl(err error) string { |
| 428 | ce, ok := err.(*ControlError) |
| 429 | if !ok { |
| 430 | return codeControlUnavailable |
| 431 | } |
| 432 | if ce.Status >= 500 || ce.Status == 429 || ce.Status < 400 { |
| 433 | return codeControlUnavailable |
| 434 | } |
| 435 | switch ce.Code { |
| 436 | case codeLicenseInactive, codeDeviceRevoked, codePaused: |
| 437 | return ce.Code |
| 438 | case "unauthorized", "invalid_authorize_request", "request_too_large", "bad_response": |
| 439 | // The control plane refused the NODE (its assertion, its request), not the device: nothing the device can fix, so "try later", never "auth". |
| 440 | return codeControlUnavailable |
| 441 | } |
| 442 | return codeAuth |
| 443 | } |
| 444 | |
| 445 | // checkAuthorize refuses an answer that is not what was asked: another device's, a host that is not exactly one label under a zone or does not start |
| 446 | // with the answer's own label, an unknown status or plan, windows out of range. The node never relays an answer it cannot vouch for. |
| 447 | func checkAuthorize(a AuthorizeResponse, req AuthorizeRequest) error { |
| 448 | switch { |
| 449 | case a.DeviceID != req.DeviceID: |
| 450 | return fmt.Errorf("authorize answer is for another device") |
| 451 | case !reLabel.MatchString(a.Label), !validFQDN(a.Host), !strings.HasPrefix(a.Host, a.Label+"."): |
| 452 | return fmt.Errorf("authorize answer has a bad host") |
| 453 | case !validStatus(a.Status): |
| 454 | return fmt.Errorf("authorize answer has a bad status") |
| 455 | case a.Plan != "paid" && a.Plan != "trial": |
| 456 | return fmt.Errorf("authorize answer has a bad plan") |
| 457 | case a.ValidUntil < 1 || a.ValidUntil > epochMax || a.ServerTime < 1 || a.ServerTime > epochMax: |
| 458 | return fmt.Errorf("authorize answer has a bad time") |
| 459 | } |
| 460 | for _, v := range []int64{a.Policy.PingIntervalS, a.Policy.DeadPeerS, a.Policy.StreamIdleS, a.Policy.StreamMaxAgeS, a.Policy.RenewBeforeValidUntilS} { |
| 461 | if v < 1 || v > 86400 { |
| 462 | return fmt.Errorf("authorize answer has a bad policy window") |
| 463 | } |
| 464 | } |
| 465 | return nil |
| 466 | } |
| 467 | |
| 468 | // boundWindows bounds the windows of the answer to policy.json: the node never offers a device a window longer than the table's own. (The control |
| 469 | // plane builds them from the same table, so this changes nothing for an honest answer.) |
| 470 | func boundWindows(p okPolicy) okPolicy { |
| 471 | t := policyData.Timing |
| 472 | return okPolicy{ |
| 473 | PingIntervalS: min(p.PingIntervalS, int64(t.PingIntervalS)), |
| 474 | DeadPeerS: min(p.DeadPeerS, int64(t.DeadPeerS)), |
| 475 | StreamIdleS: min(p.StreamIdleS, int64(t.StreamIdleS)), |
| 476 | StreamMaxAgeS: min(p.StreamMaxAgeS, int64(t.StreamMaxAgeS)), |
| 477 | RenewBeforeValidUntilS: min(p.RenewBeforeValidUntilS, int64(t.RenewBeforeValidUntilS)), |
| 478 | } |
| 479 | } |