Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .clusterfuzzlite/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ for dir in harness/*; do
mv "${dir}/${zip_name}" "${OUT}"
fi
done

# Copy libFuzzer dictionaries so clusterfuzzlite auto-loads <fuzzer>.dict next to the binary
cp harness/fuzz_sign_transaction.dict "${OUT}/" 2>/dev/null || true

cmake --build build
mv ./build/fuzz_* "${OUT}"
popd
43 changes: 43 additions & 0 deletions APP_SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,49 @@ None

[Description](doc/SIGN_TRANSACTION.md)

## Supported credits.aleo functions for clear-signing

The device performs clear-signed review screens only for the following
`credits.aleo` functions. Any other function name causes the signing flow to
return `SWO_INCORRECT_DATA`.

### Transfer functions

| Function name | Review subtitle | Fields shown on device |
| --------------------------- | -------------------------------------------- | --------------------------------------- |
| `transfer_public` | "Public transfer" | Amount, To (address), Fees |
| `transfer_private` | "Private transfer" | Amount, To (address), Fees |
| `transfer_public_to_private`| "Transfer from public to private address" | Amount, To (address), Fees |
| `transfer_private_to_public`| "Transfer from private to public address" | Amount, To (address), Fees |

### Fee functions (auto-approved, no interactive review screen)

| Function name | Behaviour |
| -------------- | ---------------------------------------------------- |
| `fee_public` | Automatically signed; no screen shown to the user. |
| `fee_private` | Automatically signed; no screen shown to the user. |

### Staking functions

These three functions map to `TX_STAKING` and display the "Review staking
transaction?" screen.

| Function name | Review subtitle | Fields shown on device |
| ---------------------- | --------------- | --------------------------------------------------------------------- |
| `bond_public` | "Stake ALEO" | Validator (address), Payout to / withdrawal (address), Amount, Fees |
| `unbond_public` | "Unstake ALEO" | Staker (address), Amount, Fees |
| `claim_unbond_public` | "Claim ALEO" | Staker (address), Fees *(no Amount field — claim has no amount)* |

All amounts are displayed in ALEO (microcredits converted by
`EXPONENT_SMALLEST_UNIT` = 6 decimal places). Fees shown are
`max_base_fee + max_priority_fee`.

### Other functions recognised by the BHP-1024 parameter table

`split` and `join` are present in `bhp_1024_parameters.c` for function-ID
verification purposes but are not dispatched to a review screen by the current
firmware; they return `SWO_INCORRECT_DATA` if presented as a root transaction.

## STATUS WORDS

The following standard Status Words are returned for all APDUs.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ APPNAME = "Aleo"

# Application version
APPVERSION_M = 1
APPVERSION_N = 2
APPVERSION_N = 3
APPVERSION_P = 0
APPVERSION = "$(APPVERSION_M).$(APPVERSION_N).$(APPVERSION_P)"

Expand Down
31 changes: 31 additions & 0 deletions fuzzing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,34 @@ docker run --rm --privileged -e FUZZING_LANGUAGE=c -v "$(realpath .)/fuzzing/out
```bash
docker run --rm --privileged -e FUZZING_ENGINE=libfuzzer -e RUN_FUZZER_MODE=interactive -v "$(realpath .)/fuzzing/corpus:/tmp/fuzz_corpus" -v "$(realpath .)/fuzzing/out:/out" -ti gcr.io/oss-fuzz-base/base-runner run_fuzzer fuzzer
```

## Seed corpus and dictionary for `fuzz_sign_transaction`

### Seed corpus

Seed inputs live in `fuzzing/harness/fuzz_sign_transaction/` (one binary file per seed).
`build.sh` automatically zips that directory into `fuzz_sign_transaction_seed_corpus.zip` and
copies it to `$OUT`, where clusterfuzzlite picks it up on the first run.

To regenerate or extend the seeds locally:

```bash
cd fuzzing
python3 gen_seeds.py
```

### libFuzzer dictionary

`fuzzing/harness/fuzz_sign_transaction.dict` contains meaningful tokens for the TLV wire format:
function-name strings (`transfer_public`, `bond_public`, …), TLV type bytes (`\xb6` for
FUNCTION_NAME, `\xb4` for REQUEST, etc.) and common input-type tag sequences (address/u64,
public/private visibility).

`build.sh` copies the dictionary to `$OUT/fuzz_sign_transaction.dict`; clusterfuzzlite and
oss-fuzz automatically load a file named `<fuzzer_binary>.dict` placed next to the binary.

To use the dictionary during a local run:

```bash
build/fuzz_sign_transaction -dict=harness/fuzz_sign_transaction.dict [corpus_dir]
```
109 changes: 109 additions & 0 deletions fuzzing/gen_seeds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Generate the libFuzzer seed corpus for fuzz_sign_transaction.

Each seed is the raw APDU DATA stream (5-byte header stripped from every chunk
and concatenated) for one sign-transaction interaction. Seeds are written one
binary file per case into fuzzing/harness/fuzz_sign_transaction/; build.sh zips
that directory into the seed corpus that clusterfuzzlite loads on first run.

Requires Python >= 3.9 (the reused test client uses PEP 585 generics):
cd fuzzing && python3 gen_seeds.py
"""

import os
import sys

# Reuse the functional-test client so seeds match real APDU encoding.
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
TESTS_DIR = os.path.join(os.path.dirname(SCRIPT_DIR), "tests")
sys.path.insert(0, TESTS_DIR)

from application_client.transaction import Transaction # noqa: E402

OUT_DIR = os.path.join(SCRIPT_DIR, "harness", "fuzz_sign_transaction")
os.makedirs(OUT_DIR, exist_ok=True)

# Test vectors (mirror tests/standalone/test_sign_transaction.py).
PATH = "m/44'/683'/0'/0'"
ADDR = "aleo1sfydt6z6cnqjx3hcgk9ajw03ecj6uqlfcm9u3p3gdhckzcc2w5xqv3v3pe"
ADDR_WITHDRAW = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px"
RECORD = [
"3614797564276936744957924747041031196891698846785520060979425601577054464500field",
"2426895214035216932245297778850989035038538961658726507442215877484415082794field",
"0220642863446832956019507279394572297489712696240584424406852292692897199577field",
]
EXEC_ID = "7266375125414209082394925781071362722506946030314916664133746682226945366259field"


def _request(function_name: str, inputs: list) -> dict:
return {
'network_id': 'mainnet', 'program_id': 'credits.aleo',
'function_name': function_name, 'inputs': inputs,
'nested_call_count': 0, 'program_checksum': '',
}


def intent(function_name: str, inputs: list, fee_function_name: str) -> dict:
return {
'type': 'intent', 'path': PATH,
'max_base_fee': 500, 'max_priority_fee': 100,
'fee_program_id': 'credits.aleo', 'fee_function_name': fee_function_name,
'request': _request(function_name, inputs),
}


def fee(function_name: str, inputs: list) -> dict:
return {'type': 'fee', 'request': _request(function_name, inputs)}


CASES = {
'bond_public': intent('bond_public', [
{'type': 'address.public', 'value': ADDR},
{'type': 'address.public', 'value': ADDR_WITHDRAW},
{'type': 'u64.public', 'value': 10000000000},
], 'fee_public'),
'unbond_public': intent('unbond_public', [
{'type': 'address.public', 'value': ADDR},
{'type': 'u64.public', 'value': 5000000000},
], 'fee_public'),
'claim_unbond_public': intent('claim_unbond_public', [
{'type': 'address.public', 'value': ADDR},
], 'fee_public'),
'transfer_public': intent('transfer_public', [
{'type': 'address.public', 'value': ADDR},
{'type': 'u64.public', 'value': 1000},
], 'fee_public'),
'transfer_private': intent('transfer_private', [
{'type': 'credits.record', 'value': RECORD},
{'type': 'address.private', 'value': ADDR},
{'type': 'u64.private', 'value': 1000},
], 'fee_private'),
'fee_public': fee('fee_public', [
{'type': 'u64.public', 'value': 500},
{'type': 'u64.public', 'value': 100},
{'type': 'field.public', 'value': EXEC_ID},
]),
'fee_private': fee('fee_private', [
{'type': 'credits.record', 'value': RECORD},
{'type': 'u64.public', 'value': 500},
{'type': 'u64.public', 'value': 100},
{'type': 'field.public', 'value': EXEC_ID},
]),
}

HEADER_HEX_CHARS = 10 # CLA INS P1 P2 Lc = 5 bytes


def apdus_to_seed(apdus: list) -> bytes:
"""Strip the 5-byte header from each APDU chunk and concatenate the payload."""
return bytes.fromhex("".join(apdu[HEADER_HEX_CHARS:] for apdu in apdus))


for name, tx in CASES.items():
seed = apdus_to_seed(Transaction().gen_apdus_tx(tx))
with open(os.path.join(OUT_DIR, f"seed_{name}"), "wb") as f:
f.write(seed)
print(f"seed_{name}: {len(seed)} bytes")

print(f"\n{len(CASES)} seeds written to {OUT_DIR}")
64 changes: 64 additions & 0 deletions fuzzing/harness/fuzz_sign_transaction.dict
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# libFuzzer dictionary for fuzz_sign_transaction
# Function names recognised by tx_parse.c (credits.aleo program_id + one of these triggers a parse path)
"transfer_public"
"transfer_private"
"transfer_public_to_private"
"transfer_private_to_public"
"fee_public"
"fee_private"
"bond_public"
"unbond_public"
"claim_unbond_public"
"credits.aleo"

# TLV type bytes (single-byte tags encoded with 0x81 long-form prefix in the wire format)
# Tags <= 0x7f are encoded directly; tags 0x80-0xff use the 0x81 <tag> two-byte form.
# STRUCTURE_TYPE=0x01, VERSION=0x02 (short-form tags)
"\x01"
"\x02"
# SIGNATURE=0x15
"\x15"
# Long-form tag prefix used for all 0x80+ tags
"\x81"
# MAX_BASE_FEE=0xb0, MAX_PRIORITY_FEE=0xb1
"\xb0"
"\xb1"
# FEE_FUNCTION_NAME=0xb2, FEE_PROGRAM_ID=0xb3
"\xb2"
"\xb3"
# REQUEST=0xb4, PROGRAM_ID=0xb5
"\xb4"
"\xb5"
# FUNCTION_NAME=0xb6, INPUT_COUNT=0xb7
"\xb6"
"\xb7"
# INPUT_VALUES=0xb8, INPUT_TYPES=0xb9, NESTED_CALL_COUNT=0xba
"\xb8"
"\xb9"
"\xba"
# NETWORK_ID=0xc3, PROGRAM_CHECKSUM=0xc4
"\xc3"
"\xc4"

# Input-type tag sequences (3-byte prefix encoding: visibility | plaintext-kind | literal-type)
# address.public -> 0x01 (public) 0x00 (plaintext) 0x00 (literal address)
"\x01\x00\x00"
# address.private -> 0x02 (private) 0x00 (plaintext) 0x00 (literal address)
"\x02\x00\x00"
# u64.public -> 0x01 (public) 0x00 (plaintext) 0x0c (literal u64)
"\x01\x00\x0c"
# u64.private -> 0x02 (private) 0x00 (plaintext) 0x0c (literal u64)
"\x02\x00\x0c"
# record input -> 0x03 (record) prefix
"\x03"

# Tokens auto-discovered by libFuzzer (recommended dictionary from a 78M-run session).
# High-use byte sequences that drove new coverage — mostly u64 boundary values.
# zero / two-byte zero pad
"\x00\x00"
# u64 little-endian == 1
"\x01\x00"
# u64 little-endian == 2
"\x02\x00\x00\x00\x00\x00\x00\x00"
# u64 == 0xFFFFFFFFFFFFFFFF (max / -1)
"\xff\xff\xff\xff\xff\xff\xff\xff"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
26 changes: 25 additions & 1 deletion src/db/db_program_function.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

#include "db_program_function.h"

#define NB_OF_CREDITS_ALEO_FUNCTIONS (8)
#define NB_OF_CREDITS_ALEO_FUNCTIONS (11)
const function_parameters_t credits_aleo[NB_OF_CREDITS_ALEO_FUNCTIONS] = {
{.name = "transfer_public",
.tx_type = TX_ALEO_TRANSFER_PUBLIC,
Expand Down Expand Up @@ -83,6 +83,30 @@ const function_parameters_t credits_aleo[NB_OF_CREDITS_ALEO_FUNCTIONS] = {
= {0xb56e347d9733de05, 0x29c0febaeb2bee52, 0x6c4d0ed1285f19fd, 0x0ec48ec3bdf25eb8}},
{.big.u64
= {0x921936604e6eddfe, 0x429d23effcfce58b, 0x072b665308c3d12b, 0x1241fcb49a8a9b72}}}},
{.name = "bond_public",
.tx_type = TX_STAKING,
.input_count = 3,
.bhp_1024_hashes
= {{.big.u64
= {0xfad1870949c8aa5f, 0xb94139693a1a9ede, 0x13826b9d3dda98ed, 0x11692029f1d694e3}},
{.big.u64
= {0x11e14f163d3f2b7c, 0xc591f619d35fb0ab, 0x3456c789125b6f67, 0x0eec83be57f64f40}}}},
{.name = "unbond_public",
.tx_type = TX_STAKING,
.input_count = 2,
.bhp_1024_hashes
= {{.big.u64
= {0x7023ecbf86089ad7, 0x1d8626b632ad9001, 0x9f1706e883708361, 0x029c23c1fc2b9465}},
{.big.u64
= {0x75ca6051e895980f, 0x86110aa6b233d58e, 0x53106280baec89af, 0x0628c9c0bfeaeb79}}}},
{.name = "claim_unbond_public",
.tx_type = TX_STAKING,
.input_count = 1,
.bhp_1024_hashes
= {{.big.u64
= {0x44e8eea54a97db6b, 0x137b370eeb14174b, 0xb14498709cf52fb5, 0x10df36004e953eb8}},
{.big.u64
= {0xe506d14510bc7c88, 0xb5566ac3133fe094, 0x8c7852dcca7e24e1, 0x0bce73be56cbba46}}}},
};

#define NB_OF_LDG_P_28_ALEO_FUNCTIONS (7)
Expand Down
57 changes: 57 additions & 0 deletions src/transaction/tx_parse.c
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ static int parse_token_batch_transfer_private_to_public(sign_transaction_datas_t

static int parse_fee_public(sign_transaction_datas_t *data, tx_t *tx);
static int parse_fee_private(sign_transaction_datas_t *data, tx_t *tx);
static int parse_bond_public(sign_transaction_datas_t *data, tx_t *tx);
static int parse_unbond_public(sign_transaction_datas_t *data, tx_t *tx);
static int parse_claim_unbond_public(sign_transaction_datas_t *data, tx_t *tx);

static int get_u64(input_t *input, bool is_private, uint64_t *value)
{
Expand Down Expand Up @@ -386,6 +389,43 @@ static int parse_fee_private(sign_transaction_datas_t *data, tx_t *tx)
return status;
}

static int parse_bond_public(sign_transaction_datas_t *data, tx_t *tx)
{
// TODO: (decide on that)
// NOTE: bond_public's on-chain finalize asserts caller != validator (and a
// minimum bond of 1 ALEO). Both are locally checkable on-device (we hold the
// signer address and the validator input at index 0), but per ADR004 §12 they
// are intentionally NOT rejected here for now: the device is a trusted-display
// passthrough signer and lets on-chain finalize stay authoritative.
tx->staking.type = TX_STAKING_BOND;
int status = get_address(&data->prepared_request.inputs[0], false, tx->staking.validator);
if (status == 0) {
status = get_address(&data->prepared_request.inputs[1], false, tx->staking.withdrawal);
}
if (status == 0) {
status = get_u64(&data->prepared_request.inputs[2], false, &tx->staking.amount);
}

return status;
}

static int parse_unbond_public(sign_transaction_datas_t *data, tx_t *tx)
{
tx->staking.type = TX_STAKING_UNBOND;
int status = get_address(&data->prepared_request.inputs[0], false, tx->staking.staker);
if (status == 0) {
status = get_u64(&data->prepared_request.inputs[1], false, &tx->staking.amount);
}

return status;
}

static int parse_claim_unbond_public(sign_transaction_datas_t *data, tx_t *tx)
{
tx->staking.type = TX_STAKING_CLAIM;
return get_address(&data->prepared_request.inputs[0], false, tx->staking.staker);
}

int tx_parse(sign_transaction_datas_t *data, tx_t *tx)
{
LEDGER_ASSERT(data != NULL, "NULL data");
Expand Down Expand Up @@ -471,6 +511,23 @@ int tx_parse(sign_transaction_datas_t *data, tx_t *tx)
G_context.r_list.count = 0;
return parse_fee_private(data, tx);

case TX_STAKING:
G_context.r_list.count = 0;
if ((data->prepared_request.function_name_length == 11)
&& !memcmp(data->prepared_request.function_name, "bond_public", 11)) {
return parse_bond_public(data, tx);
}
else if ((data->prepared_request.function_name_length == 13)
&& !memcmp(data->prepared_request.function_name, "unbond_public", 13)) {
return parse_unbond_public(data, tx);
}
else if ((data->prepared_request.function_name_length == 19)
&& !memcmp(
data->prepared_request.function_name, "claim_unbond_public", 19)) {
return parse_claim_unbond_public(data, tx);
}
return -1;

default:
break;
}
Expand Down
Loading