Layr8.ChildDid (layr8 v0.3.2)

Copy Markdown View Source

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:k7m2q9x4h3bd

and 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

child_name_source()

@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

alphabet()

Crockford base32: the digits and lower-case letters, less i, l, o, u.

beneath_parent?(child_did, parent_did)

@spec beneath_parent?(String.t(), String.t()) :: boolean()

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.

did_namespace_of(parent_did)

@spec did_namespace_of(String.t()) :: String.t()

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.

random_child_segment()

@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.

resolve_borrower_did(did, parent_did)

@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. did is 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.

segment_length()

Characters in a generated segment. 12 × 5 bits = 60 bits.

settle(did, did_spec)

@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.