Overview
VoicePact implements multi-modal verification to accommodate both smartphone and feature phone users across African markets. Parties can confirm contracts using:
- SMS confirmations with OTP codes
- USSD menus (dial codes like
*483#)
- Cryptographic signatures for audit trail integrity
This inclusive approach ensures that informal sector participants without smartphones can still participate in digital contracting.
Verification Methods
1. SMS Confirmation
The primary verification method uses SMS with confirmation codes.
Flow:
1. Contract created
↓
2. SMS sent to all parties with contract summary + code
↓
3. Party replies "YES-{CONTRACT_ID}" to confirm
↓
4. System validates reply and updates signature status
↓
5. When all parties confirm → Contract status = CONFIRMED
SMS generation (africastalking_client.py:407-430):
def generate_contract_sms(
self,
contract_id: str,
contract_terms: Dict[str, Any]
) -> str:
product = contract_terms.get('product', 'Product')
quantity = contract_terms.get('quantity', '')
unit = contract_terms.get('unit', '')
total_amount = contract_terms.get('total_amount', 0)
currency = contract_terms.get('currency', 'KES')
delivery_date = contract_terms.get('delivery_deadline', '')
quantity_str = f"{quantity} {unit}" if quantity and unit else "Items"
amount_str = f"{currency} {total_amount:,.2f}" if total_amount else "Amount pending"
date_str = f", Due: {delivery_date}" if delivery_date else ""
return (
f"VoicePact Contract Summary:\n"
f"ID: {contract_id}\n"
f"Product: {product} ({quantity_str})\n"
f"Total: {amount_str}{date_str}\n"
f"Reply YES-{contract_id} to confirm or NO-{contract_id} to decline"
)
Sending confirmations (contracts.py:427-446):
async def send_contract_confirmations(
contract_id: str,
parties: List[Dict[str, str]],
terms: Dict[str, Any],
at_client: AfricasTalkingClient
):
try:
message = at_client.generate_contract_sms(contract_id, terms)
recipients = [party["phone"] for party in parties]
await at_client.send_sms(
message=message,
recipients=recipients
)
logger.info(f"Contract confirmations sent for {contract_id}")
except Exception as e:
logger.error(f"Failed to send contract confirmations: {e}")
SMS webhook handler (sms.py:293-328):
@router.post("/webhook")
async def sms_webhook(request: Request):
try:
form_data = await request.form()
phone_number = form_data.get("from")
message = form_data.get("text", "").upper().strip()
# Handle contract confirmations
if message.startswith("YES-") or message.startswith("NO-"):
contract_id = message.split("-", 1)[1] if "-" in message else "unknown"
action = "confirm" if message.startswith("YES-") else "reject"
logger.info(f"Contract {action}: {contract_id} from {phone_number}")
# Update signature status in database
if action == "confirm":
await confirm_contract(contract_id, phone_number, db)
else:
await reject_contract(contract_id, phone_number, db)
return {
"action": action,
"contract_id": contract_id,
"phone_number": phone_number
}
return {"status": "webhook_received"}
except Exception as e:
logger.error(f"SMS webhook error: {e}")
return {"status": "webhook_error", "error": str(e)}
SMS confirmations work on any phone, including basic feature phones. Users simply send a text reply to confirm.
2. SMS OTP Codes
For higher-security workflows, VoicePact generates 6-digit OTP codes tied to each party.
OTP generation (crypto_service.py:101-111):
def generate_sms_confirmation_code(
self,
contract_id: str,
phone_number: str
) -> str:
try:
# Create daily-rotated hash
content = f"{contract_id}:{phone_number}:{datetime.utcnow().date().isoformat()}"
hash_obj = hashlib.sha256(content.encode('utf-8'))
hex_hash = hash_obj.hexdigest()
# Convert to 6-digit code
numeric_code = int(hex_hash[:8], 16) % 1000000
return f"{numeric_code:06d}"
except Exception as e:
logger.error(f"SMS code generation failed: {e}")
raise CryptographicError(f"Failed to generate SMS code: {e}")
OTP verification (crypto_service.py:113-119):
def verify_sms_confirmation(
self,
contract_id: str,
phone_number: str,
code: str
) -> bool:
try:
expected_code = self.generate_sms_confirmation_code(contract_id, phone_number)
# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(code, expected_code)
except Exception as e:
logger.error(f"SMS verification failed: {e}")
return False
Usage pattern:
SMS to party: "Your VoicePact confirmation code for contract AG-260306-A1B2C3 is: 482719. Reply with this code to confirm."
Party replies: "482719"
System validates code and confirms signature.
OTP codes are time-limited (daily rotation) and phone-specific, preventing replay attacks.
3. USSD Verification
USSD (Unstructured Supplementary Service Data) provides interactive menus accessible via short codes.
USSD menu flow:
User dials: *483#
┌─────────────────────────────────┐
│ VoicePact │
│ 1. View My Contracts │
│ 2. Confirm Delivery │
│ 3. Check Payments │
│ 4. Report Issue │
└─────────────────────────────────┘
User selects: 1
┌─────────────────────────────────┐
│ Select Contract: │
│ 1. AG-260306-123 - Pending │
│ (KES 150,000) │
│ 2. GP-260305-456 - Active │
│ (KES 50,000) │
└─────────────────────────────────┘
User selects: 1
┌─────────────────────────────────┐
│ Contract: AG-260306-123 │
│ Product: Maize (100 bags) │
│ Amount: KES 150,000 │
│ Status: Pending │
│ │
│ 1. Confirm Contract │
│ 2. Decline │
│ 0. Back │
└─────────────────────────────────┘
USSD session management (contract.py:312-340):
class USSDSession(Base):
__tablename__ = "ussd_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
session_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
phone_number: Mapped[str] = mapped_column(String(20), index=True)
# Navigation state
current_menu: Mapped[str] = mapped_column(String(50), default="main")
context_data: Mapped[dict] = mapped_column(JSON, default=dict)
last_input: Mapped[Optional[str]] = mapped_column(String(200))
last_response: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
expires_at: Mapped[datetime] = mapped_column(DateTime)
USSD response builder (africastalking_client.py:382-385):
def build_ussd_response(self, text: str, end_session: bool = False) -> str:
if end_session:
return f"END {text}"
return f"CON {text}"
CON = Continue session (show menu)
END = End session (final message)
USSD menu generators (africastalking_client.py:461-492):
def generate_ussd_contract_menu(self, contracts: List[Dict[str, Any]]) -> str:
if not contracts:
return "No active contracts found."
menu_items = ["Select Contract:"]
for i, contract in enumerate(contracts[:9], 1):
status = contract.get('status', 'unknown')
amount = contract.get('total_amount', 0)
currency = contract.get('currency', 'KES')
menu_items.append(
f"{i}. {contract['id']} - {status.title()} ({currency} {amount:,.0f})"
)
return "\n".join(menu_items)
def generate_ussd_contract_detail(self, contract: Dict[str, Any]) -> str:
product = contract.get('terms', {}).get('product', 'Product')
quantity = contract.get('terms', {}).get('quantity', '')
unit = contract.get('terms', {}).get('unit', '')
amount = contract.get('total_amount', 0)
currency = contract.get('currency', 'KES')
status = contract.get('status', 'unknown')
quantity_str = f" ({quantity} {unit})" if quantity and unit else ""
return (
f"Contract: {contract['id']}\n"
f"Product: {product}{quantity_str}\n"
f"Amount: {currency} {amount:,.2f}\n"
f"Status: {status.title()}\n"
f"1. Confirm Delivery\n"
f"2. Report Issue\n"
f"0. Back"
)
USSD works on all phones without internet, making it ideal for rural areas with limited data connectivity.
4. Cryptographic Signatures
All verifications are backed by cryptographic signatures for tamper-proof audit trails.
Ed25519 signature generation (crypto_service.py:65-76):
def sign_contract(self, contract_data: str, phone_number: str) -> str:
try:
# Derive signing key from phone number
signing_key = self._derive_signing_key(phone_number)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(signing_key)
# Create timestamped message
message = f"{contract_data}:{phone_number}:{datetime.utcnow().isoformat()}"
signature = private_key.sign(message.encode('utf-8'))
return base64.b64encode(signature).decode('utf-8')
except Exception as e:
logger.error(f"Contract signing failed: {e}")
raise CryptographicError(f"Failed to sign contract: {e}")
Key derivation (crypto_service.py:246-259):
def _derive_signing_key(self, phone_number: str) -> bytes:
try:
# Use PBKDF2 to derive unique key per phone number
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=self.salt.encode('utf-8'),
iterations=100000,
)
key_material = f"{self.master_key}:{phone_number}".encode('utf-8')
return kdf.derive(key_material)
except Exception as e:
logger.error(f"Key derivation failed: {e}")
raise CryptographicError(f"Failed to derive signing key: {e}")
Signature verification (crypto_service.py:78-99):
def verify_signature(
self,
contract_data: str,
phone_number: str,
signature: str
) -> bool:
try:
signing_key = self._derive_signing_key(phone_number)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(signing_key)
public_key = private_key.public_key()
signature_bytes = base64.b64decode(signature.encode('utf-8'))
# Check multiple time windows (allows for clock skew)
for time_window in [0, 1, 2]:
test_time = datetime.utcnow().replace(
minute=(datetime.utcnow().minute // 10 - time_window) * 10,
second=0, microsecond=0
)
test_message = f"{contract_data}:{phone_number}:{test_time.isoformat()}"
try:
public_key.verify(signature_bytes, test_message.encode('utf-8'))
return True
except:
continue
return False
except Exception as e:
logger.error(f"Signature verification failed: {e}")
return False
Why Ed25519?
- Small signatures (64 bytes) - efficient for SMS/USSD
- Fast verification - critical for real-time responses
- Strong security - equivalent to 3072-bit RSA
- Deterministic - same input always produces same signature
Signature Status Tracking
Each party’s signature is tracked independently (contract.py:173-216):
class SignatureStatus(str, enum.Enum):
PENDING = "pending" # Awaiting confirmation
SIGNED = "signed" # Confirmed
REJECTED = "rejected" # Declined
EXPIRED = "expired" # Timeout
class ContractSignature(Base):
__tablename__ = "contract_signatures"
contract_id: Mapped[str] = mapped_column(
String(50),
ForeignKey("contracts.id", ondelete="CASCADE")
)
signer_phone: Mapped[str] = mapped_column(String(20), index=True)
signature_method: Mapped[str] = mapped_column(
String(20),
default="sms_confirmation"
)
signature_hash: Mapped[str] = mapped_column(String(128))
signature_data: Mapped[Optional[dict]] = mapped_column(JSON, default=dict)
status: Mapped[SignatureStatus] = mapped_column(
SQLEnum(SignatureStatus),
default=SignatureStatus.PENDING
)
signed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
Phone Number Validation
Ensuring correct phone number formats (africastalking_client.py:390-405):
async def validate_phone_number(self, phone_number: str) -> bool:
if not phone_number.startswith('+'):
return False
phone_digits = ''.join(filter(str.isdigit, phone_number))
return len(phone_digits) >= 10 and len(phone_digits) <= 15
async def format_phone_number(
self,
phone_number: str,
country_code: str = "254"
) -> str:
phone_clean = ''.join(filter(str.isdigit, phone_number))
if phone_clean.startswith(country_code):
return f"+{phone_clean}"
elif phone_clean.startswith('0'):
return f"+{country_code}{phone_clean[1:]}"
else:
return f"+{country_code}{phone_clean}"
Multi-Party Confirmation
Contracts require all parties to confirm before activation:
# Check signature progress (contracts.py:313-337)
all_signatures_result = await db.execute(
select(ContractSignature).where(
ContractSignature.contract_id == contract_id
)
)
all_signatures = all_signatures_result.scalars().all()
signed_count = sum(
1 for sig in all_signatures
if sig.status == SignatureStatus.SIGNED
)
if signed_count == len(all_signatures):
# All parties confirmed - update contract
contract.status = ContractStatus.CONFIRMED
contract.confirmed_at = datetime.utcnow()
await db.commit()
Security Best Practices
Verification security:
- Constant-time comparison - Use
hmac.compare_digest() to prevent timing attacks
- Key derivation - Never reuse master keys; derive per-phone keys with PBKDF2
- Signature expiration - Time-limited signatures prevent replay attacks
- Rate limiting - Prevent SMS/USSD flooding (planned)
- Phone verification - Validate format before processing
- Audit logging - Record all verification attempts
Testing
Send test SMS:
curl -X POST http://localhost:8000/sms/test \
-d 'phone_number=+254712345678'
Simulate USSD session:
curl -X POST http://localhost:8000/ussd/webhook \
-d 'sessionId=ATUid_test123' \
-d 'phoneNumber=+254712345678' \
-d 'text=*483#'