Skip to content
LogoLogo

Credential-gated assets

Some assets require you to prove something about yourself before you can access them — that you hold a particular verifiable credential, issued by someone the publisher trusts. This guide covers both sides: consuming a gated asset, and publishing one.

This is new in nautilus v2. It did not exist in v1, because the stack it depends on did not.

How the pieces fit

Four components are involved, and they form a deliberate loop so that the node observes every credential exchange:

Loading diagram...

The loop is deliberate: every credential exchange passes through the node, so it observes the result rather than taking the client's word for it.

nautilus only ever talks to two of them directly: ocean-node, through its initializePSVerification and policy-server passthrough endpoints, and the walt.id wallet, to actually present credentials. Everything else happens between the services.

What nautilus needs out of the exchange is one thing: a policy-server session id. On a node with a policy server, ocean-node checks it on every download and compute job of an asset that has credentials, whether the asset is gated by addresses or by an SSIpolicy.

Consuming a gated asset

nautilus opens the policy-server session itself, inside access, compute and freeCompute. Nothing changes at the call site.

Gated by addresses only, an asset needs no wallet and no configuration: the session the policy server opens is all the node checks.

const  = await .(, {  })
 
// The session is opened before the order, and carried into the download URL.
const {  } = await .({ : 'did:ope:1234abcd...' })

Gated by an SSIpolicy, an asset asks for a verifiable presentation. Configure a credential provider once; it answers the presentation request from your wallet.

import { ,  } from 'ethers'
import { ,  } from '@deltadao/nautilus'
 
const  = new ('0x...', new ('https://rpc.dev.pontus-x.eu'))
const  = { : 'https://node.example.org' }
 
// The provider needs a node client, so build the instance in two steps.
const  = await .(, {  })
 
const  = await .(, {
  ,
  : new (.(), { 
    : 'https://wallet.example.org'
  }) 
})
 
// Unchanged — the credential exchange happens inside.
const {  } = await .({ : 'did:ope:1234abcd...' })

Without a provider, an asset that asks for a presentation is refused with a clear error before anything is signed or ordered.

What happens, in order

Loading diagram...
  1. nautilus reads isPSConfigured from the node's status. A node without a policy server checks credentials itself, and needs no session: nautilus opens none. Upstream ocean-node 4.2.0 does not report isPSConfigured; there nautilus learns it from initiate, which such a node answers with a 404 and no body.
  2. If neither the asset nor the service has credentials ({} counts as having them, as the node checks it), the node checks nothing, and no session is opened either.
  3. Otherwise nautilus starts a verification. The policy server checks your address against the asset's allow list and opens a session bound to (consumer, asset, service), or refuses with a PolicyDeniedError that carries its reason.
  4. If the asset or service carries an SSIpolicy that asks for credentials, the credential provider answers the openid4vp request from your wallet, and nautilus checks with the policy server (checkSessionId) that the verifier accepted it. A presentation that was not accepted is a PolicyDeniedError naming the failed policies. When the wallet itself reports that the verifier refused it (walt.id answers a refused presentation with an error), nautilus still asks the verifier's record which policies failed, and names them the same way.
  5. The session id is carried into the download URL, or into initializeCompute and computeStart: one session per compute input, the algorithm included.

Handling a refusal

try {
  await .({ : 'did:ope:1234abcd...' })
} catch () {
  if ( instanceof ) 
    .(., .) 
403 'Access denied: Address not allowed at asset level.'
  else throw 
}

code is the policy server's status for the refusal, and undefined when the verifier did not accept a presentation; reason then names the failed policies (revoked-status-list: credential is revoked), and policyResults lists each policy's name, outcome and error, never the presentation itself. Only the policy server's own refusal is a PolicyDeniedError. Everything else is an OceanNodeError with the status: the node rejecting the nonce or signature (401), not having indexed the asset (404), not reaching its policy server (400), a network error, a timeout or a 5xx.

If the wallet holds nothing that matches, the provider's error carries the credentials you are missing, rather than just reporting no match.

What a rejection looks like

When the verifier refuses a presentation, the refusal names the policies that failed, as the node reports them (the node relays the verifier's record, and for a download it is the publisher's choice of node):

try {
  await .({ : 'did:ope:1234abcd...' })
} catch () {
  if (!( instanceof )) throw 
 
  .(.) 
'the node reports that the verifier did not accept the presentation (holder-binding: java.lang.NullPointerException)'
  for (const { , , , :  } of . ?? []) 
    if (!) .(, , ) 
VerifiablePresentation holder-binding java.lang.NullPointerException
}
  • reason lists what fits of the failed policies (policy: error, each cut at 80 characters) and counts the rest (; 2 more in policyResults); it always ends in ). policyResults has all of them, at most 50, failures first, each name and error at most 100 characters and stripped of control characters.
  • cause is the wallet's error when the wallet reported the failure: a CredentialPresentationError with reason: 'verifier-rejected' and details.errorMessage (walt.id's message, e.g. Presentation failed - redirecting to error page).
  • When the wallet failed before the verifier, nautilus does not ask the verifier: the wallet's own error is thrown, a CredentialPresentationError whose reason says which step failed (no-matching-credential, none-selected, wallet-setup, request-invalid or wallet-failed). So is a verifier-rejected one whose session record names no failed policy (the session expired, for example) or cannot be read within 15 seconds.
  • Neither error carries the policy-server session id or the wallet's redirect URI: the session id is a credential (see PolicySessionResolver), and the wallet's answer to a refused presentation is the verifier's error page with the session id in it. The redirect URI is dropped, and the session id is redacted wherever the wallet's text quotes it (walt.id does, for an expired session). A URL quoted in the text keeps its origin (scheme, host and port), so the message can still name the verifier's host. To see the wallet's whole answer and the verifier's full record, turn on NAUTILUS_DEBUG=sensitive with NAUTILUS_DEBUG_TOPICS=policy,wallet (debugging), on your machine only.

What the wallet trusts

For a download, the presentation request comes from the node in the service's serviceEndpoint, which the publisher chose. So WaltIdCredentialProvider checks it before the wallet sees it:

  • the request must be an openid4vp:// URL, and its request_uri, response_uri and presentation_definition_uri must be https://, or http:// on a loopback host (allowInsecureTransport: true accepts plain http:// elsewhere, e.g. a private network). Other schemes, link-local hosts and cloud metadata addresses are always refused;
  • every input descriptor of the presentation definition must ask for a credential type the asset's or service's request_credentials name, and only wallet credentials of those types are presented, whatever the wallet matched or onSelectCredentials returned;
  • the wallet signer must be the consumer: a provider built with another signer refuses to present its credentials for a session bound to your address.

Choosing credentials and DIDs

By default the provider is headless: it presents every matching credential and uses the wallet's first DID. That is what makes it usable from a script or CI with no interaction.

For an interactive application, supply the selection callbacks instead:

const  = new (.(), {
  : 'https://wallet.example.org',
 
  // Ask the user which of the matching credentials to present.
  : async () => { 
    return .(() => . === 'a-chosen-credential') 
  }, 
 
  // Ask the user which holder DID to present as.
  : async () => [0]. 
})

You can also pin a wallet and DID outright with walletId and did.

Sessions

nautilus caches each verified session per (node, asset, service, consumer): the policy server binds a session id to sha256(consumerAddress:documentId:serviceId) and the node that opened it, so a session for another account or node would be refused. Pass sessionStore to Nautilus.create to keep address-only sessions somewhere else than in memory.

A cached session is reused for at most sessionTtlMs (2 minutes by default) from when it was opened, and one that needed a presentation is checked with the policy server again before it is reused: walt.id's verifier forgets a session after 5 minutes, or when it restarts. A stale session is opened again before anything is ordered, so a store that outlives the policy server costs one more exchange, not a refused order.

Publishing a gated asset

Declare which credentials you require, who must have issued them, and the checks they must pass. This is the set to start from: it works through nautilus, ocean-node, the policy server and the walt.id verifier as they are deployed today.


  // The gate that holds: list addresses explicitly, never '*' (see the limitation below).
  .(., ['0x0DB823218e337a...'])
  .(., [ 
    { 
      : 'gx:LegalPerson', 
      : 'jwt_vc_json', 
      : [ 
        // Issuer trust, per credential: only credentials from these issuers count.
        { : 'allowed-issuer', : ['did:web:delta-dao.com'] } 
      ] 
    } 
  ]) 
  .(., [ 
    'signature', 
    'not-before', 
    'revoked-status-list'
  ]) 
  .(., [ 
    { : 'signature' }, 
    { : 'minimum-credentials', : '1' } 
  ]) 

VC policies check each presented credential; VP policies check the presentation as a whole. The policy server puts its own DEFAULT_VC_POLICIES and DEFAULT_VP_POLICIES in front of the asset's lists. Its operator sets them: the local dev stack uses signature,not-before,revoked-status-list, the policy server's shipped .env.example lists expired,signature,revoked-status-list,not-before, and the values of a deployed policy server are unknown. So write every policy you rely on into the asset. Valid names can be listed from the verifier's /openid4vc/policy-list endpoint.

VC policies are names, while every VP policy is an object, { policy } or { policy, args } with args as a string, as the enterprise market writes them: an asset whose list mixed names and objects was published but not indexed by ocean-node 4.2.x. The policy server parses args as JSON when it can, so '1' reaches the verifier as the number 1; pass structured arguments through JSON.stringify. minimum-credentials needs its argument: walt.id reads it as a number, and a bare { policy: 'minimum-credentials' } fails in the verifier for every presentation.

Check the presentation's own signature with { policy: 'signature' } among the VP policies. walt.id runs only the VP policies the session lists, so without it nothing checks that the presentation was signed by the DID it names, unless the policy server's operator adds it through DEFAULT_VP_POLICIES, which nautilus cannot see. Listed on the asset, the check holds whatever that setting is; when the policy server adds it too, it simply runs twice. It does not bind the credential to the presenter, which is holder-binding's job, and it does not stop a presentation replayed from an earlier session: the verifier accepts it.

Put policies that take arguments on the credential. The policy server keeps only the name of an asset- or service-level VC policy and drops its arguments. allowed-issuer without its list of issuers throws "Invalid argument" in walt.id, which fails the presentation and so denies every consumer. Per-credential policies (the policies of a request_credentials entry) and VP policies keep their arguments, so issuer trust and other argument-bearing checks such as walt.id's schema go on the credential, as above.

Known limitation: credentials of another type

walt.id applies per-credential policies by the type of the presented credential, matched on the last entry of its type array against request_credentials. A presented credential of any other type runs only the asset-level VC policies, and minimum-credentials counts it. So a presentation holding a self-issued credential of another type passes a gate whose issuer trust is per credential: any credential that passes signature will do.

Put plainly: until the policy server enforces the requested credential type, the credential check stops only consumers who use a normal wallet, which presents a credential of the requested type or nothing. Anyone who builds their own presentation passes it with a self-issued credential of another type. nautilus cannot close that on its own.

What holds is the address allow list: the consumer signs the download with their Ethereum key, and the policy server checks that address independently of the credential.

Address allow listAgainst a consumer with a normal walletAgainst a consumer who builds their own presentation
Explicit addressesAddress and credential requiredAddress only
Contains '*'Credential requiredNo gate at all

So list the allowed addresses explicitly, and never combine a credential gate with '*' in the allow list: the policy server lets every address through it, which leaves no gate at all. Treat issuer trust and attribute checks as a filter for honest consumers, not as the only protection.

A normal wallet is no guarantee either: in this release, the credential type alone is not a reliable gate. Trust is enforced by the issuer check together with the address allow list, so use both. See known limitations.

Gating also works per service, via ServiceBuilder.addRequestCredentials — useful when an asset offers both a preview that needs no credential (allow-listed accounts only) and a full service that also requires a credential. Two limits apply today:

  • ServiceBuilder.addRequestCredentials takes no VC or VP policies, so a service-level gate runs only with the policy server's DEFAULT_VC_POLICIES and DEFAULT_VP_POLICIES, which its operator sets and whose deployed values are unknown. The presentation's own signature, for one, may not be checked. Per-service VC and VP policies are a planned SDK change.
  • Don't add setVcPolicies or setVpPolicies to such an asset. They write an asset-level SSIpolicy without request_credentials, and the policy server then refuses every service that needs no credential ("SSIpolicy was found, but failed to fetch request credentials").

Issuing from a real DID

By default nautilus signs the DDO with your Ethereum key, using a non-standard alg: 'ETH-EIP191' header, and sets the issuer to your address. That works without any SSI setup, but the credential is not verifiable by a standard JOSE verifier.

To issue from a proper DID, sign with a walt.id wallet key:

import { ,  } from 'ethers'
import {
  ,
  ,
  ,
  
} from '@deltadao/nautilus'
 
const  = new ('0x...', new ('https://rpc.dev.pontus-x.eu'))
const  = { : 'https://node.example.org' }
 
const  = new ({ : 'https://wallet.example.org' })
const  = await .()
const [] = await .(.)
const [] = await .(., .)
const [] = await .(., .)
 
const  = await .(, {
  ,
  : new ({ : 'http://127.0.0.1:5001/api/v0/add' }),
  : new ({ 
    , 
    : ., 
    : .., 
    : ., 
    : . 
  }) 
})

Things to know about deployments

Credentials are checked by the policy server only on a node that has one. Without a policy server (POLICY_SERVER_URL unset), ocean-node runs its own check of address and access lists. An SSIpolicy entry is a type it does not know: under the default match_allow: 'all' it denies everyone, and under match_allow: 'any' it is ignored when another entry matches, so the credentials it asks for are not checked. In a deny list it is ignored. Check how the node is configured before relying on an SSIpolicy.

Address lists are written as { address }. That is the form the enterprise market writes and reads; an edit leaves an address entry it does not change as stored. OceanProtocolEnterprise ocean-node and the policy server read bare strings and objects alike. Upstream ocean-node 4.2.0 without a policy server reads only bare strings, and fails a request on { address } with an error.

An empty POLICY_SERVER_URL turns every check off. With POLICY_SERVER_URL= (as OceanProtocolEnterprise ocean-node's .env.node.example ships it), ocean-node has no policy server to ask and grants every request; the enterprise build still reports isPSConfigured: true. nautilus sees the node answer initiate as one without a policy server, opens no session, and warns once.

On a node with a policy server, an asset needs an address allow list. The policy server checks the consumer's address against the asset-level allow list before anything else, and refuses an address that is not on it. An asset with credentials: {}, or an allow list without an address entry, is refused to every consumer. publish() and edit() log a warning for such an asset when the node has a policy server. Add addresses with addCredentialAddresses.

The policy server checks consume time. It checks initiate, download and startCompute; its publish-time actions (newDDO, updateDDO, validateDDO, encrypt, decrypt) allow every request.

Swapping out walt.id

The walt.id endpoints nautilus uses (/wallet-api/*, /openid4vc/*) are the v1 generation, which walt.id has marked for deprecation in favour of *-api2 (OID4VCI/VP 1.0).

nautilus keeps them behind a narrow eleven-method WaltIdWallet interface for exactly this reason: a v2 backend is a new implementation of that interface, not a rewrite. Pass your own with the wallet option.

Try it

The examples cover both halves of the exchange. Set SSI_WALLET_API in .env first, and SSI_TRUSTED_ISSUERS (the issuer DIDs your gates accept) to publish a gate:

npm start -- ssi:connect                 # authenticate, list your wallets, keys and DIDs
npm start -- ssi:round-trip              # publish a gated dataset and consume it, end to end
npm start -- ssi:consume did:ope:…       # consume someone else's gated asset