Skip to content

Commit 359e598

Browse files
committed
Add Credential Issuer tutorial
Adds a code sample and a code walkthrough explaining how to build a service that issues Credentials (XLS-70) on the XRP Ledger. Credential issuer: Clarify/revise documents field Issue credentials code sample: fix bugs Apply suggestions from @oeggert review Co-authored-by: oeggert <117319296+oeggert@users.noreply.github.com> Credential Issuer: more edits for clarity
1 parent 7253913 commit 359e598

File tree

10 files changed

+827
-0
lines changed

10 files changed

+827
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Credential Issuing Service
2+
3+
This sample code shows how to issue credentials to XRPL users using a basic API service.
4+
5+
For a full walkthrough of the code, see the tutorial: https://xrpl.org/docs/tutorials/python/build-apps/credential-issuing-service
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Credential Issuing Service - Python sample code
2+
3+
This code implements an HTTP API that issues credentials on the XRPL on request.
4+
5+
Quick install & usage:
6+
7+
```sh
8+
python -m venv .venv
9+
source .venv/bin/activate
10+
pip install -r requirements.txt
11+
flask --app issuer_service run
12+
```
13+
14+
For more detail, see the full tutorial for [How to build a service that issues credentials on the XRP Ledger](https://xrpl.org/docs/tutorials/python/build-apps/credential-issuing-service).
15+
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env python
2+
3+
from binascii import unhexlify
4+
from os import getenv
5+
from getpass import getpass
6+
7+
from xrpl.clients import JsonRpcClient
8+
from xrpl.models.requests import AccountObjects, AccountObjectType
9+
from xrpl.models.transactions import CredentialAccept
10+
from xrpl.transaction import submit_and_wait
11+
from xrpl.utils import str_to_hex, datetime_to_ripple_time
12+
from xrpl.wallet import Wallet, generate_faucet_wallet
13+
14+
from look_up_credentials import look_up_credentials
15+
from decode_hex import decode_hex
16+
17+
XRPL_SERVER = "https://s.devnet.rippletest.net:51234/"
18+
19+
client = JsonRpcClient(XRPL_SERVER)
20+
21+
def init_wallet():
22+
seed = getenv("SUBJECT_ACCOUNT_SEED")
23+
if not seed:
24+
seed = getpass(prompt='Subject account seed: ',stream=None)
25+
if not seed:
26+
print("Please specify the subject's master seed")
27+
exit(1)
28+
return Wallet.from_seed(seed=seed)
29+
30+
wallet = init_wallet()
31+
32+
pending_credentials = look_up_credentials(
33+
client,
34+
subject=wallet.address,
35+
accepted="no"
36+
)
37+
38+
prompt = """
39+
Accept a credential?
40+
0) No, quit."""
41+
for i, cred in enumerate(pending_credentials):
42+
credential_type_s = decode_hex(cred["CredentialType"])
43+
prompt += f"\n {i+1}) '{credential_type_s}' issued by {cred['Issuer']}"
44+
45+
selection = None
46+
options = [str(n) for n in range(len(pending_credentials)+1)]
47+
while selection not in options:
48+
selection = input(prompt+f"\n Select an option (0-{len(options)-1}): ")
49+
50+
if selection == "0":
51+
exit(0)
52+
53+
chosen_cred = pending_credentials[int(selection)-1]
54+
tx = CredentialAccept(
55+
account=wallet.address,
56+
credential_type=chosen_cred["CredentialType"],
57+
issuer=chosen_cred["Issuer"]
58+
)
59+
print("Submitting transaction", tx)
60+
response = submit_and_wait(tx, client=client, wallet=wallet, autofill=True)
61+
print(response)
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import re
2+
from datetime import datetime
3+
4+
from xrpl.core.addresscodec import is_valid_classic_address
5+
from xrpl.utils import ripple_time_to_datetime, datetime_to_ripple_time, str_to_hex
6+
7+
from decode_hex import decode_hex
8+
9+
def is_allowed_credential_type(credential_type: str):
10+
"""
11+
Returns True if the specified credential type is one that this service
12+
issues, or False otherwise.
13+
14+
XRPL credential types can be any binary data; this service issues
15+
any credential that can be encoded from the following ASCII chars:
16+
alphanumeric characters, underscore, period, and dash.
17+
(min length 1, max 64)
18+
19+
You might want to further limit the credential types, depending on your
20+
use case; for example, you might only issue one specific credential type.
21+
"""
22+
CREDENTIAL_REGEX = re.compile(r'^[A-Za-z0-9_\.\-]{1,64}$')
23+
if CREDENTIAL_REGEX.match(credential_type):
24+
return True
25+
return False
26+
27+
28+
def is_allowed_uri(uri):
29+
"""
30+
Returns True if the specified URI is acceptable for this service, or
31+
False otherwise.
32+
33+
XRPL Credentials' URI values can be any binary data; this service
34+
adds any user-requested URI to a Credential as long as the URI
35+
can be encoded from the characters usually allowed in URIs, namely
36+
the following ASCII chars:
37+
38+
alphanumeric characters (upper and lower case)
39+
the following symbols: -._~:/?#[]@!$&'()*+,;=%
40+
(minimum length 1 and max length 256 chars)
41+
42+
You might want to instead define your own URI and attach it to the
43+
Credential regardless of user input, or you might want to verify that the
44+
URI points to a valid Verifiable Credential document that matches the user.
45+
"""
46+
URI_REGEX = re.compile(r"^[A-Za-z0-9\-\._~:/\?#\[\]@!$&'\(\)\*\+,;=%]{1,256}$")
47+
if URI_REGEX.match(uri):
48+
return True
49+
return False
50+
51+
52+
class Credential:
53+
"""
54+
A credential object, in a simplified format for our API.
55+
The constructor performs parameter validation. Attributes:
56+
subject (str): the subject of the credential, as a classic address
57+
credential (str): the credential type, in human-readable (ASCII) chars
58+
uri (str, optional): URI of the credential in human-readable (ASCII) chars
59+
expiration (datetime, optional): time when the credential expires
60+
(displayed as an ISO 8601 format string in JSON)
61+
accepted (bool, optional): true if this credential has been accepted
62+
on the XRPL by the subject account.
63+
False if not accepted.
64+
Omitted for credentials that haven't been
65+
issued yet.
66+
"""
67+
def __init__(self, d: dict):
68+
self.subject = d.get("subject")
69+
if type(self.subject) != str:
70+
raise ValueError("Must provide a string 'subject' field")
71+
if not is_valid_classic_address(self.subject):
72+
raise ValueError(f"subject not valid address: '{self.subject}'")
73+
74+
self.credential = d.get("credential")
75+
if type(self.credential) != str:
76+
raise ValueError("Must provide a string 'credential' field")
77+
78+
if not is_allowed_credential_type(self.credential):
79+
raise ValueError(f"credential not allowed: '{self.credential}'.")
80+
81+
self.uri = d.get("uri")
82+
if self.uri is not None and (
83+
type(self.uri) != str or not is_allowed_uri(self.uri)):
84+
raise ValueError(f"URI isn't valid: {self.uri}")
85+
86+
exp = d.get("expiration")
87+
if exp:
88+
if type(exp) == str:
89+
self.expiration = datetime.fromisoformat(exp)
90+
elif type(exp) == datetime:
91+
self.expiration = exp
92+
else:
93+
raise ValueError(f"Unsupported expiration format: {type(exp)}")
94+
else:
95+
self.expiration = None
96+
97+
self.accepted = d.get("accepted")
98+
99+
@classmethod
100+
def from_xrpl(cls, xrpl_d: dict):
101+
"""
102+
Instantiate from a Credential ledger entry in the XRPL format.
103+
"""
104+
d = {
105+
"subject": xrpl_d["Subject"],
106+
"credential": decode_hex(xrpl_d["CredentialType"]),
107+
"accepted": bool(xrpl_d["Flags"] & 0x00010000) # lsfAccepted
108+
}
109+
if xrpl_d.get("URI"):
110+
d["uri"] = decode_hex(xrpl_d["URI"])
111+
if xrpl_d.get("Expiration"):
112+
d["expiration"] = ripple_time_to_datetime(xrpl_d["Expiration"])
113+
return cls(d)
114+
115+
def to_dict(self):
116+
d = {
117+
"subject": self.subject,
118+
"credential": self.credential,
119+
}
120+
if self.expiration is not None:
121+
d["expiration"] = self.expiration.isoformat()
122+
if self.uri:
123+
d["uri"] = self.uri
124+
if self.accepted is not None:
125+
d["accepted"] = self.accepted
126+
return d
127+
128+
def to_xrpl(self):
129+
"""
130+
Return an object with parameters formatted for the XRPL
131+
"""
132+
return XrplCredential(self)
133+
134+
class XrplCredential:
135+
"""
136+
A Credential object, in a format closer to the XRP Ledger representation.
137+
Credential type and URI are hexadecimal;
138+
Expiration, if present, is in seconds since the Ripple Epoch.
139+
"""
140+
def __init__(self, c:Credential):
141+
self.subject = c.subject
142+
self.credential = str_to_hex(c.credential)
143+
if c.expiration:
144+
self.expiration = datetime_to_ripple_time(c.expiration)
145+
else:
146+
self.expiration = None
147+
148+
if c.uri:
149+
self.uri = str_to_hex(c.uri)
150+
else:
151+
self.uri = None
152+
153+
class CredentialRequest(Credential):
154+
"""
155+
Request from user to issue a credential on ledger.
156+
The constructor performs parameter validation.
157+
"""
158+
def __init__(self, cred_request):
159+
super().__init__(cred_request)
160+
# As a credential issuer, you typically need to verify some information
161+
# about someone before you issue them a credential. For this example,
162+
# the user passes relevant information in a documents field of the API
163+
# request. The documents are kept confidential, off-chain.
164+
self.documents = cred_request.get("documents")
165+
166+
def verify_documents(self):
167+
# This is where you would check the user's documents to see if you
168+
# should issue the requested Credential to them.
169+
# Depending on the type of credentials your service needs, you might
170+
# need to implement different types of checks here.
171+
if not self.documents:
172+
raise ValueError(f"you must provide a non-empty 'documents' field")
173+
174+
# As a placeholder, this example checks that the documents field
175+
# contains a string field named "reason" containing the word "please"
176+
if type(self.documents.get("reason")) != str:
177+
raise ValueError(f"documents must contain a 'reason' string")
178+
if "please" not in self.documents["reason"].lower():
179+
raise ValueError(f"reason must include 'please'")
180+
181+
return True
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from binascii import unhexlify
2+
3+
def decode_hex(s_hex):
4+
"""
5+
Try decoding a hex string as ASCII; return the decoded string on success,
6+
or the un-decoded string prefixed by '(BIN) ' on failure.
7+
"""
8+
try:
9+
s = unhexlify(s_hex).decode("ascii")
10+
# Could use utf-8 instead, but it has more edge cases.
11+
# Optionally, sanitize the string for display before returning
12+
except:
13+
s = "(BIN) "+s_hex
14+
return s

0 commit comments

Comments
 (0)