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
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.
The three patterns above are not hypothetical — they correspond to concrete deployments already on the alpha track:
tools/daemons/npsd enrolls itself in NPS Cloud staging: NIDs are generated by Terraform with a known urn:nps:agent:internal.npscloud.dev:* shape and there is no operator-in-the-loop step.tools/daemons/nps-runner FaaS runtime needs for v1.0-alpha.6 — each runner pod gets a single-use token at scheduler-spawn time, registers on cold-start, and discards the token.compat/mcp-ingress integration wants for external MCP-server identities — a human approves the integration once, the CA mints the NID asynchronously.Without an RA layer, every deployment has to either:
register with a held-secret Operator key.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).
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.
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:
nid from the register request body.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.NIP-RA-NID-NOT-ALLOWED.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().
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:
Authorization. If it lacks the nps-bootstrap- prefix, falls back to the Operator-credential path (an Operator key is always permitted).NIP-RA-TOKEN-INVALID if not found or already used.NIP-RA-TOKEN-EXPIRED if now > expires_at.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.)NIP-RA-TOKEN-INVALID).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.
Tier 3 turns the registration request into an asynchronous workflow:
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).
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" }
}
}
]
}
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>".
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.
spec/NPS-3-NIP.md §8The 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.
NipCaOptions fieldsAdd 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).
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.
| 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. |
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/:
AllowlistPolicyTests.MatchingNid_IsAdmittedAllowlistPolicyTests.NonMatchingNid_RejectedWith_NipRaNidNotAllowedAllowlistPolicyTests.OverbroadPattern_FailsStartupValidationBootstrapTokenPolicyTests.Mint_ThenUse_IssuesIdentFrameBootstrapTokenPolicyTests.SecondPresentation_RejectedWith_TokenInvalidBootstrapTokenPolicyTests.ExpiredToken_RejectedWith_TokenExpiredBootstrapTokenPolicyTests.NidMismatch_RejectedWith_NidNotAllowedBootstrapTokenPolicyTests.PlaintextNotStoredPendingQueuePolicyTests.Register_ReturnsAccepted_WithPendingIdPendingQueuePolicyTests.Approve_IssuesIdentFrame_AndEmptiesPendingPendingQueuePolicyTests.Reject_PollReturns_PendingRejectedPendingQueuePolicyTests.Sweep_ExpiresStaleEntries_With_SyntheticRejectPendingQueuePolicyTests.OverflowsQueue_RejectedWith_ServerOverloaded| 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.
EnrollmentTier selector is single-value by design. A multi-tier policy chain is a clean follow-up if a deployment justifies it.nid + public_key shape inherited from /v1/agents/register. Full CSR review (subject alt names, key usage hints, extension requests) is a heavier RFC-track change./v1/enrollment/pending/{id} today. A push channel (server-sent events or NOP-style stream) is a future enhancement.DELETE /v1/enrollment/tokens/{id}. Tokens are single-use and short-TTL; explicit revocation is not motivated by any alpha.6 use case. The endpoint shape is reserved./v1/agents/register route. This CR only adds admission paths; the existing route, request shape, and Operator-credential semantics are unchanged..cn.md) of §8.x stub. The §8.x section added by this CR is a forward-reference stub naming the future endpoint set; full bilingual edit lands when the spec-section body is written under the same milestone.This CR is considered accepted and ready to merge when:
spec/cr/NPS-CR-0005-nip-ca-ra-model.md) is committed under dev.spec/NPS-3-NIP.md §8 carries a stub §8.x sub-section referencing NPS-CR-0005 and listing the four new endpoints by path.spec/cr/README.md index gains a row for CR-0005 with status Draft.NipCaOptions.EnrollmentTier + supporting fields land in impl/dotnet/src/NPS.NIP/Ca/NipCaOptions.cs with the documented defaults — Task 2.IEnrollmentPolicy strategy + three concrete policies wired into NipCaService — Task 3.INipBootstrapTokenStore and INipPendingStore plus InMemory/SQLite/PostgreSQL backends — Task 4.NipCaRouter and reflected in the well-known capabilities array — Task 5.spec/error-codes.md and spec/NPS-3-NIP.md §9 carry the four new error codes — Task 6.- **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.
audience claim (e.g. restrict use to a specific source IP / source ASN)? The current draft keeps the token opaque and NID-scoped only; audience-binding would mirror OAuth 2.0 aud and is implementable additively in a follow-up CR.public_key. If the agent reboots between submit and approval, it may want to rotate the key. Should approval accept a new key in its request body, or strictly bind to the originally-submitted one? Draft answer: strict bind, to prevent a key-substitution attack via a leaked pending_id. Worth a second opinion.enrollment_source = "ra-pending-approved" audit entries also embed the pending_id to make join-back to the original request trivial? Likely yes; this CR’s draft mentions approver_operator_id only. Tighten when implementing.*. Should * be allowed to consume zero characters (i.e. a prefix-only pattern), or is requiring at least one character preferred (more conservative)? Draft answer: at-least-one, matching POSIX shell glob; revisit if any real fleet shape needs zero-match./v1/<resource>/... REST shape adopted hereagent-01 runs under NPS-RFC-0002 for external-domain proof; RA Tier 2 runs for closed-deployment internal enrollment)