本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント・一次情報と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

Ocean Node に Compute-to-Data の実行環境を建て、アクセス制御が効くことを確認するでは、誰がジョブを実行できるかを許可リストで制御できることを確かめました。自前の Ocean Node に繋いだら、ポータルが動かなかったでは、その上で publish からダウンロード、Compute-to-Data までを画面から一周させました。

そこまでやって、ひとつ気になったことがあります。認可の根拠が、最後まで「何を持っているか」だったことです。

Ocean の認可には、データトークンを買う経路と、資産の記述 (DDO) の credentials で絞る経路があります。後者はさらに 2 種類あり、素のアドレスの許可・拒否リスト(type: address)と、アクセス権を表す ERC-721 (Ethereum Request for Comments 721、代替不可能なトークンの標準。ここでは AccessList) を持っているか(type: accessList)です。いずれも、その人が誰であるかは問いません。トークンは譲渡できますし、リストへの登録は運用者の裁量です。商用のライセンスを表現するには十分ですが、「この機関は、この条件のもとで、この資料を扱う資格がある」という種類の話は表現できません。文化資源や機微情報を扱う場合は、この層を別に用意することになります。

Gaia-X はまさにこの層──参加者が何者であるかを検証可能な資格情報で示す仕組み──を持っています。今回はその準拠層を手元に建て、Ocean の認可判断をそこに差し替えるところまでやりました。

検証は 2026-08-12、Gaia-X Lab Compliance Servicedevelopment ブランチ 166295apackage.json は 2.12.0。リリースタグ v2.12.0 = 50b174f より後のコミットです)、ocean-node 3.2.0(oceanprotocol/ocean-node:latest が検証時点で 3.2.0 でした)、Apple Silicon の macOS 上です。

資格情報の形式そのものについてはW3C は Verifiable Credentials の何を決めていて、何を決めていないか、ウォレットが担う機能と holder binding の位置づけについてはアイデンティティ・ウォレットの 4 つの機能と、学認のトラストアンカーに書きました。

同じダウンロード要求が、認可の根拠を変えると結果を変えることを示した図。副題に「GET /api/services/download(ocean-node 3.2.0 で実測)」とある。上段「既定の認可」は、利用者 0xe2DD…7260 から Ocean Node の checkCredentials() を経て「何を持っているか(datatoken / AccessList / allow リスト)」に至り、許可される。下段「Policy Server を設定した経路」は、同じ利用者から Ocean Node(DDO に credentials あり)を経て「どの資格情報を提示できるか(Gaia-X Compliance Credential)」に至り、そこから二つに分岐する。証明を出さない場合は HTTP 403「Error: Access to asset did:op:bb83b4b7… was denied」、証明を出す場合は認可を通過し、注文が無いため後段で 500 になる。最下部に「要点: allow リストに載っている本人が、適合性の証明を出さない限り拒否される」と記されている。

Gaia-X の準拠層とは、実際には何か

Gaia-X の Lighthouse プロジェクトとはで触れたとおり、Pontus-X は Gaia-X 準拠を掲げたデータエコシステムです。ではその「準拠」の実体はどこにあるかというと、Compliance Service という 1 本のサービスに集約されています。

やっていることは、乱暴に言えばこうです。

  1. 参加者が、自分についての主張(法人名・所在地・登記番号・利用規約への同意)を Verifiable Credential として自己申告する
  2. それらを Verifiable Presentation にまとめて Compliance Service に提出する
  3. Compliance Service が、署名・証明書チェーン・SHACL (Shapes Constraint Language、RDF データへの制約を書く W3C 勧告) による形式検証・規則群を通す
  4. 通れば Compliance Credential(適合性の証明)を、Compliance Service 自身の鍵で発行して返す

以後、この Compliance Credential を提示できることが「Gaia-X のデータスペースの参加者である」ことの証明になります。認証局が証明書を出すのと同じ構図で、ブロックチェーンは一切登場しません。

ここが個人的にいちばん腑に落ちた点でした。Pontus-X は Web3 の色が濃いエコシステムですが、適合性の判定そのものは完全にオフチェーンです。チェーンは資産の登記と決済に使われているだけで、「誰であるか」の部分は VC (Verifiable Credential、検証可能な資格情報) と DID (Decentralized Identifier、分散識別子) と X.509 証明書で組まれています。それぞれの位置づけは前掲の記事に書きました。

手元に建てる

上流のリポジトリには docker-compose.yaml が付いています。ただし、そのままでは動きませんでした。動かすまでに 5 つ引っかかっています。

1. ローカル手順書と、2.12.0 の実際が異なる 3 点

docs/developer-and-local-testing.md の記述と、2.12.0 の実際の挙動は次の 3 点で異なっていました。

手順書2.12.0 の実際
JSON-LD を JsonWebSignature2020 で署名して提出するVP-JWT(W3C の Securing Verifiable Credentials using JOSE and COSE)。各 VC は VC-JWT にして data:application/vc+jwt,<jwt>id に持つ EnvelopedVerifiableCredential として並べる
cert.pemsrc/static/.well-known/ にコピーする不要。同じ手順書が .envX509_CERTIFICATE を置けとも書いていて、そちらが優先される(identity/service/did.service.ts)。環境変数で渡せば静的ファイルは自動で書き出される
LOCAL_HTTPS='true' を設定するそのような変数はもう読まれていない

3 点目のうち 2 つ目は、壊れるわけではなく不要な作業が残っている、という種類の差です。提出形式が JSON-LD から JWT に変わっているのは、手順書だけ読んでいると気づけません。正しい形は、リポジトリに同梱されている src/examples/signed-examples.ts の実例をデコードして起こすのが早いです。

なお DISABLE_SIGNATURE_CHECK は今も生きています(vp-validation/service/registry.service.ts)。後述しますが、これはローカル検証では必須で、同時にこの記事でいちばん重要な但し書きの原因でもあります。

2. BASE_URL にポートを付けると、自分の DID が引けなくなる

これが最も時間を取られた箇所です。

Compliance Service は、提出された VP の署名者 DID を(@gaia-x/json-web-signature-2020 が内部で使う)web-did-resolver で解決します。この実装は https:// しか組み立てません(lib/resolver.cjsconst url = `https://${path}`;)。一方で Nest 側は httpsOptions を渡していないので、本体だけでは TLS を張れません。前段に終端を置く必要があります。

ここまでは普通の話です。問題は、ポートを付けた BASE_URL が二通りの理由で失敗することでした。

BASE_URL の選び方で自分の did:web が引けなくなることを示した図。副題に「gx-compliance 2.12.0 / web-did-resolver は https しか組み立てない」とある。列は左から BASE_URL、生成される DID、resolver が取りに行く先の 3 列で、4 行の表。1 行目は http://localhost:3020 で DID は did:web:localhost:3020、resolver は「そもそも http では取りに行かない」で失敗。2 行目は https://localhost:3010 で DID は did:web:localhost:3010、resolver は https://localhost/3010/did.json(ポートがパス扱い)を取りに行って失敗。3 行目は https://localhost%3A3010 で DID は did:web:localhost%3A3010、DID は正しいが x5u のホスト名が不正になり失敗。4 行目は https://gx-tls(443 で終端)で DID は did:web:gx-tls、resolver は https://gx-tls/.well-known/did.json を取りに行って成功。最下部に「ポートを付けられないので、DID のホスト名を Docker のサービス名にして、解決も終端もネットワークの内側で完結させる」と記されている。

did:web の仕様では、ポートは %3A にエスケープして書きます。ところが Compliance Service の DidWebProviderBASE_URL のコロンをそのまま DID に持ち込むので、https://localhost:3010 からは did:web:localhost:3010 が生成されます。resolver 側はこれを見て、コロンをパス区切りと解釈し、https://localhost/3010/did.json を取りに行きます。当然 404 です。

では BASE_URLhttps://localhost%3A3010 にすればよいかというと、DID は did:web:localhost%3A3010 になって正しくなるのですが、今度は DID ドキュメント内の x5u(証明書チェーンの取得先)が https://localhost%3A3010/... になります。これはホスト名として不正なので、証明書チェーンの取得(credential-offer/service/credential-offer.service.ts)で落ちます。DID を正しくすると x5u が壊れるという関係になっていて、ポート付きでは抜けられません。

結局 443 で終端するしかないのですが、macOS では 1024 未満のポートを root なしで bind できず、手元では 443 を別のスタック(k3d のコントロールプレーン)が既に握っていました。

そこで、DID のホスト名を Docker Compose のサービス名にしました。

services:
  gx-compliance:
    environment:
      BASE_URL: 'https://gx-tls'      # → did:web:gx-tls
  gx-tls:
    image: nginx:1.27-alpine
    ports:
      - '8443:443'                    # ホストから覗く用。DID の解決には使わない

こうすると DID は did:web:gx-tls になり、Compliance Service がそれを解決するとき Docker の内蔵 DNS が gx-tls を引き、コンテナネットワークの内側の 443 に届きます。ホストのポートを一切奪いません。 外から見るときだけ 8443 を使います。

3. Java 実行環境が必要(ホストには用意していませんでした)

SHACL 検証は Apache Jena を子プロセスで叩いています(vp-validation/service/shacl.service.tstools/jena/bin/riot を呼ぶ)。手元の Mac には JRE (Java 実行環境) が入っておらず、java -version が "Unable to locate a Java Runtime" を返す状態でした。

手順書どおりホストで npm run start すると、起動はします。落ちるのは検証段階に入ってからです。上流の Dockerfileapk add openjdk17 しているので、コンテナで動かすほうが前提が少なくて済みます。ホストに Node も Java も置かずに完結させました。

4. グラフデータベースが必要。環境変数名は小文字の dburl

VP の中身の検証は、VC を RDF (Resource Description Framework) に落として Cypher (グラフデータベースの問い合わせ言語) で問い合わせる方式です。したがって memgraph が要ります。無いと HTTP 500 で、この応答が返ります。

{
  "statusCode": 500,
  "errors": [
    "Failed to connect to server. Please ensure that your database is listening on the correct host and port …"
  ]
}

接続先の環境変数は NEO4J_URL のような大文字ではなく、小文字の dburl です。

// vp-validation/service/verifiable-presentation-validation.service.ts
private readonly _driver = neo4j.driver(process.env.dburl || 'bolt://localhost:7687')

環境変数の一覧を grep 'process.env.[A-Z_]*' で拾うと、これだけ漏れます。

5. nginx が upstream の IP を焼き付ける

これは Gaia-X とは無関係な、しかし切り分けに時間を取られた事故なので書いておきます。

location / {
    proxy_pass http://gx-compliance:3020;   # ← これがまずい
}

このようにホスト名をリテラルで書くと、nginx は起動時に一度だけ名前解決して IP を固定します。compliance のコンテナを作り直すと IP が変わり、以後ずっと 502 になります。

connect() failed (111: Connection refused) while connecting to upstream,
upstream: "http://172.24.0.2:3020/…"

厄介なのは、compliance 側のログには何も出ないことです。リクエストが到達していないので当然なのですが、「さっきまで動いていたのに」という状況で犯人を nginx だと思いつくまでに時間がかかりました。Docker の内蔵 DNS を明示して、変数経由の proxy_pass にするとリクエストごとに再解決されます。

resolver 127.0.0.11 valid=10s ipv6=off;

location / {
    set $upstream http://gx-compliance:3020;
    proxy_pass $upstream$request_uri;
}

Compliance Credential を受け取る

提出するのは 3 通の VC です。gx:LegalPerson(法人としての自己記述)、gx:Issuer(Gaia-X 利用規約のハッシュへの同意)、gx:VatID(登記番号)。これらをそれぞれ VC-JWT にして、EnvelopedVerifiableCredential として VP に並べ、VP 全体をもう一度署名します。

const enveloped = jwt => ({
  '@context': 'https://www.w3.org/ns/credentials/v2',
  id: `data:application/vc+jwt,${jwt}`,
  type: 'EnvelopedVerifiableCredential'
})

const vp = {
  '@context': ['https://www.w3.org/ns/credentials/v2', 'https://www.w3.org/ns/credentials/examples/v2'],
  type: 'VerifiablePresentation',
  verifiableCredential: vcs.map(enveloped),
  issuer: DID, validFrom, validUntil
}

const vpJwt = await new SignJWT(vp)
  .setProtectedHeader({ alg: 'PS256', typ: 'vp+jwt', cty: 'vp', iss: DID, kid: `${DID}#X509-JWK` })
  .sign(key)

これを POST /api/credential-offers/standard-compliance?vcid=… に投げます。できあがった VP は 6,968 バイトでした。

HTTP 201 Created

返ってきた Compliance Credential の中身です(VC-JWT のペイロード部)。

{
  "type": ["VerifiableCredential", "gx:LabelCredential"],
  "issuer": "did:web:gx-tls",
  "credentialSubject": {
    "gx:labelLevel": "SC",
    "gx:engineVersion": "2.12.0",
    "gx:rulesVersion": "CD25.10",
    "gx:compliantCredentials": [
      { "type": "gx:LegalPerson", "gx:digestSRI": "sha256-00fe13c0…" },
      { "type": "gx:Issuer",      "gx:digestSRI": "sha256-1774e0a6…" },
      { "type": "gx:VatID",       "gx:digestSRI": "sha256-798c4cfc…" }
    ],
    "gx:validatedCriteria": [
      "https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/criteria_participant/#PA1.1"
    ]
  }
}

gx:labelLevel: "SC" は standard compliance、gx:rulesVersion: "CD25.10" は適用された規則集(Compliance Document 25.10)の版です。信頼の根の規定は 24.11 版をGaia-X Registry には何が入っているのかで参照していますが、参照している章が違うだけで、どちらも現行の文書です。何をどう検証したかが gx:validatedCriteria に URL で入り、提出した各 VC のダイジェストが gx:digestSRI(SRI = Subresource Integrity 形式のダイジェスト)として刻まれます。後から「何を根拠に適合と判定したのか」を辿れる形になっています。

ただし「通った」は「準拠した」ではありません

gx:VatID(登記番号)は、本来 registrationnumber.notary.lab.gaia-x.eu などの公証サービスが発行し(registry の信頼済み発行者一覧には現在 17 の notary が載っていて、deltaDAO 自身も www.delta-dao.com/notary を運用しています)、Compliance Service はその発行者が一覧にあるかを確認します。ところがローカルでは production が未設定=開発環境扱いになるため、この判定が無条件に true を返します。

// vp-validation/service/trusted-notary-issuer.service.ts
async isTrusted(issuer: string): Promise<boolean> {
  return this.developmentEnvironment || (await this.retrieveTrustedNotaryIssuers()).some(…)
}

つまり今回は登記番号を自己発行して通しています。同様に DISABLE_SIGNATURE_CHECK=true は、証明書チェーンを Gaia-X の trust anchor と突き合わせる処理を丸ごと飛ばします。自己署名証明書では絶対に通らない検査なので切っていますが、本番の Compliance Service に同じ VP を出せば落ちます

したがってここで言えるのは「Gaia-X の準拠エンジンを自分で動かし、その検証規則を実際に通した」までです。「Gaia-X に準拠した」ではありません。この区別は、外に出す文章では必ず守る必要があります。

Ocean の認可を差し替える

この Compliance Credential を、Ocean のダウンロード認可の条件にします。

Ocean Node には Policy Server という仕掛けがあります。環境変数 POLICY_SERVER_URL を設定すると、Ocean Node は認可の判断を外部の HTTP サーバに委ねます。

// dist/components/policyServer/index.js
async askServer(command) {
  if (!this.serverUrl) return { success: true, message: '', httpStatus: 404 }
  const response = await fetch(this.serverUrl, { method: 'POST', body: JSON.stringify(commandWithNodeAddress) })
  if (response.status === 200) return { success: true, … }
  return { success: false, … }
}

契約は単純で、POLICY_SERVER_URL が設定されているとき、200 なら許可、それ以外は拒否です(未設定なら Policy Server は呼ばれず、既定の判定に落ちます)。ダウンロードの認可に関わる呼び出し箇所は downloadHandler.js と、Compute-to-Data の startCompute.js / initialize.js です。ほかに暗号化(encryptHandler.js)や索引時の DDO 検査(MetadataEventProcessor.js)からも同じ Policy Server が呼ばれるので、判定側は action を見て振り分ける必要があります。

そして重要なのが、呼び出しの条件です。

// dist/components/core/handler/downloadHandler.js
const { chainId: ddoChainId, nftAddress, metadata, credentials } = ddoInstance.getDDOFields()
…
if (credentials) {
  if (isPolicyServerConfigured()) {
    const response = await policyServer.checkDownload(ddoInstance.getDid(), ddo, task.serviceId, task.consumerAddress, task.policyServer)
    accessGrantedDDOLevel = response.success
  } else {
    accessGrantedDDOLevel = await checkCredentials(task.consumerAddress, credentials, await blockchain.getSigner())
  }
  …
}

資産(DDO)またはサービスに credentials が入っているときだけ、Policy Server に問い合わせます。逆に言えば、この credentials ブロックが、外部の認可判断を差し込むかどうかのスイッチになっています(サービス側の判定は DDO 側の結果と OR で合成されます)。

判定を書く

Policy Server 側は 200 か 403 を返すだけなので、実装は素直です。提示された Compliance Credential について、次の 6 点を順に見ます。

  1. 提示物があるか
  2. 署名が信頼済み発行者のものか(did:web を解決して得た JWK (JSON Web Key、公開鍵の JSON 表現) で検証)
  3. gx:LabelCredential
  4. 有効期間内か
  5. ラベル水準が要求以上か
  6. 提示者と請求元アドレスが結びつくか

落ちた理由をそのまま返すようにしておくと、切り分けが一気に楽になります。

2026-08-12T14:46:57.520Z download did=did:op:bb83b4b7… consumer=0xe2DD…7260 -> DENY (no-credential-presented)
2026-08-12T14:46:57.539Z download did=did:op:bb83b4b7… consumer=0xe2DD…7260 -> ALLOW (ok)

なお細かい罠として、ダウンロード API は policyServer をクエリ文字列で受けるため、オブジェクトではなく文字列のまま転送されてきます。Compute 側はオブジェクトで来るので、両方受けるようにしておく必要がありました。

6 番目だけは、実運用の設計ではありません

「提示者と請求元アドレスの結びつき」は、本来は提示者の鍵による所有証明(VP の holder binding、あるいはウォレット署名)で示すべきものです。今回はローカル検証なので、DID と Ethereum アドレスの対応表を外から与えて代用しています。ここを潰すのが次の課題です(この欠落が何を意味するかはアイデンティティ・ウォレットの 4 つの機能と、学認のトラストアンカーで整理しました)。

実測: 許可リストに載っている本人が、拒否される

ローカルチェーンに credentials つきの資産を publish しました。credentials.allow に載せたのは、publish した本人のアドレスです。

{
  "credentials": {
    "allow": [{ "type": "address", "values": ["0xe2DD09d719Da89e5a3D0F2549c7E24566e947260"] }],
    "deny": []
  }
}

つまり既定の認可規則だけなら、この人は間違いなく通ります。その本人が GET /api/services/download を叩いた結果です。

提示HTTP応答Policy Server のログ
資格証明なし403Error: Access to asset did:op:bb83b4b7… was deniedDENY (no-credential-presented)
資格証明あり500{}(注文が無いため後段で失敗)ALLOW (ok)

allow リストに載っている本人が、適合性の証明を出さない限り拒否されました。 認可の根拠が「何を持っているか」から「どの資格情報を提示できるか」に移っています。ただしこの構成では提示者の所有証明(holder binding)を対応表で代用しているので、「誰であるか」まで証明できているわけではありません(後述)。

資格証明を出したときの 500 は認可の失敗ではありません。この資産に対する注文(order)が存在しないため、その先の検証で止まったものです(ログで確認済み)。認可の関門を越えたことが、エラーの種類が変わることで分かるという読み方になります。

念のため、Policy Server の判定そのものが効いているかを 4 通りで確かめました。以下の HTTP コードは Ocean Node のダウンロード API ではなく、Policy Server が返した応答です(ダウンロード API 側の結果は 1 つ前の表のとおりです)。

場合Policy Server の期待実際理由
有効な資格証明 + 結びついたアドレス200200ok
何も提示しない403403no-credential-presented
署名の末尾 2 文字を差し替えた403403signature-invalid:…
資格証明は正しいが別アドレスから403403address-not-bound:…

ローカルチェーンへの移行

これまで Sepolia を使っていましたが、この検証のためにローカルチェーンへ移しました。理由は速度ではなく、公開 RPC の制約に何度も足を取られたからです(archive 状態を持たない RPC で subgraph が落ちる、無料枠の上限に当たって同期が止まる、など)。

移すときに 2 つ引っかかりました。

  • Ocean のデプロイスクリプトは chainId 8996 でしか動きません。 hardhat の設定にネットワーク定義が 1 つしかなく、接続先の chainId が違うと HH101: Hardhat was set to use chain id 8996, but connected to a chain with id 31337 で止まります。別の chainId のローカルチェーンを既に動かしていたので、8996 を新たに建てました。
  • 公式イメージ oceanprotocol/ocean-contracts に arm64 がありません。 no matching manifest for linux/arm64/v8 で pull できないので、--platform linux/amd64 でエミュレーション実行します。さらにイメージにはコンパイル済みの artifacts が入っておらず、npx hardhat compile から始める必要があります(10 分前後)。

結果として、publish から索引まで 約 2 秒になりました。Sepolia では数十秒から数分かかっていたので、試行の回数がそのまま変わります。

何ができたことになるのか

できたこと

Gaia-X の準拠エンジンを自分で動かし、Compliance Document 25.10 の規則を通して Compliance Credential を発行させました。そのうえで、その提示を Ocean のダウンロード認可の条件にしています。適合性の証明が無ければ、既定の認可を通過するはずの相手も拒否されます。

できていないこと

提示者の所有証明(holder binding)は対応表で代用したままです。Compute-to-Data 側は、Policy Server を呼ぶ関数自体は同じですが、policyServer の渡り方が異なる(ダウンロードは文字列、Compute はオブジェクト)ので、実測していません。適合させたのは参加者向けの基準(PA1.1)だけで、サービス提供者としての適合は手つかずです。

そして技術ではどうにもならない論点がひとつあります。日本の法人番号を直接表す型は、調べた限り Gaia-X にありません(gx:VatID / gx:LEICode / gx:EORI / gx:EUID はいずれも EU の制度に紐づく型で、汎用の受け皿としては gx:TaxIDgx:LocalRegistrationNumber があります)。より本質的なのは型ではなく公証のほうで、登記番号を検証して資格情報を発行する notary は現状すべて EU の枠組みの中にあり、日本の法人番号を照会する経路が見当たりませんでした。実際に提出して確かめたわけではないので、本番の窓口でどう扱われるかは別に確認が要ります。信頼の根の側の事情はGaia-X Registry には何が入っているのかに書きました。

参照