Bootloader infrastructure and main logic (#404)
* adds bootloader code without a SHA256 implementation * small fixes and typos
This commit is contained in:
6
.github/workflows/cargo_check.yml
vendored
6
.github/workflows/cargo_check.yml
vendored
@@ -82,3 +82,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
command: check
|
command: check
|
||||||
args: --target thumbv7em-none-eabi --release --examples
|
args: --target thumbv7em-none-eabi --release --examples
|
||||||
|
|
||||||
|
- name: Check bootloader
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: check
|
||||||
|
args: --manifest-path bootloader/Cargo.toml --target thumbv7em-none-eabi --release
|
||||||
|
|||||||
6
.github/workflows/cargo_fmt.yml
vendored
6
.github/workflows/cargo_fmt.yml
vendored
@@ -71,3 +71,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
command: fmt
|
command: fmt
|
||||||
args: --manifest-path tools/heapviz/Cargo.toml --all -- --check
|
args: --manifest-path tools/heapviz/Cargo.toml --all -- --check
|
||||||
|
|
||||||
|
- name: Cargo format bootloader
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: fmt
|
||||||
|
args: --manifest-path bootloader/Cargo.toml --all -- --check
|
||||||
|
|||||||
2
bootloader/.cargo/config
Normal file
2
bootloader/.cargo/config
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[target.thumbv7em-none-eabi]
|
||||||
|
linker = "arm-none-eabi-gcc"
|
||||||
28
bootloader/Cargo.toml
Normal file
28
bootloader/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "bootloader"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = [
|
||||||
|
"Fabian Kaczmarczyck <kaczmarczyck@google.com>",
|
||||||
|
]
|
||||||
|
build = "build.rs"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
byteorder = { version = "1", default-features = false }
|
||||||
|
cortex-m = "^0.6.0"
|
||||||
|
cortex-m-rt = "*"
|
||||||
|
cortex-m-rt-macros = "*"
|
||||||
|
panic-abort = "0.3.2"
|
||||||
|
rtt-target = { version = "*", features = ["cortex-m"] }
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
panic = "abort"
|
||||||
|
lto = true
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
panic = "abort"
|
||||||
|
lto = true
|
||||||
|
# Level "z" may decrease the binary size more of necessary.
|
||||||
|
opt-level = 3
|
||||||
36
bootloader/README.md
Normal file
36
bootloader/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# OpenSK Bootloader
|
||||||
|
|
||||||
|
This bootloader supports upgradability for OpenSK. Its functionality is to
|
||||||
|
|
||||||
|
- check images on A/B partitions,
|
||||||
|
- boot the most recent valid partition.
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
The bootloader is built and deployed by OpenSK's `deploy.py`. If your board
|
||||||
|
defines a metadata address, it is detected as an upgradable board and this
|
||||||
|
bootloader is flashed to memory address 0.
|
||||||
|
|
||||||
|
## How to debug
|
||||||
|
|
||||||
|
The bootloader prints debug message over RTT when compiled in debug mode. Using
|
||||||
|
`nrfjprog` for flashing and inspecting memory is recommended for debugging.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
RUSTFLAGS="-C link-arg=-Wl,-Tlink.x -C link-arg=-nostartfiles" \
|
||||||
|
cargo build --target thumbv7em-none-eabi
|
||||||
|
llvm-objcopy -O ihex target/thumbv7em-none-eabi/debug/bootloader \
|
||||||
|
target/thumbv7em-none-eabi/debug/bootloader.hex
|
||||||
|
nrfjprog --program target/thumbv7em-none-eabi/debug/bootloader.hex \
|
||||||
|
--sectorerase -f nrf52 --reset
|
||||||
|
```
|
||||||
|
|
||||||
|
To read the debug messages, open two terminals for:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
JLinkRTTLogger -device NRF52840_XXAA -if swd -speed 1000 -RTTchannel 0
|
||||||
|
JLinkRTTClient
|
||||||
|
```
|
||||||
|
|
||||||
|
The first command also logs the output to a file. The second shows all output in
|
||||||
|
real time.
|
||||||
17
bootloader/build.rs
Normal file
17
bootloader/build.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright 2021 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=memory.x");
|
||||||
|
}
|
||||||
21
bootloader/memory.x
Normal file
21
bootloader/memory.x
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/* Copyright 2021 Google LLC
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
MEMORY
|
||||||
|
{
|
||||||
|
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 16K
|
||||||
|
RAM (rwx) : ORIGIN = 0x20020000, LENGTH = 128K
|
||||||
|
}
|
||||||
|
|
||||||
4
bootloader/rust-toolchain
Normal file
4
bootloader/rust-toolchain
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly-2021-03-25"
|
||||||
|
components = ["clippy", "rustfmt"]
|
||||||
|
targets = ["thumbv7em-none-eabi"]
|
||||||
149
bootloader/src/main.rs
Normal file
149
bootloader/src/main.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright 2021 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
#![no_main]
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
extern crate cortex_m;
|
||||||
|
extern crate cortex_m_rt as rt;
|
||||||
|
|
||||||
|
use byteorder::{ByteOrder, LittleEndian};
|
||||||
|
use core::convert::TryInto;
|
||||||
|
use core::ptr;
|
||||||
|
use cortex_m::asm;
|
||||||
|
use panic_abort as _;
|
||||||
|
use rt::entry;
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
use rtt_target::{rprintln, rtt_init_print};
|
||||||
|
|
||||||
|
/// Size of a flash page in bytes.
|
||||||
|
const PAGE_SIZE: usize = 0x1000;
|
||||||
|
|
||||||
|
/// A flash page.
|
||||||
|
type Page = [u8; PAGE_SIZE];
|
||||||
|
|
||||||
|
/// Reads a page of memory.
|
||||||
|
unsafe fn read_page(address: usize) -> Page {
|
||||||
|
debug_assert!(address % PAGE_SIZE == 0);
|
||||||
|
let address_pointer = address as *const Page;
|
||||||
|
ptr::read(address_pointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed metadata for a firmware partition.
|
||||||
|
struct Metadata {
|
||||||
|
checksum: [u8; 32],
|
||||||
|
timestamp: u32,
|
||||||
|
address: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metadata {
|
||||||
|
pub const DATA_LEN: usize = 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the metadata from a flash page.
|
||||||
|
impl From<Page> for Metadata {
|
||||||
|
fn from(page: Page) -> Self {
|
||||||
|
Metadata {
|
||||||
|
checksum: page[0..32].try_into().unwrap(),
|
||||||
|
timestamp: LittleEndian::read_u32(&page[32..36]),
|
||||||
|
address: LittleEndian::read_u32(&page[36..Metadata::DATA_LEN]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Location of a firmware partition's data.
|
||||||
|
struct BootPartition {
|
||||||
|
firmware_address: usize,
|
||||||
|
metadata_address: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootPartition {
|
||||||
|
const _FIRMWARE_LENGTH: usize = 0x00040000;
|
||||||
|
|
||||||
|
/// Reads the metadata, returns the timestamp if all checks pass.
|
||||||
|
pub fn read_timestamp(&self) -> Result<u32, ()> {
|
||||||
|
let metadata_page = unsafe { read_page(self.metadata_address) };
|
||||||
|
let hash_value = self.compute_upgrade_hash(&metadata_page);
|
||||||
|
let metadata = Metadata::from(metadata_page);
|
||||||
|
if self.firmware_address != metadata.address as usize {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
rprintln!(
|
||||||
|
"Firmware address mismatch: expected 0x{:08X}, metadata 0x{:08X}",
|
||||||
|
self.firmware_address,
|
||||||
|
metadata.address as usize
|
||||||
|
);
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
if hash_value != metadata.checksum {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
rprintln!("Hash mismatch");
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
Ok(metadata.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for the SHA256 implementation.
|
||||||
|
///
|
||||||
|
/// TODO implemented in next PR
|
||||||
|
/// Without it, the bootloader will never boot anything.
|
||||||
|
fn compute_upgrade_hash(&self, _metadata_page: &[u8]) -> [u8; 32] {
|
||||||
|
[0; 32]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump to the firmware.
|
||||||
|
pub fn boot(&self) -> ! {
|
||||||
|
let address = self.firmware_address;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
rprintln!("Boot jump to {:08X}", address);
|
||||||
|
let address_pointer = address as *const u32;
|
||||||
|
// https://docs.rs/cortex-m/0.7.2/cortex_m/asm/fn.bootload.html
|
||||||
|
unsafe { asm::bootload(address_pointer) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[entry]
|
||||||
|
fn main() -> ! {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
rtt_init_print!();
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
rprintln!("Starting bootloader");
|
||||||
|
let partition_a = BootPartition {
|
||||||
|
firmware_address: 0x20000,
|
||||||
|
metadata_address: 0x4000,
|
||||||
|
};
|
||||||
|
let partition_b = BootPartition {
|
||||||
|
firmware_address: 0x60000,
|
||||||
|
metadata_address: 0x5000,
|
||||||
|
};
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
rprintln!("Reading partition A");
|
||||||
|
let timestamp_a = partition_a.read_timestamp();
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
rprintln!("Reading partition B");
|
||||||
|
let timestamp_b = partition_b.read_timestamp();
|
||||||
|
|
||||||
|
match (timestamp_a, timestamp_b) {
|
||||||
|
(Ok(t1), Ok(t2)) => {
|
||||||
|
if t1 >= t2 {
|
||||||
|
partition_a.boot()
|
||||||
|
} else {
|
||||||
|
partition_b.boot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Ok(_), Err(_)) => partition_a.boot(),
|
||||||
|
(Err(_), Ok(_)) => partition_b.boot(),
|
||||||
|
(Err(_), Err(_)) => panic!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
41
deploy.py
41
deploy.py
@@ -384,6 +384,39 @@ class OpenSKInstaller:
|
|||||||
self._check_invariants()
|
self._check_invariants()
|
||||||
self._build_app_or_example(is_example=False)
|
self._build_app_or_example(is_example=False)
|
||||||
|
|
||||||
|
def build_bootloader(self):
|
||||||
|
"""Builds the upgrade bootloader."""
|
||||||
|
props = SUPPORTED_BOARDS[self.args.board]
|
||||||
|
info("Building bootloader")
|
||||||
|
rust_flags = [
|
||||||
|
f"--remap-path-prefix={os.getcwd()}=",
|
||||||
|
"-C",
|
||||||
|
"link-arg=-Wl,-Tlink.x",
|
||||||
|
"-C",
|
||||||
|
"link-arg=-nostartfiles",
|
||||||
|
]
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["RUSTFLAGS"] = " ".join(rust_flags)
|
||||||
|
cargo_command = ["cargo", "build", "--release", f"--target={props.arch}"]
|
||||||
|
self.checked_command(cargo_command, cwd="bootloader", env=env)
|
||||||
|
binary_path = os.path.join("target", props.arch, "release", "bootloader")
|
||||||
|
objcopy_command = [
|
||||||
|
"llvm-objcopy", "-O", "binary", binary_path, f"{binary_path}.bin"
|
||||||
|
]
|
||||||
|
self.checked_command(objcopy_command, cwd="bootloader")
|
||||||
|
|
||||||
|
def flash_bootloader(self):
|
||||||
|
"""Flashes the upgrade bootloader."""
|
||||||
|
props = SUPPORTED_BOARDS[self.args.board]
|
||||||
|
info("Flashing bootloader")
|
||||||
|
bin_file = os.path.join("bootloader", "target", props.arch, "release",
|
||||||
|
"bootloader.bin")
|
||||||
|
if not os.path.exists(bin_file):
|
||||||
|
fatal(f"File not found: {bin_file}")
|
||||||
|
with open(bin_file, "rb") as bootloader_bin:
|
||||||
|
bootloader = bootloader_bin.read()
|
||||||
|
self.write_binary(bootloader, 0)
|
||||||
|
|
||||||
def _build_app_or_example(self, is_example: bool):
|
def _build_app_or_example(self, is_example: bool):
|
||||||
"""Builds the application specified through args.
|
"""Builds the application specified through args.
|
||||||
|
|
||||||
@@ -732,9 +765,13 @@ class OpenSKInstaller:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Compile what needs to be compiled
|
# Compile what needs to be compiled
|
||||||
|
board_props = SUPPORTED_BOARDS[self.args.board]
|
||||||
if self.args.tockos:
|
if self.args.tockos:
|
||||||
self.build_tockos()
|
self.build_tockos()
|
||||||
|
|
||||||
|
if board_props.metadata_address is not None:
|
||||||
|
self.build_bootloader()
|
||||||
|
|
||||||
if self.args.application == "ctap2":
|
if self.args.application == "ctap2":
|
||||||
self.generate_crypto_materials(self.args.regenerate_keys)
|
self.generate_crypto_materials(self.args.regenerate_keys)
|
||||||
self.build_opensk()
|
self.build_opensk()
|
||||||
@@ -748,7 +785,6 @@ class OpenSKInstaller:
|
|||||||
self.clear_storage()
|
self.clear_storage()
|
||||||
|
|
||||||
# Flashing
|
# Flashing
|
||||||
board_props = SUPPORTED_BOARDS[self.args.board]
|
|
||||||
if self.args.programmer in ("jlink", "openocd"):
|
if self.args.programmer in ("jlink", "openocd"):
|
||||||
# We rely on Tockloader to do the job
|
# We rely on Tockloader to do the job
|
||||||
if self.args.clear_apps:
|
if self.args.clear_apps:
|
||||||
@@ -756,6 +792,9 @@ class OpenSKInstaller:
|
|||||||
if self.args.tockos:
|
if self.args.tockos:
|
||||||
# Install Tock OS
|
# Install Tock OS
|
||||||
self.install_tock_os()
|
self.install_tock_os()
|
||||||
|
if board_props.metadata_address is not None:
|
||||||
|
# Install the bootloader
|
||||||
|
self.flash_bootloader()
|
||||||
# Install padding and application if needed
|
# Install padding and application if needed
|
||||||
if self.args.application:
|
if self.args.application:
|
||||||
self.install_padding()
|
self.install_padding()
|
||||||
|
|||||||
@@ -48,3 +48,25 @@ There are 3 switches that need to be in the correct position:
|
|||||||
* Power (bottom left): On
|
* Power (bottom left): On
|
||||||
* nRF power source (center left): VDD
|
* nRF power source (center left): VDD
|
||||||
* SW6 (top right): DEFAULT
|
* SW6 (top right): DEFAULT
|
||||||
|
|
||||||
|
### Upgradability
|
||||||
|
|
||||||
|
There are variants of the board that introduce A/B partitions for upgrading the
|
||||||
|
firmware. You can bootstrap an upgradable board using one of the two commands:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
./deploy.py --board=nrf52840dk_opensk_a --opensk
|
||||||
|
./deploy.py --board=nrf52840dk_opensk_b --opensk
|
||||||
|
```
|
||||||
|
|
||||||
|
Afterwards, you can upgrade the other partition with
|
||||||
|
|
||||||
|
```shell
|
||||||
|
./tools/perform_upgrade.sh nrf52840dk_opensk_b
|
||||||
|
./tools/perform_upgrade.sh nrf52840dk_opensk_a
|
||||||
|
```
|
||||||
|
|
||||||
|
respectively. You can only upgrade the partition that is not currently running,
|
||||||
|
so always alternate your calls to `perform_upgrade.sh`. Otherwise, this script
|
||||||
|
works like `deploy.py`. You can call it even after you locked down your device,
|
||||||
|
to deploy changes to your development board.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ following:
|
|||||||
* `nrfutil` (can be installed using `pip3 install nrfutil`) if you want to flash
|
* `nrfutil` (can be installed using `pip3 install nrfutil`) if you want to flash
|
||||||
a device with DFU
|
a device with DFU
|
||||||
* `uuid-runtime` if you are missing the `uuidgen` command.
|
* `uuid-runtime` if you are missing the `uuidgen` command.
|
||||||
|
* `llvm` if you want to use the upgradability feature.
|
||||||
|
|
||||||
The proprietary software to use the default programmer can be found on the
|
The proprietary software to use the default programmer can be found on the
|
||||||
[Segger website](https://www.segger.com/downloads/jlink). Please follow their
|
[Segger website](https://www.segger.com/downloads/jlink). Please follow their
|
||||||
@@ -149,3 +150,23 @@ If your board is already flashed with Tock OS, you may skip installing it:
|
|||||||
|
|
||||||
For more options, we invite you to read the help of our `deploy.py` script by
|
For more options, we invite you to read the help of our `deploy.py` script by
|
||||||
running `./deploy.py --help`.
|
running `./deploy.py --help`.
|
||||||
|
|
||||||
|
### Upgradability
|
||||||
|
|
||||||
|
We experiment with a new CTAP command to allow upgrading your device without
|
||||||
|
access to its debugging port. For that purpose, the flash storage is split into
|
||||||
|
4 parts:
|
||||||
|
|
||||||
|
* the bootloader to decide with partition to boot
|
||||||
|
* firmware partition A
|
||||||
|
* firmware partition B
|
||||||
|
* the persistent storage for credentials
|
||||||
|
|
||||||
|
The storage is backward compatible to non-upgradable boards. Deploying an
|
||||||
|
upgradable board automatically installs the bootloader. Please keep in mind that
|
||||||
|
you have to safely store your private signing key for upgrades if you want to
|
||||||
|
use this feature. For more information on the cryptographic material, see
|
||||||
|
[Customization](customization.md).
|
||||||
|
|
||||||
|
So far, upgradability is only supported for the development board. See the
|
||||||
|
instructions on the [board specific page](boards/nrf52840dk.md).
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ cd ../..
|
|||||||
cd tools/heapviz
|
cd tools/heapviz
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cd ../..
|
cd ../..
|
||||||
|
cd bootloader
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cd ..
|
||||||
|
|
||||||
echo "Running Clippy lints..."
|
echo "Running Clippy lints..."
|
||||||
cargo clippy --all-targets --features std -- -A clippy::new_without_default -D warnings
|
cargo clippy --all-targets --features std -- -A clippy::new_without_default -D warnings
|
||||||
@@ -55,6 +58,11 @@ echo "Checking that examples build properly..."
|
|||||||
cargo check --release --target=thumbv7em-none-eabi --examples
|
cargo check --release --target=thumbv7em-none-eabi --examples
|
||||||
cargo check --release --target=thumbv7em-none-eabi --examples --features with_nfc
|
cargo check --release --target=thumbv7em-none-eabi --examples --features with_nfc
|
||||||
|
|
||||||
|
echo "Checking that bootloader builds properly..."
|
||||||
|
cd bootloader
|
||||||
|
cargo check --release --target=thumbv7em-none-eabi
|
||||||
|
cd ..
|
||||||
|
|
||||||
echo "Checking that fuzz targets build properly..."
|
echo "Checking that fuzz targets build properly..."
|
||||||
cargo fuzz build
|
cargo fuzz build
|
||||||
cd libraries/cbor
|
cd libraries/cbor
|
||||||
|
|||||||
Reference in New Issue
Block a user