Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f896c32fb7 | ||
|
|
2101785b3d | ||
|
|
d878c81ce8 | ||
|
|
8a0c7592cc | ||
|
|
882ca0a216 | ||
|
|
5d1c4f15b7 | ||
|
|
745b53698a | ||
|
|
b938b63e8a | ||
|
|
b76e5e6ba4 | ||
|
|
bbcc35e209 | ||
|
|
bede05356d | ||
|
|
031e73bbe6 | ||
|
|
9ea1f0065a | ||
|
|
8267bb22bd | ||
|
|
07a87a8254 | ||
|
|
eb719f6fca | ||
|
|
02081dc85d | ||
|
|
ed52e3019d | ||
|
|
c2bcec93d0 | ||
|
|
8bb5364d8c | ||
|
|
83c7e4ede3 | ||
|
|
a14f7afe87 | ||
|
|
1490daa7ca | ||
|
|
a4001d71d5 | ||
|
|
6da9f5f20e | ||
|
|
ab882c5619 | ||
|
|
2541db2bef | ||
|
|
f875518b28 | ||
|
|
334ad9b313 | ||
|
|
0953706e9e | ||
|
|
309d51a3e8 | ||
|
|
93f87c57b9 | ||
|
|
94ef2ae451 | ||
|
|
abef0d85a2 | ||
|
|
6b3f975ba5 |
@@ -6,8 +6,9 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 -c \"import json,sys,subprocess,shutil,os; f=json.load(sys.stdin).get('tool_input',{}).get('file_path',''); t=shutil.which('trunk') or os.path.expanduser('~/.cache/trunk/launcher/trunk'); f and os.path.exists(t) and subprocess.run([t,'fmt','--force',f],stderr=subprocess.DEVNULL)\" 2>/dev/null || true",
|
||||
"statusMessage": "Formatting..."
|
||||
"command": "f=$(tr -d '\\n' | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed 's/.*:[[:space:]]*\"//; s/\"$//'); [ -n \"$f\" ] && [ -f \"$f\" ] || exit 0; t=$(command -v trunk || echo \"$HOME/.cache/trunk/launcher/trunk\"); [ -x \"$t\" ] || { echo \"trunk-fmt hook: trunk not found; its launcher needs curl or wget to bootstrap the CLI (see 'Formatting & the trunk toolchain' in .github/copilot-instructions.md)\" >&2; exit 1; }; out=$(\"$t\" fmt --force \"$f\" 2>&1) || { echo \"trunk-fmt hook: trunk fmt failed on $f: $out\" >&2; exit 1; }",
|
||||
"timeout": 120,
|
||||
"statusMessage": "Formatting (trunk)..."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -283,6 +283,15 @@ firmware/
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### Formatting & the trunk toolchain
|
||||
|
||||
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed — no python or jq required — but trunk itself must be able to run:
|
||||
|
||||
- Trunk's launcher (`~/.cache/trunk/launcher/trunk`, or `trunk` on PATH) downloads the CLI version pinned in `.trunk/trunk.yaml` on first use and again whenever that pin is bumped. **The launcher needs `curl` or `wget`**; without one it fails with "Cannot download… please install curl or wget", and the hook surfaces that as a warning on every write.
|
||||
- No curl/wget available (e.g. a minimal WSL image)? Bootstrap by hand with any Python (PlatformIO bundles one at `~/.platformio/penv/bin/python`): download `https://trunk.io/releases/<ver>/trunk-<ver>-linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/<ver>-linux-x86_64/trunk` (chmod +x), where `<ver>` is the `cli.version` from `.trunk/trunk.yaml`.
|
||||
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage — don't re-add `2>/dev/null || true` around the whole thing.
|
||||
- More generally: don't assume a stock Linux userland in hooks or helper scripts — minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
|
||||
|
||||
### General Style
|
||||
|
||||
- Follow existing code style - run `trunk fmt` before commits
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
name: Post Web Flasher Link Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-flasher-link:
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled' &&
|
||||
github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Per-board manifests carry the firmware's own metadata (activelySupported,
|
||||
# displayName, ...) generated from each target's custom_meshtastic_* config.
|
||||
- name: Download board manifests
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: manifest-*
|
||||
path: ./manifests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Post or update web flasher link comment
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- web-flasher-link -->';
|
||||
const run = context.payload.workflow_run;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Resolve the PR by matching the run's head SHA against the repo's open
|
||||
// PRs. workflow_run.pull_requests is empty for fork PRs, and
|
||||
// listPullRequestsAssociatedWithCommit won't return an open fork PR by
|
||||
// its head commit — but pulls.list includes fork PRs. Matching on head
|
||||
// SHA also enforces that the run is for the PR's current commit, so stale
|
||||
// re-runs of an outdated commit won't match.
|
||||
const openPrs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
const pr = openPrs.find((p) => p.head.sha === run.head_sha);
|
||||
if (!pr) {
|
||||
core.info(`No open pull request matches commit ${run.head_sha}; skipping.`);
|
||||
return;
|
||||
}
|
||||
const prNumber = pr.number;
|
||||
|
||||
// Restrict to trusted authors. NOTE: author_association is computed for
|
||||
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
|
||||
// those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
|
||||
// alone silently excludes most maintainers. We allow the trusted set the
|
||||
// token can actually identify (members, collaborators, and anyone with a
|
||||
// previously merged PR). For strict members-only you'd need an org-read
|
||||
// App/PAT token to call orgs.checkMembershipForUser.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Require at least one per-arch firmware artifact from gather-artifacts
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner, repo, run_id: run.id, per_page: 100,
|
||||
});
|
||||
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
|
||||
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
|
||||
if (archArtifacts.length === 0) {
|
||||
core.info('No per-arch firmware artifacts found; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = archRe.exec(archArtifacts[0].name)[2];
|
||||
const expiresAt = archArtifacts[0].expires_at
|
||||
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
|
||||
: null;
|
||||
|
||||
// Read each built board's manifest (.mt.json). activelySupported,
|
||||
// displayName and architecture come straight from the board's
|
||||
// custom_meshtastic_* platformio config, so the list is in sync with
|
||||
// the firmware itself — no external device database needed.
|
||||
const fs = require('fs');
|
||||
let boards = [];
|
||||
try {
|
||||
boards = fs.readdirSync('./manifests')
|
||||
.filter((f) => f.endsWith('.mt.json'))
|
||||
.map((f) => {
|
||||
try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter((m) => m && m.activelySupported === true && m.platformioTarget)
|
||||
.map((m) => ({
|
||||
board: m.platformioTarget,
|
||||
platform: m.architecture || '',
|
||||
// displayName is maintainer-authored text; escape table-breaking pipes
|
||||
displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
|
||||
image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
|
||||
}))
|
||||
.sort((a, b) => a.board.localeCompare(b.board));
|
||||
} catch (e) {
|
||||
core.warning(`Could not read board manifests: ${e.message}`);
|
||||
}
|
||||
|
||||
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
|
||||
// Device illustrations are served by the flasher from the same image
|
||||
// names the manifest declares (custom_meshtastic_images). The flasher
|
||||
// serves its SPA shell (HTML, 200) for unknown paths, so confirm each
|
||||
// image really resolves to an image before linking it.
|
||||
const imageBase = 'https://flasher.meshtastic.org/img/devices/';
|
||||
await Promise.all(boards.map(async (b) => {
|
||||
if (!b.image) return;
|
||||
try {
|
||||
const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
|
||||
const type = res.headers.get('content-type') || '';
|
||||
if (!res.ok || !type.startsWith('image/')) b.image = '';
|
||||
} catch { b.image = ''; }
|
||||
}));
|
||||
|
||||
const boardLines = boards
|
||||
.map((b) => {
|
||||
const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
|
||||
return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Shields.io badges. Only non-user-controlled, charset-constrained values
|
||||
// (version, commit sha, counts, dates) go into badge URLs — never board
|
||||
// names or the PR title — so the rendered comment cannot be spoofed.
|
||||
const shieldText = (s) =>
|
||||
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
|
||||
const shield = (label, message, color) =>
|
||||
`https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
|
||||
const buttonUrl =
|
||||
`https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
|
||||
const badges = [
|
||||
`})`,
|
||||
`, '2C2D3C')})`,
|
||||
`})`,
|
||||
];
|
||||
if (expiresAt) badges.push(`})`);
|
||||
|
||||
// Only render the board table when there are supported boards to list
|
||||
const boardTable = boards.length > 0 ? [
|
||||
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
|
||||
'',
|
||||
'| | Device | Board | Platform |',
|
||||
'| --- | --- | --- | --- |',
|
||||
boardLines,
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
] : [];
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
`[](${flasherUrl})`,
|
||||
'',
|
||||
badges.join(' '),
|
||||
'',
|
||||
'> [!WARNING]',
|
||||
'> This is an automated, unreviewed CI test build. Back up your device configuration',
|
||||
'> before flashing, and only flash devices you are able to recover.',
|
||||
'',
|
||||
...boardTable,
|
||||
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
|
||||
].join('\n');
|
||||
|
||||
// Sticky comment: update in place when the marker is found
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: prNumber, per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Post Web Flasher Build Placeholder
|
||||
|
||||
# Drops an immediate "build in progress" comment when a PR opens, so the web
|
||||
# flasher entry shows up right away. The real CI-driven workflow
|
||||
# (flasher-link-comment.yml) later replaces it in place via the shared marker.
|
||||
#
|
||||
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
|
||||
# safe because it never checks out or runs PR code and posts a fully static body
|
||||
# — no PR title, branch name, or other untrusted input is used anywhere.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
post-placeholder:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Post web flasher build-in-progress placeholder
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- web-flasher-link -->';
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Trusted authors only (matches the real workflow). author_association
|
||||
// can't reflect private org membership for the token, so concealed
|
||||
// members appear as CONTRIBUTOR — include it, or maintainers are excluded.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only seed a placeholder when no flasher comment exists yet — never
|
||||
// overwrite a real (or existing placeholder) comment.
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
if (comments.some((c) => c.body?.includes(marker))) {
|
||||
core.info('Flasher comment already exists; nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
'> [!NOTE]',
|
||||
'> Building this pull request… the flash button, badges and supported-board',
|
||||
'> list will appear here automatically once CI finishes.',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number, body,
|
||||
});
|
||||
@@ -82,8 +82,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
check: ${{ fromJson(needs.setup.outputs.check) }}
|
||||
# Use 'arctastic' self-hosted runner pool when checking in the main repo
|
||||
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
|
||||
# Runs on GitHub-hosted runners so checks don't compete with builds for the
|
||||
# self-hosted 'arctastic' pool (which builds use).
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -286,11 +287,11 @@ jobs:
|
||||
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
|
||||
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
|
||||
if [ -n "$ARTIFACT_NAME" ]; then
|
||||
gh run download "$RUN_ID" -R "${{ github.repository }}" \
|
||||
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
|
||||
cp "./baseline-develop/${ARTIFACT_NAME}/current-sizes.json" ./develop-sizes.json
|
||||
cp "./baseline-develop/current-sizes.json" ./develop-sizes.json
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -311,11 +312,11 @@ jobs:
|
||||
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | .name' | head -1)
|
||||
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
|
||||
if [ -n "$ARTIFACT_NAME" ]; then
|
||||
gh run download "$RUN_ID" -R "${{ github.repository }}" \
|
||||
--name "$ARTIFACT_NAME" --dir ./baseline-master/
|
||||
cp "./baseline-master/${ARTIFACT_NAME}/current-sizes.json" ./master-sizes.json
|
||||
cp "./baseline-master/current-sizes.json" ./master-sizes.json
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -37,13 +37,33 @@ jobs:
|
||||
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
|
||||
|
||||
- name: Integration test
|
||||
# Cap the whole step: if the simulator ever fails to exit (e.g. the
|
||||
# exit_simulator admin path regresses again) the job must fail fast,
|
||||
# not run to GitHub's 6-hour limit.
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
.pio/build/coverage/meshtasticd -s &
|
||||
PID=$!
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
|
||||
echo "Simulator started, launching python test..."
|
||||
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
|
||||
wait
|
||||
# The Python harness sends exit_simulator and exits; the simulator is
|
||||
# expected to terminate on its own. Give it a moment, then verify.
|
||||
# If it is still alive the exit handshake is broken — fail loudly and
|
||||
# do NOT fall through to `wait`, which would otherwise block until the
|
||||
# job's hard timeout.
|
||||
for i in $(seq 1 10); do
|
||||
kill -0 "$PID" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
|
||||
kill -9 "$PID" 2>/dev/null || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
wait "$PID" 2>/dev/null || true
|
||||
|
||||
- name: Capture coverage information
|
||||
if: always() # run this step even if previous step failed
|
||||
|
||||
@@ -64,7 +64,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
|
||||
- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
|
||||
- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test.
|
||||
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
|
||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
|
||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI — see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
|
||||
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
||||
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52832_s132_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52832_XXAA -DNRF52",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x8029"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x239A", "0x802A"]
|
||||
],
|
||||
"usb_product": "Feather nRF52832 Express",
|
||||
"mcu": "nrf52832",
|
||||
"variant": "WisCore_RAK4600_Board",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS132",
|
||||
"sd_name": "s132",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B7"
|
||||
},
|
||||
"zephyr": {
|
||||
"variant": "nrf52_adafruit_feather"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52832_xxAA",
|
||||
"svd_path": "nrf52.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino", "zephyr"],
|
||||
"name": "Adafruit Bluefruit nRF52832 Feather",
|
||||
"upload": {
|
||||
"maximum_ram_size": 65536,
|
||||
"maximum_size": 524288,
|
||||
"require_upload_port": true,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"]
|
||||
},
|
||||
"url": "https://www.adafruit.com/product/3406",
|
||||
"vendor": "Adafruit"
|
||||
}
|
||||
@@ -80,14 +80,19 @@ env.AddBuildMiddleware(_no_lto)
|
||||
# the weak `b .` Default_Handler stub -- i.e. LTO (or a deps bump, or a new ISR-owning library
|
||||
# that nobody added to LIB_ISR) silently dropped it. A dropped handler hangs the chip the
|
||||
# instant that IRQ fires; this turns a field hang into a red build. CI builds every nrf52840
|
||||
# target, so this runs on every PR automatically. All five are used by every nrf52840
|
||||
# Meshtastic build; if a board deliberately stops using one, edit this tuple on purpose.
|
||||
# target, so this runs on every PR automatically. If a board deliberately stops using one of
|
||||
# these, edit the tuples on purpose.
|
||||
_REQUIRED_STRONG = (
|
||||
"SWI2_EGU2_IRQHandler", # SoftDevice BLE event (SD_EVT) -- advertising & connections
|
||||
"GPIOTE_IRQHandler", # GPIO interrupts: radio DIO + buttons
|
||||
"RTC1_IRQHandler", # FreeRTOS scheduler tick
|
||||
)
|
||||
# Owned by the TinyUSB stack, so only required when the board builds with USB at all.
|
||||
# Boards without native USB wiring (e.g. wio-sdk-wm1110's CH340 UART) strip TinyUSB via
|
||||
# disable_adafruit_usb.py / unflagging USE_TINYUSB, leaving these legitimately weak.
|
||||
_REQUIRED_STRONG_USB = (
|
||||
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
|
||||
"POWER_CLOCK_IRQHandler", # HF/LF clock + power (HFCLK start for radio & SoftDevice)
|
||||
"POWER_CLOCK_IRQHandler", # USB power events (VBUS detect/ready) via TinyUSB hal
|
||||
)
|
||||
|
||||
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
|
||||
@@ -113,7 +118,13 @@ def _assert_isr_handlers_survived(source, target, env):
|
||||
f = line.split()
|
||||
if len(f) >= 3 and f[-1].endswith("_IRQHandler"):
|
||||
kind[f[-1]] = f[-2]
|
||||
dropped = [h for h in _REQUIRED_STRONG if kind.get(h, "W").upper() != "T"]
|
||||
required = list(_REQUIRED_STRONG)
|
||||
defines = [
|
||||
str(d[0] if isinstance(d, tuple) else d) for d in env.get("CPPDEFINES", [])
|
||||
]
|
||||
if "USE_TINYUSB" in defines:
|
||||
required += _REQUIRED_STRONG_USB
|
||||
dropped = [h for h in required if kind.get(h, "W").upper() != "T"]
|
||||
if dropped:
|
||||
sys.stderr.write(
|
||||
"\n*** nrf52 LTO guard: interrupt handler(s) DROPPED: %s ***\n"
|
||||
@@ -127,8 +138,7 @@ def _assert_isr_handlers_survived(source, target, env):
|
||||
|
||||
Exit(1) # canonical SCons build-abort -> red build
|
||||
print(
|
||||
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong"
|
||||
% len(_REQUIRED_STRONG)
|
||||
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong" % len(required)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
|
||||
"esp32-c6",
|
||||
"esp32c6",
|
||||
}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840"}
|
||||
|
||||
|
||||
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
|
||||
|
||||
+1
-1
Submodule protobufs updated: 7916a0ce81...1df6c11542
+2
-2
@@ -14,8 +14,8 @@
|
||||
#define FILE_O_READ "r"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL)
|
||||
// STM32WL
|
||||
#if defined(ARCH_STM32)
|
||||
// STM32
|
||||
#include "LittleFS.h"
|
||||
#define FSCom InternalFS
|
||||
#define FSBegin() FSCom.begin()
|
||||
|
||||
+11
-6
@@ -14,6 +14,7 @@
|
||||
* For more information, see: https://meshtastic.org/
|
||||
*/
|
||||
#include "power.h"
|
||||
#include "BluetoothCommon.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PowerFSM.h"
|
||||
@@ -47,7 +48,7 @@
|
||||
#include "concurrency/LockGuard.h"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
|
||||
#if defined(ARCH_STM32) && defined(BATTERY_PIN)
|
||||
#include "stm32yyxx_ll_adc.h"
|
||||
|
||||
/* Analog read resolution */
|
||||
@@ -430,7 +431,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
float scaled = 0;
|
||||
|
||||
battery_adcEnable();
|
||||
#ifdef ARCH_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
// STM32 ADC with VREFINT runtime calibration
|
||||
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
|
||||
raw = analogRead(BATTERY_PIN);
|
||||
@@ -607,7 +608,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
bool initial_read_done = false;
|
||||
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
|
||||
uint32_t last_read_time_ms = 0;
|
||||
#ifdef ARCH_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
// 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing
|
||||
// (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset")
|
||||
uint32_t Vref = 3300;
|
||||
@@ -717,7 +718,7 @@ bool Power::analogInit()
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
|
||||
adc_oneshot_unit_init_cfg_t init_config = {
|
||||
@@ -748,7 +749,7 @@ bool Power::analogInit()
|
||||
|
||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32)
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#endif
|
||||
|
||||
@@ -837,7 +838,7 @@ void Power::reboot()
|
||||
}
|
||||
LOG_DEBUG("final reboot!");
|
||||
::reboot();
|
||||
#elif defined(ARCH_STM32WL)
|
||||
#elif defined(ARCH_STM32)
|
||||
HAL_NVIC_SystemReset();
|
||||
#else
|
||||
rebootAtMsec = -1;
|
||||
@@ -962,6 +963,10 @@ void Power::readPowerStatus()
|
||||
lastLogTime = millis();
|
||||
}
|
||||
newStatus.notifyObservers(&powerStatus2);
|
||||
|
||||
// Mirror battery level to the BLE Battery Service (0x2A19); the platform layer clamps and dedupes.
|
||||
if (hasBattery == OptTrue)
|
||||
updateBatteryLevel(powerStatus2.getBatteryChargePercent());
|
||||
#ifdef DEBUG_HEAP
|
||||
if (lastheap != memGet.getFreeHeap()) {
|
||||
// Use stack-allocated buffer to avoid heap allocations in monitoring code
|
||||
|
||||
@@ -219,7 +219,11 @@ static void darkEnter()
|
||||
static void serialEnter()
|
||||
{
|
||||
LOG_POWERFSM("State: serialEnter");
|
||||
#ifndef ARCH_NRF52
|
||||
// nRF52 runs BLE on SoftDevice independently of USB serial — no need to disable it.
|
||||
// (Same rationale as nbEnter() which already guards this with #ifdef ARCH_ESP32)
|
||||
setBluetoothEnable(false);
|
||||
#endif
|
||||
if (screen) {
|
||||
screen->setOn(true);
|
||||
}
|
||||
|
||||
@@ -573,5 +573,94 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define USE_ETHERNET_DEFAULT 0
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// MESHTASTIC_LOCKDOWN — runtime, client-toggleable hardening (nRF52 only)
|
||||
//
|
||||
// Lockdown/protect support is opt-in at build time. Builds that need it pass
|
||||
// -DMESHTASTIC_ENABLE_LOCKDOWN=1. When enabled on nRF52 (CC310 hardware
|
||||
// crypto), whether it is ACTIVE is decided entirely at runtime by
|
||||
// EncryptedStorage::isLockdownActive()
|
||||
// (== a passphrase has been provisioned, i.e. /prefs/.dek exists). A device
|
||||
// that has never been provisioned — or that the operator disabled from the
|
||||
// client app — behaves exactly like stock firmware: plaintext storage, no
|
||||
// redaction, normal logging, normal display.
|
||||
//
|
||||
// The operator toggles lockdown from the client app:
|
||||
// off -> on : provision a passphrase (AdminMessage.lockdown_auth). The
|
||||
// firmware generates a DEK, encrypts the stored config, and
|
||||
// authorizes the connection.
|
||||
// on -> off : AdminMessage.lockdown_auth { disable=true } with the
|
||||
// passphrase — decrypts storage back to plaintext and removes
|
||||
// the DEK / token / monotonic-counter / backoff files, then
|
||||
// reboots into normal mode. APPROTECT is the one thing that
|
||||
// does NOT revert (see below).
|
||||
//
|
||||
// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker. It gates the UI
|
||||
// bits (lock screen, pairing-PIN handling). Flash-constrained nRF52 variants
|
||||
// that genuinely cannot afford the ~tens-of-KB of crypto + access-control code
|
||||
// may also opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
|
||||
//
|
||||
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — per-connection auth + redaction,
|
||||
// gated at runtime on isLockdownActive()
|
||||
// MESHTASTIC_ENCRYPTED_STORAGE — AES-128-CTR + HMAC-SHA256 at-rest
|
||||
// MESHTASTIC_ENABLE_APPROTECT — UICR APPROTECT capability. The actual
|
||||
// one-way burn happens at runtime, only
|
||||
// once provisioned, only on non-vulnerable
|
||||
// silicon, and is STICKY: disabling
|
||||
// lockdown does NOT (cannot) reverse it.
|
||||
//
|
||||
// DEBUG_MUTE is intentionally NOT coupled to lockdown — a capable-but-off
|
||||
// device must log normally. Define DEBUG_MUTE separately for a silent build.
|
||||
//
|
||||
// -DMESHTASTIC_LOCKDOWN_DEBUG=1 keeps the irreversible APPROTECT burn disabled
|
||||
// even when provisioned — for development so dev boards never lose SWD.
|
||||
// -----------------------------------------------------------------------------
|
||||
#if defined(ARCH_NRF52)
|
||||
#ifndef MESHTASTIC_ENABLE_LOCKDOWN
|
||||
#define MESHTASTIC_ENABLE_LOCKDOWN 0
|
||||
#endif
|
||||
|
||||
#if !MESHTASTIC_ENABLE_LOCKDOWN
|
||||
#undef MESHTASTIC_LOCKDOWN
|
||||
#undef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
#undef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#undef MESHTASTIC_ENABLE_APPROTECT
|
||||
#ifndef MESHTASTIC_EXCLUDE_LOCKDOWN
|
||||
#define MESHTASTIC_EXCLUDE_LOCKDOWN 1
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if MESHTASTIC_ENABLE_LOCKDOWN && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
|
||||
#define MESHTASTIC_LOCKDOWN 1
|
||||
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
|
||||
#define MESHTASTIC_ENCRYPTED_STORAGE 1
|
||||
#ifndef MESHTASTIC_LOCKDOWN_DEBUG
|
||||
#define MESHTASTIC_ENABLE_APPROTECT 1
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
|
||||
// Per-boot uptime cap on unlocked sessions. 0 = unlimited (token-only
|
||||
// enforcement, the existing behavior). When non-zero, every passphrase
|
||||
// unlock (and every token-auto-unlock that inherits the value) arms a
|
||||
// timer; on expiry the device lockNow()s and reboots into locked state.
|
||||
// Bounds the total exposure window to bootsRemaining * this value if an
|
||||
// attacker has physical possession but not the passphrase.
|
||||
//
|
||||
// Override at build time. Suggested:
|
||||
// carry device: 3600 (1h sessions, periodic re-auth from phone)
|
||||
// tower / infra node: 0 (default — relies on token TTLs only)
|
||||
//
|
||||
// A future LockdownAuth.max_session_seconds proto field will let the
|
||||
// client set this per-token; until that lands the build-time value is
|
||||
// the only source.
|
||||
#ifndef MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS
|
||||
#define MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS 0
|
||||
#endif
|
||||
|
||||
#endif // MESHTASTIC_LOCKDOWN
|
||||
|
||||
#include "DebugConfiguration.h"
|
||||
#include "RF95Configuration.h"
|
||||
|
||||
@@ -37,8 +37,8 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
|
||||
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
|
||||
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
|
||||
return firstOfOrNONE(11, types);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#include "linux/LinuxHardwareI2C.h"
|
||||
#endif
|
||||
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL)
|
||||
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32)
|
||||
#include "meshUtils.h" // vformat
|
||||
|
||||
#endif
|
||||
|
||||
+75
-21
@@ -47,7 +47,7 @@ template <typename T, std::size_t N> std::size_t array_count(const T (&)[N])
|
||||
|
||||
#if defined(ARCH_NRF52)
|
||||
Uart *GPS::_serial_gps = &GPS_SERIAL_PORT;
|
||||
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
|
||||
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
|
||||
HardwareSerial *GPS::_serial_gps = &GPS_SERIAL_PORT;
|
||||
#elif defined(ARCH_RP2040)
|
||||
SerialUART *GPS::_serial_gps = &GPS_SERIAL_PORT;
|
||||
@@ -80,6 +80,12 @@ namespace
|
||||
constexpr uint32_t GPS_PROBE_CACHE_MAGIC = 0x47504348UL; // "GPCH"
|
||||
constexpr uint16_t GPS_PROBE_CACHE_VERSION = 1;
|
||||
constexpr const char *GPS_PROBE_CACHE_FILE = "/prefs/gps_probe_cache.dat";
|
||||
constexpr int MIN_PLAUSIBLE_GPS_YEAR = 2020;
|
||||
constexpr int MAX_PLAUSIBLE_GPS_YEAR = 2100;
|
||||
#ifdef TRACKER_T1000_E
|
||||
constexpr uint32_t T1000_E_AIROHA_WAKE_MS = 1000;
|
||||
constexpr uint32_t T1000_E_AIROHA_WAKE_INTERVAL_MS = 40;
|
||||
#endif
|
||||
|
||||
struct GPSProbeCacheRecord {
|
||||
uint32_t magic;
|
||||
@@ -101,6 +107,45 @@ bool isValidProbeBaud(uint32_t baud)
|
||||
return baud >= 1200 && baud <= 921600;
|
||||
}
|
||||
|
||||
template <typename T> void wakeAirohaForActiveProbe(T *serialGps)
|
||||
{
|
||||
#ifdef TRACKER_T1000_E
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
digitalWrite(GPS_RTC_INT, HIGH);
|
||||
delay(3);
|
||||
digitalWrite(GPS_RTC_INT, LOW);
|
||||
delay(50);
|
||||
|
||||
const uint32_t start = millis();
|
||||
do {
|
||||
serialGps->write("$PAIR382,1*2E\r\n");
|
||||
delay(T1000_E_AIROHA_WAKE_INTERVAL_MS);
|
||||
} while (Throttle::isWithinTimespanMs(start, T1000_E_AIROHA_WAKE_MS));
|
||||
#elif defined(GNSS_AIROHA)
|
||||
serialGps->write("$PAIR382,1*2E\r\n");
|
||||
delay(20);
|
||||
#else
|
||||
(void)serialGps;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isPlausibleNmeaTime(const struct tm &t)
|
||||
{
|
||||
const int year = t.tm_year + 1900;
|
||||
if (year < MIN_PLAUSIBLE_GPS_YEAR || year > MAX_PLAUSIBLE_GPS_YEAR) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef BUILD_EPOCH
|
||||
const int64_t candidate = static_cast<int64_t>(gm_mktime(&t));
|
||||
const int64_t minEpoch = static_cast<int64_t>(BUILD_EPOCH);
|
||||
const int64_t maxEpoch = minEpoch + static_cast<int64_t>(FORTY_YEARS);
|
||||
return candidate >= minEpoch && candidate <= maxEpoch;
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
template <typename T> bool sawNmeaSentenceAtBaud(T *serialGps, uint32_t timeoutMs)
|
||||
{
|
||||
// Lightweight passive check: look for at least one complete
|
||||
@@ -642,7 +687,7 @@ bool GPS::verifyCachedProbePresence()
|
||||
return false;
|
||||
}
|
||||
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
|
||||
_serial_gps->end();
|
||||
_serial_gps->begin(cachedProbeBaud);
|
||||
#elif defined(ARCH_RP2040)
|
||||
@@ -689,6 +734,7 @@ bool GPS::verifyCachedProbePresence()
|
||||
case GNSS_MODEL_AG3352:
|
||||
if (cachedProbeModel == GNSS_MODEL_AG3352)
|
||||
cachedProbeModelName = "AG3352";
|
||||
wakeAirohaForActiveProbe(_serial_gps);
|
||||
_serial_gps->write("$PAIR021*39\r\n");
|
||||
present = (getACK("$PAIR021,", 900) == GNSS_RESPONSE_OK);
|
||||
break;
|
||||
@@ -741,7 +787,6 @@ bool GPS::verifyCachedProbePresence()
|
||||
if (!present) {
|
||||
LOG_WARN("Cached GPS probe is stale (%s @ %d), clearing cache", cachedProbeModelName, cachedProbeBaud);
|
||||
clearProbeCache();
|
||||
cachedProbeFailedThisBoot = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -762,13 +807,6 @@ bool GPS::setup()
|
||||
{
|
||||
if (!didSerialInit) {
|
||||
int msglen = 0;
|
||||
if (cachedProbeFailedThisBoot) {
|
||||
// If cached verification failed, suppress further probing until
|
||||
// reboot.
|
||||
didSerialInit = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
if (!hasProbeCache && !triedProbeCache) {
|
||||
(void)loadProbeCache();
|
||||
@@ -777,12 +815,13 @@ bool GPS::setup()
|
||||
if (hasProbeCache && !triedProbeCache) {
|
||||
triedProbeCache = true;
|
||||
if (!verifyCachedProbePresence()) {
|
||||
// Cache was stale and got wiped; skip scanning this boot
|
||||
// and let next boot do a full probe.
|
||||
didSerialInit = true;
|
||||
return true;
|
||||
currentStep = 0;
|
||||
speedSelect = 0;
|
||||
probeTries = 0;
|
||||
}
|
||||
} else if (probeTries < GPS_PROBETRIES) {
|
||||
}
|
||||
|
||||
if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries < GPS_PROBETRIES) {
|
||||
// No usable cache: walk common baud rates first.
|
||||
gnssModel = probe(serialSpeeds[speedSelect]);
|
||||
if (gnssModel != GNSS_MODEL_UNKNOWN) {
|
||||
@@ -794,7 +833,7 @@ bool GPS::setup()
|
||||
}
|
||||
// Rare Serial Speeds
|
||||
#ifndef CONFIG_IDF_TARGET_ESP32C6
|
||||
else if (probeTries == GPS_PROBETRIES) {
|
||||
else if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries == GPS_PROBETRIES) {
|
||||
// Then try less common baud rates before giving up.
|
||||
gnssModel = probe(rareSerialSpeeds[speedSelect]);
|
||||
if (gnssModel != GNSS_MODEL_UNKNOWN) {
|
||||
@@ -1125,6 +1164,15 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
|
||||
break;
|
||||
if (oldState != GPS_ACTIVE && oldState != GPS_IDLE) // If hardware just waking now, clear buffer
|
||||
clearBuffer();
|
||||
#ifdef TRACKER_T1000_E
|
||||
pinMode(GPS_VRTC_EN, OUTPUT);
|
||||
digitalWrite(GPS_VRTC_EN, HIGH);
|
||||
pinMode(GPS_SLEEP_INT, OUTPUT);
|
||||
digitalWrite(GPS_SLEEP_INT, HIGH);
|
||||
pinMode(GPS_RTC_INT, OUTPUT);
|
||||
digitalWrite(GPS_RTC_INT, LOW);
|
||||
pinMode(GPS_RESETB_OUT, INPUT_PULLUP);
|
||||
#endif
|
||||
powerMon->setState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
|
||||
writePinEN(true); // Power (EN pin): on
|
||||
setPowerPMU(true); // Power (PMU): on
|
||||
@@ -1383,9 +1431,8 @@ int32_t GPS::runOnce()
|
||||
if (!setup())
|
||||
return currentDelay; // Setup failed, re-run in two seconds
|
||||
|
||||
if (cachedProbeFailedThisBoot || gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
LOG_WARN("GPS not detected at cached settings; marked not present "
|
||||
"for this boot");
|
||||
if (gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
LOG_WARN("GPS not detected; marked not present for this boot");
|
||||
return disable();
|
||||
}
|
||||
|
||||
@@ -1564,7 +1611,7 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
|
||||
switch (currentStep) {
|
||||
case 0: {
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
|
||||
_serial_gps->end();
|
||||
_serial_gps->begin(serialSpeed);
|
||||
#elif defined(ARCH_RP2040)
|
||||
@@ -1585,6 +1632,9 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); // assert for 10ms
|
||||
delay(10);
|
||||
digitalWrite(PIN_GPS_RESET, !GPS_RESET_MODE);
|
||||
#ifdef TRACKER_T1000_E
|
||||
delay(100);
|
||||
#endif
|
||||
|
||||
// attempt to detect the chip based on boot messages
|
||||
std::vector<ChipInfo> passive_detect = {
|
||||
@@ -1637,6 +1687,7 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
}
|
||||
case 3: {
|
||||
/* Airoha (Mediatek) AG3335A/M/S, A3352Q, Quectel L89 2.0, SimCom SIM65M */
|
||||
wakeAirohaForActiveProbe(_serial_gps);
|
||||
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF to reduce volume
|
||||
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF to reduce volume
|
||||
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
|
||||
@@ -1921,7 +1972,7 @@ std::unique_ptr<GPS> GPS::createGps()
|
||||
#elif defined(ARCH_NRF52)
|
||||
_serial_gps->setPins(new_gps->rx_gpio, new_gps->tx_gpio);
|
||||
_serial_gps->begin(GPS_BAUDRATE);
|
||||
#elif defined(ARCH_STM32WL)
|
||||
#elif defined(ARCH_STM32)
|
||||
_serial_gps->setTx(new_gps->tx_gpio);
|
||||
_serial_gps->setRx(new_gps->rx_gpio);
|
||||
_serial_gps->begin(GPS_BAUDRATE);
|
||||
@@ -1968,6 +2019,9 @@ The Unix epoch (or Unix time or POSIX time or Unix timestamp) is the number of s
|
||||
t.tm_year = d.year() - 1900;
|
||||
t.tm_isdst = false;
|
||||
if (t.tm_mon > -1) {
|
||||
if (!isPlausibleNmeaTime(t)) {
|
||||
return false;
|
||||
}
|
||||
if (perhapsSetRTC(RTCQualityGPS, t) == RTCSetResultSuccess) {
|
||||
LOG_DEBUG("NMEA GPS time set %02d-%02d-%02d %02d:%02d:%02d age %d", d.year(), d.month(), t.tm_mday, t.tm_hour,
|
||||
t.tm_min, t.tm_sec, ti.age());
|
||||
|
||||
@@ -193,8 +193,6 @@ class GPS : private concurrency::OSThread
|
||||
bool hasProbeCache = false;
|
||||
// Ensures cached probe is attempted once per boot.
|
||||
bool triedProbeCache = false;
|
||||
// Latched when cached presence check fails
|
||||
bool cachedProbeFailedThisBoot = false;
|
||||
|
||||
/**
|
||||
* hasValidLocation - indicates that the position variables contain a complete
|
||||
|
||||
+159
-26
@@ -41,6 +41,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "draw/UIRenderer.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
#include "security/LockdownDisplay.h"
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_GPS
|
||||
#include "GPS.h"
|
||||
@@ -119,8 +120,76 @@ static inline void prepareFrameColorRegions()
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
// Static lock screen drawn in place of normal frames when
|
||||
// meshtastic_security::shouldRedactDisplay() returns true. Renders centered
|
||||
// "LOCKED" plus battery so the operator can see the device is alive and
|
||||
// charged without leaking any node/channel/message/position content.
|
||||
// Draw the LOCKED frame into the host-side framebuffer. Does NOT commit
|
||||
// to the panel — the caller is responsible for calling display->display()
|
||||
// once it has composited any overlays on top. Committing here would cause
|
||||
// visible flicker between "just LOCKED" and "LOCKED + banner overlay" when
|
||||
// the pairing-PIN special-case in updateUiFrame paints the overlay after
|
||||
// this returns.
|
||||
static void drawLockdownLockScreenIntoBuffer(OLEDDisplay *display)
|
||||
{
|
||||
display->clear();
|
||||
|
||||
const int w = display->getWidth();
|
||||
const int h = display->getHeight();
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->setFont(FONT_LARGE);
|
||||
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, "LOCKED");
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
char status[32] = "Connect to unlock";
|
||||
if (powerStatus && powerStatus->getHasBattery()) {
|
||||
int pct = powerStatus->getBatteryChargePercent();
|
||||
snprintf(status, sizeof(status), "Battery %d%%", pct);
|
||||
}
|
||||
display->drawString(w / 2, h / 2 + 2, status);
|
||||
}
|
||||
|
||||
// Convenience wrapper for callers that want the LOCKED frame committed
|
||||
// to the panel immediately and have no overlay to compose on top.
|
||||
static void drawLockdownLockScreen(OLEDDisplay *display)
|
||||
{
|
||||
drawLockdownLockScreenIntoBuffer(display);
|
||||
display->display();
|
||||
}
|
||||
#endif
|
||||
|
||||
static inline void updateUiFrame(OLEDDisplayUi *ui)
|
||||
{
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
if (meshtastic_security::shouldRedactDisplay() && screen != nullptr) {
|
||||
OLEDDisplay *display = screen->getDisplayDevice();
|
||||
// Paint LOCKED into the framebuffer WITHOUT committing. We commit
|
||||
// exactly once at the bottom — after any overlay has been composed
|
||||
// on top — so the panel never visibly transitions from "just LOCKED"
|
||||
// to "LOCKED + overlay" mid-frame. Committing twice per cycle was
|
||||
// the source of the H13 flicker.
|
||||
drawLockdownLockScreenIntoBuffer(display);
|
||||
// Special-case the BLE pairing PIN banner. The PIN is needed to
|
||||
// complete first-pair against a locked device, but the lockdown
|
||||
// short-circuit would otherwise hide the PIN entirely. The PIN is
|
||||
// a per-attempt ephemeral pair-handshake artifact, not operator
|
||||
// content, so compositing it over the LOCKED frame is safe.
|
||||
//
|
||||
// Calling ui->update() here would be wrong: it redraws the current
|
||||
// carousel frame (the dashboard) into the framebuffer before the
|
||||
// overlay paints, leaving operator content visible underneath the
|
||||
// banner. Instead we invoke the banner overlay callback directly,
|
||||
// which paints only the banner box on top of the LOCKED pixels we
|
||||
// already have in the framebuffer.
|
||||
if (NotificationRenderer::current_notification_type == notificationTypeEnum::pairing_pin) {
|
||||
NotificationRenderer::drawBannercallback(display, ui->getUiState());
|
||||
}
|
||||
display->display();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
prepareFrameColorRegions();
|
||||
#endif
|
||||
@@ -164,6 +233,16 @@ static inline float wrapHeading360(float heading)
|
||||
return heading;
|
||||
}
|
||||
|
||||
static inline float wrapDelta180(float delta)
|
||||
{
|
||||
if (delta > 180.0f) {
|
||||
delta -= 360.0f;
|
||||
} else if (delta < -180.0f) {
|
||||
delta += 360.0f;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
void Screen::setHeading(float heading)
|
||||
{
|
||||
const float wrappedHeading = wrapHeading360(heading);
|
||||
@@ -175,37 +254,30 @@ void Screen::setHeading(float heading)
|
||||
}
|
||||
|
||||
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
|
||||
float delta = wrappedHeading - compassHeading;
|
||||
if (delta > 180.0f) {
|
||||
delta -= 360.0f;
|
||||
} else if (delta < -180.0f) {
|
||||
delta += 360.0f;
|
||||
}
|
||||
float delta = wrapDelta180(wrappedHeading - compassHeading);
|
||||
|
||||
// Adaptive filtering:
|
||||
// - Strong damping for tiny deltas (jitter)
|
||||
// - Faster response for larger turns
|
||||
const float absDelta = (delta >= 0.0f) ? delta : -delta;
|
||||
if (absDelta < 1.0f) {
|
||||
return;
|
||||
}
|
||||
if (absDelta >= 1.0f) {
|
||||
float alpha = 0.35f;
|
||||
if (absDelta > 25.0f) {
|
||||
alpha = 0.85f;
|
||||
} else if (absDelta > 10.0f) {
|
||||
alpha = 0.65f;
|
||||
}
|
||||
|
||||
float alpha = 0.35f;
|
||||
if (absDelta > 25.0f) {
|
||||
alpha = 0.85f;
|
||||
} else if (absDelta > 10.0f) {
|
||||
alpha = 0.65f;
|
||||
}
|
||||
float step = delta * alpha;
|
||||
const float maxStep = 12.0f;
|
||||
if (step > maxStep) {
|
||||
step = maxStep;
|
||||
} else if (step < -maxStep) {
|
||||
step = -maxStep;
|
||||
}
|
||||
|
||||
float step = delta * alpha;
|
||||
const float maxStep = 12.0f;
|
||||
if (step > maxStep) {
|
||||
step = maxStep;
|
||||
} else if (step < -maxStep) {
|
||||
step = -maxStep;
|
||||
compassHeading = wrapHeading360(compassHeading + step);
|
||||
}
|
||||
|
||||
compassHeading = wrapHeading360(compassHeading + step);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
@@ -583,6 +655,21 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
setScreensaverFrames(einkScreensaver);
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
// M19: before turning the panel off, paint a safe frame into the
|
||||
// OLED's GDDRAM. The panel retains whatever was last written even
|
||||
// while powered down, so when displayOn() is called later the
|
||||
// screen would otherwise flash the previous frame's content for
|
||||
// 16-50 ms before the next ui->update() lands. Painting the
|
||||
// LOCKED frame now ensures the only thing the operator (or
|
||||
// someone over their shoulder) can see on wake is the redacted
|
||||
// view. Gated on lockdown — non-lockdown builds keep the
|
||||
// previous frame as a UX cue that the display is just dimmed.
|
||||
// dispdev is dereferenced unguarded throughout this file (incl.
|
||||
// displayOff() just below), so no null check here.
|
||||
drawLockdownLockScreen(dispdev);
|
||||
#endif
|
||||
|
||||
#ifdef PIN_EINK_EN
|
||||
digitalWrite(PIN_EINK_EN, LOW);
|
||||
#elif defined(PCA_PIN_EINK_EN)
|
||||
@@ -702,6 +789,27 @@ void Screen::setup()
|
||||
#endif
|
||||
LOG_INFO("Applied screen brightness: %d", brightness);
|
||||
|
||||
#if defined(MESHTASTIC_LOCKDOWN) && defined(USE_EINK)
|
||||
// M20: e-ink panels physically retain the last-rendered image without
|
||||
// power, so a power-cycled lockdown handheld would keep showing
|
||||
// operator-identifying content (position, messages, node info) until
|
||||
// the firmware's first natural refresh — which on e-ink can be seconds
|
||||
// into boot. Force a full refresh to the LOCKED frame here, immediately
|
||||
// after the display is initialised and before any other rendering, so
|
||||
// the persistent pixels are wiped to the redacted view before an
|
||||
// observer can see them.
|
||||
if (meshtastic_security::shouldRedactDisplay()) {
|
||||
drawLockdownLockScreen(dispdev);
|
||||
#if defined(USE_EINK_PARALLELDISPLAY)
|
||||
// Parallel-display variants drive refresh through a different path;
|
||||
// a bare drawLockdownLockScreen above lands the frame into the
|
||||
// panel buffer and the next ui->update() commits it as normal.
|
||||
#else
|
||||
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// Set custom overlay callbacks
|
||||
static OverlayCallback overlays[] = {
|
||||
graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame
|
||||
@@ -809,10 +917,16 @@ void Screen::setOn(bool on, FrameCallback einkScreensaver)
|
||||
if (cardKbI2cImpl)
|
||||
cardKbI2cImpl->toggleBacklight(on);
|
||||
#endif
|
||||
if (!on)
|
||||
if (!on) {
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
// Screen powering off (idle timeout, shutdown, deep sleep) latches
|
||||
// the screen-lock. Next time the display wakes it shows the LOCKED
|
||||
// frame until a client authenticates with the passphrase.
|
||||
meshtastic_security::lockScreen();
|
||||
#endif
|
||||
// We handle off commands immediately, because they might be called because the CPU is shutting down
|
||||
handleSetOn(false, einkScreensaver);
|
||||
else
|
||||
} else
|
||||
enqueueCmd(ScreenCmd{.cmd = Cmd::SET_ON});
|
||||
}
|
||||
|
||||
@@ -919,7 +1033,17 @@ int32_t Screen::runOnce()
|
||||
#endif
|
||||
|
||||
#ifndef DISABLE_WELCOME_UNSET
|
||||
if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
bool suppressRegionOnboard = false;
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
// While lockdown is active and storage is still locked, config.lora.region
|
||||
// is a deliberate UNSET placeholder — the real region lives in encrypted
|
||||
// storage and is restored on unlock (see NodeDB's locked-boot path). Don't
|
||||
// pop the region picker over the lock screen: it would trap input, and the
|
||||
// operator can't set a region until they unlock anyway.
|
||||
suppressRegionOnboard = meshtastic_security::shouldRedactDisplay();
|
||||
#endif
|
||||
if (!suppressRegionOnboard && !NotificationRenderer::isOverlayBannerShowing() &&
|
||||
config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
#if defined(OLED_TINY)
|
||||
menuHandler::LoraRegionPicker();
|
||||
#else
|
||||
@@ -1635,6 +1759,15 @@ void Screen::handleStartFirmwareUpdateScreen()
|
||||
|
||||
void Screen::blink()
|
||||
{
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
// L4: defensive guard. blink() paints arbitrary geometry, not node
|
||||
// data, so it doesn't actually leak today. But it bypasses the normal
|
||||
// ui->update() path that the lockdown short-circuit gates, so any
|
||||
// future change that puts content into blink would silently leak past
|
||||
// redaction. Refuse to draw when the redaction latch is set.
|
||||
if (meshtastic_security::shouldRedactDisplay())
|
||||
return;
|
||||
#endif
|
||||
setFastFramerate();
|
||||
uint8_t count = 10;
|
||||
dispdev->setBrightness(254);
|
||||
|
||||
+15
-1
@@ -12,7 +12,21 @@
|
||||
#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2)
|
||||
namespace graphics
|
||||
{
|
||||
enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, hex_picker, text_input };
|
||||
enum notificationTypeEnum {
|
||||
none,
|
||||
text_banner,
|
||||
selection_picker,
|
||||
node_picker,
|
||||
number_picker,
|
||||
hex_picker,
|
||||
text_input,
|
||||
// BLE pairing PIN banner. Treated specially by the lockdown short-circuit
|
||||
// in Screen.cpp: the PIN is ephemeral (regenerated per pair attempt) and
|
||||
// not a real secret, so we allow ui->update() to composite it over the
|
||||
// LOCKED frame. Without this, a first-pair on a locked device cannot
|
||||
// complete because the PIN never renders.
|
||||
pairing_pin,
|
||||
};
|
||||
|
||||
struct BannerOverlayOptions {
|
||||
const char *message;
|
||||
|
||||
@@ -1533,8 +1533,7 @@ bool TFTDisplay::hasTouch(void)
|
||||
{
|
||||
#ifdef RAK14014
|
||||
return true;
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
|
||||
!defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
return tft->touch() != nullptr;
|
||||
#else
|
||||
return false;
|
||||
@@ -1553,8 +1552,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
|
||||
!defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
return tft->getTouch(x, y);
|
||||
#else
|
||||
return false;
|
||||
|
||||
@@ -180,6 +180,18 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
|
||||
config.lora.region = region;
|
||||
config.lora.channel_num = 0; // Reset to default channel
|
||||
|
||||
// Reconcile the preset with the explicitly chosen region: a preset locked to another
|
||||
// region would leave config.lora invalid until applyModemConfig() repairs it with
|
||||
// error/critical-error side effects — or, for the swappable EU trio, the clamp would
|
||||
// flip the region right back. The user picked the region, so the preset follows it.
|
||||
const RegionInfo *newRegion = getRegion(region);
|
||||
if (config.lora.use_preset && !newRegion->supportsPreset(config.lora.modem_preset)) {
|
||||
LOG_INFO("Preset %s not available in %s, using default %s",
|
||||
DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), newRegion->name,
|
||||
DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true));
|
||||
config.lora.modem_preset = newRegion->getDefaultPreset();
|
||||
}
|
||||
|
||||
if (isHam && adminModule) {
|
||||
meshtastic_HamParameters hamParams = meshtastic_HamParameters_init_zero;
|
||||
strncpy(hamParams.call_sign, "N0CALL", sizeof(hamParams.call_sign) - 1);
|
||||
@@ -199,7 +211,7 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
|
||||
config.lora.ignore_mqtt = true;
|
||||
}
|
||||
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
|
||||
changes |= SEGMENT_MODULECONFIG;
|
||||
}
|
||||
service->reloadConfig(changes);
|
||||
@@ -263,13 +275,17 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: without a reboot, reconfigure() applies the region directly.
|
||||
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
|
||||
// TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora
|
||||
// rather than a hardcoded check for LORA_24.
|
||||
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
|
||||
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
|
||||
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection");
|
||||
// Guard: without a reboot, reconfigure() applies the region directly, so reject
|
||||
// regions this node can't use up front: unrecognized codes, licensed-only regions,
|
||||
// and radio hardware mismatches (2.4 GHz vs sub-GHz) — the same checks the admin
|
||||
// set-config path applies, but side-effect-free: ignoring a menu selection should
|
||||
// not record a critical error or notify clients. getRadio() used to catch hardware
|
||||
// mismatches post-reboot only.
|
||||
auto candidateLora = config.lora;
|
||||
candidateLora.region = selectedRegion;
|
||||
char regionErr[160];
|
||||
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) {
|
||||
LOG_WARN("Ignoring region selection: %s", regionErr);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,12 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
|
||||
break;
|
||||
case notificationTypeEnum::text_banner:
|
||||
case notificationTypeEnum::selection_picker:
|
||||
case notificationTypeEnum::pairing_pin:
|
||||
// pairing_pin is rendered the same as text_banner — it's just a
|
||||
// text banner. The split type exists only so the lockdown UI
|
||||
// short-circuit in Screen.cpp can recognise the BLE pair-PIN
|
||||
// banner as the one safe banner to composite over the LOCKED
|
||||
// frame.
|
||||
drawAlertBannerOverlay(display, state);
|
||||
break;
|
||||
case notificationTypeEnum::node_picker:
|
||||
|
||||
@@ -129,6 +129,7 @@ enum MenuAction {
|
||||
// Administration
|
||||
RESET_NODEDB_ALL,
|
||||
RESET_NODEDB_KEEP_FAVORITES,
|
||||
WIPE_MESSAGES_ALL,
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "GPS.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "RTC.h"
|
||||
#include "Router.h"
|
||||
#include "airtime.h"
|
||||
@@ -287,7 +288,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
|
||||
}
|
||||
|
||||
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
|
||||
changes |= SEGMENT_MODULECONFIG;
|
||||
}
|
||||
// Notify UI that changes are being applied
|
||||
@@ -1013,6 +1014,13 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
break;
|
||||
|
||||
case WIPE_MESSAGES_ALL:
|
||||
LOG_INFO("Wiping all messages from menu");
|
||||
messageStore.clearAllMessages();
|
||||
inkhud->persistence->loadLatestMessage();
|
||||
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true);
|
||||
break;
|
||||
|
||||
default:
|
||||
LOG_WARN("Action not implemented");
|
||||
}
|
||||
@@ -1130,6 +1138,7 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
// Administration Section
|
||||
items.push_back(MenuItem::Header("Administration"));
|
||||
items.push_back(MenuItem("Reset NodeDB", MenuPage::NODE_CONFIG_ADMIN_RESET));
|
||||
items.push_back(MenuItem("Wipe Messages", MenuPage::NODE_CONFIG_ADMIN_MESSAGES));
|
||||
|
||||
// Exit
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
@@ -1534,6 +1543,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
case NODE_CONFIG_ADMIN_MESSAGES:
|
||||
previousPage = MenuPage::NODE_CONFIG;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
items.push_back(MenuItem("Wipe All Messages", MenuAction::WIPE_MESSAGES_ALL, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
// Exit
|
||||
case EXIT:
|
||||
sendToBackground(); // Menu applet dismissed, allow normal behavior to resume
|
||||
|
||||
@@ -36,6 +36,7 @@ enum MenuPage : uint8_t {
|
||||
NODE_CONFIG_BLUETOOTH,
|
||||
NODE_CONFIG_POSITION,
|
||||
NODE_CONFIG_ADMIN_RESET,
|
||||
NODE_CONFIG_ADMIN_MESSAGES,
|
||||
TIMEZONE,
|
||||
APPLETS,
|
||||
AUTOSHOW,
|
||||
|
||||
@@ -22,6 +22,8 @@ void InkHUD::Persistence::loadSettings()
|
||||
// are immediately available to applets (DMApplet, AllMessageApplet, NotificationApplet).
|
||||
void InkHUD::Persistence::loadLatestMessage()
|
||||
{
|
||||
latestMessage = LatestMessage();
|
||||
|
||||
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
|
||||
for (const StoredMessage &m : messageStore.getLiveMessages()) {
|
||||
if (m.type == MessageType::BROADCAST) {
|
||||
@@ -75,4 +77,4 @@ void InkHUD::Persistence::printSettings(Settings *settings)
|
||||
}
|
||||
*/
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#include "configuration.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
#include "security/LockdownDisplay.h"
|
||||
#endif
|
||||
|
||||
#if ARCH_PORTDUINO
|
||||
#include "input/LinuxInputImpl.h"
|
||||
@@ -122,6 +125,22 @@ int InputBroker::handleInputEvent(const InputEvent *event)
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
// Lockdown: when the display is redacted (storage locked, or screen-lock
|
||||
// latch set after idle) the screen content is hidden, but local input
|
||||
// would otherwise still flow into UI handlers — letting an operator
|
||||
// drive menus, fire canned messages, change settings etc. blind. Eat
|
||||
// the event here so input is no-op until the redaction clears.
|
||||
// The latch is cleared only by unlockScreen() on a successful
|
||||
// passphrase auth (see PhoneAPI::handleLockdownAuthInline) — local
|
||||
// input does not clear it, even if storage happens to be unlocked.
|
||||
// PowerFSM was already triggered above, so the backlight still wakes
|
||||
// to show the LOCKED frame — the input just doesn't act on anything.
|
||||
if (meshtastic_security::shouldRedactDisplay()) {
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
this->notifyObservers(event);
|
||||
return 0;
|
||||
}
|
||||
|
||||
+144
-2
@@ -68,6 +68,19 @@ void nrf54l15Loop();
|
||||
NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr;
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_ENABLE_APPROTECT
|
||||
#include "security/APProtect.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
#include "mesh/PhoneAPI.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
#include "security/LockdownDisplay.h"
|
||||
#endif
|
||||
|
||||
#if HAS_WIFI || defined(USE_WS5500) || defined(USE_CH390D)
|
||||
#include "mesh/api/WiFiServerAPI.h"
|
||||
#include "mesh/wifi/WiFiAPClient.h"
|
||||
@@ -379,6 +392,14 @@ void setup()
|
||||
consoleInit(); // Set serial baud rate and init our mesh console
|
||||
#endif
|
||||
|
||||
// M23 (audit): APPROTECT engagement moved below fsInit() so we can gate
|
||||
// on EncryptedStorage::isProvisioned(). Engaging on an unprovisioned dev
|
||||
// board permanently locks SWD before the operator has even set a
|
||||
// passphrase — a misconfigured CI build flashed to a developer device
|
||||
// would brick its debug port on first boot. Now we only engage when the
|
||||
// device has a DEK file on flash, i.e. the operator has explicitly
|
||||
// committed to lockdown via passphrase provisioning.
|
||||
|
||||
#ifdef UNPHONE
|
||||
unphone.printStore();
|
||||
#endif
|
||||
@@ -409,7 +430,12 @@ void setup()
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(DEBUG_MUTE) && defined(DEBUG_PORT)
|
||||
// The DEBUG_MUTE "we are muted, FYI" banner spills APP_VERSION / APP_ENV /
|
||||
// APP_REPO out the USB CDC even with logging otherwise suppressed — a free
|
||||
// firmware-fingerprinting primitive for an attacker holding the cable.
|
||||
// Under MESHTASTIC_LOCKDOWN we want the device to look uniformly silent
|
||||
// until the operator authenticates, so skip the banner entirely there.
|
||||
#if defined(DEBUG_MUTE) && defined(DEBUG_PORT) && !defined(MESHTASTIC_LOCKDOWN)
|
||||
DEBUG_PORT.printf("\r\n\r\n//\\ E S H T /\\ S T / C\r\n");
|
||||
DEBUG_PORT.printf("Version %s for %s from %s\r\n", optstr(APP_VERSION), optstr(APP_ENV), optstr(APP_REPO));
|
||||
DEBUG_PORT.printf("Debug mute is enabled, there will be no serial output.\r\n");
|
||||
@@ -481,6 +507,38 @@ void setup()
|
||||
|
||||
fsInit();
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
EncryptedStorage::initLocked();
|
||||
if (!EncryptedStorage::isUnlocked()) {
|
||||
if (!EncryptedStorage::isProvisioned()) {
|
||||
LOG_WARN("Lockdown: Device not provisioned — connect and set a passphrase to unlock storage");
|
||||
} else {
|
||||
LOG_WARN("Lockdown: Device locked — connect and provide passphrase to unlock storage");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(MESHTASTIC_ENABLE_APPROTECT) && defined(MESHTASTIC_ENCRYPTED_STORAGE)
|
||||
// M23 (audit): only engage the irreversible UICR APPROTECT lockout once
|
||||
// the device has been provisioned with a passphrase. A misconfigured
|
||||
// CI build of a lockdown variant flashed to a developer board would
|
||||
// otherwise burn SWD on first boot before the operator has even set a
|
||||
// passphrase, taking the board out of the dev/recovery workflow with
|
||||
// no real security benefit (there's no DEK to protect yet). Once a
|
||||
// DEK file exists, the operator has committed to lockdown — engaging
|
||||
// APPROTECT then is the protection they asked for.
|
||||
if (EncryptedStorage::isProvisioned()) {
|
||||
enableAPProtect();
|
||||
} else {
|
||||
LOG_INFO("APPROTECT deferred: device not yet provisioned");
|
||||
}
|
||||
#elif defined(MESHTASTIC_ENABLE_APPROTECT)
|
||||
// Lockdown without encrypted storage shouldn't be reachable per
|
||||
// configuration.h, but if it ever is, fall back to the unconditional
|
||||
// engagement.
|
||||
enableAPProtect();
|
||||
#endif
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_I2C
|
||||
#if defined(I2C_SDA1) && defined(ARCH_RP2040)
|
||||
Wire1.setSDA(I2C_SDA1);
|
||||
@@ -1077,6 +1135,11 @@ uint32_t rebootAtMsec; // If not zero we will reboot at this time (used to r
|
||||
uint32_t shutdownAtMsec; // If not zero we will shutdown at this time (used to shutdown from python or mobile client)
|
||||
bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for OTA handoff)
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
volatile bool lockdownReloadPending; // see main.h — deferred NodeDB reload after lockdown unlock
|
||||
volatile bool lockdownDisablePending; // see main.h — deferred decrypt-revert after lockdown disable
|
||||
#endif
|
||||
|
||||
// If a thread does something that might need for it to be rescheduled ASAP it can set this flag
|
||||
// This will suppress the current delay and instead try to run ASAP.
|
||||
bool runASAP;
|
||||
@@ -1128,7 +1191,7 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
|
||||
// No bluetooth on these targets (yet):
|
||||
// Pico W / 2W may get it at some point
|
||||
// Portduino and ESP32-C6 are excluded because we don't have a working bluetooth stacks integrated yet.
|
||||
#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL) || defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_BLUETOOTH_CONFIG;
|
||||
#endif
|
||||
|
||||
@@ -1161,6 +1224,85 @@ void loop()
|
||||
{
|
||||
runASAP = false;
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
if (lockdownDisablePending) {
|
||||
lockdownDisablePending = false;
|
||||
LOG_INFO("Lockdown: disabling — reverting encrypted storage to plaintext");
|
||||
if (nodeDB->disableLockdownToPlaintext()) {
|
||||
LOG_INFO("Lockdown: disabled, rebooting into normal mode");
|
||||
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
} else {
|
||||
// Revert failed mid-way (a file couldn't be decrypted/rewritten).
|
||||
// The DEK file is still present (it's deleted last), so the device
|
||||
// stays in lockdown and the operator can retry disable. Surface
|
||||
// the failure rather than leaving the client hanging.
|
||||
LOG_ERROR("Lockdown: disable revert failed — device remains in lockdown");
|
||||
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "disable_failed", 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (lockdownReloadPending) {
|
||||
lockdownReloadPending = false;
|
||||
LOG_INFO("Lockdown: reloading config from disk after unlock");
|
||||
bool reloadOk = nodeDB->reloadFromDisk();
|
||||
if (!reloadOk) {
|
||||
// Storage decrypt/decode failed during reload. Treat as
|
||||
// unrecoverable for this boot: lock storage, revoke any
|
||||
// auth that managed to slip through (defense in depth — the
|
||||
// cold-unlock path doesn't authorize until completion, but
|
||||
// a concurrent re-verify-path call from another connection
|
||||
// might have), and notify clients. Storage will be locked
|
||||
// on next boot anyway; deferring to the user-visible
|
||||
// notification path is sufficient for now.
|
||||
LOG_ERROR("Lockdown: reload failed — locking and notifying clients");
|
||||
EncryptedStorage::lockNow();
|
||||
PhoneAPI::revokeAllAuth();
|
||||
}
|
||||
PhoneAPI::completePendingUnlocks(reloadOk);
|
||||
}
|
||||
|
||||
// Periodic session-expiry check. Cheap — millis() comparison. Don't
|
||||
// hammer it every loop tick; once a second is plenty.
|
||||
static uint32_t lastSessionCheckMs = 0;
|
||||
if (millis() - lastSessionCheckMs > 1000) {
|
||||
lastSessionCheckMs = millis();
|
||||
if (rebootAtMsec == 0 && EncryptedStorage::isUnlocked() && EncryptedStorage::isSessionExpired()) {
|
||||
// The session expired. Two paths:
|
||||
// 1. Budget remains (bootsRemaining > 0): decrement the
|
||||
// on-flash boot count in place, revoke per-connection
|
||||
// auth, re-engage screen redaction, re-arm the uptime
|
||||
// timer — all WITHOUT rebooting. Storage stays unlocked
|
||||
// so the mesh keeps routing. Clients must re-authenticate
|
||||
// to see content again. The decrement is what enforces
|
||||
// the rollback ceiling — bootsRemaining ticks down
|
||||
// monotonically whether the device reboots or not.
|
||||
// 2. Budget exhausted (bootsRemaining == 0): no more
|
||||
// sessions to grant. Hard lock (token deleted, DEK
|
||||
// zeroed) and reboot. Operator must re-enter passphrase.
|
||||
if (EncryptedStorage::getBootsRemaining() == 0) {
|
||||
LOG_WARN("Lockdown: session limit reached and boot budget exhausted, locking and rebooting");
|
||||
EncryptedStorage::lockNow();
|
||||
PhoneAPI::revokeAllAuth();
|
||||
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "session_budget_exhausted", 0, 0, 0);
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
} else {
|
||||
uint8_t newBoots = EncryptedStorage::consumeSessionBoot();
|
||||
LOG_WARN("Lockdown: session expired, rolled to next budget slot (boots=%u remaining)", newBoots);
|
||||
PhoneAPI::revokeAllAuth();
|
||||
meshtastic_security::lockScreen();
|
||||
// Signal clients that they need to re-auth on this
|
||||
// connection. Storage is still unlocked (DEK in RAM,
|
||||
// mesh keeps routing) but per-connection auth is gone.
|
||||
// Reusing the LOCKED(needs_auth) post-config emission
|
||||
// pattern so existing clients don't need a new state.
|
||||
PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "needs_auth", newBoots,
|
||||
EncryptedStorage::getValidUntilEpoch(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
esp32Loop();
|
||||
#endif
|
||||
|
||||
+13
@@ -92,6 +92,19 @@ extern uint32_t rebootAtMsec;
|
||||
extern uint32_t shutdownAtMsec;
|
||||
extern bool suppressRebootBanner;
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
// Set by PhoneAPI::handleLockdownAuthInline after a successful unlock.
|
||||
// Serviced on the main loop thread because NodeDB::reloadFromDisk() is
|
||||
// too heavy for the BLE/serial transport callback stack.
|
||||
extern volatile bool lockdownReloadPending;
|
||||
|
||||
// Set by PhoneAPI::handleLockdownAuthInline on a disable request (after the
|
||||
// passphrase is verified). Serviced on the main loop thread: decrypt every
|
||||
// pref back to plaintext, remove the lockdown artifacts, reboot. Heavy file
|
||||
// IO, same reason as lockdownReloadPending.
|
||||
extern volatile bool lockdownDisablePending;
|
||||
#endif
|
||||
|
||||
extern uint32_t serialSinceMsec;
|
||||
|
||||
// If a thread does something that might need for it to be rescheduled ASAP it can set this flag
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
#include <malloc.h>
|
||||
#include <unistd.h> // sbrk
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
#if defined(ARCH_STM32)
|
||||
// Returns the uncommitted sbrk headroom: addressable space between the current heap
|
||||
// break and the stack pointer that has not yet been committed to the arena.
|
||||
static uint32_t sbrkHeadroom()
|
||||
|
||||
@@ -404,6 +404,42 @@ bool Channels::isDefaultChannel(ChannelIndex chIndex)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cryptoKeyIsPublic(const CryptoKey &key)
|
||||
{
|
||||
if (key.length == 0)
|
||||
return true; // encryption disabled
|
||||
// Match the defaultpsk family ignoring its last byte (getKey() bumps only that byte per 1-byte index).
|
||||
if (key.length == (int)sizeof(defaultpsk) && memcmp(key.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Channels::usesPublicKey(ChannelIndex chIndex)
|
||||
{
|
||||
const meshtastic_Channel &ch = getByIndex(chIndex);
|
||||
if (!ch.has_settings || ch.role == meshtastic_Channel_Role_DISABLED)
|
||||
return false;
|
||||
|
||||
const auto &psk = ch.settings.psk;
|
||||
if (psk.size == 0) {
|
||||
// Secondary channels inherit the primary key when unset; primary size==0 means encryption disabled.
|
||||
if (ch.role == meshtastic_Channel_Role_SECONDARY) {
|
||||
// Guard against malformed configs with no PRIMARY channel (primaryIndex could point back to us).
|
||||
if (primaryIndex == chIndex)
|
||||
return true; // fail closed: treat as public
|
||||
return usesPublicKey(primaryIndex);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (psk.size == 1) {
|
||||
// Short PSK aliases: 0 disables encryption; 1..255 are the public defaultpsk family.
|
||||
return true;
|
||||
}
|
||||
|
||||
return (psk.size == sizeof(defaultpsk) && memcmp(psk.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0);
|
||||
}
|
||||
|
||||
bool Channels::hasDefaultChannel()
|
||||
{
|
||||
// If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel
|
||||
|
||||
@@ -86,6 +86,9 @@ class Channels
|
||||
// Returns true if the channel has the default name and PSK
|
||||
bool isDefaultChannel(ChannelIndex chIndex);
|
||||
|
||||
// Returns true if this channel's effective key is publicly decryptable (open or well-known/default PSK).
|
||||
bool usesPublicKey(ChannelIndex chIndex);
|
||||
|
||||
// Returns true if we can be reached via a channel with the default settings given a region and modem preset
|
||||
bool hasDefaultChannel();
|
||||
|
||||
@@ -144,6 +147,9 @@ extern Channels channels;
|
||||
static const uint8_t defaultpsk[] = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59,
|
||||
0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01};
|
||||
|
||||
/// True if a getKey()-resolved key offers no privacy: length 0 (off) or the public defaultpsk family. Pure; for tests.
|
||||
bool cryptoKeyIsPublic(const CryptoKey &key);
|
||||
|
||||
static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36,
|
||||
0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74,
|
||||
0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1};
|
||||
+108
-3
@@ -12,10 +12,18 @@
|
||||
#include <Curve25519.h>
|
||||
#include <RNG.h>
|
||||
#include <SHA256.h>
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
|
||||
#if !defined(ARCH_STM32WL)
|
||||
#define CryptRNG RNG
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
#include "XEdDSA.h"
|
||||
#include <Ed25519.h>
|
||||
|
||||
#ifndef NUM_LIMBS_256BIT
|
||||
#define NUM_LIMBS_BITS(n) (((n) + sizeof(limb_t) * 8 - 1) / (8 * sizeof(limb_t)))
|
||||
#define NUM_LIMBS_256BIT NUM_LIMBS_BITS(256)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
|
||||
|
||||
/**
|
||||
* Create a public/private key pair with Curve25519.
|
||||
@@ -46,6 +54,9 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey)
|
||||
Curve25519::dh1(public_key, private_key);
|
||||
memcpy(pubKey, public_key, sizeof(public_key));
|
||||
memcpy(privKey, private_key, sizeof(private_key));
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +76,9 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
|
||||
}
|
||||
memcpy(private_key, privKey, sizeof(private_key));
|
||||
memcpy(public_key, pubKey, sizeof(public_key));
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key);
|
||||
#endif
|
||||
} else {
|
||||
LOG_WARN("X25519 key generation failed due to blank private key");
|
||||
return false;
|
||||
@@ -72,6 +86,97 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
/**
|
||||
* Build a signing buffer that covers packet metadata and payload:
|
||||
* [fromNode(4) | packetId(4) | portnum(4) | payload(N)]
|
||||
* This prevents replay, reattribution, and portnum redirection attacks.
|
||||
*/
|
||||
static size_t buildSigningBuffer(uint8_t *buf, size_t bufSize, uint32_t fromNode, uint32_t packetId, uint32_t portnum,
|
||||
const uint8_t *payload, size_t payloadLen)
|
||||
{
|
||||
const size_t headerLen = sizeof(uint32_t) * 3;
|
||||
size_t totalLen = headerLen + payloadLen;
|
||||
if (totalLen > bufSize)
|
||||
return 0;
|
||||
// May need endian conversion for oddball platforms.
|
||||
memcpy(buf, &fromNode, sizeof(uint32_t));
|
||||
memcpy(buf + sizeof(uint32_t), &packetId, sizeof(uint32_t));
|
||||
memcpy(buf + sizeof(uint32_t) * 2, &portnum, sizeof(uint32_t));
|
||||
memcpy(buf + headerLen, payload, payloadLen);
|
||||
return totalLen;
|
||||
}
|
||||
|
||||
bool CryptoEngine::xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen,
|
||||
uint8_t *signature)
|
||||
{
|
||||
if (memfll(xeddsa_private_key, 0, sizeof(xeddsa_private_key)))
|
||||
return false;
|
||||
uint8_t sigBuf[MAX_BLOCKSIZE];
|
||||
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
|
||||
if (sigLen == 0)
|
||||
return false;
|
||||
// the XEdDSA::sign function requires at least the first 32 bytes of signature to be pre-filled with randomness
|
||||
HardwareRNG::fill(signature, 32);
|
||||
XEdDSA::sign(signature, xeddsa_private_key, xeddsa_public_key, sigBuf, sigLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CryptoEngine::xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum,
|
||||
const uint8_t *payload, size_t payloadLen, const uint8_t *signature)
|
||||
{
|
||||
// Use cached Ed25519 key if the Curve25519 key matches, avoiding expensive field inversion
|
||||
if (memcmp(pubKey, cached_curve_pubkey, 32) != 0) {
|
||||
curve_to_ed_pub(pubKey, cached_ed_pubkey);
|
||||
memcpy(cached_curve_pubkey, pubKey, 32);
|
||||
}
|
||||
uint8_t sigBuf[MAX_BLOCKSIZE];
|
||||
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
|
||||
if (sigLen == 0)
|
||||
return false;
|
||||
return XEdDSA::verify(signature, cached_ed_pubkey, sigBuf, sigLen);
|
||||
}
|
||||
|
||||
void CryptoEngine::curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey)
|
||||
{
|
||||
|
||||
// Apply the birational map defined in RFC 7748, section 4.1 "Curve25519" to calculate an Ed25519 public
|
||||
// key from a Curve25519 public key. Because the serialization format of Curve25519 public keys only
|
||||
// contains the u coordinate, the x coordinate of the corresponding Ed25519 public key can't be uniquely
|
||||
// calculated as defined by the birational map. The x coordinate is represented in the serialization
|
||||
// format of Ed25519 public keys only in a single sign bit. XEdDSA always normalizes the Ed25519 public
|
||||
// key to a sign bit of zero (the signer negates its key pair when needed), so this function clears the
|
||||
// sign bit unconditionally below instead of taking it as an input.
|
||||
fe u, y;
|
||||
fe one;
|
||||
fe u_minus_one, u_plus_one, u_plus_one_inv;
|
||||
|
||||
// Parse the Curve25519 public key input as a field element containing the u coordinate. RFC 7748,
|
||||
// section 5 "The X25519 and X448 Functions", mandates that the most significant bit of the Curve25519
|
||||
// public key has to be zeroized. This is handled by fe_frombytes internally.
|
||||
fe_frombytes(u, curve_pubkey);
|
||||
|
||||
// Calculate the parameters (u - 1) and (u + 1)
|
||||
fe_1(one);
|
||||
fe_sub(u_minus_one, u, one);
|
||||
fe_add(u_plus_one, u, one);
|
||||
|
||||
// Invert u + 1
|
||||
fe_invert(u_plus_one_inv, u_plus_one);
|
||||
|
||||
// Calculate y = (u - 1) * inv(u + 1) (mod p)
|
||||
fe_mul(y, u_minus_one, u_plus_one_inv);
|
||||
|
||||
// Serialize the field element containing the y coordinate to the Ed25519 public key output
|
||||
fe_tobytes(ed_pubkey, y);
|
||||
|
||||
// Set the sign bit to zero
|
||||
ed_pubkey[31] &= 0x7f;
|
||||
|
||||
// need to convert the pubkey y = ( u - 1) * inv( u + 1) (mod p).
|
||||
}
|
||||
#endif
|
||||
|
||||
bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user)
|
||||
{
|
||||
if (user.is_licensed) {
|
||||
|
||||
+15
-1
@@ -23,6 +23,7 @@ struct CryptoKey {
|
||||
|
||||
#define MAX_BLOCKSIZE 256
|
||||
#define TEST_CURVE25519_FIELD_OPS // Exposes Curve25519::isWeakPoint() for testing keys
|
||||
#define XEDDSA_SIGNATURE_SIZE 64
|
||||
|
||||
class CryptoEngine
|
||||
{
|
||||
@@ -37,7 +38,12 @@ class CryptoEngine
|
||||
virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey);
|
||||
virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey);
|
||||
virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user);
|
||||
|
||||
#endif
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
bool xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen,
|
||||
uint8_t *signature);
|
||||
bool xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload,
|
||||
size_t payloadLen, const uint8_t *signature);
|
||||
#endif
|
||||
void setDHPrivateKey(uint8_t *_private_key);
|
||||
// The remotePublic key parameter takes the public_key bytes container from
|
||||
@@ -85,6 +91,14 @@ class CryptoEngine
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
uint8_t shared_key[32] = {0};
|
||||
uint8_t private_key[32] = {0};
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
uint8_t xeddsa_public_key[32] = {0};
|
||||
uint8_t xeddsa_private_key[32] = {0};
|
||||
void curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey);
|
||||
// Single-entry cache for curve_to_ed_pub conversion (avoids expensive field inversion per packet)
|
||||
uint8_t cached_curve_pubkey[32] = {0};
|
||||
uint8_t cached_ed_pubkey[32] = {0};
|
||||
#endif
|
||||
#endif
|
||||
/**
|
||||
* Init our 128 bit nonce for a new packet
|
||||
|
||||
@@ -65,6 +65,14 @@ struct RegionInfo {
|
||||
// Preset accessors (delegate through profile)
|
||||
meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return defaultPreset; }
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; }
|
||||
bool supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) const
|
||||
{
|
||||
for (size_t i = 0; profile->presets[i] != MODEM_PRESET_END; i++) {
|
||||
if (profile->presets[i] == preset)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
size_t getNumPresets() const
|
||||
{
|
||||
size_t n = 0;
|
||||
|
||||
@@ -141,6 +141,12 @@ class MeshService
|
||||
/// Release the next ClientNotification packet to pool.
|
||||
void releaseClientNotificationToPool(meshtastic_ClientNotification *p) { clientNotificationPool.release(p); }
|
||||
|
||||
/// Bump fromNum to signal connected clients to poll for new FromRadio data.
|
||||
/// Used by code paths (e.g. lockdown status queueing) that surface a new
|
||||
/// FromRadio variant without going through one of the existing pool-backed
|
||||
/// senders.
|
||||
void nudgeFromNum() { fromNum++; }
|
||||
|
||||
/**
|
||||
* Given a ToRadio buffer parse it and properly handle it (setup radio, owner or send packet into the mesh)
|
||||
* Called by PhoneAPI.handleToRadio. Note: p is a scratch buffer, this function is allowed to write to it but it can not keep
|
||||
|
||||
+367
-35
@@ -36,6 +36,11 @@
|
||||
#include <power/PowerHAL.h>
|
||||
#include <vector>
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#include "security/SecureZero.h"
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
#if HAS_WIFI
|
||||
#include "mesh/wifi/WiFiAPClient.h"
|
||||
@@ -365,6 +370,15 @@ extern void getMacAddr(uint8_t *dmac);
|
||||
* we use !macaddr (no colons).
|
||||
*/
|
||||
meshtastic_User &owner = devicestate.owner;
|
||||
|
||||
// The slim NodeInfoLite header defines the local long_name cap; the wire-facing
|
||||
// meshtastic_User stays wider so names from senders built against the older
|
||||
// 39-byte limit still decode (nanopb halts on string overflow).
|
||||
static_assert(MAX_LONG_NAME_BYTES + 1 == sizeof(meshtastic_NodeInfoLite::long_name),
|
||||
"MAX_LONG_NAME_BYTES must match the NodeInfoLite storage width");
|
||||
static_assert(sizeof(meshtastic_User::long_name) > MAX_LONG_NAME_BYTES,
|
||||
"wire User.long_name must be wider than the local cap so clampLongName stays in bounds");
|
||||
|
||||
meshtastic_Position localPosition = meshtastic_Position_init_default;
|
||||
meshtastic_CriticalErrorCode error_code =
|
||||
meshtastic_CriticalErrorCode_NONE; // For the error code, only show values from this boot (discard value from flash)
|
||||
@@ -434,8 +448,6 @@ NodeDB::NodeDB()
|
||||
|
||||
// likewise - we always want the app requirements to come from the running appload
|
||||
myNodeInfo.min_app_version = 30200; // format is Mmmss (where M is 1+the numeric major number. i.e. 30200 means 2.2.00
|
||||
// Note! We do this after loading saved settings, so that if somehow an invalid nodenum was stored in preferences we won't
|
||||
// keep using that nodenum forever. Crummy guess at our nodenum (but we will check against the nodedb to avoid conflicts)
|
||||
pickNewNodeNum();
|
||||
|
||||
// Set our board type so we can share it with others
|
||||
@@ -455,31 +467,18 @@ NodeDB::NodeDB()
|
||||
}
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
|
||||
if (!owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
bool keygenSuccess = false;
|
||||
keyIsLowEntropy = checkLowEntropyPublicKey(config.security.public_key);
|
||||
if (config.security.private_key.size == 32 && !keyIsLowEntropy) {
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
} else {
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
if (keygenSuccess) {
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
}
|
||||
}
|
||||
// Generate crypto keys if needed using consolidated function
|
||||
// Set my node num uint32 value to bytes from the public key (if we have one)
|
||||
// Generate identity and crypto keys if needed; this will create a new identity if one does not exist
|
||||
generateCryptoKeyPair(nullptr);
|
||||
#elif !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// Calculate Curve25519 public and private keys
|
||||
if (config.security.private_key.size == 32 && config.security.public_key.size == 32) {
|
||||
owner.public_key.size = config.security.public_key.size;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, config.security.public_key.size);
|
||||
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
||||
// Set my node num uint32 value to bytes from the new public key
|
||||
myNodeInfo.my_node_num = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size);
|
||||
}
|
||||
#endif
|
||||
// Include our owner in the node db under our nodenum
|
||||
@@ -899,6 +898,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||
config.security.private_key.size = 0;
|
||||
}
|
||||
config.security.public_key.size = 0;
|
||||
|
||||
#ifdef PIN_GPS_EN
|
||||
config.position.gps_en_gpio = PIN_GPS_EN;
|
||||
#endif
|
||||
@@ -1514,6 +1514,7 @@ void NodeDB::installDefaultDeviceState()
|
||||
#else
|
||||
snprintf(owner.long_name, sizeof(owner.long_name), "Meshtastic %04x", getNodeNum() & 0x0ffff);
|
||||
#endif
|
||||
clampLongName(owner.long_name); // vendor userprefs may exceed the local cap
|
||||
#ifdef USERPREFS_CONFIG_OWNER_SHORT_NAME
|
||||
snprintf(owner.short_name, sizeof(owner.short_name), (const char *)USERPREFS_CONFIG_OWNER_SHORT_NAME);
|
||||
#else
|
||||
@@ -1542,8 +1543,6 @@ void NodeDB::pickNewNodeNum()
|
||||
|
||||
// Identity check via public key (or "empty slot?" when no keys yet);
|
||||
// macaddr no longer lives on the slim header.
|
||||
// This check does not work when is_licensed=true since we don't store a public key.
|
||||
// Revisit with XEdDSA signing.
|
||||
auto isOurOwnEntry = [&](const meshtastic_NodeInfoLite *n) -> bool {
|
||||
if (!n)
|
||||
return false;
|
||||
@@ -1552,16 +1551,13 @@ void NodeDB::pickNewNodeNum()
|
||||
return !nodeInfoLiteHasUser(n);
|
||||
};
|
||||
|
||||
// Short circuit the check for licensed devices since they do not have public keys to compare against the nodeDB.
|
||||
if (!owner.is_licensed) {
|
||||
meshtastic_NodeInfoLite *found;
|
||||
while (((found = getMeshNode(nodeNum)) && !isOurOwnEntry(found)) ||
|
||||
(nodeNum == NODENUM_BROADCAST || nodeNum < NUM_RESERVED)) {
|
||||
NodeNum candidate = random(NUM_RESERVED, LONG_MAX); // try a new random choice
|
||||
if (found)
|
||||
LOG_WARN("NOTE! Our desired nodenum 0x%x is invalid or in use, picking 0x%x", nodeNum, candidate);
|
||||
nodeNum = candidate;
|
||||
}
|
||||
meshtastic_NodeInfoLite *found;
|
||||
while (((found = getMeshNode(nodeNum)) && !isOurOwnEntry(found)) ||
|
||||
(nodeNum == NODENUM_BROADCAST || nodeNum < NUM_RESERVED)) {
|
||||
NodeNum candidate = random(NUM_RESERVED, LONG_MAX); // try a new random choice
|
||||
if (found)
|
||||
LOG_WARN("NOTE! Our desired nodenum 0x%x is invalid or in use, picking 0x%x", nodeNum, candidate);
|
||||
nodeNum = candidate;
|
||||
}
|
||||
LOG_DEBUG("Use nodenum 0x%x ", nodeNum);
|
||||
|
||||
@@ -1573,6 +1569,41 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t
|
||||
void *dest_struct)
|
||||
{
|
||||
LoadFileResult state = LoadFileResult::OTHER_FAILURE;
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// check if the file is encrypted and decrypt before protobuf decode
|
||||
if (EncryptedStorage::isEncrypted(filename)) {
|
||||
// ZeroizingArrayPtr wipes the decrypted plaintext (which contains config
|
||||
// secrets — channel PSKs, security private_key, etc.) before delete[],
|
||||
// so it isn't recoverable from the heap after this function returns.
|
||||
auto decBuf = meshtastic_security::make_zeroizing_array(protoSize);
|
||||
if (!decBuf) {
|
||||
LOG_ERROR("OOM decrypting %s", filename);
|
||||
return LoadFileResult::OTHER_FAILURE;
|
||||
}
|
||||
size_t decLen = 0;
|
||||
if (EncryptedStorage::readAndDecrypt(filename, decBuf.get(), protoSize, decLen)) {
|
||||
LOG_INFO("Load encrypted %s", filename);
|
||||
pb_istream_t stream = pb_istream_from_buffer(decBuf.get(), decLen);
|
||||
if (fields != &meshtastic_NodeDatabase_msg)
|
||||
memset(dest_struct, 0, objSize);
|
||||
if (!pb_decode(&stream, fields, dest_struct)) {
|
||||
LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream));
|
||||
state = LoadFileResult::DECODE_FAILED;
|
||||
storageCorruptThisLoad = true;
|
||||
} else {
|
||||
LOG_INFO("Loaded encrypted %s successfully", filename);
|
||||
state = LoadFileResult::LOAD_SUCCESS;
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR("Decrypt failed for %s, treating as corrupt", filename);
|
||||
state = LoadFileResult::DECODE_FAILED;
|
||||
storageCorruptThisLoad = true;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef FSCom
|
||||
concurrency::LockGuard g(spiLock);
|
||||
|
||||
@@ -1607,6 +1638,13 @@ void NodeDB::loadFromDisk()
|
||||
// Mark the current device state as completely unusable, so that if we fail reading the entire file from
|
||||
// disk we will still factoryReset to restore things.
|
||||
devicestate.version = 0;
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// Reset the per-load decrypt-failure tracker. Set by loadProto on any
|
||||
// encrypted file that fails to decrypt or proto-decode; consumed by
|
||||
// reloadFromDisk to surface storage corruption to the operator instead
|
||||
// of silently falling back to defaults.
|
||||
storageCorruptThisLoad = false;
|
||||
#endif
|
||||
|
||||
meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;
|
||||
|
||||
@@ -1650,6 +1688,39 @@ void NodeDB::loadFromDisk()
|
||||
}
|
||||
|
||||
#endif
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// Only take the locked-boot defaults path when lockdown is ACTIVE (the
|
||||
// device is provisioned) AND storage is still locked. A lockdown-capable
|
||||
// build that has never been provisioned — or that was disabled — falls
|
||||
// through to the normal plaintext load below and behaves like stock.
|
||||
if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
|
||||
// Encrypted storage is locked. Install defaults and wait for the
|
||||
// passphrase over BLE/serial; PhoneAPI::handleLockdownAuthInline
|
||||
// calls reloadFromDisk() once the storage is unlocked.
|
||||
LOG_WARN("NodeDB: Encrypted storage locked, using default config until unlocked");
|
||||
installDefaultNodeDatabase();
|
||||
installDefaultDeviceState();
|
||||
installDefaultConfig();
|
||||
installDefaultModuleConfig();
|
||||
installDefaultChannels();
|
||||
|
||||
// Hold the radio silent until the operator unlocks. installDefaultConfig
|
||||
// would otherwise honour USERPREFS_CONFIG_LORA_REGION (the common shape
|
||||
// for managed deployments) and the LongFast default channel synthesised
|
||||
// by installDefaultChannels, so the device would beacon nodeinfo /
|
||||
// telemetry on the public default PSK before any unlock — and process
|
||||
// incoming default-channel packets the same way. Forcing region=UNSET
|
||||
// gates both TX and RX in RadioLibInterface (see the region==UNSET
|
||||
// checks in startSend and readData); tx_enabled=false is belt-and-
|
||||
// suspenders for any code path that does not consult region directly.
|
||||
// reloadFromDisk() restores the persisted lora config when the
|
||||
// operator unlocks.
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||
config.lora.tx_enabled = false;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Arm the direct-into-map decode so satellite entries skip the temp vectors.
|
||||
{
|
||||
concurrency::LockGuard guard(&satelliteMutex);
|
||||
@@ -1732,8 +1803,9 @@ void NodeDB::loadFromDisk()
|
||||
if (nodeInfoLiteHasUser(us)) {
|
||||
LOG_WARN("Restoring owner fields (long_name/short_name/is_licensed/is_unmessagable) from NodeDB for our node 0x%08x",
|
||||
us->num);
|
||||
memcpy(owner.long_name, us->long_name, sizeof(owner.long_name));
|
||||
owner.long_name[sizeof(owner.long_name) - 1] = '\0';
|
||||
// owner.long_name (40) is wider than the lite source (25); bound by the source
|
||||
memcpy(owner.long_name, us->long_name, sizeof(us->long_name));
|
||||
owner.long_name[sizeof(us->long_name) - 1] = '\0';
|
||||
memcpy(owner.short_name, us->short_name, sizeof(owner.short_name));
|
||||
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
|
||||
owner.is_licensed = nodeInfoLiteIsLicensed(us);
|
||||
@@ -1747,6 +1819,10 @@ void NodeDB::loadFromDisk()
|
||||
LOG_INFO("Loaded saved devicestate version %d", devicestate.version);
|
||||
}
|
||||
|
||||
// Devicestate saved by firmware that allowed 39-byte names gets clamped on
|
||||
// first load; from here on owner never carries more than the local cap.
|
||||
clampLongName(owner.long_name);
|
||||
|
||||
state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg,
|
||||
&config);
|
||||
if (state != LoadFileResult::LOAD_SUCCESS) {
|
||||
@@ -1881,6 +1957,40 @@ void NodeDB::loadFromDisk()
|
||||
LOG_INFO("Loaded UIConfig");
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// Ensure all config segments are persisted to encrypted storage.
|
||||
// installDefaultConfig/installDefaultModuleConfig only set in-memory structs
|
||||
// without saving to disk, so we force a save here to ensure encrypted files exist.
|
||||
//
|
||||
// Only when lockdown is ACTIVE. A capable-but-off device must leave its
|
||||
// files as plaintext — encryptAndWrite would fail anyway (no DEK), but
|
||||
// skipping the whole block avoids the wasted attempts and error logs.
|
||||
if (EncryptedStorage::isLockdownActive()) {
|
||||
const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
|
||||
nodeDatabaseFileName};
|
||||
const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE,
|
||||
SEGMENT_NODEDATABASE};
|
||||
int toSave = 0;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
if (!EncryptedStorage::isEncrypted(filesToCheck[i])) {
|
||||
toSave |= segments[i];
|
||||
}
|
||||
}
|
||||
if (toSave) {
|
||||
LOG_INFO("Lockdown: Saving unencrypted segments to encrypted storage (mask=0x%x)", toSave);
|
||||
saveToDisk(toSave);
|
||||
}
|
||||
|
||||
// Migrate any remaining plaintext proto files (from standard firmware upgrade)
|
||||
for (const char *fn : filesToCheck) {
|
||||
if (!EncryptedStorage::isEncrypted(fn)) {
|
||||
LOG_INFO("Migrating %s to encrypted storage", fn);
|
||||
EncryptedStorage::migrateFile(fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// 2.4.X - configuration migration to update new default intervals
|
||||
if (moduleConfig.version < 23) {
|
||||
LOG_DEBUG("ModuleConfig version %d is stale, upgrading to new default intervals", moduleConfig.version);
|
||||
@@ -1918,6 +2028,87 @@ void NodeDB::loadFromDisk()
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// Serializes reloadFromDisk against itself. Other readers of config /
|
||||
// channelFile / nodeDatabase don't take this lock today, so this only
|
||||
// prevents reload-vs-reload races (e.g. fast successive unlocks). It is
|
||||
// not a full data-race fix for those structs — that would require
|
||||
// thread-shared locking discipline across the whole codebase, beyond
|
||||
// the audit's M7 scope. The radio standby+reconfigure below keeps the
|
||||
// radio out of the window where SX12xx registers are mid-swap.
|
||||
static concurrency::Lock g_reloadFromDiskMutex;
|
||||
|
||||
/**
|
||||
* Re-run loadFromDisk() after encrypted storage is unlocked at runtime.
|
||||
* Holds the radio in standby across the file IO + proto decode so the
|
||||
* SX12xx is not mid-RX/TX when config.lora is overwritten, then calls
|
||||
* reconfigure() to push the now-real settings to the chip.
|
||||
*
|
||||
* Returns true iff every encrypted file decrypted and decoded cleanly.
|
||||
* On false the caller MUST treat storage as corrupt — see header.
|
||||
*/
|
||||
bool NodeDB::reloadFromDisk()
|
||||
{
|
||||
concurrency::LockGuard guard(&g_reloadFromDiskMutex);
|
||||
LOG_INFO("NodeDB: Reloading config from encrypted storage after unlock");
|
||||
|
||||
RadioInterface *rIface = router ? router->getRadioIface() : nullptr;
|
||||
|
||||
// Park the radio while config.lora / channelFile swap. Without this,
|
||||
// a concurrent send or receive can read half-old / half-new state
|
||||
// (channel keys, region, modem preset) and the SX12xx ends up in
|
||||
// an inconsistent register set that only a reboot recovers from.
|
||||
if (rIface)
|
||||
rIface->sleep();
|
||||
|
||||
loadFromDisk();
|
||||
|
||||
if (storageCorruptThisLoad) {
|
||||
LOG_ERROR("NodeDB: storage decrypt/decode failed during reload — surfacing as corrupt");
|
||||
// Leave the radio sleeping. Caller will lock storage and emit
|
||||
// a LOCKED(storage_corrupt) status; we must not reconfigure
|
||||
// the chip with the locked-default placeholder values still
|
||||
// sitting in config.lora.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Push the now-real config to the radio.
|
||||
if (rIface) {
|
||||
channels.onConfigChanged();
|
||||
rIface->reconfigure();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NodeDB::disableLockdownToPlaintext()
|
||||
{
|
||||
concurrency::LockGuard guard(&g_reloadFromDiskMutex);
|
||||
if (!EncryptedStorage::isUnlocked()) {
|
||||
LOG_ERROR("NodeDB: disable requested but storage not unlocked");
|
||||
return false;
|
||||
}
|
||||
LOG_INFO("NodeDB: reverting encrypted prefs to plaintext for lockdown disable");
|
||||
|
||||
// Decrypt each encrypted pref back to plaintext IN PLACE. Mirror of the
|
||||
// plaintext->encrypted migrate loop above. Order does not matter here;
|
||||
// EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK,
|
||||
// the commit point) only runs after every file is confirmed plaintext.
|
||||
const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
|
||||
nodeDatabaseFileName};
|
||||
for (const char *fn : filesToCheck) {
|
||||
if (!EncryptedStorage::migrateFileToPlaintext(fn)) {
|
||||
LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All files are plaintext now — remove the lockdown artifacts. Deleting
|
||||
// /prefs/.dek is the atomic commit: after it, isLockdownActive() is false.
|
||||
EncryptedStorage::removeLockdownArtifacts();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
/** Save a protobuf from a file, return true for success */
|
||||
bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct,
|
||||
bool fullAtomic)
|
||||
@@ -1930,6 +2121,38 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// Encrypt all files except uiconfig (no secrets) and the DEK file (self-encrypted).
|
||||
// Only when lockdown is ACTIVE (provisioned). A lockdown-capable but DISABLED
|
||||
// device has no DEK, so encryptAndWrite would fail and config would never
|
||||
// persist — it must save plaintext exactly like stock firmware. Once enabled,
|
||||
// the reloadFromDisk migrate pass re-saves these plaintext files encrypted.
|
||||
if (EncryptedStorage::isLockdownActive() && strcmp(filename, uiconfigFileName) != 0) {
|
||||
// ZeroizingArrayPtr wipes the unencrypted protobuf encoding (which contains
|
||||
// config secrets — channel PSKs, security private_key, etc.) before delete[],
|
||||
// so plaintext copies aren't left in heap memory after encryption completes.
|
||||
auto pbBuf = meshtastic_security::make_zeroizing_array(protoSize);
|
||||
if (!pbBuf) {
|
||||
LOG_ERROR("OOM encoding %s for encryption", filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
pb_ostream_t stream = pb_ostream_from_buffer(pbBuf.get(), protoSize);
|
||||
if (!pb_encode(&stream, fields, dest_struct)) {
|
||||
LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream));
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t encodedSize = stream.bytes_written;
|
||||
bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf.get(), encodedSize, fullAtomic);
|
||||
|
||||
if (!ok) {
|
||||
LOG_ERROR("EncryptedStorage: Failed to encrypt and write %s", filename);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool okay = false;
|
||||
#ifdef FSCom
|
||||
auto f = SafeFile(filename, fullAtomic);
|
||||
@@ -2105,6 +2328,22 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// When lockdown is ACTIVE but storage is still locked, encryptAndWrite()
|
||||
// returns false for every file. That would cause saveToDisk()'s nRF52 retry
|
||||
// path to call FSCom.format(), wiping all encrypted proto files from flash.
|
||||
// Return true here — "nothing to save, not an error."
|
||||
//
|
||||
// Gate on isLockdownActive(): a lockdown-capable but DISABLED device (never
|
||||
// provisioned) also has isUnlocked()==false, but it must persist plaintext
|
||||
// normally — skipping here would silently drop every config write (e.g. the
|
||||
// LoRa region) until the device is provisioned.
|
||||
if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
|
||||
LOG_WARN("NodeDB: saveToDisk skipped — encrypted storage locked");
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool success = true;
|
||||
#ifdef FSCom
|
||||
spiLock->lock();
|
||||
@@ -2805,6 +3044,99 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub
|
||||
}
|
||||
#endif
|
||||
|
||||
bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
||||
{
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
// Only generate keys for non-licensed users and if LoRa region is set
|
||||
if (owner.is_licensed || config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool keygenSuccess = false;
|
||||
// Record whether the stored key is a known compromised/low-entropy key so main.cpp can warn the
|
||||
// user. A detected low-entropy key is regenerated below, but the flag stays set so the
|
||||
// "Compromised keys were detected and regenerated" notification still fires.
|
||||
keyIsLowEntropy = checkLowEntropyPublicKey(config.security.public_key);
|
||||
|
||||
// If a specific private key was provided, use it
|
||||
if (privateKey != nullptr) {
|
||||
LOG_INFO("Using provided private key for PKI");
|
||||
memcpy(config.security.private_key.bytes, privateKey, 32);
|
||||
config.security.private_key.size = 32;
|
||||
config.security.public_key.size = 32;
|
||||
|
||||
// Generate public key from the provided private key
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
} else {
|
||||
LOG_ERROR("Failed to generate public key from provided private key");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Try to regenerate public key from existing private key if it's valid and not low entropy
|
||||
else if (config.security.private_key.size == 32 && !keyIsLowEntropy) {
|
||||
config.security.public_key.size = 32;
|
||||
LOG_DEBUG("Regenerate PKI public key from existing private key");
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
keygenSuccess = true;
|
||||
}
|
||||
} else {
|
||||
// Generate a new key pair
|
||||
LOG_INFO("Generate new PKI keys");
|
||||
config.security.public_key.size = 32;
|
||||
config.security.private_key.size = 32;
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
keygenSuccess = true;
|
||||
}
|
||||
|
||||
// Update sizes and copy to owner if successful
|
||||
if (keygenSuccess) {
|
||||
owner.public_key.size = 32;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
||||
|
||||
// Set the DH private key for crypto operations
|
||||
LOG_DEBUG("Set DH private key for crypto operations");
|
||||
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
||||
|
||||
// Conditionally create new identity based on parameter
|
||||
createNewIdentity();
|
||||
}
|
||||
return keygenSuccess;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool NodeDB::createNewIdentity()
|
||||
{
|
||||
uint32_t oldNodeNum = getNodeNum();
|
||||
uint32_t newNodeNum = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size);
|
||||
|
||||
// If the key hasn't changed, nothing to do
|
||||
if (newNodeNum == oldNodeNum)
|
||||
return false;
|
||||
|
||||
// Retire the old node entry
|
||||
meshtastic_NodeInfoLite *node = getMeshNode(oldNodeNum);
|
||||
if (node != NULL) {
|
||||
LOG_DEBUG("Old node num %u is now %u", oldNodeNum, newNodeNum);
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||
node->public_key.size = 0;
|
||||
memset(node->public_key.bytes, 0, sizeof(node->public_key.bytes));
|
||||
}
|
||||
|
||||
// Drop satellite-store entries (position/telemetry/environment/status) keyed by the retired
|
||||
// node number so stale data isn't left attached to the old identity.
|
||||
eraseNodeSatellites(oldNodeNum);
|
||||
|
||||
myNodeInfo.my_node_num = newNodeNum;
|
||||
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum());
|
||||
TypeConversions::CopyUserToNodeInfoLite(info, owner);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NodeDB::backupPreferences(meshtastic_AdminMessage_BackupLocation location)
|
||||
{
|
||||
bool success = false;
|
||||
|
||||
+45
-1
@@ -376,6 +376,12 @@ class NodeDB
|
||||
bool checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest);
|
||||
#endif
|
||||
|
||||
/// Consolidate crypto key generation logic used across multiple modules
|
||||
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
|
||||
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
|
||||
|
||||
bool createNewIdentity();
|
||||
|
||||
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
|
||||
bool restorePreferences(meshtastic_AdminMessage_BackupLocation location,
|
||||
int restoreWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
||||
@@ -388,6 +394,38 @@ class NodeDB
|
||||
newStatus.notifyObservers(&status);
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
/// Re-run loadFromDisk() after the encrypted storage is unlocked at runtime.
|
||||
/// Trigger: PhoneAPI::handleLockdownAuthInline sets lockdownReloadPending
|
||||
/// on a successful provisionPassphrase / unlockWithPassphrase; the main
|
||||
/// loop in main.cpp services the flag and calls this method on the main
|
||||
/// thread. The transport callback stack (BLE/USB) is too small for the
|
||||
/// file IO + MAX_NUM_NODES vector reserve + proto decode this triggers.
|
||||
///
|
||||
/// Returns true iff every encrypted file decrypted and decoded cleanly.
|
||||
/// On false the caller MUST treat the storage as corrupt: leave the
|
||||
/// connection unauthenticated, emit a LOCKED(storage_corrupt) status,
|
||||
/// and refuse to call setAdminAuthorized — otherwise a subsequent
|
||||
/// set_config would re-encrypt a wrong baseline (the locked-default
|
||||
/// values still resident in `config` / `channelFile` / `nodeDatabase`)
|
||||
/// and overwrite the operator's persisted state.
|
||||
bool reloadFromDisk();
|
||||
|
||||
/// Disable lockdown: decrypt every encrypted pref file back to plaintext,
|
||||
/// then remove the DEK / token / counter / backoff artifacts. Requires
|
||||
/// EncryptedStorage to be unlocked (DEK in RAM). Returns false if any
|
||||
/// file failed to revert — in which case the DEK is still present and the
|
||||
/// device remains in lockdown so the operator can retry. APPROTECT is not
|
||||
/// reversed. Called from the main loop via lockdownDisablePending.
|
||||
bool disableLockdownToPlaintext();
|
||||
|
||||
/// Set by loadProto when any encrypted file fails to decrypt or decode.
|
||||
/// Tracked across an entire loadFromDisk pass so reloadFromDisk can
|
||||
/// surface the condition without callers re-walking each loadProto
|
||||
/// result. Cleared at the top of every loadFromDisk run.
|
||||
bool storageCorruptThisLoad = false;
|
||||
#endif
|
||||
|
||||
private:
|
||||
mutable concurrency::Lock satelliteMutex;
|
||||
bool duplicateWarned = false;
|
||||
@@ -487,7 +525,9 @@ extern uint32_t error_address;
|
||||
#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT)
|
||||
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT 8
|
||||
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT)
|
||||
// Bits 9..31 reserved for future single-bit flags.
|
||||
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT 9
|
||||
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK (1u << NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT)
|
||||
// Bits 10..31 reserved for future single-bit flags.
|
||||
|
||||
// Convenience accessors so call sites read like the old struct fields.
|
||||
inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n)
|
||||
@@ -526,6 +566,10 @@ inline bool nodeInfoLiteIsKeyManuallyVerified(const meshtastic_NodeInfoLite *n)
|
||||
{
|
||||
return n && (n->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK);
|
||||
}
|
||||
inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n)
|
||||
{
|
||||
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
|
||||
}
|
||||
|
||||
inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value)
|
||||
{
|
||||
|
||||
+803
-28
@@ -3,6 +3,12 @@
|
||||
#include "GPS.h"
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
#include "security/LockdownDisplay.h"
|
||||
#endif
|
||||
#include "Channels.h"
|
||||
#include "Default.h"
|
||||
#include "FSCommon.h"
|
||||
@@ -36,6 +42,194 @@
|
||||
// Flag to indicate a heartbeat was received and we should send queue status
|
||||
bool heartbeatReceived = false;
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// Auth-slot table and status-slot table are both sized to the typical
|
||||
// SerialConsole + BluetoothPhoneAPI footprint plus room for WiFi/TCP
|
||||
// transports. Sized together so both tables are keyed identically.
|
||||
static constexpr size_t MAX_AUTH_SLOTS = 6;
|
||||
|
||||
// Per-PhoneAPI pending LockdownStatus. One slot per connection so a
|
||||
// status produced for connection A (e.g. UNLOCKED with the active TTL,
|
||||
// or UNLOCK_FAILED with a backoff) cannot be drained by connection B,
|
||||
// which would otherwise learn that A just authenticated or just failed
|
||||
// — a real information leak across local clients.
|
||||
//
|
||||
// File-scope rather than a per-PhoneAPI member because adding any
|
||||
// non-trivial state directly to PhoneAPI broke USB-CDC enumeration on
|
||||
// the current nRF52 framework; the auth-slot table next door uses the
|
||||
// same workaround. Lifecycle is tied to the auth slot table — both are
|
||||
// keyed by PhoneAPI*, both are cleared together in clearAuthSlot_LH,
|
||||
// and both share g_authSlotsMutex.
|
||||
struct PendingStatusSlot {
|
||||
PhoneAPI *who = nullptr;
|
||||
meshtastic_LockdownStatus status = {};
|
||||
bool hasPending = false;
|
||||
// True between a successful passphrase verify and the main-loop
|
||||
// reloadFromDisk that follows. While set, the connection is NOT
|
||||
// yet authorized and no UNLOCKED status has been emitted — the
|
||||
// client still sees LOCKED, and any admin op it tries is dropped
|
||||
// by the existing unauth gates. Cleared either way by
|
||||
// completePendingUnlocks once reload finishes.
|
||||
bool pendingUnlockAfterReload = false;
|
||||
};
|
||||
static PendingStatusSlot g_statusSlots[MAX_AUTH_SLOTS];
|
||||
|
||||
// Lock-held helpers ---------------------------------------------------------
|
||||
|
||||
static PendingStatusSlot *findOrAllocStatusSlot_LH(PhoneAPI *p)
|
||||
{
|
||||
if (!p)
|
||||
return nullptr;
|
||||
for (auto &s : g_statusSlots)
|
||||
if (s.who == p)
|
||||
return &s;
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (s.who == nullptr) {
|
||||
s.who = p;
|
||||
s.hasPending = false;
|
||||
s.pendingUnlockAfterReload = false;
|
||||
memset(&s.status, 0, sizeof(s.status));
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
// Mirror the auth-slot eviction policy: stale slots can be reused.
|
||||
// A connection that lost its auth slot has nothing meaningful to be
|
||||
// told via a pending status anyway. Never evict a slot mid-unlock
|
||||
// (pendingUnlockAfterReload set) — completing that flow on the
|
||||
// wrong PhoneAPI would authorize the wrong connection.
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (!s.hasPending && !s.pendingUnlockAfterReload) {
|
||||
s.who = p;
|
||||
memset(&s.status, 0, sizeof(s.status));
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void clearStatusSlot_LH(const PhoneAPI *p)
|
||||
{
|
||||
if (!p)
|
||||
return;
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (s.who == p) {
|
||||
s.who = nullptr;
|
||||
s.hasPending = false;
|
||||
s.pendingUnlockAfterReload = false;
|
||||
memset(&s.status, 0, sizeof(s.status));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a LockdownStatus message under lock from the supplied fields,
|
||||
// applying the audit's M13 redaction so token_* tamper-detection
|
||||
// strings are not leaked to unauth clients over the wire.
|
||||
static void buildStatus_LH(meshtastic_LockdownStatus &out, meshtastic_LockdownStatus_State state, const char *lock_reason,
|
||||
uint8_t boots_remaining, uint32_t valid_until_epoch, uint32_t backoff_seconds)
|
||||
{
|
||||
memset(&out, 0, sizeof(out));
|
||||
out.state = state;
|
||||
// Collapse the specific token_* reasons to a generic "locked" over
|
||||
// the wire — full detail still goes to local logs. An unauth client
|
||||
// does not need to know whether HMAC failed vs the boot count
|
||||
// hit zero vs the file was the wrong size; all of those mean the
|
||||
// same thing to the client ("locked, ask for passphrase") but
|
||||
// telling them apart over the network lets an attacker confirm
|
||||
// that their tampering or rollback attempt was noticed.
|
||||
const char *wireReason = lock_reason;
|
||||
if (state == meshtastic_LockdownStatus_State_LOCKED && wireReason && wireReason[0] != '\0') {
|
||||
if (strncmp(wireReason, "token_", 6) == 0)
|
||||
wireReason = "locked";
|
||||
}
|
||||
if (wireReason && wireReason[0] != '\0')
|
||||
strncpy(out.lock_reason, wireReason, sizeof(out.lock_reason) - 1);
|
||||
out.boots_remaining = boots_remaining;
|
||||
out.valid_until_epoch = valid_until_epoch;
|
||||
out.backoff_seconds = backoff_seconds;
|
||||
}
|
||||
|
||||
// Per-connection auth state table keyed by PhoneAPI*. Searched linearly;
|
||||
// cost is negligible compared to the redaction gates that call it.
|
||||
struct PhoneAuthSlot {
|
||||
PhoneAPI *who = nullptr;
|
||||
bool authorized = false;
|
||||
uint32_t epoch = 0;
|
||||
};
|
||||
static PhoneAuthSlot g_authSlots[MAX_AUTH_SLOTS];
|
||||
|
||||
// Global auth epoch. Lock Now bumps it; per-slot `epoch` compared against
|
||||
// this. Wraps at 2^32 revocations — practically unreachable; on wrap the
|
||||
// only behavioral effect is that any slot whose epoch happens to match the
|
||||
// new low value would be treated as authorized again, which requires a
|
||||
// pre-existing authorized slot to survive 2^32 lockNow events on the same
|
||||
// boot.
|
||||
static uint32_t g_authEpoch = 1;
|
||||
|
||||
// Single mutex guarding g_authSlots and g_authEpoch. All readers and
|
||||
// writers — including const getters like getAdminAuthorized — must take
|
||||
// it. Granularity is fine because the critical sections are short (a
|
||||
// fixed-size linear scan over 6 entries) and contention is dominated by
|
||||
// getFromRadio's per-call redaction checks, which tolerate brief
|
||||
// blocking.
|
||||
static concurrency::Lock g_authSlotsMutex;
|
||||
|
||||
// Find or allocate the auth slot for `p`. Caller must hold g_authSlotsMutex.
|
||||
// When the table is full of *unauthorized* slots from prior dead PhoneAPIs,
|
||||
// evicts the first unauthorized slot found. Refuses to evict an authorized
|
||||
// slot (those represent a live operator session and must outlive the table
|
||||
// pressure of reconnect churn). Returns nullptr only if every slot is
|
||||
// occupied by a different live, authorized PhoneAPI — practically only
|
||||
// reachable as a DoS via 7+ simultaneous authed connections, in which
|
||||
// case fail-closed and log.
|
||||
static PhoneAuthSlot *findOrAllocSlot_LH(PhoneAPI *p)
|
||||
{
|
||||
if (!p)
|
||||
return nullptr;
|
||||
for (auto &s : g_authSlots)
|
||||
if (s.who == p)
|
||||
return &s;
|
||||
// First pass: free (who==nullptr) slot.
|
||||
for (auto &s : g_authSlots) {
|
||||
if (s.who == nullptr) {
|
||||
s.who = p;
|
||||
s.authorized = false;
|
||||
s.epoch = 0;
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
// Second pass: evict an unauthorized stale slot. Don't touch authorized
|
||||
// ones — those still represent an operator-authenticated session.
|
||||
for (auto &s : g_authSlots) {
|
||||
if (!s.authorized) {
|
||||
s.who = p;
|
||||
s.epoch = 0;
|
||||
LOG_WARN("Lockdown: auth slot table full, evicted stale unauthorized slot for new PhoneAPI %p", p);
|
||||
return &s;
|
||||
}
|
||||
}
|
||||
LOG_WARN("Lockdown: auth slot table full of authorized sessions, refusing new PhoneAPI %p (fail-closed)", p);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Drop p's slot from both the auth table and the status-queue table.
|
||||
// Lock-held variant.
|
||||
static void clearAuthSlot_LH(const PhoneAPI *p)
|
||||
{
|
||||
if (!p)
|
||||
return;
|
||||
for (auto &s : g_authSlots) {
|
||||
if (s.who == p) {
|
||||
s.authorized = false;
|
||||
s.epoch = 0;
|
||||
s.who = nullptr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
clearStatusSlot_LH(p);
|
||||
}
|
||||
#endif
|
||||
|
||||
PhoneAPI::PhoneAPI()
|
||||
{
|
||||
lastContactMsec = millis();
|
||||
@@ -45,6 +239,17 @@ PhoneAPI::PhoneAPI()
|
||||
PhoneAPI::~PhoneAPI()
|
||||
{
|
||||
close();
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// Free the auth slot unconditionally, regardless of whether close()'s
|
||||
// slot-clear branch ran (it skips when state == STATE_SEND_NOTHING).
|
||||
// Leaving a stale slot.who pointing at freed memory lets a future
|
||||
// PhoneAPI heap-allocated at the same address inherit the prior
|
||||
// session's authorization through findOrAllocSlot.
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
clearAuthSlot_LH(this);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void PhoneAPI::handleStartConfig()
|
||||
@@ -55,6 +260,28 @@ void PhoneAPI::handleStartConfig()
|
||||
observe(&service->fromNumChanged);
|
||||
#ifdef FSCom
|
||||
observe(&xModem.packetReady);
|
||||
#endif
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// New physical connection: clear this PhoneAPI's auth slot so the new
|
||||
// client must present a passphrase or PKC admin signature before
|
||||
// seeing full config. Do NOT reset on a subsequent want_config_id
|
||||
// within the same connection: after a successful unlock the client
|
||||
// re-requests config to pull the now-unredacted values, and re-locking
|
||||
// that same-link re-fetch would strip the auth it just earned (config
|
||||
// comes back redacted and set_config writes get dropped).
|
||||
//
|
||||
// The security boundary is therefore the physical connection, not the
|
||||
// want_config handshake. For BLE that boundary is enforced in
|
||||
// onConnect() (which fires once per link and also resets the slot), so
|
||||
// a reconnect re-locks even if this !isConnected() transition was
|
||||
// missed because the prior link's close() raced the new config burst.
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
if (auto *slot = findOrAllocSlot_LH(this)) {
|
||||
slot->authorized = false;
|
||||
slot->epoch = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -157,6 +384,12 @@ void PhoneAPI::close()
|
||||
config_state = 0;
|
||||
pauseBluetoothLogging = false;
|
||||
heartbeatReceived = false;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
clearAuthSlot_LH(this);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +418,26 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
|
||||
if (pb_decode_from_bytes(buf, bufLength, &meshtastic_ToRadio_msg, &toRadioScratch)) {
|
||||
switch (toRadioScratch.which_payload_variant) {
|
||||
case meshtastic_ToRadio_packet_tag:
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Allow admin messages addressed to this device — passphrase delivery must get through.
|
||||
// AdminModule handles its own is_managed gate for those.
|
||||
// Block everything else — unauthorized clients cannot inject mesh traffic.
|
||||
// Require the packet to carry a decoded (not encrypted) payload so portnum is valid.
|
||||
// Refuse to match when our own node number is still 0 (NodeDB
|
||||
// not yet loaded — happens during the locked-default boot path
|
||||
// before reloadFromDisk). Otherwise a packet with to==0 would
|
||||
// satisfy the equality and bypass the gate.
|
||||
NodeNum ourNum = nodeDB->getNodeNum();
|
||||
bool isLocalAdmin =
|
||||
ourNum != 0 && toRadioScratch.packet.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
toRadioScratch.packet.decoded.portnum == meshtastic_PortNum_ADMIN_APP && toRadioScratch.packet.to == ourNum;
|
||||
if (!isLocalAdmin) {
|
||||
LOG_INFO("Lockdown: Dropping non-admin ToRadio packet from unauthorized client");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return handleToRadioPacket(toRadioScratch.packet);
|
||||
case meshtastic_ToRadio_want_config_id_tag:
|
||||
config_nonce = toRadioScratch.want_config_id;
|
||||
@@ -196,6 +449,12 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
|
||||
close();
|
||||
break;
|
||||
case meshtastic_ToRadio_xmodemPacket_tag:
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
LOG_INFO("Lockdown: Dropping xmodem packet from unauthorized client");
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
LOG_INFO("Got xmodem packet");
|
||||
#ifdef FSCom
|
||||
xModem.handlePacket(toRadioScratch.xmodemPacket);
|
||||
@@ -298,6 +557,21 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env));
|
||||
myNodeInfo.nodedb_count = static_cast<uint16_t>(nodeDB->getNumMeshNodes());
|
||||
fromRadioScratch.my_info = myNodeInfo;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// device_id is a stable hardware identifier — useful for an attacker
|
||||
// to fingerprint / correlate the device across observations. Strip it
|
||||
// for unauthenticated clients. my_node_num is kept (it's broadcast
|
||||
// on the mesh anyway). pio_env / min_app_version reveal the exact
|
||||
// build flavour, useful only for picking which known-CVE to try.
|
||||
// nodedb_count stays — clients need it to decide whether to pull
|
||||
// the node DB after unlocking.
|
||||
fromRadioScratch.my_info.device_id.size = 0;
|
||||
memset(fromRadioScratch.my_info.device_id.bytes, 0, sizeof(fromRadioScratch.my_info.device_id.bytes));
|
||||
memset(fromRadioScratch.my_info.pio_env, 0, sizeof(fromRadioScratch.my_info.pio_env));
|
||||
fromRadioScratch.my_info.min_app_version = 0;
|
||||
}
|
||||
#endif
|
||||
state = STATE_SEND_UIDATA;
|
||||
|
||||
service->refreshLocalMeshNode(); // Update my NodeInfo because the client will be asking for it soon.
|
||||
@@ -331,8 +605,15 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
}
|
||||
if (config_nonce == SPECIAL_NONCE_ONLY_NODES) {
|
||||
// If client only wants node info, jump directly to sending nodes
|
||||
state = STATE_SEND_OTHER_NODEINFOS;
|
||||
onNowHasData(0);
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
state = STATE_SEND_COMPLETE_ID; // Unauthorized: skip node DB
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
state = STATE_SEND_OTHER_NODEINFOS;
|
||||
onNowHasData(0);
|
||||
}
|
||||
} else {
|
||||
state = STATE_SEND_METADATA;
|
||||
}
|
||||
@@ -343,12 +624,35 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
LOG_DEBUG("Send device metadata");
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_metadata_tag;
|
||||
fromRadioScratch.metadata = getDeviceMetadata();
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// DeviceMetadata is one large fingerprint vector for an unauth
|
||||
// client: firmware_version, device_state_version, hw_model,
|
||||
// hw_model_string, has_bluetooth/has_wifi/has_ethernet, role,
|
||||
// position_flags, excluded_modules, optionsCount. None of it
|
||||
// is needed to drive lockdown_auth, and most of it tells an
|
||||
// attacker which CVE / behavior quirks to probe. Wipe the
|
||||
// whole struct — clients re-fetch once authenticated.
|
||||
memset(&fromRadioScratch.metadata, 0, sizeof(fromRadioScratch.metadata));
|
||||
}
|
||||
#endif
|
||||
state = STATE_SEND_CHANNELS;
|
||||
break;
|
||||
|
||||
case STATE_SEND_CHANNELS:
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_channel_tag;
|
||||
fromRadioScratch.channel = channels.getByIndex(config_state);
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthenticated: emit a zero-initialized Channel. fromRadioScratch
|
||||
// was memset(0) at the top of getFromRadio(), so leaving .channel
|
||||
// untouched gives the client an empty entry — no name, no PSK, no
|
||||
// role. Advances the state machine normally so config_complete_id
|
||||
// still fires.
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.channel = channels.getByIndex(config_state);
|
||||
}
|
||||
config_state++;
|
||||
// Advance when we have sent all of our Channels
|
||||
if (config_state >= MAX_NUM_CHANNELS) {
|
||||
@@ -380,7 +684,15 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
case meshtastic_Config_network_tag:
|
||||
LOG_DEBUG("Send config: network");
|
||||
fromRadioScratch.config.which_payload_variant = meshtastic_Config_network_tag;
|
||||
fromRadioScratch.config.payload_variant.network = config.network;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthenticated: emit an empty NetworkConfig (zero-init from the
|
||||
// top-of-loop memset). No wifi_psk, no SSID, no static IP info.
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.config.payload_variant.network = config.network;
|
||||
}
|
||||
break;
|
||||
case meshtastic_Config_display_tag:
|
||||
LOG_DEBUG("Send config: display");
|
||||
@@ -390,7 +702,28 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
case meshtastic_Config_lora_tag:
|
||||
LOG_DEBUG("Send config: lora");
|
||||
fromRadioScratch.config.which_payload_variant = meshtastic_Config_lora_tag;
|
||||
fromRadioScratch.config.payload_variant.lora = config.lora;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Whitelist only the spec-mandated radio identity fields that
|
||||
// are intrinsically observable on the air anyway: region,
|
||||
// modem_preset, use_preset, channel_num, hop_limit. Operator-
|
||||
// private knobs (ignore_incoming list, override_duty_cycle,
|
||||
// override_frequency, sx126x_rx_boosted_gain, tx_power,
|
||||
// ignore_mqtt, fem_lna_mode, config_ok_to_mqtt, ...) stay
|
||||
// hidden — they tell an attacker how the operator has tuned
|
||||
// the device but are not needed by an unauth client.
|
||||
meshtastic_Config_LoRaConfig whitelist = {};
|
||||
whitelist.use_preset = config.lora.use_preset;
|
||||
whitelist.modem_preset = config.lora.modem_preset;
|
||||
whitelist.region = config.lora.region;
|
||||
whitelist.channel_num = config.lora.channel_num;
|
||||
whitelist.hop_limit = config.lora.hop_limit;
|
||||
fromRadioScratch.config.payload_variant.lora = whitelist;
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.config.payload_variant.lora = config.lora;
|
||||
}
|
||||
break;
|
||||
case meshtastic_Config_bluetooth_tag:
|
||||
LOG_DEBUG("Send config: bluetooth");
|
||||
@@ -400,7 +733,21 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
case meshtastic_Config_security_tag:
|
||||
LOG_DEBUG("Send config: security");
|
||||
fromRadioScratch.config.which_payload_variant = meshtastic_Config_security_tag;
|
||||
fromRadioScratch.config.payload_variant.security = config.security;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthenticated: emit an empty SecurityConfig (zero-init from
|
||||
// the top-of-loop memset). No private_key, no admin_keys, no
|
||||
// public_key — nothing for an attacker to inspect.
|
||||
//
|
||||
// Provisioning state (NEEDS_PROVISION vs LOCKED) is conveyed via
|
||||
// the FromRadio.lockdown_status proto sent post-config; clients
|
||||
// should consume that rather than inferring from this empty
|
||||
// security config.
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.config.payload_variant.security = config.security;
|
||||
}
|
||||
break;
|
||||
case meshtastic_Config_sessionkey_tag:
|
||||
LOG_DEBUG("Send config: sessionkey");
|
||||
@@ -430,7 +777,17 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
case meshtastic_ModuleConfig_mqtt_tag:
|
||||
LOG_DEBUG("Send module config: mqtt");
|
||||
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
|
||||
fromRadioScratch.moduleConfig.payload_variant.mqtt = moduleConfig.mqtt;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthenticated: emit an empty MQTTConfig (zero-init from
|
||||
// the top-of-loop memset). MQTT broker username/password, the
|
||||
// server address, and root_topic are credentials/config that
|
||||
// shouldn't be visible to an unauth client.
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.moduleConfig.payload_variant.mqtt = moduleConfig.mqtt;
|
||||
}
|
||||
break;
|
||||
case meshtastic_ModuleConfig_serial_tag:
|
||||
LOG_DEBUG("Send module config: serial");
|
||||
@@ -509,15 +866,21 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
config_state++;
|
||||
// Advance when we have sent all of our ModuleConfig objects
|
||||
if (config_state > (_meshtastic_AdminMessage_ModuleConfigType_MAX + 1)) {
|
||||
// Handle special nonce behaviors:
|
||||
// - SPECIAL_NONCE_ONLY_CONFIG: Skip node info, go directly to file manifest
|
||||
// - SPECIAL_NONCE_ONLY_NODES: After sending nodes, skip to complete
|
||||
if (config_nonce == SPECIAL_NONCE_ONLY_CONFIG) {
|
||||
state = STATE_SEND_FILEMANIFEST;
|
||||
} else {
|
||||
state = STATE_SEND_OTHER_NODEINFOS;
|
||||
onNowHasData(0);
|
||||
}
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
// Unauthorized client: skip node DB and file manifest — only send config complete
|
||||
state = STATE_SEND_COMPLETE_ID;
|
||||
} else
|
||||
#endif
|
||||
// Handle special nonce behaviors:
|
||||
// - SPECIAL_NONCE_ONLY_CONFIG: Skip node info, go directly to file manifest
|
||||
// - SPECIAL_NONCE_ONLY_NODES: After sending nodes, skip to complete
|
||||
if (config_nonce == SPECIAL_NONCE_ONLY_CONFIG) {
|
||||
state = STATE_SEND_FILEMANIFEST;
|
||||
} else {
|
||||
state = STATE_SEND_OTHER_NODEINFOS;
|
||||
onNowHasData(0);
|
||||
}
|
||||
config_state = 0;
|
||||
}
|
||||
break;
|
||||
@@ -590,24 +953,58 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
fromRadioScratch.queueStatus = *queueStatusPacketForPhone;
|
||||
releaseQueueStatusPhonePacket();
|
||||
} else if (mqttClientProxyMessageForPhone) {
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag;
|
||||
fromRadioScratch.mqttClientProxyMessage = *mqttClientProxyMessageForPhone;
|
||||
releaseMqttClientProxyPhonePacket();
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
releaseMqttClientProxyPhonePacket(); // Discard — unauthorized client
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag;
|
||||
fromRadioScratch.mqttClientProxyMessage = *mqttClientProxyMessageForPhone;
|
||||
releaseMqttClientProxyPhonePacket();
|
||||
}
|
||||
} else if (xmodemPacketForPhone.control != meshtastic_XModem_Control_NUL) {
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_xmodemPacket_tag;
|
||||
fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
|
||||
xmodemPacketForPhone = meshtastic_XModem_init_zero;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
xmodemPacketForPhone = meshtastic_XModem_init_zero; // Discard — unauthorized client
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_xmodemPacket_tag;
|
||||
fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
|
||||
xmodemPacketForPhone = meshtastic_XModem_init_zero;
|
||||
}
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
} else if (hasPendingLockdownStatus()) {
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
// Look up our own slot only — never another connection's. Re-check
|
||||
// hasPending under the lock since a concurrent drain on the same
|
||||
// connection (unlikely but possible if multiple transport
|
||||
// callbacks race against one PhoneAPI) may have grabbed it.
|
||||
if (auto *slot = findOrAllocStatusSlot_LH(this); slot && slot->hasPending) {
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_lockdown_status_tag;
|
||||
fromRadioScratch.lockdown_status = slot->status;
|
||||
memset(&slot->status, 0, sizeof(slot->status));
|
||||
slot->hasPending = false;
|
||||
}
|
||||
#endif
|
||||
} else if (clientNotification) {
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_clientNotification_tag;
|
||||
fromRadioScratch.clientNotification = *clientNotification;
|
||||
releaseClientNotification();
|
||||
} else if (packetForPhone) {
|
||||
printPacket("phone downloaded packet", packetForPhone);
|
||||
|
||||
// Encapsulate as a FromRadio packet
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||
fromRadioScratch.packet = *packetForPhone;
|
||||
releasePhonePacket();
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (!getAdminAuthorized()) {
|
||||
releasePhonePacket(); // Discard mesh traffic — unauthorized client
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
printPacket("phone downloaded packet", packetForPhone);
|
||||
// Encapsulate as a FromRadio packet
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
|
||||
fromRadioScratch.packet = *packetForPhone;
|
||||
releasePhonePacket();
|
||||
}
|
||||
} else if (replayPending()) {
|
||||
// No live packet pending — feed the phone one cached satellite-DB packet.
|
||||
// popReplayPacket advances through positions->telemetry->environment->status,
|
||||
@@ -669,6 +1066,29 @@ void PhoneAPI::sendConfigComplete()
|
||||
service->api_state = service->STATE_ETH;
|
||||
}
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
if (!EncryptedStorage::isLockdownActive()) {
|
||||
// Lockdown-capable firmware, but lockdown is not active on this
|
||||
// device (never provisioned, or disabled). Tell the client so its
|
||||
// "lockdown mode" toggle renders OFF. Note getAdminAuthorized()
|
||||
// returns true in this state, so the redaction gates are no-ops and
|
||||
// the client just received the full, unredacted config above.
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
|
||||
LOG_INFO("PhoneAPI: DISABLED (lockdown not active) sent to client");
|
||||
} else if (!getAdminAuthorized()) {
|
||||
if (!EncryptedStorage::isProvisioned()) {
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_NEEDS_PROVISION, "", 0, 0, 0);
|
||||
LOG_INFO("PhoneAPI: NEEDS_PROVISION sent to client");
|
||||
} else if (!EncryptedStorage::isUnlocked()) {
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, EncryptedStorage::getLockReason(), 0, 0, 0);
|
||||
LOG_INFO("PhoneAPI: LOCKED (%s) sent to client", EncryptedStorage::getLockReason());
|
||||
} else {
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "needs_auth", 0, 0, 0);
|
||||
LOG_INFO("PhoneAPI: LOCKED (needs_auth) sent to client");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Allow subclasses to know we've entered steady-state so they can lower power consumption
|
||||
onConfigComplete();
|
||||
|
||||
@@ -1121,6 +1541,10 @@ bool PhoneAPI::available()
|
||||
if (!clientNotification)
|
||||
clientNotification = service->getClientNotificationForPhone();
|
||||
bool hasPacket = !!queueStatusPacketForPhone || !!mqttClientProxyMessageForPhone || !!clientNotification;
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (hasPendingLockdownStatus())
|
||||
hasPacket = true;
|
||||
#endif
|
||||
if (hasPacket)
|
||||
return true;
|
||||
|
||||
@@ -1192,6 +1616,46 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
|
||||
{
|
||||
printPacket("PACKET FROM PHONE", &p);
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
// Local admin gating happens here, synchronously on the dispatching
|
||||
// task. Two distinct cases:
|
||||
//
|
||||
// (a) lockdown_auth: handled inline. Passphrase never enters the
|
||||
// routed MeshPacket queue, and authorize-this-connection
|
||||
// runs while `this` is still on the call stack.
|
||||
//
|
||||
// (b) Any other admin payload from an unauthorized connection:
|
||||
// dropped here. The previous design relied on AdminModule
|
||||
// to apply isLocalAdminAuthorized() during dispatch, but
|
||||
// AdminModule runs on the Router task — by then the
|
||||
// PhoneAPI dispatching task has already exited and the
|
||||
// per-connection auth context is unrecoverable. Putting
|
||||
// the gate here closes that race and covers H6/H7 from the
|
||||
// audit: get_config_request and set_config from unauthed
|
||||
// clients no longer reach AdminModule at all.
|
||||
if (p.from == 0 && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) {
|
||||
meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero;
|
||||
if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) {
|
||||
if (admin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) {
|
||||
handleLockdownAuthInline(admin.lockdown_auth);
|
||||
// Wipe the decoded passphrase scratch — the byte array in
|
||||
// p.decoded.payload.bytes is wiped by handleLockdownAuthInline.
|
||||
volatile uint8_t *adminVol = const_cast<volatile uint8_t *>(admin.lockdown_auth.passphrase.bytes);
|
||||
for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++)
|
||||
adminVol[i] = 0;
|
||||
return true;
|
||||
}
|
||||
if (!getAdminAuthorized()) {
|
||||
LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// pb_decode failure: fall through to normal handling so the
|
||||
// regular Router/AdminModule reject path can respond.
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
// For use with the simulator, we should not ignore duplicate packets from the phone
|
||||
if (SimRadio::instance == nullptr)
|
||||
@@ -1260,3 +1724,314 @@ int PhoneAPI::onNotify(uint32_t newValue)
|
||||
|
||||
return timeout ? -1 : 0; // If we timed out, MeshService should stop iterating through observers as we just removed one
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
bool PhoneAPI::getAdminAuthorized() const
|
||||
{
|
||||
// Runtime-toggle model: when lockdown is NOT active (a lockdown-capable
|
||||
// build that hasn't been provisioned, or that was disabled), there is
|
||||
// nothing to protect — every connection is implicitly authorized, so
|
||||
// all the `if (!getAdminAuthorized())` redaction gates throughout
|
||||
// getFromRadio() / handleToRadio() become no-ops and the device serves
|
||||
// config exactly like stock firmware. Only once provisioned (lockdown
|
||||
// active) do we consult the per-connection auth slot table.
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
if (!EncryptedStorage::isLockdownActive())
|
||||
return true;
|
||||
#endif
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
// const_cast is safe — findOrAllocSlot_LH only mutates the slot table,
|
||||
// not the PhoneAPI itself, and the table key is just the pointer.
|
||||
const auto *slot = findOrAllocSlot_LH(const_cast<PhoneAPI *>(this));
|
||||
return slot && slot->authorized && slot->epoch == g_authEpoch;
|
||||
}
|
||||
|
||||
void PhoneAPI::setAdminAuthorized(bool authorized)
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
auto *slot = findOrAllocSlot_LH(this);
|
||||
if (!slot)
|
||||
return; // slot table full — fail-closed
|
||||
if (authorized) {
|
||||
slot->epoch = g_authEpoch;
|
||||
slot->authorized = true;
|
||||
} else {
|
||||
slot->authorized = false;
|
||||
slot->epoch = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void PhoneAPI::revokeAllAuth()
|
||||
{
|
||||
{
|
||||
concurrency::LockGuard g(&g_authSlotsMutex);
|
||||
g_authEpoch++;
|
||||
}
|
||||
LOG_INFO("Lockdown: All connection auth revoked (Lock Now)");
|
||||
}
|
||||
|
||||
void PhoneAPI::completePendingUnlocks(bool reloadOk)
|
||||
{
|
||||
// Snapshot fields that we'll need outside the lock (we cannot call
|
||||
// EncryptedStorage / setAdminAuthorized / unlockScreen while holding
|
||||
// g_authSlotsMutex without risking re-entry — setAdminAuthorized
|
||||
// itself takes the same lock).
|
||||
constexpr size_t kMaxSnapshots = MAX_AUTH_SLOTS;
|
||||
PhoneAPI *targets[kMaxSnapshots] = {};
|
||||
size_t targetCount = 0;
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (!s.pendingUnlockAfterReload || !s.who)
|
||||
continue;
|
||||
if (targetCount < kMaxSnapshots)
|
||||
targets[targetCount++] = s.who;
|
||||
// Clear the pending flag either way — failure path must not
|
||||
// leave it set so a subsequent successful reload retries
|
||||
// against the wrong PhoneAPI.
|
||||
s.pendingUnlockAfterReload = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (reloadOk) {
|
||||
uint8_t boots = EncryptedStorage::getBootsRemaining();
|
||||
uint32_t until = EncryptedStorage::getValidUntilEpoch();
|
||||
for (size_t i = 0; i < targetCount; i++) {
|
||||
PhoneAPI *p = targets[i];
|
||||
p->setAdminAuthorized(true);
|
||||
p->queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCKED, "", boots, until, 0);
|
||||
}
|
||||
// Screen-lock latch is cleared once any client successfully
|
||||
// unlocks — the operator has proven the passphrase. Matches the
|
||||
// re-verify path's behavior.
|
||||
if (targetCount > 0)
|
||||
meshtastic_security::unlockScreen();
|
||||
LOG_INFO("Lockdown: post-reload completion: authorized %u connection(s)", (unsigned)targetCount);
|
||||
} else {
|
||||
// Storage corrupt — emit LOCKED(storage_corrupt) to every slot
|
||||
// that was awaiting the unlock. setAdminAuthorized is NOT called
|
||||
// so the connection stays redacted and any set_config it sends
|
||||
// is dropped at the existing unauth gates. Caller (main.cpp) has
|
||||
// already lockNow'd storage and broadcast-revoked.
|
||||
for (size_t i = 0; i < targetCount; i++) {
|
||||
targets[i]->queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "storage_corrupt", 0, 0, 0);
|
||||
}
|
||||
LOG_ERROR("Lockdown: post-reload completion: storage corrupt, notified %u connection(s)", (unsigned)targetCount);
|
||||
}
|
||||
}
|
||||
|
||||
void PhoneAPI::queueLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
|
||||
uint32_t valid_until_epoch, uint32_t backoff_seconds)
|
||||
{
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
auto *slot = findOrAllocStatusSlot_LH(this);
|
||||
if (!slot)
|
||||
return; // slot table exhausted — fail-closed, no status delivered
|
||||
buildStatus_LH(slot->status, state, lock_reason, boots_remaining, valid_until_epoch, backoff_seconds);
|
||||
slot->hasPending = true;
|
||||
}
|
||||
if (service)
|
||||
service->nudgeFromNum();
|
||||
}
|
||||
|
||||
void PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
|
||||
uint32_t valid_until_epoch, uint32_t backoff_seconds)
|
||||
{
|
||||
bool anyOverwritten = false;
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
for (auto &s : g_statusSlots) {
|
||||
if (s.who) {
|
||||
buildStatus_LH(s.status, state, lock_reason, boots_remaining, valid_until_epoch, backoff_seconds);
|
||||
s.hasPending = true;
|
||||
anyOverwritten = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Service nudge is shared across connections; one nudge wakes every
|
||||
// drainer. Skip if no connection currently has a slot.
|
||||
if (anyOverwritten && service)
|
||||
service->nudgeFromNum();
|
||||
}
|
||||
|
||||
bool PhoneAPI::hasPendingLockdownStatus() const
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
for (const auto &s : g_statusSlots) {
|
||||
if (s.who == this && s.hasPending)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la)
|
||||
{
|
||||
// Wipe passphrase bytes in the caller's decoded scratch on every exit.
|
||||
auto zeroPassphrase = [&]() {
|
||||
volatile uint8_t *ppVol = const_cast<volatile uint8_t *>(la.passphrase.bytes);
|
||||
for (pb_size_t zi = 0; zi < la.passphrase.size; zi++)
|
||||
ppVol[zi] = 0;
|
||||
};
|
||||
|
||||
// Lock Now — only honored from a connection that has already proven
|
||||
// the passphrase. Unauthenticated clients used to be able to trigger
|
||||
// a reboot, which was a trivial local-presence DoS (any BLE/USB
|
||||
// attacker could brick-loop the device). Now lock_now requires
|
||||
// prior auth on this connection.
|
||||
if (la.lock_now) {
|
||||
if (!getAdminAuthorized()) {
|
||||
LOG_WARN("Lockdown: LOCK NOW from unauthorized connection — denied");
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
LOG_INFO("Lockdown: LOCK NOW command received from authorized connection");
|
||||
EncryptedStorage::lockNow();
|
||||
revokeAllAuth();
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Disable lockdown entirely. Requires the passphrase (must prove
|
||||
// ownership before reverting at-rest encryption). We verify it here to
|
||||
// load the DEK, then hand the heavy decrypt-revert work to the main
|
||||
// loop via lockdownDisablePending — exactly like the unlock reload
|
||||
// path, because decrypting + rewriting nodes.proto is too heavy for
|
||||
// this transport-callback stack. APPROTECT is NOT reversed.
|
||||
if (la.disable) {
|
||||
if (la.passphrase.size < 1) {
|
||||
LOG_WARN("Lockdown: disable with empty passphrase — rejecting");
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
if (!EncryptedStorage::isLockdownActive()) {
|
||||
// Already off — nothing to do; report DISABLED so the client UI settles.
|
||||
LOG_INFO("Lockdown: disable requested but lockdown is not active");
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
// Re-verify the passphrase (loads the DEK needed to decrypt files).
|
||||
bool ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size,
|
||||
EncryptedStorage::TOKEN_DEFAULT_BOOTS, 0, 0);
|
||||
if (!ok) {
|
||||
uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, backoff);
|
||||
LOG_WARN("Lockdown: disable passphrase verification failed");
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
setAdminAuthorized(true);
|
||||
lockdownDisablePending = true; // main loop runs nodeDB->disableLockdownToPlaintext() then reboots
|
||||
LOG_INFO("Lockdown: disable authorized, deferring decrypt-revert to main loop");
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Empty-passphrase auth was previously a silent success — clients
|
||||
// got no feedback and the device looked the same as it would after
|
||||
// an actual no-op. Emit UNLOCK_FAILED with no backoff so honest
|
||||
// clients can detect their own bug and an attacker still learns
|
||||
// nothing they wouldn't from any other failed attempt.
|
||||
if (la.passphrase.size < 1) {
|
||||
LOG_WARN("Lockdown: lockdown_auth with empty passphrase and lock_now=false — rejecting");
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
|
||||
// boots_remaining is uint32 on the wire but the token field is uint8.
|
||||
// Silently truncating (256 -> 0 -> default 50) hides a real client
|
||||
// bug. Reject explicitly so the client can correct its request.
|
||||
if (la.boots_remaining > 255) {
|
||||
LOG_WARN("Lockdown: boots_remaining=%u exceeds uint8 cap, rejecting", la.boots_remaining);
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, 0);
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t boots = la.boots_remaining != 0 ? (uint8_t)la.boots_remaining : EncryptedStorage::TOKEN_DEFAULT_BOOTS;
|
||||
uint32_t validUntilEpoch = la.valid_until_epoch;
|
||||
// Client-supplied session cap when present; otherwise the
|
||||
// firmware-side default. 0 from the client means "use firmware
|
||||
// default", consistent with the boots_remaining sentinel.
|
||||
uint32_t sessionMaxSeconds =
|
||||
la.max_session_seconds != 0 ? la.max_session_seconds : MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS;
|
||||
|
||||
bool ok = false;
|
||||
bool needsReload = false;
|
||||
if (!EncryptedStorage::isUnlocked()) {
|
||||
if (!EncryptedStorage::isProvisioned()) {
|
||||
LOG_INFO("Lockdown: first-time provisioning with passphrase");
|
||||
ok = EncryptedStorage::provisionPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
|
||||
sessionMaxSeconds);
|
||||
} else {
|
||||
LOG_INFO("Lockdown: unlock with passphrase");
|
||||
ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
|
||||
sessionMaxSeconds);
|
||||
}
|
||||
if (ok) {
|
||||
needsReload = true;
|
||||
// Mark this slot for the main-loop completion handler. Don't
|
||||
// authorize or emit UNLOCKED yet — `config` / `channelFile`
|
||||
// / `nodeDatabase` still hold the locked-default placeholders
|
||||
// installed by loadFromDisk()'s !isUnlocked() branch. If we
|
||||
// flipped the connection to authorized here, the client could
|
||||
// read those placeholders as if they were the operator's real
|
||||
// settings, or set_config write a corrupted baseline that
|
||||
// overwrites the real config when reloadFromDisk swaps them
|
||||
// in. completePendingUnlocks() runs on the main thread after
|
||||
// reloadFromDisk has populated the real values and the radio
|
||||
// has been reconfigured.
|
||||
{
|
||||
concurrency::LockGuard guard(&g_authSlotsMutex);
|
||||
if (auto *slot = findOrAllocStatusSlot_LH(this))
|
||||
slot->pendingUnlockAfterReload = true;
|
||||
}
|
||||
lockdownReloadPending = true;
|
||||
LOG_INFO("Lockdown: storage unlocked, awaiting reload before client visibility");
|
||||
}
|
||||
} else {
|
||||
LOG_INFO("Lockdown: passphrase re-verify for admin authorization");
|
||||
ok = EncryptedStorage::unlockWithPassphrase(la.passphrase.bytes, la.passphrase.size, boots, validUntilEpoch,
|
||||
sessionMaxSeconds);
|
||||
if (ok) {
|
||||
// Storage was already unlocked — no reload needed. Authorize
|
||||
// and surface UNLOCKED to the client immediately.
|
||||
setAdminAuthorized(true);
|
||||
LOG_INFO("Lockdown: passphrase verified, this connection authorized");
|
||||
}
|
||||
}
|
||||
|
||||
if (ok && !needsReload) {
|
||||
// Re-verify path: storage was already unlocked. Clear the screen
|
||||
// latch and emit UNLOCKED now. The cold-unlock path defers both
|
||||
// of these to completePendingUnlocks() once reloadFromDisk finishes.
|
||||
meshtastic_security::unlockScreen();
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCKED, "", EncryptedStorage::getBootsRemaining(),
|
||||
EncryptedStorage::getValidUntilEpoch(), 0);
|
||||
} else if (ok && needsReload) {
|
||||
// Cold-unlock path: deliberately no status emission yet — the
|
||||
// client keeps seeing LOCKED until completePendingUnlocks()
|
||||
// runs after a successful reload.
|
||||
} else {
|
||||
uint32_t backoff = EncryptedStorage::getBackoffSecondsRemaining();
|
||||
queueLockdownStatus(meshtastic_LockdownStatus_State_UNLOCK_FAILED, "", 0, 0, backoff);
|
||||
// Don't log backoff seconds — the client receives it in the
|
||||
// UNLOCK_FAILED status anyway, and in non-DEBUG_MUTE builds the
|
||||
// numeric value would otherwise spill onto a USB-attached
|
||||
// attacker's serial terminal alongside other diagnostic noise.
|
||||
LOG_WARN("Lockdown: passphrase verification failed");
|
||||
(void)backoff;
|
||||
}
|
||||
|
||||
zeroPassphrase();
|
||||
return true;
|
||||
}
|
||||
#endif // MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#endif // MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "concurrency/Lock.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "meshtastic/portnums.pb.h"
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <iterator>
|
||||
@@ -170,6 +171,49 @@ class PhoneAPI
|
||||
bool isConnected() { return state != STATE_SEND_NOTHING; }
|
||||
bool isSendingPackets() { return state == STATE_SEND_PACKETS; }
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
/// Per-connection auth: tracked in a small file-scope slot table keyed
|
||||
/// by PhoneAPI*. Adding state members directly to PhoneAPI broke
|
||||
/// USB-CDC enumeration on current nRF52 framework — even one extra
|
||||
/// per-instance uint32_t was enough. Keeping all state out-of-line
|
||||
/// avoids the issue.
|
||||
void setAdminAuthorized(bool authorized);
|
||||
bool getAdminAuthorized() const;
|
||||
|
||||
/// Lock Now: O(1) invalidation of every connection's auth by advancing
|
||||
/// the global epoch. Subsequent gate checks see slot.myEpoch != epoch
|
||||
/// and treat the connection as unauthenticated.
|
||||
static void revokeAllAuth();
|
||||
|
||||
/// Called from the main loop after NodeDB::reloadFromDisk() finishes.
|
||||
/// On reloadOk=true: any connection marked pending-unlock-after-reload
|
||||
/// is promoted to authorized and receives an UNLOCKED status; the
|
||||
/// screen-lock latch clears. On reloadOk=false: those connections
|
||||
/// receive a LOCKED(storage_corrupt) status and remain unauthorized
|
||||
/// so they cannot drive set_config against the corrupt baseline.
|
||||
static void completePendingUnlocks(bool reloadOk);
|
||||
|
||||
/// Queue a LockdownStatus FromRadio for THIS connection only. Each
|
||||
/// PhoneAPI owns its own pending-status slot in a file-scope table
|
||||
/// (file-scope because adding fields directly to PhoneAPI broke
|
||||
/// USB-CDC enumeration on nRF52); a status produced here will not
|
||||
/// be delivered to any other connection. `lock_reason` may be
|
||||
/// nullptr / empty for non-LOCKED states.
|
||||
void queueLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
|
||||
uint32_t valid_until_epoch, uint32_t backoff_seconds);
|
||||
|
||||
/// Queue the same LockdownStatus on every active connection's slot.
|
||||
/// Use for events with no specific originating connection (session
|
||||
/// expiry tick in main.cpp, broadcast revocations, etc.). Per-
|
||||
/// connection callers should prefer the instance method above to
|
||||
/// avoid leaking one client's auth state to another.
|
||||
static void broadcastLockdownStatus(meshtastic_LockdownStatus_State state, const char *lock_reason, uint8_t boots_remaining,
|
||||
uint32_t valid_until_epoch, uint32_t backoff_seconds);
|
||||
|
||||
/// True iff this connection has a pending lockdown_status drain.
|
||||
bool hasPendingLockdownStatus() const;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/// Our fromradio packet while it is being assembled
|
||||
meshtastic_FromRadio fromRadioScratch = {};
|
||||
@@ -211,6 +255,20 @@ class PhoneAPI
|
||||
|
||||
APIType api_type = TYPE_NONE;
|
||||
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
// No per-instance auth members — see method-level note. All state lives
|
||||
// in a file-scope slot table in PhoneAPI.cpp keyed by `this` pointer.
|
||||
|
||||
// Pending LockdownStatus storage is NOT a class member — having a
|
||||
// meshtastic_LockdownStatus (~50 bytes with the char[33] lock_reason)
|
||||
// as a PhoneAPI member broke USB-CDC enumeration on the nRF52 Adafruit
|
||||
// framework. The exact mechanism wasn't pinned down, but moving the
|
||||
// storage to a file-scope static in PhoneAPI.cpp side-steps it cleanly.
|
||||
// Trade-off: all PhoneAPI instances share one pending slot. Acceptable
|
||||
// because only one transport delivers a lockdown command at a time in
|
||||
// any realistic scenario.
|
||||
#endif
|
||||
|
||||
private:
|
||||
void releasePhonePacket();
|
||||
|
||||
@@ -248,6 +306,16 @@ class PhoneAPI
|
||||
*/
|
||||
bool handleToRadioPacket(meshtastic_MeshPacket &p);
|
||||
|
||||
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
|
||||
/// Synchronously handle a lockdown_auth AdminMessage from the local
|
||||
/// client. Runs inside handleToRadioPacket so the originating
|
||||
/// connection is reachable via `this` — avoids the async context
|
||||
/// loss that broke the previous AdminModule path. Always consumes the
|
||||
/// packet (returns true): lockdown_auth is local-only and must not be
|
||||
/// forwarded to the mesh router.
|
||||
bool handleLockdownAuthInline(const meshtastic_LockdownAuth &la);
|
||||
#endif
|
||||
|
||||
/// If the mesh service tells us fromNum has changed, tell the phone
|
||||
virtual int onNotify(uint32_t newValue) override;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,16 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel)
|
||||
|
||||
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
|
||||
{
|
||||
return getPositionPrecisionForChannel(channels.getByIndex(channelIndex));
|
||||
const meshtastic_Channel &ch = channels.getByIndex(channelIndex);
|
||||
if (ch.role == meshtastic_Channel_Role_DISABLED)
|
||||
return 0;
|
||||
uint32_t precision = getPositionPrecisionForChannel(ch);
|
||||
|
||||
// Never send a precise position on a publicly-decryptable channel (key check is gated on > ceiling).
|
||||
if (precision > MAX_POSITION_PRECISION_PUBLIC_KEY && channels.usesPublicKey(channelIndex)) {
|
||||
precision = MAX_POSITION_PRECISION_PUBLIC_KEY;
|
||||
}
|
||||
return precision;
|
||||
}
|
||||
|
||||
static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
#include "meshtastic/mesh.pb.h"
|
||||
#include <stdint.h>
|
||||
|
||||
// Max precision on a publicly-decryptable channel. CCPA "precise geolocation" = within a ~564m (1,850ft) radius.
|
||||
// Precision is bit-truncation of latitude_i/longitude_i: the latitude cell stays ~constant in meters worldwide
|
||||
// (~700m at 15 bits), while only the longitude cell varies — widest at the equator, narrowing toward the poles.
|
||||
// 15 also matches the MQTT map-report public precision ceiling.
|
||||
#define MAX_POSITION_PRECISION_PUBLIC_KEY 15
|
||||
|
||||
// Configured precision as-is; does NOT apply the public-key clamp -- use the channelIndex overload for the on-wire value.
|
||||
uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel);
|
||||
|
||||
// Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable.
|
||||
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex);
|
||||
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision);
|
||||
bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision);
|
||||
|
||||
+125
-46
@@ -63,6 +63,8 @@ const RegionProfile PROFILE_HAM_20KHZ = {PRESETS_TINY, 0, 0.0022f, false, true,
|
||||
// Ham '100kHz' profile. 62.5kHz bandwidth coerced to 100kHz via padding.
|
||||
const RegionProfile PROFILE_HAM_100KHZ = {PRESETS_NARROW, 0, 0.01875f, false, true, 0, 1, 1};
|
||||
|
||||
Observable<uint32_t> RadioInterface::loraRxPacketObservable;
|
||||
|
||||
#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset, \
|
||||
override_slot) \
|
||||
{ \
|
||||
@@ -85,20 +87,30 @@ const RegionInfo regions[] = {
|
||||
*/
|
||||
RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
/*
|
||||
https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/
|
||||
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
|
||||
https://www.legislation.gov.uk/uksi/1999/930/schedule/6/part/III/made/data.xht?view=snippet&wrap=true
|
||||
https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/
|
||||
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
|
||||
https://www.legislation.gov.uk/uksi/1999/930/schedule/6/part/III/made/data.xht?view=snippet&wrap=true
|
||||
|
||||
audio_permitted = false per regulation
|
||||
audio_permitted = false per regulation
|
||||
|
||||
Special Note:
|
||||
The link above describes LoRaWAN's band plan, stating a power limit of 16 dBm. This is their own suggested specification,
|
||||
we do not need to follow it. The European Union regulations clearly state that the power limit for this frequency range is
|
||||
500 mW, or 27 dBm. It also states that we can use interference avoidance and spectrum access techniques (such as LBT +
|
||||
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
|
||||
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
|
||||
*/
|
||||
Special Note:
|
||||
The link above describes LoRaWAN's band plan, stating a power limit of 16 dBm. This is their own suggested specification,
|
||||
we do not need to follow it. The European Union regulations clearly state that the power limit for this frequency range is
|
||||
500 mW, or 27 dBm. It also states that we can use interference avoidance and spectrum access techniques (such as LBT +
|
||||
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
|
||||
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
|
||||
|
||||
EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD)
|
||||
Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm,
|
||||
duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123
|
||||
|
||||
EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz
|
||||
Channel centres at 869.442/869.525/869.608 MHz,
|
||||
10.4 kHz padding on channels, 27 dBm, duty cycle 10%
|
||||
*/
|
||||
RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868, PRESET(LONG_FAST), 0),
|
||||
RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST), 0),
|
||||
RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW), 1),
|
||||
|
||||
/*
|
||||
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
|
||||
@@ -276,20 +288,6 @@ const RegionInfo regions[] = {
|
||||
*/
|
||||
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD, PRESET(LONG_FAST), 0),
|
||||
|
||||
/*
|
||||
EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD)
|
||||
Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm,
|
||||
duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123
|
||||
*/
|
||||
RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST), 0),
|
||||
|
||||
/*
|
||||
EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz
|
||||
Channel centres at 869.442/869.525/869.608 MHz,
|
||||
10.4 kHz padding on channels, 27 dBm, duty cycle 10%
|
||||
*/
|
||||
RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW), 1),
|
||||
|
||||
/*
|
||||
This needs to be last. Same as US.
|
||||
*/
|
||||
@@ -857,53 +855,116 @@ uint32_t RadioInterface::getChannelNum()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error-level client notification. Safe to call when service is null (e.g. in tests).
|
||||
* Send a client notification (error level unless specified). Safe to call when service is null (e.g. in tests).
|
||||
*/
|
||||
static void sendErrorNotification(const char *msg)
|
||||
static void sendErrorNotification(const char *msg, meshtastic_LogRecord_Level level = meshtastic_LogRecord_Level_ERROR)
|
||||
{
|
||||
if (!service)
|
||||
return;
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
if (!cn)
|
||||
return;
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
cn->level = level;
|
||||
snprintf(cn->message, sizeof(cn->message), "%s", msg);
|
||||
service->sendClientNotification(cn);
|
||||
}
|
||||
|
||||
// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset
|
||||
// locked to a sibling means the user wants that sibling region, not the default preset.
|
||||
static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_868,
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_866,
|
||||
meshtastic_Config_LoRaConfig_RegionCode_EU_N_868,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a region is valid for the current settings.
|
||||
* If currentRegion is one of the swappable EU regions and preset belongs to a sibling in
|
||||
* that trio, return the sibling region that owns the preset. Returns nullptr otherwise.
|
||||
*/
|
||||
const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset preset)
|
||||
{
|
||||
bool currentIsSwappable = false;
|
||||
for (auto code : SWAPPABLE_EU_REGIONS) {
|
||||
if (code == currentRegion)
|
||||
currentIsSwappable = true;
|
||||
}
|
||||
if (!currentIsSwappable)
|
||||
return nullptr;
|
||||
|
||||
for (auto code : SWAPPABLE_EU_REGIONS) {
|
||||
if (code == currentRegion)
|
||||
continue;
|
||||
const RegionInfo *sibling = getRegion(code);
|
||||
if (sibling->supportsPreset(preset))
|
||||
return sibling;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a region is valid for the current settings, with no side effects.
|
||||
* Safe to call speculatively (e.g. from UI pickers). When errBuf is given, it
|
||||
* receives the human-readable failure reason.
|
||||
* Returns false if not compatible.
|
||||
*/
|
||||
bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig)
|
||||
bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen)
|
||||
{
|
||||
const RegionInfo *newRegion = getRegion(loraConfig.region);
|
||||
|
||||
// Reject unrecognized region codes (getRegion returns UNSET sentinel for unknown codes)
|
||||
if (newRegion->code != loraConfig.region) {
|
||||
char err_string[160];
|
||||
snprintf(err_string, sizeof(err_string), "Region code %d is not recognized", loraConfig.region);
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
if (errBuf)
|
||||
snprintf(errBuf, errLen, "Region code %d is not recognized", loraConfig.region);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If you are not licensed, you can't use ham regions.
|
||||
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
|
||||
char err_string[160];
|
||||
snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name);
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
if (errBuf)
|
||||
snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hardware compatibility: wide-LoRa (2.4 GHz) regions need a wide-capable radio, and
|
||||
// sub-GHz regions need a radio that can tune below 2.4 GHz (SX128x cannot). UNSET is
|
||||
// always allowed since it is the "no region" state.
|
||||
if (newRegion->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && RadioLibInterface::instance) {
|
||||
const char *unsupported = nullptr;
|
||||
if (newRegion->wideLora && !RadioLibInterface::instance->wideLora()) {
|
||||
unsupported = "2.4 GHz";
|
||||
} else if (!newRegion->wideLora && !RadioLibInterface::instance->supportsSubGhz()) {
|
||||
unsupported = "sub-GHz";
|
||||
}
|
||||
if (unsupported) {
|
||||
if (errBuf)
|
||||
snprintf(errBuf, errLen, "Region %s needs %s, which this radio does not support", newRegion->name, unsupported);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: validate or clamp a LoRa config against its region.
|
||||
* Checks if a region is valid for the current settings. On failure, logs at ERROR,
|
||||
* records a critical error, and sends a client notification.
|
||||
* Returns false if not compatible.
|
||||
*/
|
||||
bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig)
|
||||
{
|
||||
char err_string[160];
|
||||
if (checkConfigRegion(loraConfig, err_string, sizeof(err_string)))
|
||||
return true;
|
||||
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: check or clamp a LoRa config against its region.
|
||||
* When clamp==false, returns false on first error (pure validation).
|
||||
* When clamp==true, fixes invalid settings in-place and returns true.
|
||||
*/
|
||||
@@ -920,11 +981,29 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
|
||||
if (loraConfig.use_preset) {
|
||||
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
|
||||
|
||||
bool preset_valid = false;
|
||||
for (size_t i = 0; i < newRegion->getNumPresets(); i++) {
|
||||
if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) {
|
||||
bool preset_valid = newRegion->supportsPreset(loraConfig.modem_preset);
|
||||
if (!preset_valid) {
|
||||
// A preset locked to a sibling of the swappable EU regions swaps the region instead
|
||||
// of clamping the preset, as long as the previous region was itself one of the trio.
|
||||
const RegionInfo *swapRegion = regionSwapForPreset(loraConfig.region, loraConfig.modem_preset);
|
||||
if (swapRegion) {
|
||||
if (!clamp) {
|
||||
// Validation must still fail so callers route into the clamp, but quietly:
|
||||
// the clamp will accept this config by swapping regions, so don't record a
|
||||
// critical error or alarm the user over a change that is about to succeed.
|
||||
LOG_INFO("Preset %s implies region swap %s to %s, deferring to clamp", presetName, newRegion->name,
|
||||
swapRegion->name);
|
||||
return false;
|
||||
}
|
||||
snprintf(err_string, sizeof(err_string), "Preset %s swaps region %s to %s", presetName, newRegion->name,
|
||||
swapRegion->name);
|
||||
LOG_INFO("%s", err_string);
|
||||
sendErrorNotification(err_string, meshtastic_LogRecord_Level_INFO);
|
||||
|
||||
loraConfig.region = swapRegion->code;
|
||||
newRegion = swapRegion;
|
||||
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
|
||||
preset_valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!preset_valid) {
|
||||
@@ -1288,4 +1367,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p)
|
||||
|
||||
sendingPacket = p;
|
||||
return p->encrypted.size + sizeof(PacketHeader);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,9 @@ class RadioInterface
|
||||
|
||||
virtual ~RadioInterface() {}
|
||||
|
||||
/// Fires once per valid received LoRa packet (arg = sender NodeNum). Used e.g. to flash LED_LORA.
|
||||
static Observable<uint32_t> loraRxPacketObservable;
|
||||
|
||||
/**
|
||||
* Coerce LoRa config fields (bandwidth/spread_factor) derived from presets.
|
||||
* This is used during early bootstrapping so UIs that display these fields directly remain consistent.
|
||||
@@ -143,6 +146,10 @@ class RadioInterface
|
||||
|
||||
virtual bool wideLora() { return false; }
|
||||
|
||||
/// Whether the radio can tune sub-GHz bands. False for 2.4 GHz-only chips (SX128x);
|
||||
/// multiband chips like the LR1121 keep the default.
|
||||
virtual bool supportsSubGhz() { return true; }
|
||||
|
||||
/// Prepare hardware for sleep. Call this _only_ for deep sleep, not needed for light sleep.
|
||||
virtual bool sleep() { return true; }
|
||||
|
||||
@@ -244,7 +251,12 @@ class RadioInterface
|
||||
|
||||
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
|
||||
|
||||
// Check if a candidate region is compatible and valid.
|
||||
// Check if a candidate region is compatible and valid, with no side effects (safe for
|
||||
// speculative UI checks). errBuf, if given, receives the failure reason.
|
||||
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0);
|
||||
|
||||
// Check if a candidate region is compatible and valid. On failure, logs at ERROR,
|
||||
// records a critical error, and sends a client notification.
|
||||
static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig);
|
||||
|
||||
// Check if a candidate radio configuration is valid.
|
||||
@@ -253,6 +265,11 @@ class RadioInterface
|
||||
// Make a candidate radio configuration valid, even if it isn't.
|
||||
static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig);
|
||||
|
||||
// If preset is locked to a sibling of currentRegion among the swappable EU regions
|
||||
// (EU_868/EU_866/EU_N_868), return the sibling region owning the preset, else nullptr.
|
||||
static const RegionInfo *regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset preset);
|
||||
|
||||
protected:
|
||||
int8_t power = 17; // Set by applyModemConfig()
|
||||
|
||||
|
||||
@@ -614,6 +614,10 @@ void RadioLibInterface::handleReceiveInterrupt()
|
||||
|
||||
printPacket("Lora RX", mp);
|
||||
|
||||
#ifdef LED_LORA
|
||||
loraRxPacketObservable.notifyObservers(mp->from);
|
||||
#endif
|
||||
|
||||
airTime->logAirtime(RX_LOG, rxMsec);
|
||||
|
||||
deliverToReceiver(mp);
|
||||
|
||||
@@ -559,6 +559,38 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
if (p->decoded.has_bitfield)
|
||||
p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && node->public_key.size == 32) {
|
||||
p->xeddsa_signed =
|
||||
crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
|
||||
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
|
||||
if (p->xeddsa_signed) {
|
||||
// Mark this node as a signer so future unsigned packets from it are rejected
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
|
||||
} else {
|
||||
LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
}
|
||||
} else {
|
||||
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
|
||||
}
|
||||
} else {
|
||||
// Unsigned packet — only reject the class of packet a signing node always signs:
|
||||
// an unencrypted broadcast small enough to also carry a signature (see perhapsEncode()).
|
||||
// Unicast packets and oversized broadcasts are never signed, so they must not be
|
||||
// hard-failed here even if this node has signed before.
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && nodeInfoLiteHasXeddsaSigned(node) && isBroadcast(p->to) &&
|
||||
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Not actually ever used.
|
||||
// Decompress if needed. jm
|
||||
if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) {
|
||||
@@ -629,6 +661,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
p->decoded.has_bitfield = true;
|
||||
p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT);
|
||||
p->decoded.bitfield |= (p->decoded.want_response << BITFIELD_WANT_RESPONSE_SHIFT);
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
// Sign broadcast packets if payload + signature fits within the max Data payload.
|
||||
// The actual encoded size is checked after pb_encode (TOO_LARGE).
|
||||
if (!p->pki_encrypted && isBroadcast(p->to) &&
|
||||
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
|
||||
p->decoded.xeddsa_signature.bytes)) {
|
||||
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
|
||||
LOG_DEBUG("XEdDSA signed packet 0x%08x", p->id);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
size_t numbytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_Data_msg, &p->decoded);
|
||||
|
||||
@@ -35,6 +35,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory
|
||||
*/
|
||||
void addInterface(std::unique_ptr<RadioInterface> _iface) { iface = std::move(_iface); }
|
||||
|
||||
/**
|
||||
* Borrowed (non-owning) access to the radio interface — used by NodeDB
|
||||
* after a lockdown unlock so it can push the freshly-loaded config to
|
||||
* the SX12xx via reconfigure(). Returns nullptr when no radio has been
|
||||
* attached (e.g. ARCH_PORTDUINO simulator before SimRadio bind).
|
||||
*/
|
||||
RadioInterface *getRadioIface() { return iface.get(); }
|
||||
|
||||
/**
|
||||
* do idle processing
|
||||
* Mostly looking in our incoming rxPacket queue and calling handleReceived.
|
||||
|
||||
@@ -19,6 +19,9 @@ template <class T> class SX128xInterface : public RadioLibInterface
|
||||
|
||||
virtual bool wideLora() override;
|
||||
|
||||
/// SX128x is a 2.4 GHz-only chip; it cannot tune sub-GHz regions
|
||||
virtual bool supportsSubGhz() override { return false; }
|
||||
|
||||
/// Apply any radio provisioning changes
|
||||
/// Make sure the Driver is properly configured before calling init().
|
||||
/// \return true if initialisation succeeded.
|
||||
|
||||
@@ -18,6 +18,7 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo
|
||||
info.is_ignored = nodeInfoLiteIsIgnored(lite);
|
||||
info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite);
|
||||
info.is_muted = nodeInfoLiteIsMuted(lite);
|
||||
info.has_xeddsa_signed = nodeInfoLiteHasXeddsaSigned(lite);
|
||||
|
||||
if (lite->has_hops_away) {
|
||||
info.has_hops_away = true;
|
||||
|
||||
@@ -234,6 +234,9 @@ typedef struct _meshtastic_HamParameters {
|
||||
float frequency;
|
||||
/* Optional short name of user */
|
||||
char short_name[5];
|
||||
/* Optional long name of user
|
||||
Appended to callsign */
|
||||
char long_name[15];
|
||||
} meshtastic_HamParameters;
|
||||
|
||||
/* Response envelope for node_remote_hardware_pins */
|
||||
@@ -544,7 +547,7 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_default {"", 0, 0, ""}
|
||||
#define meshtastic_HamParameters_init_default {"", 0, 0, "", ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||
#define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0}
|
||||
#define meshtastic_KeyVerificationAdmin_init_default {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0}
|
||||
@@ -557,7 +560,7 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, ""}
|
||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, "", ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||
#define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0}
|
||||
#define meshtastic_KeyVerificationAdmin_init_zero {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0}
|
||||
@@ -584,6 +587,7 @@ extern "C" {
|
||||
#define meshtastic_HamParameters_tx_power_tag 2
|
||||
#define meshtastic_HamParameters_frequency_tag 3
|
||||
#define meshtastic_HamParameters_short_name_tag 4
|
||||
#define meshtastic_HamParameters_long_name_tag 5
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_node_remote_hardware_pins_tag 1
|
||||
#define meshtastic_SharedContact_node_num_tag 1
|
||||
#define meshtastic_SharedContact_user_tag 2
|
||||
@@ -786,7 +790,8 @@ X(a, STATIC, SINGULAR, BOOL, disable, 6)
|
||||
X(a, STATIC, SINGULAR, STRING, call_sign, 1) \
|
||||
X(a, STATIC, SINGULAR, INT32, tx_power, 2) \
|
||||
X(a, STATIC, SINGULAR, FLOAT, frequency, 3) \
|
||||
X(a, STATIC, SINGULAR, STRING, short_name, 4)
|
||||
X(a, STATIC, SINGULAR, STRING, short_name, 4) \
|
||||
X(a, STATIC, SINGULAR, STRING, long_name, 5)
|
||||
#define meshtastic_HamParameters_CALLBACK NULL
|
||||
#define meshtastic_HamParameters_DEFAULT NULL
|
||||
|
||||
@@ -891,7 +896,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg;
|
||||
#define meshtastic_AdminMessage_InputEvent_size 14
|
||||
#define meshtastic_AdminMessage_OTAEvent_size 36
|
||||
#define meshtastic_AdminMessage_size 511
|
||||
#define meshtastic_HamParameters_size 31
|
||||
#define meshtastic_HamParameters_size 47
|
||||
#define meshtastic_KeyVerificationAdmin_size 25
|
||||
#define meshtastic_LockdownAuth_size 56
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_size 496
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
typedef struct _meshtastic_DeviceProfile {
|
||||
/* Long name for the node */
|
||||
bool has_long_name;
|
||||
char long_name[40];
|
||||
char long_name[25];
|
||||
/* Short name of the node */
|
||||
bool has_short_name;
|
||||
char short_name[5];
|
||||
|
||||
@@ -69,8 +69,8 @@ typedef PB_BYTES_ARRAY_T(32) meshtastic_NodeInfoLite_public_key_t;
|
||||
typedef struct _meshtastic_NodeInfoLite {
|
||||
/* The node number */
|
||||
uint32_t num;
|
||||
/* Returns the Signal-to-noise ratio (SNR) of the last received message,
|
||||
as measured by the receiver. Return SNR of the last received message in dB */
|
||||
/* In-memory SNR of the last received message in dB. Not serialised directly:
|
||||
always zeroed before encode; persisted as snr_q4 = 19 below. */
|
||||
float snr;
|
||||
/* Set to indicate the last time we received a packet from this node */
|
||||
uint32_t last_heard;
|
||||
@@ -94,6 +94,10 @@ typedef struct _meshtastic_NodeInfoLite {
|
||||
meshtastic_Config_DeviceConfig_Role role;
|
||||
/* The public key of the user's device, for PKI-based encrypted DMs. */
|
||||
meshtastic_NodeInfoLite_public_key_t public_key;
|
||||
/* Q4-encoded SNR: dB × 4, sint32 zigzag. Matches RouteDiscovery convention.
|
||||
Encode: snr_q4 = (int32_t)(snr * 4.0f). Decode: snr = snr_q4 / 4.0f.
|
||||
float snr is always zeroed on disk; this field carries all persisted SNR. */
|
||||
int32_t snr_q4;
|
||||
} meshtastic_NodeInfoLite;
|
||||
|
||||
/* This message is never sent over the wire, but it is used for serializing DB
|
||||
@@ -215,7 +219,7 @@ extern "C" {
|
||||
/* Initializer values for message structs */
|
||||
#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0}
|
||||
#define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0}
|
||||
#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}}
|
||||
#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, 0}
|
||||
#define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||
#define meshtastic_NodePositionEntry_init_default {0, false, meshtastic_PositionLite_init_default}
|
||||
#define meshtastic_NodeTelemetryEntry_init_default {0, false, meshtastic_DeviceMetrics_init_default}
|
||||
@@ -226,7 +230,7 @@ extern "C" {
|
||||
#define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default}
|
||||
#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0}
|
||||
#define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0}
|
||||
#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}}
|
||||
#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, 0}
|
||||
#define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||
#define meshtastic_NodePositionEntry_init_zero {0, false, meshtastic_PositionLite_init_zero}
|
||||
#define meshtastic_NodeTelemetryEntry_init_zero {0, false, meshtastic_DeviceMetrics_init_zero}
|
||||
@@ -263,6 +267,7 @@ extern "C" {
|
||||
#define meshtastic_NodeInfoLite_hw_model_tag 16
|
||||
#define meshtastic_NodeInfoLite_role_tag 17
|
||||
#define meshtastic_NodeInfoLite_public_key_tag 18
|
||||
#define meshtastic_NodeInfoLite_snr_q4_tag 19
|
||||
#define meshtastic_DeviceState_my_node_tag 2
|
||||
#define meshtastic_DeviceState_owner_tag 3
|
||||
#define meshtastic_DeviceState_receive_queue_tag 5
|
||||
@@ -330,7 +335,8 @@ X(a, STATIC, SINGULAR, STRING, long_name, 14) \
|
||||
X(a, STATIC, SINGULAR, STRING, short_name, 15) \
|
||||
X(a, STATIC, SINGULAR, UENUM, hw_model, 16) \
|
||||
X(a, STATIC, SINGULAR, UENUM, role, 17) \
|
||||
X(a, STATIC, SINGULAR, BYTES, public_key, 18)
|
||||
X(a, STATIC, SINGULAR, BYTES, public_key, 18) \
|
||||
X(a, STATIC, SINGULAR, SINT32, snr_q4, 19)
|
||||
#define meshtastic_NodeInfoLite_CALLBACK NULL
|
||||
#define meshtastic_NodeInfoLite_DEFAULT NULL
|
||||
|
||||
@@ -450,7 +456,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
|
||||
#define meshtastic_ChannelFile_size 718
|
||||
#define meshtastic_DeviceState_size 1944
|
||||
#define meshtastic_NodeEnvironmentEntry_size 170
|
||||
#define meshtastic_NodeInfoLite_size 105
|
||||
#define meshtastic_NodeInfoLite_size 112
|
||||
#define meshtastic_NodePositionEntry_size 42
|
||||
#define meshtastic_NodeStatusEntry_size 89
|
||||
#define meshtastic_NodeTelemetryEntry_size 35
|
||||
|
||||
@@ -325,6 +325,14 @@ typedef enum _meshtastic_HardwareModel {
|
||||
meshtastic_HardwareModel_T_IMPULSE_PLUS = 135,
|
||||
/* Lilygo T-Echo Card */
|
||||
meshtastic_HardwareModel_T_ECHO_CARD = 136,
|
||||
/* Seeed Tracker L2 */
|
||||
meshtastic_HardwareModel_SEEED_WIO_TRACKER_L2 = 137,
|
||||
/* Elecrow CrowPanel Advance P4 models, ESP32-P4 and TFT with SX1262 radio plugin */
|
||||
meshtastic_HardwareModel_CROWPANEL_P4 = 138,
|
||||
/* Heltec Mesh Tower V2 */
|
||||
meshtastic_HardwareModel_HELTEC_MESH_TOWER_V2 = 139,
|
||||
/* Meshnology W10 */
|
||||
meshtastic_HardwareModel_MESHNOLOGY_W10 = 140,
|
||||
/* ------------------------------------------------------------------------------------------------------------------------------------------
|
||||
Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits.
|
||||
------------------------------------------------------------------------------------------------------------------------------------------ */
|
||||
@@ -774,7 +782,10 @@ typedef struct _meshtastic_User {
|
||||
Note: app developers are encouraged to also use the following standard
|
||||
node IDs "^all" (for broadcast), "^local" (for the locally connected node) */
|
||||
char id[16];
|
||||
/* A full name for this user, i.e. "Kevin Hester" */
|
||||
/* A full name for this user, i.e. "Kevin Hester"
|
||||
Limited to 24 bytes of UTF-8: longer names are accepted from senders
|
||||
built against the older 39-byte limit, but devices truncate them before
|
||||
storing or rebroadcasting. Clients should enforce 24 bytes in their UI. */
|
||||
char long_name[40];
|
||||
/* A VERY short name, ideally two characters.
|
||||
Suitable for a tiny OLED screen */
|
||||
|
||||
@@ -27,7 +27,7 @@ typedef struct _meshtastic_ServiceEnvelope {
|
||||
/* Information about a node intended to be reported unencrypted to a map using MQTT. */
|
||||
typedef struct _meshtastic_MapReport {
|
||||
/* A full name for this user, i.e. "Kevin Hester" */
|
||||
char long_name[40];
|
||||
char long_name[25];
|
||||
/* A VERY short name, ideally two characters.
|
||||
Suitable for a tiny OLED screen */
|
||||
char short_name[5];
|
||||
@@ -126,7 +126,7 @@ extern const pb_msgdesc_t meshtastic_MapReport_msg;
|
||||
/* Maximum encoded size of messages (where known) */
|
||||
/* meshtastic_ServiceEnvelope_size depends on runtime parameters */
|
||||
#define MESHTASTIC_MESHTASTIC_MQTT_PB_H_MAX_SIZE meshtastic_MapReport_size
|
||||
#define meshtastic_MapReport_size 110
|
||||
#define meshtastic_MapReport_size 95
|
||||
|
||||
#ifdef __cplusplus
|
||||
} /* extern "C" */
|
||||
|
||||
@@ -573,11 +573,14 @@ static void WiFiEvent(WiFiEvent_t event)
|
||||
#endif
|
||||
break;
|
||||
case ARDUINO_EVENT_ETH_GOT_IP6:
|
||||
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||
#if defined(USE_CH390D)
|
||||
// The CH390 driver's ETH class doesn't expose the IPv6 address getters
|
||||
LOG_INFO("Obtained IP6 address");
|
||||
#elif defined(USE_WS5500)
|
||||
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)
|
||||
LOG_INFO("Obtained Local IP6 address: %s", ETH.linkLocalIPv6().toString().c_str());
|
||||
LOG_INFO("Obtained GlobalIP6 address: %s", ETH.globalIPv6().toString().c_str());
|
||||
#elif defined(USE_WS5500)
|
||||
#else
|
||||
LOG_INFO("Obtained IP6 address: %s", ETH.localIPv6().toString().c_str());
|
||||
#endif
|
||||
#endif
|
||||
|
||||
+7
-1
@@ -206,4 +206,10 @@ bool sanitizeUtf8(char *buf, size_t bufSize)
|
||||
}
|
||||
|
||||
return replaced;
|
||||
}
|
||||
}
|
||||
|
||||
void clampLongName(char *longName)
|
||||
{
|
||||
longName[MAX_LONG_NAME_BYTES] = '\0';
|
||||
sanitizeUtf8(longName, MAX_LONG_NAME_BYTES + 1);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,17 @@ size_t pb_string_length(const char *str, size_t max_len);
|
||||
// Ensures the result is null-terminated within bufSize. Returns true if any bytes were replaced.
|
||||
bool sanitizeUtf8(char *buf, size_t bufSize);
|
||||
|
||||
// Longest User.long_name content (bytes, excluding NUL) we store or transmit.
|
||||
// The wire decode buffer stays at 40 so names from senders built against the
|
||||
// older 39-byte limit still parse; everything we keep or send is clamped to
|
||||
// this, matching the slim NodeInfoLite storage width in deviceonly.proto.
|
||||
#define MAX_LONG_NAME_BYTES 24
|
||||
|
||||
// Clamp a long_name buffer (at least MAX_LONG_NAME_BYTES + 1 bytes) in-place
|
||||
// to MAX_LONG_NAME_BYTES bytes of content, fixing any partial UTF-8 sequence
|
||||
// left at the cut.
|
||||
void clampLongName(char *longName);
|
||||
|
||||
/// Calculate 2^n without calling pow() - used for spreading factor and other calculations
|
||||
inline uint32_t pow_of_2(uint32_t n)
|
||||
{
|
||||
|
||||
+147
-26
@@ -2,6 +2,7 @@
|
||||
#include "Channels.h"
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PositionPrecision.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RTC.h"
|
||||
#include "SPILock.h"
|
||||
@@ -19,6 +20,7 @@
|
||||
#include "main.h"
|
||||
#endif
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#include "PortduinoGlue.h"
|
||||
#include "unistd.h"
|
||||
#endif
|
||||
|
||||
@@ -27,6 +29,12 @@
|
||||
#include "RadioInterface.h"
|
||||
#include "TypeConversions.h"
|
||||
#include "mesh/RadioLibInterface.h"
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
#include "mesh/PhoneAPI.h"
|
||||
#endif
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
@@ -76,20 +84,69 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
// if handled == false, then let others look at this message also if they want
|
||||
bool handled = false;
|
||||
assert(r);
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// While storage is locked, drop every admin payload — both local and
|
||||
// remote (PKC, mesh-relayed). Lockdown unlock is the prerequisite for
|
||||
// any admin operation: operators must authenticate via lockdown_auth
|
||||
// first. The lockdown_auth path itself is handled synchronously in
|
||||
// PhoneAPI::handleToRadioPacket before reaching here, so the real
|
||||
// unlock flow is not affected by this gate. Without this, a remote
|
||||
// PKC-authorized peer (or a USERPREFS-baked admin_key) could drive
|
||||
// factory_reset / set_config against a locked device before the
|
||||
// operator has even unlocked it.
|
||||
// Only gate when lockdown is ACTIVE. A lockdown-capable build that hasn't
|
||||
// been provisioned (or was disabled) is not unlocked either, but must
|
||||
// still serve admin normally — so check isLockdownActive() first.
|
||||
if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
|
||||
LOG_WARN("AdminModule: dropping admin payload — storage locked");
|
||||
return handled;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool fromOthers = !isFromUs(&mp);
|
||||
if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
|
||||
return handled;
|
||||
}
|
||||
#ifdef ARCH_PORTDUINO
|
||||
// Simulator only: honor exit_simulator unconditionally for the local client (from==0).
|
||||
// The from==0 branch below now covers pki_encrypted local packets too, but is_managed
|
||||
// can still block it. Rather than threading simulator awareness through the auth gates,
|
||||
// intercept here before any auth logic runs. Local-origin + force_simradio only.
|
||||
// TODO: should a local client bypass admin auth at all? Fenced to the simulator for now.
|
||||
if (portduino_config.force_simradio && mp.from == 0 &&
|
||||
r->which_payload_variant == meshtastic_AdminMessage_exit_simulator_tag) {
|
||||
LOG_INFO("Exiting simulator");
|
||||
exit(0);
|
||||
}
|
||||
#endif
|
||||
meshtastic_Channel *ch = &channels.getByIndex(mp.channel);
|
||||
// Could tighten this up further by tracking the last public_key we went an AdminMessage request to
|
||||
// and only allowing responses from that remote.
|
||||
if (messageIsResponse(r)) {
|
||||
LOG_DEBUG("Allow admin response message");
|
||||
} else if (mp.from == 0) {
|
||||
// Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the
|
||||
// mesh: RF drops packets without a sender (RadioLibInterface) and MQTT treats
|
||||
// from == 0 as our own downlink and ignores it. Clients may set pki_encrypted
|
||||
// on self-addressed admin (the python CLI does), so don't use it to reroute
|
||||
// local packets into the remote-PKC key check.
|
||||
//
|
||||
// Under MESHTASTIC_PHONEAPI_ACCESS_CONTROL, the per-connection auth
|
||||
// gate lives in PhoneAPI::handleToRadioPacket — any local admin
|
||||
// payload other than lockdown_auth is dropped there if the
|
||||
// originating connection is unauthorized. By the time we reach
|
||||
// this branch the connection has already proven the passphrase,
|
||||
// so is_managed needs no additional gate here.
|
||||
//
|
||||
// Without that build flag the legacy is_managed semantics still
|
||||
// apply: refuse all plain local admin and require PKC instead.
|
||||
#ifndef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (config.security.is_managed) {
|
||||
LOG_INFO("Ignore local admin payload because is_managed");
|
||||
return handled;
|
||||
}
|
||||
#endif
|
||||
} else if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) {
|
||||
if (!config.security.admin_channel_enabled) {
|
||||
LOG_INFO("Ignore admin channel, legacy admin is disabled");
|
||||
@@ -105,6 +162,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
memcmp(mp.public_key.bytes, config.security.admin_key[2].bytes, 32) == 0)) {
|
||||
LOG_INFO("PKC admin payload with authorized sender key");
|
||||
|
||||
// Note: PKC admin does NOT automatically authorize the
|
||||
// originating local PhoneAPI connection for content
|
||||
// redaction purposes. PKC and the per-connection lockdown
|
||||
// auth slot are independent gates — operators using PKC
|
||||
// admin from a local app should still send lockdown_auth
|
||||
// separately to unlock the redacted FromRadio stream.
|
||||
// (The previous auto-authorize path read a shared
|
||||
// g_currentContext set during synchronous PhoneAPI
|
||||
// dispatch; by the time this Router-thread handler runs
|
||||
// that pointer is unrelated, so the path was unsafe.)
|
||||
|
||||
// Automatically favorite the node that is using the admin key
|
||||
auto remoteNode = nodeDB->getMeshNode(mp.from);
|
||||
if (remoteNode && !nodeInfoLiteIsFavorite(remoteNode)) {
|
||||
@@ -141,6 +209,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
}
|
||||
switch (r->which_payload_variant) {
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// lockdown_auth is handled synchronously in
|
||||
// PhoneAPI::handleToRadioPacket — see handleLockdownAuthInline. A
|
||||
// packet should not normally reach AdminModule under that flag set,
|
||||
// but if it ever does (e.g. injected via a non-PhoneAPI path), drop
|
||||
// it silently rather than leaking a partial response.
|
||||
case meshtastic_AdminMessage_lockdown_auth_tag:
|
||||
LOG_WARN("AdminModule: lockdown_auth reached Router/AdminModule path; ignoring (should be handled in PhoneAPI)");
|
||||
return handled;
|
||||
#endif // MESHTASTIC_ENCRYPTED_STORAGE
|
||||
|
||||
/**
|
||||
* Getters
|
||||
*/
|
||||
@@ -481,7 +560,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
#if HAS_SCREEN
|
||||
IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0));
|
||||
#endif
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32WL)
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32)
|
||||
enterDfuMode();
|
||||
#endif
|
||||
break;
|
||||
@@ -625,10 +704,15 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
|
||||
int changed = 0;
|
||||
|
||||
if (*o.long_name) {
|
||||
changed |= strcmp(owner.long_name, o.long_name);
|
||||
strncpy(owner.long_name, o.long_name, sizeof(owner.long_name));
|
||||
// Apps built against the older 39-byte limit may send longer names; clamp
|
||||
// before the changed-compare so re-sending the same long name is a no-op.
|
||||
char longName[sizeof(o.long_name)];
|
||||
strncpy(longName, o.long_name, sizeof(longName));
|
||||
longName[sizeof(longName) - 1] = '\0';
|
||||
clampLongName(longName);
|
||||
changed |= strcmp(owner.long_name, longName);
|
||||
strncpy(owner.long_name, longName, sizeof(owner.long_name));
|
||||
owner.long_name[sizeof(owner.long_name) - 1] = '\0';
|
||||
sanitizeUtf8(owner.long_name, sizeof(owner.long_name));
|
||||
}
|
||||
if (*o.short_name) {
|
||||
changed |= strcmp(owner.short_name, o.short_name);
|
||||
@@ -829,7 +913,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
}
|
||||
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
||||
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
|
||||
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
|
||||
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
|
||||
}
|
||||
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
|
||||
} else {
|
||||
@@ -840,13 +924,39 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
|
||||
if (!RadioInterface::validateConfigLora(validatedLora)) {
|
||||
if (fromOthers) {
|
||||
LOG_WARN("Invalid LoRa config received from another node, rejecting changes");
|
||||
// modem_preset set to use the old setting if the check fails
|
||||
validatedLora.modem_preset = oldLoraConfig.modem_preset;
|
||||
// A preset locked to a sibling EU region still swaps the region for remote admin;
|
||||
// any other invalid config is rejected outright.
|
||||
const RegionInfo *swapRegion =
|
||||
validatedLora.use_preset
|
||||
? RadioInterface::regionSwapForPreset(validatedLora.region, validatedLora.modem_preset)
|
||||
: NULL;
|
||||
if (swapRegion) {
|
||||
validatedLora.region = swapRegion->code;
|
||||
}
|
||||
if (!swapRegion || !RadioInterface::validateConfigLora(validatedLora)) {
|
||||
LOG_WARN("Invalid LoRa config received from another node, rejecting changes");
|
||||
// Rejecting means rejecting everything: a partial restore of region/preset
|
||||
// could still apply other fields the validation already deemed invalid.
|
||||
validatedLora = oldLoraConfig;
|
||||
}
|
||||
} else {
|
||||
LOG_WARN("Invalid LoRa config received from client, using corrected values");
|
||||
RadioInterface::clampConfigLora(validatedLora);
|
||||
}
|
||||
// A preset locked to a sibling EU region swaps the region during the clamp;
|
||||
// apply the same housekeeping as an explicit region change.
|
||||
if (validatedLora.region != oldLoraConfig.region) {
|
||||
config.lora.region = validatedLora.region;
|
||||
initRegion();
|
||||
if (getEffectiveDutyCycle() < 100) {
|
||||
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
|
||||
}
|
||||
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
|
||||
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
|
||||
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
|
||||
}
|
||||
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
|
||||
}
|
||||
// use_preset and bandwidth are coerced into valid values by the check.
|
||||
}
|
||||
|
||||
@@ -895,22 +1005,14 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
LOG_INFO("Set config: Security");
|
||||
config.security = c.payload_variant.security;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// If the client set the key to blank, go ahead and regenerate so long as we're not in ham mode
|
||||
if (!owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
if (config.security.private_key.size != 32) {
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
|
||||
} else {
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
config.security.public_key.size = 32;
|
||||
}
|
||||
}
|
||||
// Only regenerate keys if the private key is not 32 bytes
|
||||
if (config.security.private_key.size != 32) {
|
||||
nodeDB->generateCryptoKeyPair();
|
||||
}
|
||||
// If user provided a private key of correct size but no public key, generate the public key from private key
|
||||
else if (config.security.private_key.size == 32 && config.security.public_key.size == 0) {
|
||||
nodeDB->generateCryptoKeyPair(config.security.private_key.bytes);
|
||||
}
|
||||
#endif
|
||||
owner.public_key.size = config.security.public_key.size;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, config.security.public_key.size);
|
||||
#if !MESHTASTIC_EXCLUDE_PKI
|
||||
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
||||
#endif
|
||||
if (config.security.is_managed && !(config.security.admin_key[0].size == 32 || config.security.admin_key[1].size == 32 ||
|
||||
config.security.admin_key[2].size == 32)) {
|
||||
@@ -920,9 +1022,9 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
sendWarning(warning);
|
||||
}
|
||||
|
||||
if (config.security.debug_log_api_enabled == c.payload_variant.security.debug_log_api_enabled &&
|
||||
config.security.serial_enabled == c.payload_variant.security.serial_enabled)
|
||||
requiresReboot = false;
|
||||
changes = SEGMENT_CONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
||||
|
||||
requiresReboot = true;
|
||||
|
||||
break;
|
||||
case meshtastic_Config_device_ui_tag:
|
||||
@@ -1056,7 +1158,26 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc)
|
||||
if (channels.ensureLicensedOperation()) {
|
||||
sendWarning(licensedModeMessage);
|
||||
}
|
||||
// Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey()
|
||||
// resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex;
|
||||
// running the clamp first could evaluate secondaries against the previous primary and skip the clamp/warning.
|
||||
channels.onConfigChanged(); // tell the radios about this change
|
||||
|
||||
// Persist the public-key precision clamp for all channels that may be affected (e.g. secondaries
|
||||
// that inherit a now-public primary key) and warn the client once if anything was coarsened.
|
||||
bool clamped = false;
|
||||
for (uint8_t i = 0; i < channels.getNumChannels(); i++) {
|
||||
meshtastic_Channel &ch = channels.getByIndex(i);
|
||||
if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.settings.has_module_settings)
|
||||
continue;
|
||||
uint32_t allowed = getPositionPrecisionForChannel(i);
|
||||
if (allowed != ch.settings.module_settings.position_precision) {
|
||||
ch.settings.module_settings.position_precision = allowed;
|
||||
clamped = true;
|
||||
}
|
||||
}
|
||||
if (clamped)
|
||||
sendWarning(publicChannelPrecisionMessage);
|
||||
saveChanges(SEGMENT_CHANNELS, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
||||
static constexpr const char *licensedModeMessage =
|
||||
"Licensed mode activated, removing admin channel and encryption from all channels";
|
||||
|
||||
static constexpr const char *publicChannelPrecisionMessage =
|
||||
"Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision";
|
||||
|
||||
extern AdminModule *adminModule;
|
||||
|
||||
void disableBluetooth();
|
||||
@@ -49,6 +49,12 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
|
||||
LOG_WARN("Invalid nodeInfo detected, is_licensed mismatch!");
|
||||
return true;
|
||||
}
|
||||
NodeNum sourceNum = getFrom(&mp);
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum);
|
||||
if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed) {
|
||||
LOG_WARN("Dropping unsigned NodeInfo from node 0x%08x that previously signed", sourceNum);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Coerce user.id to be derived from the node number
|
||||
snprintf(p.id, sizeof(p.id), "!%08x", getFrom(&mp));
|
||||
@@ -158,8 +164,8 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply()
|
||||
ignoreRequest = true;
|
||||
return NULL;
|
||||
} else {
|
||||
ignoreRequest = false; // Don't ignore requests anymore
|
||||
meshtastic_User &u = owner;
|
||||
ignoreRequest = false; // Don't ignore requests anymore
|
||||
meshtastic_User u = owner; // deliberate copy: the licensed strip below must not clobber the global owner state
|
||||
|
||||
// Strip the public key if the user is licensed
|
||||
if (u.is_licensed && u.public_key.size > 0) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "StatusLEDModule.h"
|
||||
#include "MeshService.h"
|
||||
#include "configuration.h"
|
||||
#include "mesh/RadioInterface.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
/*
|
||||
@@ -17,6 +18,9 @@ StatusLEDModule::StatusLEDModule() : concurrency::OSThread("StatusLEDModule")
|
||||
if (inputBroker)
|
||||
inputObserver.observe(inputBroker);
|
||||
#endif
|
||||
#ifdef LED_LORA
|
||||
loraRxObserver.observe(&RadioInterface::loraRxPacketObservable);
|
||||
#endif
|
||||
#ifdef NEOPIXEL_STATUS_POWER_PIN
|
||||
powerPixel.begin();
|
||||
powerPixel.clear();
|
||||
@@ -90,6 +94,18 @@ int StatusLEDModule::handleInputEvent(const InputEvent *event)
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
#ifdef LED_LORA
|
||||
int StatusLEDModule::handleLoRaRx(uint32_t)
|
||||
{
|
||||
// Briefly flash LED_LORA on each received packet. Turn it on now (we share the main thread with
|
||||
// the radio's receive handler, so this is safe) and wake runOnce() at flash end to turn it off.
|
||||
digitalWrite(LED_LORA, LED_STATE_ON);
|
||||
LORA_LED_state = LED_STATE_ON;
|
||||
LORA_LED_starttime = millis();
|
||||
setIntervalFromNow(LORA_RX_LED_FLASH_MS);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
int32_t StatusLEDModule::runOnce()
|
||||
{
|
||||
@@ -227,6 +243,20 @@ int32_t StatusLEDModule::runOnce()
|
||||
digitalWrite(Battery_LED_4, chargeIndicatorLED4);
|
||||
#endif
|
||||
|
||||
#ifdef LED_LORA
|
||||
// End the LoRa-RX flash once its duration has elapsed; otherwise make sure we come back
|
||||
// exactly at flash end (only ever clamp my_interval down, so other LED timing is preserved).
|
||||
if (LORA_LED_state == LED_STATE_ON) {
|
||||
uint32_t elapsed = millis() - LORA_LED_starttime;
|
||||
if (elapsed >= LORA_RX_LED_FLASH_MS) {
|
||||
digitalWrite(LED_LORA, LED_STATE_OFF);
|
||||
LORA_LED_state = LED_STATE_OFF;
|
||||
} else if ((uint32_t)my_interval > LORA_RX_LED_FLASH_MS - elapsed) {
|
||||
my_interval = LORA_RX_LED_FLASH_MS - elapsed;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return (my_interval);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ class StatusLEDModule : private concurrency::OSThread
|
||||
#if !MESHTASTIC_EXCLUDE_INPUTBROKER
|
||||
int handleInputEvent(const InputEvent *arg);
|
||||
#endif
|
||||
#ifdef LED_LORA
|
||||
int handleLoRaRx(uint32_t sender);
|
||||
#endif
|
||||
|
||||
void setPowerLED(bool);
|
||||
|
||||
@@ -65,6 +68,10 @@ class StatusLEDModule : private concurrency::OSThread
|
||||
CallbackObserver<StatusLEDModule, const InputEvent *> inputObserver =
|
||||
CallbackObserver<StatusLEDModule, const InputEvent *>(this, &StatusLEDModule::handleInputEvent);
|
||||
#endif
|
||||
#ifdef LED_LORA
|
||||
CallbackObserver<StatusLEDModule, uint32_t> loraRxObserver =
|
||||
CallbackObserver<StatusLEDModule, uint32_t>(this, &StatusLEDModule::handleLoRaRx);
|
||||
#endif
|
||||
|
||||
private:
|
||||
bool CHARGE_LED_state = LED_STATE_OFF;
|
||||
@@ -77,6 +84,11 @@ class StatusLEDModule : private concurrency::OSThread
|
||||
uint32_t lastUserbuttonTime = 0;
|
||||
uint32_t POWER_LED_starttime = 0;
|
||||
bool doing_fast_blink = false;
|
||||
#ifdef LED_LORA
|
||||
static constexpr uint32_t LORA_RX_LED_FLASH_MS = 100;
|
||||
bool LORA_LED_state = LED_STATE_OFF;
|
||||
uint32_t LORA_LED_starttime = 0;
|
||||
#endif
|
||||
|
||||
enum PowerState { discharging, charging, charged, critical };
|
||||
|
||||
|
||||
@@ -428,20 +428,6 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
LOG_DEBUG("Start next execution in 5s, then sleep");
|
||||
setIntervalFromNow(FIVE_SECONDS_MS);
|
||||
}
|
||||
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR && config.power.is_power_saving) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
sleepOnNextExecution = true;
|
||||
LOG_DEBUG("Start next execution in 5s, then sleep");
|
||||
setIntervalFromNow(FIVE_SECONDS_MS);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
#endif
|
||||
#include "BMM150Sensor.h"
|
||||
#include "BMX160Sensor.h"
|
||||
#include "ICM42607PSensor.h"
|
||||
#include "ICM20948Sensor.h"
|
||||
#include "ICM42607PSensor.h"
|
||||
#include "LIS3DHSensor.h"
|
||||
#include "LSM6DS3Sensor.h"
|
||||
#include "MPU6050Sensor.h"
|
||||
@@ -92,32 +92,44 @@ class AccelerometerThread : public concurrency::OSThread
|
||||
sensor = new BMA423Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<Adafruit_MPU6050.h>)
|
||||
case ScanI2C::DeviceType::MPU6050:
|
||||
sensor = new MPU6050Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
case ScanI2C::DeviceType::BMX160:
|
||||
sensor = new BMX160Sensor(device);
|
||||
break;
|
||||
#if __has_include(<Adafruit_LIS3DH.h>)
|
||||
case ScanI2C::DeviceType::LIS3DH:
|
||||
sensor = new LIS3DHSensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<Adafruit_LSM6DS3TRC.h>)
|
||||
case ScanI2C::DeviceType::LSM6DS3:
|
||||
sensor = new LSM6DS3Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
#ifdef HAS_STK8XXX
|
||||
case ScanI2C::DeviceType::STK8BAXX:
|
||||
sensor = new STK8XXXSensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<ICM_20948.h>)
|
||||
case ScanI2C::DeviceType::ICM20948:
|
||||
sensor = new ICM20948Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<ICM42670P.h>)
|
||||
case ScanI2C::DeviceType::ICM42607P:
|
||||
sensor = new ICM42607PSensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<DFRobot_BMM150.h>)
|
||||
case ScanI2C::DeviceType::BMM150:
|
||||
sensor = new BMM150Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
#ifdef HAS_BMI270
|
||||
case ScanI2C::DeviceType::BMI270:
|
||||
sensor = new BMI270Sensor(device);
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
|
||||
#include "detect/ScanI2CTwoWire.h"
|
||||
#include <ICM42670P.h>
|
||||
#include <math.h>
|
||||
|
||||
static constexpr uint16_t ICM42607P_ACCEL_ODR_HZ = 50;
|
||||
static constexpr uint16_t ICM42607P_ACCEL_FSR_G = 2;
|
||||
static constexpr float ICM42607P_COUNTS_PER_G = 32768.0f / ICM42607P_ACCEL_FSR_G;
|
||||
static constexpr float ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE =
|
||||
#ifdef ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG
|
||||
ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG;
|
||||
#else
|
||||
0.0f;
|
||||
#endif
|
||||
|
||||
#ifdef ICM_42607P_INT_PIN
|
||||
volatile static bool ICM42607P_IRQ = false;
|
||||
@@ -18,10 +24,7 @@ void ICM42607PSetInterrupt()
|
||||
}
|
||||
#endif
|
||||
|
||||
ICM42607PSensor::ICM42607PSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice)
|
||||
{
|
||||
wire = ScanI2CTwoWire::fetchI2CBus(foundDevice.address);
|
||||
}
|
||||
ICM42607PSensor::ICM42607PSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {}
|
||||
|
||||
ICM42607PSensor::~ICM42607PSensor() = default;
|
||||
|
||||
@@ -30,6 +33,7 @@ bool ICM42607PSensor::init()
|
||||
bool addressLsb = deviceAddress() == ICM42607P_ADDR_ALT;
|
||||
|
||||
LOG_DEBUG("ICM-42607-P begin on addr 0x%02X (port=%d)", deviceAddress(), devicePort());
|
||||
TwoWire *wire = ScanI2CTwoWire::fetchI2CBus(device.address);
|
||||
sensor.reset();
|
||||
auto newSensor = std::make_unique<ICM42670>(*wire, addressLsb);
|
||||
|
||||
@@ -82,8 +86,22 @@ int32_t ICM42607PSensor::runOnce()
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
// LOG_DEBUG("ICM-42607-P accel read x=%.3fg y=%.3fg z=%.3fg", (float)event.accel[0] / ICM42607P_COUNTS_PER_G,
|
||||
// (float)event.accel[1] / ICM42607P_COUNTS_PER_G, (float)event.accel[2] / ICM42607P_COUNTS_PER_G);
|
||||
float ax = static_cast<float>(event.accel[0]);
|
||||
float ay = static_cast<float>(event.accel[1]);
|
||||
const float az = static_cast<float>(event.accel[2]);
|
||||
|
||||
if (ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE != 0.0f) {
|
||||
static const float rotRad = ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE * DEG_TO_RAD;
|
||||
static const float cosTheta = cosf(rotRad);
|
||||
static const float sinTheta = sinf(rotRad);
|
||||
const float rotatedX = (ax * cosTheta) - (ay * sinTheta);
|
||||
const float rotatedY = (ax * sinTheta) + (ay * cosTheta);
|
||||
ax = rotatedX;
|
||||
ay = rotatedY;
|
||||
}
|
||||
|
||||
// Match the accel sign convention used by other FusionCompass sensor paths.
|
||||
publishCompassAccelSample(ax, -ay, -az);
|
||||
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
#endif
|
||||
|
||||
@@ -14,7 +14,6 @@ class ICM42607PSensor : public MotionSensor
|
||||
{
|
||||
private:
|
||||
std::unique_ptr<ICM42670> sensor;
|
||||
TwoWire *wire = nullptr;
|
||||
|
||||
public:
|
||||
explicit ICM42607PSensor(ScanI2C::FoundDevice foundDevice);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include(<SparkFun_MMC5983MA_Arduino_Library.h>)
|
||||
|
||||
#include "Fusion/Fusion.h"
|
||||
#include "detect/ScanI2CTwoWire.h"
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
@@ -10,8 +11,11 @@ extern graphics::Screen *screen;
|
||||
|
||||
static constexpr float MMC5983MA_ZERO_FIELD = 131072.0f;
|
||||
static constexpr float MMC5983MA_COUNTS_PER_GAUSS = 16384.0f;
|
||||
static constexpr uint16_t MMC5983MA_CONTINUOUS_FREQUENCY_HZ = 10;
|
||||
static constexpr uint16_t MMC5983MA_CONTINUOUS_FREQUENCY_HZ = 50;
|
||||
static constexpr int32_t MMC5983MA_UPDATE_INTERVAL_MS = 20;
|
||||
static constexpr float MMC5983MA_HEADING_OFFSET_DEG = 180.0f;
|
||||
static constexpr uint32_t MMC5983MA_ACCEL_STALE_MS = 300;
|
||||
static constexpr float MMC5983MA_MIN_AXIS_RADIUS = 1e-4f;
|
||||
|
||||
MMC5983MASensor::MMC5983MASensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {}
|
||||
|
||||
@@ -59,40 +63,68 @@ bool MMC5983MASensor::readMagnetometer(float &xGauss, float &yGauss, float &zGau
|
||||
}
|
||||
|
||||
int32_t MMC5983MASensor::runOnce()
|
||||
{
|
||||
float magX = 0, magY = 0, magZ = 0;
|
||||
if (!readMagnetometer(magX, magY, magZ)) {
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
{
|
||||
float magX = 0, magY = 0, magZ = 0;
|
||||
if (!readMagnetometer(magX, magY, magZ)) {
|
||||
return MMC5983MA_UPDATE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
if (doCalibration) {
|
||||
beginCalibrationDisplay(showingScreen);
|
||||
updateCalibrationExtrema(magX, magY, magZ, highestX, lowestX, highestY, lowestY, highestZ, lowestZ);
|
||||
finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ,
|
||||
lowestZ);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Hard-iron bias removal.
|
||||
magX -= (highestX + lowestX) * 0.5f;
|
||||
magY -= (highestY + lowestY) * 0.5f;
|
||||
magZ -= (highestZ + lowestZ) * 0.5f;
|
||||
|
||||
// Soft-iron diagonal scaling from calibration extrema.
|
||||
const float radiusX = (highestX - lowestX) * 0.5f;
|
||||
const float radiusY = (highestY - lowestY) * 0.5f;
|
||||
const float radiusZ = (highestZ - lowestZ) * 0.5f;
|
||||
const float avgRadius = (radiusX + radiusY + radiusZ) / 3.0f;
|
||||
magX *= (radiusX > MMC5983MA_MIN_AXIS_RADIUS) ? (avgRadius / radiusX) : 1.0f;
|
||||
magY *= (radiusY > MMC5983MA_MIN_AXIS_RADIUS) ? (avgRadius / radiusY) : 1.0f;
|
||||
magZ *= (radiusZ > MMC5983MA_MIN_AXIS_RADIUS) ? (avgRadius / radiusZ) : 1.0f;
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN
|
||||
float heading;
|
||||
float accelX = 0.0f;
|
||||
float accelY = 0.0f;
|
||||
float accelZ = 0.0f;
|
||||
uint32_t accelAgeMs = 0;
|
||||
|
||||
if (getLatestCompassAccelSample(accelX, accelY, accelZ, accelAgeMs) && accelAgeMs <= MMC5983MA_ACCEL_STALE_MS) {
|
||||
FusionVector ga = {.axis = {accelX, accelY, accelZ}};
|
||||
FusionVector ma = {.axis = {magX, magY, magZ}};
|
||||
if (config.display.compass_orientation > meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270) {
|
||||
ma = FusionAxesSwap(ma, FusionAxesAlignmentNXNYPZ);
|
||||
ga = FusionAxesSwap(ga, FusionAxesAlignmentNXNYPZ);
|
||||
}
|
||||
heading = FusionCompassCalculateHeading(FusionConventionNed, ga, ma) + MMC5983MA_HEADING_OFFSET_DEG;
|
||||
} else {
|
||||
heading = atan2f(magY, magX) * RAD_TO_DEG + MMC5983MA_HEADING_OFFSET_DEG;
|
||||
}
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
if (doCalibration) {
|
||||
beginCalibrationDisplay(showingScreen);
|
||||
updateCalibrationExtrema(magX, magY, magZ, highestX, lowestX, highestY, lowestY, highestZ, lowestZ);
|
||||
finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ,
|
||||
lowestZ);
|
||||
}
|
||||
#endif
|
||||
if (heading >= 360.0f)
|
||||
heading -= 360.0f;
|
||||
else if (heading < 0.0f)
|
||||
heading += 360.0f;
|
||||
heading = 360.0f - heading;
|
||||
if (heading >= 360.0f)
|
||||
heading -= 360.0f;
|
||||
|
||||
magX -= (highestX + lowestX) / 2;
|
||||
magY -= (highestY + lowestY) / 2;
|
||||
magZ -= (highestZ + lowestZ) / 2;
|
||||
heading = applyCompassOrientation(heading);
|
||||
if (screen)
|
||||
screen->setHeading(heading);
|
||||
#endif
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN
|
||||
float heading = atan2f(magY, magX) * RAD_TO_DEG + MMC5983MA_HEADING_OFFSET_DEG;
|
||||
if (heading < 0.0f) {
|
||||
heading += 360.0f;
|
||||
} else if (heading >= 360.0f) {
|
||||
heading -= 360.0f;
|
||||
}
|
||||
|
||||
heading = applyCompassOrientation(heading);
|
||||
if (screen) {
|
||||
screen->setHeading(heading);
|
||||
}
|
||||
#endif
|
||||
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
return MMC5983MA_UPDATE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
void MMC5983MASensor::calibrate(uint16_t forSeconds)
|
||||
|
||||
@@ -47,9 +47,8 @@ class MagnetometerThread : public concurrency::OSThread
|
||||
{
|
||||
canSleep = true;
|
||||
|
||||
if (isInitialised) {
|
||||
if (isInitialised)
|
||||
return sensor->runOnce();
|
||||
}
|
||||
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "FSCommon.h"
|
||||
#include "SPILock.h"
|
||||
#include "SafeFile.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
#include "graphics/draw/CompassRenderer.h"
|
||||
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C
|
||||
@@ -30,6 +31,17 @@ bool isRangeValid(float highest, float lowest)
|
||||
// NaN/Inf guard without pulling in extra math helpers.
|
||||
return (highest == highest) && (lowest == lowest) && (highest > lowest);
|
||||
}
|
||||
|
||||
struct CompassAccelSample {
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
float z = 0.0f;
|
||||
uint32_t sampledAtMs = 0;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
concurrency::Lock latestCompassAccelLock;
|
||||
CompassAccelSample latestCompassAccelSample;
|
||||
} // namespace
|
||||
|
||||
// screen is defined in main.cpp
|
||||
@@ -204,6 +216,35 @@ float MotionSensor::applyCompassOrientation(float heading)
|
||||
}
|
||||
}
|
||||
|
||||
void MotionSensor::publishCompassAccelSample(float x, float y, float z)
|
||||
{
|
||||
concurrency::LockGuard guard(&latestCompassAccelLock);
|
||||
latestCompassAccelSample.x = x;
|
||||
latestCompassAccelSample.y = y;
|
||||
latestCompassAccelSample.z = z;
|
||||
latestCompassAccelSample.sampledAtMs = millis();
|
||||
latestCompassAccelSample.valid = true;
|
||||
}
|
||||
|
||||
bool MotionSensor::getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs)
|
||||
{
|
||||
uint32_t sampledAtMs = 0;
|
||||
{
|
||||
concurrency::LockGuard guard(&latestCompassAccelLock);
|
||||
if (!latestCompassAccelSample.valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
x = latestCompassAccelSample.x;
|
||||
y = latestCompassAccelSample.y;
|
||||
z = latestCompassAccelSample.z;
|
||||
sampledAtMs = latestCompassAccelSample.sampledAtMs;
|
||||
}
|
||||
|
||||
ageMs = millis() - sampledAtMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN
|
||||
void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
|
||||
@@ -67,6 +67,8 @@ class MotionSensor
|
||||
static void updateCalibrationExtrema(float x, float y, float z, float &highestX, float &lowestX, float &highestY,
|
||||
float &lowestY, float &highestZ, float &lowestZ);
|
||||
static float applyCompassOrientation(float heading);
|
||||
static void publishCompassAccelSample(float x, float y, float z);
|
||||
static bool getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs);
|
||||
|
||||
ScanI2C::FoundDevice device;
|
||||
|
||||
|
||||
+7
-2
@@ -22,6 +22,9 @@
|
||||
#if HAS_ETHERNET && defined(ARCH_ESP32)
|
||||
#include <ETH.h>
|
||||
#endif // HAS_ETHERNET
|
||||
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||
#include "ESP32_CH390.h"
|
||||
#endif // USE_CH390D
|
||||
#include "Default.h"
|
||||
#include <Throttle.h>
|
||||
#include <assert.h>
|
||||
@@ -250,7 +253,7 @@ inline bool isConnectedToNetwork()
|
||||
if (ETH.connected())
|
||||
return true;
|
||||
#elif defined(USE_CH390D)
|
||||
if (ETH.isConnected())
|
||||
if (CH390.isConnected())
|
||||
return true;
|
||||
#endif
|
||||
|
||||
@@ -726,7 +729,9 @@ void MQTT::perhapsReportToMap()
|
||||
|
||||
// Fill MapReport message
|
||||
meshtastic_MapReport mapReport = meshtastic_MapReport_init_default;
|
||||
memcpy(mapReport.long_name, owner.long_name, sizeof(owner.long_name));
|
||||
// owner.long_name (40) is wider than mapReport.long_name (25); bound by the destination
|
||||
strncpy(mapReport.long_name, owner.long_name, sizeof(mapReport.long_name));
|
||||
mapReport.long_name[sizeof(mapReport.long_name) - 1] = '\0';
|
||||
memcpy(mapReport.short_name, owner.short_name, sizeof(owner.short_name));
|
||||
mapReport.role = config.device.role;
|
||||
mapReport.hw_model = owner.hw_model;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// meshtastic_ServiceEnvelope that automatically releases dynamically allocated memory when it goes out of scope.
|
||||
struct DecodedServiceEnvelope : public meshtastic_ServiceEnvelope {
|
||||
DecodedServiceEnvelope(const uint8_t *payload, size_t length);
|
||||
DecodedServiceEnvelope(DecodedServiceEnvelope &) = delete;
|
||||
// const-qualified so std::variant instantiation works on Apple libc++ (copying stays ill-formed either way)
|
||||
DecodedServiceEnvelope(const DecodedServiceEnvelope &) = delete;
|
||||
DecodedServiceEnvelope(DecodedServiceEnvelope &&);
|
||||
~DecodedServiceEnvelope();
|
||||
// Clients must check that this is true before using.
|
||||
|
||||
@@ -40,6 +40,7 @@ constexpr uint16_t kPreferredBleTxTimeUs = (kPreferredBleTxOctets + 14) * 8;
|
||||
|
||||
BLECharacteristic *fromNumCharacteristic;
|
||||
BLECharacteristic *BatteryCharacteristic;
|
||||
static int lastBatteryLevel = -1; // last value written to 0x2A19, to skip redundant writes/notifies
|
||||
BLECharacteristic *logRadioCharacteristic;
|
||||
BLEServer *bleServer;
|
||||
|
||||
@@ -718,6 +719,8 @@ void NimbleBluetooth::deinit()
|
||||
#endif
|
||||
|
||||
BLEDevice::deinit(true);
|
||||
BatteryCharacteristic = nullptr; // freed by deinit; clear so updateBatteryLevel() won't touch it
|
||||
lastBatteryLevel = -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -856,16 +859,31 @@ void NimbleBluetooth::setupService()
|
||||
BatteryCharacteristic = batteryService->createCharacteristic( // 0x2A19 is the Battery Level characteristic)
|
||||
(uint16_t)0x2a19, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
|
||||
BatteryCharacteristic->addDescriptor(batteryLevelDescriptor);
|
||||
// Seed an initial 0-100 level so an early read of 0x2A19 returns a valid value.
|
||||
uint8_t initialLevel = (powerStatus && powerStatus->getHasBattery()) ? powerStatus->getBatteryChargePercent() : 0;
|
||||
if (initialLevel > 100)
|
||||
initialLevel = 100;
|
||||
BatteryCharacteristic->setValue(&initialLevel, 1);
|
||||
lastBatteryLevel = initialLevel;
|
||||
batteryService->start();
|
||||
}
|
||||
|
||||
/// Given a level between 0-100, update the BLE attribute
|
||||
void updateBatteryLevel(uint8_t level)
|
||||
{
|
||||
if ((config.bluetooth.enabled == true) && nimbleBluetooth && nimbleBluetooth->isConnected()) {
|
||||
BatteryCharacteristic->setValue(&level, 1);
|
||||
if (!config.bluetooth.enabled || !BatteryCharacteristic)
|
||||
return;
|
||||
|
||||
if (level > 100) // 0x2A19 must stay within the BAS 0-100 range
|
||||
level = 100;
|
||||
if (level == lastBatteryLevel)
|
||||
return;
|
||||
lastBatteryLevel = level;
|
||||
|
||||
// Cache the value so a READ works without a subscriber; notify only when connected.
|
||||
BatteryCharacteristic->setValue(&level, 1);
|
||||
if (nimbleBluetooth && nimbleBluetooth->isConnected())
|
||||
BatteryCharacteristic->notify();
|
||||
}
|
||||
}
|
||||
|
||||
void NimbleBluetooth::clearBonds()
|
||||
|
||||
@@ -15,8 +15,9 @@ static BLECharacteristic fromRadio = BLECharacteristic(BLEUuid(FROMRADIO_UUID_16
|
||||
static BLECharacteristic toRadio = BLECharacteristic(BLEUuid(TORADIO_UUID_16));
|
||||
static BLECharacteristic logRadio = BLECharacteristic(BLEUuid(LOGRADIO_UUID_16));
|
||||
|
||||
static BLEDis bledis; // DIS (Device Information Service) helper class instance
|
||||
static BLEBas blebas; // BAS (Battery Service) helper class instance
|
||||
static BLEDis bledis; // DIS (Device Information Service) helper class instance
|
||||
static BLEBas blebas; // BAS (Battery Service) helper class instance
|
||||
static int lastBatteryLevel = -1; // last value written to BAS, to skip redundant writes/notifies
|
||||
#ifndef BLE_DFU_SECURE
|
||||
static BLEDfu bledfu; // DFU software update helper service
|
||||
#else
|
||||
@@ -66,6 +67,16 @@ void onConnect(uint16_t conn_handle)
|
||||
connection->getPeerName(central_name, sizeof(central_name));
|
||||
LOG_INFO("BLE Connected to %s", central_name);
|
||||
|
||||
// A new physical link must start unauthenticated. The auth slot is keyed by
|
||||
// the (single, reused) bluetoothPhoneAPI instance, so a prior session's
|
||||
// authorization can otherwise survive a quick reconnect. handleStartConfig()
|
||||
// re-locks on every want_config too; this closes the window before that.
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (bluetoothPhoneAPI) {
|
||||
bluetoothPhoneAPI->setAdminAuthorized(false);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Notify UI (or any other interested firmware components)
|
||||
meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED);
|
||||
bluetoothStatus->updateStatus(&newStatus);
|
||||
@@ -336,6 +347,7 @@ void NRF52Bluetooth::setup()
|
||||
LOG_INFO("Init the Battery Service");
|
||||
blebas.begin();
|
||||
blebas.write(0); // Unknown battery level for now
|
||||
lastBatteryLevel = 0;
|
||||
// Setup the Heart Rate Monitor service using
|
||||
// BLEService and BLECharacteristic classes
|
||||
LOG_INFO("Init the Mesh bluetooth service");
|
||||
@@ -355,6 +367,14 @@ void NRF52Bluetooth::resumeAdvertising()
|
||||
/// Given a level between 0-100, update the BLE attribute
|
||||
void updateBatteryLevel(uint8_t level)
|
||||
{
|
||||
if (!nrf52Bluetooth) // skip until the Battery Service has been begun in setup()
|
||||
return;
|
||||
|
||||
if (level > 100) // BAS battery level must stay within 0-100
|
||||
level = 100;
|
||||
if (level == lastBatteryLevel)
|
||||
return;
|
||||
lastBatteryLevel = level;
|
||||
blebas.write(level);
|
||||
}
|
||||
void NRF52Bluetooth::clearBonds()
|
||||
@@ -391,7 +411,15 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke
|
||||
std::string configuredPasskeyText = std::to_string(configuredPasskey);
|
||||
std::string ble_message =
|
||||
"Bluetooth\nPIN\n[M]" + configuredPasskeyText.substr(0, 3) + " " + configuredPasskeyText.substr(3, 6);
|
||||
screen->showSimpleBanner(ble_message.c_str(), 30000);
|
||||
// Use the pairing_pin notification type so the lockdown UI short-
|
||||
// circuit (Screen.cpp updateUiFrame) allows the overlay through
|
||||
// even on a locked device — see H13 audit fix. The banner content
|
||||
// is the per-attempt ephemeral pair PIN, not operator content.
|
||||
graphics::BannerOverlayOptions opts;
|
||||
opts.message = ble_message.c_str();
|
||||
opts.durationMs = 30000;
|
||||
opts.notificationType = graphics::notificationTypeEnum::pairing_pin;
|
||||
screen->showOverlayBanner(opts);
|
||||
}
|
||||
#endif
|
||||
passkeyShowing = true;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#include "configuration.h"
|
||||
#include <core_cm4.h>
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
|
||||
// Based on reading/modifying https://blog.feabhas.com/2013/02/developing-a-generic-hard-fault-handler-for-arm-cortex-m3cortex-m4/
|
||||
|
||||
enum { r0, r1, r2, r3, r12, lr, pc, psr };
|
||||
@@ -50,6 +54,16 @@ static void printMemErrorMsg(uint32_t cfsr)
|
||||
|
||||
extern "C" void HardFault_Impl(uint32_t stack[])
|
||||
{
|
||||
// M11 (audit): before any diagnostic / coredump path that could capture
|
||||
// RAM contents, zero the DEK / KEK / ephemeralKEK so they aren't sitting
|
||||
// in BSS for a fault dump to pick up. This is called from the asm naked
|
||||
// HardFault_Handler entry above, so we're effectively in the chip's
|
||||
// exception context — keep this strictly to in-RAM scrubbing, no flash
|
||||
// I/O, no logging.
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
EncryptedStorage::secureWipeKeys();
|
||||
#endif
|
||||
|
||||
FAULT_MSG("Hard Fault occurred! SCB->HFSR = 0x%08lx\n", SCB->HFSR);
|
||||
|
||||
if ((SCB->HFSR & SCB_HFSR_FORCED_Msk) != 0) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#define ARCH_STM32WL
|
||||
#define ARCH_STM32
|
||||
|
||||
//
|
||||
// defaults for STM32WL architecture
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
|
||||
// Device specific curves go in variant.h
|
||||
#ifndef OCV_ARRAY
|
||||
#if defined(ARCH_STM32WL) && BATTERY_PIN == AVBAT
|
||||
#if defined(ARCH_STM32) && BATTERY_PIN == AVBAT
|
||||
// STM32 VDD/VBAT absolute maximum is 4V so use an LFP curve
|
||||
#define OCV_ARRAY 3650, 3400, 3340, 3320, 3300, 3280, 3270, 3260, 3240, 3200, 2500
|
||||
#else
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "configuration.h"
|
||||
|
||||
#ifdef MESHTASTIC_ENABLE_APPROTECT
|
||||
#ifdef ARCH_NRF52
|
||||
|
||||
#include "APProtect.h"
|
||||
#include <nrf.h>
|
||||
|
||||
// M22 (audit): refuse to engage APPROTECT on silicon revisions where the
|
||||
// debug-port lockout is publicly known to be bypassable. nRF52840 build
|
||||
// codes AAB0..AAF0 are all affected by the SWD glitching attack documented
|
||||
// in LimitedResults' nRF52-series research — i.e. every nRF52840 currently
|
||||
// in shipping Meshtastic hardware. Engaging APPROTECT on these revisions
|
||||
// gives the operator a false sense of security AND irreversibly blocks
|
||||
// legitimate SWD-based dev/recovery: the worst of both. Detect-and-skip
|
||||
// is the policy; log loudly so the operator knows.
|
||||
//
|
||||
// FICR.INFO.VARIANT is a 32-bit register storing 4 ASCII characters as a
|
||||
// big-endian word ('AAB0' = 0x41414230). Compare whole-word.
|
||||
static bool isApProtectVulnerableSilicon(uint32_t variant)
|
||||
{
|
||||
// Known-affected nRF52840 build codes. Only remove entries with
|
||||
// positive evidence the variant is fixed.
|
||||
static const uint32_t kVulnerable[] = {
|
||||
0x41414230, // AAB0
|
||||
0x41414330, // AAC0
|
||||
0x41414430, // AAD0
|
||||
0x41414530, // AAE0
|
||||
0x41414630, // AAF0
|
||||
};
|
||||
for (uint32_t v : kVulnerable) {
|
||||
if (variant == v)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void logApProtectVariant(const char *prefix, uint32_t variant)
|
||||
{
|
||||
// Render the 4-byte ASCII variant for the log line. FICR encodes it
|
||||
// big-endian, so the high byte is the first ASCII character.
|
||||
char buf[5] = {(char)((variant >> 24) & 0xFF), (char)((variant >> 16) & 0xFF), (char)((variant >> 8) & 0xFF),
|
||||
(char)(variant & 0xFF), '\0'};
|
||||
LOG_WARN("%s (FICR.INFO.VARIANT='%s', 0x%08x)", prefix, buf, variant);
|
||||
}
|
||||
|
||||
void enableAPProtect()
|
||||
{
|
||||
const uint32_t variant = NRF_FICR->INFO.VARIANT;
|
||||
|
||||
if (isApProtectVulnerableSilicon(variant)) {
|
||||
logApProtectVariant("APPROTECT NOT engaged: silicon revision is publicly known "
|
||||
"bypassable via SWD glitching. Skipping irreversible UICR write so "
|
||||
"the operator is not misled into thinking SWD is locked when it is "
|
||||
"not. To override (e.g. for testing on a known-vulnerable board), "
|
||||
"rebuild with -DMESHTASTIC_APPROTECT_OVERRIDE_VULNERABLE_SILICON=1",
|
||||
variant);
|
||||
#ifndef MESHTASTIC_APPROTECT_OVERRIDE_VULNERABLE_SILICON
|
||||
return;
|
||||
#else
|
||||
LOG_WARN("APPROTECT vulnerable-silicon override flag set; engaging anyway");
|
||||
#endif
|
||||
}
|
||||
|
||||
// APPROTECT register: 0x00 = enabled (protected), 0xFF = disabled (open)
|
||||
// On nRF52840, UICR.APPROTECT at address 0x10001208
|
||||
if (NRF_UICR->APPROTECT != 0x00) {
|
||||
LOG_WARN("Enabling APPROTECT - debug port will be disabled after reset");
|
||||
|
||||
// UICR writes require NVMC to be in write mode
|
||||
NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
|
||||
while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
|
||||
;
|
||||
|
||||
NRF_UICR->APPROTECT = 0x00;
|
||||
while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
|
||||
;
|
||||
|
||||
// Return NVMC to read-only mode
|
||||
NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
|
||||
while (NRF_NVMC->READY == NVMC_READY_READY_Busy)
|
||||
;
|
||||
|
||||
// UICR APPROTECT is latched at chip reset, so the lock is NOT in effect
|
||||
// until we reset. Force a reset now to close the window where SWD remains
|
||||
// attachable on this same boot. We're called early in setup() before any
|
||||
// sensitive data is in RAM, so the reboot is safe.
|
||||
LOG_INFO("APPROTECT written; resetting to engage debug port lockout");
|
||||
NVIC_SystemReset();
|
||||
// unreachable
|
||||
} else {
|
||||
LOG_DEBUG("APPROTECT already enabled");
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
// Non-nRF52 builds - no-op
|
||||
void enableAPProtect()
|
||||
{
|
||||
LOG_DEBUG("APPROTECT not supported on this platform");
|
||||
}
|
||||
#endif // ARCH_NRF52
|
||||
#endif // MESHTASTIC_ENABLE_APPROTECT
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_ENABLE_APPROTECT
|
||||
|
||||
/**
|
||||
* Enable APPROTECT on nRF52840 to disable the SWD/JTAG debug port.
|
||||
*
|
||||
* Writes NRF_UICR->APPROTECT = 0x00 (and ERASEPROTECT/DEBUG variants where
|
||||
* applicable) if not already set, then triggers a reset so the change takes
|
||||
* effect. Must be called early in setup(), before any sensitive data is
|
||||
* loaded into RAM, so an attacker who powered the device cannot halt it via
|
||||
* SWD before the lock is in place.
|
||||
*
|
||||
* Once APPROTECT is written:
|
||||
* - SWD/JTAG halt, memory read, and register access are blocked.
|
||||
* - The lock survives reboot, power cycle, and ordinary USB/DFU firmware
|
||||
* reflash. The DFU bootloader path keeps working for routine app
|
||||
* updates because the bootloader doesn't need SWD.
|
||||
* - The only way to clear APPROTECT is an SWD-side `nrfjprog --recover`
|
||||
* (CTRL-AP ERASEALL), which wipes the entire chip — bootloader,
|
||||
* application, LittleFS, and the encrypted DEK — destroying all
|
||||
* on-device state in the process. That destructive coupling is the
|
||||
* point: an attacker cannot clear APPROTECT to extract user data
|
||||
* without also wiping the data they were trying to read.
|
||||
*
|
||||
* Practical implication: do not enable this on a device you might want to
|
||||
* SWD-debug later. Recovery is possible but always destroys all user
|
||||
* data; routine USB reflashing alone will NOT clear it.
|
||||
*/
|
||||
void enableAPProtect();
|
||||
|
||||
#endif // MESHTASTIC_ENABLE_APPROTECT
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* Encrypted storage layer for lockdown builds.
|
||||
*
|
||||
* Key hierarchy:
|
||||
* FICR eFuse IDs + passphrase -> SHA-256 -> KEK (16 bytes, never stored)
|
||||
* KEK wraps -> DEK (Data Encryption Key, 16 bytes, random, stored in /prefs/.dek)
|
||||
* DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(DEK)
|
||||
* (the DEK file itself is HMAC'd with KEK; only proto files use HMAC(DEK))
|
||||
*
|
||||
* Ephemeral KEK (FICR-only, no passphrase) -> wraps DEK in the unlock token only.
|
||||
* Unlock token (/prefs/.unlock_token) — valid for N boots and/or M hours after provisioning.
|
||||
*
|
||||
* Boot flow:
|
||||
* 1. initLocked() — derive ephemeral KEK, try unlock token
|
||||
* 2a. Token valid → UNLOCKED (DEK in RAM, all encrypted files accessible)
|
||||
* 2b. No token, no DEK file → NOT PROVISIONED (operator must call provisionPassphrase)
|
||||
* 2c. No token, DEK file exists → LOCKED (operator must call unlockWithPassphrase)
|
||||
* 3. provisionPassphrase() / unlockWithPassphrase() complete the unlock
|
||||
* 4. lockNow() immediately invalidates the token and zeroes the DEK from RAM
|
||||
*
|
||||
* On-disk formats carry a 4-byte magic but no version byte: this layer has
|
||||
* never shipped, so there are no older files to stay compatible with. The
|
||||
* magic alone identifies each format; a corrupt or foreign file fails the
|
||||
* magic check (and, for the keyed formats, the HMAC).
|
||||
*
|
||||
* Encrypted proto file format ("MENC"):
|
||||
* [4B] Magic 0x4D454E43 ("MENC")
|
||||
* [13B] Nonce (random per write)
|
||||
* [4B] Original plaintext length (LE uint32)
|
||||
* [NB] AES-128-CTR ciphertext
|
||||
* [32B] HMAC-SHA256(DEK, magic || nonce || plaintext_len || ciphertext)
|
||||
* Total overhead: 53 bytes per file.
|
||||
*
|
||||
* DEK file format ("MDEK"):
|
||||
* [4B] Magic 0x4D44454B ("MDEK")
|
||||
* [13B] Nonce (random per write)
|
||||
* [16B] AES-128-CTR(KEK, nonce, DEK)
|
||||
* [32B] HMAC-SHA256(KEK, "mdek-auth" || nonce || encrypted_DEK)
|
||||
* Total: 65 bytes.
|
||||
*
|
||||
* Unlock token format ("UTOK"):
|
||||
* [4B] Magic 0x55544F4B ("UTOK")
|
||||
* [13B] Nonce (random per write)
|
||||
* [16B] AES-128-CTR(ephemeralKEK, nonce, DEK)
|
||||
* [1B] boots_remaining
|
||||
* [4B] valid_until_epoch (LE uint32, 0 = no time limit)
|
||||
* [4B] session_max_seconds (LE uint32, 0 = no session limit)
|
||||
* [4B] monotonic_counter (LE uint32) — see /prefs/.tokmono
|
||||
* [32B] HMAC-SHA256(ephemeralKEK, all above fields)
|
||||
* Total: 78 bytes.
|
||||
*
|
||||
* Monotonic counter file (/prefs/.tokmono):
|
||||
* [4B] highest counter ever issued (LE uint32)
|
||||
* [32B] HMAC-SHA256(ephemeralKEK, "tokmono-auth" || counter)
|
||||
* Total: 36 bytes.
|
||||
* readAndConsumeToken rejects any token whose body counter is less
|
||||
* than the persisted value, defeating a flash-write-only attacker who
|
||||
* tries to restore an older (e.g. higher-boot-count) token.
|
||||
*
|
||||
* Backoff state file (/prefs/.backoff):
|
||||
* [1B] attempts
|
||||
* [1B] bootsSinceFail
|
||||
* [4B] lastFailEpoch (LE uint32)
|
||||
* [32B] HMAC-SHA256(ephemeralKEK, "backoff-auth" || body)
|
||||
* Total: 38 bytes. Missing / short / MAC-fail are all treated as
|
||||
* max-attempts so a tamper-delete can only increase the wait.
|
||||
*/
|
||||
|
||||
namespace EncryptedStorage
|
||||
{
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File format constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static constexpr uint32_t MAGIC = 0x4D454E43; // "MENC" — encrypted proto files
|
||||
static constexpr size_t NONCE_SIZE = 13;
|
||||
static constexpr size_t HMAC_SIZE = 32;
|
||||
static constexpr size_t HEADER_SIZE = 4 + NONCE_SIZE + 4; // magic+nonce+plaintext_len
|
||||
static constexpr size_t OVERHEAD = HEADER_SIZE + HMAC_SIZE; // 53 bytes
|
||||
static constexpr size_t AES_KEY_SIZE = 16;
|
||||
static constexpr size_t AES_BLOCK_SIZE = 16;
|
||||
|
||||
static constexpr uint32_t DEK_MAGIC = 0x4D44454B; // "MDEK"
|
||||
static constexpr size_t DEK_SIZE = 4 + NONCE_SIZE + AES_KEY_SIZE + HMAC_SIZE; // 65 bytes
|
||||
|
||||
static constexpr uint32_t TOKEN_MAGIC = 0x55544F4B; // "UTOK"
|
||||
// magic(4) + nonce(NONCE_SIZE=13) + encDek(AES_KEY_SIZE=16)
|
||||
// + bootsRemaining(1) + validUntilEpoch(4) + sessionMaxSeconds(4)
|
||||
// + monotonicCounter(4) = 46 bytes
|
||||
static constexpr size_t TOKEN_BODY_SIZE = 4 + NONCE_SIZE + AES_KEY_SIZE + 1 + 4 + 4 + 4;
|
||||
static constexpr size_t TOKEN_TOTAL_SIZE = TOKEN_BODY_SIZE + HMAC_SIZE; // 78 bytes
|
||||
|
||||
static constexpr uint8_t TOKEN_DEFAULT_BOOTS = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Passphrase-gated boot API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Boot-time init: derive ephemeral KEK and attempt to unlock via the stored token.
|
||||
* Sets isUnlocked()=true if the token is present and valid.
|
||||
* Must be called after fsInit(), before loadFromDisk().
|
||||
*/
|
||||
void initLocked();
|
||||
|
||||
/**
|
||||
* First-time provisioning: set the device passphrase, generate a fresh DEK,
|
||||
* save it wrapped with the passphrase-mixed KEK, and create an unlock token.
|
||||
*
|
||||
* @param passphrase Raw passphrase bytes (need not be NUL-terminated)
|
||||
* @param passphraseLen Length in bytes (1–32; matches the proto private_key field size)
|
||||
* @param bootsRemaining Token valid for this many boots (default TOKEN_DEFAULT_BOOTS)
|
||||
* @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
|
||||
* @param sessionMaxSeconds Per-boot uptime cap on the unlocked session (0 = no cap).
|
||||
* Persists in the token; cold-boot via token inherits the same cap.
|
||||
* @return true on success
|
||||
*/
|
||||
bool provisionPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS,
|
||||
uint32_t validUntilEpoch = 0, uint32_t sessionMaxSeconds = 0);
|
||||
|
||||
/**
|
||||
* Unlock after token expiry (or after lockNow()): derive KEK from passphrase,
|
||||
* unwrap the stored DEK, and create a fresh unlock token.
|
||||
*
|
||||
* @param passphrase Raw passphrase bytes
|
||||
* @param passphraseLen Length in bytes (1–32; matches the proto private_key field size)
|
||||
* @param bootsRemaining New token valid for this many boots
|
||||
* @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
|
||||
* @param sessionMaxSeconds Per-boot uptime cap on the unlocked session (0 = no cap).
|
||||
* Persists in the new token; reboot starts a fresh session window.
|
||||
* @return true if passphrase was correct and DEK is now loaded
|
||||
*/
|
||||
bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS,
|
||||
uint32_t validUntilEpoch = 0, uint32_t sessionMaxSeconds = 0);
|
||||
|
||||
/**
|
||||
* Immediately lock: delete the unlock token and zero the DEK from RAM.
|
||||
* The device will require the passphrase on the next boot (or connection).
|
||||
*/
|
||||
void lockNow();
|
||||
|
||||
/**
|
||||
* Wipe in-RAM key material WITHOUT touching flash. Designed to be called
|
||||
* from fault / watchdog handlers before any coredump or RAM-snapshot path
|
||||
* runs, so the DEK / KEK / ephemeralKEK don't end up in crash reports.
|
||||
*
|
||||
* Safe to call from interrupt context: does not take any FreeRTOS locks
|
||||
* and does not log. Equivalent to the RAM-wipe half of lockNow() with the
|
||||
* token file left intact (so the device can still auto-unlock on the
|
||||
* next normal boot via the token).
|
||||
*/
|
||||
void secureWipeKeys();
|
||||
|
||||
/** Returns true if the DEK file exists (device has been provisioned). */
|
||||
bool isProvisioned();
|
||||
|
||||
/** Returns true if the DEK is loaded in RAM (device is unlocked). */
|
||||
bool isUnlocked();
|
||||
|
||||
/**
|
||||
* Returns true when lockdown is active on this device (== isProvisioned()).
|
||||
* The runtime gate for all access-control / redaction / locked-boot
|
||||
* behavior. A lockdown-CAPABLE build that has not been provisioned (or has
|
||||
* been disabled) returns false here and runs like stock firmware.
|
||||
*/
|
||||
bool isLockdownActive();
|
||||
|
||||
/**
|
||||
* Decrypt one encrypted file back to plaintext in place (the inverse of
|
||||
* migrateFile). Idempotent: a file that is already plaintext returns true
|
||||
* without touching it. Requires isUnlocked() (DEK in RAM). Used by the
|
||||
* lockdown-disable flow; NodeDB drives the per-file iteration since it owns
|
||||
* the proto filenames.
|
||||
*
|
||||
* @return true on success or if the file was already plaintext.
|
||||
*/
|
||||
bool migrateFileToPlaintext(const char *filename);
|
||||
|
||||
/**
|
||||
* Final step of disabling lockdown: remove the DEK, unlock token,
|
||||
* monotonic-counter, and backoff files, then wipe the in-RAM keys.
|
||||
* Call this ONLY after every encrypted file has been reverted to plaintext
|
||||
* via migrateFileToPlaintext() — deleting the DEK first would make any
|
||||
* remaining encrypted file permanently unreadable. After this returns,
|
||||
* isProvisioned()/isLockdownActive() are false. APPROTECT is NOT touched
|
||||
* (its lockout is permanent on silicon where it engaged).
|
||||
*/
|
||||
void removeLockdownArtifacts();
|
||||
|
||||
/**
|
||||
* Returns a short string describing why the device is locked (set during initLocked()).
|
||||
* Useful for client-side diagnostics. Examples:
|
||||
* "token_missing" — no unlock token file found
|
||||
* "token_wrong_size" — token file exists but is corrupt
|
||||
* "token_bad_magic" — wrong magic bytes
|
||||
* "token_hmac_fail" — HMAC mismatch (tampered or wrong device)
|
||||
* "token_boots_zero" — boot count exhausted
|
||||
* "token_expired" — TTL expired
|
||||
* "token_dek_fail" — DEK decrypt failed
|
||||
* "not_provisioned" — no DEK file; needs first provisioning
|
||||
* "ok" — unlocked successfully via token
|
||||
*/
|
||||
const char *getLockReason();
|
||||
|
||||
/** Boots remaining in the current unlock token (0 if not unlocked or last boot consumed). */
|
||||
uint8_t getBootsRemaining();
|
||||
|
||||
/** Unix epoch at which the current unlock token expires (0 = no time limit or not unlocked). */
|
||||
uint32_t getValidUntilEpoch();
|
||||
|
||||
/** Seconds remaining before next passphrase attempt is allowed (0 = can attempt now). */
|
||||
uint32_t getBackoffSecondsRemaining();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uptime-based session limit
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Independent of the wall-clock and boot-count TTLs on the token. Caps how
|
||||
// long a single auto-unlocked session can keep storage unlocked, measured
|
||||
// in firmware millis() since the unlock. Reboot resets the counter, so an
|
||||
// attacker who power-cycles to dodge the timer still burns a boot count.
|
||||
// Combined hard cap: bootsRemaining * sessionMaxSeconds total exposure.
|
||||
//
|
||||
// Uptime (not wall-clock) by design: an attacker pulling the RTC backup
|
||||
// battery and spoofing GPS to roll the clock back cannot defeat this —
|
||||
// we never read getValidTime() for session enforcement. The check only
|
||||
// engages when sessionMaxSeconds is non-zero, so 0 = unlimited (the
|
||||
// existing token-only behavior, suitable for tower/infra nodes).
|
||||
|
||||
/// Start a session timer. Called after a successful passphrase unlock.
|
||||
/// maxSeconds = 0 disables the timer for this session.
|
||||
void setSession(uint32_t maxSeconds);
|
||||
|
||||
/// True if a session timer is set and has elapsed. Idempotent — call
|
||||
/// from the main loop on a low-frequency tick.
|
||||
bool isSessionExpired();
|
||||
|
||||
/// Seconds remaining in the current session. 0 if no timer is set, or if
|
||||
/// the timer has expired (use isSessionExpired() to distinguish).
|
||||
uint32_t getSessionRemainingSeconds();
|
||||
|
||||
/// Consume one boot from the on-flash token (the rollback ledger) and
|
||||
/// re-arm the session timer in place — no reboot. Called from the main
|
||||
/// loop when a session expires AND there is still budget. Decrements
|
||||
/// bootsRemaining on flash (delete-and-rewrite of the token file, or
|
||||
/// outright deletion if the new count is 0). Returns the new boot
|
||||
/// count. Caller should check getBootsRemaining() == 0 before this
|
||||
/// call: when zero, the budget is exhausted and a hard lock + reboot
|
||||
/// should be issued instead.
|
||||
uint8_t consumeSessionBoot();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted file I/O (require isUnlocked())
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns true if the file starts with the MENC magic bytes. */
|
||||
bool isEncrypted(const char *filename);
|
||||
|
||||
/**
|
||||
* Read and decrypt a file into outBuf.
|
||||
* Returns true on success; sets outLen to the plaintext byte count.
|
||||
*/
|
||||
bool readAndDecrypt(const char *filename, uint8_t *outBuf, size_t outBufSize, size_t &outLen);
|
||||
|
||||
/**
|
||||
* Encrypt plaintext and write to filename.
|
||||
* Returns true on success.
|
||||
*/
|
||||
bool encryptAndWrite(const char *filename, const uint8_t *plaintext, size_t plaintextLen, bool fullAtomic = false);
|
||||
|
||||
/**
|
||||
* Migrate a plaintext proto file to encrypted format in-place.
|
||||
* Returns true on success or if already encrypted.
|
||||
*/
|
||||
bool migrateFile(const char *filename);
|
||||
|
||||
} // namespace EncryptedStorage
|
||||
|
||||
#endif // MESHTASTIC_ENCRYPTED_STORAGE
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "configuration.h"
|
||||
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
|
||||
#include "LockdownDisplay.h"
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace meshtastic_security
|
||||
{
|
||||
|
||||
// Screen-lock latch. Set when the display powers off (idle timeout etc.),
|
||||
// cleared only when a client authenticates with the passphrase. Separate
|
||||
// from storage-lock state: the device keeps routing while this is set,
|
||||
// only the display is gated.
|
||||
//
|
||||
// Initialised to true so that even a token-auto-unlocked cold boot comes
|
||||
// up with a redacted screen. Otherwise an attacker holding a screen-locked
|
||||
// device could simply power-cycle it (RAM latch resets) to get back to a
|
||||
// content screen. Operator must authenticate from a client to reveal
|
||||
// content after any boot.
|
||||
//
|
||||
// std::atomic so cross-task reads (PowerFSM / Screen / InputBroker) see
|
||||
// writes immediately and the compiler is not free to speculate the load.
|
||||
// Plain bool happens to work on single-core Cortex-M4 today but breaks
|
||||
// silently the moment lockdown ports to ESP32 / RP2040 / LTO whole-program
|
||||
// elision.
|
||||
static std::atomic<bool> s_screenLocked{true};
|
||||
|
||||
bool shouldRedactDisplay()
|
||||
{
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
// Lockdown not active (capable build, never provisioned or disabled):
|
||||
// never redact the display — behave like stock firmware.
|
||||
if (!EncryptedStorage::isLockdownActive())
|
||||
return false;
|
||||
if (!EncryptedStorage::isUnlocked())
|
||||
return true;
|
||||
#endif
|
||||
return s_screenLocked.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void lockScreen()
|
||||
{
|
||||
s_screenLocked.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void unlockScreen()
|
||||
{
|
||||
s_screenLocked.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
} // namespace meshtastic_security
|
||||
|
||||
#endif // MESHTASTIC_LOCKDOWN
|
||||
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace meshtastic_security
|
||||
{
|
||||
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
|
||||
/**
|
||||
* Display privacy policy for hardened lockdown builds.
|
||||
*
|
||||
* Renderers (Screen, InkHUD, niche graphics, device-ui) should consult
|
||||
* shouldRedactDisplay() at their top-level draw entry point. When true,
|
||||
* render a static "locked" view (e.g. just product name + battery), NOT
|
||||
* the normal node list / messages / GPS / channel content.
|
||||
*
|
||||
* Redaction triggers on either of two conditions:
|
||||
*
|
||||
* 1. Encrypted storage is locked (no DEK in RAM). NodeDB holds only
|
||||
* defaults, but the explicit gate also keeps cached/stale UI state
|
||||
* from leaking. Only firmware built with MESHTASTIC_ENCRYPTED_STORAGE
|
||||
* has a storage state to check; elsewhere this condition is false.
|
||||
*
|
||||
* 2. The screen-lock latch is set. This is a separate state from
|
||||
* storage-locked: the device stays fully functional on the mesh,
|
||||
* only the display is gated. The latch is set by lockScreen() when
|
||||
* the stock idle timeout powers the screen off (hooked in
|
||||
* Screen::setOn) — so it reuses config.display.screen_on_secs
|
||||
* rather than running a second timer. It is cleared only by
|
||||
* unlockScreen(), called from PhoneAPI's lockdown_auth handler when
|
||||
* a client authenticates with the passphrase over any transport.
|
||||
* Button/joystick input can wake the backlight but does NOT clear
|
||||
* the latch — the woken screen shows the LOCKED frame, not content.
|
||||
* This closes the "operator walked away from an unlocked device"
|
||||
* leak without conflating it with the storage-lock security state.
|
||||
*
|
||||
* The latch starts TRUE at boot so a token-auto-unlocked cold boot
|
||||
* comes up redacted — otherwise an attacker holding a screen-locked
|
||||
* device could power-cycle it (RAM latch resets) to recover a
|
||||
* content screen. After any boot, the operator must authenticate
|
||||
* from a client to reveal content.
|
||||
*
|
||||
* CURRENT COVERAGE
|
||||
* - graphics/Screen.cpp (OLED via OLEDDisplayUi): GATED, renders a centered
|
||||
* "LOCKED" + battery when shouldRedactDisplay() is true.
|
||||
*
|
||||
* KNOWN GAPS — these renderers still leak content under lockdown
|
||||
* - graphics/InkHUD/ (e-ink rich UI on supported boards)
|
||||
* - graphics/niche/ (TFT niche graphics)
|
||||
* - meshtastic/device-ui (T-Deck/TFT, separate submodule)
|
||||
*
|
||||
* Each of those does not flow through Screen::updateUiFrame() and therefore
|
||||
* does not yet consult this policy. Operators using lockdown builds on
|
||||
* InkHUD/niche/device-ui hardware should treat the screen as an
|
||||
* always-on plaintext leak surface until those renderers are wired up.
|
||||
* Wiring the other renderers is a follow-up effort once this lands.
|
||||
*/
|
||||
bool shouldRedactDisplay();
|
||||
|
||||
/// Set the screen-lock latch. Called from Screen::setOn(false) when the
|
||||
/// display powers off (idle timeout, shutdown, deep sleep). Idempotent.
|
||||
void lockScreen();
|
||||
|
||||
/// Clear the screen-lock latch. Called from PhoneAPI's lockdown_auth
|
||||
/// handler after a client authenticates with the passphrase.
|
||||
void unlockScreen();
|
||||
|
||||
#else
|
||||
|
||||
inline bool shouldRedactDisplay()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
inline void lockScreen() {}
|
||||
inline void unlockScreen() {}
|
||||
|
||||
#endif // MESHTASTIC_LOCKDOWN
|
||||
|
||||
} // namespace meshtastic_security
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
namespace meshtastic_security
|
||||
{
|
||||
|
||||
// Compiler-barrier wipe: a plain memset on a dying stack/heap buffer can be
|
||||
// elided as dead-store. The volatile function pointer forces emission.
|
||||
inline void secure_zero(void *p, std::size_t n)
|
||||
{
|
||||
if (!p || n == 0)
|
||||
return;
|
||||
static void *(*volatile memset_v)(void *, int, std::size_t) = std::memset;
|
||||
memset_v(p, 0, n);
|
||||
}
|
||||
|
||||
// Fixed-size RAII buffer for key material; zeroed in destructor.
|
||||
template <std::size_t N> class ZeroizingBuffer
|
||||
{
|
||||
public:
|
||||
ZeroizingBuffer() { secure_zero(buf_, N); }
|
||||
~ZeroizingBuffer() { secure_zero(buf_, N); }
|
||||
|
||||
ZeroizingBuffer(const ZeroizingBuffer &) = delete;
|
||||
ZeroizingBuffer &operator=(const ZeroizingBuffer &) = delete;
|
||||
|
||||
uint8_t *data() { return buf_; }
|
||||
const uint8_t *data() const { return buf_; }
|
||||
constexpr std::size_t size() const { return N; }
|
||||
uint8_t &operator[](std::size_t i) { return buf_[i]; }
|
||||
const uint8_t &operator[](std::size_t i) const { return buf_[i]; }
|
||||
|
||||
private:
|
||||
uint8_t buf_[N];
|
||||
};
|
||||
|
||||
// unique_ptr deleter that wipes the buffer before delete[].
|
||||
struct ZeroizingArrayDeleter {
|
||||
std::size_t n;
|
||||
void operator()(uint8_t *p) const noexcept
|
||||
{
|
||||
if (p) {
|
||||
secure_zero(p, n);
|
||||
delete[] p;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
using ZeroizingArrayPtr = std::unique_ptr<uint8_t[], ZeroizingArrayDeleter>;
|
||||
|
||||
inline ZeroizingArrayPtr make_zeroizing_array(std::size_t n)
|
||||
{
|
||||
return ZeroizingArrayPtr(new uint8_t[n](), ZeroizingArrayDeleter{n});
|
||||
}
|
||||
|
||||
} // namespace meshtastic_security
|
||||
@@ -55,6 +55,11 @@ uninitMemberVar:*/AudioThread.h
|
||||
constVariableReference:*/Channels.cpp
|
||||
constParameterPointer:*/unishox2.c
|
||||
|
||||
// False positive: make_zeroizing_array() returns unique_ptr<uint8_t[], ...>, so
|
||||
// .get() is uint8_t*, not void*. cppcheck can't resolve the custom-deleter alias
|
||||
// and reports arithmetic on these buffers as void* pointer math.
|
||||
arithOperationsOnVoidPointer:*/EncryptedStorage.cpp
|
||||
|
||||
useStlAlgorithm
|
||||
|
||||
variableScope
|
||||
+1
-1
@@ -85,7 +85,7 @@ The native build requires several system libraries. Install them all at once:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y \
|
||||
libbluetooth-dev libgpiod-dev libyaml-cpp-dev openssl libssl-dev \
|
||||
libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \
|
||||
libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
|
||||
```
|
||||
|
||||
|
||||
@@ -707,6 +707,91 @@ static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault()
|
||||
TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Region-locked preset swap tests (EU_868 / EU_866 / EU_N_868 trio)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static void test_clampConfigLora_narrowPresetOnEU866SwapsToEUN868()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
|
||||
cfg.use_preset = true;
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST;
|
||||
|
||||
RadioInterface::clampConfigLora(cfg);
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, cfg.region);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, cfg.modem_preset);
|
||||
}
|
||||
|
||||
static void test_clampConfigLora_litePresetOnEU868SwapsToEU866()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
||||
cfg.use_preset = true;
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW;
|
||||
|
||||
RadioInterface::clampConfigLora(cfg);
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_866, cfg.region);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW, cfg.modem_preset);
|
||||
}
|
||||
|
||||
static void test_clampConfigLora_eu868PresetOnEUN868SwapsToEU868()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_N_868;
|
||||
cfg.use_preset = true;
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
||||
|
||||
RadioInterface::clampConfigLora(cfg);
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, cfg.region);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
|
||||
}
|
||||
|
||||
static void test_clampConfigLora_litePresetOnUSDoesNotSwap()
|
||||
{
|
||||
// Previous region is not one of the swappable trio, so the preset clamps to the
|
||||
// region default instead of swapping regions.
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
cfg.use_preset = true;
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST;
|
||||
|
||||
RadioInterface::clampConfigLora(cfg);
|
||||
|
||||
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, cfg.region);
|
||||
TEST_ASSERT_EQUAL(us->getDefaultPreset(), cfg.modem_preset);
|
||||
}
|
||||
|
||||
static void test_clampConfigLora_narrowPresetOnHam125cmDoesNotSwap()
|
||||
{
|
||||
// ITU2_125CM shares the NARROW presets, so they are valid there and nothing changes
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM;
|
||||
cfg.use_preset = true;
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW;
|
||||
|
||||
RadioInterface::clampConfigLora(cfg);
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM, cfg.region);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW, cfg.modem_preset);
|
||||
}
|
||||
|
||||
static void test_validateConfigLora_siblingLockedPresetStillFailsValidation()
|
||||
{
|
||||
// Validation (no clamp) must keep failing so callers route into clampConfigLora,
|
||||
// which performs the region swap.
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
|
||||
cfg.use_preset = true;
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST;
|
||||
|
||||
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RegionInfo preset list integrity tests
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -921,6 +1006,93 @@ static void test_handleSetConfig_fromOthers_validPresetAccepted()
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset);
|
||||
}
|
||||
|
||||
static void test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected()
|
||||
{
|
||||
// Rejecting a remote config must reject ALL of it: an invalid channel_num must not
|
||||
// leak into config.lora alongside the restored region/preset.
|
||||
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
config.lora.use_preset = true;
|
||||
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
||||
config.lora.channel_num = 0;
|
||||
initRegion();
|
||||
|
||||
meshtastic_Config c =
|
||||
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
||||
c.payload_variant.lora.channel_num = 5000; // far beyond US slot count
|
||||
|
||||
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, config.lora.channel_num);
|
||||
}
|
||||
|
||||
static void test_regionInfo_supportsPreset()
|
||||
{
|
||||
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
||||
TEST_ASSERT_TRUE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
|
||||
TEST_ASSERT_FALSE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO));
|
||||
TEST_ASSERT_FALSE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST));
|
||||
|
||||
const RegionInfo *eu866 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
|
||||
TEST_ASSERT_TRUE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW));
|
||||
TEST_ASSERT_FALSE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
|
||||
}
|
||||
|
||||
static void test_checkConfigRegion_quietCheckReportsReason()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
TEST_ASSERT_TRUE(RadioInterface::checkConfigRegion(cfg));
|
||||
|
||||
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)254;
|
||||
char err[160] = {0};
|
||||
TEST_ASSERT_FALSE(RadioInterface::checkConfigRegion(cfg, err, sizeof(err)));
|
||||
TEST_ASSERT_TRUE_MESSAGE(strlen(err) > 0, "Expected a failure reason in errBuf");
|
||||
}
|
||||
|
||||
static void test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion()
|
||||
{
|
||||
// Baseline: EU_866 (LITE profile)
|
||||
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
|
||||
config.lora.use_preset = true;
|
||||
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST;
|
||||
initRegion();
|
||||
|
||||
// Remote admin keeps the region but selects a NARROW preset (locked to EU_N_868)
|
||||
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_866, true,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST);
|
||||
|
||||
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, config.lora.region);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, config.lora.modem_preset);
|
||||
|
||||
// Restore the region table pointer for subsequent tests
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||
initRegion();
|
||||
}
|
||||
|
||||
static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected()
|
||||
{
|
||||
// Baseline: US is not one of the swappable trio, so a LITE preset must be rejected
|
||||
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
config.lora.use_preset = true;
|
||||
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
||||
initRegion();
|
||||
|
||||
meshtastic_Config c =
|
||||
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST);
|
||||
|
||||
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test runner
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -992,6 +1164,14 @@ void setup()
|
||||
RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast);
|
||||
RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault);
|
||||
|
||||
// Region-locked preset swap
|
||||
RUN_TEST(test_clampConfigLora_narrowPresetOnEU866SwapsToEUN868);
|
||||
RUN_TEST(test_clampConfigLora_litePresetOnEU868SwapsToEU866);
|
||||
RUN_TEST(test_clampConfigLora_eu868PresetOnEUN868SwapsToEU868);
|
||||
RUN_TEST(test_clampConfigLora_litePresetOnUSDoesNotSwap);
|
||||
RUN_TEST(test_clampConfigLora_narrowPresetOnHam125cmDoesNotSwap);
|
||||
RUN_TEST(test_validateConfigLora_siblingLockedPresetStillFailsValidation);
|
||||
|
||||
// RegionInfo preset list integrity
|
||||
RUN_TEST(test_presetsStd_hasNineEntries);
|
||||
RUN_TEST(test_presetsEU868_hasSevenEntries);
|
||||
@@ -1016,6 +1196,11 @@ void setup()
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected);
|
||||
RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped);
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted);
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected);
|
||||
RUN_TEST(test_regionInfo_supportsPreset);
|
||||
RUN_TEST(test_checkConfigRegion_quietCheckReportsReason);
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "CryptoEngine.h"
|
||||
|
||||
#include "TestUtil.h"
|
||||
#include <XEdDSA.h>
|
||||
#include <unity.h>
|
||||
|
||||
void HexToBytes(uint8_t *result, const std::string hex, size_t len = 0)
|
||||
@@ -152,6 +153,134 @@ void test_PKC(void)
|
||||
TEST_ASSERT_EQUAL_MEMORY(expected_decrypted, decrypted, 10);
|
||||
}
|
||||
|
||||
void test_XEdDSA(void)
|
||||
{
|
||||
uint8_t private_key[32];
|
||||
uint8_t x_public_key[32];
|
||||
uint8_t ed_private_key[32];
|
||||
uint8_t ed_public_key[32];
|
||||
uint8_t ed_public_key2[32];
|
||||
uint8_t message[] = "This is a test!";
|
||||
uint8_t message2[] = "This is a test.";
|
||||
uint8_t signature[64];
|
||||
uint32_t fromNode = 0x1234;
|
||||
uint32_t packetId = 0xDEADBEEF;
|
||||
uint32_t portnum = 1;
|
||||
for (int times = 0; times < 10; times++) {
|
||||
printf("Start of time %u\n", times);
|
||||
crypto->generateKeyPair(x_public_key, private_key);
|
||||
XEdDSA::priv_curve_to_ed_keys(private_key, ed_private_key, ed_public_key);
|
||||
crypto->curve_to_ed_pub(x_public_key, ed_public_key2);
|
||||
TEST_ASSERT_EQUAL_MEMORY(ed_public_key, ed_public_key2, 32);
|
||||
|
||||
// Sign and verify with metadata
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
TEST_ASSERT(crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
|
||||
// Different payload fails
|
||||
TEST_ASSERT_FALSE(
|
||||
crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum, message2, sizeof(message2), signature));
|
||||
|
||||
// Different fromNode fails
|
||||
TEST_ASSERT_FALSE(
|
||||
crypto->xeddsa_verify(x_public_key, fromNode + 1, packetId, portnum, message, sizeof(message), signature));
|
||||
|
||||
// Different packetId fails
|
||||
TEST_ASSERT_FALSE(
|
||||
crypto->xeddsa_verify(x_public_key, fromNode, packetId + 1, portnum, message, sizeof(message), signature));
|
||||
|
||||
// Different portnum fails
|
||||
TEST_ASSERT_FALSE(
|
||||
crypto->xeddsa_verify(x_public_key, fromNode, packetId, portnum + 1, message, sizeof(message), signature));
|
||||
}
|
||||
}
|
||||
|
||||
// A signature only verifies under the signer's own key; a different key (or an all-zero key) fails.
|
||||
void test_XEdDSA_cross_key_reject(void)
|
||||
{
|
||||
uint8_t pubA[32], privA[32];
|
||||
uint8_t pubB[32], privB[32];
|
||||
uint8_t signature[64];
|
||||
uint8_t message[] = "cross-key check";
|
||||
uint32_t fromNode = 0x4242, packetId = 0xABCD1234, portnum = 7;
|
||||
|
||||
crypto->generateKeyPair(pubA, privA); // engine now holds key A
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
|
||||
crypto->generateKeyPair(pubB, privB); // unrelated key pair
|
||||
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pubB, fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
|
||||
uint8_t zeroKey[32] = {0};
|
||||
TEST_ASSERT_FALSE(crypto->xeddsa_verify(zeroKey, fromNode, packetId, portnum, message, sizeof(message), signature));
|
||||
}
|
||||
|
||||
// Signing with an unset (all-zero) private key must fail rather than emit a bogus signature.
|
||||
void test_XEdDSA_empty_key_sign_fails(void)
|
||||
{
|
||||
CryptoEngine fresh; // freshly constructed: xeddsa_private_key is all zero
|
||||
uint8_t signature[64];
|
||||
uint8_t message[] = "no key";
|
||||
TEST_ASSERT_FALSE(fresh.xeddsa_sign(0x1, 0x2, 0x3, message, sizeof(message), signature));
|
||||
}
|
||||
|
||||
// curve_to_ed_pub caches the last converted key; verifying A, then B, then A must stay correct.
|
||||
void test_XEdDSA_curve_to_ed_cache(void)
|
||||
{
|
||||
uint8_t pubA[32], privA[32], sigA[64];
|
||||
uint8_t pubB[32], privB[32], sigB[64];
|
||||
uint8_t message[] = "cache check";
|
||||
uint32_t fromNode = 0x11, packetId = 0x22, portnum = 3;
|
||||
|
||||
crypto->generateKeyPair(pubA, privA);
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sigA));
|
||||
crypto->generateKeyPair(pubB, privB);
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sigB));
|
||||
|
||||
// Interleave keys to exercise both cache hits and cache invalidation.
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigA));
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubB, fromNode, packetId, portnum, message, sizeof(message), sigB));
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigA));
|
||||
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pubA, fromNode, packetId, portnum, message, sizeof(message), sigB));
|
||||
}
|
||||
|
||||
// A payload at the maximum signable size (DATA_PAYLOAD_LEN - signature) round-trips and detects tampering.
|
||||
void test_XEdDSA_max_payload(void)
|
||||
{
|
||||
const size_t len = meshtastic_Constants_DATA_PAYLOAD_LEN - XEDDSA_SIGNATURE_SIZE;
|
||||
uint8_t payload[meshtastic_Constants_DATA_PAYLOAD_LEN];
|
||||
for (size_t i = 0; i < len; i++)
|
||||
payload[i] = (uint8_t)(i * 7 + 1);
|
||||
|
||||
uint8_t pub[32], priv[32], signature[64];
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
uint32_t fromNode = 0xFEED, packetId = 0xC0DE, portnum = 1;
|
||||
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, payload, len, signature));
|
||||
TEST_ASSERT(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, payload, len, signature));
|
||||
payload[0] ^= 0x01;
|
||||
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, payload, len, signature));
|
||||
}
|
||||
|
||||
// Signing the same message twice yields signatures that both verify. This XEdDSA implementation is
|
||||
// deterministic in practice (the two signatures are typically byte-identical, even though
|
||||
// HardwareRNG::fill provides real entropy on this platform), so we assert only the security-relevant
|
||||
// property — every produced signature verifies — rather than asserting (non-)determinism.
|
||||
void test_XEdDSA_repeated_sign_verifies(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32], sig1[64], sig2[64];
|
||||
uint8_t message[] = "same message";
|
||||
uint32_t fromNode = 0x9, packetId = 0x9, portnum = 9;
|
||||
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sig1));
|
||||
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, portnum, message, sizeof(message), sig2));
|
||||
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, message, sizeof(message), sig1));
|
||||
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, portnum, message, sizeof(message), sig2));
|
||||
}
|
||||
|
||||
void test_AES_CTR(void)
|
||||
{
|
||||
uint8_t expected[32];
|
||||
@@ -192,6 +321,12 @@ void setup()
|
||||
RUN_TEST(test_DH25519);
|
||||
RUN_TEST(test_AES_CTR);
|
||||
RUN_TEST(test_PKC);
|
||||
RUN_TEST(test_XEdDSA);
|
||||
RUN_TEST(test_XEdDSA_cross_key_reject);
|
||||
RUN_TEST(test_XEdDSA_empty_key_sign_fails);
|
||||
RUN_TEST(test_XEdDSA_curve_to_ed_cache);
|
||||
RUN_TEST(test_XEdDSA_max_payload);
|
||||
RUN_TEST(test_XEdDSA_repeated_sign_verifies);
|
||||
exit(UNITY_END()); // stop unit testing
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "TestUtil.h"
|
||||
#include <cstdlib>
|
||||
#include <unity.h>
|
||||
|
||||
static void test_placeholder()
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
// Tests for XEdDSA packet-signing *policy* — the receive-path accept/reject behavior and the
|
||||
// send-path signing policy — as opposed to the raw sign/verify primitive (covered in test_crypto).
|
||||
//
|
||||
// The decision logic under test lives inside perhapsDecode()/perhapsEncode() (free functions in
|
||||
// Router.cpp). It only runs after a packet is decrypted, so every case drives a real
|
||||
// encode -> decode round-trip through the default channel (black-box, no production changes).
|
||||
//
|
||||
// Group A receive-side accept/reject matrix (verify, downgrade protection, signer-bit learning)
|
||||
// Group B send-side signing policy (which outgoing packets perhapsEncode signs)
|
||||
// Group C NodeInfoModule's stricter "drop unsigned NodeInfo from a known signer" rule
|
||||
|
||||
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/CryptoEngine.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/Router.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixture identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
|
||||
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;
|
||||
|
||||
// A "small" broadcast payload that leaves room for a 64-byte signature (payload + 64 < 233),
|
||||
// and an "oversized" one that does not (payload + 64 >= 233) yet still encodes within a LoRa frame.
|
||||
static constexpr size_t SMALL_PAYLOAD = 16;
|
||||
static constexpr size_t OVERSIZED_PAYLOAD = 180;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockNodeDB — inject nodes with controlled public keys / signer bits.
|
||||
// Mirrors the pattern in test/test_hop_scaling. meshNodes/numMeshNodes are public on NodeDB.
|
||||
// ---------------------------------------------------------------------------
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
// Add a bare node and return a stable handle (fetch via getMeshNode so the pointer stays valid
|
||||
// even if the vector reallocates after later adds).
|
||||
void addNode(NodeNum num)
|
||||
{
|
||||
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
||||
node.num = num;
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = testNodes.size();
|
||||
}
|
||||
|
||||
void setPublicKey(NodeNum num, const uint8_t *pubKey)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
n->public_key.size = 32;
|
||||
memcpy(n->public_key.bytes, pubKey, 32);
|
||||
}
|
||||
|
||||
void setSignerBit(NodeNum num, bool value)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, value);
|
||||
}
|
||||
|
||||
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||
};
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a decoded packet with a deterministic payload of the requested size.
|
||||
static meshtastic_MeshPacket makeDecoded(NodeNum from, NodeNum to, meshtastic_PortNum port, size_t payloadLen)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.from = from;
|
||||
p.to = to;
|
||||
p.id = 0x12345678;
|
||||
p.channel = 0; // primary channel index (perhapsEncode rewrites this to the channel hash)
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.decoded.portnum = port;
|
||||
p.decoded.payload.size = payloadLen;
|
||||
for (size_t i = 0; i < payloadLen; i++)
|
||||
p.decoded.payload.bytes[i] = (uint8_t)(i & 0xff);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Sign a decoded packet with the CryptoEngine's current key — used to simulate a *remote* signer,
|
||||
// because perhapsEncode only auto-signs packets that originate from us.
|
||||
static void signWithCurrentKey(meshtastic_MeshPacket *p)
|
||||
{
|
||||
bool ok = crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
|
||||
p->decoded.xeddsa_signature.bytes);
|
||||
TEST_ASSERT_TRUE_MESSAGE(ok, "xeddsa_sign failed in test setup");
|
||||
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
|
||||
}
|
||||
|
||||
// Encrypt (perhapsEncode) then decrypt+evaluate (perhapsDecode) the same packet in place.
|
||||
static DecodeState roundTrip(meshtastic_MeshPacket *p)
|
||||
{
|
||||
meshtastic_Routing_Error enc = perhapsEncode(p);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NONE, enc, "perhapsEncode did not succeed");
|
||||
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, p->which_payload_variant,
|
||||
"perhapsEncode left packet unencrypted");
|
||||
return perhapsDecode(p);
|
||||
}
|
||||
|
||||
static bool remoteSignerBit()
|
||||
{
|
||||
return nodeInfoLiteHasXeddsaSigned(mockNodeDB->getMeshNode(REMOTE_NODE));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unity lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
void setUp(void)
|
||||
{
|
||||
// Clean global config/owner; zeroed config => rebroadcast ALL (no KNOWN_ONLY drop) and
|
||||
// security.private_key.size == 0 (PKI encrypt path skipped => simple channel crypto).
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
owner = meshtastic_User_init_zero;
|
||||
|
||||
mockNodeDB = new MockNodeDB();
|
||||
mockNodeDB->clearTestNodes();
|
||||
nodeDB = mockNodeDB;
|
||||
myNodeInfo.my_node_num = LOCAL_NODE; // drives isFromUs()/getFrom()/isToUs()
|
||||
|
||||
// Working primary channel with the default PSK so encrypt/decrypt round-trips.
|
||||
channels.initDefaults();
|
||||
channels.onConfigChanged();
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
delete mockNodeDB;
|
||||
mockNodeDB = nullptr;
|
||||
nodeDB = nullptr;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group A — receive-side accept/reject matrix
|
||||
// ===========================================================================
|
||||
|
||||
// A1: valid signature from a node whose key we know -> accepted, marked signed, signer bit learned.
|
||||
void test_A1_valid_signature_accepted_and_learns_signer(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv); // engine now holds REMOTE's key
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
|
||||
|
||||
TEST_ASSERT_FALSE(remoteSignerBit()); // not known as a signer yet
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
signWithCurrentKey(&p);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_TRUE(p.xeddsa_signed);
|
||||
TEST_ASSERT_TRUE_MESSAGE(remoteSignerBit(), "verified signature must set the signer bit");
|
||||
}
|
||||
|
||||
// A2: a tampered signature from a known key -> dropped.
|
||||
void test_A2_bad_signature_dropped(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
signWithCurrentKey(&p);
|
||||
p.decoded.xeddsa_signature.bytes[0] ^= 0xFF; // corrupt the signature
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p));
|
||||
}
|
||||
|
||||
// A3: signed packet but we have no key for the sender -> accepted unverified, signer bit NOT set.
|
||||
void test_A3_signed_no_pubkey_accepted_unverified(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv);
|
||||
mockNodeDB->addNode(REMOTE_NODE); // node exists, but no public key stored
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
signWithCurrentKey(&p);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_FALSE_MESSAGE(p.xeddsa_signed, "cannot be marked verified without a key");
|
||||
TEST_ASSERT_FALSE_MESSAGE(remoteSignerBit(), "must not learn signer without verifying");
|
||||
}
|
||||
|
||||
// A4: downgrade protection — unsigned small broadcast from a known signer -> dropped.
|
||||
void test_A4_downgrade_unsigned_broadcast_from_signer_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
// from != us, so perhapsEncode leaves it unsigned.
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p));
|
||||
}
|
||||
|
||||
// A5: no prior knowledge — unsigned small broadcast from a non-signer -> accepted.
|
||||
void test_A5_unsigned_broadcast_from_nonsigner_accepted(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_FALSE(p.xeddsa_signed);
|
||||
}
|
||||
|
||||
// A6: unsigned UNICAST from a known signer -> accepted (unicasts are never signed).
|
||||
void test_A6_unsigned_unicast_from_signer_accepted(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
// Unicast to us; PRIVATE_APP avoids the unrelated legacy-DM rejection for TEXT_MESSAGE_APP.
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
}
|
||||
|
||||
// A7: unsigned OVERSIZED broadcast from a known signer -> accepted (couldn't have carried a sig).
|
||||
void test_A7_unsigned_oversized_broadcast_from_signer_accepted(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group B — send-side signing policy (perhapsEncode)
|
||||
// ===========================================================================
|
||||
|
||||
// B1: our own small broadcast is auto-signed (and verifies on the way back in).
|
||||
void test_B1_local_broadcast_is_signed(void)
|
||||
{
|
||||
uint8_t pub[32], priv[32];
|
||||
crypto->generateKeyPair(pub, priv); // engine signs with this; store the matching pubkey for us
|
||||
mockNodeDB->addNode(LOCAL_NODE);
|
||||
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_SIZE, p.decoded.xeddsa_signature.size, "broadcast should be auto-signed");
|
||||
TEST_ASSERT_TRUE(p.xeddsa_signed);
|
||||
}
|
||||
|
||||
// B2: our own unicast is NOT signed.
|
||||
void test_B2_local_unicast_not_signed(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
|
||||
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must not be signed");
|
||||
}
|
||||
|
||||
// B3: our own oversized broadcast is NOT signed (signature wouldn't fit).
|
||||
void test_B3_local_oversized_broadcast_not_signed(void)
|
||||
{
|
||||
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);
|
||||
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "oversized broadcast must not be signed");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group C — NodeInfoModule downgrade drop (stricter: any unsigned NodeInfo from a known signer)
|
||||
// ===========================================================================
|
||||
class NodeInfoTestShim : public NodeInfoModule
|
||||
{
|
||||
public:
|
||||
using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call
|
||||
};
|
||||
|
||||
static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_)
|
||||
{
|
||||
// Broadcast so the module's phone-forward path (which needs `service`) is skipped.
|
||||
meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
|
||||
mp.xeddsa_signed = signed_;
|
||||
return mp;
|
||||
}
|
||||
|
||||
// C1: unsigned NodeInfo from a node that previously signed -> dropped.
|
||||
void test_C1_unsigned_nodeinfo_from_signer_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_TRUE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "unsigned NodeInfo from signer must be dropped");
|
||||
}
|
||||
|
||||
// C2: signed NodeInfo from a known signer -> not dropped by this rule.
|
||||
void test_C2_signed_nodeinfo_from_signer_not_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setSignerBit(REMOTE_NODE, true);
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/true);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
|
||||
}
|
||||
|
||||
// C3: unsigned NodeInfo from a node we've never seen sign -> not dropped.
|
||||
void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
|
||||
|
||||
NodeInfoTestShim shim;
|
||||
meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false);
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
user.is_licensed = owner.is_licensed;
|
||||
|
||||
TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
|
||||
printf("\n=== Group A: receive-side accept/reject ===\n");
|
||||
RUN_TEST(test_A1_valid_signature_accepted_and_learns_signer);
|
||||
RUN_TEST(test_A2_bad_signature_dropped);
|
||||
RUN_TEST(test_A3_signed_no_pubkey_accepted_unverified);
|
||||
RUN_TEST(test_A4_downgrade_unsigned_broadcast_from_signer_dropped);
|
||||
RUN_TEST(test_A5_unsigned_broadcast_from_nonsigner_accepted);
|
||||
RUN_TEST(test_A6_unsigned_unicast_from_signer_accepted);
|
||||
RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted);
|
||||
|
||||
printf("\n=== Group B: send-side signing policy ===\n");
|
||||
RUN_TEST(test_B1_local_broadcast_is_signed);
|
||||
RUN_TEST(test_B2_local_unicast_not_signed);
|
||||
RUN_TEST(test_B3_local_oversized_broadcast_not_signed);
|
||||
|
||||
printf("\n=== Group C: NodeInfoModule downgrade drop ===\n");
|
||||
RUN_TEST(test_C1_unsigned_nodeinfo_from_signer_dropped);
|
||||
RUN_TEST(test_C2_signed_nodeinfo_from_signer_not_dropped);
|
||||
RUN_TEST(test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
|
||||
#else // MESHTASTIC_EXCLUDE_PKI
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
exit(UNITY_END());
|
||||
}
|
||||
void loop() {}
|
||||
|
||||
#endif
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "Channels.h"
|
||||
#include "PositionPrecision.h"
|
||||
#include "TestUtil.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include <cstring>
|
||||
#include <unity.h>
|
||||
|
||||
static meshtastic_Position makePosition()
|
||||
@@ -119,6 +121,92 @@ static void test_getPositionPrecisionForChannel_secondaryWithoutModuleSettingsFa
|
||||
TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(channel));
|
||||
}
|
||||
|
||||
// End-to-end via the channelIndex overload + live channels singleton, exercising getKey()'s 1-byte->16-byte expansion.
|
||||
static void test_getPositionPrecisionForChannel_clampsPreciseOnDefaultKeyChannel()
|
||||
{
|
||||
channels.initDefaults(); // channel 0: primary, default key (psk {0x01}) -> publicly decryptable
|
||||
uint8_t idx = 0;
|
||||
meshtastic_Channel &ch = channels.getByIndex(idx);
|
||||
ch.settings.has_module_settings = true;
|
||||
ch.settings.module_settings.position_precision = 32; // user requests "Precise" on a public channel
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(MAX_POSITION_PRECISION_PUBLIC_KEY, getPositionPrecisionForChannel(idx));
|
||||
}
|
||||
|
||||
static void test_getPositionPrecisionForChannel_keepsPreciseOnStrongKeyChannel()
|
||||
{
|
||||
channels.initDefaults();
|
||||
uint8_t idx = 0;
|
||||
meshtastic_Channel &ch = channels.getByIndex(idx);
|
||||
memset(ch.settings.psk.bytes, 0xAB, 16); // a private 128-bit key, not the defaultpsk family
|
||||
ch.settings.psk.size = 16;
|
||||
ch.settings.has_module_settings = true;
|
||||
ch.settings.module_settings.position_precision = 32;
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(32, getPositionPrecisionForChannel(idx));
|
||||
}
|
||||
|
||||
static CryptoKey makeCryptoKey(const uint8_t *bytes, int length)
|
||||
{
|
||||
CryptoKey k;
|
||||
memset(k.bytes, 0, sizeof(k.bytes));
|
||||
|
||||
// CryptoKey::length is int8_t and CryptoKey::bytes is 32 bytes; keep the helper consistent and overflow-safe.
|
||||
int cappedLen = length;
|
||||
if (cappedLen < 0)
|
||||
cappedLen = -1;
|
||||
else if (cappedLen > static_cast<int>(sizeof(k.bytes)))
|
||||
cappedLen = static_cast<int>(sizeof(k.bytes));
|
||||
|
||||
if (cappedLen > 0 && bytes != nullptr) {
|
||||
memcpy(k.bytes, bytes, static_cast<size_t>(cappedLen));
|
||||
}
|
||||
|
||||
k.length = static_cast<int8_t>(cappedLen);
|
||||
return k;
|
||||
}
|
||||
|
||||
static void test_cryptoKeyIsPublic_openKeyIsPublic()
|
||||
{
|
||||
// length 0 == encryption disabled.
|
||||
TEST_ASSERT_TRUE(cryptoKeyIsPublic(makeCryptoKey(nullptr, 0)));
|
||||
}
|
||||
|
||||
static void test_cryptoKeyIsPublic_defaultKeyIsPublic()
|
||||
{
|
||||
// The expanded default PSK (the 16-byte defaultpsk) -- the case a key-length check misses.
|
||||
TEST_ASSERT_TRUE(cryptoKeyIsPublic(makeCryptoKey(defaultpsk, sizeof(defaultpsk))));
|
||||
}
|
||||
|
||||
static void test_cryptoKeyIsPublic_defaultKeyFamilyVariesLastByte()
|
||||
{
|
||||
// Higher indices (e.g. {0x02}) expand to defaultpsk with only the last byte bumped -- still public.
|
||||
uint8_t key[sizeof(defaultpsk)];
|
||||
memcpy(key, defaultpsk, sizeof(defaultpsk));
|
||||
key[sizeof(defaultpsk) - 1] = static_cast<uint8_t>(key[sizeof(defaultpsk) - 1] + 1);
|
||||
TEST_ASSERT_TRUE(cryptoKeyIsPublic(makeCryptoKey(key, sizeof(key))));
|
||||
}
|
||||
|
||||
static void test_cryptoKeyIsPublic_strongKeyIsPrivate()
|
||||
{
|
||||
uint8_t key[16];
|
||||
memset(key, 0xAB, sizeof(key)); // not the defaultpsk family
|
||||
TEST_ASSERT_FALSE(cryptoKeyIsPublic(makeCryptoKey(key, sizeof(key))));
|
||||
}
|
||||
|
||||
static void test_cryptoKeyIsPublic_aes256KeyIsPrivate()
|
||||
{
|
||||
uint8_t key[32];
|
||||
memset(key, 0x11, sizeof(key));
|
||||
TEST_ASSERT_FALSE(cryptoKeyIsPublic(makeCryptoKey(key, sizeof(key))));
|
||||
}
|
||||
|
||||
static void test_cryptoKeyIsPublic_invalidKeyIsNotPublic()
|
||||
{
|
||||
// length < 0 == no/invalid key (e.g. a disabled channel); it carries no traffic to leak.
|
||||
TEST_ASSERT_FALSE(cryptoKeyIsPublic(makeCryptoKey(nullptr, -1)));
|
||||
}
|
||||
|
||||
void setUp(void) {}
|
||||
|
||||
void tearDown(void) {}
|
||||
@@ -136,6 +224,14 @@ void setup()
|
||||
RUN_TEST(test_getPositionPrecisionForChannel_explicitZeroDisablesPrimary);
|
||||
RUN_TEST(test_getPositionPrecisionForChannel_primaryWithoutModuleSettingsFailsClosed);
|
||||
RUN_TEST(test_getPositionPrecisionForChannel_secondaryWithoutModuleSettingsFailsClosed);
|
||||
RUN_TEST(test_getPositionPrecisionForChannel_clampsPreciseOnDefaultKeyChannel);
|
||||
RUN_TEST(test_getPositionPrecisionForChannel_keepsPreciseOnStrongKeyChannel);
|
||||
RUN_TEST(test_cryptoKeyIsPublic_openKeyIsPublic);
|
||||
RUN_TEST(test_cryptoKeyIsPublic_defaultKeyIsPublic);
|
||||
RUN_TEST(test_cryptoKeyIsPublic_defaultKeyFamilyVariesLastByte);
|
||||
RUN_TEST(test_cryptoKeyIsPublic_strongKeyIsPrivate);
|
||||
RUN_TEST(test_cryptoKeyIsPublic_aes256KeyIsPrivate);
|
||||
RUN_TEST(test_cryptoKeyIsPublic_invalidKeyIsNotPublic);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "TestUtil.h"
|
||||
#include <cstdlib>
|
||||
#include <unity.h>
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Tests for src/mesh/TypeConversions.cpp covering:
|
||||
// - bitfield bit collapse on store + extraction round-trip
|
||||
// - long_name / short_name truncation at the new max_size:25 / 5 boundaries
|
||||
// - long_name / short_name truncation at the storage boundaries (wire User
|
||||
// stays 40 wide for decoding legacy senders; NodeInfoLite stores 25 / 5)
|
||||
// - wire-level decode acceptance of legacy 39-byte long_names
|
||||
// - public_key / hw_model / role pass-through
|
||||
// - thin vs bundled NodeInfo emission
|
||||
//
|
||||
@@ -10,6 +12,8 @@
|
||||
#include "NodeDB.h"
|
||||
#include "TestUtil.h"
|
||||
#include "TypeConversions.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "meshUtils.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <unity.h>
|
||||
@@ -128,6 +132,46 @@ void test_long_name_truncated_utf8_boundary_sanitized(void)
|
||||
TEST_ASSERT_EQUAL_INT('?', lite.long_name[23]);
|
||||
}
|
||||
|
||||
// ---------- wire decode width (decode-liberal, store-narrow) ------------------
|
||||
|
||||
// Hand-built wire-format User payload: field 2 (long_name), wire type 2.
|
||||
static size_t makeUserPayload(uint8_t *buf, size_t nameLen)
|
||||
{
|
||||
size_t i = 0;
|
||||
buf[i++] = 0x12; // tag: field 2, length-delimited
|
||||
buf[i++] = (uint8_t)nameLen;
|
||||
for (size_t j = 0; j < nameLen; j++)
|
||||
buf[i++] = (uint8_t)('A' + (j % 26));
|
||||
return i;
|
||||
}
|
||||
|
||||
void test_wire_decode_accepts_legacy_39_byte_long_name(void)
|
||||
{
|
||||
// The longest name a sender built against the old max_size:40 can emit.
|
||||
// nanopb halts on string overflow rather than truncating, so this only
|
||||
// passes while the wire-facing meshtastic_User stays 40 wide.
|
||||
uint8_t buf[64];
|
||||
size_t len = makeUserPayload(buf, 39);
|
||||
meshtastic_User u = meshtastic_User_init_zero;
|
||||
TEST_ASSERT_TRUE(pb_decode_from_bytes(buf, len, &meshtastic_User_msg, &u));
|
||||
TEST_ASSERT_EQUAL_INT(39, (int)strlen(u.long_name));
|
||||
|
||||
// ...and the store boundary clamps it to the local cap.
|
||||
meshtastic_NodeInfoLite lite = meshtastic_NodeInfoLite_init_default;
|
||||
TypeConversions::CopyUserToNodeInfoLite(&lite, u);
|
||||
TEST_ASSERT_EQUAL_INT(MAX_LONG_NAME_BYTES, (int)strlen(lite.long_name));
|
||||
}
|
||||
|
||||
void test_wire_decode_rejects_name_beyond_wire_limit(void)
|
||||
{
|
||||
// 45 bytes exceeds even the 40-byte wire buffer; the whole message is
|
||||
// rejected (documents the hard outer bound).
|
||||
uint8_t buf[64];
|
||||
size_t len = makeUserPayload(buf, 45);
|
||||
meshtastic_User u = meshtastic_User_init_zero;
|
||||
TEST_ASSERT_FALSE(pb_decode_from_bytes(buf, len, &meshtastic_User_msg, &u));
|
||||
}
|
||||
|
||||
// ---------- short_name truncation --------------------------------------------
|
||||
|
||||
void test_short_name_passes_through(void)
|
||||
@@ -383,6 +427,8 @@ void setup()
|
||||
RUN_TEST(test_long_name_truncates_when_too_long);
|
||||
RUN_TEST(test_long_name_round_trip_to_wire);
|
||||
RUN_TEST(test_long_name_truncated_utf8_boundary_sanitized);
|
||||
RUN_TEST(test_wire_decode_accepts_legacy_39_byte_long_name);
|
||||
RUN_TEST(test_wire_decode_rejects_name_beyond_wire_limit);
|
||||
RUN_TEST(test_short_name_passes_through);
|
||||
RUN_TEST(test_short_name_truncates_when_too_long);
|
||||
RUN_TEST(test_bitfield_is_licensed_round_trip);
|
||||
|
||||
@@ -163,6 +163,47 @@ void test_above_max_codepoint()
|
||||
TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf)));
|
||||
}
|
||||
|
||||
// --- clampLongName: local 24-byte cap over wider wire buffers ---
|
||||
|
||||
void test_clamp_long_name_short_unchanged()
|
||||
{
|
||||
char buf[40] = "Kevin Hester";
|
||||
clampLongName(buf);
|
||||
TEST_ASSERT_EQUAL_STRING("Kevin Hester", buf);
|
||||
}
|
||||
|
||||
void test_clamp_long_name_exact_cap_unchanged()
|
||||
{
|
||||
char buf[40] = "abcdefghijklmnopqrstuvwx"; // exactly 24 bytes
|
||||
clampLongName(buf);
|
||||
TEST_ASSERT_EQUAL_STRING("abcdefghijklmnopqrstuvwx", buf);
|
||||
}
|
||||
|
||||
void test_clamp_long_name_truncates_39_bytes()
|
||||
{
|
||||
char buf[40];
|
||||
memset(buf, 'a', 39);
|
||||
buf[39] = '\0';
|
||||
clampLongName(buf);
|
||||
TEST_ASSERT_EQUAL_INT(MAX_LONG_NAME_BYTES, (int)strlen(buf));
|
||||
}
|
||||
|
||||
void test_clamp_long_name_fixes_partial_rune_at_cut()
|
||||
{
|
||||
// 22 ASCII then a 4-byte emoji straddling the 24-byte boundary
|
||||
char buf[40];
|
||||
memset(buf, 'a', 22);
|
||||
buf[22] = '\xF0';
|
||||
buf[23] = '\x9F';
|
||||
buf[24] = '\x8C';
|
||||
buf[25] = '\x99';
|
||||
buf[26] = '\0';
|
||||
clampLongName(buf);
|
||||
TEST_ASSERT_EQUAL_INT(24, (int)strlen(buf));
|
||||
TEST_ASSERT_EQUAL_INT('?', buf[22]);
|
||||
TEST_ASSERT_EQUAL_INT('?', buf[23]);
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
UNITY_BEGIN();
|
||||
@@ -191,6 +232,12 @@ void setup()
|
||||
RUN_TEST(test_valid_max_codepoint);
|
||||
RUN_TEST(test_above_max_codepoint);
|
||||
|
||||
// clampLongName
|
||||
RUN_TEST(test_clamp_long_name_short_unchanged);
|
||||
RUN_TEST(test_clamp_long_name_exact_cap_unchanged);
|
||||
RUN_TEST(test_clamp_long_name_truncates_39_bytes);
|
||||
RUN_TEST(test_clamp_long_name_fixes_partial_rune_at_cut);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
Executable
+668
@@ -0,0 +1,668 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""
|
||||
Lockdown passphrase provisioning / unlock / lock-now over USB serial.
|
||||
|
||||
Speaks the AdminMessage.lockdown_auth / FromRadio.lockdown_status wire format
|
||||
introduced for MESHTASTIC_LOCKDOWN firmware builds. **This tool is the
|
||||
canonical reference implementation** — downstream clients (Meshtastic-Android,
|
||||
in-tree TCP/BLE tools) should mirror its packet shape.
|
||||
|
||||
==============================================================================
|
||||
SECURITY MODEL — READ BEFORE EXTENDING
|
||||
==============================================================================
|
||||
|
||||
* USB-ONLY by design. The passphrase is sent **in cleartext over the USB
|
||||
CDC link** between this script and the device's bootloader-managed
|
||||
serial channel. The link is local; an attacker would need physical
|
||||
access to the cable to read it.
|
||||
* DO NOT extend to TCP or BLE transports without first redesigning the
|
||||
handshake — both broadcast the wire format over channels an attacker
|
||||
can passively sniff or actively MITM.
|
||||
* Passphrases entered at a shell prompt land in your shell history. Use
|
||||
--passphrase-file (mode 0600) or the interactive prompt for anything
|
||||
you care about keeping. --passphrase on the command line requires
|
||||
--insecure-passphrase-on-cmdline as an explicit acknowledgement.
|
||||
* Passphrase cannot be recovered. There is no firmware-side reset that
|
||||
leaves stored data intact; losing the passphrase means factory-erasing
|
||||
the device's flash partition.
|
||||
|
||||
==============================================================================
|
||||
REQUIREMENTS
|
||||
==============================================================================
|
||||
|
||||
A meshtastic Python package built against protobufs that include
|
||||
LockdownAuth (admin.proto tag 104) and LockdownStatus (mesh.proto tag 18).
|
||||
If your installed package is older than that, regenerate the Python proto
|
||||
bindings from this repo's protobufs/ submodule and either overlay them into
|
||||
your site-packages or add them to PYTHONPATH before this script's imports.
|
||||
|
||||
==============================================================================
|
||||
USAGE
|
||||
==============================================================================
|
||||
|
||||
# Interactive provision (prompts twice for passphrase, confirms intent):
|
||||
tools/lockdown_provision.py --port /dev/cu.usbmodem* provision
|
||||
|
||||
# Provision with a passphrase from a 0600-mode file:
|
||||
tools/lockdown_provision.py --port /dev/cu.usbmodem* \\
|
||||
provision --passphrase-file ~/.lockdown-passphrase
|
||||
|
||||
# Re-authenticate this connection on an already-provisioned device:
|
||||
tools/lockdown_provision.py --port /dev/cu.usbmodem* unlock
|
||||
|
||||
# Lock the device immediately (forces reboot into locked state):
|
||||
tools/lockdown_provision.py --port /dev/cu.usbmodem* lock-now --yes
|
||||
|
||||
# Turn lockdown OFF (runtime toggle; reverts storage to plaintext, reboots):
|
||||
tools/lockdown_provision.py --port /dev/cu.usbmodem* disable
|
||||
|
||||
# Just listen for LockdownStatus notifications:
|
||||
tools/lockdown_provision.py --port /dev/cu.usbmodem* watch --seconds 30
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
try:
|
||||
import meshtastic
|
||||
import meshtastic.mesh_interface
|
||||
import meshtastic.serial_interface
|
||||
from meshtastic.protobuf import admin_pb2, mesh_pb2, portnums_pb2
|
||||
except ImportError:
|
||||
sys.stderr.write(
|
||||
"error: meshtastic Python package not installed\n"
|
||||
" pip install meshtastic # or: pipx install meshtastic\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# Sanity-check the schema is new enough.
|
||||
_missing = []
|
||||
if not hasattr(admin_pb2, "LockdownAuth"):
|
||||
_missing.append("admin_pb2.LockdownAuth")
|
||||
if not hasattr(mesh_pb2, "LockdownStatus"):
|
||||
_missing.append("mesh_pb2.LockdownStatus")
|
||||
if _missing:
|
||||
sys.stderr.write(
|
||||
"error: your meshtastic Python package is too old for the lockdown\n"
|
||||
f" wire format. Missing: {', '.join(_missing)}\n"
|
||||
" Update to a meshtastic release built against protobufs that\n"
|
||||
" contain AdminMessage.lockdown_auth (tag 104) and\n"
|
||||
" FromRadio.lockdown_status (tag 18). See the firmware repo's\n"
|
||||
" protobufs/ submodule for the proto definitions.\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# Mirrors meshtastic_LockdownStatus_State in mesh.pb.h.
|
||||
_STATE_NAMES = {
|
||||
mesh_pb2.LockdownStatus.STATE_UNSPECIFIED: "UNSPECIFIED",
|
||||
mesh_pb2.LockdownStatus.NEEDS_PROVISION: "NEEDS_PROVISION",
|
||||
mesh_pb2.LockdownStatus.LOCKED: "LOCKED",
|
||||
mesh_pb2.LockdownStatus.UNLOCKED: "UNLOCKED",
|
||||
mesh_pb2.LockdownStatus.UNLOCK_FAILED: "UNLOCK_FAILED",
|
||||
}
|
||||
# DISABLED arrived with the runtime-toggle schema. Guard so older bindings that
|
||||
# only know the original five states still import cleanly; without this a
|
||||
# capable-but-off boot would print an opaque "state=<num>" instead of DISABLED.
|
||||
if hasattr(mesh_pb2.LockdownStatus, "DISABLED"):
|
||||
_STATE_NAMES[mesh_pb2.LockdownStatus.DISABLED] = "DISABLED"
|
||||
|
||||
|
||||
# Internal coordination between the FromRadio listener thread and the
|
||||
# main thread so we can block until the device replies (M29) instead of
|
||||
# sleep()ing a fixed window and hoping.
|
||||
class StatusFuture:
|
||||
"""Single-shot future for the next LockdownStatus that arrives after arm()."""
|
||||
|
||||
def __init__(self):
|
||||
self._event = threading.Event()
|
||||
self._status: mesh_pb2.LockdownStatus | None = None
|
||||
|
||||
def deliver(self, status: mesh_pb2.LockdownStatus) -> None:
|
||||
if not self._event.is_set():
|
||||
self._status = status
|
||||
self._event.set()
|
||||
|
||||
def wait(self, timeout: float) -> mesh_pb2.LockdownStatus | None:
|
||||
return self._status if self._event.wait(timeout) else None
|
||||
|
||||
|
||||
_STATUS_FUTURE: StatusFuture | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport guard (M30)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_NON_LOCAL_PREFIXES = (
|
||||
"tcp:",
|
||||
"tcp://",
|
||||
"ble:",
|
||||
"ble://",
|
||||
"udp:",
|
||||
"udp://",
|
||||
"ws:",
|
||||
"wss:",
|
||||
)
|
||||
|
||||
|
||||
def reject_non_usb_port(port: str | None) -> None:
|
||||
"""Refuse anything that looks like a remote transport.
|
||||
|
||||
The wire format sends the passphrase in cleartext. That's tolerable
|
||||
over USB CDC (physical-attacker model) and explicitly NOT tolerable
|
||||
over TCP/BLE/UDP. Reject any --port that names one of those schemes
|
||||
so a copy-paste of an example into a different shell can't silently
|
||||
leak credentials.
|
||||
"""
|
||||
if not port:
|
||||
return
|
||||
lowered = port.lower()
|
||||
for prefix in _NON_LOCAL_PREFIXES:
|
||||
if lowered.startswith(prefix):
|
||||
sys.stderr.write(
|
||||
f"error: refusing --port {port!r}: this tool is USB-only by\n"
|
||||
" design (passphrase is cleartext on the wire). See the\n"
|
||||
" SECURITY MODEL block at the top of this file.\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passphrase input (M26)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def read_passphrase_from_file(path: str) -> bytes:
|
||||
"""Read a passphrase from a 0600-mode file.
|
||||
|
||||
Refuse to read if the file is world- or group-readable to avoid
|
||||
silently using a passphrase that another user could lift off the
|
||||
filesystem.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError as exc:
|
||||
sys.exit(f"error: cannot stat {path}: {exc}")
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
if mode & 0o077:
|
||||
sys.exit(
|
||||
f"error: {path} mode is {oct(mode)} — must be 0600 (operator-only).\n"
|
||||
f" run: chmod 600 {path}"
|
||||
)
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
except OSError as exc:
|
||||
sys.exit(f"error: cannot read {path}: {exc}")
|
||||
# Strip a single trailing newline (common when authored with `echo`).
|
||||
if raw.endswith(b"\r\n"):
|
||||
raw = raw[:-2]
|
||||
elif raw.endswith(b"\n"):
|
||||
raw = raw[:-1]
|
||||
return raw
|
||||
|
||||
|
||||
def prompt_passphrase(confirm: bool) -> bytes:
|
||||
"""Interactive prompt. confirm=True double-enters and matches."""
|
||||
pp = getpass.getpass("passphrase: ").encode("utf-8")
|
||||
if confirm:
|
||||
pp2 = getpass.getpass("passphrase (confirm): ").encode("utf-8")
|
||||
if pp != pp2:
|
||||
sys.exit("error: passphrases do not match")
|
||||
return pp
|
||||
|
||||
|
||||
def gather_passphrase(args, *, confirm: bool) -> bytes:
|
||||
"""Resolve the passphrase from --passphrase / --passphrase-file / prompt.
|
||||
|
||||
Order of precedence: argv (with --insecure-passphrase-on-cmdline) >
|
||||
--passphrase-file > interactive prompt.
|
||||
"""
|
||||
if args.passphrase is not None:
|
||||
if not args.insecure_passphrase_on_cmdline:
|
||||
sys.exit(
|
||||
"error: --passphrase on argv requires "
|
||||
"--insecure-passphrase-on-cmdline.\n"
|
||||
" Reason: argv lands in shell history and is visible via\n"
|
||||
" `ps`. Prefer --passphrase-file or the interactive prompt."
|
||||
)
|
||||
sys.stderr.write(
|
||||
"warning: passphrase passed on argv — visible to other users via\n"
|
||||
" ps(1), and persisted in your shell history file.\n"
|
||||
)
|
||||
pp = args.passphrase.encode("utf-8")
|
||||
elif args.passphrase_file is not None:
|
||||
pp = read_passphrase_from_file(args.passphrase_file)
|
||||
else:
|
||||
pp = prompt_passphrase(confirm)
|
||||
|
||||
if not 1 <= len(pp) <= 32:
|
||||
sys.exit(f"error: passphrase must be 1..32 bytes utf-8, got {len(pp)}")
|
||||
return pp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FromRadio notification interception (L7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def install_notification_printer(iface) -> None:
|
||||
"""Wrap _handleFromRadio to print LockdownStatus frames and feed the future.
|
||||
|
||||
meshtastic-python (as of the version this script was last tested
|
||||
against) does not dispatch LockdownStatus on a public pubsub topic.
|
||||
We hook the private _handleFromRadio entry point, which is the
|
||||
fragility flagged in the audit's L7 finding. If a future lib release
|
||||
breaks this, the missing-attr error will be obvious; until then this
|
||||
is the only seam available.
|
||||
"""
|
||||
original = getattr(iface, "_handleFromRadio", None)
|
||||
if original is None:
|
||||
sys.exit(
|
||||
"error: meshtastic.serial_interface.SerialInterface has no\n"
|
||||
" _handleFromRadio method. The lib's private API changed —\n"
|
||||
" this tool needs to be updated. See L7 in the audit notes."
|
||||
)
|
||||
|
||||
def wrapped(fromRadioBytes):
|
||||
try:
|
||||
fr = mesh_pb2.FromRadio()
|
||||
fr.ParseFromString(fromRadioBytes)
|
||||
if fr.HasField("lockdown_status"):
|
||||
ls = fr.lockdown_status
|
||||
state = _STATE_NAMES.get(ls.state, f"state={ls.state}")
|
||||
parts = [state]
|
||||
if ls.lock_reason:
|
||||
parts.append(f"reason={ls.lock_reason}")
|
||||
if ls.boots_remaining:
|
||||
parts.append(f"boots={ls.boots_remaining}")
|
||||
if ls.valid_until_epoch:
|
||||
parts.append(f"until={ls.valid_until_epoch}")
|
||||
if ls.backoff_seconds:
|
||||
parts.append(f"backoff={ls.backoff_seconds}s")
|
||||
print(f"[device:LOCKDOWN] {' '.join(parts)}", flush=True)
|
||||
if _STATUS_FUTURE is not None:
|
||||
_STATUS_FUTURE.deliver(ls)
|
||||
except Exception as exc: # noqa: BLE001 — best-effort logging only
|
||||
print(f"[notif-parse-error] {exc}", flush=True)
|
||||
return original(fromRadioBytes)
|
||||
|
||||
iface._handleFromRadio = wrapped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LockdownAuth construction + send
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_lockdown_auth(
|
||||
passphrase: bytes,
|
||||
boots: int,
|
||||
hours: int,
|
||||
max_session_seconds: int,
|
||||
lock_now: bool,
|
||||
disable: bool = False,
|
||||
):
|
||||
la = admin_pb2.LockdownAuth()
|
||||
if passphrase:
|
||||
la.passphrase = passphrase
|
||||
la.boots_remaining = max(0, min(255, boots))
|
||||
la.valid_until_epoch = int(time.time()) + hours * 3600 if hours > 0 else 0
|
||||
la.max_session_seconds = max(0, max_session_seconds)
|
||||
la.lock_now = lock_now
|
||||
if disable:
|
||||
# disable lives on the runtime-toggle schema. Fail loudly on older
|
||||
# bindings rather than silently sending an unlock the firmware would
|
||||
# honour as a normal auth.
|
||||
if not hasattr(la, "disable"):
|
||||
sys.exit(
|
||||
"error: your meshtastic Python package is too old for the\n"
|
||||
" runtime-toggle disable flow (LockdownAuth.disable\n"
|
||||
" missing). Update the protobuf bindings."
|
||||
)
|
||||
la.disable = True
|
||||
return la
|
||||
|
||||
|
||||
def send_lockdown_auth(iface, la, label: str) -> int:
|
||||
"""Send AdminMessage.lockdown_auth to this node. Returns mp.id on success."""
|
||||
if iface.myInfo is None:
|
||||
sys.exit(
|
||||
"error: device never sent my_info; cannot determine destination nodenum"
|
||||
)
|
||||
my_node_num = iface.myInfo.my_node_num
|
||||
|
||||
am = admin_pb2.AdminMessage()
|
||||
am.lockdown_auth.CopyFrom(la)
|
||||
|
||||
# _generatePacketId is private but stable across recent lib versions.
|
||||
generate_id = getattr(iface, "_generatePacketId", None)
|
||||
if generate_id is None:
|
||||
sys.exit("error: meshtastic lib missing _generatePacketId — see L7 note")
|
||||
mp = mesh_pb2.MeshPacket()
|
||||
mp.to = my_node_num
|
||||
mp.id = generate_id()
|
||||
mp.channel = 0
|
||||
mp.want_ack = True
|
||||
mp.hop_limit = 7
|
||||
mp.hop_start = 7
|
||||
mp.priority = mesh_pb2.MeshPacket.Priority.RELIABLE
|
||||
mp.decoded.portnum = portnums_pb2.PortNum.ADMIN_APP
|
||||
mp.decoded.payload = am.SerializeToString()
|
||||
# NOTE: pki_encrypted intentionally left False — see top-of-file note in
|
||||
# the original tool. Lockdown firmware drops PKI-encrypted ToRadio.
|
||||
|
||||
tr = mesh_pb2.ToRadio()
|
||||
tr.packet.CopyFrom(mp)
|
||||
|
||||
send_to_radio = getattr(iface, "_sendToRadio", None)
|
||||
if send_to_radio is None:
|
||||
sys.exit("error: meshtastic lib missing _sendToRadio — see L7 note")
|
||||
print(
|
||||
f"[client] sending {label} (to=0x{my_node_num:08x}, id={mp.id}) ...", flush=True
|
||||
)
|
||||
send_to_radio(tr)
|
||||
return mp.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _await_status(timeout: float) -> mesh_pb2.LockdownStatus | None:
|
||||
if _STATUS_FUTURE is None:
|
||||
time.sleep(timeout)
|
||||
return None
|
||||
print(f"[client] waiting up to {timeout}s for LockdownStatus ...", flush=True)
|
||||
return _STATUS_FUTURE.wait(timeout)
|
||||
|
||||
|
||||
def cmd_provision(iface, args) -> int:
|
||||
# M27: this is the destructive setup step. Warn explicitly and require
|
||||
# a typed confirmation unless --yes was supplied.
|
||||
if not args.yes:
|
||||
sys.stderr.write(
|
||||
"WARNING: first-time provision binds this device to a passphrase\n"
|
||||
" that cannot be recovered. If you lose it, the only way\n"
|
||||
" back is a factory-erase that wipes ALL stored state\n"
|
||||
" (channels, contacts, messages, position, etc.).\n"
|
||||
)
|
||||
ans = input("Type 'yes' to continue: ").strip().lower()
|
||||
if ans != "yes":
|
||||
sys.exit("aborted")
|
||||
|
||||
pp = gather_passphrase(args, confirm=True)
|
||||
la = build_lockdown_auth(
|
||||
pp,
|
||||
args.boots,
|
||||
args.hours,
|
||||
args.max_session_seconds,
|
||||
lock_now=False,
|
||||
)
|
||||
global _STATUS_FUTURE
|
||||
_STATUS_FUTURE = StatusFuture()
|
||||
send_lockdown_auth(iface, la, "provision/unlock")
|
||||
status = _await_status(args.wait)
|
||||
if status is None:
|
||||
sys.stderr.write("warning: no LockdownStatus received within wait window\n")
|
||||
return 1
|
||||
return _exit_code_for_status(status)
|
||||
|
||||
|
||||
def cmd_unlock(iface, args) -> int:
|
||||
pp = gather_passphrase(args, confirm=False)
|
||||
la = build_lockdown_auth(
|
||||
pp,
|
||||
args.boots,
|
||||
args.hours,
|
||||
args.max_session_seconds,
|
||||
lock_now=False,
|
||||
)
|
||||
global _STATUS_FUTURE
|
||||
_STATUS_FUTURE = StatusFuture()
|
||||
send_lockdown_auth(iface, la, "unlock")
|
||||
status = _await_status(args.wait)
|
||||
if status is None:
|
||||
sys.stderr.write("warning: no LockdownStatus received within wait window\n")
|
||||
return 1
|
||||
return _exit_code_for_status(status)
|
||||
|
||||
|
||||
def cmd_lock(iface, args) -> int:
|
||||
if not args.yes:
|
||||
sys.stderr.write(
|
||||
"WARNING: 'lock' will revoke all current auth and reboot the\n"
|
||||
" device into the locked state. The next connect will\n"
|
||||
" require the passphrase.\n"
|
||||
)
|
||||
ans = input("Type 'yes' to continue: ").strip().lower()
|
||||
if ans != "yes":
|
||||
sys.exit("aborted")
|
||||
la = build_lockdown_auth(b"", 0, 0, 0, lock_now=True)
|
||||
global _STATUS_FUTURE
|
||||
_STATUS_FUTURE = StatusFuture()
|
||||
send_lockdown_auth(iface, la, "LOCK NOW")
|
||||
# Device may not get an UNLOCKED/LOCKED back to us before it reboots;
|
||||
# accept the lack of a status as "probably worked" for this command.
|
||||
status = _await_status(args.wait)
|
||||
if status is None:
|
||||
print("[client] no status received (device may already be rebooting)")
|
||||
return 0
|
||||
return _exit_code_for_status(status)
|
||||
|
||||
|
||||
def cmd_disable(iface, args) -> int:
|
||||
# Runtime-toggle OFF. Unlike 'lock' (which reboots back into the locked
|
||||
# state), 'disable' turns lockdown off entirely: the firmware re-verifies
|
||||
# the passphrase to load the DEK, reverts at-rest encryption to plaintext,
|
||||
# then reboots into normal mode. A non-empty passphrase is REQUIRED — the
|
||||
# firmware rejects an empty one with UNLOCK_FAILED.
|
||||
if not args.yes:
|
||||
sys.stderr.write(
|
||||
"WARNING: 'disable' turns lockdown OFF on this device. Stored files\n"
|
||||
" are reverted to plaintext, per-connection admin auth is no\n"
|
||||
" longer enforced, and the device reboots into normal mode.\n"
|
||||
" (APPROTECT is NOT reversed.)\n"
|
||||
)
|
||||
ans = input("Type 'yes' to continue: ").strip().lower()
|
||||
if ans != "yes":
|
||||
sys.exit("aborted")
|
||||
pp = gather_passphrase(args, confirm=False)
|
||||
# TTL/session fields are ignored by the firmware on a disable request.
|
||||
la = build_lockdown_auth(pp, 0, 0, 0, lock_now=False, disable=True)
|
||||
global _STATUS_FUTURE
|
||||
_STATUS_FUTURE = StatusFuture()
|
||||
send_lockdown_auth(iface, la, "disable")
|
||||
# On success the firmware decrypts every stored file before broadcasting
|
||||
# DISABLED, so a large node DB can take longer than the default wait — bump
|
||||
# --wait if you see no status. The DISABLED broadcast precedes the reboot.
|
||||
status = _await_status(args.wait)
|
||||
if status is None:
|
||||
sys.stderr.write("warning: no LockdownStatus received within wait window\n")
|
||||
return 1
|
||||
return _exit_code_for_status(status)
|
||||
|
||||
|
||||
def cmd_watch(_iface, args) -> int:
|
||||
print(
|
||||
f"[client] watching for LockdownStatus notifications for {args.seconds}s — Ctrl-C to exit early",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
time.sleep(args.seconds)
|
||||
except KeyboardInterrupt:
|
||||
print("[client] interrupted")
|
||||
return 0
|
||||
|
||||
|
||||
def _exit_code_for_status(status: mesh_pb2.LockdownStatus) -> int:
|
||||
"""Map the final LockdownStatus to a shell exit code (M29)."""
|
||||
if status.state == mesh_pb2.LockdownStatus.UNLOCKED:
|
||||
return 0
|
||||
# DISABLED is a terminal success: a runtime-toggle 'disable' completed, or a
|
||||
# capable-but-off device reported its state. Guarded for older bindings.
|
||||
if (
|
||||
hasattr(mesh_pb2.LockdownStatus, "DISABLED")
|
||||
and status.state == mesh_pb2.LockdownStatus.DISABLED
|
||||
):
|
||||
return 0
|
||||
if status.state == mesh_pb2.LockdownStatus.UNLOCK_FAILED:
|
||||
sys.stderr.write(
|
||||
"error: UNLOCK_FAILED"
|
||||
+ (
|
||||
f" — try again in {status.backoff_seconds}s"
|
||||
if status.backoff_seconds
|
||||
else ""
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
return 4
|
||||
if status.state == mesh_pb2.LockdownStatus.LOCKED:
|
||||
# Common: the firmware emitted LOCKED before our auth could process,
|
||||
# or this is the LOCKED-with-needs_auth that follows a successful
|
||||
# provision-then-disconnect cycle. Treat as ambiguous.
|
||||
return 3
|
||||
if status.state == mesh_pb2.LockdownStatus.NEEDS_PROVISION:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argparse + entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _add_passphrase_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--passphrase",
|
||||
help="passphrase on cmdline (requires --insecure-passphrase-on-cmdline)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--passphrase-file", help="path to a 0600-mode file containing the passphrase"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--insecure-passphrase-on-cmdline",
|
||||
action="store_true",
|
||||
help="acknowledge that --passphrase will be visible via ps and shell history",
|
||||
)
|
||||
|
||||
|
||||
def _add_ttl_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--boots",
|
||||
type=int,
|
||||
default=0,
|
||||
help="boot-count token TTL (0 = firmware default 50, max 255)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hours",
|
||||
type=int,
|
||||
default=0,
|
||||
help="wall-clock token TTL in hours (0 = no time limit)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-session-seconds",
|
||||
type=int,
|
||||
default=0,
|
||||
help="per-boot uptime cap on the unlocked session (0 = unlimited)",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__.split("\n\n")[0],
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
ap.add_argument(
|
||||
"--port",
|
||||
help="USB serial device path, e.g. /dev/cu.usbmodem* — TCP/BLE/UDP rejected",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--wait",
|
||||
type=float,
|
||||
default=8.0,
|
||||
help="seconds to wait for response (default: 8)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--yes", "-y", action="store_true", help="skip interactive confirmation prompts"
|
||||
)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_prov = sub.add_parser("provision", help="first-time set passphrase (binds DEK)")
|
||||
_add_passphrase_args(p_prov)
|
||||
_add_ttl_args(p_prov)
|
||||
p_prov.set_defaults(func=cmd_provision)
|
||||
|
||||
p_unlock = sub.add_parser(
|
||||
"unlock", help="re-authenticate this connection with the existing passphrase"
|
||||
)
|
||||
_add_passphrase_args(p_unlock)
|
||||
_add_ttl_args(p_unlock)
|
||||
p_unlock.set_defaults(func=cmd_unlock)
|
||||
|
||||
p_lock = sub.add_parser(
|
||||
"lock", aliases=["lock-now"], help="send LOCK NOW; device reboots locked"
|
||||
)
|
||||
p_lock.set_defaults(func=cmd_lock)
|
||||
|
||||
p_disable = sub.add_parser(
|
||||
"disable",
|
||||
help="turn lockdown OFF (runtime toggle); requires passphrase, reverts to plaintext",
|
||||
)
|
||||
_add_passphrase_args(p_disable)
|
||||
p_disable.set_defaults(func=cmd_disable)
|
||||
|
||||
p_watch = sub.add_parser(
|
||||
"watch", help="just listen for LockdownStatus notifications"
|
||||
)
|
||||
p_watch.add_argument(
|
||||
"--seconds", type=float, default=60.0, help="how long to watch (default: 60)"
|
||||
)
|
||||
p_watch.set_defaults(func=cmd_watch)
|
||||
|
||||
args = ap.parse_args()
|
||||
reject_non_usb_port(args.port)
|
||||
|
||||
sys.stderr.write(
|
||||
"lockdown_provision: USB-only, passphrase travels cleartext on the cable.\n"
|
||||
" See SECURITY MODEL block at top of this file.\n"
|
||||
)
|
||||
|
||||
print(f"[client] opening serial port (port={args.port or 'auto'}) ...", flush=True)
|
||||
iface = meshtastic.serial_interface.SerialInterface(
|
||||
devPath=args.port,
|
||||
noNodes=True,
|
||||
connectNow=False,
|
||||
)
|
||||
install_notification_printer(iface)
|
||||
try:
|
||||
iface.connect()
|
||||
print("[client] config handshake complete", flush=True)
|
||||
except meshtastic.mesh_interface.MeshInterface.MeshInterfaceError as exc:
|
||||
# Locked device may never send config_complete_id; we can still send
|
||||
# lockdown_auth because the firmware reads ToRadio independent of the
|
||||
# client's config-download state.
|
||||
print(f"[client] handshake timed out ({exc}); proceeding anyway", flush=True)
|
||||
|
||||
rc = 1
|
||||
try:
|
||||
rc = args.func(iface, args)
|
||||
finally:
|
||||
print("[client] closing", flush=True)
|
||||
iface.close()
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -70,8 +70,8 @@ lib_deps =
|
||||
https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip
|
||||
# renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib
|
||||
lewisxhe/XPowersLib@0.3.3
|
||||
# renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto
|
||||
rweather/Crypto@0.4.0
|
||||
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
|
||||
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
|
||||
|
||||
lib_ignore =
|
||||
segger_rtt
|
||||
|
||||
@@ -55,5 +55,5 @@ lib_deps =
|
||||
https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip
|
||||
# renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib
|
||||
lewisxhe/XPowersLib@0.3.3
|
||||
# renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto
|
||||
rweather/Crypto@0.4.0
|
||||
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
|
||||
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
|
||||
|
||||
@@ -104,7 +104,7 @@ lib_deps =
|
||||
${networking_extra.lib_deps}
|
||||
${environmental_base.lib_deps}
|
||||
${radiolib_base.lib_deps}
|
||||
# renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto
|
||||
rweather/Crypto@0.4.0
|
||||
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
|
||||
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
|
||||
# renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master
|
||||
https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user