NPS-Release

NPS Change Request: NIP CA Registration Authority (RA) Model

CR ID: NPS-CR-0005 Target version: v1.0-alpha.6 Status: Implemented (2026-05-27) Type: Backward-compatible extension (new CA endpoints, new options, new error codes; Operator-credential registration unchanged) Author: Ori, LabAcacia Affected components: NIP spec (NPS-3) §8 / §9, spec/error-codes.md, .NET SDK (NPS.NIP.Ca), tools/nip-ca-server, conformance docs, top-level CHANGELOG / README Issue: GitHub NPS-Dev#43


1. Summary

NIP §8 today exposes one path for getting a new agent NID into the CA: POST /v1/agents/register with an Operator certificate. That works for closed deployments where one human operator mints every agent by hand, but it is the wrong shape for the three patterns that dominate real-world enrollment:

These patterns mirror the Registration Authority (RA) layer in classical X.509 PKI (RFC 5280 §3.2) — a policy-driven front door to the CA that decides who is allowed to ask before the CA decides what to mint. NIP today collapses the RA into the CA, which is fine for v0.x demos and breaks for every production-shaped deployment.

This CR introduces a three-tier RA model, configurable per CA deployment via a single NipCaOptions.EnrollmentTier selector, and the small surface of CA endpoints needed to drive it. The existing Operator-credential POST /v1/agents/register path is unchanged and remains the default; the RA tiers are alternative front doors that all eventually issue an IdentFrame through the same internal NipCaService.IssueAgentAsync core.

2. Motivation

The three patterns above are not hypothetical — they correspond to concrete deployments already on the alpha track:

Without an RA layer, every deployment has to either:

The CR codifies that wrapper service into the CA itself, with three concrete tiers chosen because they cover the three production patterns end-to-end while staying small enough to fit one alpha cycle. Heavier RA flows (full PKCS#10 CSR review, hardware-attested enrollment, cross-CA delegation through TrustFrame) are explicitly out of scope (§7).

3. Specification changes

3.1 RA tier model overview

A CA deployment selects exactly one active RA tier via NipCaOptions.EnrollmentTier (§3.5). The Operator-credential path on POST /v1/agents/register is always accepted regardless of tier — RA tiers are additional front doors, not replacements. Tiers gate which authentication credential the registration request may present instead of an Operator certificate:

Tier Selector What replaces the Operator credential Operator-in-the-loop?
Tier 1 — Allowlist allowlist The requested NID matches a configured pattern. No additional credential is needed; the request is unauthenticated beyond the NID match. No (static config)
Tier 2 — Bootstrap token bootstrap_token A one-time, NID-scoped token presented in Authorization: Bearer nps-bootstrap-<token>. At token mint only
Tier 3 — Pending queue pending_queue None — the request enters a queue. An operator approves or rejects asynchronously. At approval time
(default) operator_only n/a — only the existing Operator-credential path is open. Yes, every request

EnrollmentTier = operator_only is the default and is exactly the v0.x behaviour. The three new tiers are opt-in and MUST be configured explicitly.

3.2 Tier 1 — Allowlist

Tier 1 admits registration requests whose requested nid matches at least one entry in NipCaOptions.EnrollmentAllowlistPatterns. Patterns use the same glob shape as NIP §3 NIDs with a * wildcard in the identifier segment and (optionally) in the issuer-domain segment:

urn:nps:agent:internal.npscloud.dev:*
urn:nps:agent:*.staging.npscloud.dev:runner-*

When EnrollmentTier = allowlist, the CA:

  1. Reads the nid from the register request body.
  2. Compares it against every entry in EnrollmentAllowlistPatterns. The match is a literal prefix-glob: each * consumes one or more identifier characters from the corresponding NID segment but MUST NOT cross a : boundary.
  3. If no pattern matches, returns NIP-RA-NID-NOT-ALLOWED.
  4. If a pattern matches, proceeds with the same IssueAgentAsync core path as the Operator-credential branch — same IdentFrame shape, same default validity, same audit log entry with enrollment_source = "ra-allowlist".

Tier 1 carries no per-request operator action and no expiry beyond the configured pattern set. Deployments using Tier 1 SHOULD set EnrollmentAllowlistPatterns to the tightest shape that still covers the fleet — a pattern of urn:nps:agent:*:* is operationally equivalent to disabling NID-side admission and is rejected at startup by NipCaService.ValidateOptionsAsync().

3.3 Tier 2 — Bootstrap token

Tier 2 introduces a small bootstrap-token store at the CA. An operator pre-mints a token bound to a specific NID; the agent presents the token on register instead of an Operator credential.

Token minting is an operator-authed endpoint:

POST /v1/enrollment/tokens
Authorization: Bearer <operator-api-key>
Content-Type: application/json

Request body:

{
  "nid":            "urn:nps:agent:ca.example.com:runner-42",   // required, target NID
  "ttl_seconds":    900,                                          // optional, default 900, clamped to [60, BootstrapTokenMaxTtl]
  "capabilities":   ["nwp:read"],                                 // optional, becomes the issued IdentFrame's capability set
  "scope":          "https://api.example.com/products/*",         // optional, becomes the IdentFrame's scope binding
  "metadata":       { "issued_for": "runner pod abc123" }         // optional, audit-only — NOT included in the IdentFrame
}

Response (HTTP 201):

{
  "token":        "nps-bootstrap-7f3c9e1ab2d8...",   // single-use opaque secret (≥256 bits of entropy)
  "token_id":     "tok-1714672800-f3a92c0b",          // stable ID for revocation / audit
  "nid":          "urn:nps:agent:ca.example.com:runner-42",
  "expires_at":   1714673700                          // unix seconds
}

Token use — the agent calls the existing POST /v1/agents/register endpoint with the token in Authorization:

POST /v1/agents/register
Authorization: Bearer nps-bootstrap-7f3c9e1ab2d8...
Content-Type: application/json

The CA, when EnrollmentTier = bootstrap_token:

  1. Parses Authorization. If it lacks the nps-bootstrap- prefix, falls back to the Operator-credential path (an Operator key is always permitted).
  2. Strips the prefix and looks the token up in the bootstrap-token store. Returns NIP-RA-TOKEN-INVALID if not found or already used.
  3. Returns NIP-RA-TOKEN-EXPIRED if now > expires_at.
  4. Returns NIP-RA-NID-NOT-ALLOWED if the registration request’s nid ≠ the token’s bound NID. (Receivers MUST treat NID mismatch as a credential-binding failure, not a not-found.)
  5. Marks the token used (single-use; second presentation returns NIP-RA-TOKEN-INVALID).
  6. Issues the IdentFrame with the capabilities / scope captured at mint time, audit log entry enrollment_source = "ra-bootstrap-token", enrollment_token_id populated.

Token revocation is implicit — tokens are single-use and time-bound. Explicit revocation can be done out-of-band by deleting the row; a dedicated DELETE /v1/enrollment/tokens/{id} endpoint is reserved for a future CR (§7).

Storage requirements: bootstrap tokens are bearer secrets and MUST be stored hashed (Argon2id or SHA-256 with at least 256 bits of token entropy) — never as plaintext. The mint-time response is the only time the plaintext token is observable. This mirrors the standard handling for API keys.

3.4 Tier 3 — Pending queue and admin approval

Tier 3 turns the registration request into an asynchronous workflow:

  1. The agent calls POST /v1/agents/register with no RA credential (or with a credential the deployment doesn’t recognise as bootstrap). The CA responds HTTP 202 Accepted with a body of:

    {
      "status":     "pending",
      "pending_id": "pen-1714672800-f3a92c0b",
      "submitted_at": 1714672800,
      "poll_url":   "/v1/enrollment/pending/pen-1714672800-f3a92c0b"
    }
    

    The agent records the pending_id and polls (or, in a future CR, subscribes via a streaming channel — out of scope here).

  2. Operators list pending requests:

    GET /v1/enrollment/pending
    Authorization: Bearer <operator-api-key>
    

    Response:

    {
      "items": [
        {
          "pending_id":   "pen-1714672800-f3a92c0b",
          "nid":          "urn:nps:agent:ca.example.com:third-party-tool-7",
          "submitted_at": 1714672800,
          "request": {
            "public_key":   "ed25519:MCowBQYDK...",
            "capabilities": ["nwp:read"],
            "scope":        "https://api.example.com/products/*",
            "metadata":     { "contact": "alice@partner.example" }
          }
        }
      ]
    }
    
  3. An operator approves a pending request:

    POST /v1/enrollment/pending/{id}/approve
    Authorization: Bearer <operator-api-key>
    Content-Type: application/json
    

    Optional request body to refine the issued IdentFrame at approval time:

    {
      "capabilities": ["nwp:read"],     // optional override; MUST be subset of the request's
      "scope":        "https://api.example.com/products/{id}/*",  // optional override
      "validity_days": 30               // optional; defaults to AgentCertValidityDays
    }
    

    Response: the issued IdentFrame, identical in shape to a synchronous register response. The pending row is deleted (or marked terminal). Audit log entry carries enrollment_source = "ra-pending-approved", approver_operator_id = "<key id>".

  4. An operator rejects a pending request:

    POST /v1/enrollment/pending/{id}/reject
    Authorization: Bearer <operator-api-key>
    Content-Type: application/json
    

    Optional request body:

    {
      "reason":  "third-party tool not in approved-integrations list",
      "code":    "POLICY"          // optional short tag for audit grouping
    }
    

    Response: HTTP 200 with the rejection record. Subsequent polls of /v1/enrollment/pending/{id} return HTTP 410 Gone with error code NIP-RA-PENDING-REJECTED and the operator-supplied reason in the error body, so the requesting agent learns why without needing a separate channel.

Bounds: the pending queue MUST be size-bounded (default 1000 entries per NipCaOptions.PendingQueueMaxSize). When full, new requests are rejected synchronously with NPS-SERVER-OVERLOADED. Stale pending entries older than PendingQueueMaxAge (default 14 days) are garbage-collected by a background sweep; sweeping emits NIP-RA-PENDING-REJECTED with reason = "queue garbage collection — entry expired" so a polling agent sees a clean terminal state.

3.5 New endpoints in spec/NPS-3-NIP.md §8

The following rows are added to the NIP §8 CA Server API table:

Method Path Auth Description
POST /v1/enrollment/tokens Operator Cert (NPS-CR-0005) Mint a single-use bootstrap token bound to a specific NID. Body fields per §3.3.
GET /v1/enrollment/pending Operator Cert (NPS-CR-0005) List pending registration requests awaiting admin decision. Tier 3 only.
POST /v1/enrollment/pending/{id}/approve Operator Cert (NPS-CR-0005) Approve a pending request and issue the IdentFrame. Body fields per §3.4.
POST /v1/enrollment/pending/{id}/reject Operator Cert (NPS-CR-0005) Reject a pending request with an operator-supplied reason.

Discovery (/.well-known/nps-ca) gains a capabilities entry "ra-tier-<selector>" (e.g. "ra-tier-bootstrap-token") so clients can detect which RA tier the deployment runs without trial registration.

3.6 New NipCaOptions fields

Add to impl/dotnet/src/NPS.NIP/Ca/NipCaOptions.cs:

/// <summary>
/// Active Registration Authority (RA) tier. Determines which front-door
/// admission flow the CA accepts in addition to the Operator-credential
/// path on POST /v1/agents/register (NPS-CR-0005). Default: OperatorOnly.
/// </summary>
public EnrollmentTier EnrollmentTier { get; set; } = EnrollmentTier.OperatorOnly;

/// <summary>
/// Tier 1 — NID-pattern allowlist. Requests whose nid matches at least one
/// pattern are admitted without an Operator credential. Required when
/// EnrollmentTier = Allowlist; rejected at startup if it would admit any
/// NID (e.g. "urn:nps:agent:*:*"). Patterns use the glob syntax in
/// NPS-CR-0005 §3.2.
/// </summary>
public IReadOnlyList<string> EnrollmentAllowlistPatterns { get; set; } = [];

/// <summary>
/// Tier 2 — max permitted bootstrap-token TTL. Mint requests above this
/// are rejected with NPS-CLIENT-BAD-PARAM. Default 24 hours.
/// </summary>
public TimeSpan BootstrapTokenMaxTtl { get; set; } = TimeSpan.FromHours(24);

/// <summary>
/// Tier 3 — max number of unresolved pending registration requests the
/// CA will hold. Requests over this limit are rejected synchronously with
/// NPS-SERVER-OVERLOADED. Default 1000.
/// </summary>
public int PendingQueueMaxSize { get; set; } = 1000;

/// <summary>
/// Tier 3 — max age of a pending entry before background sweep collects
/// it and emits a synthetic rejection. Default 14 days.
/// </summary>
public TimeSpan PendingQueueMaxAge { get; set; } = TimeSpan.FromDays(14);

Plus the enum:

public enum EnrollmentTier
{
    OperatorOnly = 0,
    Allowlist,
    BootstrapToken,
    PendingQueue
}

EnrollmentTier is a single selector by deliberate choice. Stacking tiers (e.g. allowlist-then-bootstrap) is doable but creates compounding ambiguity over which tier admitted a given request and is left to a future CR if real deployments need it (§7).

3.7 New error codes (NPS-3 §9 / spec/error-codes.md)

Error Code NPS Status Description
NIP-RA-TOKEN-INVALID NPS-AUTH-UNAUTHENTICATED Bootstrap token does not exist, has already been used, or its hash does not match.
NIP-RA-TOKEN-EXPIRED NPS-AUTH-UNAUTHENTICATED Bootstrap token’s expires_at is in the past.
NIP-RA-NID-NOT-ALLOWED NPS-AUTH-FORBIDDEN Requested NID does not match the active tier’s policy: no allowlist pattern matches (Tier 1) or the bootstrap token’s bound NID differs (Tier 2).
NIP-RA-PENDING-REJECTED NPS-AUTH-FORBIDDEN The pending registration request was rejected by an operator (or garbage-collected after PendingQueueMaxAge). The rejection reason is in the error response body.

All four are reported through the standard NIP error envelope used by the §8 CA endpoints.

4. SDK changes

SDK Phase 1 (this CR) Notes
.NET (NPS.NIP.Ca) Required Reference impl: new IEnrollmentPolicy strategy, three concrete policies (AllowlistPolicy / BootstrapTokenPolicy / PendingQueuePolicy), INipBootstrapTokenStore + InMemory / SQLite / PostgreSQL impls, INipPendingStore + same backends, four new HTTP route handlers, and a startup validator on NipCaOptions that rejects misconfiguration (Tier 1 with empty/overbroad patterns; Tier 2 with BootstrapTokenMaxTtl > 7 days as a sanity ceiling; Tier 3 with PendingQueueMaxSize ≤ 0).
Python / TypeScript / Java / Rust / Go Deferred Tracked as follow-up tickets following the parity model used for NPS-RFC-0002 / RFC-0003 / RFC-0004 / NPS-CR-0003. The four error codes ride into the existing per-SDK error tables on next parity sweep; no client-side helper is needed because RA tiers are server-side admission policy, not over-the-wire frame changes.

5. Conformance changes

This CR does not add Node-side conformance items — RA is purely a CA-server concern and the Node continues to verify IdentFrames the same way regardless of which RA tier minted them. Audit-log fields (enrollment_source, enrollment_token_id, approver_operator_id) are CA-internal and not on the wire.

CA-side correctness is enforced by the unit test suite added under impl/dotnet/tests/NPS.Tests/Nip/Ra/:

6. Migration impact

Audience Impact
Existing deployments on EnrollmentTier = OperatorOnly None. Default behaviour is unchanged; register continues to require an Operator credential.
Deployments choosing to switch tier Must restart the CA after setting EnrollmentTier and the tier-specific options. Operator-credential register continues to work in parallel.
Existing tests / fixtures None affected. New tests are additive.
Audit-log consumers Three new fields appear on registration audit-log entries when an RA tier was used (enrollment_source, enrollment_token_id, approver_operator_id). Consumers that pin a strict schema should be updated; consumers that pass through unknown fields are unaffected.

CA Server operators upgrading from alpha.5 → alpha.6 do NOT need a forced data migration — the bootstrap-token and pending-queue tables are created lazily on first use of their respective tiers. Operators staying on OperatorOnly see no schema delta.

7. Out of scope (explicit non-changes)

8. Acceptance criteria

This CR is considered accepted and ready to merge when:

9. CHANGELOG entry (proposed text, EN)

- **NPS-CR-0005**: NIP CA Registration Authority (RA) model. Adds a
  three-tier admission front door to the NIP CA Server — Tier 1
  NID-pattern allowlist, Tier 2 one-time NID-scoped bootstrap token,
  Tier 3 pending queue with admin approve / reject — selected via
  `NipCaOptions.EnrollmentTier`. Four new endpoints under
  `/v1/enrollment/...` and four new error codes
  (`NIP-RA-TOKEN-INVALID`, `NIP-RA-TOKEN-EXPIRED`,
  `NIP-RA-NID-NOT-ALLOWED`, `NIP-RA-PENDING-REJECTED`). The existing
  Operator-credential `POST /v1/agents/register` path is unchanged and
  remains the default. Reference impl: .NET (`NPS.NIP.Ca`); other SDKs
  deferred.

10. Open questions

11. References