diff --git a/starling b/starling index 5efd4ee..b48d098 100755 --- a/starling +++ b/starling @@ -3,15 +3,22 @@ #-----------------------------------------------------------------------------# import argparse +import base64 +from cryptography.hazmat.primitives.asymmetric.ec import ECDSA +from cryptography.hazmat.primitives.hashes import SHA512 +from cryptography.hazmat.primitives.serialization import load_pem_private_key +import datetime +import hashlib import io import json import re import requests import sys import types +import urllib base_url = 'https://api.starlingbank.com/api/v2' -uid_re = re.compile(r'^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[\da-f]{4}-[\da-f]{12}$') +uid_re = re.compile(r'[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[\da-f]{4}-[\da-f]{12}') #-----------------------------------------------------------------------------# @@ -50,11 +57,11 @@ class Question: return size >= self.values[0] and size <= self.values[1] if self.type == 'digits': size = len(self.answer) - return size == self.values and re.match('^\d+$', self.answer) + return size == self.values and re.fullmatch('\d+', self.answer) if self.type == 'enum': return self.answer in self.values if self.type == 'uid': - return uid_re.match(self.answer) != None + return uid_re.fullmatch(self.answer) != None else: raise Exception(f'Unknown value type "{self.type}" for "{self.description}"') @@ -84,6 +91,26 @@ class Form: sys.exit(1) return dict(map(lambda q: (q.name, q.answer), self.questions)) +class StarlingKey: + + algorithm = 'ecdsa-sha512' + uid_line_re = re.compile(r'^Key Uid:\s*(' + uid_re.pattern + r')\s*$') + + def __init__(self, name): + path = f'keys/{name}.key' + with io.open(path, 'rb') as fh: + line = fh.readline().decode('utf-8').rstrip() + match = self.uid_line_re.fullmatch(line) + if match is None: + sys.exit(f'ERROR: missing/malformed "Key Uid:" line in "{path}"') + self.uid = match.group(1) + self.key = load_pem_private_key(fh.read(), None) + #print(f'Loaded key with UID {self.uid} from "{path}"') + + def sign(self, content): + signature = self.key.sign(content.encode('utf-8'), ECDSA(SHA512())) + return base64.b64encode(signature).decode('utf-8') + class StarlingClient: new_payee_form = Form( @@ -103,8 +130,11 @@ class StarlingClient: Question('account_description', 'Account description', 'string', (1, 255,)), ) + amount_re = re.compile(r'^(\d+)\.(\d\d)$') + def __init__(self): self.tokens = self.read_tokens('main', 'payments') + self.api_key = StarlingKey('starling-api-key') def read_tokens(self, *names): tokens = {} @@ -126,7 +156,22 @@ class StarlingClient: headers = { 'Authorization': f'Bearer {self.tokens[token]}', } - response = requests.put(url, json=data, headers=headers) + response = requests.put(url, headers=headers, json=data) + return self.parse_response(response) + + def signed_put(self, path, data, token='main'): + url = f'{base_url}/{path}' + body = (json.dumps(data) + '\n').encode('utf-8') + date = datetime.datetime.utcnow().isoformat() + '+00:00' + digest = base64.b64encode(hashlib.sha512(body).digest()).decode('utf-8') + signature = self.make_signature('put', url, date, digest) + headers = { + 'Authorization': f'Bearer {self.tokens[token]}; Signature {signature}', + 'Content-Type': 'application/json', + 'Date': date, + 'Digest': digest, + } + response = requests.put(url, headers=headers, data=body) return self.parse_response(response) def delete(self, path, token='main'): @@ -146,6 +191,13 @@ class StarlingClient: sys.exit(1) return json.loads(response.text, object_hook=lambda obj: types.SimpleNamespace(**obj)) + def make_signature(self, method, url, date, digest): + path = urllib.parse.urlparse(url).path + headers = '(request-target) Date Digest' + content = f'(request-target): {method} {path}\nDate: {date}\nDigest: {digest}' + signature = self.api_key.sign(content) + return f'keyid="{self.api_key.uid}",algorithm="{StarlingKey.algorithm}",headers="{headers}",signature="{signature}"' + ### Low-level API wrappers ### def account_holder(self): @@ -157,8 +209,8 @@ class StarlingClient: def accounts(self): return self.get('accounts') - def balance(self, accountUid): - return self.get(f'accounts/{accountUid}/balance') + def balance(self, account_uid): + return self.get(f'accounts/{account_uid}/balance') def payees(self, data=None): if data is None: @@ -168,10 +220,13 @@ class StarlingClient: def payees_account(self, payee_uid, data): return self.put(f'payees/{payee_uid}/account', data) + def payment(self, account_uid, category_uid, data): + return self.signed_put(f'payments/local/account/{account_uid}/category/{category_uid}', data, token='payments') + ### Mid-level methods to munge the data from the low-level calls ### - def formatted_balance(self, accountUid): - balance = self.balance(accountUid).effectiveBalance + def formatted_balance(self, account_uid): + balance = self.balance(account_uid).effectiveBalance if balance.currency == 'GBP': symbol = '£' elif balance.currency == 'EUR': @@ -287,6 +342,10 @@ class StarlingClient: sys.exit(1) def pay(self, payee_uid, amount, ref): + match = self.amount_re.match(amount) + if match is None: + sys.exit(f'ERROR: bad amount "{amount}" - must be number with two decimal places') + minor_units = (int(match.group(1)) * 100) + int(match.group(2)) primary = None for account in self.accounts().accounts: if account.accountType == 'PRIMARY': @@ -294,7 +353,17 @@ class StarlingClient: break else: sys.exit('ERROR: No PRIMARY account found') - print(primary) + data = { + 'externalIdentifier': str(datetime.datetime.utcnow().timestamp()), + 'destinationPayeeAccountUid': payee_uid, + 'reference': ref, + 'amount': { + 'currency': 'GBP', + 'minorUnits': minor_units, + } + } + response = self.payment(primary.accountUid, primary.defaultCategory, data) + print(f'Successfully created payment order with UID {response.paymentOrderUid}') #-----------------------------------------------------------------------------# @@ -338,8 +407,7 @@ elif action == 'payee': parser.error(f'Too few arguments for "{action} {subaction}" action') if count > 2: parser.error(f'Too many arguments for "{action} {subaction}" action') - payee_uid = action_args.pop(0) - client.payee_del(payee_uid) + client.payee_del(*action_args) else: parser.error(f'Unknown "{action} {subaction}" action') elif action == 'account': @@ -355,8 +423,7 @@ elif action == 'account': parser.error(f'Too few arguments for "{action} {subaction}" action') if count > 2: parser.error(f'Too many arguments for "{action} {subaction}" action') - account_uid = action_args.pop(0) - client.account_del(account_uid) + client.account_del(*actuon_args) else: parser.error(f'Unknown "{action} {subaction}" action') elif action == 'pay':