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.
  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 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, and the checks they must pass:


  .(., [ 
    { : 'VerifiableId', : 'jwt_vc_json' } 
  ]) 
  .(., [ 
    'signature', 
    'not-before', 
    'revoked-status-list'
  ]) 
  .(., [ 
    { : 'holder-binding' }, 
    { : 'minimum-credentials', : '1' } 
  ]) 

VC policies check each presented credential; VP policies check the presentation as a whole. The policy server's defaults are signature, not-before and revoked-status-list. 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.

Gating also works per service, via ServiceBuilder.addRequestCredentials — useful when an asset offers both an open preview and a restricted full dataset.

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:

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