Skip to content

Commit

Permalink
Blinded commitment verification (#276)
Browse files Browse the repository at this point in the history
* Initial implementation of blinded commitment verification

Refs #274

* Don't mix up query string and hash parameters

A fallback from query string parameters to hash parameters was
implemented to retain backwards compatibility with old URLs.

This was causing the unblinding data to leak into the query string
whenever the query string was updated, e.g. by expanding details.

Removing the compatibility is not terrible, because the old style URLs
were only used for a short while, and this just means that auto-expand
and highlighting of specific inputs/outputs won't work.

* Retain hash parameters when updating the query string

* Don't pollute the global namespace with Module/getValue/ccall/etc

This requires running the emscripten compiler with
`-s MODULARIZE=1 -s EXPORT_NAME=InitWally`.

* Add libwally WASM to the Docker build process

* Switch to upstream libwally

* Fix WASM_URL path

* Fix rendering of coinbase transactions

* Display a warning if any of the blinders do not match
  • Loading branch information
shesek authored Jan 25, 2021
1 parent 67e7e9c commit 1efe58f
Show file tree
Hide file tree
Showing 14 changed files with 292 additions and 23 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ data_liquid_mainnet
data_bitcoin_testnet
data_bitcoin_regtest
data_liquid_regtest
www/libwally
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ FROM blockstream/esplora-base:latest AS build
FROM debian:buster@sha256:e2cc6fb403be437ef8af68bdc3a89fd58e80b4e390c58f14c77c466002391193

COPY --from=build /srv/explorer /srv/explorer
COPY --from=build /srv/wally_wasm /srv/wally_wasm
COPY --from=build /root/.nvm /root/.nvm

RUN apt-get -yqq update \
Expand Down Expand Up @@ -44,6 +45,9 @@ RUN source /root/.nvm/nvm.sh \
&& DEST=/srv/explorer/static/liquid-regtest-blockstream \
npm run dist -- liquid-regtest blockstream

# symlink the libwally wasm files into liquid's www directories (for client-side unblinding)
RUN for dir in /srv/explorer/static/liquid*; do ln -s /srv/wally_wasm $dir/libwally; done

# configuration
RUN cp /srv/explorer/source/run.sh /srv/explorer/

Expand Down
18 changes: 17 additions & 1 deletion Dockerfile.deps
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
FROM debian:buster@sha256:e2cc6fb403be437ef8af68bdc3a89fd58e80b4e390c58f14c77c466002391193
# Build libwally wasm files. Used for client-side blinding verification on Elements-based chains

FROM greenaddress/wallycore@sha256:d63d222be12f6b2535e1548aa7f21cf649e2230d6c9e01bd518c23eb0bccd46f AS libwally-wasm
ARG NO_LIQUID
ENV EXPORTED_FUNCTIONS="['_malloc', '_free', '_wally_init','_wally_asset_value_commitment','_wally_asset_generator_from_bytes']"
ENV EXTRA_EXPORTED_RUNTIME_METHODS="['getValue', 'ccall']"
ENV EMCC_OPTIONS="-s MODULARIZE=1 -s EXPORT_NAME=InitWally"
RUN sh -c '[ -n "$NO_LIQUID" ] && mkdir -p /wally/wasm_dist || ( \
apt-get -qq update && apt-get -yqq install git \
&& cd /opt/emsdk && . ./emsdk_env.sh \
&& git clone --no-checkout https://github.com/elementsproject/libwally-core /wally \
&& cd /wally && git checkout ea984fc07f4f450b33d4eb78756f25f553e60b44 \
&& git submodule sync --recursive && git submodule update --init --recursive \
&& ./tools/build_wasm.sh --enable-elements)'

FROM debian:buster@sha256:e2cc6fb403be437ef8af68bdc3a89fd58e80b4e390c58f14c77c466002391193
SHELL ["/bin/bash", "-c"]

RUN mkdir -p /srv/explorer \
Expand Down Expand Up @@ -47,3 +61,5 @@ RUN apt-get --auto-remove remove -yqq --purge clang cmake manpages curl git \
&& apt-get clean \
&& apt-get autoclean \
&& rm -rf /usr/share/doc* /usr/share/man /usr/share/postgresql/*/man /var/lib/apt/lists/* /var/cache/* /tmp/* /root/.cache /*.deb /root/.cargo

COPY --from=libwally-wasm /wally/wally_dist /srv/wally_wasm
24 changes: 19 additions & 5 deletions client/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const reservedPaths = [ 'mempool', 'assets', 'search' ]
// Make driver source observables rxjs5-compatible via rxjs-compat
setAdapt(stream => O.from(stream))

export default function main({ DOM, HTTP, route, storage, scanner: scan$, search: searchResult$ }) {
export default function main({ DOM, HTTP, route, storage, scanner: scan$, search: searchResult$, blinding: unblinded$ }) {
const

reply = (cat, raw) => dropErrors(HTTP.select(cat)).map(r => raw ? r : (r.body || r.text))
Expand Down Expand Up @@ -57,6 +57,8 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
, sort_dir: loc.query.sort_dir != null ? loc.query.sort_dir : 'asc'
, limit: +loc.query.limit || 50,
}))
, blindingReq$ = !process.env.IS_ELEMENTS ? O.empty()
: page$.map(loc => loc.hash.startsWith('#blinded=') ? loc.hash.substr(9) : null)
// End Elements only


Expand Down Expand Up @@ -250,7 +252,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
, mempool$, mempoolRecent$, feeEst$
, tx$, txAnalysis$, openTx$
, goAddr$, addr$, addrTxs$, addrQR$
, assetMap$, assetList$, goAssetList$, goAsset$, asset$, assetTxs$
, assetMap$, assetList$, goAssetList$, goAsset$, asset$, assetTxs$, unblinded$
, isReady$, loading$, page$, view$, title$, theme$
})

Expand All @@ -262,7 +264,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
.map(Boolean).distinctUntilChanged()
.withLatestFrom(route.all$)
.filter(([ expand, page ]) => page.query.expand != expand)
.map(([ expand, page ]) => [ page.pathname, updateQuery(page.query, { expand }) ])
.map(([ expand, page ]) => [ page.pathname, page.hash, updateQuery(page.query, { expand }) ])

/// Sinks

Expand Down Expand Up @@ -365,7 +367,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
searchResult$.filter(Boolean).map(result => ({ type: 'replace', ...result }))
, byHeight$.map(hash => ({ type: 'replace', pathname: `/block/${hash}` }))
, pushedtx$.map(txid => ({ type: 'push', pathname: `/tx/${txid}` }))
, updateQuery$.map(([ pathname, qs ]) => ({ type: 'replace', pathname, search: qs, state: { noRouting: true } }))
, updateQuery$.map(([ pathname, hash, qs ]) => ({ type: 'replace', pathname, hash, search: qs, state: { noRouting: true } }))
, searchQuery$.map(q => ({ type: 'push', pathname: '/search', search: `q=${encodeURIComponent(q)}` }))
)

Expand Down Expand Up @@ -418,5 +420,17 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
})
}

return { DOM: vdom$, HTTP: req$, route: navto$, storage: store$, search: goSearch$, scanner: scanning$, title: title$, state: state$ }
return {
DOM: vdom$
, HTTP: req$
, route: navto$
, storage: store$
, search: goSearch$
, scanner: scanning$
, title: title$
, state: state$

// elements only
, blinding: blindingReq$
}
}
93 changes: 93 additions & 0 deletions client/src/driver/blinding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Observable as O } from '../rxjs'
import * as libwally from '../lib/libwally'

// Accepts a stream of blinding data strings, returns a stream of Unblinded
// objects with a map from the commitments to the unblinded data
module.exports = blinders_str$ =>
O.from(blinders_str$).flatMap(async blinders_str => {
if (!blinders_str) return null

await libwally.load()

try {
const blinders = parseBlinders(blinders_str)
return new Unblinded(blinders)
}
catch (error) {
return { error }
}
})
.share()

class Unblinded {
constructor(blinders) {
this.commitments = makeCommitmentMap(blinders)
}

// Look for the given output, returning an { value, asset } object
find(vout) {
return vout.assetcommitment && vout.valuecommitment &&
this.commitments.get(`${vout.assetcommitment}:${vout.valuecommitment}`)
}

// Lookup all transaction inputs/outputs and attach the unblinded data
tryUnblindTx(tx) {
if (tx._unblinded) return tx._unblinded
let matched = 0
tx.vout.forEach(vout => matched += +this.tryUnblindOut(vout))
tx.vin.filter(vin => vin.prevout).forEach(vin => matched += +this.tryUnblindOut(vin.prevout))
tx._unblinded = { matched, total: this.commitments.size }
return tx._unblinded
}

// Look the given output and attach the unblinded data
tryUnblindOut(vout) {
const unblinded = this.find(vout)
if (unblinded) Object.assign(vout, unblinded)
return !!unblinded
}
}

function makeCommitmentMap(blinders) {
const commitments = new Map

blinders.forEach(b => {
const { asset_commitment, value_commitment } =
libwally.generate_commitments(b.value, b.asset, b.value_blinder, b.asset_blinder)

commitments.set(`${asset_commitment}:${value_commitment}`, {
asset: b.asset,
value: b.value,
})
})

return commitments
}

// Parse the blinders data from a string encoded as a comma separated list, in the following format:
// <value_in_satoshis>,<asset_tag_hex>,<amount_blinder_hex>,<asset_blinder_hex>
// This can be repeated with a comma separator to specify blinders for multiple outputs.

function parseBlinders(str) {
const parts = str.split(',')
, blinders = []

while (parts.length) {
blinders.push({
value: verifyNum(parts.shift())
, asset: verifyHex32(parts.shift())
, value_blinder: verifyHex32(parts.shift())
, asset_blinder: verifyHex32(parts.shift())
})
}
return blinders
}

function verifyNum(num) {
if (!+num) throw new Error('Invalid blinding data (invalid number)')
return +num
}
function verifyHex32(str) {
if (!str || !/^[0-9a-f]{64}$/i.test(str)) throw new Error('Invalid blinding data (invalid hex)')
return str
}
6 changes: 1 addition & 5 deletions client/src/driver/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ const baseHref = process.env.BASE_HREF || '/'
, stripBase = path => path.indexOf(baseHref) == 0 ? path.substr(baseHref.length-1) : path

const parseQuery = loc => {
// Older versions of Esplora used a comma-separated url hash instead of a query string.
// Try both for backward compatibility with old links.
const query = loc.search ? qs.parse(loc.search.substr(1))
: loc.hash ? loc.hash.substr(1).split(',').reduce((acc, k) => ({ ...acc, [k]: true }), {})
: {}
const query = loc.search ? qs.parse(loc.search.substr(1)) : {}

// Convert value-less args to true
Object.keys(query).filter(key => key !== 'q' && query[key] === '')
Expand Down
106 changes: 106 additions & 0 deletions client/src/lib/libwally.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
const WALLY_OK = 0
, ASSET_COMMITMENT_LEN = 33
, ASSET_GENERATOR_LEN = 33
, ASSET_TAG_LEN = 32
, BLINDING_FACTOR_LEN = 32

const STATIC_ROOT = process.env.STATIC_ROOT || ''
, WASM_URL = process.env.LIBWALLY_WASM_URL || `${STATIC_ROOT}libwally/wallycore.js`

let load_promise, Module
export function load() {
return load_promise || (load_promise = new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = WASM_URL
script.addEventListener('error', reject)
script.addEventListener('load', () =>
InitWally().then(module => { Module=module; resolve() }, reject))
document.body.appendChild(script)
}))
}

// Simple wrapper to execute both asset_generator_from_bytes and asset_value_commitment,
// with hex conversions
export function generate_commitments(value, asset_hex, value_blinder_hex, asset_blinder_hex) {
const asset = parseHex(asset_hex, ASSET_TAG_LEN)
, value_blinder = parseHex(value_blinder_hex, BLINDING_FACTOR_LEN)
, asset_blinder = parseHex(asset_blinder_hex, BLINDING_FACTOR_LEN)

const asset_commitment = asset_generator_from_bytes(asset, asset_blinder)
, value_commitment = asset_value_commitment(value, value_blinder, asset_commitment)

return { asset_commitment: encodeHex(asset_commitment)
, value_commitment: encodeHex(value_commitment) }
}

export function asset_generator_from_bytes(asset, asset_blinder) {
const asset_commitment_ptr = Module._malloc(ASSET_GENERATOR_LEN)
checkCode(Module.ccall('wally_asset_generator_from_bytes'
, 'number'
, [ 'array', 'number', 'array', 'number', 'number', 'number' ]
, [ asset, asset.length
, asset_blinder, asset_blinder.length
, asset_commitment_ptr, ASSET_GENERATOR_LEN
]))

const asset_commitment = readBytes(asset_commitment_ptr, ASSET_GENERATOR_LEN)
Module._free(asset_commitment_ptr)
return asset_commitment
}

export function asset_value_commitment(value, value_blinder, asset_commitment) {
// Emscripten transforms int64 function arguments into two int32 arguments, see:
// https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-pass-int64-t-and-uint64-t-values-from-js-into-wasm-functions
const [value_lo, value_hi] = split_int52_lo_hi(value)

const value_commitment_ptr = Module._malloc(ASSET_COMMITMENT_LEN)
checkCode(Module.ccall('wally_asset_value_commitment'
, 'number'
, [ 'number', 'number', 'array', 'number', 'array', 'number', 'number', 'number' ]
, [ value_lo, value_hi
, value_blinder, value_blinder.length
, asset_commitment, asset_commitment.length
, value_commitment_ptr, ASSET_COMMITMENT_LEN
]))

const value_commitment = readBytes(value_commitment_ptr, ASSET_COMMITMENT_LEN)
Module._free(value_commitment_ptr)
return value_commitment
}

function checkCode(code) {
if (code != WALLY_OK)
throw new Error(`libwally failed with code ${code}`)
}

function readBytes(ptr, size) {
const bytes = new Uint8Array(size)
for (let i=0; i<size; i++) bytes[i] = Module.getValue(ptr+i, 'i8')
return bytes
}

// Split a 52-bit JavaScript number into two 32-bits numbers for the low and high bits
// https://stackoverflow.com/a/19274574
function split_int52_lo_hi(i) {
let lo = i | 0
if (lo < 0) lo += 4294967296

let hi = i - lo
hi /= 4294967296

if ((hi < 0) || (hi >= 1048576)) throw new Error ("not an int52: "+i)

return [ lo, hi ]
}

function encodeHex(bytes) {
return Buffer.from(bytes).toString('hex')
//return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}

// Parse hex string encoded in *reverse*
function parseHex(str, expected_size) {
if (!/^([0-9a-f]{2})+$/.test(str)) throw new Error('Invalid blinders (invalid hex)')
if (str.length != expected_size*2) throw new Error('Invalid blinders (invalid length)')
return new Uint8Array(str.match(/.{2}/g).map(hex_byte => parseInt(hex_byte, 16)).reverse())
}
5 changes: 5 additions & 0 deletions client/src/run-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const apiBase = (process.env.API_URL || '/api').replace(/\/+$/, '')
const titleDriver = title$ => O.from(title$)
.subscribe(title => document.title = title ? `${title} · ${initTitle}` : initTitle)

const blindingDriver = process.env.IS_ELEMENTS
? require('./driver/blinding')
: _ => O.empty()

run(main, {
DOM: makeDOMDriver('#explorer')
, HTTP: makeHTTPDriver()
Expand All @@ -26,4 +30,5 @@ run(main, {
, search: makeSearchDriver(apiBase)
, title: titleDriver
, scanner: makeScanDriver()
, blinding: blindingDriver
})
2 changes: 2 additions & 0 deletions client/src/run-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,7 @@ export default function render(pathname, args='', body, locals={}, cb) {
, scanner: _ => O.empty()
, search: makeSearchDriver(apiBase)
, state: state$ => O.from(state$).subscribe(stateUpdate)
// unblinding is disabled with server-side rendering
, blinding: _ => O.empty(),
})
}
2 changes: 1 addition & 1 deletion client/src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const updateBlocks = (current, to_add) => {

// Transaction helpers

export const isAnyConfidential = tx => tx.vout.some(vout => vout.value == null)
export const isAllUnconfidential = tx => tx.vout.every(vout => vout.value != null)

export const isRbf = tx => tx.vin.some(vin => vin.sequence < 0xfffffffe)

Expand Down
4 changes: 3 additions & 1 deletion client/src/views/tx-vin.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Snabbdom from 'snabbdom-pragma'
import { linkToParentOut, formatOutAmount, formatAssetAmount, formatHex, linkToAddr, formatNumber } from './util'

const layout = (vin, desc, body, { t, ...S }) =>
<div class={{ vin: true, active: isActive(vin, S) }}>
<div class={{ vin: true, active: isActive(vin, S), unblinded: isUnblinded(vin) }}>
<div className="vin-header">
<div className="vin-header-container">
<span>{ desc }</span>
Expand All @@ -16,6 +16,8 @@ const isActive = (vin, { index, view, query, addr }) =>
(view == 'tx' && query && !!query[`input:${index}`])
|| (view == 'addr' && addr && vin.prevout && vin.prevout.scriptpubkey_address == addr.address)

const isUnblinded = vin => vin.prevout && vin.prevout.valuecommitment != null && vin.prevout.value != null

const pegin = (vin, { isOpen, t, ...S }) => layout(
vin
, linkToParentOut(vin, t`Output in parent chain`)
Expand Down
Loading

0 comments on commit 1efe58f

Please sign in to comment.