You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@@ -3,106 +3,149 @@ title: Secure Access on GetEndpoint and Signed Endpoint
3
3
authors:
4
4
- "@Pangjiping"
5
5
creation-date: 2026-04-19
6
-
last-updated: 2026-04-20
7
-
status: draft
6
+
last-updated: 2026-04-21
7
+
status: implementing
8
8
---
9
9
10
10
# OSEP-0011: Secure Access on GetEndpoint and Signed Endpoint
11
11
12
12
## Summary
13
13
14
-
Optional `secure_access` on sandbox create. **`GetSignedEndpoint(sandboxId, port)`** returns a URL that embeds a **route `signature`** (a **10-character** token). There is**no**`expires`, **no** signing of app path or query, and **no** DNS parent domain in the signed material. The wildcard parent domain is **routing-only**.
14
+
Optional `secure_access` on sandbox create. There are**two**complementary mechanisms:
15
15
16
-
The **`signature`** is:
16
+
1.**Static header authorization (from `GetEndpoint`)** — when `secure_access` is enabled, **`GetEndpoint`** returns a stable opaque **`SecureAccessToken`**. Clients attach it to **all subsequent requests** as
17
+
**`OPENSANDBOX-SECURE-ACCESS: <token>`**
18
+
Ingress evaluates this header **before** route-signature verification, with **fail-fast** semantics when the header field is **present** but wrong (see § *Ingress verification*).
17
19
18
-
1.**`hex8`** — first **8** characters of the lowercase hex encoding of **`SHA256(inner)`** (i.e. the first **4** bytes of the digest as hex).
19
-
2.**`signed_key_id`** — the **last 2** characters of **`signature`**, **`[0-9a-z]`**, equal to the **`key_id`** of the `secret_bytes` row used to mint (typically the server **`active_key`**).
20
+
2.**Route `signature` (short route token)** — a **9-character** value embedded in host / header / path: **`hex8`** (8 lowercase hex) + **`signed_key_id`** (**exactly 1** char **`[0-9a-z]`**).
21
+
**Every signed route also carries an `expires` value:** Unix epoch seconds as **`uint64`**, encoded for routing and signing as **`expires_b36`**: **base-36** using **lowercase** digits **`0-9`** and letters **`a-z`**, **no leading zeros** (except **`expires_sec == 0`** is **`0`**). Equivalently (Go): **`strconv.FormatUint(expires_sec, 36)`** / **`strconv.ParseUint(s, 36, 64)`**. It appears in **`canonical_bytes`** and as its **own**`-`-delimited segment: **`{sandbox_id}-{port}-{expires_b36}-{signature}`**.
22
+
**Minting** always requires an **`expires`** input (see API). Ingress enforces **`now ≤ expires_seconds`** after decoding.
20
23
21
-
`GetEndpoint` may still return an opaque static **`OPENSANDBOX-SECURE-ACCESS`**header value (annotation / access token) when enabled. That header path is **separate** from the route **`signature`**.
24
+
There is **no** signing of app path or query, and **no**DNS parent domain in the signed material. The wildcard parent domain is **routing-only**.
22
25
23
-
## Signing algorithm (implementation order)
26
+
## Static access token (`GetEndpoint`)
24
27
25
-
### 1) Inputs and constraints
28
+
When the sandbox has **`secure_access` enabled**, **`GetEndpoint(sandboxId)`** (or equivalent lifecycle response) includes **`SecureAccessToken`**.
26
29
27
-
-**`sandbox_id`**: used verbatim in canonical (may contain `-`).
28
-
-**`port`**: decimal integer in **`1..65535`**, **no leading zeros** (e.g. `08080` is invalid).
29
-
-**`secret_bytes`**: raw decoded secret bytes for the chosen **`signed_key_id`** (same material ingress uses to verify).
30
+
**Client rule:** for **every** follow-up request through the gateway:
30
31
31
-
### 2) Build `canonical_bytes` (UTF-8)
32
+
```http
33
+
OPENSANDBOX-SECURE-ACCESS: <token>
34
+
```
32
35
33
-
Concatenate **exactly**in this order, using a single **LF**(`\n`) between segments:
36
+
**Ingress rule (secure sandbox):**define **header present** as: the **`OPENSANDBOX-SECURE-ACCESS`**field appears on the HTTP request (any value, including empty). Then:
34
37
35
-
```text
36
-
v1\nshort\n{sandbox_id}\n{port}\n
37
-
```
38
+
- If **present** and the value **matches****`SecureAccessToken`** (constant-time compare) → **allow**; route-signature verification is **not** required.
39
+
- If **present** and the value **does not match** → **`401` immediately**; ingress **must not** fall through to route-signature verification (prevents “bad/stale header + valid signed URL” from being accepted).
40
+
- If **absent** → ingress may authenticate using the route **`signature`** path (when provided and valid).
41
+
42
+
## `expires_b36` encoding
43
+
44
+
Let **`expires_sec`** be **`uint64`** Unix epoch seconds (UTC).
45
+
46
+
**`expires_b36`** is the **base-36** encoding of **`expires_sec`** using **lowercase** alphabet **`0-9a-z`**, with **no leading zeros**, except **`expires_sec == 0`** is encoded as **`0`**. Normative reference (Go): **`strconv.FormatUint(expires_sec, 36)`** for minting and **`strconv.ParseUint(segment, 36, 64)`** for ingress.
47
+
48
+
-**Length:****1** to **13** characters inclusive for any **`uint64`** value (max is **`18446744073709551615`** → **`3w5e11264sgsf`**).
-**Routing segment** and **`canonical_bytes`** embed the **same** literal string (not decimal seconds).
51
+
-**Ingress:** reject empty, invalid charset, overflow on parse, or length **> 13** → **`400`**. Then **`401`** if **`now > expires_sec`**.
52
+
53
+
> **Rationale:** Base36 is shorter than decimal for typical timestamps (e.g. **`2000000000`** → **`x2qxvk`**, 6 chars) while staying URL/host friendly without extra escaping.
54
+
55
+
## Signing algorithm (signed routes **always** include `expires_b36`)
56
+
57
+
### Inputs and constraints
58
+
59
+
-**`sandbox_id`**: verbatim in canonical (may contain `-`).
60
+
-**`port`**: decimal **`1..65535`**, **no leading zeros**.
61
+
-**`expires_b36`**: **required** for any minted signed route; rules above.
62
+
-**`secret_bytes`**: raw decoded secret for **`signed_key_id`** (see config: **`key_id`** is **1** char **`[0-9a-z]`**).
63
+
64
+
### `canonical_bytes` (UTF-8)
38
65
39
-
Equivalent explicit concatenation:
66
+
Always (note: **`{expires_b36}`** is base36, **not** decimal):
-**GetSignedEndpoint(sandboxId, port):** returns `signed_endpoint` consistent with `[ingress.gateway].route.mode`, embedding **`signature`**.
102
+
-**`GetEndpoint(sandboxId)`:** when secure access is on, includes **`SecureAccessToken`** for **`OPENSANDBOX-SECURE-ACCESS`**.
103
+
-**Mint signed URL / host token (all require expiry input):**
104
+
-**`GET /sandboxes/{sandboxId}/endpoints/secure/{port}?expires=<unix_seconds>`** (and/or **`GetSignedEndpoint`** with the same query).
105
+
-**`expires`** query is a **decimal `uint64`** Unix second (human-friendly). The server **normalizes** to **`expires_b36`** (rules above) for both **`canonical_bytes`** and the returned routing token.
106
+
- Missing **`expires`** on mint → **`400`**.
107
+
108
+
Returned signed routing material always uses **`{sandbox_id}-{port}-{expires_b36}-{signature}`**.
71
109
72
-
## Gateway routing (where the credential lives)
110
+
## Gateway routing
73
111
74
112
### Host / header token (split on `-` from the **right**)
75
113
76
-
-**Three or more segments**`<sandbox-id>-<port>-<signature>`:
77
-
-**Last** segment: **`signature`** (must match **`[0-9a-f]{8}[0-9a-z]{2}`**).
78
-
-**Second-to-last**: **`port`** (rules above).
79
-
-**Everything before** (re-joined with `-`): **`sandbox_id`**.
80
-
-**Two segments**`<sandbox-id>-<port>`: **unsigned** route; **`signature`** is empty (legacy compatibility).
|**Wildcard**| Host: `{sandbox_id}-{port}-{signature}.<parent-domain>` (parent domain from gateway DNS only; not signed) |
85
-
|**Header**| Header value only: `{sandbox_id}-{port}-{signature}`|
86
-
|**URI**| Path: `/{sandbox_id}/{port}/{signature}/` + remainder to upstream |
123
+
### URI parsing
87
124
88
-
### URI parsing nuance
125
+
**Secure sandboxes (secure access required):**
89
126
90
-
- If the path matches **OSEP** shape (valid **`port`** in segment 2 and a valid 10-char **`signature`** in segment 3), treat segments 1–3 as routing prefix and the rest as upstream path.
91
-
- Otherwise parse as **legacy** URI: first segment = **`sandbox_id`**, second = **`port`**, remainder (if any) = upstream path — **no** embedded **`signature`**.
92
-
- For sandboxes that **do not** require secure access, an OSEP-shaped path may be **reinterpreted** as legacy so a normal path segment is not mistaken for **`signature`**.
127
+
- If segments 2–4 are syntactically valid **`port`**, **`expires_b36`**, **`signature`**, treat the path as **signed OSEP**: strip **`/{sandbox_id}/{port}/{expires_b36}/{signature}`** and forward the remainder + query unchanged.
93
128
94
-
After successful authorization, strip the routing token from host / header / path prefix; forward the remaining path and query unchanged.
129
+
**Unsecured sandboxes (secure access not required) — legacy safeguard:**
130
+
131
+
- Even when segments 2–4 **happen to match** the **`expires_b36`** / **`signature`** charset and length rules, ingress **must not** treat them as a signed routing prefix for forwarding purposes.
132
+
- Instead, **re-parse the full path using legacy URI rules** (first segment = **`sandbox_id`**, second = **`port`**, **everything after** is the upstream path, including any segments that looked like **`expires_b36`** / **`signature`**). This preserves existing **unsigned** apps whose paths could collide with the signed shape and avoids silently rewriting upstream paths.
133
+
134
+
**How to decide:** after resolving **`sandbox_id`** from the first path segment, consult **`GetEndpoint` / secure-access policy**. Apply the **signed OSEP** strip **only** when the sandbox **requires** secure access; otherwise apply **legacy** parsing for URI mode.
135
+
136
+
**Legacy unsigned (always):**`/{sandbox_id}/{port}/…` when the path is not using the signed prefix **or** when legacy re-parse is mandated above.
137
+
138
+
Strip the signed prefix **only** on the secure path; forward path + query unchanged relative to the chosen interpretation.
95
139
96
140
## Ingress verification
97
141
98
-
1. Parse **`sandbox_id`**, **`port`**, optional route **`signature`** from host, header, or URI (per mode).
99
-
2.**`GetEndpoint(sandbox_id)`** — determine whether the sandbox requires secure access and obtain **`SecureAccessToken`** (annotation) if any.
100
-
3.**Unified access decision:**
101
-
- If the sandbox does **not** require secure access → allow.
102
-
- If it **does** require secure access:
103
-
- If **`OPENSANDBOX-SECURE-ACCESS`** is present → it **must** equal the sandbox token (constant-time compare) or **`401`**.
104
-
- Else if route **`signature`** is present → rebuild **`canonical_bytes`**, recompute **`hex8`**, verify against **`secret_bytes`** for **`signed_key_id`** from **`--secure-access-keys`** → **`401`** on mismatch or unknown key.
105
-
- Else **`401`** (signature required).
142
+
1.**Parse routing input** (mode-dependent). For **URI** mode, a path may **syntactically** match **`/{sandbox_id}/{port}/{expires_b36}/{signature}/…`**; still resolve **`sandbox_id`** (at minimum the first segment) for lookup.
143
+
2.**`GetEndpoint(sandbox_id)`** once: secure-access flag, **`SecureAccessToken`**, and backend endpoint.
144
+
3.**URI mode + secure access not required:****re-parse the full path using legacy URI rules** for **`sandbox_id`**, **`port`**, and upstream **`requestURI`** (§ *URI parsing* / unsecured safeguard). **Do not** strip **`expires_b36`** / **`signature`**-shaped segments from the forwarded path.
145
+
4.**Secure access required** (final signed interpretation for URI / host / header):
146
+
-**Header branch:** if **`OPENSANDBOX-SECURE-ACCESS`** is **present** (see § *Static access token*): **match** → **allow**; **mismatch** → **`401`** (no route-signature fallback).
147
+
-**Signature branch:** if the header is **absent** and a signed route token is present: decode **`expires_sec`** from **`expires_b36`**, require **`now ≤ expires_sec`**, rebuild **`canonical_bytes`** with the **same****`expires_b36`**, verify **`signature`** → **`401`** on failure; if no signed credential → **`401`**.
148
+
5.**Secure access not required** (URI after step 3 legacy re-parse, or unsigned host/header shapes): **allow** without route-signature verification.
106
149
107
150
## Config
108
151
@@ -111,33 +154,30 @@ After successful authorization, strip the routing token from host / header / pat
111
154
```toml
112
155
[ingress.secure_access]
113
156
enabled = true
114
-
active_key = "k1"#2 chars, must exist in keys
157
+
active_key = "a"#1 char [0-9a-z], must exist in keys
115
158
116
159
[[ingress.secure_access.keys]]
117
-
key_id = "k1"
160
+
key_id = "a"
118
161
secret = "base64:..."
119
162
120
163
[[ingress.secure_access.keys]]
121
-
key_id = "k0"
164
+
key_id = "b"
122
165
secret = "base64:..."
123
166
```
124
167
125
-
The server mints **`signature`** using **`secret_bytes`** for **`active_key`**.
126
-
127
-
**Ingress:**
168
+
**Ingress:**`--secure-access-keys` uses the same **1-character**`key_id` per segment, e.g. **`a=base64:...,b=base64:...`**.
-**`401`:** bad **`hex8`**, unknown **`signed_key_id`**, missing credential when required, or secure-access header mismatch.
138
-
-**GetSignedEndpoint:**`404` / `403` when sandbox is missing or secure access is disabled.
177
+
-**`400`:** missing **`expires`** on mint, malformed token, invalid **`expires_b36`** (empty / bad charset / length **> 13** / parse overflow), bad **`port`** / **`signature`**.
178
+
-**`401`:** header mismatch, **`now > expires_sec`**, bad **`hex8`**, unknown key, missing credential when required.
139
179
140
180
## Tests
141
181
142
-
- Unit: `inner` / `hex8`, right-split with hyphens in **`sandbox_id`**, two-segment unsigned host, URI OSEP vs legacy.
143
-
- Integration: three route modes + one tampered hex → **`401`**.
182
+
- Unit: `inner` / `hex8`, four-part right split with `-` in **`sandbox_id`**, **`expires_b36`** canonicalization (no leading zeros, **`0`** case, round-trip **`ParseUint(..., 36, 64)`**).
183
+
- Integration: three modes; invalid **`expires_b36`** → **`400`**; past expiry → **`401`**; mint without **`expires`** → **`400`**.
0 commit comments