Naming a DID that borrows a parent's authority.
A join may name the parent whose authority its DID borrows
(Layr8.Config :parent_did, or "parentDid" inside a did_spec). The node
requires such a DID to be named beneath that parent — the parent, then
exactly one further segment:
parent did:web:acme.example:users:alice
child did:web:acme.example:users:alice:k7m2q9x4h3bdand refuses a join whose DID is not, with the problem code
e.join.plugin.child.not-beneath-parent.
Why the shape is fixed rather than free
A cloud-node API key can restrict which DIDs it may bind. An entry is either
an exact DID or a literal prefix with a trailing *, so a key can admit a
whole FAMILY of DIDs only when that family is a namespace. While a borrower's
name was unrelated to its parent, no entry shorter than the borrower's whole
DID covered it — and since the name is generated per connection, that entry
cannot be written in advance. The only key that admitted a borrower was one
with no restrictions at all, which admits every DID on the node.
Named beneath its parent, the family is <parent>:*, and a key carrying the
parent plus that one namespace admits the parent and its borrowers and nothing
else.
The node is the control, not this module. A client that builds its own name reaches the same socket, so the rule is enforced at the join; deriving a conforming name here is what stops a caller having to know the rule.
The segment: random, and why not the alternatives
random_child_segment/0 returns 12 characters of Crockford base32 — 60 bits
from a cryptographic source, in an alphabet that omits i, l, o and u
so the value survives being read off a screen and typed back.
It appears in the node's audit rows, so a person reads it. Two alternatives were considered and both fail on something a reader would care about:
- A counter. There is no shared state that owns one. Two processes borrowing from the same parent would allocate the same number, and a collision here is one connection joining onto another's identity.
- A name the operator supplies. That is the thing this removes: a caller that has to hand-build a conforming DID is a caller that can get it wrong, and the resulting refusal happens at connect time in production.
Twelve characters is far more than collision needs (a single parent would need on the order of a billion simultaneous borrowers before a repeat became likely) and short enough to sit in a log line. There is no readable prefix on it: under this rule EVERY segment beneath a parent is a borrower, so a marker saying so would be true of every value it could ever have.
The value is generated once, when the configuration is resolved — not per join. A reconnect therefore returns under the same DID, which is what lets the node re-mint the same delegated credentials for it.
Summary
Types
Who chose the segment of a borrower's DID.
Functions
Crockford base32: the digits and lower-case letters, less i, l, o, u.
Is child_did named beneath parent_did — the parent, then exactly one
further non-empty segment?
The API-key entry covering every DID that may borrow parent_did's authority.
A fresh segment for a borrower's DID.
Settles the DID a join will use, and records who chose its name.
Characters in a generated segment. 12 × 5 bits = 60 bits.
Applies the rule to a did_spec that names a parent itself.
Types
@type child_name_source() :: :sdk | :client | nil
Who chose the segment of a borrower's DID.
nil is a THIRD value — "this client does not report it" — and is never
folded into :client. It travels as an ABSENT childNameSource key, not as
an empty string. A generated name and a hand-built one that conforms are
identical bytes on the socket, so without this the node's log could not say
whether a malformed borrower DID came from this library or from a caller's
typo.
Functions
Crockford base32: the digits and lower-case letters, less i, l, o, u.
Is child_did named beneath parent_did — the parent, then exactly one
further non-empty segment?
false for the parent itself, for a sibling that merely starts with the
parent's text (…:users:alicent), and for a name two segments deeper.
The API-key entry covering every DID that may borrow parent_did's authority.
Public because a key is written by hand from it, and a key written with a different pattern is one the node's rule and the key disagree about.
@spec random_child_segment() :: String.t()
A fresh segment for a borrower's DID.
Each character consumes exactly five bits of one random byte, so every character is uniformly distributed — a modulo over a 31- or 36-character alphabet would not be.
@spec resolve_borrower_did(String.t(), String.t() | nil) :: {:ok, String.t(), child_name_source()} | {:error, Layr8.Error.t()}
Settles the DID a join will use, and records who chose its name.
Three inputs, three outcomes, and the three are kept apart on the wire:
- No parent named.
didis returned unchanged and nothing is claimed about who named it. This rule is about a relationship between two names and there is only one name here. - A parent, and no DID. The caller passes nothing but the parent; a
segment is generated and the result is reported as
:sdk. - A parent and a DID. The caller named the borrower itself, and the result
is reported as
:client. A name that is not beneath the parent is an error here, rather than travelling to the node and coming back as a join refusal at connect time — the node still refuses it, for every client that is not this one.
Characters in a generated segment. 12 × 5 bits = 60 bits.
@spec settle(String.t(), map() | nil) :: {:ok, String.t(), map() | nil} | {:error, Layr8.Error.t()}
Applies the rule to a did_spec that names a parent itself.
Layr8.Config settles the primary DID from :parent_did and puts both keys
on the spec, so this is a no-op there. It is the whole rule for a did_spec
handed straight to Layr8.Client.join_did/2, where the caller named both the
DID and the parent and nothing upstream has looked at the pair.
Returns {:ok, did, did_spec} with "childNameSource" filled in, or
{:error, %Layr8.Error{}} for a DID that is not beneath the parent it names.