|
| 1 | +// Copyright 2023 Shift Crypto AG |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +use super::pb; |
| 16 | +use super::Error; |
| 17 | +use pb::BtcCoin; |
| 18 | + |
| 19 | +use pb::btc_script_config::Policy; |
| 20 | + |
| 21 | +use alloc::string::String; |
| 22 | + |
| 23 | +use core::str::FromStr; |
| 24 | + |
| 25 | +// Arbitrary limit of keys that can be present in a policy. |
| 26 | +const MAX_KEYS: usize = 20; |
| 27 | + |
| 28 | +// We only support Bitcoin testnet for now. |
| 29 | +fn check_enabled(coin: BtcCoin) -> Result<(), Error> { |
| 30 | + if !matches!(coin, BtcCoin::Tbtc) { |
| 31 | + return Err(Error::InvalidInput); |
| 32 | + } |
| 33 | + Ok(()) |
| 34 | +} |
| 35 | + |
| 36 | +/// See `ParsedPolicy`. |
| 37 | +#[derive(Debug)] |
| 38 | +pub struct Wsh<'a> { |
| 39 | + policy: &'a Policy, |
| 40 | + miniscript_expr: miniscript::Miniscript<String, miniscript::Segwitv0>, |
| 41 | +} |
| 42 | + |
| 43 | +/// Result of `parse()`. |
| 44 | +#[derive(Debug)] |
| 45 | +pub enum ParsedPolicy<'a> { |
| 46 | + // `wsh(...)` policies |
| 47 | + Wsh(Wsh<'a>), |
| 48 | + // `tr(...)` Taproot etc. in the future. |
| 49 | +} |
| 50 | + |
| 51 | +impl<'a> ParsedPolicy<'a> { |
| 52 | + fn get_policy(&self) -> &Policy { |
| 53 | + match self { |
| 54 | + Self::Wsh(Wsh { ref policy, .. }) => policy, |
| 55 | + } |
| 56 | + } |
| 57 | + |
| 58 | + /// Validate a policy. |
| 59 | + /// - Coin is supported (only Bitcoin testnet for now) |
| 60 | + /// - Number of keys |
| 61 | + /// - TODO: many more checks. |
| 62 | + pub fn validate(&self, coin: BtcCoin) -> Result<(), Error> { |
| 63 | + check_enabled(coin)?; |
| 64 | + |
| 65 | + let policy = self.get_policy(); |
| 66 | + |
| 67 | + if policy.keys.len() > MAX_KEYS { |
| 68 | + return Err(Error::InvalidInput); |
| 69 | + } |
| 70 | + |
| 71 | + // TODO: more checks |
| 72 | + |
| 73 | + Ok(()) |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +/// Parses a policy as specified by 'Wallet policies': https://github.com/bitcoin/bips/pull/1389. |
| 78 | +/// Only `wsh(<miniscript expression>)` is supported for now. |
| 79 | +/// Example: `wsh(pk(@0/**))`. |
| 80 | +/// |
| 81 | +/// The parsed output keeps the key strings as is (e.g. "@0/**"). They will be processed and |
| 82 | +/// replaced with actual pubkeys in a later step. |
| 83 | +pub fn parse(policy: &Policy) -> Result<ParsedPolicy, Error> { |
| 84 | + let desc = policy.policy.as_str(); |
| 85 | + match desc.as_bytes() { |
| 86 | + // Match wsh(...). |
| 87 | + [b'w', b's', b'h', b'(', .., b')'] => { |
| 88 | + let miniscript_expr: miniscript::Miniscript<String, miniscript::Segwitv0> = |
| 89 | + miniscript::Miniscript::from_str(&desc[4..desc.len() - 1]) |
| 90 | + .or(Err(Error::InvalidInput))?; |
| 91 | + |
| 92 | + Ok(ParsedPolicy::Wsh(Wsh { |
| 93 | + policy, |
| 94 | + miniscript_expr, |
| 95 | + })) |
| 96 | + } |
| 97 | + _ => Err(Error::InvalidInput), |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +#[cfg(test)] |
| 102 | +mod tests { |
| 103 | + use super::*; |
| 104 | + |
| 105 | + use alloc::vec::Vec; |
| 106 | + |
| 107 | + use crate::bip32::parse_xpub; |
| 108 | + use bitbox02::testing::mock_unlocked; |
| 109 | + use util::bip32::HARDENED; |
| 110 | + |
| 111 | + const SOME_XPUB_1: &str = "xpub6FMWuwbCA9KhoRzAMm63ZhLspk5S2DM5sePo8J8mQhcS1xyMbAqnc7Q7UescVEVFCS6qBMQLkEJWQ9Z3aDPgBov5nFUYxsJhwumsxM4npSo"; |
| 112 | + |
| 113 | + const KEYPATH_ACCOUNT: &[u32] = &[48 + HARDENED, 1 + HARDENED, 0 + HARDENED, 3 + HARDENED]; |
| 114 | + |
| 115 | + // Creates a policy key without fingerprint/keypath from an xpub string. |
| 116 | + fn make_key(xpub: &str) -> pb::KeyOriginInfo { |
| 117 | + pb::KeyOriginInfo { |
| 118 | + root_fingerprint: vec![], |
| 119 | + keypath: vec![], |
| 120 | + xpub: Some(parse_xpub(xpub).unwrap()), |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + // Creates a policy for one of our own keys at keypath. |
| 125 | + fn make_our_key(keypath: &[u32]) -> pb::KeyOriginInfo { |
| 126 | + let our_xpub = crate::keystore::get_xpub(keypath).unwrap(); |
| 127 | + pb::KeyOriginInfo { |
| 128 | + root_fingerprint: crate::keystore::root_fingerprint().unwrap(), |
| 129 | + keypath: keypath.to_vec(), |
| 130 | + xpub: Some(our_xpub.into()), |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + fn make_policy(policy: &str, keys: &[pb::KeyOriginInfo]) -> Policy { |
| 135 | + Policy { |
| 136 | + policy: policy.into(), |
| 137 | + keys: keys.to_vec(), |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + #[test] |
| 142 | + fn test_parse_wsh_miniscript() { |
| 143 | + // Parse a valid example and check that the keys are collected as is as strings. |
| 144 | + let policy = make_policy("wsh(pk(@0/**))", &[]); |
| 145 | + match parse(&policy).unwrap() { |
| 146 | + ParsedPolicy::Wsh(Wsh { |
| 147 | + ref miniscript_expr, |
| 148 | + .. |
| 149 | + }) => { |
| 150 | + assert_eq!( |
| 151 | + miniscript_expr.iter_pk().collect::<Vec<String>>(), |
| 152 | + vec!["@0/**"] |
| 153 | + ); |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + // Parse another valid example and check that the keys are collected as is as strings. |
| 158 | + let policy = make_policy("wsh(or_b(pk(@0/**),s:pk(@1/**)))", &[]); |
| 159 | + match parse(&policy).unwrap() { |
| 160 | + ParsedPolicy::Wsh(Wsh { |
| 161 | + ref miniscript_expr, |
| 162 | + .. |
| 163 | + }) => { |
| 164 | + assert_eq!( |
| 165 | + miniscript_expr.iter_pk().collect::<Vec<String>>(), |
| 166 | + vec!["@0/**", "@1/**"] |
| 167 | + ); |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + // Unknown top-level fragment. |
| 172 | + assert_eq!( |
| 173 | + parse(&make_policy("unknown(pk(@0/**))", &[])).unwrap_err(), |
| 174 | + Error::InvalidInput, |
| 175 | + ); |
| 176 | + |
| 177 | + // Unknown script fragment. |
| 178 | + assert_eq!( |
| 179 | + parse(&make_policy("wsh(unknown(@0/**))", &[])).unwrap_err(), |
| 180 | + Error::InvalidInput, |
| 181 | + ); |
| 182 | + |
| 183 | + // Miniscript type-check fails (should be `or_b(pk(@0/**),s:pk(@1/**))`). |
| 184 | + assert_eq!( |
| 185 | + parse(&make_policy("wsh(or_b(pk(@0/**),pk(@1/**)))", &[])).unwrap_err(), |
| 186 | + Error::InvalidInput, |
| 187 | + ); |
| 188 | + } |
| 189 | + |
| 190 | + #[test] |
| 191 | + fn test_parse_validate() { |
| 192 | + mock_unlocked(); |
| 193 | + |
| 194 | + let our_key = make_our_key(KEYPATH_ACCOUNT); |
| 195 | + |
| 196 | + // All good. |
| 197 | + assert!(parse(&make_policy("wsh(pk(@0/**))", &[our_key.clone()])) |
| 198 | + .unwrap() |
| 199 | + .validate(BtcCoin::Tbtc) |
| 200 | + .is_ok()); |
| 201 | + |
| 202 | + // Unsupported coins |
| 203 | + for coin in [BtcCoin::Btc, BtcCoin::Ltc, BtcCoin::Tltc] { |
| 204 | + assert_eq!( |
| 205 | + parse(&make_policy("wsh(pk(@0/**))", &[our_key.clone()])) |
| 206 | + .unwrap() |
| 207 | + .validate(coin), |
| 208 | + Err(Error::InvalidInput) |
| 209 | + ); |
| 210 | + } |
| 211 | + |
| 212 | + // Too many keys. |
| 213 | + let many_keys: Vec<pb::KeyOriginInfo> = (0..=20) |
| 214 | + .map(|i| make_our_key(&[48 + HARDENED, 1 + HARDENED, i + HARDENED, 3 + HARDENED])) |
| 215 | + .collect(); |
| 216 | + assert_eq!( |
| 217 | + parse(&make_policy("wsh(pk(@0/**))", &many_keys)) |
| 218 | + .unwrap() |
| 219 | + .validate(BtcCoin::Tbtc), |
| 220 | + Err(Error::InvalidInput) |
| 221 | + ); |
| 222 | + } |
| 223 | +} |
0 commit comments