Skip to content

Commit 17e1922

Browse files
authored
feat(osep): refine secure-access and signed route token spec (opensandbox-group#765)
1 parent 0977b05 commit 17e1922

1 file changed

Lines changed: 108 additions & 68 deletions

File tree

oseps/0011-secure-access-endpoint.md

Lines changed: 108 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,106 +3,149 @@ title: Secure Access on GetEndpoint and Signed Endpoint
33
authors:
44
- "@Pangjiping"
55
creation-date: 2026-04-19
6-
last-updated: 2026-04-20
7-
status: draft
6+
last-updated: 2026-04-21
7+
status: implementing
88
---
99

1010
# OSEP-0011: Secure Access on GetEndpoint and Signed Endpoint
1111

1212
## Summary
1313

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:
1515

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*).
1719

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.
2023

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**.
2225

23-
## Signing algorithm (implementation order)
26+
## Static access token (`GetEndpoint`)
2427

25-
### 1) Inputs and constraints
28+
When the sandbox has **`secure_access` enabled**, **`GetEndpoint(sandboxId)`** (or equivalent lifecycle response) includes **`SecureAccessToken`**.
2629

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:
3031

31-
### 2) Build `canonical_bytes` (UTF-8)
32+
```http
33+
OPENSANDBOX-SECURE-ACCESS: <token>
34+
```
3235

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:
3437

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`**).
49+
- **Charset:** **`[0-9a-z]`** only; reject uppercase.
50+
- **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)
3865

39-
Equivalent explicit concatenation:
66+
Always (note: **`{expires_b36}`** is base36, **not** decimal):
4067

4168
```text
42-
"v1" + "\n" + "short" + "\n" + sandbox_id + "\n" + decimal(port) + "\n"
69+
v1\nshort\n{sandbox_id}\n{port}\n{expires_b36}\n
4370
```
4471

45-
### 3) Build `inner` (length-prefixed byte concatenation)
72+
### `inner` and `signature`
4673

47-
`BE32(x)` is **4** bytes, **big-endian** unsigned 32-bit integer **`x`**.
74+
`BE32(x)` = 4-byte big-endian uint32.
4875

4976
```text
50-
inner = BE32(len(secret_bytes))
51-
|| secret_bytes
52-
|| BE32(len(canonical_bytes))
53-
|| canonical_bytes
77+
inner = BE32(len(secret_bytes)) || secret_bytes || BE32(len(canonical_bytes)) || canonical_bytes
78+
digest = SHA256(inner)
79+
hex_all = lowercase_hex(digest)
80+
hex8 = hex_all[0:8]
81+
signature = hex8 + signed_key_id // 9 chars total
5482
```
5583

56-
### 4) Hash and mint `signature`
84+
### Routing token (always four logical segments for **signed** routes)
5785

5886
```text
59-
digest = SHA256(inner) // 32 bytes
60-
hex_all = lowercase_hex(digest) // 64 chars
61-
hex8 = hex_all[0:8]
62-
signature = hex8 + signed_key_id // 10 chars total
87+
{sandbox-id}-{port}-{expires_b36}-{signature}
6388
```
6489

65-
> The signature binds **`sandbox_id`**, **`port`**, and the signing key only — not the gateway hostname or DNS suffix.
90+
**Right-to-left parse:**
91+
92+
1. **Last**: **`signature`** (**`[0-9a-f]{8}[0-9a-z]{1}`** — exactly **9** characters).
93+
2. **Second-to-last**: **`expires_b36`** (**`[0-9a-z]{1,13}`**, decode with **base 36** to **`uint64`**).
94+
3. **Third-to-last**: **`port`** (decimal, rules above).
95+
4. **Remaining** (joined with `-`): **`sandbox_id`**.
96+
97+
**Unsigned legacy (no route signature):** **`{sandbox_id}-{port}`** — two segments only.
6698

6799
## API
68100

69101
- **CreateSandbox:** `secure_access.enabled` (default `false`).
70-
- **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}`**.
71109

72-
## Gateway routing (where the credential lives)
110+
## Gateway routing
73111

74112
### Host / header token (split on `-` from the **right**)
75113

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).
114+
- **Signed:** **`{sandbox_id}-{port}-{expires_b36}-{signature}`**.
115+
- **Unsigned legacy:** **`{sandbox_id}-{port}`**.
116+
117+
| Mode | Where | Example (illustrative) |
118+
|------|-------|-------------------------|
119+
| **Wildcard** | Host: `{sandbox_id}-{port}-{expires_b36}-{signature}.<parent>` | `my-sandbox-8080-x2qxvk-aabbccddk.sandbox.example.com`**`expires_b36`** = **`x2qxvk`** (**`2000000000`** sec, Go **`FormatUint(..., 36)`**); **`signature`** = **`aabbccddk`**; parent = **`sandbox.example.com`**. |
120+
| **Header** | Value: same `-`-joined token | `my-sandbox-8080-x2qxvk-aabbccddk` |
121+
| **URI** | Prefix: `/{sandbox_id}/{port}/{expires_b36}/{signature}/` + upstream remainder | `/my-sandbox/8080/x2qxvk/aabbccddk/v1/status` — upstream after strip: **`/v1/status`**. |
81122

82-
| Mode | Where |
83-
|------|-------|
84-
| **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
87124

88-
### URI parsing nuance
125+
**Secure sandboxes (secure access required):**
89126

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.
93128

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.
95139

96140
## Ingress verification
97141

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.
106149

107150
## Config
108151

@@ -111,33 +154,30 @@ After successful authorization, strip the routing token from host / header / pat
111154
```toml
112155
[ingress.secure_access]
113156
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
115158

116159
[[ingress.secure_access.keys]]
117-
key_id = "k1"
160+
key_id = "a"
118161
secret = "base64:..."
119162

120163
[[ingress.secure_access.keys]]
121-
key_id = "k0"
164+
key_id = "b"
122165
secret = "base64:..."
123166
```
124167

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:...`**.
128169

129170
```bash
130171
opensandbox-ingress --secure-access-enabled \
131-
--secure-access-keys "k1=base64:...,k0=base64:..."
172+
--secure-access-keys "a=base64:...,b=base64:..."
132173
```
133174

134175
## Errors
135176

136-
- **`400`:** malformed route / token shape, invalid **`port`**, invalid **`signature`** charset or length.
137-
- **`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.
139179

140180
## Tests
141181

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

Comments
 (0)