Verifiable Credentials Implementation Guide
Contents
Verifiable Credentials Implementation Guide#
This guide is for developers implementing verifiable credentials functionality in OpenSPP or creating custom credential types.
Prerequisites#
Before implementing VCs, ensure you understand:
/developer_guide/setup/development_environment - Development setup
Customize Program - Creating Odoo modules
Verifiable Credentials Overview - VC concepts and architecture
W3C Verifiable Credentials Data Model - W3C VC data model
Quick Start#
1. Install Required Modules#
# In your OpenSPP instance
odoo-bin -i spp_verifiable_credentials,spp_openid_vci,spp_openid_vci_rest_api -d your_database
Module Dependencies:
spp_verifiable_credentials- Core VC functionalityspp_encryption- Cryptographic signingspp_openid_vci- OpenID4VCI protocolspp_openid_vci_rest_api- REST API endpoints
2. Configure Issuer Identity#
# Set base URL (used for DID:web)
env['ir.config_parameter'].sudo().set_param(
'web.base.url',
'https://registry.example'
)
# Configure signing key
key_service = env['spp.key.service']
issuer_key = key_service.create_key(
key_type='ES256',
name='VC Issuer Key',
purpose='signing'
)
3. Issue Your First Credential#
# Get credential type
credential_type = env['spp.credential.type'].search([
('code', '=', 'EntitlementCredential')
], limit=1)
# Get subject (e.g., entitlement)
entitlement = env['spp.entitlement'].browse(entitlement_id)
# Issue credential
vc = entitlement.issue_credential(credential_type.id)
print(vc) # SD-JWT VC string
Creating Custom Credential Types#
Step 1: Define Credential Type#
Create a credential type configuration for your domain object.
Option A: Via UI
Navigate to Settings → Verifiable Credentials → Credential Types → Create:
Field |
Value |
|---|---|
Name |
Assessment Credential |
Code |
AssessmentCredential |
Source Model |
spp.event.data |
Format |
SD-JWT VC |
Validity Days |
180 |
Option B: Via Data File
<!-- my_module/data/credential_types.xml -->
<odoo>
<record id="credential_type_assessment" model="spp.credential.type">
<field name="name">Assessment Credential</field>
<field name="code">AssessmentCredential</field>
<field name="source_model">spp.event.data</field>
<field name="format">sd_jwt_vc</field>
<field name="validity_days">180</field>
<field name="always_disclosed_claims">assessmentId,date,status</field>
<field name="selectively_disclosable_claims">score,vulnerabilityIndex,recommendations</field>
<field name="display_name">Vulnerability Assessment</field>
<field name="display_logo_uri">https://registry.example/logos/assessment.png</field>
<field name="background_color">#4A90E2</field>
<field name="text_color">#FFFFFF</field>
</record>
</odoo>
Step 2: Implement Credential Subject Mixin#
Make your model credentialable by inheriting the mixin:
# my_module/models/event_data.py
from odoo import models, fields, api
class EventData(models.Model):
_name = "spp.event.data"
_inherit = ["spp.event.data", "spp.credential.subject.mixin"]
# Add VC-related computed fields if needed
can_issue_credential = fields.Boolean(
compute='_compute_can_issue_credential',
string="Can Issue Credential"
)
@api.depends('state', 'event_type')
def _compute_can_issue_credential(self):
"""Determine if this assessment can be credentialed"""
for record in self:
record.can_issue_credential = (
record.state == 'completed' and
record.event_type == 'vulnerability_assessment'
)
def get_credential_claims(self, credential_type):
"""Return claims dict for this assessment"""
self.ensure_one()
# Get base claims from parent if any
claims = {}
if hasattr(super(), 'get_credential_claims'):
claims = super().get_credential_claims(credential_type)
# Add assessment-specific claims
claims.update({
'assessmentId': self.name,
'date': self.event_date.isoformat() if self.event_date else None,
'status': self.state,
'score': self.score,
'vulnerabilityIndex': self._compute_vulnerability_index(),
'assessor': {
'name': self.assessor_id.name,
'id': self.assessor_id.external_id,
},
'recommendations': self._get_recommendations(),
})
return claims
def get_credential_subject_id(self):
"""Return DID for this assessment subject"""
self.ensure_one()
# Use the beneficiary's DID
if self.partner_id.wallet_did:
return self.partner_id.wallet_did
# Fallback to generating DID
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
domain = base_url.replace('https://', '').replace('http://', '')
return f"did:web:{domain}:beneficiary:{self.partner_id.id}"
def _compute_vulnerability_index(self):
"""Calculate vulnerability index from assessment data"""
# Your business logic here
return 0.75
def _get_recommendations(self):
"""Get list of recommendations from assessment"""
return [
'Enroll in food security program',
'Access healthcare services',
]
Step 3: Add UI Actions#
Add button to issue credentials from the form view:
<!-- my_module/views/event_data_views.xml -->
<odoo>
<record id="view_event_data_form_inherit_vc" model="ir.ui.view">
<field name="name">spp.event.data.form.vc</field>
<field name="model">spp.event.data</field>
<field name="inherit_id" ref="spp_event_data.view_event_data_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_issue_credential"
type="object"
string="Issue Credential"
class="oe_highlight"
invisible="not can_issue_credential"/>
</xpath>
</field>
</record>
</odoo>
Implement the action:
def action_issue_credential(self):
"""Wizard to issue credential for this assessment"""
self.ensure_one()
# Get credential type
credential_type = self.env['spp.credential.type'].search([
('code', '=', 'AssessmentCredential')
], limit=1)
if not credential_type:
raise UserError("Assessment credential type not configured")
# Open wizard
return {
'type': 'ir.actions.act_window',
'name': 'Issue Assessment Credential',
'res_model': 'spp.credential.issue.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_credential_type_id': credential_type.id,
'default_credential_subject_model': self._name,
'default_credential_subject_id': self.id,
}
}
Step 4: Configure Claim Mapping (Optional)#
For complex claim transformations, use JQ expressions:
# In credential type configuration
credential_type.write({
'claim_mapping_jq': '''
{
"assessmentId": .name,
"date": .event_date | strftime("%Y-%m-%d"),
"score": .score | tonumber,
"category": (
if .score > 0.8 then "high-risk"
elif .score > 0.5 then "medium-risk"
else "low-risk"
end
),
"location": {
"region": .partner_id.area_id.name,
"coordinates": {
"lat": .partner_id.latitude,
"lon": .partner_id.longitude
}
}
}
'''
})
Implementing Issuance Flows#
Push Model: Registry-Initiated Issuance#
When the registry approves an entitlement, automatically generate a credential offer:
# my_module/models/entitlement.py
class SPPEntitlement(models.Model):
_inherit = "spp.entitlement"
def action_approve(self):
"""Approve entitlement and issue credential offer"""
res = super().action_approve()
# Generate credential offer for approved entitlements
for entitlement in self:
if entitlement.state == 'approved':
entitlement._issue_credential_offer()
return res
def _issue_credential_offer(self):
"""Create and send credential offer to beneficiary"""
self.ensure_one()
# Get credential type
credential_type = self.env['spp.credential.type'].search([
('code', '=', 'EntitlementCredential')
], limit=1)
if not credential_type:
return
# Get or create wallet for beneficiary
wallet = self.env['spp.wallet'].get_or_create_wallet(self.partner_id)
# Create offer
offer_service = self.env['spp.credential.offer']
offer = offer_service.create({
'credential_type_id': credential_type.id,
})
offer = offer.create_offer(
credential_subject=self,
credential_type=credential_type,
holder_wallet_did=wallet.wallet_did
)
# Generate QR code
qr_uri = offer.get_credential_offer_uri()
# Send to beneficiary (SMS, email, or app notification)
self._send_credential_offer(qr_uri)
# Log the offer
self.message_post(
body=f"Credential offer created: {offer.id}",
subject="Verifiable Credential Issued"
)
def _send_credential_offer(self, qr_uri):
"""Send credential offer to beneficiary"""
# Option 1: SMS with deep link
if self.partner_id.mobile:
sms_service = self.env['spp.sms.service']
sms_service.send_sms(
self.partner_id.mobile,
f"Your entitlement credential is ready: {qr_uri}"
)
# Option 2: Email with QR code image
if self.partner_id.email:
qr_image = self._generate_qr_image(qr_uri)
self.env['mail.mail'].create({
'email_to': self.partner_id.email,
'subject': 'Your Entitlement Credential',
'body_html': f'''
<p>Your entitlement has been approved.</p>
<p>Scan this QR code with your wallet app:</p>
<img src="data:image/png;base64,{qr_image}" />
''',
}).send()
# Option 3: Push notification to mobile app
if hasattr(self.partner_id, 'fcm_token'):
notification_service = self.env['spp.notification.service']
notification_service.send_push(
self.partner_id.fcm_token,
title='Credential Available',
body='Your entitlement credential is ready',
data={'credential_offer_uri': qr_uri}
)
Pull Model: Beneficiary-Initiated Request#
Allow beneficiaries to request credentials through a portal:
# my_module/controllers/portal.py
from odoo import http
from odoo.http import request
class CredentialPortal(http.Controller):
@http.route('/my/credentials', type='http', auth='user', website=True)
def my_credentials(self, **kwargs):
"""List available credentials for current user"""
partner = request.env.user.partner_id
# Get credentialable records
entitlements = request.env['spp.entitlement'].search([
('partner_id', '=', partner.id),
('state', '=', 'approved'),
])
memberships = request.env['spp.program.membership'].search([
('partner_id', '=', partner.id),
('state', '=', 'enrolled'),
])
return request.render('my_module.portal_my_credentials', {
'entitlements': entitlements,
'memberships': memberships,
})
@http.route('/my/credentials/request/<model>/<int:record_id>',
type='http', auth='user', website=True)
def request_credential(self, model, record_id, **kwargs):
"""Request credential for a specific record"""
partner = request.env.user.partner_id
# Validate access
record = request.env[model].browse(record_id)
if record.partner_id != partner:
return request.redirect('/my/credentials?error=access_denied')
# Get wallet DID (or create wallet)
wallet = request.env['spp.wallet'].get_or_create_wallet(partner)
# Determine credential type
type_mapping = {
'spp.entitlement': 'EntitlementCredential',
'spp.program.membership': 'ProgramMembershipCredential',
}
credential_code = type_mapping.get(model)
credential_type = request.env['spp.credential.type'].search([
('code', '=', credential_code)
], limit=1)
# Create offer
offer_service = request.env['spp.credential.offer']
offer = offer_service.create({})
offer = offer.create_offer(
credential_subject=record,
credential_type=credential_type,
holder_wallet_did=wallet.wallet_did
)
# Return QR code page
qr_uri = offer.get_credential_offer_uri()
return request.render('my_module.credential_offer_qr', {
'qr_uri': qr_uri,
'record': record,
})
Testing#
Unit Tests#
# my_module/tests/test_assessment_credentials.py
from odoo.tests import TransactionCase
from odoo.exceptions import UserError
class TestAssessmentCredentials(TransactionCase):
def setUp(self):
super().setUp()
self.credential_type = self.env['spp.credential.type'].create({
'name': 'Test Assessment Credential',
'code': 'TestAssessmentCredential',
'source_model': 'spp.event.data',
'format': 'sd_jwt_vc',
'validity_days': 180,
'always_disclosed_claims': 'assessmentId,date',
'selectively_disclosable_claims': 'score,recommendations',
})
self.partner = self.env['res.partner'].create({
'name': 'Test Beneficiary',
})
self.assessment = self.env['spp.event.data'].create({
'partner_id': self.partner.id,
'event_type': 'vulnerability_assessment',
'state': 'completed',
'score': 0.75,
})
def test_get_credential_claims(self):
"""Test assessment claim generation"""
claims = self.assessment.get_credential_claims(self.credential_type)
self.assertIn('assessmentId', claims)
self.assertIn('score', claims)
self.assertEqual(claims['score'], 0.75)
self.assertIn('recommendations', claims)
self.assertIsInstance(claims['recommendations'], list)
def test_issue_credential(self):
"""Test issuing assessment credential"""
vc = self.assessment.issue_credential(self.credential_type.id)
# Verify it's a valid SD-JWT
self.assertTrue(isinstance(vc, str))
self.assertTrue('~' in vc) # SD-JWT contains tildes
# Parse and verify structure
sd_jwt_service = self.env['spp.sd.jwt']
parsed = sd_jwt_service.parse_sd_jwt(vc)
self.assertIn('iss', parsed['jwt_payload'])
self.assertIn('vct', parsed['jwt_payload'])
self.assertEqual(
parsed['jwt_payload']['vct'],
'https://registry.example/credentials/TestAssessmentCredential'
)
def test_cannot_issue_for_incomplete_assessment(self):
"""Test that incomplete assessments cannot be credentialed"""
incomplete = self.env['spp.event.data'].create({
'partner_id': self.partner.id,
'event_type': 'vulnerability_assessment',
'state': 'draft',
})
self.assertFalse(incomplete.can_issue_credential)
def test_selective_disclosure(self):
"""Test selective disclosure configuration"""
vc = self.assessment.issue_credential(self.credential_type.id)
sd_jwt_service = self.env['spp.sd.jwt']
parsed = sd_jwt_service.parse_sd_jwt(vc)
# Always disclosed claims should be in JWT payload
self.assertIn('assessmentId', parsed['revealed_claims'])
# Selectively disclosable claims should be in disclosures
disclosure_names = [d[1] for d in parsed['disclosures']]
self.assertIn('score', disclosure_names)
self.assertIn('recommendations', disclosure_names)
Integration Tests#
# my_module/tests/test_credential_issuance_flow.py
from odoo.tests import TransactionCase
class TestCredentialIssuanceFlow(TransactionCase):
def test_end_to_end_issuance(self):
"""Test complete credential issuance flow"""
# 1. Create assessment
assessment = self.env['spp.event.data'].create({
'partner_id': self.partner.id,
'event_type': 'vulnerability_assessment',
'state': 'completed',
'score': 0.85,
})
# 2. Get wallet for beneficiary
wallet = self.env['spp.wallet'].get_or_create_wallet(self.partner)
self.assertTrue(wallet)
# 3. Create credential offer
credential_type = self.env.ref('my_module.credential_type_assessment')
offer = self.env['spp.credential.offer'].create({})
offer = offer.create_offer(
credential_subject=assessment,
credential_type=credential_type,
holder_wallet_did=wallet.wallet_did
)
self.assertEqual(offer.state, 'pending')
self.assertTrue(offer.pre_authorized_code)
# 4. Simulate wallet redeeming offer
token_service = self.env['spp.token.service']
token_data = token_service.exchange_pre_authorized_code(
offer.pre_authorized_code
)
self.assertIn('access_token', token_data)
self.assertIn('c_nonce', token_data)
# 5. Issue credential
credential_subject = offer.get_credential_subject()
vc = credential_subject.issue_credential(
credential_type.id,
holder_did=wallet.wallet_did
)
self.assertTrue(vc)
self.assertEqual(offer.state, 'redeemed')
# 6. Verify credential stored in wallet
wallet_credential = self.env['spp.wallet.credential'].search([
('wallet_id', '=', wallet.id),
('credential_subject_id', '=', assessment.id),
])
self.assertEqual(len(wallet_credential), 1)
self.assertEqual(wallet_credential.state, 'valid')
Performance Considerations#
Batch Credential Issuance#
When issuing credentials for many beneficiaries:
def batch_issue_credentials(self, entitlements):
"""Issue credentials for multiple entitlements efficiently"""
credential_type = self.env['spp.credential.type'].search([
('code', '=', 'EntitlementCredential')
], limit=1)
# Pre-fetch related data
entitlements = entitlements.with_context(prefetch_fields=True)
partners = entitlements.mapped('partner_id')
wallets = self.env['spp.wallet'].get_or_create_wallets_batch(partners)
# Create offers in batch
offer_vals = []
for entitlement in entitlements:
wallet = wallets.get(entitlement.partner_id.id)
if wallet:
offer_vals.append({
'credential_type_id': credential_type.id,
'credential_subject_model': 'spp.entitlement',
'credential_subject_id': entitlement.id,
'holder_wallet_did': wallet.wallet_did,
})
offers = self.env['spp.credential.offer'].create(offer_vals)
# Generate pre-authorized codes
for offer in offers:
offer.create_offer(
offer.get_credential_subject(),
credential_type,
offer.holder_wallet_did
)
return offers
Caching Status Lists#
Status list verification can be expensive. Cache aggressively:
# Enable caching in production
@tools.ormcache('status_list_id')
def get_status_list_bitstring(self, status_list_id):
"""Get cached bitstring for status list"""
status_list = self.browse(status_list_id)
return status_list.get_decoded_bitstring()
Are You Stuck?#
Model doesn't have issue_credential() method?
Ensure you've inherited the mixin:
_inherit = ["your.model", "spp.credential.subject.mixin"]
And implemented required methods:
get_credential_claims(credential_type)get_credential_subject_id()
Getting "Credential type not found" error?
Check:
Credential type record exists in database
Code matches exactly (case-sensitive)
Module with credential type data is installed
Debug:
types = env['spp.credential.type'].search([])
for t in types:
print(f"{t.code}: {t.name}")
Credential offer QR code not working in wallet?
Verify:
QR contains valid
openid-credential-offer://URIJSON is properly URL-encoded
credential_issuerURL is accessibleWallet supports OpenID4VCI protocol
Test offer manually:
offer = env['spp.credential.offer'].browse(offer_id)
uri = offer.get_credential_offer_uri()
print(uri)
# Decode and inspect
import urllib.parse
import json
decoded = urllib.parse.unquote(uri.split('credential_offer=')[1])
print(json.dumps(json.loads(decoded), indent=2))
How do I add custom validation before issuance?
Override issue_credential in your model:
def issue_credential(self, credential_type_id, holder_did=None):
"""Add custom validation"""
self.ensure_one()
# Custom checks
if not self.is_eligible_for_credential():
raise UserError("Record not eligible for credential")
if self.has_pending_issues():
raise UserError("Resolve pending issues before issuing")
# Call parent implementation
return super().issue_credential(credential_type_id, holder_did)
Next Steps#
Wallet Integration: Integrate with mobile wallet apps
Verification: Implement verifier endpoints (/developer_guide/integrations/vc_verification)
Revocation: Set up automated revocation workflows
Monitoring: Add logging and metrics for credential issuance
See Also#
Verifiable Credentials Overview - Verifiable Credentials Overview
W3C Verifiable Credentials Data Model - W3C VC Data Model
OpenID for Verifiable Credential Issuance - OpenID4VCI Protocol
Customize Program - Creating Custom Modules
/developer_guide/architecture/security - Security Best Practices
openspp.org