Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Key rotation operations #525

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion aries_cloudagent/config/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,14 @@ def add_arguments(self, parser: ArgumentParser):
"--wallet-key",
type=str,
metavar="<wallet-key>",
help="Specifies the master key value to use for opening the wallet.",
help="Specifies the master key value to use to open the wallet.",
)
parser.add_argument(
"--wallet-rekey",
type=str,
metavar="<wallet-rekey>",
help="Specifies a new master key value to which to rotate and to\
open the wallet next time.",
)
parser.add_argument(
"--wallet-name",
Expand Down Expand Up @@ -801,6 +808,8 @@ def get_settings(self, args: Namespace) -> dict:
settings["wallet.seed"] = args.seed
if args.wallet_key:
settings["wallet.key"] = args.wallet_key
if args.wallet_rekey:
settings["wallet.rekey"] = args.wallet_rekey
if args.wallet_name:
settings["wallet.name"] = args.wallet_name
if args.wallet_storage_type:
Expand Down
9 changes: 9 additions & 0 deletions aries_cloudagent/ledger/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ async def register_nym(
def nym_to_did(self, nym: str) -> str:
"""Format a nym with the ledger's DID prefix."""

@abstractmethod
async def rotate_public_did_keypair(self, next_seed: str = None) -> None:
"""
Rotate keypair for public DID: create new key, submit to ledger, update wallet.

Args:
next_seed: seed for incoming ed25519 keypair (default random)
"""

def did_to_nym(self, did: str) -> str:
"""Remove the ledger's DID prefix to produce a nym."""
if did:
Expand Down
89 changes: 88 additions & 1 deletion aries_cloudagent/ledger/indy.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
import tempfile

from datetime import datetime, date
from enum import Enum
from hashlib import sha256
from os import path
from time import time
from typing import Sequence, Tuple
from typing import Sequence, Tuple, Union

import indy.ledger
import indy.pool
Expand Down Expand Up @@ -42,6 +43,53 @@
)


class Role(Enum):
"""Enum for indy roles."""

STEWARD = (2,)
TRUSTEE = (0,)
ENDORSER = (101,)
NETWORK_MONITOR = (201,)
USER = (None, "") # in case reading from file, default empty "" or None for USER
ROLE_REMOVE = ("",) # but indy-sdk uses "" to identify a role in reset

@staticmethod
def get(token: Union[str, int] = None) -> "Role":
"""
Return enum instance corresponding to input token.

Args:
token: token identifying role to indy-sdk:
"STEWARD", "TRUSTEE", "ENDORSER", "" or None
"""
if token is None:
return Role.USER

for role in Role:
if role == Role.ROLE_REMOVE:
continue # not a sensible role to parse from any configuration
if isinstance(token, int) and token in role.value:
return role
if str(token).upper() == role.name or token in (str(v) for v in role.value):
return role

return None

def to_indy_num_str(self) -> str:
"""
Return (typically, numeric) string value that indy-sdk associates with role.

Recall that None signifies USER and "" signifies role in reset.
"""

return str(self.value[0]) if isinstance(self.value[0], int) else self.value[0]

def token(self) -> str:
"""Return token identifying role to indy-sdk."""

return self.value[0] if self in (Role.USER, Role.ROLE_REMOVE) else self.name


class IndyLedger(BaseLedger):
"""Indy ledger class."""

Expand Down Expand Up @@ -761,6 +809,45 @@ def nym_to_did(self, nym: str) -> str:
nym = self.did_to_nym(nym)
return f"did:sov:{nym}"

async def rotate_public_did_keypair(self, next_seed: str = None) -> None:
"""
Rotate keypair for public DID: create new key, submit to ledger, update wallet.

Args:
next_seed: seed for incoming ed25519 keypair (default random)
"""
# generate new key
public_info = await self.wallet.get_public_did()
public_did = public_info.did
verkey = await self.wallet.rotate_did_keypair_start(public_did, next_seed)

# submit to ledger (retain role and alias)
nym = self.did_to_nym(public_did)
with IndyErrorHandler("Exception when building nym request", LedgerError):
request_json = await indy.ledger.build_get_nym_request(public_did, nym)
response_json = await self._submit(request_json)
data = json.loads((json.loads(response_json))["result"]["data"])
if not data:
raise BadLedgerRequestError(
f"Ledger has no public DID for wallet {self.wallet.name}"
)
seq_no = data["seqNo"]
txn_req_json = await indy.ledger.build_get_txn_request(None, None, seq_no)
txn_resp_json = await self._submit(txn_req_json)
txn_resp = json.loads(txn_resp_json)
txn_resp_data = txn_resp["result"]["data"]
if not txn_resp_data:
raise BadLedgerRequestError(
f"Bad or missing ledger NYM transaction for DID {public_did}"
)
txn_data_data = txn_resp_data["txn"]["data"]
role_token = Role.get(txn_data_data.get("role")).token()
alias = txn_data_data.get("alias")
await self.register_nym(public_did, verkey, role_token, alias)

# update wallet
await self.wallet.rotate_did_keypair_apply(public_did)

async def get_txn_author_agreement(self, reload: bool = False) -> dict:
"""Get the current transaction author agreement, fetching it if necessary."""
if not self.taa_cache or reload:
Expand Down
25 changes: 24 additions & 1 deletion aries_cloudagent/ledger/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
from marshmallow import fields, Schema, validate

from ..messaging.valid import INDY_DID, INDY_RAW_PUBLIC_KEY
from ..wallet.error import WalletError
from .base import BaseLedger
from .error import LedgerTransactionError
from .error import BadLedgerRequestError, LedgerTransactionError


class AMLRecordSchema(Schema):
Expand Down Expand Up @@ -115,6 +116,27 @@ async def register_ledger_nym(request: web.BaseRequest):
return web.json_response({"success": success})


@docs(tags=["ledger"], summary="Rotate key pair for public DID.")
async def rotate_public_did_keypair(request: web.BaseRequest):
"""
Request handler for rotating key pair associated with public DID.

Args:
request: aiohttp request object
"""
context = request.app["request_context"]
ledger = await context.inject(BaseLedger, required=False)
if not ledger:
raise web.HTTPForbidden()
async with ledger:
try:
await ledger.rotate_public_did_keypair() # do not take seed over the wire
except (WalletError, BadLedgerRequestError):
raise web.HTTPBadRequest()

return web.json_response({})


@docs(
tags=["ledger"], summary="Get the verkey for a DID from the ledger.",
)
Expand Down Expand Up @@ -234,6 +256,7 @@ async def register(app: web.Application):
app.add_routes(
[
web.post("/ledger/register-nym", register_ledger_nym),
web.patch("/ledger/rotate-public-did-keypair", rotate_public_did_keypair),
web.get("/ledger/did-verkey", get_did_verkey, allow_head=False),
web.get("/ledger/did-endpoint", get_did_endpoint, allow_head=False),
web.get("/ledger/taa", ledger_get_taa, allow_head=False),
Expand Down
124 changes: 124 additions & 0 deletions aries_cloudagent/ledger/tests/test_indy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,44 @@
LedgerConfigError,
LedgerError,
LedgerTransactionError,
Role,
TAA_ACCEPTED_RECORD_TYPE,
)
from aries_cloudagent.storage.indy import IndyStorage
from aries_cloudagent.storage.record import StorageRecord
from aries_cloudagent.wallet.base import DIDInfo


class TestRole(AsyncTestCase):
async def test_role(self):
assert Role.get(2) is Role.STEWARD
assert Role.get(0) is Role.TRUSTEE
assert Role.get(101) is Role.ENDORSER
assert Role.get(201) is Role.NETWORK_MONITOR
assert Role.get(None) is Role.USER
assert Role.get(-1) is None
assert Role.get("user") is Role.USER
assert Role.get("steward") is Role.STEWARD
assert Role.get("trustee") is Role.TRUSTEE
assert Role.get("endorser") is Role.ENDORSER
assert Role.get("network_monitor") is Role.NETWORK_MONITOR
assert Role.get("ROLE_REMOVE") is None

assert Role.STEWARD.to_indy_num_str() == "2"
assert Role.TRUSTEE.to_indy_num_str() == "0"
assert Role.ENDORSER.to_indy_num_str() == "101"
assert Role.NETWORK_MONITOR.to_indy_num_str() == "201"
assert Role.USER.to_indy_num_str() is None
assert Role.ROLE_REMOVE.to_indy_num_str() == ""

assert Role.STEWARD.token() == "STEWARD"
assert Role.TRUSTEE.token() == "TRUSTEE"
assert Role.ENDORSER.token() == "ENDORSER"
assert Role.NETWORK_MONITOR.token() == "NETWORK_MONITOR"
assert Role.USER.token() is None
assert Role.ROLE_REMOVE.to_indy_num_str() == ""


@pytest.mark.indy
class TestIndyLedger(AsyncTestCase):
test_did = "55GkHamhTU1ZbTbV2ab9DE"
Expand Down Expand Up @@ -1814,6 +1845,99 @@ async def test_register_nym_read_only(
)
assert "read only" in str(context.exception)

@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close")
@async_mock.patch("indy.ledger.build_get_nym_request")
@async_mock.patch("indy.ledger.build_get_txn_request")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.register_nym")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit")
async def test_rotate_public_did_keypair(
self,
mock_submit,
mock_register_nym,
mock_build_get_txn_request,
mock_build_get_nym_request,
mock_close,
mock_open,
):
mock_wallet = async_mock.MagicMock(
get_public_did=async_mock.CoroutineMock(return_value=self.test_did_info),
rotate_did_keypair_start=async_mock.CoroutineMock(
return_value=self.test_verkey
),
rotate_did_keypair_apply=async_mock.CoroutineMock(return_value=None),
)
mock_wallet.WALLET_TYPE = "indy"
mock_submit.side_effect = [
json.dumps({"result": {"data": json.dumps({"seqNo": 1234})}}),
json.dumps(
{
"result": {
"data": {"txn": {"data": {"role": "101", "alias": "Billy"}}}
}
}
),
]

ledger = IndyLedger("name", mock_wallet)
async with ledger:
await ledger.rotate_public_did_keypair()

@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close")
@async_mock.patch("indy.ledger.build_get_nym_request")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit")
async def test_rotate_public_did_keypair_no_nym(
self, mock_submit, mock_build_get_nym_request, mock_close, mock_open
):
mock_wallet = async_mock.MagicMock(
get_public_did=async_mock.CoroutineMock(return_value=self.test_did_info),
rotate_did_keypair_start=async_mock.CoroutineMock(
return_value=self.test_verkey
),
rotate_did_keypair_apply=async_mock.CoroutineMock(return_value=None),
)
mock_wallet.WALLET_TYPE = "indy"
mock_submit.return_value = json.dumps({"result": {"data": json.dumps(None)}})

ledger = IndyLedger("name", mock_wallet)
async with ledger:
with self.assertRaises(BadLedgerRequestError):
await ledger.rotate_public_did_keypair()

@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close")
@async_mock.patch("indy.ledger.build_get_nym_request")
@async_mock.patch("indy.ledger.build_get_txn_request")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.register_nym")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit")
async def test_rotate_public_did_keypair_corrupt_nym_txn(
self,
mock_submit,
mock_register_nym,
mock_build_get_txn_request,
mock_build_get_nym_request,
mock_close,
mock_open,
):
mock_wallet = async_mock.MagicMock(
get_public_did=async_mock.CoroutineMock(return_value=self.test_did_info),
rotate_did_keypair_start=async_mock.CoroutineMock(
return_value=self.test_verkey
),
rotate_did_keypair_apply=async_mock.CoroutineMock(return_value=None),
)
mock_wallet.WALLET_TYPE = "indy"
mock_submit.side_effect = [
json.dumps({"result": {"data": json.dumps({"seqNo": 1234})}}),
json.dumps({"result": {"data": None}}),
]

ledger = IndyLedger("name", mock_wallet)
async with ledger:
with self.assertRaises(BadLedgerRequestError):
await ledger.rotate_public_did_keypair()

@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close")
@async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit")
Expand Down
29 changes: 29 additions & 0 deletions aries_cloudagent/ledger/tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ async def test_missing_ledger(self):
with self.assertRaises(HTTPForbidden):
await test_module.register_ledger_nym(request)

with self.assertRaises(HTTPForbidden):
await test_module.rotate_public_did_keypair(request)

with self.assertRaises(HTTPForbidden):
await test_module.get_did_verkey(request)

Expand Down Expand Up @@ -110,6 +113,32 @@ async def test_register_nym_ledger_txn_error(self):
with self.assertRaises(HTTPForbidden):
await test_module.register_ledger_nym(request)

async def test_rotate_public_did_keypair(self):
request = async_mock.MagicMock()
request.app = self.app

with async_mock.patch.object(
test_module.web, "json_response", async_mock.Mock()
) as json_response:
self.ledger.rotate_public_did_keypair = async_mock.CoroutineMock()

await test_module.rotate_public_did_keypair(request)
json_response.assert_called_once_with({})

async def test_rotate_public_did_keypair_public_wallet_x(self):
request = async_mock.MagicMock()
request.app = self.app

with async_mock.patch.object(
test_module.web, "json_response", async_mock.Mock()
) as json_response:
self.ledger.rotate_public_did_keypair = async_mock.CoroutineMock(
side_effect=test_module.WalletError("Exception")
)

with self.assertRaises(test_module.web.HTTPBadRequest):
await test_module.rotate_public_did_keypair(request)

async def test_taa_forbidden(self):
request = async_mock.MagicMock()
request.app = self.app
Expand Down
Loading