Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 17 additions & 20 deletions docs/nethernet-provider-registration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,11 @@ Registration combines one mode with one advertised authorization scheme:
| Standalone server, no credential | `new-service` | `anonymous-proof-of-work` | New public service and machine identity |
| Existing provider customer | `new-service` | `bearer-token` | Account-owned service with no PoW |
| Autoscaled proxy replica | `attach-instance` | `bearer-token` | Machine in an existing service/pool with no PoW |
| One-machine controller handoff | `attach-instance` | `bootstrap-grant` | Attachment using a short-lived key-bound grant |
| Server host's own infrastructure | Either | Any advertised scheme | Registration stays at the configured host origin |

Provider policy decides what a token represents. It may map to an account that
can create services, or to a fixed service, region, pool, and tag set. That mapping
is deliberately outside the protocol.
Provider policy decides whether a token is reusable, short-lived, single-use or
key-bound, and whether it can create services or attach to a fixed placement.
Token issuance and exchange are outside this protocol.

## Discovery and trust

Expand All @@ -44,8 +43,7 @@ profiles, operations, authorization, policy, and limits. For example:
"header": "Authorization",
"schemes": [
{ "scheme": "anonymous-proof-of-work", "modes": ["new-service"] },
{ "scheme": "bearer-token", "modes": ["new-service", "attach-instance"] },
{ "scheme": "bootstrap-grant", "modes": ["attach-instance"] }
{ "scheme": "bearer-token", "modes": ["new-service", "attach-instance"] }
]
}
}
Expand All @@ -62,7 +60,7 @@ responses, proof payloads, durable machine state, or logs.

## Challenge request

First create and durably store a fresh P-384 machine key. A fleet request is:
First create and durably store a fresh P-384 instance key. A fleet request is:

```json
{
Expand All @@ -81,19 +79,18 @@ First create and durably store a fresh P-384 machine key. A fleet request is:
```

`new-service` cannot name a service or instance. `attach-instance` requires an
explicit placement and provider-resolved authority for the existing service. A
`bootstrap-grant` request also carries `bootstrapGrant` in JSON; the grant should
be short-lived and bound to the machine public-key thumbprint.

For first-v0 compatibility, omitting `authorization` implies `bootstrap-grant`
when `bootstrapGrant` is present, otherwise `anonymous-proof-of-work`. New clients
should send the selection explicitly.
explicit placement and a bearer token authorizing the existing service. Clients
send the authorization selection explicitly.

Placement contains `region`, `pool`, and at most 16 provider-defined tags. Tag
keys match `[A-Za-z0-9_.-]{1,32}`. Values are trimmed, non-empty strings of at most
64 characters. Tags are sorted by key for canonicalization. Providers authorize
the exact placement; labels do not grant authority.

An instance key identifies one logical instance, not a node or fleet. Concurrent
instances must not share or copy a key or state directory. A restart of the same
logical instance reuses its state; images and templates must not contain it.

## Proof of work and possession

The provider stores and returns a challenge containing the machine thumbprint,
Expand All @@ -110,7 +107,7 @@ integer epoch milliseconds, and empty strings for absent context strings. The ba
context digest hashes:

```text
[mode, profile, label, grantId, serviceId, region, pool, registrationId]
[mode, profile, label, authorizationId, serviceId, region, pool, registrationId]
```

When tags are non-empty, append `tagsDigest`: unpadded base64url SHA-256 of the
Expand All @@ -128,10 +125,10 @@ ES384 signatures are 96-byte P1363 `r || s`, unpadded base64url; DER is rejected
PoW counts leading zero bits of SHA-256 over the same proof bytes. Completion sends
`protocol`, `challengeId`, `proofNonce`, `idempotencyKey`, and `signature`.

The provider revalidates token or grant authority at atomic completion. A token
revoked after challenge creation fails closed. Challenge/grant consumption and all
created resources commit together. Completion is single-use and must not replay
one-time secrets.
The provider revalidates token authority at atomic completion. A token revoked
after challenge creation fails closed. Authority validation and all created
resources commit together. Completion is single-use and must not replay one-time
secrets.

## Result and lifecycle

Expand All @@ -141,7 +138,7 @@ return one-time ticket material needed by the profile. Anonymous creation may
return an optional provider-account claim action. Token-owned creation should not
require a second claim.

Persist the private key before requesting a challenge, the challenge before
Persist the instance private key before requesting a challenge, the challenge before
completion, and returned IDs/key material before activation. On restart, use the
discovered recovery operation and prove possession of the same key. Do not register
a new machine merely because a process restarted.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,22 @@
/** One asynchronous, serialized control lifecycle per backend, never one poller per player. */
public final class ProviderClient implements AutoCloseable {
public static final String NEW_SERVICE = "new-service", ATTACH_INSTANCE = "attach-instance";
public static final String ANONYMOUS_PROOF_OF_WORK = "anonymous-proof-of-work", BEARER_TOKEN = "bearer-token", BOOTSTRAP_GRANT = "bootstrap-grant";
public static final String ANONYMOUS_PROOF_OF_WORK = "anonymous-proof-of-work", BEARER_TOKEN = "bearer-token";
public record Configuration(URI provider, String profile, String label, String registrationMode, String authorizationScheme,
String authorizationToken, String bootstrapGrant, String region, String pool, Map<String, String> tags) {
String authorizationToken, String region, String pool, Map<String, String> tags) {
public Configuration {
Objects.requireNonNull(provider); Objects.requireNonNull(profile); Objects.requireNonNull(registrationMode); Objects.requireNonNull(authorizationScheme);
tags = tags == null ? Map.of() : Collections.unmodifiableMap(new TreeMap<>(tags));
if (!Set.of(NEW_SERVICE, ATTACH_INSTANCE).contains(registrationMode)) throw new IllegalArgumentException("Invalid provider registration mode");
if (!Set.of(ANONYMOUS_PROOF_OF_WORK, BEARER_TOKEN, BOOTSTRAP_GRANT).contains(authorizationScheme)) throw new IllegalArgumentException("Invalid provider authorization scheme");
if (!Set.of(ANONYMOUS_PROOF_OF_WORK, BEARER_TOKEN).contains(authorizationScheme)) throw new IllegalArgumentException("Invalid provider authorization scheme");
if ((BEARER_TOKEN.equals(authorizationScheme)) != (authorizationToken != null && !authorizationToken.isBlank())) throw new IllegalArgumentException("Bearer authorization requires exactly one token");
if ((BOOTSTRAP_GRANT.equals(authorizationScheme)) != (bootstrapGrant != null && !bootstrapGrant.isBlank())) throw new IllegalArgumentException("Bootstrap authorization requires exactly one grant");
if (ANONYMOUS_PROOF_OF_WORK.equals(authorizationScheme) && !NEW_SERVICE.equals(registrationMode)) throw new IllegalArgumentException("Anonymous proof of work can only create a service");
if (BOOTSTRAP_GRANT.equals(authorizationScheme) && !ATTACH_INSTANCE.equals(registrationMode)) throw new IllegalArgumentException("Bootstrap grants can only attach an instance");
if (ATTACH_INSTANCE.equals(registrationMode) && (region == null || region.isBlank() || pool == null || pool.isBlank())) throw new IllegalArgumentException("Attached instances require region and pool");
if ((region == null) != (pool == null) || (!tags.isEmpty() && region == null)) throw new IllegalArgumentException("Provider placement requires region and pool together");
if (tags.size() > 16 || tags.entrySet().stream().anyMatch(e -> !e.getKey().matches("[A-Za-z0-9_.-]{1,32}") || e.getValue() == null || !e.getValue().equals(e.getValue().trim()) || e.getValue().isEmpty() || e.getValue().length() > 64)) throw new IllegalArgumentException("Invalid provider placement tags");
}
/** Compatibility constructor for the original anonymous/grant configuration. */
public Configuration(URI provider, String profile, String label, String bootstrapGrant, String region, String pool) {
this(provider, profile, label, bootstrapGrant == null ? NEW_SERVICE : ATTACH_INSTANCE,
bootstrapGrant == null ? ANONYMOUS_PROOF_OF_WORK : BOOTSTRAP_GRANT, null, bootstrapGrant, region, pool, Map.of());
public Configuration(URI provider, String profile, String label) {
this(provider, profile, label, NEW_SERVICE, ANONYMOUS_PROOF_OF_WORK, null, null, null, Map.of());
}
@Override public String toString() { return "Configuration[provider=" + provider + ", profile=" + profile + ", registrationMode=" + registrationMode + ", authorizationScheme=" + authorizationScheme + "]"; }
}
Expand Down Expand Up @@ -165,7 +161,6 @@ private void enroll() throws Exception {
JsonObject request = new JsonObject(); request.addProperty("protocol", ProviderCrypto.PROTOCOL); request.addProperty("mode", config.registrationMode());
request.addProperty("profile", config.profile()); request.add("publicKeyJwk", state.get("publicKeyJwk")); if (config.label() != null) request.addProperty("label", config.label());
JsonObject authorization = new JsonObject(); authorization.addProperty("scheme", config.authorizationScheme()); request.add("authorization", authorization);
if (config.bootstrapGrant() != null) request.addProperty("bootstrapGrant", config.bootstrapGrant());
if (config.region() != null) { JsonObject p = new JsonObject(); p.addProperty("region", config.region()); p.addProperty("pool", config.pool()); if (!config.tags().isEmpty()) p.add("tags", JSON.toJsonTree(config.tags())); request.add("placement", p); }
challenge = unsigned("challenges", request, config.authorizationToken()); state.add("challenge", challenge); save();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;

/** One process owns a directory; atomic file replacement precedes any readiness advertisement. */
/** One logical instance owns this directory and key; never share or clone it across live instances. */
public final class ProviderStateStore implements AutoCloseable {
private final Path directory, stateFile;
private final FileChannel lockChannel;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,6 @@
"type": "string",
"maxLength": 128
},
"bootstrapGrant": {
"type": "string"
},
"authorization": {
"type": "object",
"additionalProperties": false,
Expand All @@ -69,8 +66,7 @@
"scheme": {
"enum": [
"anonymous-proof-of-work",
"bearer-token",
"bootstrap-grant"
"bearer-token"
]
}
}
Expand Down Expand Up @@ -117,29 +113,18 @@
},
"then": {
"required": [
"placement"
"placement",
"authorization"
],
"anyOf": [
{
"required": [
"bootstrapGrant"
]
},
{
"required": [
"authorization"
],
"properties": {
"authorization": {
"properties": {
"authorization": {
"properties": {
"scheme": {
"const": "bearer-token"
}
}
"scheme": {
"const": "bearer-token"
}
}
}
]
}
}
}
],
Expand Down Expand Up @@ -181,8 +166,7 @@
"scheme": {
"enum": [
"anonymous-proof-of-work",
"bearer-token",
"bootstrap-grant"
"bearer-token"
]
},
"modes": {
Expand Down Expand Up @@ -239,8 +223,7 @@
"scheme": {
"enum": [
"anonymous-proof-of-work",
"bearer-token",
"bootstrap-grant"
"bearer-token"
]
},
"reference": {
Expand Down Expand Up @@ -339,7 +322,7 @@
"mode",
"profile",
"label",
"grantId",
"authorizationId",
"serviceId",
"region",
"pool",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public static void main(String[] args) throws Exception {
try {
nativeHost = NativeProviderTransport.open(bootstrap, new InetSocketAddress("127.0.0.1", port),
state.resolve("host-cert.pem"), state.resolve("host-key.pem"), new AdmissionGate.Limits(4, 8, 2, 10_000)).toCompletableFuture().get(10, TimeUnit.SECONDS);
provider = new ProviderClient(new ProviderClient.Configuration(origin, "warden-admission-v1", "Provider native integration", null, null, null),
provider = new ProviderClient(new ProviderClient.Configuration(origin, "warden-admission-v1", "Provider native integration"),
new ProviderStateStore(state), nativeHost,
() -> new ServerStatus("Automatic native server", 1234, "fixture-only", "Integration", 0, 4, 0),
() -> new ProviderClient.Health(true, 4, 0, "nethernet", "provider-native-bench"), System.err::println);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,21 @@ private JsonObject dispatch(HttpExchange e) throws Exception {
d.add("operations", operations); JsonObject limits = new JsonObject(); limits.addProperty("heartbeatIntervalMs", 1000); if (checkInMillis > 0) limits.addProperty("checkInVersion", 1); limits.addProperty("maxControlPage", 100); limits.addProperty("leaseMs", 30000); limits.addProperty("maxBodyBytes", 65536); limits.addProperty("clockSkewMs", 60000); d.add("limits", limits);
JsonObject policy = new JsonObject(); policy.addProperty("newServiceClaim", "none"); policy.addProperty("anonymousPow", true); policy.addProperty("attachmentPow", false); d.add("policy", policy);
JsonObject authorization = new JsonObject(); authorization.addProperty("header", "Authorization"); JsonArray schemes = new JsonArray();
schemes.add(authorizationScheme("anonymous-proof-of-work", "new-service")); schemes.add(authorizationScheme("bearer-token", "new-service", "attach-instance")); schemes.add(authorizationScheme("bootstrap-grant", "attach-instance")); authorization.add("schemes", schemes); d.add("authorization", authorization); return d;
schemes.add(authorizationScheme("anonymous-proof-of-work", "new-service")); schemes.add(authorizationScheme("bearer-token", "new-service", "attach-instance")); authorization.add("schemes", schemes); d.add("authorization", authorization); return d;
}
if (path.equals("/example/challenges") || path.equals("/example/recover")) {
boolean recovery = path.endsWith("recover");
if (recovery && (registration == null || !registration.get("registrationId").equals(body.get("registrationId")))) throw new Failure(403, "recovery_unavailable");
JsonObject key = recovery ? keys.get(registration.get("keyId").getAsString()) : body.getAsJsonObject("publicKeyJwk");
if (!recovery && !body.get("mode").getAsString().equals("new-service")) throw new Failure(403, "bootstrap_grant_required");
String authorization = recovery ? "recovery" : body.getAsJsonObject("authorization").get("scheme").getAsString();
if (!recovery && body.get("mode").getAsString().equals("attach-instance") && !authorization.equals("bearer-token")) throw new Failure(403, "bearer_token_required");
if (authorization.equals("bearer-token")) {
challengeAuthorization = e.getRequestHeaders().getFirst("Authorization");
if (!"Bearer independent-provider-token".equals(challengeAuthorization)) throw new Failure(401, "invalid_bearer_token");
}
JsonObject c = new JsonObject(); c.addProperty("protocol", ProviderCrypto.PROTOCOL); c.addProperty("signature", ProviderCrypto.SIGNATURE); c.addProperty("challengeId", UUID.randomUUID().toString()); c.addProperty("audience", origin); c.addProperty("nonce", UUID.randomUUID().toString()); c.addProperty("thumbprint", ProviderCrypto.thumbprint(key)); c.addProperty("expiresAt", System.currentTimeMillis() + 60000); c.addProperty("serverTime", System.currentTimeMillis());
JsonObject context = new JsonObject(); for (String f : List.of("label", "grantId", "serviceId", "region", "pool", "registrationId")) context.addProperty(f, ""); context.addProperty("mode", recovery ? "recover" : "new-service"); context.addProperty("profile", "example-profile-v0");
if (!recovery && authorization.equals("bearer-token")) { context.addProperty("grantId", "independent-authority"); JsonObject selected = new JsonObject(); selected.addProperty("scheme", authorization); selected.addProperty("reference", "independent-authority"); c.add("authorization", selected); }
JsonObject context = new JsonObject(); for (String f : List.of("label", "authorizationId", "serviceId", "region", "pool", "registrationId")) context.addProperty(f, ""); context.addProperty("mode", recovery ? "recover" : body.get("mode").getAsString()); context.addProperty("profile", "example-profile-v0");
if (!recovery && authorization.equals("bearer-token")) { context.addProperty("authorizationId", "independent-authority"); JsonObject selected = new JsonObject(); selected.addProperty("scheme", authorization); selected.addProperty("reference", "independent-authority"); c.add("authorization", selected); }
if (!recovery && body.has("placement")) { JsonObject placement = body.getAsJsonObject("placement"); context.add("region", placement.get("region")); context.add("pool", placement.get("pool")); if (placement.has("tags")) { Map<String, String> tags = new TreeMap<>(); for (var tag : placement.getAsJsonObject("tags").entrySet()) tags.put(tag.getKey(), tag.getValue().getAsString()); context.addProperty("tagsDigest", ProviderCrypto.tagsDigest(tags)); } }
c.add("context", context); c.addProperty("contextDigest", ProviderCrypto.contextDigest(context)); JsonObject pow = new JsonObject(); pow.addProperty("algorithm", "sha256-leading-zero-bits-v0"); challengeDifficulty = recovery || authorization.equals("bearer-token") ? 0 : 2; pow.addProperty("difficulty", challengeDifficulty); c.add("pow", pow);
challenges.put(c.get("challengeId").getAsString(), c.deepCopy()); keys.put(c.get("challengeId").getAsString(), key);
Expand Down
Loading
Loading