use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
use base64::Engine as _;
use ed25519_dalek::{SigningKey, Signer};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use time::OffsetDateTime;
use crate::did::Did;
use crate::pop::{Pop, PopPayloadV1};
#[derive(Debug, Error)]
pub enum WalletError {
#[error("invalid key encoding: {0}")]
InvalidKey(String),
#[error("expired wallet")]
Expired,
#[error("signing error: {0}")]
SigningError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwkPub {
pub kty: String,
pub crv: String,
pub x: String,
}
#[derive(Clone)]
pub struct Wallet {
signing_key: SigningKey,
did: Did,
created_at: i64,
ttl_secs: Option<i64>,
}
impl Wallet {
pub fn generate() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
let vk = signing_key.verifying_key();
let did = Did::key_from_ed25519(&vk.to_bytes());
Self {
signing_key,
did,
created_at: OffsetDateTime::now_utc().unix_timestamp(),
ttl_secs: None,
}
}
pub fn generate_with_ttl(ttl_secs: i64) -> Self {
let mut w = Self::generate();
w.ttl_secs = Some(ttl_secs);
w
}
pub fn from_priv_b64(priv_b64: &str) -> Result<Self, WalletError> {
let bytes = B64URL
.decode(priv_b64.as_bytes())
.map_err(|_| WalletError::InvalidKey("bad base64".into()))?;
let key_bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| WalletError::InvalidKey("expected 32 bytes".into()))?;
let signing_key = SigningKey::from_bytes(&key_bytes);
let vk = signing_key.verifying_key();
let did = Did::key_from_ed25519(&vk.to_bytes());
Ok(Self {
signing_key,
did,
created_at: OffsetDateTime::now_utc().unix_timestamp(),
ttl_secs: None,
})
}
pub fn did(&self) -> &Did {
&self.did
}
pub fn jwk_pub(&self) -> JwkPub {
let vk = self.signing_key.verifying_key();
JwkPub {
kty: "OKP".into(),
crv: "Ed25519".into(),
x: B64URL.encode(vk.to_bytes()),
}
}
pub fn priv_b64(&self) -> String {
B64URL.encode(self.signing_key.to_bytes())
}
pub fn pub_x_b64(&self) -> String {
B64URL.encode(self.signing_key.verifying_key().to_bytes())
}
pub fn is_expired(&self) -> bool {
if let Some(ttl) = self.ttl_secs {
let now = OffsetDateTime::now_utc().unix_timestamp();
now > self.created_at + ttl
} else {
false
}
}
pub fn sign_pop(&self, method: &str, path: &str) -> Result<Pop, WalletError> {
if self.is_expired() {
return Err(WalletError::Expired);
}
self.sign_pop_with_ts(method, path, None, None)
}
pub fn sign_pop_with_ath(
&self,
method: &str,
path: &str,
access_token: &str,
) -> Result<Pop, WalletError> {
if self.is_expired() {
return Err(WalletError::Expired);
}
self.sign_pop_with_ts(method, path, Some(access_token), None)
}
fn sign_pop_with_ts(
&self,
method: &str,
path: &str,
access_token: Option<&str>,
ts_override: Option<i64>,
) -> Result<Pop, WalletError> {
let ts = ts_override.unwrap_or_else(|| OffsetDateTime::now_utc().unix_timestamp());
let ath = access_token.map(|tok| {
format!("blake3:{}", blake3::hash(tok.as_bytes()).to_hex())
});
let payload = PopPayloadV1 {
v: 1,
m: method.to_uppercase(),
p: path.to_string(),
ts,
ath,
};
let payload_bytes = json_atomic::canonize(&payload)
.map_err(|e| WalletError::SigningError(e.to_string()))?;
let sig = self.signing_key.sign(&payload_bytes);
Ok(Pop {
payload,
payload_bytes,
signature: sig.to_bytes(),
wallet_did: self.did.as_str().to_string(),
})
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"did": self.did.as_str(),
"jwk_pub": self.jwk_pub(),
"jwk_priv_b64": self.priv_b64(),
"created_at": self.created_at,
"ttl_secs": self.ttl_secs
})
}
}
impl std::fmt::Debug for Wallet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Wallet")
.field("did", &self.did)
.field("created_at", &self.created_at)
.field("ttl_secs", &self.ttl_secs)
.field("signing_key", &"[REDACTED]")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wallet_generate() {
let w = Wallet::generate();
assert!(w.did().as_str().starts_with("did:key:z"));
assert!(!w.is_expired());
}
#[test]
fn test_wallet_roundtrip() {
let w1 = Wallet::generate();
let priv_b64 = w1.priv_b64();
let w2 = Wallet::from_priv_b64(&priv_b64).unwrap();
assert_eq!(w1.did().as_str(), w2.did().as_str());
}
#[test]
fn test_wallet_sign_pop() {
let w = Wallet::generate();
let pop = w.sign_pop("POST", "/v1/chips/mint").unwrap();
assert_eq!(pop.wallet_did, w.did().as_str());
assert_eq!(pop.payload.m, "POST");
assert_eq!(pop.payload.p, "/v1/chips/mint");
assert_eq!(pop.payload.v, 1);
}
}