Layr8.Wallet (layr8 v0.3.2)

Copy Markdown View Source

Attaching Verifiable Grants to outbound messages.

Why this exists

The cloud-node REQUIRES a VG for any message its policy does not allow outright, and until now nothing in this SDK attached one. There was no enforcement on outgoing requests — there was no mechanism. An agent that connected directly, on any protocol, sent nothing and was denied with "no grant covers this call" — a message that reads as "your grant is misconfigured" when the truth is "no credential was ever put on the wire".

That misreading is the expensive part. Two teams spent days on it: checking the grant, the Space policy, whether the PDP expanded messageTypes: ["*"]. The sender is the only party that knows it attached nothing, so the sender is the only one that can say so — see :on_grant_miss on Layr8.Client.

Cross-language contract: ~/Developments/contracts/sender-cn-vg-attachment.md. @layr8/sdk's src/wallet.ts is the same abstraction on the Node side.

The attachment shape is load-bearing

media_type is the ONLY thing the node's credential extractor filters on: it keeps attachments whose media type is exactly "application/vc+jwt" and drops every other one SILENTLY, before looking at the data at all. A Verifiable Presentation (application/vp+jwt) is discarded on that rule, and the denial that follows is byte-for-byte the one you get for attaching nothing — which is how a partner team spent a day looking at a grant that was fine.

format is NOT the filter. This module writes media_type, and Layr8.Attachment's docs used to point the other way.

data.jws is the primary place the JWS is read from, and what this SDK writes. data.base64 is NOT dropped: the extractor falls back to it and base64url-decodes it. data.jws is still the right choice — it is the field the extractor reaches for first and the one the whole ecosystem writes — but the reason is "primary path", not "the alternative is discarded".

Over-attaching is free; under-attaching is not

grant.rego allows on the FIRST passing grant and simply ignores the rest, so an extra credential on the wire costs nothing. A credential withheld costs a working call, and the failure is invisible — it presents as the same "no grant covers this call" this module exists to end.

That asymmetry decides every judgement call here. Nothing filters on the grant's credentialSubject.grant.tools allowlist: no policy reads it, helix evaluates credentialSubject.constraints.rego keyed by grant id, which this side cannot reproduce and should not try to. tools only ranks candidates when the cap bites.

Selection mirrors the policy, and deliberately errs wide

covers?/4 mirrors helix's structure_v2.rego: some scope entry must match the protocol, the message type and the resource. What this does NOT do is decide anything the PDP decides — revocation and validity windows are checked there, against sources this side cannot see. Attaching a revoked or expired grant costs one denial; withholding one because a local cache thought it was dead costs a working call, and that failure is silent.

Summary

Types

A grant this DID holds, decoded far enough to decide whether it covers a message. raw_jwt is what actually goes on the wire.

Reads the stored credential records for a holder DID.

t()

The wallet's grant cache. Immutable — every read returns a new one.

Functions

The attachments for one outbound message.

The credentials a join reply handed to did.

Forgets what was delivered to did — a join whose reply carried no reading.

The grants did holds, read from the node and cached for ttl_ms.

The most credentials put on one message.

Builds a wallet over a Layr8.REST client, or over any reader/0.

Decodes one stored credential record into a held/0, or nil when it is not a Verifiable Grant.

Drops the cached grants for did (or all of them), forcing the next send to re-read.

Records what a join reply handed to did, REPLACING anything held before.

The covering set for one outbound message, as ready-to-send attachments.

Splits a DIDComm type into {protocol, message_type}.

The tool name the policy will match, if this body carries one.

Types

held()

@type held() :: %{
  id: String.t(),
  raw_jwt: String.t(),
  scope: [map()],
  tools: [String.t()],
  expires_at: integer() | nil
}

A grant this DID holds, decoded far enough to decide whether it covers a message. raw_jwt is what actually goes on the wire.

reader()

@type reader() :: (String.t() -> {:ok, [map()]} | {:error, term()})

Reads the stored credential records for a holder DID.

A function rather than a Layr8.REST client so the cache and its failure TTL are testable without a node — and so a consumer that already holds its credentials somewhere else can say so.

t()

@type t() :: %Layr8.Wallet{
  cache: %{
    optional(String.t()) => {integer(), {:ok, [held()]} | {:error, term()}}
  },
  delivered: %{optional(String.t()) => [held()]},
  failure_ttl_ms: non_neg_integer(),
  read_timeout_ms: pos_integer(),
  reader: reader(),
  ttl_ms: non_neg_integer()
}

The wallet's grant cache. Immutable — every read returns a new one.

Functions

attachments_for(wallet, did, msg, on_capped \\ nil)

@spec attachments_for(t(), String.t(), map(), (map() -> any()) | nil) ::
  {:ok, [Layr8.Attachment.t()], t()} | {:error, term(), t()}

The attachments for one outbound message.

Returns {:ok, attachments, wallet} — where attachments may legitimately be [], since most DIDComm traffic (discovery, trust-ping, problem reports) rides the node's allow rules with no grant at all — or {:error, reason, wallet} when the grants could not be READ, which is never a normal outcome.

on_capped is called with %{covering: n, attached: m} when the cap left credentials off. Silence there is the same class of failure this module exists to end: the holder is the only party that knows a covering credential never reached the wire.

delivered_to(wallet, did)

@spec delivered_to(t(), String.t()) :: [held()]

The credentials a join reply handed to did.

forget_delivered(wallet, did)

@spec forget_delivered(t(), String.t()) :: t()

Forgets what was delivered to did — a join whose reply carried no reading.

held_by(wallet, did, now \\ nil)

@spec held_by(t(), String.t(), integer()) ::
  {:ok, [held()], t()} | {:error, term(), t()}

The grants did holds, read from the node and cached for ttl_ms.

Cached because a send should not cost a round trip.

max_attached()

The most credentials put on one message.

Over-attaching is free at the policy, but not on the wire: a holder with per-tool grants can hold dozens, each a 1-2KB JWT, on every message. The cap is far above any real holding; when it bites, the entries kept are the most likely to matter (see select_for/3) and the caller is TOLD, because a credential dropped here produces the same indistinguishable denial as one never held.

new(rest_or_reader, opts \\ [])

@spec new(
  Layr8.REST.t() | reader(),
  keyword()
) :: t()

Builds a wallet over a Layr8.REST client, or over any reader/0.

Options

  • :ttl_ms — how long a successful read is cached. Default 60_000.
  • :read_timeout_ms — deadline on one credential read. Default 2_000.
  • :failure_ttl_ms — how long a FAILED read is remembered. Defaults to min(ttl_ms, max(5_000, read_timeout_ms)).

A failure is cached, briefly. Only caching successes meant an agent whose API key cannot read credentials paid a full failing round trip on EVERY outbound message, forever — turning a config mistake into a permanent latency tax. Short, because the fix for that mistake should take effect without a restart; and never shorter than the read deadline, because the entry is stamped with the time the read STARTED, so a failure TTL at or below the deadline is already lapsed the moment a timeout records it.

parse_credential(rec)

@spec parse_credential(map()) :: held() | nil

Decodes one stored credential record into a held/0, or nil when it is not a Verifiable Grant.

refresh(wallet, did \\ nil)

@spec refresh(t(), String.t() | nil) :: t()

Drops the cached grants for did (or all of them), forcing the next send to re-read.

The cache TTL is the whole freshness story: a grant minted seconds ago is invisible until it lapses. An agent that has just been TOLD it was granted something should not have to wait out a timer it cannot see.

seed_delivered(wallet, did, credentials)

@spec seed_delivered(t(), String.t(), [Layr8.Delegated.Credential.t()]) :: t()

Records what a join reply handed to did, REPLACING anything held before.

Replacing, not merging: the node mints a fresh set on every join, and the previous set names credentials issued to a DID document that a rejoin may have replaced. Keeping both would put dead credentials on the wire and make the live one's slot under max_attached/0 a matter of ordering.

"A fresh set on every join" only holds if something calls this (or forget_delivered/2) on every join. Layr8.Client is that something, and it runs even when the reply carried no reading — which is precisely when the previous set is most likely to be wrong.

These are held APART from the read cache, for two reasons that both matter: the node stores nothing about a delegated credential, so GET /api/v1/credentials will never return one and a TTL that exists to re-read that endpoint must not expire them; and they must survive a failed read of it.

An entry that does not parse as a grant is dropped here rather than at send time, exactly as one read over REST is.

select_for(creds, msg, on_capped \\ nil)

@spec select_for([held()], map(), (map() -> any()) | nil) :: [Layr8.Attachment.t()]

The covering set for one outbound message, as ready-to-send attachments.

recipients is the message's to: the node evaluates one decision per recipient, so a credential covering ANY of them belongs on the wire.

An empty result is a legitimate outcome, not an error.

split_type_uri(type_uri)

@spec split_type_uri(String.t()) :: {String.t(), String.t()}

Splits a DIDComm type into {protocol, message_type}.

A type URI is <protocol>/<messageType> and the policy matches the two separately. Splitting on the LAST slash is what the node's own parser does.

tool_name_of(arg1)

@spec tool_name_of(term()) :: String.t() | nil

The tool name the policy will match, if this body carries one.