Skip to content

Commit ff48f45

Browse files
Pangjipingclaude
andauthored
feat(server): implement OSEP-0011 signed endpoint for secure route access (#787)
* feat(server): implement OSEP-0011 signed endpoint for secure route access Add route signing utilities (base36 expiry, SHA256-based signature), config models for secure access keys, and API/service layer changes to issue time-limited signed route tokens on GetEndpoint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: remove routeToken field from Endpoint schema The routeToken field is not needed since the signed route is already embedded in the endpoint URL. Remove the corresponding field from the schema, the build_signed_route_token utility, and all related tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(sdk): add GetSignedEndpoint to all five SDKs Implement get_signed_endpoint across Go, JS, Python, Kotlin, and C# SDKs. Each SDK exposes a new method (GetSignedEndpoint / get_signed_endpoint / getSignedEndpoint) that calls the existing endpoint API with the expires query parameter for OSEP-0011 signed route tokens. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(helm): shared signing key config for server and ingress Add server.gateway.secureAccess to values.yaml. When keys are provided, the Helm chart renders [ingress.secure_access] into the server's TOML config and passes --secure-access-keys to the ingress gateway container, ensuring both sides use the same OSEP-0011 signing keys. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(e2e): add GetSignedEndpoint test and signing key config for ingress Add K8s E2E test for OSEP-0011 signed endpoints: verify route token is returned, make a /ping call through the gateway with the signed endpoint, and confirm expired timestamps are rejected. Wire signing keys into Helm values so server and ingress share the same key for E2E runs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(kotlin): fix spotless formatting on getSignedEndpoint Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(js): convert expires to string in getSignedEndpoint query param Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(python): convert expires to str in get_signed_sandbox_endpoint calls Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(csharp): add GetSignedSandboxEndpointAsync to test stub Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(e2e): prepend protocol to signed endpoint URL for /ping call The API returns endpoint without scheme; the SDK internally prepends {protocol}://. Match that in the E2E test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(e2e): log all headers from signed endpoint response Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(csharp): remove made-up X-Route-Token header from test stub Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: mark OSEP-0011 as implemented Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(server): omit SecureAccessToken header from signed endpoint response feat(tests): verify signed endpoint omits and unsigned includes the header Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(server): reject expires values exceeding uint64 max before signing Without this check, values > uint64 max reach encode_expires_b36() and raise ValueError, which the generic exception handler converts into a 500. Validate explicitly and return 400 INVALID_PARAMETER. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(server): enforce unique secure_access key_ids in config validation Duplicate key_id entries cause a runtime mismatch: server signs with the first match while ingress stores keys in a map (last duplicate wins), making all signed endpoints fail verification. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(server): rename leftover reference to key_ids -> seen after refactor Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b481aa8 commit ff48f45

43 files changed

Lines changed: 1738 additions & 24 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

kubernetes/charts/opensandbox-server/templates/_helpers.tpl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ mode = {{ .Values.server.gateway.enabled | ternary "gateway" "direct" | quote }}
117117
gateway.address = {{ .Values.server.gateway.host | quote }}
118118
gateway.route.mode = {{ .Values.server.gateway.gatewayRouteMode | quote }}
119119
{{- end }}
120+
{{- if and .Values.server.gateway.enabled .Values.server.gateway.secureAccess.keys }}
121+
122+
[ingress.secure_access]
123+
active_key = {{ .Values.server.gateway.secureAccess.activeKey | quote }}
124+
{{- range .Values.server.gateway.secureAccess.keys }}
125+
[[ingress.secure_access.keys]]
126+
key_id = {{ .key_id | quote }}
127+
key = {{ .key | quote }}
128+
{{- end }}
129+
{{- end }}
120130

121131
{{- end }}
122132

kubernetes/charts/opensandbox-server/templates/ingress-gateway.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ spec:
7676
- "--provider-type={{ .Values.server.gateway.providerType }}"
7777
- "--mode={{ .Values.server.gateway.gatewayRouteMode }}"
7878
- "--log-level={{ .Values.server.gateway.logLevel }}"
79+
{{- if .Values.server.gateway.secureAccess.keys }}
80+
- "--secure-access-keys={{- range $i, $k := .Values.server.gateway.secureAccess.keys }}{{ if $i }},{{ end }}{{ $k.key_id }}={{ $k.key }}{{- end }}"
81+
{{- end }}
7982
ports:
8083
- name: http
8184
containerPort: {{ .Values.server.gateway.port }}

kubernetes/charts/opensandbox-server/values.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ server:
5454
requests:
5555
cpu: "1"
5656
memory: 4Gi
57+
# OSEP-0011 signing keys shared between server and ingress.
58+
# When keys are provided, the server signs route tokens with the active key
59+
# and the ingress gateway verifies them.
60+
secureAccess:
61+
activeKey: ""
62+
# List of signing keys. Each entry: { key_id: "a", key: "<base64-secret>" }.
63+
# key_id must be exactly one character in [0-9a-z].
64+
keys: []
5765

5866
# -- Server config (TOML). Mounted at /etc/opensandbox/config.toml.
5967
configToml: |

oseps/0011-secure-access-endpoint.md

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

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

oseps/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ This is the complete list of OpenSandbox Enhancement Proposals:
1616
| [OSEP-0008](0008-pause-resume-rootfs-snapshot.md) | Pause and Resume via Rootfs Snapshot | implementing | 2026-03-13 |
1717
| [OSEP-0009](0009-auto-renew-sandbox-on-ingress-access.md) | Auto-Renew Sandbox on Ingress Access | implemented | 2026-03-23 |
1818
| [OSEP-0010](0010-opentelemetry-instrumentation.md) | OpenTelemetry Metrics and Logs (execd, egress, and ingress) | implementing | 2026-04-12 |
19+
| [OSEP-0011](0011-secure-access-endpoint.md) | Secure Access on GetEndpoint and Signed Endpoint | implemented | 2026-04-25 |

scripts/common/kubernetes-e2e.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ EOF
131131
}
132132

133133
k8s_e2e_write_server_helm_values() {
134+
local signing_key
135+
signing_key=$(openssl rand -base64 32 | tr -d '\n')
136+
134137
{
135138
cat <<EOF
136139
server:
@@ -165,6 +168,11 @@ EOF
165168
requests:
166169
cpu: "250m"
167170
memory: 512Mi
171+
secureAccess:
172+
activeKey: "a"
173+
keys:
174+
- key_id: "a"
175+
key: "${signing_key}"
168176
EOF
169177
fi
170178
cat <<EOF

sdks/sandbox/csharp/src/OpenSandbox/Adapters/SandboxesAdapter.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,32 @@ public async Task<Endpoint> GetSandboxEndpointAsync(
143143
queryParams,
144144
cancellationToken: cancellationToken).ConfigureAwait(false);
145145

146+
return ParseEndpointResponse(response);
147+
}
148+
149+
public async Task<Endpoint> GetSignedSandboxEndpointAsync(
150+
string sandboxId,
151+
int port,
152+
long expires,
153+
bool useServerProxy = false,
154+
CancellationToken cancellationToken = default)
155+
{
156+
var queryParams = new Dictionary<string, string?>
157+
{
158+
["use_server_proxy"] = useServerProxy ? "true" : "false",
159+
["expires"] = expires.ToString()
160+
};
161+
162+
var response = await _client.GetAsync<JsonElement>(
163+
$"/sandboxes/{Uri.EscapeDataString(sandboxId)}/endpoints/{port}",
164+
queryParams,
165+
cancellationToken: cancellationToken).ConfigureAwait(false);
166+
167+
return ParseEndpointResponse(response);
168+
}
169+
170+
private static Endpoint ParseEndpointResponse(JsonElement response)
171+
{
146172
return new Endpoint
147173
{
148174
EndpointAddress = response.GetProperty("endpoint").GetString() ?? throw new SandboxApiException("Missing endpoint in response"),

sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,19 @@ public Task<Endpoint> GetEndpointAsync(int port, CancellationToken cancellationT
562562
return _sandboxes.GetSandboxEndpointAsync(Id, port, ConnectionConfig.UseServerProxy, cancellationToken);
563563
}
564564

565+
/// <summary>
566+
/// Gets a signed endpoint for a port with an OSEP-0011 route token.
567+
/// </summary>
568+
/// <param name="port">The port number.</param>
569+
/// <param name="expires">Unix epoch seconds for the signed route token expiry.</param>
570+
/// <param name="cancellationToken">Cancellation token.</param>
571+
/// <returns>The endpoint information.</returns>
572+
/// <exception cref="SandboxApiException">Thrown when the sandbox API returns an error.</exception>
573+
public Task<Endpoint> GetSignedEndpointAsync(int port, long expires, CancellationToken cancellationToken = default)
574+
{
575+
return _sandboxes.GetSignedSandboxEndpointAsync(Id, port, expires, ConnectionConfig.UseServerProxy, cancellationToken);
576+
}
577+
565578
/// <summary>
566579
/// Gets the endpoint URL for a port.
567580
/// </summary>

sdks/sandbox/csharp/src/OpenSandbox/Services/ISandboxes.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,22 @@ Task<Endpoint> GetSandboxEndpointAsync(
119119
int port,
120120
bool useServerProxy = false,
121121
CancellationToken cancellationToken = default);
122+
123+
/// <summary>
124+
/// Gets a signed endpoint for a sandbox port with an OSEP-0011 route token.
125+
/// </summary>
126+
/// <param name="sandboxId">The sandbox ID.</param>
127+
/// <param name="port">The port number.</param>
128+
/// <param name="expires">Unix epoch seconds for the signed route token expiry.</param>
129+
/// <param name="useServerProxy">Whether to return a server-proxied URL.</param>
130+
/// <param name="cancellationToken">Cancellation token.</param>
131+
/// <returns>The endpoint information.</returns>
132+
/// <exception cref="InvalidArgumentException">Thrown when arguments are invalid.</exception>
133+
/// <exception cref="SandboxException">Thrown when the sandbox service request fails.</exception>
134+
Task<Endpoint> GetSignedSandboxEndpointAsync(
135+
string sandboxId,
136+
int port,
137+
long expires,
138+
bool useServerProxy = false,
139+
CancellationToken cancellationToken = default);
122140
}

sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,19 @@ public Task<Endpoint> GetSandboxEndpointAsync(string sandboxId, int port, bool u
236236
}
237237
});
238238
}
239+
240+
public Task<Endpoint> GetSignedSandboxEndpointAsync(string sandboxId, int port, long expires, bool useServerProxy = false, CancellationToken cancellationToken = default)
241+
{
242+
EndpointCalls.Add(port);
243+
return Task.FromResult(new Endpoint
244+
{
245+
EndpointAddress = $"127.0.0.1:{port}",
246+
Headers = new Dictionary<string, string>
247+
{
248+
["X-Port"] = port.ToString()
249+
}
250+
});
251+
}
239252
}
240253

241254
private sealed class StubEgress : IEgress

0 commit comments

Comments
 (0)