|
| 1 | +--- |
| 2 | +title: Secure Access on GetEndpoint and Signed Endpoint |
| 3 | +authors: |
| 4 | + - "@Pangjiping" |
| 5 | +creation-date: 2026-04-19 |
| 6 | +last-updated: 2026-04-20 |
| 7 | +status: draft |
| 8 | +--- |
| 9 | + |
| 10 | +# OSEP-0011: Secure Access on GetEndpoint and Signed Endpoint |
| 11 | + |
| 12 | +## Summary |
| 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**. |
| 15 | + |
| 16 | +The **`signature`** is: |
| 17 | + |
| 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 | + |
| 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`**. |
| 22 | + |
| 23 | +## Signing algorithm (implementation order) |
| 24 | + |
| 25 | +### 1) Inputs and constraints |
| 26 | + |
| 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 | + |
| 31 | +### 2) Build `canonical_bytes` (UTF-8) |
| 32 | + |
| 33 | +Concatenate **exactly** in this order, using a single **LF** (`\n`) between segments: |
| 34 | + |
| 35 | +```text |
| 36 | +v1\nshort\n{sandbox_id}\n{port}\n |
| 37 | +``` |
| 38 | + |
| 39 | +Equivalent explicit concatenation: |
| 40 | + |
| 41 | +```text |
| 42 | +"v1" + "\n" + "short" + "\n" + sandbox_id + "\n" + decimal(port) + "\n" |
| 43 | +``` |
| 44 | + |
| 45 | +### 3) Build `inner` (length-prefixed byte concatenation) |
| 46 | + |
| 47 | +`BE32(x)` is **4** bytes, **big-endian** unsigned 32-bit integer **`x`**. |
| 48 | + |
| 49 | +```text |
| 50 | +inner = BE32(len(secret_bytes)) |
| 51 | + || secret_bytes |
| 52 | + || BE32(len(canonical_bytes)) |
| 53 | + || canonical_bytes |
| 54 | +``` |
| 55 | + |
| 56 | +### 4) Hash and mint `signature` |
| 57 | + |
| 58 | +```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 |
| 63 | +``` |
| 64 | + |
| 65 | +> The signature binds **`sandbox_id`**, **`port`**, and the signing key only — not the gateway hostname or DNS suffix. |
| 66 | +
|
| 67 | +## API |
| 68 | + |
| 69 | +- **CreateSandbox:** `secure_access.enabled` (default `false`). |
| 70 | +- **GetSignedEndpoint(sandboxId, port):** returns `signed_endpoint` consistent with `[ingress.gateway].route.mode`, embedding **`signature`**. |
| 71 | + |
| 72 | +## Gateway routing (where the credential lives) |
| 73 | + |
| 74 | +### Host / header token (split on `-` from the **right**) |
| 75 | + |
| 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). |
| 81 | + |
| 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 | |
| 87 | + |
| 88 | +### URI parsing nuance |
| 89 | + |
| 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`**. |
| 93 | + |
| 94 | +After successful authorization, strip the routing token from host / header / path prefix; forward the remaining path and query unchanged. |
| 95 | + |
| 96 | +## Ingress verification |
| 97 | + |
| 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). |
| 106 | + |
| 107 | +## Config |
| 108 | + |
| 109 | +**Server (`~/.sandbox.toml`):** |
| 110 | + |
| 111 | +```toml |
| 112 | +[ingress.secure_access] |
| 113 | +enabled = true |
| 114 | +active_key = "k1" # 2 chars, must exist in keys |
| 115 | + |
| 116 | +[[ingress.secure_access.keys]] |
| 117 | +key_id = "k1" |
| 118 | +secret = "base64:..." |
| 119 | + |
| 120 | +[[ingress.secure_access.keys]] |
| 121 | +key_id = "k0" |
| 122 | +secret = "base64:..." |
| 123 | +``` |
| 124 | + |
| 125 | +The server mints **`signature`** using **`secret_bytes`** for **`active_key`**. |
| 126 | + |
| 127 | +**Ingress:** |
| 128 | + |
| 129 | +```bash |
| 130 | +opensandbox-ingress --secure-access-enabled \ |
| 131 | + --secure-access-keys "k1=base64:...,k0=base64:..." |
| 132 | +``` |
| 133 | + |
| 134 | +## Errors |
| 135 | + |
| 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. |
| 139 | + |
| 140 | +## Tests |
| 141 | + |
| 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`**. |
0 commit comments