Skip to content

Commit 3efb118

Browse files
authored
Merge pull request #16 from Ankvik-Tech-Labs/feat/add-gcp-credentials-support
Feat: added gcp credentials support
2 parents 440a802 + b45c4e1 commit 3efb118

File tree

9 files changed

+283
-5
lines changed

9 files changed

+283
-5
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.1.0] - 2025-01-09
6+
7+
### 🚀 Features
8+
9+
- Added option to pass credentials as dict object
10+
11+
### ⚙️ Miscellaneous Tasks
12+
13+
- Changelog updated
14+
515
## [0.0.2] - 2025-01-02
616

717
### 🚀 Features
@@ -29,6 +39,7 @@ All notable changes to this project will be documented in this file.
2939
- Version updated to 0.0.2
3040
- Changelog updated
3141
- Changelog updated
42+
- Changelog updated
3243

3344
## [0.0.1] - 2024-12-27
3445

README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,114 @@ except Exception as e:
368368
print(f"Transaction error: {e}")
369369
```
370370

371+
## 🚀 CI/CD Pipeline Integration
372+
373+
### 🔄 Using in GitHub Actions or Other CI/CD Pipelines
374+
375+
For CI/CD environments where you can't use traditional environment variables or service account files, you can pass the credentials directly as a JSON string:
376+
377+
```python
378+
from web3_google_hsm.accounts.gcp_kms_account import GCPKmsAccount
379+
from web3_google_hsm.config import BaseConfig
380+
import json
381+
382+
# Create config from environment variables
383+
config = BaseConfig(
384+
project_id="your-project-id",
385+
location_id="your-location",
386+
key_ring_id="your-keyring",
387+
key_id="your-key-id"
388+
)
389+
390+
# Load credentials from CI/CD secret
391+
credentials = json.loads(os.environ["GCP_ADC_CREDENTIALS_STRING"])
392+
393+
# Initialize account with both config and credentials
394+
account = GCPKmsAccount(config=config, credentials=credentials)
395+
396+
# or Let the class read the values from env variables
397+
account = GCPKmsAccount(credentials=credentials)
398+
399+
```
400+
401+
### 🔒 GitHub Actions Example
402+
403+
```yaml
404+
name: Deploy with HSM Signing
405+
406+
jobs:
407+
deploy:
408+
runs-on: ubuntu-latest
409+
steps:
410+
- uses: actions/checkout@v2
411+
412+
- name: Set up Python
413+
uses: actions/setup-python@v2
414+
with:
415+
python-version: '3.10'
416+
417+
- name: Install dependencies
418+
run: pip install web3-google-hsm
419+
420+
- name: Sign and Deploy
421+
env:
422+
GOOGLE_CLOUD_PROJECT: ${{ secrets.GCP_PROJECT_ID }}
423+
GOOGLE_CLOUD_REGION: ${{ secrets.GCP_REGION }}
424+
KEY_RING: ${{ secrets.GCP_KEYRING }}
425+
KEY_NAME: ${{ secrets.GCP_KEY_NAME }}
426+
GCP_ADC_CREDENTIALS_STRING: ${{ secrets.GCP_ADC_CREDENTIALS_STRING }}
427+
run: |
428+
python your_deployment_script.py
429+
```
430+
431+
### 📝 Example Deployment Script
432+
```python
433+
import os
434+
import json
435+
from web3_google_hsm.accounts.gcp_kms_account import GCPKmsAccount
436+
from web3_google_hsm.config import BaseConfig
437+
from web3_google_hsm.types.ethereum_types import Transaction
438+
439+
def deploy_contract():
440+
# Initialize with both config and credentials
441+
config = BaseConfig.from_env() # Uses environment variables
442+
credentials = json.loads(os.environ["GCP_ADC_CREDENTIALS_STRING"])
443+
444+
account = GCPKmsAccount(config=config, credentials=credentials)
445+
446+
# Your deployment logic here
447+
print(f"Deploying from address: {account.address}")
448+
449+
# Example transaction
450+
tx = Transaction(
451+
nonce=0,
452+
gas_price=2000000000,
453+
gas_limit=1000000,
454+
to="0x...",
455+
value=0,
456+
data="0x...",
457+
chain_id=1
458+
)
459+
460+
signed_tx = account.sign_transaction(tx)
461+
# Send transaction...
462+
463+
if __name__ == "__main__":
464+
deploy_contract()
465+
```
466+
467+
### 🔑 Required Secrets for CI/CD
468+
469+
Set these secrets in your CI/CD environment:
470+
471+
- `GCP_PROJECT_ID`: Your Google Cloud project ID
472+
- `GCP_REGION`: The region where your KMS resources are located
473+
- `GCP_KEYRING`: The name of your KMS key ring
474+
- `GCP_KEY_NAME`: The name of your KMS key
475+
- `GCP_ADC_CREDENTIALS_STRING`: Your service account credentials JSON as a string
476+
477+
478+
371479

372480
---
373481

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ dynamic = ["version"]
99
license = { file = "LICENSE" }
1010
classifiers = [
1111
"Programming Language :: Python :: 3",
12+
"Programming Language :: Python :: 3.9",
1213
"Programming Language :: Python :: 3.10",
14+
"Programming Language :: Python :: 3.11",
15+
"Programming Language :: Python :: 3.12",
16+
"Programming Language :: Python :: 3.13",
1317
"Programming Language :: Python :: Implementation :: CPython",
1418
"Programming Language :: Python :: Implementation :: PyPy",
1519
]

src/web3_google_hsm/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VERSION = "0.0.2"
1+
VERSION = "0.1.0"

src/web3_google_hsm/accounts/gcp_kms_account.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from eth_account.messages import _hash_eip191_message, encode_defunct # noqa: PLC2701
99
from eth_typing import ChecksumAddress
1010
from eth_utils import keccak, to_checksum_address
11+
from google.auth import load_credentials_from_dict
1112
from google.cloud import kms
1213
from google.protobuf import duration_pb2 # type: ignore
1314
from pydantic import BaseModel, Field, PrivateAttr
@@ -33,10 +34,33 @@ class GCPKmsAccount(BaseModel):
3334
_cached_public_key: bytes | None = PrivateAttr(default=None)
3435
_settings: BaseConfig = PrivateAttr()
3536

36-
def __init__(self, config: BaseConfig | None = None, **data: Any):
37+
def __init__(self, config: BaseConfig | None = None, credentials: dict | None = None, **data: Any):
38+
"""
39+
Initialize GCP KMS Account with either config or credentials.
40+
If neither is provided, uses Google SDK default auth mechanism.
41+
42+
Args:
43+
config: BaseConfig instance for environment-based configuration
44+
credentials: Dictionary containing GCP credentials
45+
**data: Additional data passed to BaseModel
46+
47+
Raises:
48+
ValueError: If both config and credentials are provided
49+
"""
50+
3751
super().__init__(**data)
38-
self._client = kms.KeyManagementServiceClient()
52+
53+
if isinstance(credentials, dict):
54+
credentials, _ = load_credentials_from_dict(credentials)
55+
# Initialize client based on provided auth method
56+
self._client = (
57+
kms.KeyManagementServiceClient(credentials=credentials) if credentials else kms.KeyManagementServiceClient() # type: ignore
58+
)
59+
60+
# Initialize settings if config is provided, otherwise None
3961
self._settings = config or BaseConfig.from_env()
62+
63+
# Set key path based on config or credentials
4064
self.key_path = self._get_key_version_path()
4165

4266
def _get_key_version_path(self) -> str:

src/web3_google_hsm/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,9 @@ class SignatureError(Exception):
22
"""
33
Raised when there are issues with signing.
44
"""
5+
6+
7+
class ConfigurationError(Exception):
8+
"""
9+
Raised when there are issues with the configuration.
10+
"""
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import os
2+
3+
from pydantic import ValidationError
4+
import pytest
5+
from web3_google_hsm.accounts.gcp_kms_account import GCPKmsAccount
6+
from web3_google_hsm.config import BaseConfig
7+
import json
8+
9+
# Define required environment variables
10+
REQUIRED_ENV_VARS = {
11+
"GOOGLE_CLOUD_PROJECT": os.getenv("GOOGLE_CLOUD_PROJECT"),
12+
"GOOGLE_CLOUD_REGION": os.getenv("GOOGLE_CLOUD_REGION"),
13+
"KEY_RING": os.getenv("KEY_RING"),
14+
"KEY_NAME": os.getenv("KEY_NAME"),
15+
"GCP_ADC_CREDENTIALS_STRING": os.getenv("GCP_ADC_CREDENTIALS_STRING"),
16+
}
17+
18+
# Skip all tests if any required env var is missing
19+
missing_vars = [k for k, v in REQUIRED_ENV_VARS.items() if not v]
20+
pytestmark = pytest.mark.skipif(
21+
bool(missing_vars),
22+
reason=f"Missing required environment variables: {', '.join(missing_vars)}"
23+
)
24+
25+
def test_account_initialization_with_both():
26+
"""Test initializing account with both config and credentials."""
27+
# Load credentials from GCP_ADC_CREDENTIALS_STRING env var
28+
credentials = json.loads(os.environ["GCP_ADC_CREDENTIALS_STRING"])
29+
30+
# Create config from environment
31+
config = BaseConfig.from_env()
32+
33+
# Initialize account with both
34+
account = GCPKmsAccount(config=config, credentials=credentials)
35+
36+
# Verify initialization
37+
assert account._client is not None
38+
assert account._settings is not None
39+
assert account.key_path is not None
40+
assert account.address.startswith("0x")
41+
42+
# Test basic functionality
43+
message = "Test message"
44+
signature = account.sign_message(message)
45+
assert signature.v in (27, 28)
46+
assert len(signature.r) == 32
47+
assert len(signature.s) == 32
48+
49+
def test_account_initialization_with_neither():
50+
"""Test initializing account with neither config nor credentials (using env vars)."""
51+
# Initialize account without explicit config or credentials
52+
account = GCPKmsAccount()
53+
54+
# Verify initialization
55+
assert account._client is not None
56+
assert account._settings is not None # Should be created from env
57+
assert account.key_path is not None
58+
assert account.address.startswith("0x")
59+
60+
# Test basic functionality
61+
message = "Test message"
62+
signature = account.sign_message(message)
63+
assert signature.v in (27, 28)
64+
assert len(signature.r) == 32
65+
assert len(signature.s) == 32
66+
67+
def test_fail_account_initialization_with_only_config(monkeypatch):
68+
"""Test that initializing with only config raises error."""
69+
env_vars_to_clear = [
70+
"GOOGLE_CLOUD_PROJECT",
71+
"GOOGLE_CLOUD_REGION",
72+
"KEY_RING",
73+
"KEY_NAME",
74+
"GOOGLE_APPLICATION_CREDENTIALS",
75+
"GCP_ADC_CREDENTIALS_STRING"
76+
]
77+
78+
for env_var in env_vars_to_clear:
79+
monkeypatch.delenv(env_var, raising=False)
80+
81+
with pytest.raises(ValidationError):
82+
config = BaseConfig.from_env()
83+
GCPKmsAccount(config=config)
84+
85+
def test_fail_account_initialization_with_only_credentials(monkeypatch):
86+
"""Test that initializing with only credentials raises error."""
87+
env_vars_to_clear = [
88+
"GOOGLE_CLOUD_PROJECT",
89+
"GOOGLE_CLOUD_REGION",
90+
"KEY_RING",
91+
"KEY_NAME",
92+
]
93+
94+
for env_var in env_vars_to_clear:
95+
monkeypatch.delenv(env_var, raising=False)
96+
credentials = json.loads(os.environ["GCP_ADC_CREDENTIALS_STRING"])
97+
98+
with pytest.raises(ValidationError):
99+
GCPKmsAccount(credentials=credentials)
100+
101+
def test_key_path_matches_config(monkeypatch):
102+
"""Test that the key path matches the config values."""
103+
# Load both config and credentials
104+
credentials = json.loads(os.environ["GCP_ADC_CREDENTIALS_STRING"])
105+
106+
config = BaseConfig.from_env()
107+
account = GCPKmsAccount(config=config, credentials=credentials)
108+
109+
# Verify key path contains all the expected components
110+
assert config.project_id in account.key_path
111+
assert config.location_id in account.key_path
112+
assert config.key_ring_id in account.key_path
113+
assert config.key_id in account.key_path

tests/integration/accounts/test_gcp_kms_account_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def test_transaction_signing(gcp_account, fund_account, web3):
6262
gas_price=web3.eth.gas_price,
6363
gas_limit=21000,
6464
to="0xa5D3241A1591061F2a4bB69CA0215F66520E67cf",
65-
value=web3.to_wei(0.001, "ether"),
65+
value=web3.to_wei(0.0001, "ether"),
6666
data="0x",
6767
from_=gcp_account.address
6868
)

tests/unit/test_cli.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,20 @@ def test_generate_with_explicit_args(
122122
assert result.exit_code == 0
123123
assert "Created Ethereum signing key" in result.stdout
124124

125-
def test_fail_generate_missing_required_args(self, runner: CliRunner) -> None:
125+
def test_fail_generate_missing_required_args(self, runner: CliRunner, monkeypatch) -> None:
126126
"""Test key generation fails when required arguments are missing."""
127+
# ? Need to clrear the env vars in order to raise error in the cli
128+
env_vars_to_clear = [
129+
"GOOGLE_CLOUD_PROJECT",
130+
"GOOGLE_CLOUD_REGION",
131+
"KEY_RING",
132+
"KEY_NAME",
133+
"GOOGLE_APPLICATION_CREDENTIALS",
134+
"GCP_ADC_CREDENTIALS_STRING"
135+
]
136+
137+
for env_var in env_vars_to_clear:
138+
monkeypatch.delenv(env_var, raising=False)
127139
result = runner.invoke(app, ["generate"])
128140
assert result.exit_code == 2
129141
assert "Usage:" in result.stdout

0 commit comments

Comments
 (0)