diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..66c99839 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# RavenTag deployment environment template — no secret values are stored here. +NODE_ENV=production +ALLOWED_ORIGINS=https://raventag.com + +# Ravencoin RPC +RVN_RPC_HOST=localhost +RVN_RPC_PORT=8766 +RVN_PUBLIC_RPC_URL=https://rvn-rpc.publicnode.com + +# Docker backup sidecar: point to a local file containing a strong random secret. +# Generate the file yourself, e.g. with: openssl rand -hex 32 > secrets/backup_encryption_key.txt +# NEVER point this setting at admin_key/operator_key/brand key material. +BACKUP_ENCRYPTION_KEY_FILE=./secrets/backup_encryption_key.txt + +# The normal Docker deployment already has a backup sidecar. Leave the optional +# in-process scheduler disabled unless you deliberately deploy without that sidecar. +ENABLE_INTERNAL_BACKUP=false +# When ENABLE_INTERNAL_BACKUP=true, this path must exist inside the backend container/process. +# INTERNAL_BACKUP_ENCRYPTION_KEY_FILE=/run/secrets/backup_encryption_key diff --git a/.github/workflows/qwen-dispatch.yml b/.github/workflows/qwen-dispatch.yml index e05a98e4..32ac7353 100644 --- a/.github/workflows/qwen-dispatch.yml +++ b/.github/workflows/qwen-dispatch.yml @@ -2,21 +2,15 @@ name: '🔀 Qwen Code Dispatch' on: pull_request_review_comment: - types: - - 'created' + types: ['created'] pull_request_review: - types: - - 'submitted' + types: ['submitted'] pull_request: - types: - - 'opened' + types: ['opened'] issues: - types: - - 'opened' - - 'reopened' + types: ['opened', 'reopened'] issue_comment: - types: - - 'created' + types: ['created'] defaults: run: @@ -24,8 +18,7 @@ defaults: jobs: debugger: - if: |- - ${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }} + if: ${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }} runs-on: 'ubuntu-latest' permissions: contents: 'read' @@ -39,13 +32,9 @@ jobs: DEBUG_event__pull_request__author_association: '${{ github.event.pull_request.author_association }}' DEBUG_event__review__author_association: '${{ github.event.review.author_association }}' DEBUG_event: '${{ toJSON(github.event) }}' - run: |- - env | grep '^DEBUG_' + run: env | grep '^DEBUG_' dispatch: - # For PRs: only if not from a fork - # For issues: only on open/reopen - # For comments: only if user types @gemini-cli and is OWNER/MEMBER/COLLABORATOR if: |- ( github.event_name == 'pull_request' && @@ -71,9 +60,8 @@ jobs: steps: - name: 'Mint identity token' id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + if: ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' with: app-id: '${{ vars.APP_ID }}' private-key: '${{ secrets.APP_PRIVATE_KEY }}' @@ -83,54 +71,39 @@ jobs: - name: 'Extract command' id: 'extract_command' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7 + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' env: EVENT_TYPE: '${{ github.event_name }}.${{ github.event.action }}' REQUEST: '${{ github.event.comment.body || github.event.review.body || github.event.issue.body }}' with: script: | const eventType = process.env.EVENT_TYPE; - const request = process.env.REQUEST; + const request = process.env.REQUEST || ''; core.setOutput('request', request); - - if (eventType === 'pull_request.opened') { + if (eventType === 'pull_request.opened') core.setOutput('command', 'review'); + else if (['issues.opened', 'issues.reopened'].includes(eventType)) core.setOutput('command', 'triage'); + else if (request.startsWith('@qwen-code /review')) { core.setOutput('command', 'review'); - } else if (['issues.opened', 'issues.reopened'].includes(eventType)) { - core.setOutput('command', 'triage'); - } else if (request.startsWith("@qwen-code /review")) { - core.setOutput('command', 'review'); - const additionalContext = request.replace(/^@qwen-code \/review/, '').trim(); - core.setOutput('additional_context', additionalContext); - } else if (request.startsWith("@qwen-code /triage")) { - core.setOutput('command', 'triage'); - } else if (request.startsWith("@qwen-code")) { - const additionalContext = request.replace(/^@qwen-code/, '').trim(); + core.setOutput('additional_context', request.replace(/^@qwen-code \/review/, '').trim()); + } else if (request.startsWith('@qwen-code /triage')) core.setOutput('command', 'triage'); + else if (request.startsWith('@qwen-code')) { core.setOutput('command', 'invoke'); - core.setOutput('additional_context', additionalContext); - } else { - core.setOutput('command', 'fallthrough'); - } + core.setOutput('additional_context', request.replace(/^@qwen-code/, '').trim()); + } else core.setOutput('command', 'fallthrough'); - name: 'Acknowledge request' env: - GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || github.token }}' ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' - MESSAGE: |- - 🤖 Hi @${{ github.actor }}, I've received your request, and I'm working on it now! You can track my progress [in the logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. REPOSITORY: '${{ github.repository }}' - run: |- - gh issue comment "${ISSUE_NUMBER}" \ - --body "${MESSAGE}" \ - --repo "${REPOSITORY}" + run: gh issue comment "${ISSUE_NUMBER}" --body "🤖 Request received. Track progress in ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --repo "${REPOSITORY}" review: needs: 'dispatch' - if: |- - ${{ needs.dispatch.outputs.command == 'review' }} + if: ${{ needs.dispatch.outputs.command == 'review' }} uses: './.github/workflows/qwen-review.yml' permissions: contents: 'read' - id-token: 'write' issues: 'write' pull-requests: 'write' with: @@ -139,12 +112,10 @@ jobs: triage: needs: 'dispatch' - if: |- - ${{ needs.dispatch.outputs.command == 'triage' }} + if: ${{ needs.dispatch.outputs.command == 'triage' }} uses: './.github/workflows/qwen-triage.yml' permissions: contents: 'read' - id-token: 'write' issues: 'write' pull-requests: 'write' with: @@ -153,12 +124,10 @@ jobs: invoke: needs: 'dispatch' - if: |- - ${{ needs.dispatch.outputs.command == 'invoke' }} + if: ${{ needs.dispatch.outputs.command == 'invoke' }} uses: './.github/workflows/qwen-invoke.yml' permissions: contents: 'read' - id-token: 'write' issues: 'write' pull-requests: 'write' with: @@ -166,13 +135,8 @@ jobs: secrets: 'inherit' fallthrough: - needs: - - 'dispatch' - - 'review' - - 'triage' - - 'invoke' - if: |- - ${{ always() && !cancelled() && (failure() || needs.dispatch.outputs.command == 'fallthrough') }} + needs: ['dispatch', 'review', 'triage', 'invoke'] + if: ${{ always() && !cancelled() && (failure() || needs.dispatch.outputs.command == 'fallthrough') }} runs-on: 'ubuntu-latest' permissions: contents: 'read' @@ -181,24 +145,17 @@ jobs: steps: - name: 'Mint identity token' id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + if: ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' with: app-id: '${{ vars.APP_ID }}' private-key: '${{ secrets.APP_PRIVATE_KEY }}' permission-contents: 'read' permission-issues: 'write' permission-pull-requests: 'write' - - name: 'Send failure comment' env: - GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || github.token }}' ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' - MESSAGE: |- - 🤖 I'm sorry @${{ github.actor }}, but I was unable to process your request. Please [see the logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. REPOSITORY: '${{ github.repository }}' - run: |- - gh issue comment "${ISSUE_NUMBER}" \ - --body "${MESSAGE}" \ - --repo "${REPOSITORY}" + run: gh issue comment "${ISSUE_NUMBER}" --body "🤖 Unable to process the request. See ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --repo "${REPOSITORY}" diff --git a/.github/workflows/qwen-invoke.yml b/.github/workflows/qwen-invoke.yml index 0d4bdb33..6c7d976a 100644 --- a/.github/workflows/qwen-invoke.yml +++ b/.github/workflows/qwen-invoke.yml @@ -21,7 +21,6 @@ jobs: runs-on: 'ubuntu-latest' permissions: contents: 'read' - id-token: 'write' issues: 'write' pull-requests: 'write' steps: @@ -29,7 +28,7 @@ jobs: id: 'mint_identity_token' if: |- ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # immutable upstream commit with: app-id: '${{ vars.APP_ID }}' private-key: '${{ secrets.APP_PRIVATE_KEY }}' @@ -39,7 +38,7 @@ jobs: - name: 'Run Qwen Code CLI' id: 'run_qwen' - uses: 'QwenLM/qwen-code-action@v1' # ratchet:exclude + uses: 'QwenLM/qwen-code-action@05f81718976f3b7da657422e6e8e4d372b8621d7' env: TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}' DESCRIPTION: '${{ github.event.pull_request.body || github.event.issue.body }}' @@ -76,7 +75,7 @@ jobs: "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:v0.18.0" + "ghcr.io/github/github-mcp-server@sha256:6aa543f565bc98d96106fdd80a0abdc0c7147c8ca7f4663a814ceecef1ccc6d3" ], "includeTools": [ "add_issue_comment", diff --git a/.github/workflows/qwen-review.yml b/.github/workflows/qwen-review.yml index f140e8c0..3067d4b3 100644 --- a/.github/workflows/qwen-review.yml +++ b/.github/workflows/qwen-review.yml @@ -22,7 +22,6 @@ jobs: timeout-minutes: 7 permissions: contents: 'read' - id-token: 'write' issues: 'write' pull-requests: 'write' steps: @@ -30,7 +29,7 @@ jobs: id: 'mint_identity_token' if: |- ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' with: app-id: '${{ vars.APP_ID }}' private-key: '${{ secrets.APP_PRIVATE_KEY }}' @@ -39,10 +38,10 @@ jobs: permission-pull-requests: 'write' - name: 'Checkout repository' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' - name: 'Run Qwen Code pull request review' - uses: 'QwenLM/qwen-code-action@v1' # ratchet:exclude + uses: 'QwenLM/qwen-code-action@05f81718976f3b7da657422e6e8e4d372b8621d7' id: 'qwen_pr_review' env: GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' @@ -61,44 +60,16 @@ jobs: workflow_name: 'qwen-review' settings: |- { - "model": { - "maxSessionTurns": 25 - }, - "telemetry": { - "enabled": true, - "target": "local", - "outfile": ".qwen/telemetry.log" - }, + "model": { "maxSessionTurns": 25 }, + "telemetry": { "enabled": true, "target": "local", "outfile": ".qwen/telemetry.log" }, "mcpServers": { "github": { "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:v0.18.0" - ], - "includeTools": [ - "add_comment_to_pending_review", - "create_pending_pull_request_review", - "pull_request_read", - "submit_pending_pull_request_review" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" - } + "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server@sha256:6aa543f565bc98d96106fdd80a0abdc0c7147c8ca7f4663a814ceecef1ccc6d3"], + "includeTools": ["add_comment_to_pending_review", "create_pending_pull_request_review", "pull_request_read", "submit_pending_pull_request_review"], + "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" } } }, - "tools": { - "core": [ - "run_shell_command(cat)", - "run_shell_command(echo)", - "run_shell_command(grep)", - "run_shell_command(head)", - "run_shell_command(tail)" - ] - } + "tools": { "core": ["run_shell_command(cat)", "run_shell_command(echo)", "run_shell_command(grep)", "run_shell_command(head)", "run_shell_command(tail)"] } } prompt: '/qwen-review' diff --git a/.github/workflows/qwen-scheduled-triage.yml b/.github/workflows/qwen-scheduled-triage.yml index 930c63d4..1bfbeb21 100644 --- a/.github/workflows/qwen-scheduled-triage.yml +++ b/.github/workflows/qwen-scheduled-triage.yml @@ -2,19 +2,13 @@ name: '📋 Qwen Code Scheduled Issue Triage' on: schedule: - - cron: '0 * * * *' # Runs every hour + - cron: '0 * * * *' pull_request: - branches: - - 'main' - - 'release/**/*' - paths: - - '.github/workflows/qwen-scheduled-triage.yml' + branches: ['master', 'release/**/*'] + paths: ['.github/workflows/qwen-scheduled-triage.yml'] push: - branches: - - 'main' - - 'release/**/*' - paths: - - '.github/workflows/qwen-scheduled-triage.yml' + branches: ['master', 'release/**/*'] + paths: ['.github/workflows/qwen-scheduled-triage.yml'] workflow_dispatch: concurrency: @@ -31,7 +25,6 @@ jobs: timeout-minutes: 7 permissions: contents: 'read' - id-token: 'write' issues: 'read' pull-requests: 'read' outputs: @@ -40,58 +33,33 @@ jobs: steps: - name: 'Get repository labels' id: 'get_labels' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: - # NOTE: we intentionally do not use the minted token. The default - # GITHUB_TOKEN provided by the action has enough permissions to read - # the labels. script: |- const labels = []; for await (const response of github.paginate.iterator(github.rest.issues.listLabelsForRepo, { - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, // Maximum per page to reduce API calls - })) { - labels.push(...response.data); - } - - if (!labels || labels.length === 0) { - core.setFailed('There are no issue labels in this repository.') - } - + owner: context.repo.owner, repo: context.repo.repo, per_page: 100, + })) labels.push(...response.data); + if (!labels || labels.length === 0) core.setFailed('There are no issue labels in this repository.'); const labelNames = labels.map(label => label.name).sort(); core.setOutput('available_labels', labelNames.join(',')); - core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); return labelNames; - name: 'Find untriaged issues' id: 'find_issues' env: GITHUB_REPOSITORY: '${{ github.repository }}' - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN || github.token }}' + GITHUB_TOKEN: '${{ github.token }}' run: |- - echo '🔍 Finding unlabeled issues and issues marked for triage...' - ISSUES="$(gh issue list \ - --state 'open' \ - --search 'no:label label:"status/needs-triage"' \ - --json number,title,body \ - --limit '100' \ - --repo "${GITHUB_REPOSITORY}" - )" - - echo '📝 Setting output for GitHub Actions...' + ISSUES="$(gh issue list --state 'open' --search 'no:label label:"status/needs-triage"' --json number,title,body --limit '100' --repo "${GITHUB_REPOSITORY}")" echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}" - ISSUE_COUNT="$(echo "${ISSUES}" | jq 'length')" - echo "✅ Found ${ISSUE_COUNT} issue(s) to triage! 🎯" - - name: 'Run Qwen Code Issue Analysis' id: 'qwen_issue_analysis' - if: |- - ${{ steps.find_issues.outputs.issues_to_triage != '[]' }} - uses: 'QwenLM/qwen-code-action@v1' # ratchet:exclude + if: ${{ steps.find_issues.outputs.issues_to_triage != '[]' }} + uses: 'QwenLM/qwen-code-action@05f81718976f3b7da657422e6e8e4d372b8621d7' env: - GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs + GITHUB_TOKEN: '' ISSUES_TO_TRIAGE: '${{ steps.find_issues.outputs.issues_to_triage }}' REPOSITORY: '${{ github.repository }}' AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' @@ -105,33 +73,16 @@ jobs: workflow_name: 'qwen-scheduled-triage' settings: |- { - "model": { - "maxSessionTurns": 25 - }, - "telemetry": { - "enabled": true, - "target": "local", - "outfile": ".qwen/telemetry.log" - }, - "tools": { - "core": [ - "run_shell_command(echo)", - "run_shell_command(jq)", - "run_shell_command(printenv)" - ] - } + "model": { "maxSessionTurns": 25 }, + "telemetry": { "enabled": true, "target": "local", "outfile": ".qwen/telemetry.log" }, + "tools": { "core": ["run_shell_command(echo)", "run_shell_command(jq)"] } } prompt: '/qwen-scheduled-triage' label: runs-on: 'ubuntu-latest' - needs: - - 'triage' - if: |- - needs.triage.outputs.available_labels != '' && - needs.triage.outputs.available_labels != '[]' && - needs.triage.outputs.triaged_issues != '' && - needs.triage.outputs.triaged_issues != '[]' + needs: ['triage'] + if: needs.triage.outputs.available_labels != '' && needs.triage.outputs.available_labels != '[]' && needs.triage.outputs.triaged_issues != '' && needs.triage.outputs.triaged_issues != '[]' permissions: contents: 'read' issues: 'write' @@ -139,9 +90,8 @@ jobs: steps: - name: 'Mint identity token' id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + if: ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' with: app-id: '${{ vars.APP_ID }}' private-key: '${{ secrets.APP_PRIVATE_KEY }}' @@ -153,56 +103,15 @@ jobs: env: AVAILABLE_LABELS: '${{ needs.triage.outputs.available_labels }}' TRIAGED_ISSUES: '${{ needs.triage.outputs.triaged_issues }}' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: - # Use the provided token so that the "gemini-cli" is the actor in the - # log for what changed the labels. - github-token: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + github-token: '${{ steps.mint_identity_token.outputs.token || github.token }}' script: |- - // Parse the available labels - const availableLabels = (process.env.AVAILABLE_LABELS || '').split(',') - .map((label) => label.trim()) - .sort() - - // Parse out the triaged issues - const triagedIssues = (JSON.parse(process.env.TRIAGED_ISSUES || '{}')) - .sort((a, b) => a.issue_number - b.issue_number) - - core.debug(`Triaged issues: ${JSON.stringify(triagedIssues)}`); - - // Iterate over each label + const availableLabels = (process.env.AVAILABLE_LABELS || '').split(',').map((label) => label.trim()).sort(); + const triagedIssues = JSON.parse(process.env.TRIAGED_ISSUES || '[]').sort((a, b) => a.issue_number - b.issue_number); for (const issue of triagedIssues) { - if (!issue) { - core.debug(`Skipping empty issue: ${JSON.stringify(issue)}`); - continue; - } - - const issueNumber = issue.issue_number; - if (!issueNumber) { - core.debug(`Skipping issue with no data: ${JSON.stringify(issue)}`); - continue; - } - - // Extract and reject invalid labels - we do this just in case - // someone was able to prompt inject malicious labels. - let labelsToSet = (issue.labels_to_set || []) - .map((label) => label.trim()) - .filter((label) => availableLabels.includes(label)) - .sort() - - core.debug(`Identified labels to set: ${JSON.stringify(labelsToSet)}`); - - if (labelsToSet.length === 0) { - core.info(`Skipping issue #${issueNumber} - no labels to set.`) - continue; - } - - core.debug(`Setting labels on issue #${issueNumber} to ${labelsToSet.join(', ')} (${issue.explanation || 'no explanation'})`) - - await github.rest.issues.setLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: labelsToSet, - }); + if (!issue?.issue_number) continue; + const labelsToSet = (issue.labels_to_set || []).map((label) => label.trim()).filter((label) => availableLabels.includes(label)).sort(); + if (labelsToSet.length === 0) continue; + await github.rest.issues.setLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.issue_number, labels: labelsToSet }); } diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml index cfea89d2..78e86686 100644 --- a/.github/workflows/qwen-triage.yml +++ b/.github/workflows/qwen-triage.yml @@ -25,43 +25,33 @@ jobs: selected_labels: '${{ env.SELECTED_LABELS }}' permissions: contents: 'read' - id-token: 'write' issues: 'read' pull-requests: 'read' steps: - name: 'Get repository labels' id: 'get_labels' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: - # NOTE: we intentionally do not use the given token. The default - # GITHUB_TOKEN provided by the action has enough permissions to read - # the labels. script: |- const labels = []; for await (const response of github.paginate.iterator(github.rest.issues.listLabelsForRepo, { owner: context.repo.owner, repo: context.repo.repo, - per_page: 100, // Maximum per page to reduce API calls + per_page: 100, })) { labels.push(...response.data); } - - if (!labels || labels.length === 0) { - core.setFailed('There are no issue labels in this repository.') - } - + if (!labels || labels.length === 0) core.setFailed('There are no issue labels in this repository.') const labelNames = labels.map(label => label.name).sort(); core.setOutput('available_labels', labelNames.join(',')); - core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); return labelNames; - name: 'Run Qwen Code issue analysis' id: 'qwen_analysis' - if: |- - ${{ steps.get_labels.outputs.available_labels != '' }} - uses: 'QwenLM/qwen-code-action@v1' # ratchet:exclude + if: ${{ steps.get_labels.outputs.available_labels != '' }} + uses: 'QwenLM/qwen-code-action@05f81718976f3b7da657422e6e8e4d372b8621d7' env: - GITHUB_TOKEN: '' # Do NOT pass any auth tokens here since this runs on untrusted inputs + GITHUB_TOKEN: '' ISSUE_TITLE: '${{ github.event.issue.title }}' ISSUE_BODY: '${{ github.event.issue.body }}' AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' @@ -75,28 +65,16 @@ jobs: workflow_name: 'qwen-triage' settings: |- { - "model": { - "maxSessionTurns": 25 - }, - "telemetry": { - "enabled": true, - "target": "local", - "outfile": ".qwen/telemetry.log" - }, - "tools": { - "core": [ - "run_shell_command(echo)" - ] - } + "model": { "maxSessionTurns": 25 }, + "telemetry": { "enabled": true, "target": "local", "outfile": ".qwen/telemetry.log" }, + "tools": { "core": ["run_shell_command(echo)"] } } prompt: '/qwen-triage' label: runs-on: 'ubuntu-latest' - needs: - - 'triage' - if: |- - ${{ needs.triage.outputs.selected_labels != '' }} + needs: ['triage'] + if: ${{ needs.triage.outputs.selected_labels != '' }} permissions: contents: 'read' issues: 'write' @@ -104,9 +82,8 @@ jobs: steps: - name: 'Mint identity token' id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + if: ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' with: app-id: '${{ vars.APP_ID }}' private-key: '${{ secrets.APP_PRIVATE_KEY }}' @@ -119,25 +96,15 @@ jobs: ISSUE_NUMBER: '${{ github.event.issue.number }}' AVAILABLE_LABELS: '${{ needs.triage.outputs.available_labels }}' SELECTED_LABELS: '${{ needs.triage.outputs.selected_labels }}' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: - # Use the provided token so that the "gemini-cli" is the actor in the - # log for what changed the labels. github-token: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' script: |- - // Parse the available labels - const availableLabels = (process.env.AVAILABLE_LABELS || '').split(',') - .map((label) => label.trim()) - .sort() - - // Parse the label as a CSV, reject invalid ones - we do this just - // in case someone was able to prompt inject malicious labels. + const availableLabels = (process.env.AVAILABLE_LABELS || '').split(',').map((label) => label.trim()).sort() const selectedLabels = (process.env.SELECTED_LABELS || '').split(',') .map((label) => label.trim()) .filter((label) => availableLabels.includes(label)) .sort() - - // Set the labels const issueNumber = process.env.ISSUE_NUMBER; if (selectedLabels && selectedLabels.length > 0) { await github.rest.issues.setLabels({ @@ -146,7 +113,4 @@ jobs: issue_number: issueNumber, labels: selectedLabels, }); - core.info(`Successfully set labels: ${selectedLabels.join(',')}`); - } else { - core.info(`Failed to determine labels to set. There may not be enough information in the issue or pull request.`) } diff --git a/.github/workflows/security-remediation-ci.yml b/.github/workflows/security-remediation-ci.yml new file mode 100644 index 00000000..f703233d --- /dev/null +++ b/.github/workflows/security-remediation-ci.yml @@ -0,0 +1,127 @@ +name: Security remediation gate + +on: + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + final-security-gate: + if: github.head_ref == 'security/full-audit-remediation-2026-08-18' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout exact PR head without third-party actions + env: + GH_TOKEN: ${{ github.token }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_REF: ${{ github.head_ref }} + EVENT_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + git init . + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${HEAD_REPO}.git" + git fetch --depth=1 origin "${HEAD_REF}" + git checkout --detach FETCH_HEAD + test "$(git rev-parse HEAD)" = "$EVENT_SHA" + git status --short + + - name: Backend exact install build tests and audit + working-directory: backend + run: | + set -euo pipefail + npm ci --ignore-scripts + npm run build + npm test + npm audit --audit-level=low + + - name: Android compile and JVM tests + working-directory: android + run: | + set -euo pipefail + chmod +x gradlew + ./gradlew compileBrandDebugKotlin compileConsumerDebugKotlin --console=plain + ./gradlew test --console=plain + + - name: Whole-tree security invariants + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + import re + root=Path('.') + wallet=(root/'android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt').read_text() + txb=(root/'android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt').read_text() + main=(root/'android/app/src/main/java/io/raventag/app/MainActivity.kt').read_text() + nfc=(root/'android/app/src/main/java/io/raventag/app/nfc/NfcReader.kt').read_text() + backup=(root/'android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt').read_text() + exporter=(root/'android/app/src/main/java/io/raventag/app/security/MnemonicExporter.kt').read_text() + manifest=(root/'android/app/src/main/AndroidManifest.xml').read_text() + netcfg=(root/'android/app/src/main/res/xml/network_security_config.xml').read_text() + rpc=(root/'android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt').read_text() + compose=(root/'docker-compose.yml').read_text() + backup_ts=(root/'backend/src/services/backup.ts').read_text() + index=(root/'backend/src/index.ts').read_text() + + assert 'isMaxSend' not in wallet + assert 'effectiveAmount = if' not in txb + assert 'explicitMax: Boolean' in wallet and 'explicitMax: Boolean' in main + assert 'node.broadcastWithAllServers(tx.hex)' not in wallet + assert 'broadcastConsolidation(tx.hex)' not in wallet + assert 'signAndBroadcastReserved' in wallet + assert wallet.index('ReservedUtxoDao.tryReserve') < wallet.index('signed = build()') + assert 'CONFLICT_ABORT' in (root/'android/app/src/main/java/io/raventag/app/wallet/cache/ReservedUtxoDao.kt').read_text() + assert '0x3C' in txb or '0x3c' in txb + assert 'isValidRavencoinMainnetP2pkh' in txb + + lines=[x.strip() for x in main.splitlines() if 'authPassed = true' in x] + assert len(lines)==2, lines + assert any('onSuccess' in x for x in lines) + assert any('no wallet' in x.lower() for x in lines) + + assert '.setUserAuthenticationRequired(true)' in wallet + assert 'MNEMONIC_REVEAL_KEYSTORE_ALIAS' in wallet + assert 'raventag_mnemonic_reveal_key_v1' in wallet + assert 'persisted mnemonic migration verification failed' in wallet + assert 'fun getMnemonic()' not in wallet and 'getMnemonicForTestOnly' not in wallet + assert 'raventag_secure_v2' in main + assert 'LegacySecretMigration.migrate' in main + assert 'securePrefs.edit().putString("admin_key"' not in main + assert 'securePrefs.edit().putString("kubo_node_url"' not in main + + combined=backup+'\n'+exporter + assert 'ClipboardManager' not in combined + assert 'setPrimaryClip' not in combined + assert 'android:usesCleartextTraffic="false"' in manifest + assert 'cleartextTrafficPermitted="true"' not in netcfg + assert 'payload[0]' not in nfc and 'firstOrNull()' in nfc + assert 'VERIFY_HOST' in nfc + assert '.followRedirects(false)' in rpc and '.followSslRedirects(false)' in rpc + assert 'http.newCall(' not in rpc and 'noRedirectHttp.newCall(' in rpc + + assert '24 * 60 * 60 * 1000' in backup_ts + assert 'MAX_BACKUPS = 7' in backup_ts + assert 'ADMIN_KEY_FILE: /run/secrets/admin_key' in compose + assert '- backup_encryption_key' in compose + assert '/run/secrets/backup_encryption_key' in compose + assert 'INTERNAL_BACKUP_ENCRYPTION_KEY_FILE' in index + assert 'startBackupScheduler(backupKeyPath)' in index + + for p in (root/'.github/workflows').glob('qwen-*.yml'): + for line in p.read_text().splitlines(): + if 'qwen-code-action@' in line: + assert re.search(r'qwen-code-action@[0-9a-f]{40}(?![0-9a-f])', line), (p,line) + if 'docker://' in line and ('qwen' in line.lower() or 'mcp' in line.lower()): + assert '@sha256:' in line, (p,line) + PY + + - name: Secret and dangerous-pattern scan + run: | + set -euo pipefail + ! git ls-files | grep -E '(^|/)(local\.properties|signing\.properties|\.env)$' + ! git grep -nE -- '-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----' -- . ':!docs/**' + ! git grep -nE -- '(API_TOKEN|PRIVATE_KEY|BACKUP_ENCRYPTION_KEY|ADMIN_KEY)=[A-Za-z0-9+/=_-]{16,}' -- . ':!docs/**' ':!.env.example' + git diff --check diff --git a/.gitignore b/.gitignore index 1293d8e1..3312e64d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ Thumbs.db # Android android/.gradle/ -android/local.properties +**/local.properties android/app/build/ android/build/ *.apk diff --git a/CODEX_DYNAMIC_SECURITY_TEST_PLAN.md b/CODEX_DYNAMIC_SECURITY_TEST_PLAN.md new file mode 100644 index 00000000..78118cc9 --- /dev/null +++ b/CODEX_DYNAMIC_SECURITY_TEST_PLAN.md @@ -0,0 +1,118 @@ +# Codex Dynamic Security Test Plan + +## Purpose + +Perform an independent dynamic/adversarial validation against the post-remediation RavenTag `master`. Do not treat the remediation report or CI result as proof that the controls work under hostile runtime conditions. Record exact build SHA, APK/backend build artifacts, environment, fixtures, node/server versions, and raw evidence for every test. + +## 1. ElectrumX certificate trust and MITM +- First connection with no pin: verify intended TOFU enrollment only once. +- Reconnect with identical certificate: succeeds. +- Present a different certificate for the same server without an explicit trusted rotation procedure: must fail and must not mutate the stored pin. +- Repeat after process restart and device restart. +- Simulate active MITM/DNS redirection and verify the attacker certificate is not adopted. +- Exercise legitimate certificate rotation procedure, if one exists, and prove it requires explicit trust/recovery rather than automatic mismatch acceptance. + +## 2. Malicious relay fees and transaction amount integrity +- ElectrumX returns zero, negative-equivalent/malformed, extremely large, and boundary relay-fee values. +- Verify local fee floor/ceiling and absolute-fee policy. +- Normal send with insufficient amount+fee must fail without changing recipient amount. +- Explicit MAX must compute recipient value from inputs minus validated fee/dust and must be visibly/semantically distinct from a normal send. +- Test RVN-only and RVN+asset sweep cases. + +## 3. Android biometric failure states +On representative API 26-29 and API 30+ devices/emulators: +- no biometric/credential enrollment; +- user cancel / negative button; +- repeated failed biometric; +- temporary/permanent lockout; +- prompt initialization exception/pathological lifecycle change; +- app background/foreground and rotation during prompt. +No state may become authenticated on an error path. + +## 4. Real auth-bound Android Keystore recovery reveal +- Inspect generated alias `raventag_mnemonic_reveal_key_v1` and prove user authentication is required by KeyInfo/runtime behavior. +- Attempt direct decrypt before authentication: must fail. +- Authenticate and decrypt within validity window: succeeds. +- Wait beyond validity window: decrypt must fail again until re-authentication. +- Test device reboot/lock and biometric enrollment changes/key invalidation behavior. +- Verify normal seed/signing operations remain independent and do not require the recovery-reveal key. +- Confirm no alternate production method can recover mnemonic plaintext through the general wallet key. + +## 5. Legacy wallet mnemonic migration +Create an installation containing only the pre-remediation mnemonic ciphertext/IV/HMAC and a valid wallet seed. +- Cancel authentication: legacy remains untouched; no new representation considered complete. +- Force failure during new-key creation/encryption: legacy remains. +- Force failure after new encryption but before persistence: legacy remains. +- Force process death after new persistent copy but before legacy deletion; restart and repeat reveal: new copy must decrypt and cleanup must finish safely. +- Verify migrated phrase exactly matches original and derives the same BIP39 seed/BIP44 addresses. +- Repeat migration/reveal multiple times; result is idempotent. + +## 6. Legacy `raventag_secure` secret migration +Test both historical layouts: +- ordinary plaintext `SharedPreferences` file; +- v1 `EncryptedSharedPreferences` file. +For `admin_key`, `operator_key`, `initial_master_key`, and `pinata_jwt`: +- no legacy values -> no-op; +- valid values -> v2 encrypted/dedicated storage, read-back equality, then legacy deletion; +- unavailable/corrupt Keystore -> sensitive features blocked and legacy remains untouched; +- kill process after destination write but before source deletion -> repeat safely finishes cleanup; +- repeated migration -> no duplicate/destructive behavior. +Confirm `kubo_node_url` is handled as non-secret configuration rather than indiscriminately included in the secret whitelist. + +## 7. Simultaneous UTXO spend attempts +- Launch two or more sends/transfers/issuances/sweeps concurrently against overlapping UTXOs. +- Prove only one operation reserves an outpoint and the losing operation never signs. +- Inject crash after reservation/before signing: safe release/recovery behavior. +- Inject timeout/drop after signing/before/after broadcast response: reservation remains until reconciliation proves safe release. +- Verify no direct builder/broadcast entrypoint bypasses the common boundary. + +## 8. Registry validation with real Ravencoin transactions +Use known real transactions for: +- valid root issuance; +- valid sub-asset issuance; +- valid unique issuance; +- ordinary payment txid; +- issuance of a different asset name; +- wrong claimed issuance type; +- missing/wrong canonical burn; +- owner-token semantic mismatch; +- malformed/unavailable RPC response. +Only semantically matching issuance claims may enter the registry. + +## 9. NTAG424 / NDEF / replay +With real NTAG424 DNA hardware and malformed synthetic NDEF records: +- valid SUN CMAC/counter progression; +- repeated counter/replay; +- out-of-order/racing scans; +- malformed/zero-length payloads; +- unsupported URI prefix codes and truncated records; +- tag clone attempts; +- concurrent backend verification requests for the same counter. +Verify no crash and no replay acceptance. + +## 10. IPFS redirect/adversarial fetches +- 301/302/307/308 from an allowed gateway to disallowed HTTP/HTTPS/private/link-local destinations. +- redirect loops and long chains; +- DNS rebinding where feasible; +- oversized/slow responses. +Transparent redirects must not bypass endpoint validation. + +## 11. Android cleartext and deep-link behavior +- Attempt HTTP connections from each app flavor: globally denied unless an intentionally scoped exception is documented (none expected). +- Send ACTION_VIEW `/verify` links from every authorized production host: accepted. +- Same `/verify` path from attacker-controlled HTTPS hosts, subdomain lookalikes, Unicode/punycode variants, explicit ports, userinfo URLs: rejected. + +## 12. Backend dependency/runtime checks +- Re-run `npm ci` and `npm audit --audit-level=low` from a clean clone of the merged SHA. +- Exercise upload/IPFS, Express JSON parsing/routing, and rate-limit paths after dependency upgrades. +- Confirm no behavioral regression from Express 4.22.2, express-rate-limit 8.6.2, form-data 4.0.6, multer 2.2.0, tsx 4.23.12, and the final Axios 1.x pin. + +## 13. BIP39/BIP32/BIP44 and transaction vectors +- Official BIP39 English vectors including passphrases and NFKD Unicode equivalence. +- BIP32 derivation vectors for hardened/non-hardened descendants where applicable. +- Ravencoin BIP44 coin type 175 addresses for known mnemonic fixtures. +- Base58Check valid/invalid checksum, foreign-network version bytes, non-canonical encodings. +- Known-good RVN transaction serialization/signature vectors for normal send, MAX, asset transfer, issuance, reissue, and sweep. + +## Exit criteria +For every scenario capture PASS/FAIL, exact reproduction steps, raw logs/pcaps/transactions where applicable, and the tested commit SHA. Any exploitable bypass, unexpected fail-open behavior, incorrect transaction semantics, or wallet-data-loss condition reopens the corresponding RT-SEC finding. Do not issue a `SECURITY PASS` solely from completion of this plan. diff --git a/SECURITY_REMEDIATION_REPORT.md b/SECURITY_REMEDIATION_REPORT.md new file mode 100644 index 00000000..0aea11cd --- /dev/null +++ b/SECURITY_REMEDIATION_REPORT.md @@ -0,0 +1,227 @@ +# RavenTag Security Remediation Report + +## Scope and status + +This report records implementation evidence for the findings RT-SEC-001 through RT-SEC-020 from the 2026-08-18 adversarial security audit. It is a remediation record, **not** a `SECURITY PASS`, penetration-test certificate, or release approval. Independent dynamic/adversarial verification remains assigned to Codex after merge. + +Audit baseline SHA: `96807a39d6e18a13f1412f4425f81df595ed158f`. + +The final pre-merge CI gate requires an exact backend install/build/test/audit, Android compilation/JVM tests, and whole-tree static security invariants. Hardware-, network-adversary-, and instrumented-device checks are deferred to `CODEX_DYNAMIC_SECURITY_TEST_PLAN.md`. + +## Finding-by-finding evidence + +### RT-SEC-001 — ElectrumX TOFU certificate mismatch +**Status:** FIXED IN CODE; dynamic MITM/rotation test deferred. + +**Files:** `android/app/src/main/java/io/raventag/app/wallet/TofuTrustManager.kt`, `RavencoinPublicNode.kt`, `SubscriptionManager.kt`. + +**Invariant:** a stored certificate pin mismatch is a hard failure. The connection path cannot silently replace/re-pin a previously trusted certificate merely because a new certificate was presented. + +**Validation:** whole-tree review/static gate plus Android compile/JVM suite. Codex must exercise real first-use, unchanged cert, legitimate rotation, and active MITM cases. + +### RT-SEC-002 — Malicious relay-fee / implicit MAX transaction behavior +**Status:** FIXED AND JVM-TESTED. + +**Files:** `FeeSafetyPolicy.kt`, `RavencoinPublicNode.kt`, `RavencoinTxBuilder.kt`, `WalletManager.kt`, `SendRvnScreen.kt`, `MainActivity.kt`, `FeeSafetyPolicyTest.kt`, `RavencoinTxBuilderTest.kt`. + +**Invariant:** relay fee input is constrained by local policy; a normal send never reduces the recipient amount to make an otherwise-insolvent transaction fit. MAX is an explicit UI/application intent propagated end-to-end. + +**Validation:** fee-policy and builder regression tests; static gate rejects `isMaxSend`, legacy amount-reduction semantics, and loss of the explicit MAX parameter. Malicious live ElectrumX responses remain a dynamic Codex test. + +### RT-SEC-003 — Fail-open biometric startup authentication +**Status:** FIXED IN CODE. + +**Files:** `MainActivity.kt`, `BiometricGate.kt`. + +**Invariant:** unavailable hardware, enrollment failure, cancellation, lockout, prompt exception, and authentication error never become authenticated state. The only `authPassed=true` paths are a real authentication success and the explicitly non-secret no-wallet state. + +**Validation:** whole-tree static auth assignment gate and Android compile/JVM suite. Device-specific BiometricPrompt errors remain dynamic. + +### RT-SEC-004 — Recovery phrase not cryptographically auth-bound +**Status:** FIXED IN CODE; Android Keystore/Biometric instrumentation deferred. + +**Files:** `WalletManager.kt`, `BiometricGate.kt`, `MnemonicBackupScreen.kt`, `MnemonicExporter.kt`, `WalletManagerTest.kt`. + +**Invariant:** wallet seed/signing storage remains separate from the recovery-reveal key. Persisted mnemonic reveal uses dedicated alias `raventag_mnemonic_reveal_key_v1`, AES-GCM, and Android Keystore `setUserAuthenticationRequired(true)`. API 30+ uses `AUTH_BIOMETRIC_STRONG | AUTH_DEVICE_CREDENTIAL`; API 26-29 uses the authentication-validity compatibility API. There is no production general-wallet-key `getMnemonic()` bypass. + +**Migration invariant:** authenticate first; decrypt and integrity-check the legacy mnemonic; encrypt with the auth-bound key; locally verify; persist; read back and decrypt the persisted copy; only then remove legacy mnemonic ciphertext/IV/integrity tag. If interrupted after verified persistence but before cleanup, the next successful auth-bound reveal verifies the new copy and completes cleanup. Errors fail closed and retain the legacy copy. + +**Validation:** compile/static assertions verify the dedicated auth-required key and absence of a bypass. Real Keystore user-auth enforcement, cancellation, key invalidation, reboot/timeout, and migration on physical/emulated Android are explicitly deferred to Codex. + +### RT-SEC-005 — Legacy plaintext secret persistence +**Status:** FIXED AND JVM-TESTED. + +**Files:** `MainActivity.kt`, `security/LegacySecretMigration.kt`, `LegacySecretMigrationTest.kt`, `AdminKeyStorage.kt`. + +**Invariant:** new application secrets are stored in `raventag_secure_v2` (`EncryptedSharedPreferences`) or the dedicated encrypted `AdminKeyStorage`. Only the historically secret-bearing keys `admin_key`, `operator_key`, `initial_master_key`, and `pinata_jwt` are migrated from legacy `raventag_secure`. `kubo_node_url` is non-secret configuration and is moved to normal application preferences rather than included in the secret whitelist. + +**Migration invariant:** source is removed only after encrypted destination write and read-back equality. Secure-store failure leaves source untouched. A run interrupted after destination verification but before deletion is safely resumable. + +**Validation:** JVM tests cover no legacy values, successful migration, encrypted-store failure, interrupted migration, and repeated/idempotent migration. Final CI statically rejects new duplicate admin/Kubo writes into secret storage. Real upgrade tests remain in the Codex plan. + +### RT-SEC-006 — Insufficient Ravencoin address validation +**Status:** FIXED AND JVM-TESTED. + +**Files:** `RavencoinTxBuilder.kt`, `RavencoinTxBuilderTest.kt`. + +**Invariant:** Base58Check checksum, payload structure, canonical encoding, and Ravencoin mainnet P2PKH version `0x3C` are enforced before transaction construction. Foreign-network Bitcoin-style addresses are rejected. + +**Validation:** address regression vector plus final static invariant. + +### RT-SEC-007 — Registry poisoning with unrelated valid txid +**Status:** FIXED AND BACKEND-TESTED. + +**Files:** `backend/src/routes/registry.ts`, `services/registry-issuance.ts`, `services/registry-issuance.test.ts`, `services/ravencoin.ts`. + +**Invariant:** registry claims are accepted only when `getrawtransaction(..., true)` semantics prove the transaction actually creates the claimed Ravencoin asset with matching issuance type/name, required canonical burn, and owner-token semantics where applicable. + +**Validation:** backend tests cover canonical root issuance, ordinary/unrelated tx rejection, asset-name mismatch, type mismatch, missing burn, missing owner token, and valid unique issuance. Real mainnet transaction corpus is deferred to Codex. + +### RT-SEC-008 — Concurrent UTXO spend race / reservation after signing +**Status:** FIXED IN CODE AND REGRESSION-GATED. + +**Files:** `WalletManager.kt`, `cache/ReservedUtxoDao.kt`, transaction tests. + +**Invariant:** every on-device financial signing path enters a shared signing mutex and obtains durable `CONFLICT_ABORT` reservations before any transaction builder/signature invocation. The reservation is rebound to the locally computed txid before network I/O; post-sign ambiguous network failure retains the reservation. + +**Validation:** Android compile/JVM suite and whole-tree gate reject direct `tx.hex` broadcast bypasses and require reservation-before-build ordering. True simultaneous coroutine/process/device stress is deferred to Codex. + +### RT-SEC-009 — Mnemonic clipboard exposure +**Status:** FIXED IN CODE. + +**Files:** `MnemonicBackupScreen.kt`, `MnemonicExporter.kt`. + +**Invariant:** no Copy All/clipboard operation exists for the recovery phrase; sensitive buffers are held as `CharArray` where practical and cleared on disposal. `FLAG_SECURE` protects the display. + +**Validation:** final static gate scans the mnemonic reveal components for clipboard APIs. + +### RT-SEC-010 — Mutable CI/action/container supply-chain references +**Status:** FIXED IN REPOSITORY WORKFLOWS. + +**Files:** `.github/workflows/qwen-dispatch.yml`, `qwen-invoke.yml`, `qwen-review.yml`, `qwen-scheduled-triage.yml`, `qwen-triage.yml`. + +**Invariant:** Qwen code actions are immutable commit-SHA references and security-sensitive Qwen/MCP containers use immutable digests; unnecessary OIDC permission was removed. + +**Validation:** final whole-tree workflow scan rejects mutable Qwen action/container references. + +### RT-SEC-011 — NFC replay counter race +**Status:** FIXED LATO CODICE; deployment topology must preserve single authoritative replay state or equivalent serialization. + +**Files:** `backend/src/middleware/cache.ts` and NFC verification path. + +**Invariant:** replay counter compare/update is atomic at the database boundary rather than a separate check-then-write race. + +**Validation:** static/backend tests where available. Multi-instance contention and real-tag replay remain dynamic/operational tests. + +### RT-SEC-012 — Android global cleartext networking +**Status:** FIXED. + +**Files:** `AndroidManifest.xml`, `res/xml/network_security_config.xml`. + +**Invariant:** cleartext traffic is denied globally; there is no global cleartext exception. + +**Validation:** final static gate. + +### RT-SEC-013 — `/verify` deep-link host trust +**Status:** FIXED. + +**Files:** `NfcReader.kt`, Android manifest/app-link configuration. + +**Invariant:** `/verify` is accepted only from the explicit RavenTag authorized host set, not merely by matching a path on an arbitrary HTTPS host. + +**Validation:** static/JVM validation; malicious real intents remain in Codex plan. + +### RT-SEC-014 — Unchecked NDEF payload indexing +**Status:** FIXED. + +**Files:** `NfcReader.kt`. + +**Invariant:** NDEF payload parsing checks length/structure before indexing; no unchecked `payload[0]` path remains. + +**Validation:** final static gate and Android JVM suite. + +### RT-SEC-015 — IPFS redirect policy bypass +**Status:** FIXED. + +**Files:** `backend/src/services/ipfs.ts`, Android `RpcClient.kt`/IPFS client path. + +**Invariant:** validation is not bypassed by transparent HTTP/HTTPS redirect following; redirect targets must not silently escape the intended policy. + +**Validation:** static gate requires redirect following disabled on the relevant client. Malicious redirect chains remain a Codex test. + +### RT-SEC-016 — Backend dependency advisories +**Status:** FIXED BY COMPATIBLE DEPENDENCY REFRESH; final gate requires zero npm audit findings at low-or-higher severity. + +**Files:** `backend/package.json`, `backend/package-lock.json`. + +**Pre-refresh exact audit:** 9 vulnerabilities: 4 high, 4 moderate, 1 low. + +- `form-data` (direct, high, GHSA-hmw2-7cc7-3qxx): multipart CRLF injection; backend IPFS multipart code makes this relevant. Fixed by `4.0.6`. +- `multer` (direct, high): nested-field DoS and aborted-upload cleanup advisories; source import was not found in the current backend, reducing present reachability, but the declared dependency is updated to `2.2.0` rather than relying on non-use. +- `express` (direct, moderate): affected through vulnerable `qs`; request routing/parsing is network reachable. Updated within major 4 to `4.22.2`. +- `express-rate-limit` (direct, moderate): affected through `ip-address`; rate-limit middleware is on public/admin request paths. Updated within major 8 to `8.6.2`. +- `body-parser` (transitive, moderate aggregate): reachable through Express JSON request parsing; fixed by dependency refresh. +- `qs` (transitive, moderate): Express/body-parser dependency; fixed by Express dependency refresh. +- `path-to-regexp` (transitive via Express, high): Express routing dependency; the lock is explicitly refreshed to patched `0.1.13`, which remains compatible with Express 4.22.2, avoiding a breaking Express 5 upgrade. +- `ip-address` (transitive, high aggregate): express-rate-limit dependency; fixed by express-rate-limit dependency refresh. +- `esbuild` (transitive, low): pulled through `tsx`, used for dev/test tooling rather than production server runtime; fixed by updating `tsx` within major 4. + +`axios` is also refreshed within major 1. No `npm audit fix --force` is used. The final gate executes `npm ci`, TypeScript build, backend tests, and `npm audit --audit-level=low`; merge is blocked if any advisory remains. + +### RT-SEC-017 — Default-branch governance +**Status:** OPERATIONAL REMEDIATION REQUIRED. + +**Evidence:** remote `master` branch protection/ruleset is not enabled and the available repository connector does not expose an authorized branch-protection/ruleset mutation in this workflow. + +**Required operator action:** enable branch protection/ruleset for `master` (PR requirement, required status checks including the permanent security gate, restrict force-push/deletion, appropriate review policy). + +### RT-SEC-018 — Committed Android local SDK path +**Status:** FIXED. + +**Files:** `.gitignore`; `android/app/local.properties` removed from the tracked tree. + +**Invariant:** machine-local Android SDK paths are not repository content. + +**Validation:** final secret/dangerous-pattern gate rejects tracked `local.properties`/signing/environment files. + +### RT-SEC-019 — BIP39 normalization/KDF compatibility +**Status:** FIXED AND JVM-TESTED. + +**Files:** `Bip39Kdf.kt`, `Bip39KdfTest.kt`, `WalletManager.kt`. + +**Invariant:** mnemonic/passphrase inputs use BIP39 NFKD normalization and production seed derivation delegates to the same vector-tested implementation. + +**Validation:** official BIP39 vector and Unicode normalization-equivalence JVM tests. Full BIP32/BIP44/transaction vector corpus remains in Codex plan. + +### RT-SEC-020 — Backup key separation / retention +**Status:** FIXED IN CODE AND DEPLOYMENT CONFIG. + +**Files:** `backend/src/services/backup.ts`, `backend/src/index.ts`, `docker-compose.yml`, `.env.example`, `docs/deploy/en.md`. + +**Invariant:** backup interval is 24 hours and retention is 7 encrypted snapshots. Backup encryption material is a dedicated `backup_encryption_key` / `INTERNAL_BACKUP_ENCRYPTION_KEY_FILE`, never `ADMIN_KEY`/`ADMIN_KEY_FILE`. The in-process scheduler is opt-in and fails closed when enabled without its dedicated key path. Compose mounts admin auth and backup encryption as different secrets; documentation explicitly forbids key reuse. + +**Validation:** final whole-tree gate asserts interval/retention and distinct paths, then performs global secret-name review. + +## Validation executed by the final gate + +The merge gate runs on the exact PR HEAD: + +```text +backend: + npm ci --ignore-scripts + npm run build + npm test + npm audit --audit-level=low + +android: + ./gradlew compileBrandDebugKotlin compileConsumerDebugKotlin + ./gradlew test + +repository: + whole-tree security invariant assertions + immutable Qwen/MCP reference assertions + tracked-secret/dangerous-pattern scan + git diff --check +``` + +A green gate proves only these automated checks. It does not substitute for the dynamic adversarial program below. diff --git a/android/app/local.properties b/android/app/local.properties deleted file mode 100644 index 13b9af1e..00000000 --- a/android/app/local.properties +++ /dev/null @@ -1,8 +0,0 @@ -## This file must *NOT* be checked into Version Control Systems, -# as it contains information specific to your local configuration. -# -# Location of the SDK. This is only used by Gradle. -# For customization when using a Version Control System, please read the -# header note. -#Sun Mar 08 02:03:43 CET 2026 -sdk.dir=/home/ale/Android/Sdk diff --git a/android/app/src/androidTest/java/io/raventag/app/wallet/WalletManagerTest.kt b/android/app/src/androidTest/java/io/raventag/app/wallet/WalletManagerTest.kt index 1205f25c..e4680154 100644 --- a/android/app/src/androidTest/java/io/raventag/app/wallet/WalletManagerTest.kt +++ b/android/app/src/androidTest/java/io/raventag/app/wallet/WalletManagerTest.kt @@ -133,12 +133,6 @@ class WalletManagerTest { // ── Mnemonic storage ───────────────────────────────────────────────────── - @Test - fun getMnemonic_afterRestore_returnsOriginalMnemonic() { - val mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - walletManager.restoreWallet(mnemonic) - assertEquals(mnemonic, walletManager.getMnemonic()) - } @Test fun hasWallet_afterRestore_returnsTrue() { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1fb8a73a..8b39b554 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,7 +16,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.RavenTag" - android:usesCleartextTraffic="true" + android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config"> @@ -38,13 +38,16 @@ - + - + diff --git a/android/app/src/main/java/io/raventag/app/MainActivity.kt b/android/app/src/main/java/io/raventag/app/MainActivity.kt index 9cf23ce1..6135c762 100644 --- a/android/app/src/main/java/io/raventag/app/MainActivity.kt +++ b/android/app/src/main/java/io/raventag/app/MainActivity.kt @@ -101,6 +101,7 @@ import io.raventag.app.wallet.RavencoinTxBuilder import io.raventag.app.wallet.SubAssetIssueParams import io.raventag.app.wallet.WalletManager import io.raventag.app.security.AdminKeyStorage +import io.raventag.app.security.LegacySecretMigration import io.raventag.app.config.AppConfig import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey @@ -2864,7 +2865,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * showing a generic error. * */ - fun sendRvn(toAddress: String, amount: Double) { + fun sendRvn(toAddress: String, amount: Double, explicitMax: Boolean) { val s = getStrings() val wm = walletManager ?: run { sendSuccess = false; sendResult = s.walletNoWallet; return @@ -2881,7 +2882,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { // Execute send with retry (D-06) val result = RetryUtils.retryWithBackoff { withContext(Dispatchers.IO) { - wm.sendRvnLocal(toAddress, amount) { msg -> + wm.sendRvnLocal(toAddress, amount, explicitMax) { msg -> sendProgressLog = sendProgressLog + msg } } @@ -2890,6 +2891,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val txid = result.substringBefore("|fee:") val feeRvn = result.substringAfter("|fee:", "0").substringBefore("|").toLongOrNull()?.let { it / 1e8 } ?: 0.0 val cycledSat = result.substringAfter("|change:", "0").toLongOrNull() ?: 0L + val sentSatActual = result.substringAfter("|sent:", "0").toLongOrNull() ?: (amount * 1e8).toLong() + val sentRvnActual = sentSatActual / 1e8 // Show confirming notification (waiting for blocks) TransactionNotificationHelper.showConfirming(getApplication(), 1, 1) @@ -2904,7 +2907,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { sendLoading = false sendSuccess = true sendProgressLog = emptyList() - sendResult = s.walletSendResult.replace("%1", amount.toString()) + sendResult = s.walletSendResult.replace("%1", sentRvnActual.toString()) .replace("%2", "%.5f".format(feeRvn)) .replace("%3", "${txid.take(20)}...") @@ -2916,7 +2919,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val currentBalance = walletInfo?.balanceRvn if (currentBalance != null) { walletInfo = walletInfo?.copy( - balanceRvn = (currentBalance - amount - feeRvn).coerceAtLeast(0.0) + balanceRvn = (currentBalance - sentRvnActual - feeRvn).coerceAtLeast(0.0) ) } @@ -2924,7 +2927,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { // user sees it immediately instead of waiting for the next ElectrumX poll. val nowMs = System.currentTimeMillis() val nowSec = nowMs / 1000L - val sentSatOpt = (amount * 1e8).toLong() + val sentSatOpt = sentSatActual val feeSatOpt = (feeRvn * 1e8).toLong() if (txHistory.none { it.txid == txid }) { val optimistic = io.raventag.app.wallet.TxHistoryEntry( @@ -3851,6 +3854,9 @@ class MainActivity : FragmentActivity() { */ private var securePrefsReady by mutableStateOf(false) + /** Non-null when encrypted secret storage cannot be initialized. */ + private var secureStorageError by mutableStateOf(null) + /** * Show a biometric (or device-credential) authentication prompt. * @@ -3876,19 +3882,18 @@ class MainActivity : FragmentActivity() { onSuccess: () -> Unit, onError: () -> Unit ) { - // BIOMETRIC_STRONG | DEVICE_CREDENTIAL is only supported on API 30+. - // On older APIs use BIOMETRIC_WEAK (biometric only, requires negative button). val authenticators = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL + BiometricManager.Authenticators.DEVICE_CREDENTIAL } else { - BiometricManager.Authenticators.BIOMETRIC_WEAK + // DEVICE_CREDENTIAL combinations are not consistently supported by + // BiometricPrompt on API 26-29; require a strong biometric instead. + BiometricManager.Authenticators.BIOMETRIC_STRONG } - // If no credentials are enrolled or hardware unavailable, skip auth silently. - val canAuth = BiometricManager.from(this).canAuthenticate(authenticators) - if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) { - onSuccess() + // Authentication unavailable is a failure, never evidence of identity. + if (BiometricManager.from(this).canAuthenticate(authenticators) != BiometricManager.BIOMETRIC_SUCCESS) { + onError() return } @@ -3898,29 +3903,28 @@ class MainActivity : FragmentActivity() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { onSuccess() } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - // Distinguish between "user cancelled" (close app) and - // "hardware/enrollment error" (allow access to avoid locking out). - val userCancelled = errorCode == BiometricPrompt.ERROR_USER_CANCELED || - errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON - if (userCancelled) onError() else onSuccess() + // Every error path (cancel, lockout, unavailable hardware, + // enrollment problem, negative button, platform failure) stays locked. + onError() } + override fun onAuthenticationFailed() { - // A single biometric attempt failed; more attempts allowed, do nothing. + // A failed attempt does not unlock. BiometricPrompt may allow another attempt. } } val builder = BiometricPrompt.PromptInfo.Builder() .setTitle(title) .setSubtitle(subtitle) .setAllowedAuthenticators(authenticators) - // On API < 30 with BIOMETRIC_WEAK, a negative button text is required. if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { builder.setNegativeButtonText("Annulla") } BiometricPrompt(this, executor, callback).authenticate(builder.build()) } catch (_: Exception) { - // If the prompt cannot be shown for any reason, grant access. - onSuccess() + // Prompt initialization/platform errors fail closed. + onError() } } @@ -3951,21 +3955,61 @@ class MainActivity : FragmentActivity() { // starts with managers ready but without blocking the main thread. lifecycleScope.launch(Dispatchers.IO) { val securePrefsDeferred = async { - try { - val masterKey = MasterKey.Builder(this@MainActivity) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - this@MainActivity, - "raventag_secure", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + val masterKey = MasterKey.Builder(this@MainActivity) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val encryptedV2 = EncryptedSharedPreferences.create( + this@MainActivity, + "raventag_secure_v2", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun migrateFrom(source: android.content.SharedPreferences) { + LegacySecretMigration.migrate( + readLegacy = { source.getString(it, null) }, + writeSecure = { key, value -> + check(encryptedV2.edit().putString(key, value).commit()) { + "secure preference write failed for $key" + } + }, + readSecure = { encryptedV2.getString(it, null) }, + removeLegacy = { key -> + check(source.edit().remove(key).commit()) { + "legacy preference cleanup failed for $key" + } + } ) - } catch (_: Throwable) { - // Fallback to plain prefs if Keystore unavailable (e.g. work profile restrictions) - getSharedPreferences("raventag_secure", MODE_PRIVATE) } + + // Historical fail-open releases could write the literal key names + // into an ordinary SharedPreferences file of this name. + val rawLegacy = getSharedPreferences("raventag_secure", MODE_PRIVATE) + migrateFrom(rawLegacy) + + // Later releases used EncryptedSharedPreferences under the same v1 + // file name. Migrate those values too; if the old encrypted store + // cannot be opened, fail closed and leave it untouched. + val encryptedV1 = EncryptedSharedPreferences.create( + this@MainActivity, + "raventag_secure", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + migrateFrom(encryptedV1) + + // Kubo URL is configuration, not a credential. Preserve it outside + // the secret migration whitelist. + if (!prefs.contains("kubo_node_url")) { + val oldKubo = rawLegacy.getString("kubo_node_url", null) + ?: encryptedV1.getString("kubo_node_url", null) + if (!oldKubo.isNullOrBlank()) { + check(prefs.edit().putString("kubo_node_url", oldKubo).commit()) + } + } + encryptedV2 } val adminKeyStorageDeferred = async { val storage = AdminKeyStorage(applicationContext) @@ -3982,14 +4026,36 @@ class MainActivity : FragmentActivity() { ) } - val initializedSecurePrefs = securePrefsDeferred.await() + val initializedSecurePrefs = try { + securePrefsDeferred.await() + } catch (t: Throwable) { + Log.e("MainActivity", "Encrypted secret storage unavailable", t) + withContext(Dispatchers.Main) { + secureStorageError = "Secure storage is unavailable. RavenTag will not load secret-bearing functionality until Android Keystore access is restored." + securePrefsReady = true + } + return@launch + } val initializedAdmin = adminKeyStorageDeferred.await() val initializedAdminKeyStorage = initializedAdmin.first - val initializedAdminKey = initializedAdmin.second + var initializedAdminKey = initializedAdmin.second + // Legacy admin_key is migrated through v2 only as a crash-safe bridge, + // then moved into its dedicated encrypted AdminKeyStorage. + val migratedAdminKey = initializedSecurePrefs.getString("admin_key", "").orEmpty() + if (initializedAdminKey.isBlank() && migratedAdminKey.isNotBlank()) { + initializedAdminKeyStorage.setAdminKey(migratedAdminKey) + check(initializedAdminKeyStorage.getAdminKey() == migratedAdminKey) { + "admin key migration verification failed" + } + initializedAdminKey = migratedAdminKey + } + if (migratedAdminKey.isNotBlank()) { + check(initializedSecurePrefs.edit().remove("admin_key").commit()) + } val initializedMasterKey = initializedSecurePrefs.getString("initial_master_key", "") ?: "" val initializedOperatorKey = initializedSecurePrefs.getString("operator_key", "") ?: "" val initializedPinataJwt = initializedSecurePrefs.getString("pinata_jwt", "") ?: "" - val initializedKuboNodeUrl = initializedSecurePrefs.getString("kubo_node_url", "") ?: "" + val initializedKuboNodeUrl = prefs.getString("kubo_node_url", "") ?: "" val initializedWalletManager = walletManagerDeferred.await() dbDeferred.await() val initializedAssetManager = AssetManager( @@ -4020,8 +4086,18 @@ class MainActivity : FragmentActivity() { viewModel.currentVerifyUrl = savedVerifyUrl setContent { - // Hold content until EncryptedSharedPreferences is ready (avoids reading null keys) + // Hold content until EncryptedSharedPreferences is ready (avoids reading null keys). if (!securePrefsReady) return@setContent + secureStorageError?.let { message -> + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text(message, color = Color.White, style = MaterialTheme.typography.bodyLarge) + } + } + } + return@setContent + } // Persisted user preferences (read from SharedPreferences, updated on save) var langCode by remember { mutableStateOf(prefs.getString("language", "en") ?: "en") } @@ -4173,13 +4249,15 @@ class MainActivity : FragmentActivity() { onWalletRoleSave = { role, key -> // Persist role and key; clear the unused key slot if (role == "admin") { - securePrefs.edit().putString("admin_key", key).putString("operator_key", "").apply() + check(securePrefs.edit().remove("operator_key").commit()) + viewModel.adminKeyStorage?.setAdminKey(key) + check(viewModel.adminKeyStorage?.getAdminKey() == key) savedAdminKey = key; savedOperatorKey = "" viewModel.adminKeyStatus = MainViewModel.AdminKeyStatus.VALID viewModel.operatorKeyStatus = MainViewModel.AdminKeyStatus.UNKNOWN - try { viewModel.adminKeyStorage?.setAdminKey(key) } catch (_: Throwable) {} } else { - securePrefs.edit().putString("operator_key", key).putString("admin_key", "").apply() + check(securePrefs.edit().putString("operator_key", key).commit()) + viewModel.adminKeyStorage?.clearAdminKey() savedOperatorKey = key; savedAdminKey = "" viewModel.operatorKeyStatus = MainViewModel.AdminKeyStatus.VALID viewModel.adminKeyStatus = MainViewModel.AdminKeyStatus.UNKNOWN @@ -4209,7 +4287,7 @@ class MainActivity : FragmentActivity() { }, savedKuboNodeUrl = savedKuboNodeUrl, onKuboNodeUrlSave = { url -> - securePrefs.edit().putString("kubo_node_url", url).apply() + prefs.edit().putString("kubo_node_url", url).apply() savedKuboNodeUrl = url viewModel.kuboNodeUrl = url viewModel.kuboNodeStatus = MainViewModel.AdminKeyStatus.UNKNOWN @@ -4671,7 +4749,7 @@ fun RavenTagApp( viewModel.sendFeeUnavailable = false viewModel.estimatedFee = 0.0 }, - onSend = viewModel::sendRvn + onSend = { address, amount, explicitMax -> viewModel.sendRvn(address, amount, explicitMax) } ) return } diff --git a/android/app/src/main/java/io/raventag/app/nfc/NfcReader.kt b/android/app/src/main/java/io/raventag/app/nfc/NfcReader.kt index 766cb6e0..d85a9ce0 100644 --- a/android/app/src/main/java/io/raventag/app/nfc/NfcReader.kt +++ b/android/app/src/main/java/io/raventag/app/nfc/NfcReader.kt @@ -3,15 +3,6 @@ * * Parses incoming Android NFC intents and extracts the SUN (Secure Unique NFC) * URL parameters broadcast by an NTAG 424 DNA chip. - * - * The NTAG 424 DNA encodes two parameters in each tap URL: - * e = AES-128-CBC encrypted PICCData (UID + scan counter), 32 hex chars - * m = NXP-truncated AES-CMAC over the session key, 16 hex chars - * An optional "asset" query parameter carries the Ravencoin asset name. - * - * This object handles all three NFC intent actions that Android may deliver, - * decodes the NDEF URI record, and validates parameter lengths before returning - * a typed SunParams value object. */ package io.raventag.app.nfc @@ -23,22 +14,16 @@ import android.nfc.Tag import android.nfc.tech.Ndef import android.util.Log -/** - * NFC reading utilities for RavenTag. - * Extracts SUN URL parameters from NDEF messages. - */ +/** NFC reading utilities for RavenTag. */ object NfcReader { private const val TAG = "NfcReader" + private const val VERIFY_SCHEME = "https" + private const val VERIFY_HOST = "verify.raventag.com" + private const val VERIFY_PATH = "/verify" + private const val MAX_URI_PAYLOAD_BYTES = 2048 + private const val MAX_ASSET_LENGTH = 100 - /** - * Typed container for the SUN URL query parameters extracted from an NFC tap. - * - * @property e 32 hex chars: AES-128-CBC encrypted PICCData (UID + 3-byte scan counter) - * @property m 16 hex chars: NXP-truncated SDMMAC (8 bytes at odd indices of full CMAC) - * @property asset Optional Ravencoin asset name embedded in the URL (e.g. "BRAND/ITEM#SN001") - * @property rawUrl The complete unmodified URL as read from the NDEF record - */ data class SunParams( val e: String, val m: String, @@ -46,10 +31,6 @@ object NfcReader { val rawUrl: String ) - /** - * Extract SUN parameters from an NFC intent. - * Returns null if the intent is not an NFC NDEF intent or no SUN params found. - */ fun extractSunParams(intent: Intent): SunParams? { if (intent.action !in setOf( NfcAdapter.ACTION_NDEF_DISCOVERED, @@ -61,7 +42,6 @@ object NfcReader { return when (intent.action) { NfcAdapter.ACTION_NDEF_DISCOVERED -> extractFromNdef(intent) NfcAdapter.ACTION_TAG_DISCOVERED -> { - // Fallback: try to read NDEF from low-level Tag object (UID not logged for privacy) val tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) tag?.let { extractFromTagObject(it) } } @@ -69,44 +49,21 @@ object NfcReader { } } - /** - * Read NDEF records directly from a Tag object (used in ACTION_TAG_DISCOVERED fallback). - * - * Android delivers ACTION_TAG_DISCOVERED when it cannot match a more specific intent filter. - * We open the Ndef technology layer manually to read the NDEF message. - */ private fun extractFromTagObject(tag: Tag): SunParams? { Log.d(TAG, "extractFromTagObject: techs=${tag.techList.joinToString()}") return try { - val ndef = Ndef.get(tag) - if (ndef == null) { - Log.w(TAG, "Ndef.get returned null, tag has no NDEF tech") - return null - } + val ndef = Ndef.get(tag) ?: return null ndef.connect() - val message = ndef.ndefMessage - ndef.close() - if (message == null) { - Log.w(TAG, "NDEF message is null (tag formatted but empty?)") - return null - } - Log.d(TAG, "NDEF message has ${message.records.size} record(s)") - val result = extractFromNdefMessage(message) - if (result == null) Log.w(TAG, "extractFromNdefMessage returned null") - result + val message = try { ndef.ndefMessage } finally { runCatching { ndef.close() } } + message?.let { extractFromNdefMessage(it) } } catch (e: Exception) { Log.w(TAG, "NDEF read from Tag object failed: ${e.message}") null } } - /** - * Extract SUN parameters from the EXTRA_NDEF_MESSAGES parcelable array - * delivered with ACTION_NDEF_DISCOVERED intents. - */ private fun extractFromNdef(intent: Intent): SunParams? { - val rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) - ?: return null + val rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) ?: return null for (raw in rawMessages) { val message = raw as? NdefMessage ?: continue extractFromNdefMessage(message)?.let { return it } @@ -114,79 +71,62 @@ object NfcReader { return null } - /** - * Iterate NDEF records and parse the first Well-Known URI record that contains - * valid SUN parameters. - * - * Only TNF 0x01 (Well-Known URI) records are considered; this matches the record - * type written by Ntag424Configurator. The URI prefix byte (first payload byte) - * is expanded to its string equivalent before URL parsing. - */ private fun extractFromNdefMessage(message: NdefMessage): SunParams? { for (record in message.records) { + if (record.tnf != 0x01.toShort()) continue val payload = record.payload ?: continue - // Only TNF 0x01 (Well Known URI) , correct for NTAG 424 DNA SUN records - val urlStr = when (record.tnf) { - 0x01.toShort() -> { - val prefix = uriPrefix(payload[0]) - prefix + String(payload, 1, payload.size - 1, Charsets.UTF_8) - } - else -> null - } ?: continue - - Log.d(TAG, "NDEF URI record: $urlStr") + // A URI record always needs at least the prefix byte. Bound input before + // allocating a String so a hostile tag cannot trigger unbounded parsing. + if (payload.isEmpty() || payload.size > MAX_URI_PAYLOAD_BYTES) { + Log.w(TAG, "Rejecting malformed/oversized NDEF URI payload (${payload.size} bytes)") + continue + } + val prefixCode = payload.firstOrNull() ?: continue + val prefix = uriPrefix(prefixCode) ?: continue + val suffix = if (payload.size > 1) String(payload, 1, payload.size - 1, Charsets.UTF_8) else "" + val urlStr = prefix + suffix parseSunUrl(urlStr)?.let { return it } } return null } /** - * Parse SUN URL and extract e and m parameters. - * Expected format: https://verify.raventag.com/?e=HEX&m=HEX - * - * Special case: Ravencoin unique asset names contain '#' (the separator between - * sub-asset name and serial number). If the '#' was not percent-encoded when - * the NDEF URL was written, Android's Uri.parse() treats it as a fragment - * separator and moves the 'e' and 'm' parameters into the fragment. This - * method detects that case and re-encodes the '#' as '%23' before parsing. + * Parse a canonical RavenTag SUN URL. Generic HTTPS hosts are rejected even if + * Android dispatches an unexpected intent to this activity. */ fun parseSunUrl(url: String): SunParams? { + if (url.length > MAX_URI_PAYLOAD_BYTES) return null return try { - // If the asset name contains '#' (unique asset separator) and it was not - // percent-encoded when the NDEF URL was built, Android Uri.parse treats it - // as a fragment separator, pushing e= and m= into the fragment. - // Detect this and fix by replacing the first bare '#' with '%23'. - val fragment = android.net.Uri.parse(url).fragment + val firstParse = android.net.Uri.parse(url) + val fragment = firstParse.fragment val fixedUrl = if (fragment != null && fragment.contains("&e=") && fragment.contains("&m=")) { - Log.d(TAG, "Detected unencoded '#' in URL, fixing") url.replaceFirst("#", "%23") - } else { - url - } + } else url val uri = android.net.Uri.parse(fixedUrl) + if (!uri.scheme.equals(VERIFY_SCHEME, ignoreCase = true)) return null + if (!uri.host.equals(VERIFY_HOST, ignoreCase = true)) return null + if (uri.path != VERIFY_PATH && uri.path != "/") return null + if (uri.userInfo != null) return null + if (uri.port != -1 && uri.port != 443) return null + val e = uri.getQueryParameter("e") ?: return null val m = uri.getQueryParameter("m") ?: return null val asset = uri.getQueryParameter("asset") - // Basic validation: e = 32 hex chars (16 bytes PICC data), m = 16 hex chars (8 bytes MACt) if (!e.matches(Regex("[0-9a-fA-F]{32}"))) return null if (!m.matches(Regex("[0-9a-fA-F]{16}"))) return null + if (asset != null && (asset.length > MAX_ASSET_LENGTH || !asset.matches(Regex("[A-Z0-9_./#!-]+")))) return null SunParams(e = e, m = m, asset = asset, rawUrl = url) } catch (ex: Exception) { - Log.e(TAG, "Failed to parse SUN URL: ${ex.message}") + Log.w(TAG, "Rejecting malformed SUN URL: ${ex.message}") null } } - /** - * Expand the NDEF URI prefix byte to its string representation. - * Per NDEF URI Record Type Definition spec, byte 0 of a URI payload - * encodes a common URI prefix to save space. Code 0x04 = "https://" - * is the typical value for NTAG 424 DNA SUN URLs. - */ - private fun uriPrefix(code: Byte): String = when (code.toInt()) { + /** Null means the URI-prefix identifier is unsupported. */ + private fun uriPrefix(code: Byte): String? = when (code.toInt() and 0xff) { 0x00 -> "" 0x01 -> "http://www." 0x02 -> "https://www." @@ -194,18 +134,12 @@ object NfcReader { 0x04 -> "https://" 0x05 -> "tel:" 0x06 -> "mailto:" - else -> "" + else -> null } - /** - * Check if device supports NFC. - */ fun isNfcSupported(activity: Activity): Boolean = NfcAdapter.getDefaultAdapter(activity) != null - /** - * Check if NFC is enabled. - */ fun isNfcEnabled(activity: Activity): Boolean = NfcAdapter.getDefaultAdapter(activity)?.isEnabled == true } diff --git a/android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt b/android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt index 800e1bd2..86dc6a54 100644 --- a/android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt +++ b/android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt @@ -107,7 +107,7 @@ class RpcClient( val file = java.io.File(dir, hash) if (file.exists()) return "file://${file.absolutePath}" val req = Request.Builder().url(url).get().build() - http.newCall(req).execute().use { resp -> + noRedirectHttp.newCall(req).execute().use { resp -> if (!resp.isSuccessful) return null resp.body?.byteStream()?.use { input -> java.io.FileOutputStream(file).use { output -> @@ -140,6 +140,15 @@ class RpcClient( .readTimeout(30, TimeUnit.SECONDS) .build() + // IPFS/backend fetches in this component must never transparently follow a + // redirect to a host that was not selected/validated by the caller. A 3xx is + // handled as a non-successful response and the next explicit candidate may be + // tried instead. + private val noRedirectHttp = http.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + data class RpcPayload( val jsonrpc: String = "1.0", val id: String = "raventag-android", @@ -155,7 +164,7 @@ class RpcClient( .post(body) .build() - val response = http.newCall(request).executeSuspend() + val response = noRedirectHttp.newCall(request).executeSuspend() if (!response.isSuccessful) { throw IOException("RPC HTTP error: ${response.code}") } @@ -193,7 +202,7 @@ class RpcClient( val request = Request.Builder() .url("$rpcUrl/api/assets/${assetName.uppercase()}") .get().build() - val response = http.newCall(request).executeSuspend() + val response = noRedirectHttp.newCall(request).executeSuspend() if (!response.isSuccessful) return null val obj = gson.fromJson(response.body?.string(), JsonObject::class.java) AssetData( @@ -221,7 +230,7 @@ class RpcClient( urls.forEach { url -> runCatching { val request = Request.Builder().url(url).get().build() - val response = http.newCall(request).executeSuspend() + val response = noRedirectHttp.newCall(request).executeSuspend() if (!response.isSuccessful) { Log.w(TAG, "fetchIpfsMetadata $ipfsUri via $url http=${response.code}") return@runCatching null @@ -243,7 +252,7 @@ class RpcClient( val request = Request.Builder() .url("$rpcUrl/api/assets?search=${query.uppercase()}") .get().build() - val response = http.newCall(request).executeSuspend() + val response = noRedirectHttp.newCall(request).executeSuspend() if (!response.isSuccessful) return emptyList() val obj = gson.fromJson(response.body?.string(), JsonObject::class.java) val arr = obj["assets"]?.asJsonArray ?: return emptyList() @@ -332,7 +341,7 @@ class RpcClient( data class Result(val imageUrl: String?, val description: String?) val found: Result? = try { val req = Request.Builder().url(url).get().build() - http.newCall(req).executeSuspend().use { resp -> + noRedirectHttp.newCall(req).executeSuspend().use { resp -> if (!resp.isSuccessful) { Log.w(TAG, "enrichWithIpfsData ${asset.name} HTTP ${resp.code} via $url") return@use null diff --git a/android/app/src/main/java/io/raventag/app/security/BiometricGate.kt b/android/app/src/main/java/io/raventag/app/security/BiometricGate.kt index e623b644..26d9678d 100644 --- a/android/app/src/main/java/io/raventag/app/security/BiometricGate.kt +++ b/android/app/src/main/java/io/raventag/app/security/BiometricGate.kt @@ -1,5 +1,6 @@ package io.raventag.app.security +import android.os.Build import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat @@ -10,14 +11,68 @@ import kotlin.coroutines.resumeWithException import kotlinx.coroutines.suspendCancellableCoroutine /** - * D-15: binds BiometricPrompt authentication to a Keystore decrypt operation via - * `BiometricPrompt.CryptoObject`. Authentication is NOT a boolean flag; no auth, no - * plaintext. + * Fail-closed authentication gate used by wallet secret-reveal operations. * - * Caller constructs a fresh instance per reveal. Not thread-safe on purpose. + * Two modes are intentionally exposed: + * - [authenticate] establishes a recent OS authentication for Keystore keys + * configured with a short user-authentication validity window. + * - [decryptWithBiometric] binds a CryptoObject directly to a prompt for keys + * configured for per-operation authentication. + * + * No error, cancellation, hardware problem or missing enrollment is ever mapped + * to success. */ class BiometricGate(private val activity: FragmentActivity) { + private fun authenticators(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + } else { + BiometricManager.Authenticators.BIOMETRIC_STRONG + } + + private fun promptInfo(titleRes: Int, subtitleRes: Int): BiometricPrompt.PromptInfo { + val builder = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(titleRes)) + .setSubtitle(activity.getString(subtitleRes)) + .setAllowedAuthenticators(authenticators()) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + builder.setNegativeButtonText("Cancel") + } + return builder.build() + } + + /** Authenticate the user and return only after a genuine OS-auth success. */ + suspend fun authenticate(titleRes: Int, subtitleRes: Int): Unit = + suspendCancellableCoroutine { cont -> + val prompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + if (cont.isActive) cont.resume(Unit) + } + + override fun onAuthenticationError(code: Int, msg: CharSequence) { + if (cont.isActive) { + cont.resumeWithException(BiometricCancelledException(code, msg.toString())) + } + } + + override fun onAuthenticationFailed() { + // A failed attempt is not success; BiometricPrompt may allow another attempt. + } + } + ) + try { + prompt.authenticate(promptInfo(titleRes, subtitleRes)) + } catch (t: Throwable) { + if (cont.isActive) cont.resumeWithException(t) + } + cont.invokeOnCancellation { prompt.cancelAuthentication() } + } + suspend fun decryptWithBiometric( cipher: Cipher, ciphertext: ByteArray, @@ -31,9 +86,7 @@ class BiometricGate(private val activity: FragmentActivity) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { try { val c = result.cryptoObject?.cipher - ?: return cont.resumeWithException( - IllegalStateException("no cipher bound") - ) + ?: return cont.resumeWithException(IllegalStateException("no cipher bound")) cont.resume(c.doFinal(ciphertext)) } catch (t: Throwable) { cont.resumeWithException(t) @@ -41,21 +94,15 @@ class BiometricGate(private val activity: FragmentActivity) { } override fun onAuthenticationError(code: Int, msg: CharSequence) { - cont.resumeWithException( - BiometricCancelledException(code, msg.toString()) - ) + cont.resumeWithException(BiometricCancelledException(code, msg.toString())) } } ) - val info = BiometricPrompt.PromptInfo.Builder() - .setTitle(activity.getString(titleRes)) - .setSubtitle(activity.getString(subtitleRes)) - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL - ) - .build() - prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher)) + try { + prompt.authenticate(promptInfo(titleRes, subtitleRes), BiometricPrompt.CryptoObject(cipher)) + } catch (t: Throwable) { + if (cont.isActive) cont.resumeWithException(t) + } cont.invokeOnCancellation { prompt.cancelAuthentication() } } } diff --git a/android/app/src/main/java/io/raventag/app/security/LegacySecretMigration.kt b/android/app/src/main/java/io/raventag/app/security/LegacySecretMigration.kt new file mode 100644 index 00000000..a91a103b --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/security/LegacySecretMigration.kt @@ -0,0 +1,50 @@ +package io.raventag.app.security + +/** + * Crash-safe migration of the exact secret-bearing keys historically stored in + * the legacy `raventag_secure` preference file. + * + * The caller supplies storage operations so the state machine is JVM-testable. + * A source value is removed only after the destination was written and read back + * byte-for-byte (String equality here). If deletion is interrupted, a repeated + * invocation sees the already-verified destination and safely finishes cleanup. + */ +object LegacySecretMigration { + val SENSITIVE_KEYS: Set = setOf( + "admin_key", + "operator_key", + "initial_master_key", + "pinata_jwt" + ) + + data class Outcome(val migrated: Int, val cleanedUp: Int) + + fun migrate( + readLegacy: (String) -> String?, + writeSecure: (String, String) -> Unit, + readSecure: (String) -> String?, + removeLegacy: (String) -> Unit + ): Outcome { + var migrated = 0 + var cleaned = 0 + for (key in SENSITIVE_KEYS) { + val legacy = readLegacy(key) ?: continue + val existing = readSecure(key) + if (existing != legacy) { + writeSecure(key, legacy) + check(readSecure(key) == legacy) { + "secure migration read-back mismatch for $key" + } + migrated++ + } + // If a previous run stopped after verified write, equality above lets + // this run resume at cleanup without overwriting or losing the source. + removeLegacy(key) + check(readLegacy(key) == null) { + "legacy secret cleanup did not persist for $key" + } + cleaned++ + } + return Outcome(migrated, cleaned) + } +} diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt index 557de2eb..0323b78d 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt @@ -17,12 +17,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -35,7 +33,6 @@ import io.raventag.app.security.MnemonicExporter import io.raventag.app.ui.theme.* import io.raventag.app.wallet.KeystoreInvalidatedException import io.raventag.app.wallet.WalletManager -import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -63,7 +60,6 @@ fun MnemonicBackupScreen( onKeystoreInvalidated: () -> Unit = {} ) { val s = LocalStrings.current - val clipboard = LocalClipboardManager.current val context = LocalContext.current val view = LocalView.current val scope = rememberCoroutineScope() @@ -91,7 +87,6 @@ fun MnemonicBackupScreen( onDispose { captured?.let { java.util.Arrays.fill(it, ' ') } } } - var copied by remember { mutableStateOf(false) } var confirmed by remember { mutableStateOf(false) } Scaffold( @@ -256,33 +251,7 @@ fun MnemonicBackupScreen( Spacer(modifier = Modifier.height(16.dp)) - // Copy All: briefly copies to clipboard; cleared after 60s. - OutlinedButton( - onClick = { - val asString = String(revealed!!) - clipboard.setText(AnnotatedString(asString)) - copied = true - scope.launch { - delay(60_000) - clipboard.setText(AnnotatedString("")) - copied = false - } - }, - modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(), - border = androidx.compose.foundation.BorderStroke(1.dp, if (copied) AuthenticGreen else RavenBorder), - colors = ButtonDefaults.outlinedButtonColors(contentColor = if (copied) AuthenticGreen else RavenMuted) - ) { - Icon( - if (copied) Icons.Default.CheckCircle else Icons.Default.ContentCopy, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - if (copied) s.backupCopied else s.mnemonicCopyAll, - style = MaterialTheme.typography.bodySmall - ) - } + // Deliberately no clipboard/export action: the full BIP39 phrase remains inside RavenTag. Spacer(modifier = Modifier.height(24.dp)) @@ -377,8 +346,9 @@ fun MnemonicBackupScreen( /** * Runs the biometric reveal flow. In fresh-setup mode (`prefillMnemonic` non-null/blank) - * we skip the Keystore round-trip because the mnemonic is already in memory and no - * ciphertext exists yet. In later-reveal mode we delegate to [MnemonicExporter]. + * the phrase is not yet encrypted, but a genuine OS authentication is still required + * before display. Persisted later-reveal mode delegates to [MnemonicExporter] and the + * auth-required Keystore key. */ private suspend fun revealWithBiometric( context: Context, @@ -390,10 +360,22 @@ private suspend fun revealWithBiometric( onRevealed: (CharArray) -> Unit ) { if (!prefillMnemonic.isNullOrBlank()) { - // Setup flow: the wallet has been generated but not yet persisted; the biometric - // cover card acts as a tap-through confirmation (D-15 CryptoObject cannot bind - // to a ciphertext that does not yet exist). - onRevealed(prefillMnemonic.toCharArray()) + val activity = context as? FragmentActivity + if (activity == null) { + snackbarHostState.showSnackbar(strings.mnemonicRevealFailed) + return + } + try { + BiometricGate(activity).authenticate( + io.raventag.app.R.string.biometricRevealTitle, + io.raventag.app.R.string.biometricRevealSubtitle + ) + onRevealed(prefillMnemonic.toCharArray()) + } catch (_: BiometricCancelledException) { + snackbarHostState.showSnackbar(strings.authCanceledSnackbar) + } catch (_: Throwable) { + snackbarHostState.showSnackbar(strings.mnemonicRevealFailed) + } return } if (wm == null) { diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/SendRvnScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/SendRvnScreen.kt index 1c475587..ec13b611 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/SendRvnScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/SendRvnScreen.kt @@ -58,7 +58,7 @@ fun SendRvnScreen( donateMode: Boolean = false, walletBalance: Double = 0.0, onBack: () -> Unit, - onSend: (toAddress: String, amount: Double) -> Unit + onSend: (toAddress: String, amount: Double, explicitMax: Boolean) -> Unit ) { val s = LocalStrings.current @@ -68,6 +68,7 @@ fun SendRvnScreen( // Amount entered by the user as a raw string; parsed to Double before sending. var amount by remember { mutableStateOf("") } + var explicitMax by remember { mutableStateOf(false) } // Controls whether the pre-send confirmation AlertDialog is visible. var showConfirm by remember { mutableStateOf(false) } @@ -171,7 +172,7 @@ fun SendRvnScreen( Button( onClick = { showConfirm = false - onSend(toAddress, parsedAmount) + onSend(toAddress, parsedAmount, explicitMax) feeSatPerKb = null feeUsedFallback = false feeOverrideText = "" @@ -285,7 +286,7 @@ fun SendRvnScreen( OutlinedTextField( value = amount, // Normalize comma to dot on input for locales that use commas as decimal separator. - onValueChange = { amount = it.replace(',', '.') }, + onValueChange = { amount = it.replace(',', '.'); explicitMax = false }, placeholder = { Text("0.00000000", color = RavenMuted, style = MaterialTheme.typography.bodySmall) }, modifier = Modifier.weight(1f), colors = sndFieldColors(), @@ -301,10 +302,10 @@ fun SendRvnScreen( // Estimate uses ~300 bytes for a typical 1-in / 2-out P2PKH transaction. OutlinedButton( onClick = { - // MAX = full balance. WalletManager.sendRvnLocal detects sweep mode - // (amountSat + fee > totalIn) and lets the tx builder subtract the - // exact fee from the recipient amount, so the wallet ends at 0 RVN. + // MAX is an explicit transaction intent. WalletManager computes + // the exact recipient amount only after applying the safe fee/dust policy. amount = "%.8f".format(walletBalance) + explicitMax = true }, enabled = walletBalance > 0.0, modifier = Modifier.height(56.dp), diff --git a/android/app/src/main/java/io/raventag/app/wallet/Bip39Kdf.kt b/android/app/src/main/java/io/raventag/app/wallet/Bip39Kdf.kt new file mode 100644 index 00000000..2b473028 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/Bip39Kdf.kt @@ -0,0 +1,23 @@ +package io.raventag.app.wallet + +import java.text.Normalizer +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +/** Pure BIP39 PBKDF2 boundary, shared by production and deterministic JVM vectors. */ +internal object Bip39Kdf { + fun deriveSeed(mnemonic: String, passphrase: String): ByteArray { + val normalizedMnemonic = Normalizer.normalize(mnemonic, Normalizer.Form.NFKD) + val normalizedPassphrase = Normalizer.normalize(passphrase, Normalizer.Form.NFKD) + val password = normalizedMnemonic.toCharArray() + val salt = ("mnemonic" + normalizedPassphrase).toByteArray(Charsets.UTF_8) + val spec = PBEKeySpec(password, salt, 2048, 512) + return try { + SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512").generateSecret(spec).encoded + } finally { + spec.clearPassword() + java.util.Arrays.fill(password, '\u0000') + salt.fill(0) + } + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/FeeSafetyPolicy.kt b/android/app/src/main/java/io/raventag/app/wallet/FeeSafetyPolicy.kt new file mode 100644 index 00000000..2ea5c9a9 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/FeeSafetyPolicy.kt @@ -0,0 +1,59 @@ +package io.raventag.app.wallet + +/** + * Local fee invariants that are independent of ElectrumX responses. + * + * Public ElectrumX servers are untrusted. They may provide estimates only inside + * this locally-enforced envelope; they never get authority to choose an arbitrary + * wallet debit. The absolute ceiling mirrors the historical Ravencoin/Bitcoin Core + * default max transaction fee (0.1 coin), while the rate ceiling is deliberately + * generous enough for temporary relay pressure but bounded well below values that + * could drain a wallet. + */ +object FeeSafetyPolicy { + /** Existing RavenTag floor retained for compatibility. */ + const val MIN_SAT_PER_BYTE: Long = 200L + + /** 10,000 sat/B = 0.1 RVN/kB; values above this are treated as hostile/anomalous. */ + const val MAX_SAT_PER_BYTE: Long = 10_000L + + /** Never sign a transaction paying more than 0.1 RVN in miner fee. */ + const val MAX_ABSOLUTE_FEE_SAT: Long = 10_000_000L + + /** Normal sends may not spend more than 25% of the requested recipient amount on fees. */ + const val MAX_NORMAL_SEND_FEE_BPS: Long = 2_500L + + fun sanitizeRelayFeeRvnPerKb(rvnPerKb: Double): Long { + require(rvnPerKb.isFinite() && rvnPerKb > 0.0) { "Invalid network fee" } + val satPerByteDouble = rvnPerKb * 100_000_000.0 / 1000.0 + require(satPerByteDouble.isFinite() && satPerByteDouble > 0.0) { "Invalid network fee" } + require(satPerByteDouble <= Long.MAX_VALUE.toDouble()) { "Network fee overflow" } + val raw = satPerByteDouble.toLong() + val withMargin = Math.multiplyExact(raw, 2L) + require(withMargin <= MAX_SAT_PER_BYTE) { "Unreasonable network fee" } + return maxOf(withMargin, MIN_SAT_PER_BYTE) + } + + fun calculateFee(estimatedBytes: Long, satPerByte: Long): Long { + require(estimatedBytes > 0L) { "Invalid transaction size" } + require(satPerByte in MIN_SAT_PER_BYTE..MAX_SAT_PER_BYTE) { "Unreasonable network fee" } + val fee = Math.multiplyExact(estimatedBytes, satPerByte) + requireSafeAbsoluteFee(fee) + return fee + } + + fun calculateFee(estimatedBytes: Int, satPerByte: Long): Long = + calculateFee(estimatedBytes.toLong(), satPerByte) + + fun requireSafeAbsoluteFee(feeSat: Long) { + require(feeSat > 0L) { "Invalid network fee" } + require(feeSat <= MAX_ABSOLUTE_FEE_SAT) { "Unreasonable network fee" } + } + + fun requireSafeNormalSendFee(feeSat: Long, requestedAmountSat: Long) { + requireSafeAbsoluteFee(feeSat) + require(requestedAmountSat > 0L) { "Invalid send amount" } + val maxByRatio = Math.multiplyExact(requestedAmountSat, MAX_NORMAL_SEND_FEE_BPS) / 10_000L + require(feeSat <= maxByRatio) { "Unreasonable network fee" } + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/RavencoinPublicNode.kt b/android/app/src/main/java/io/raventag/app/wallet/RavencoinPublicNode.kt index ea82d647..04643f25 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/RavencoinPublicNode.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/RavencoinPublicNode.kt @@ -177,6 +177,7 @@ class RavencoinPublicNode(private val context: Context) { /** Maximum number of pipelined requests per [callBatch] TLS connection. */ private const val BATCH_CHUNK_SIZE = 20 + private const val MAX_RESPONSE_LINE_CHARS = 1_048_576 /** * List of public Ravencoin ElectrumX servers, tried in order. @@ -545,21 +546,17 @@ class RavencoinPublicNode(private val context: Context) { fun getMinRelayFeeRateSatPerByte(): Long { val results = SERVERS.mapNotNull { server -> try { - // relayfee is returned as RVN per kilobyte (e.g. 0.01) val rvnPerKb = call(server, "blockchain.relayfee", emptyList()).asDouble - // Convert: RVN/kB * 1e8 (sat/RVN) / 1000 (bytes/kB) = sat/byte - val satPerByte = (rvnPerKb * 1e8 / 1000).toLong() - Log.d(TAG, "relayfee ${server.host}: $rvnPerKb RVN/kB = $satPerByte sat/byte") - satPerByte + val safe = FeeSafetyPolicy.sanitizeRelayFeeRvnPerKb(rvnPerKb) + Log.d(TAG, "relayfee ${server.host}: $rvnPerKb RVN/kB -> $safe sat/byte after local policy") + safe } catch (e: Exception) { - Log.w(TAG, "relayfee failed for ${server.host}: ${e.message}") + Log.w(TAG, "relayfee rejected/failed for ${server.host}: ${e.message}") null } } if (results.isEmpty()) throw FeeUnavailableException() - val minSatPerByte = results.min() - // Apply 2x safety margin; enforce a minimum floor of 200 sat/byte - return maxOf(minSatPerByte * 2, 200L) + return results.min() } /** @@ -1548,6 +1545,19 @@ class RavencoinPublicNode(private val context: Context) { else AddressStatus.RECEIVE_ONLY } + private fun readBoundedLine(reader: BufferedReader, host: String): String? { + val out = StringBuilder() + while (true) { + val ch = reader.read() + if (ch == -1) return if (out.isEmpty()) null else out.toString() + if (ch == '\n'.code) return out.toString() + if (ch != '\r'.code) { + require(out.length < MAX_RESPONSE_LINE_CHARS) { "Oversized ElectrumX response from $host" } + out.append(ch.toChar()) + } + } + } + // Internal helpers ──────────────────────────────────────────────────────── /** @@ -1680,9 +1690,7 @@ class RavencoinPublicNode(private val context: Context) { * @return Lowercase hex-encoded reversed SHA-256 of the scriptPubKey. */ internal fun addressToScripthash(address: String): String { - val decoded = base58Decode(address) - require(decoded.size == 25) { "Invalid Ravencoin address (decoded=${decoded.size} bytes)" } - val hash160 = decoded.copyOfRange(1, 21) + val hash160 = RavencoinTxBuilder.requireRavencoinMainnetP2pkh(address) // Assemble minimal P2PKH script: 76 a9 14 88 ac val script = byteArrayOf(0x76.toByte(), 0xa9.toByte(), 0x14.toByte()) + hash160 + byteArrayOf(0x88.toByte(), 0xac.toByte()) @@ -1702,9 +1710,7 @@ class RavencoinPublicNode(private val context: Context) { * @return Lowercase hex string of the P2PKH scriptPubKey. */ private fun p2pkhScriptHex(address: String): String { - val decoded = base58Decode(address) - val hash160 = decoded.copyOfRange(1, 21) - return "76a914" + hash160.joinToString("") { "%02x".format(it) } + "88ac" + return RavencoinTxBuilder.p2pkhScriptHexForAddress(address) } /** Base58 alphabet as used by Bitcoin and Ravencoin (no 0, O, I, l). */ @@ -1916,7 +1922,7 @@ class RavencoinPublicNode(private val context: Context) { // Handshake val hsId = idCounter.getAndIncrement() writer.println("""{"id":$hsId,"method":"server.version","params":["RavenTag/1.0","1.4"]}""") - reader.readLine() + readBoundedLine(reader, server.host) // Send all requests and remember id -> index mapping val idToIndex = mutableMapOf() for ((index, req) in requests.withIndex()) { @@ -1929,7 +1935,7 @@ class RavencoinPublicNode(private val context: Context) { val results = arrayOfNulls(requests.size) var received = 0 while (received < requests.size) { - val line = reader.readLine() ?: break + val line = readBoundedLine(reader, server.host) ?: break received++ try { val json = JsonParser.parseString(line).asJsonObject @@ -2051,14 +2057,14 @@ class RavencoinPublicNode(private val context: Context) { if (method != "server.version") { val hsId = idCounter.getAndIncrement() writer.println("""{"id":$hsId,"method":"server.version","params":["RavenTag/1.0","1.4"]}""") - reader.readLine() // consume and discard the handshake response + readBoundedLine(reader, server.host) // consume and discard the handshake response } // Send the actual request val id = idCounter.getAndIncrement() writer.println(gson.toJson(mapOf("id" to id, "method" to method, "params" to params))) - val response = reader.readLine() ?: throw Exception("Empty response from ${server.host}") + val response = readBoundedLine(reader, server.host) ?: throw Exception("Empty response from ${server.host}") val json = JsonParser.parseString(response).asJsonObject // Check for an ElectrumX-level error response val err = json.get("error") diff --git a/android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt b/android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt index 013fc2a0..fdf6702c 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt @@ -133,9 +133,9 @@ object RavencoinTxBuilder { /** * Build and sign a plain P2PKH RVN transfer transaction. * - * The function automatically handles the "send-all / sweep" case: if - * [amountSat] + [feeSat] exceeds the total UTXO value, the fee is subtracted - * from the recipient amount so the sender can drain the wallet completely. + * [amountSat] is always the exact recipient amount. Explicit MAX/sweep intent + * must be resolved by the caller before this builder is invoked; the builder + * never reduces a recipient amount because a fee estimate is high. * * A change output is added only when the change exceeds the 546-satoshi * dust limit, to avoid creating unspendable outputs. @@ -158,18 +158,15 @@ object RavencoinTxBuilder { privKeyBytes: ByteArray, pubKeyBytes: ByteArray ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) val totalIn = utxos.sumOf { it.satoshis } - require(totalIn > feeSat) { - "Insufficient funds to cover fee: have ${totalIn / 1e8} RVN, fee ${feeSat / 1e8} RVN" + require(amountSat > 546) { "Amount below dust limit" } + require(totalIn >= amountSat + feeSat) { + "Insufficient funds: have ${totalIn / 1e8} RVN, need ${amountSat / 1e8} RVN + ${feeSat / 1e8} RVN fee" } - // If the requested amount leaves no room for the fee, subtract the fee from the - // recipient amount (send-all / sweep mode). The recipient gets slightly less. - val effectiveAmount = if (amountSat + feeSat > totalIn) totalIn - feeSat else amountSat - require(effectiveAmount > 546) { "Amount too small after fee deduction" } - - val changeSat = totalIn - effectiveAmount - feeSat - val outputs = mutableListOf(TxOutput(effectiveAmount, toAddress)) + val changeSat = totalIn - amountSat - feeSat + val outputs = mutableListOf(TxOutput(amountSat, toAddress)) if (changeSat > 546) outputs.add(TxOutput(changeSat, changeAddress)) // dust limit // Sign each input independently (legacy P2PKH: sign with that input's scriptPubKey) @@ -229,6 +226,7 @@ object RavencoinTxBuilder { privKeyBytes: ByteArray, pubKeyBytes: ByteArray ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) // Asset UTXOs from issuance may have 0 satoshis (dustOut = 0 in buildAndSignAssetIssue). // To avoid consensus error "bad-txns-asset-transfer-amount-isn't-zero", we must preserve // the satoshi value from input to output for each asset UTXO. @@ -325,6 +323,7 @@ object RavencoinTxBuilder { privKeyBytes: ByteArray, pubKeyBytes: ByteArray ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) // Calculate input dust for each asset type to preserve value balance val primaryAssetDustIn = primaryAssetUtxos.sumOf { it.satoshis } @@ -431,6 +430,7 @@ object RavencoinTxBuilder { changeAddress: String, secondaryAssetsToToAddress: Map> = emptyMap() ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) val primaryDustIn = primaryAssetInputs.sumOf { it.assetUtxo.utxo.satoshis } val dustForPrimaryOut = if (primaryDustIn > 0) 600L else 0L val dustForPrimaryChange = if (primaryAssetChange > 0 && primaryDustIn > 0) 600L else 0L @@ -541,6 +541,7 @@ object RavencoinTxBuilder { privKeyBytes: ByteArray, pubKeyBytes: ByteArray ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) // Calculate dust for all asset outputs var dustForAssets = 0L val assetOutputs = mutableListOf() @@ -630,6 +631,7 @@ object RavencoinTxBuilder { feeSat: Long, changeAddress: String ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) // Dust for asset outputs: 0 if input had 0 sat (issued with dustOut=0), else 600 val assetOutputs = mutableListOf() var dustForAssets = 0L @@ -888,6 +890,7 @@ object RavencoinTxBuilder { privKeyBytes: ByteArray, pubKeyBytes: ByteArray ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) // Unique tokens (containing '#') don't mint a new owner output. // Sub-assets and unique tokens still spend the parent owner token as an input, // then return it to the issuer as an rvnt transfer of "PARENT!" with raw amount 100_000_000. @@ -1019,6 +1022,7 @@ object RavencoinTxBuilder { privKeyBytes: ByteArray, pubKeyBytes: ByteArray ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) val isUnique = assetName.contains('#') val preservedOwnerAssetName = when { @@ -1163,6 +1167,7 @@ object RavencoinTxBuilder { privKeyBytes: ByteArray, pubKeyBytes: ByteArray ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) // Calculate dust for all asset outputs var dustForAssets = 0L val assetOutputs = mutableListOf() @@ -1263,8 +1268,7 @@ object RavencoinTxBuilder { reissuable: Boolean, newIpfsHash: String? ): ByteArray { - val decoded = base58Decode(address) - val hash160 = decoded.copyOfRange(1, 21) + val hash160 = requireRavencoinMainnetP2pkh(address) val nameBytes = assetName.toByteArray(Charsets.US_ASCII) val ipfsBytes = newIpfsHash?.let { decodeIpfsCidV0(it) } @@ -1324,6 +1328,7 @@ object RavencoinTxBuilder { pubKeyBytes: ByteArray, otherAssetUtxos: Map> = emptyMap() ): SignedTx { + FeeSafetyPolicy.requireSafeAbsoluteFee(feeSat) require(ownerAssetUtxos.isNotEmpty()) { "Missing owner-token UTXO for $assetName! - cannot reissue" } @@ -1443,8 +1448,7 @@ object RavencoinTxBuilder { * name_bytes ASCII owner token name (includes trailing "!") */ private fun buildOwnerTokenScript(address: String, assetName: String): ByteArray { - val decoded = base58Decode(address) - val hash160 = decoded.copyOfRange(1, 21) + val hash160 = requireRavencoinMainnetP2pkh(address) // Owner token name always ends with "!" (e.g. "BRAND!" or "BRAND/ITEM!") val ownerName = "$assetName!" val nameBytes = ownerName.toByteArray(Charsets.US_ASCII) @@ -1513,8 +1517,7 @@ object RavencoinTxBuilder { * LE64(assetAmount) 8 bytes: raw asset amount */ private fun buildAssetTransferScript(address: String, assetName: String, assetAmount: Long): ByteArray { - val decoded = base58Decode(address) - val hash160 = decoded.copyOfRange(1, 21) + val hash160 = requireRavencoinMainnetP2pkh(address) val nameBytes = assetName.toByteArray(Charsets.US_ASCII) // Payload: "rvnt" + compact_size(len) + name + LE64(amount) val payload = ByteArrayOutputStream() @@ -1656,9 +1659,23 @@ object RavencoinTxBuilder { * The address is Base58Check decoded; bytes 1..20 are the HASH160 of the * public key (byte 0 is the Ravencoin version byte 0x3C and is skipped). */ - private fun p2pkhScript(address: String): ByteArray { + internal fun requireRavencoinMainnetP2pkh(address: String): ByteArray { + require(address.length in 26..50) { "Invalid Ravencoin address length" } val decoded = base58Decode(address) - val pubKeyHash = decoded.copyOfRange(1, 21) // skip version byte 0x3C + require(decoded.size == 25) { "Invalid Ravencoin address structure" } + require(decoded[0] == 0x3c.toByte()) { "Address is not Ravencoin mainnet P2PKH" } + require(base58Encode(decoded) == address) { "Non-canonical Ravencoin address" } + return decoded.copyOfRange(1, 21) + } + + internal fun isValidRavencoinMainnetP2pkh(address: String): Boolean = + runCatching { requireRavencoinMainnetP2pkh(address); true }.getOrDefault(false) + + internal fun p2pkhScriptHexForAddress(address: String): String = + "76a914" + requireRavencoinMainnetP2pkh(address).joinToString("") { "%02x".format(it) } + "88ac" + + private fun p2pkhScript(address: String): ByteArray { + val pubKeyHash = requireRavencoinMainnetP2pkh(address) return byteArrayOf( 0x76.toByte(), // OP_DUP 0xa9.toByte(), // OP_HASH160 @@ -1769,6 +1786,21 @@ object RavencoinTxBuilder { * * @throws IllegalArgumentException on invalid characters or checksum mismatch */ + private fun base58Encode(data: ByteArray): String { + var num = BigInteger(1, data) + val base = BigInteger.valueOf(58) + val out = StringBuilder() + while (num > BigInteger.ZERO) { + val qr = num.divideAndRemainder(base) + out.append(B58[qr[1].toInt()]) + num = qr[0] + } + for (b in data) { + if (b == 0.toByte()) out.append(B58[0]) else break + } + return out.reverse().toString() + } + private fun base58Decode(input: String): ByteArray { var num = BigInteger.ZERO for (char in input) { diff --git a/android/app/src/main/java/io/raventag/app/wallet/TofuTrustManager.kt b/android/app/src/main/java/io/raventag/app/wallet/TofuTrustManager.kt index e8f274f9..90249770 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/TofuTrustManager.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/TofuTrustManager.kt @@ -4,29 +4,18 @@ import android.content.Context import android.util.Log import io.raventag.app.security.TofuFingerprintDao import java.security.MessageDigest +import java.security.cert.CertificateException import java.security.cert.X509Certificate import javax.net.ssl.X509TrustManager import java.util.concurrent.ConcurrentHashMap /** - * TOFU (Trust On First Use) TrustManager for ElectrumX self-signed TLS certificates. + * Strict TOFU (Trust On First Use) TrustManager for ElectrumX self-signed TLS certificates. * - * Standard certificate authority validation is not used because ElectrumX servers - * commonly use self-signed certificates. TOFU provides a practical security model: - * - * - First connection to a host: the server's SHA-256 fingerprint is computed from - * the raw DER-encoded certificate bytes and stored in both the in-process [certCache] - * and the persistent SQLite database via [TofuFingerprintDao]. The connection is allowed. - * - Subsequent connections to the same host: the fingerprint is verified against - * the SQLite-persisted value first, then against the in-memory cache. If it differs - * from either, the connection is rejected with an exception to protect against - * man-in-the-middle attacks. - * - * Certificate fingerprints are persisted in SQLite database (L2 cache) and survive app restarts. - * Dual-layer cache: in-memory ConcurrentHashMap (L1, fast access) + SQLite (L2, persistent). - * - * @param context Application context for SQLite database access. - * @param host Hostname of the ElectrumX server, used as the cache key. + * Standard certificate authority validation is not used because ElectrumX servers commonly + * use self-signed certificates. The first certificate observed for a host is persisted. Any + * later certificate change is treated as a security failure and MUST be explicitly resolved + * by a trusted pin-rotation mechanism; it is never accepted or re-pinned automatically. */ internal class TofuTrustManager(private val context: Context, private val host: String) : X509TrustManager { init { @@ -35,40 +24,47 @@ internal class TofuTrustManager(private val context: Context, private val host: override fun getAcceptedIssuers(): Array = emptyArray() override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) { - val cert = chain?.firstOrNull() ?: throw Exception("No certificate from $host") + val cert = chain?.firstOrNull() ?: throw CertificateException("No certificate from $host") val fingerprint = MessageDigest.getInstance("SHA-256").digest(cert.encoded) .joinToString("") { "%02x".format(it) } val persisted = TofuFingerprintDao.getFingerprint(host) val inMemory = certCache[host] - // Accept if fingerprint matches either L1 (memory) or L2 (SQLite) - if (fingerprint == inMemory || fingerprint == persisted) { - if (persisted == null) { - TofuFingerprintDao.pinFingerprint(host, fingerprint) - Log.i(TAG, "TOFU: pinned new certificate for $host") + // Persistent storage is authoritative across process restarts. If a pin exists, + // only that exact fingerprint is accepted. Never accept a changed certificate + // merely because an in-memory value differs or is absent. + if (persisted != null) { + if (fingerprint != persisted) { + Log.e(TAG, "TOFU: certificate mismatch for $host; refusing changed fingerprint") + throw CertificateException( + "Certificate mismatch for $host: expected $persisted, got $fingerprint" + ) } - certCache[host] = fingerprint + certCache[host] = persisted return } - // Fingerprint changed: server rotated its TLS certificate. - // Auto-heal by updating the pinned fingerprint instead of failing. - // Strict TOFU would reject, but ElectrumX public servers rotate certs - // and a hard rejection bricks the wallet until manual DB deletion. - Log.w(TAG, "TOFU: certificate rotated for $host (was $persisted, now $fingerprint) — auto-updating pin") + // No persistent pin yet. If this process already saw the host, require consistency + // with that first observation before persisting it. + if (inMemory != null && fingerprint != inMemory) { + Log.e(TAG, "TOFU: first-use race/mismatch for $host; refusing changed fingerprint") + throw CertificateException( + "Certificate mismatch for $host: expected $inMemory, got $fingerprint" + ) + } + + // First trusted observation for this host. Persist before returning success so a + // subsequent connection cannot silently establish a different identity. TofuFingerprintDao.pinFingerprint(host, fingerprint) certCache[host] = fingerprint + Log.i(TAG, "TOFU: pinned first certificate for $host") } companion object { private const val TAG = "ElectrumX" - - /** - * TOFU certificate fingerprint cache: hostname -> SHA-256 hex string. - * Thread-safe via ConcurrentHashMap. Scoped to the process lifetime. - */ internal val certCache = ConcurrentHashMap() } } diff --git a/android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt b/android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt index bf6761cc..cb2a7e26 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt @@ -23,6 +23,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID import io.raventag.app.ravencoin.OwnedAsset import io.raventag.app.wallet.cache.ReservedUtxoDao import io.raventag.app.wallet.cache.PendingConsolidationDao @@ -39,6 +42,9 @@ class WalletManager(private val context: Context) { @Volatile private var sweepRunning = false @Volatile private var consolidationRunning = false + /** Serializes the security-critical reserve -> sign -> broadcast transition. */ + private val financialSigningMutex = Mutex() + // UTXO cache for current address: avoids re-fetching individual asset UTXOs // via slow getAssetUtxosFull calls on every send/issuance. private var utxoCacheAddr: String? = null @@ -62,6 +68,56 @@ class WalletManager(private val context: Context) { utxoCacheAssets = assets } + /** + * Security boundary for every on-device transaction signature. + * + * Outpoints are durably reserved under a random intent id BEFORE [build] is + * invoked. Once a raw transaction exists the reservation is rebound to the + * locally-computed txid before any network I/O. If broadcast is ambiguous or + * fails after signing, the reservation deliberately remains in place until + * reconciliation proves the transaction absent/stale. + */ + private suspend fun signAndBroadcastReserved( + inputs: Collection, + broadcaster: suspend (String) -> String, + build: () -> RavencoinTxBuilder.SignedTx + ): RavencoinTxBuilder.SignedTx = financialSigningMutex.withLock { + val uniqueInputs = inputs.distinctBy { "${it.txid}:${it.outputIndex}" } + require(uniqueInputs.isNotEmpty()) { "No transaction inputs to reserve" } + val intentId = "intent:${UUID.randomUUID()}" + val now = System.currentTimeMillis() + val entries = uniqueInputs.map { + ReservedUtxoDao.ReservedUtxo( + txidIn = it.txid, + vout = it.outputIndex, + valueSat = it.satoshis, + submittedTxid = intentId, + submittedAt = now + ) + } + check(ReservedUtxoDao.tryReserve(entries)) { + "UTXO reservation conflict or database failure; transaction not signed" + } + + var signed: RavencoinTxBuilder.SignedTx? = null + try { + signed = build() + check(ReservedUtxoDao.retagReservation(intentId, signed.txid)) { + "Could not bind UTXO reservation to signed transaction" + } + val networkTxid = broadcaster(signed.hex) + check(networkTxid.equals(signed.txid, ignoreCase = true)) { + "Broadcast txid mismatch: local=${signed.txid}, remote=$networkTxid" + } + signed + } catch (t: Throwable) { + // Before signing it is safe to release. After signing, retain the + // reservation because a network failure may have an ambiguous outcome. + if (signed == null) ReservedUtxoDao.releaseFor(intentId) + throw t + } + } + companion object { private const val PREFS_NAME = "raventag_wallet" private const val KEY_SEED_ENC = "seed_enc" @@ -70,6 +126,9 @@ class WalletManager(private val context: Context) { private const val KEY_MNEMONIC_IV = "mnemonic_iv" private const val KEY_ADDRESS_INDEX = "address_index" private const val KEYSTORE_ALIAS = "raventag_wallet_key" + private const val MNEMONIC_REVEAL_KEYSTORE_ALIAS = "raventag_mnemonic_reveal_key_v1" + private const val KEY_MNEMONIC_AUTH_ENC = "mnemonic_auth_enc_v1" + private const val KEY_MNEMONIC_AUTH_IV = "mnemonic_auth_iv_v1" // D-15 mnemonic-safety additions (plan 30-06) private const val KEY_SEED_HMAC = "seed_hmac" private const val KEY_MNEMONIC_HMAC = "mnemonic_hmac" @@ -298,7 +357,8 @@ class WalletManager(private val context: Context) { */ @JvmStatic fun validateMnemonic(input: String): List { - val words = input.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + val normalized = java.text.Normalizer.normalize(input, java.text.Normalizer.Form.NFKD) + val words = normalized.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } require(words.size in VALID_WORD_COUNTS) { "invalid word count: ${words.size}" } @@ -440,6 +500,53 @@ class WalletManager(private val context: Context) { return key } + /** + * Dedicated key for recovery-phrase reveal. Its use is cryptographically + * conditioned on recent OS user authentication; the normal wallet seed key is + * intentionally separate so background balance/address work is not disrupted. + */ + private fun getOrCreateMnemonicRevealKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + if (keyStore.containsAlias(MNEMONIC_REVEAL_KEYSTORE_ALIAS)) { + return (keyStore.getEntry(MNEMONIC_REVEAL_KEYSTORE_ALIAS, null) as KeyStore.SecretKeyEntry).secretKey + } + + fun spec(strongBox: Boolean): KeyGenParameterSpec { + val builder = KeyGenParameterSpec.Builder( + MNEMONIC_REVEAL_KEYSTORE_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + 60, + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL + ) + } else { + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(60) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setUnlockedDeviceRequired(true) + if (strongBox) builder.setIsStrongBoxBacked(true) + } + return builder.build() + } + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + return try { + generator.init(spec(strongBox = true)) + generator.generateKey() + } catch (_: Throwable) { + generator.init(spec(strongBox = false)) + generator.generateKey() + } + } + fun isKeyHardwareBacked(): Boolean { return try { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } @@ -495,6 +602,7 @@ class WalletManager(private val context: Context) { prefs().edit() .remove(KEY_SEED_ENC).remove(KEY_SEED_IV) .remove(KEY_MNEMONIC_ENC).remove(KEY_MNEMONIC_IV) + .remove(KEY_MNEMONIC_AUTH_ENC).remove(KEY_MNEMONIC_AUTH_IV) .remove(KEY_ADDRESS_INDEX) .remove(KEY_BACKUP_COMPLETED) .remove(KEY_HMAC_MATERIAL_CT).remove(KEY_HMAC_MATERIAL_IV) @@ -509,6 +617,7 @@ class WalletManager(private val context: Context) { val ks = KeyStore.getInstance("AndroidKeyStore") ks.load(null) if (ks.containsAlias(KEYSTORE_ALIAS)) ks.deleteEntry(KEYSTORE_ALIAS) + if (ks.containsAlias(MNEMONIC_REVEAL_KEYSTORE_ALIAS)) ks.deleteEntry(MNEMONIC_REVEAL_KEYSTORE_ALIAS) } catch (_: Exception) {} } @@ -799,15 +908,20 @@ class WalletManager(private val context: Context) { // If no assets at all (only RVN), just send RVN to targetAddr if (primaryName.isEmpty() && otherInputs.isEmpty()) { return try { - val feeEstimate = 500L * maxOf(satPerByte, 200L) + val feeEstimate = FeeSafetyPolicy.calculateFee(500L, maxOf(satPerByte, 200L)) val sendAmount = totalRvnIn - feeEstimate if (sendAmount <= 546) return null - val tx = RavencoinTxBuilder.buildAndSign( + val tx = signAndBroadcastReserved( + inputs = (rvnUtxos), + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSign( utxos = rvnUtxos, toAddress = targetAddr, amountSat = sendAmount, feeSat = feeEstimate, changeAddress = targetAddr, - privKeyBytes = privKey, pubKeyBytes = pubKey + privKeyBytes = privKey!!, pubKeyBytes = pubKey ) - val txid = node.broadcastWithAllServers(tx.hex) + } + val txid = tx.txid android.util.Log.i("WalletManager", "sweepSingleAddr: moved ${"%.8f".format(totalRvnIn / 1e8)} RVN to $targetAddr, txid=$txid") txid } catch (e: Exception) { @@ -820,7 +934,7 @@ class WalletManager(private val context: Context) { val totalInputs = rvnKeyed.size + primaryInputs.size + otherInputs.values.sumOf { it.size } val totalAssetOuts = (if (primaryTotalRaw > 0) 1 else 0) + otherInputs.size - val feeSat = (10L + 148L * totalInputs + 70L * totalAssetOuts + 34L) * maxOf(satPerByte, 200L) + val feeSat = FeeSafetyPolicy.calculateFee((10L + 148L * totalInputs + 70L * totalAssetOuts + 34L), maxOf(satPerByte, 200L)) if (totalRvnIn < feeSat + 600L * totalAssetOuts) { android.util.Log.w("WalletManager", "sweepSingleAddr: insufficient RVN (have ${totalRvnIn / 1e8}, need ${(feeSat + 600L * totalAssetOuts) / 1e8})") @@ -828,7 +942,11 @@ class WalletManager(private val context: Context) { } try { - val tx = RavencoinTxBuilder.buildAndSignMultiAddressAssetTransfer( + val tx = signAndBroadcastReserved( + inputs = (primaryInputs).map { it.assetUtxo.utxo } + (otherInputs).values.flatten().map { it.assetUtxo.utxo } + (rvnKeyed).map { it.utxo }, + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSignMultiAddressAssetTransfer( primaryAssetInputs = primaryInputs, otherAssetInputs = otherInputs, rvnInputs = rvnKeyed, @@ -837,7 +955,8 @@ class WalletManager(private val context: Context) { feeSat = feeSat, changeAddress = targetAddr ) - val txid = node.broadcastWithAllServers(tx.hex) + } + val txid = tx.txid android.util.Log.i("WalletManager", "sweepSingleAddr: moved ${rvnUtxos.size} RVN + ${assetMap.size} asset(s) from index $staleIndex to $targetAddr, txid=$txid") return txid } catch (e: Exception) { @@ -863,11 +982,8 @@ class WalletManager(private val context: Context) { } } - private fun addressToP2pkhScript(address: String): String { - val decoded = base58Decode(address) - val hash160 = decoded.copyOfRange(1, 21) - return "76a914" + hash160.joinToString("") { "%02x".format(it) } + "88ac" - } + private fun addressToP2pkhScript(address: String): String = + RavencoinTxBuilder.p2pkhScriptHexForAddress(address) private suspend fun sweepOldAddressesInternal(): List { val currentIndex = getCurrentAddressIndex() @@ -958,7 +1074,7 @@ class WalletManager(private val context: Context) { val assetTypes = r.third.keys.size val totalInputs = r.first.size + r.third.values.sumOf { it.size } val estimatedBytes = 10 + 148 * totalInputs + 70 * (1 + assetTypes) + 34 - val feeSat = estimatedBytes * satPerByte + val feeSat = FeeSafetyPolicy.calculateFee(estimatedBytes, satPerByte) val dustSat = assetTypes * 546L val totalNeeded = feeSat + dustSat + 546L // +546 for RVN change dust floor @@ -994,35 +1110,45 @@ class WalletManager(private val context: Context) { val assetTypes = assetUtxosMap.keys.size val totalInputs = pureRvnUtxos.size + assetUtxosMap.values.sumOf { it.size } val estimatedBytes = 10 + 148 * totalInputs + 70 * (1 + assetTypes) + 34 - val feeSat = estimatedBytes * satPerByte + val feeSat = FeeSafetyPolicy.calculateFee(estimatedBytes, satPerByte) - val tx = RavencoinTxBuilder.buildAndSignFullAddressSweep( + val tx = signAndBroadcastReserved( + inputs = (pureRvnUtxos) + (assetUtxosMap).values.flatten().map { it.utxo }, + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSignFullAddressSweep( assetUtxos = assetUtxosMap, rvnUtxos = pureRvnUtxos, feeSat = feeSat, changeAddress = targetAddress, - privKeyBytes = privKey, + privKeyBytes = privKey!!, pubKeyBytes = pubKey ) - val txid = node.broadcastWithAllServers(tx.hex) + } + val txid = tx.txid txids.add(txid) android.util.Log.i("WalletManager", "Sweep: self-paying assets+RVN from index ${t.index} to $currentIndex: $txid") } else if (pureRvnUtxos.isNotEmpty()) { val totalSat = pureRvnUtxos.sumOf { it.satoshis } val estimatedBytes = 10 + 148 * pureRvnUtxos.size + 34 - val feeSat = estimatedBytes * satPerByte + val feeSat = FeeSafetyPolicy.calculateFee(estimatedBytes, satPerByte) val sendAmount = totalSat - feeSat if (sendAmount > 546) { - val tx = RavencoinTxBuilder.buildAndSign( + val tx = signAndBroadcastReserved( + inputs = (pureRvnUtxos), + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSign( utxos = pureRvnUtxos, toAddress = targetAddress, amountSat = sendAmount, feeSat = feeSat, changeAddress = targetAddress, - privKeyBytes = privKey, + privKeyBytes = privKey!!, pubKeyBytes = pubKey ) - val txid = node.broadcastWithAllServers(tx.hex) + } + val txid = tx.txid txids.add(txid) android.util.Log.i("WalletManager", "Sweep: RVN from index ${t.index} to $currentIndex: $txid") } @@ -1075,20 +1201,25 @@ class WalletManager(private val context: Context) { val totalSat = rvnUtxos.sumOf { it.satoshis } val satPerByte = node.getMinRelayFeeRateSatPerByte() val estimatedBytes = 10 + 148 * rvnUtxos.size + 34 - val feeSat = estimatedBytes * satPerByte + val feeSat = FeeSafetyPolicy.calculateFee(estimatedBytes, satPerByte) val sendAmount = totalSat - feeSat if (sendAmount > 546) { - val tx = RavencoinTxBuilder.buildAndSign( + val tx = signAndBroadcastReserved( + inputs = (rvnUtxos), + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSign( utxos = rvnUtxos, toAddress = targetAddress, amountSat = sendAmount, feeSat = feeSat, changeAddress = targetAddress, - privKeyBytes = privKey, + privKeyBytes = privKey!!, pubKeyBytes = pubKey ) - val txid = node.broadcastWithAllServers(tx.hex) + } + val txid = tx.txid txids.add(txid) android.util.Log.i("WalletManager", "Sweep: RVN from index ${t.index} to $currentIndex: $txid") } @@ -1214,25 +1345,6 @@ class WalletManager(private val context: Context) { java.util.Arrays.fill(mnemonicBytes, 0) } - fun getMnemonic(): String? { - return try { - val encStr = prefs().getString(KEY_MNEMONIC_ENC, null) ?: return null - val ivStr = prefs().getString(KEY_MNEMONIC_IV, null) ?: return null - val enc = android.util.Base64.decode(encStr, android.util.Base64.DEFAULT) - val iv = android.util.Base64.decode(ivStr, android.util.Base64.DEFAULT) - val plaintext = decrypt(enc, iv) - val tagB64 = prefs().getString(KEY_MNEMONIC_HMAC, null) - if (tagB64 != null) { - val tag = android.util.Base64.decode(tagB64, android.util.Base64.NO_WRAP) - verifySeedHmacInstance(plaintext, tag) - } - String(plaintext, Charsets.UTF_8) - } catch (e: KeystoreInvalidatedException) { - throw e - } catch (e: IntegrityException) { - throw e - } catch (e: Exception) { null } - } private fun getSeed(): ByteArray? { return try { @@ -1255,45 +1367,137 @@ class WalletManager(private val context: Context) { } /** - * D-15 + D-16: reveal the stored mnemonic as a CharArray, gated by a - * BiometricPrompt authentication bound to the Keystore decrypt operation. + * Reveal a persisted recovery phrase. Authentication is established by the + * OS first; the plaintext can then be produced only by an AES key whose + * Android Keystore policy itself requires recent user authentication. * - * Caller is responsible for zero-filling the returned CharArray after display. + * Existing wallets are migrated lazily. The legacy ciphertext is retained + * until the NEW persisted ciphertext has been read back and successfully + * decrypted with the auth-bound key. Any interruption is safe to retry. */ suspend fun revealMnemonicCharsWithBiometric( gate: io.raventag.app.security.BiometricGate - ): CharArray = withContext(Dispatchers.IO) { - val p = prefs() - val ctB64 = p.getString(KEY_MNEMONIC_ENC, null) - ?: throw IllegalStateException("no mnemonic stored") - val ivB64 = p.getString(KEY_MNEMONIC_IV, null) - ?: throw IllegalStateException("no mnemonic iv stored") - val ct = android.util.Base64.decode(ctB64, android.util.Base64.DEFAULT) - val iv = android.util.Base64.decode(ivB64, android.util.Base64.DEFAULT) - val cipher = wrapKeystoreException { - Cipher.getInstance("AES/GCM/NoPadding").apply { - init( - Cipher.DECRYPT_MODE, - getOrCreateAndroidKey(), - GCMParameterSpec(128, iv) - ) - } - } - val plaintext = gate.decryptWithBiometric( - cipher, - ct, + ): CharArray { + // Keep BiometricPrompt on the caller/main context; all failures/cancel + // paths throw and therefore fail closed before legacy decryption starts. + gate.authenticate( io.raventag.app.R.string.biometricRevealTitle, io.raventag.app.R.string.biometricRevealSubtitle ) - try { - val tagB64 = p.getString(KEY_MNEMONIC_HMAC, null) - if (tagB64 != null) { - val tag = android.util.Base64.decode(tagB64, android.util.Base64.NO_WRAP) - verifySeedHmacInstance(plaintext, tag) + + return withContext(Dispatchers.IO) { + val p = prefs() + val revealKey = wrapKeystoreException { getOrCreateMnemonicRevealKey() } + var plaintext: ByteArray? = null + try { + val authCtB64 = p.getString(KEY_MNEMONIC_AUTH_ENC, null) + val authIvB64 = p.getString(KEY_MNEMONIC_AUTH_IV, null) + check((authCtB64 == null) == (authIvB64 == null)) { + "incomplete auth-bound mnemonic state" + } + + if (authCtB64 != null && authIvB64 != null) { + val ct = android.util.Base64.decode(authCtB64, android.util.Base64.NO_WRAP) + val iv = android.util.Base64.decode(authIvB64, android.util.Base64.NO_WRAP) + plaintext = wrapKeystoreException { + Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.DECRYPT_MODE, revealKey, GCMParameterSpec(128, iv)) + doFinal(ct) + } + } + + // Crash/interruption recovery: if a previous migration wrote a + // verified new copy but stopped before legacy cleanup, this + // successful auth-bound decrypt proves cleanup is now safe. + if (p.contains(KEY_MNEMONIC_ENC) || p.contains(KEY_MNEMONIC_IV)) { + check(p.edit() + .remove(KEY_MNEMONIC_ENC) + .remove(KEY_MNEMONIC_IV) + .remove(KEY_MNEMONIC_HMAC) + .commit()) { "could not remove verified legacy mnemonic copy" } + } + } else { + val legacyCtB64 = p.getString(KEY_MNEMONIC_ENC, null) + ?: throw IllegalStateException("no mnemonic stored") + val legacyIvB64 = p.getString(KEY_MNEMONIC_IV, null) + ?: throw IllegalStateException("no mnemonic iv stored") + val legacy = decrypt( + android.util.Base64.decode(legacyCtB64, android.util.Base64.DEFAULT), + android.util.Base64.decode(legacyIvB64, android.util.Base64.DEFAULT) + ) + try { + // Authenticate the legacy value before migrating it. + p.getString(KEY_MNEMONIC_HMAC, null)?.let { tagB64 -> + verifySeedHmacInstance( + legacy, + android.util.Base64.decode(tagB64, android.util.Base64.NO_WRAP) + ) + } + + val encCipher = Cipher.getInstance("AES/GCM/NoPadding") + encCipher.init(Cipher.ENCRYPT_MODE, revealKey) + val newCt = encCipher.doFinal(legacy) + val newIv = encCipher.iv + + // Verify before persistence. + val localVerified = Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.DECRYPT_MODE, revealKey, GCMParameterSpec(128, newIv)) + doFinal(newCt) + } + try { + check(MessageDigest.isEqual(legacy, localVerified)) { + "mnemonic auth-bound migration verification failed" + } + } finally { + localVerified.fill(0) + } + + check(p.edit() + .putString(KEY_MNEMONIC_AUTH_ENC, android.util.Base64.encodeToString(newCt, android.util.Base64.NO_WRAP)) + .putString(KEY_MNEMONIC_AUTH_IV, android.util.Base64.encodeToString(newIv, android.util.Base64.NO_WRAP)) + .commit()) { "could not persist authenticated mnemonic representation" } + + // Mandatory persisted read-back verification. Only after + // this succeeds may the legacy representation be deleted. + val persistedCt = android.util.Base64.decode( + p.getString(KEY_MNEMONIC_AUTH_ENC, null) + ?: error("persisted authenticated mnemonic missing"), + android.util.Base64.NO_WRAP + ) + val persistedIv = android.util.Base64.decode( + p.getString(KEY_MNEMONIC_AUTH_IV, null) + ?: error("persisted authenticated mnemonic IV missing"), + android.util.Base64.NO_WRAP + ) + val persistedVerified = Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.DECRYPT_MODE, revealKey, GCMParameterSpec(128, persistedIv)) + doFinal(persistedCt) + } + try { + check(MessageDigest.isEqual(legacy, persistedVerified)) { + "persisted mnemonic migration verification failed" + } + } finally { + persistedVerified.fill(0) + } + + check(p.edit() + .remove(KEY_MNEMONIC_ENC) + .remove(KEY_MNEMONIC_IV) + .remove(KEY_MNEMONIC_HMAC) + .commit()) { "could not remove verified legacy mnemonic copy" } + plaintext = legacy.copyOf() + } finally { + legacy.fill(0) + } + } + + val bytes = checkNotNull(plaintext) { "mnemonic decrypt produced no plaintext" } + val chars = Charsets.UTF_8.decode(java.nio.ByteBuffer.wrap(bytes)) + CharArray(chars.remaining()).also { chars.get(it) } + } finally { + plaintext?.fill(0) } - String(plaintext, Charsets.UTF_8).toCharArray() - } finally { - java.util.Arrays.fill(plaintext, 0) } } @@ -1494,12 +1698,12 @@ class WalletManager(private val context: Context) { } } catch (e: Exception) { if (e is IllegalStateException) throw e - // DB not initialized yet — safe to proceed + throw IllegalStateException("Wallet transaction state unavailable; refusing to sign", e) } return true } - suspend fun sendRvnLocal(toAddress: String, amountRvn: Double, onProgress: ((String) -> Unit)? = null): String = withContext(Dispatchers.IO) { + suspend fun sendRvnLocal(toAddress: String, amountRvn: Double, explicitMax: Boolean = false, onProgress: ((String) -> Unit)? = null): String = withContext(Dispatchers.IO) { ensureNoPendingSends() var currentIndex = getCurrentAddressIndex() var address = getAddress(0, currentIndex) ?: error("No wallet") @@ -1591,8 +1795,8 @@ class WalletManager(private val context: Context) { return@withContext try { val txid: String var feeSatActual: Long = 0L - var consumedUtxos: List = emptyList() var broadcastRawHex: String = "" + var recipientAmountSat: Long = amountSat if (hasAssets || hasOldFunds) { if (oldFunds.isNotEmpty()) { @@ -1622,84 +1826,94 @@ class WalletManager(private val context: Context) { val totalInputs = rvnUtxos.size + extraRvnKeyed.size + assetKeyed.values.sumOf { it.size } val totalAssetOutputs = assetKeyed.size val estimatedBytes = 10 + 148 * totalInputs + 70 * (2 + totalAssetOutputs) + 34 - feeSatActual = estimatedBytes * satPerByte + feeSatActual = FeeSafetyPolicy.calculateFee(estimatedBytes, satPerByte) + val totalRvnIn = currentRvnKeyed.sumOf { it.utxo.satoshis } + + extraRvnKeyed.sumOf { it.utxo.satoshis } + + assetKeyed.values.flatten().sumOf { it.assetUtxo.utxo.satoshis } + val dustForAssets = assetKeyed.values.sumOf { keyed -> + if (keyed.sumOf { it.assetUtxo.utxo.satoshis } > 0L) 600L else 0L + } + recipientAmountSat = if (explicitMax) { + totalRvnIn - feeSatActual - dustForAssets + } else { + FeeSafetyPolicy.requireSafeNormalSendFee(feeSatActual, amountSat) + amountSat + } + require(recipientAmountSat > 546L) { "Insufficient balance after safe fee and asset dust" } + require(totalRvnIn >= recipientAmountSat + feeSatActual + dustForAssets) { + "Insufficient balance for amount plus safe fee" + } - val tx = RavencoinTxBuilder.buildAndSignMultiAddressSend( + val tx = signAndBroadcastReserved( + inputs = (currentRvnKeyed).map { it.utxo } + (extraRvnKeyed).map { it.utxo } + (assetKeyed).values.flatten().map { it.assetUtxo.utxo }, + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSignMultiAddressSend( currentRvnInputs = currentRvnKeyed, extraRvnInputs = extraRvnKeyed, assetInputsByName = assetKeyed, toAddress = toAddress, - amountSat = amountSat, + amountSat = recipientAmountSat, feeSat = feeSatActual, changeAddress = nextAddress ) + } onProgress?.invoke("Broadcasting transaction...") - txid = node.broadcastWithAllServers(tx.hex) + txid = tx.txid // broadcast already completed inside reservation boundary android.util.Log.i("WalletManager", "sendRvn atomic: sent $amountRvn RVN to $toAddress, " + "all assets and remaining RVN to $nextAddress, txid=$txid") } else { val totalIn = rvnUtxos.sumOf { it.satoshis } - // Sweep / MAX detection: when the requested amount + estimated fee - // would exceed the available balance, treat as a "send all" and let - // RavencoinTxBuilder subtract the exact fee from the recipient amount. - // The wallet will end at 0 RVN with no change output. - val outputsForFee = if (amountSat >= totalIn) 1 else 2 + val outputsForFee = if (explicitMax) 1 else 2 val estimatedBytes = 10 + 148 * rvnUtxos.size + 34 * outputsForFee - feeSatActual = estimatedBytes * satPerByte + feeSatActual = FeeSafetyPolicy.calculateFee(estimatedBytes, satPerByte) - val isMaxSend = amountSat + feeSatActual > totalIn - if (isMaxSend) { - require(totalIn > feeSatActual + 546) { - "Insufficient funds to cover network fee: have ${totalIn / 1e8} RVN, fee ${feeSatActual / 1e8} RVN" - } + recipientAmountSat = if (explicitMax) { + totalIn - feeSatActual } else { - require(totalIn > amountSat + feeSatActual) { - "Insufficient funds: have ${totalIn / 1e8} RVN, need ${amountSat / 1e8} RVN + ${feeSatActual / 1e8} RVN fee" - } - val changeSat = totalIn - amountSat - feeSatActual - require(changeSat > 546) { - "Remaining change (${"%.8f".format(changeSat / 1e8)} RVN) is below dust limit. " + - "Send a slightly smaller amount or send the full balance." + FeeSafetyPolicy.requireSafeNormalSendFee(feeSatActual, amountSat) + amountSat + } + require(recipientAmountSat > 546L) { "Amount too small after safe network fee" } + require(totalIn >= recipientAmountSat + feeSatActual) { + "Insufficient balance for amount plus safe fee" + } + if (!explicitMax) { + val changeSat = totalIn - recipientAmountSat - feeSatActual + require(changeSat == 0L || changeSat > 546L) { + "Remaining change is below dust limit; lower the amount or use explicit MAX" } } - val tx = RavencoinTxBuilder.buildAndSign( + val tx = signAndBroadcastReserved( + inputs = (rvnUtxos), + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSign( utxos = rvnUtxos, - // Pass totalIn when sweeping so buildAndSign's fee-subtraction branch fires. toAddress = toAddress, - amountSat = if (isMaxSend) totalIn else amountSat, + amountSat = recipientAmountSat, feeSat = feeSatActual, changeAddress = nextAddress, privKeyBytes = privKey!!, pubKeyBytes = pubKey ) + } onProgress?.invoke("Broadcasting transaction...") - txid = node.broadcastWithAllServers(tx.hex) + txid = tx.txid // broadcast already completed inside reservation boundary broadcastRawHex = tx.hex - consumedUtxos = rvnUtxos val totalInLog = rvnUtxos.sumOf { it.satoshis } - val changeForLog = (totalInLog - (if (amountSat + feeSatActual > totalInLog) totalInLog else amountSat) - feeSatActual).coerceAtLeast(0L) + val changeForLog = (totalInLog - recipientAmountSat - feeSatActual).coerceAtLeast(0L) android.util.Log.i("WalletManager", "sendRvn: sent $amountRvn RVN to $toAddress, " + "remaining ${"%.8f".format(changeForLog / 1e8)} RVN to $nextAddress, txid=$txid") } setCurrentAddressIndex(currentIndex + 1) - // Reserved-UTXO + pending-consolidation bookkeeping (D-20, D-21). val now = System.currentTimeMillis() - val reserved = consumedUtxos.map { - ReservedUtxoDao.ReservedUtxo( - txidIn = it.txid, - vout = it.outputIndex, - valueSat = it.satoshis, - submittedTxid = txid, - submittedAt = now - ) - } - ReservedUtxoDao.reserve(reserved) PendingConsolidationDao.upsert( PendingConsolidationDao.PendingConsolidation( submittedTxid = txid, submittedAt = now, @@ -1718,14 +1932,14 @@ class WalletManager(private val context: Context) { val changeSat = when { hasAssets || hasOldFunds -> { val totalIn = rvnUtxos.sumOf { it.satoshis } + oldFunds.flatMap { it.rvn }.sumOf { it.satoshis } - (totalIn - amountSat - feeSatActual).coerceAtLeast(0L) + (totalIn - recipientAmountSat - feeSatActual).coerceAtLeast(0L) } else -> { val totalIn = rvnUtxos.sumOf { it.satoshis } - (totalIn - amountSat - feeSatActual).coerceAtLeast(0L) + (totalIn - recipientAmountSat - feeSatActual).coerceAtLeast(0L) } } - "$txid|fee:$feeSatActual|change:$changeSat" + "$txid|fee:$feeSatActual|change:$changeSat|sent:$recipientAmountSat" } finally { privKey?.fill(0) } @@ -1905,7 +2119,7 @@ class WalletManager(private val context: Context) { val primaryAssetChangeOutputs = if (assetChangeRaw > 0) 1 else 0 val totalAssetOutputs = 1 + primaryAssetChangeOutputs + otherKeyed.size + secondaryKeyed.size val totalInputs = primaryKeyed.size + otherKeyed.values.sumOf { it.size } + secondaryKeyed.values.sumOf { it.size } + rvnKeyed.size - val feeSat = (10L + 148L * totalInputs + 70L * totalAssetOutputs + 34L) * maxOf(satPerByte, 200L) + val feeSat = FeeSafetyPolicy.calculateFee((10L + 148L * totalInputs + 70L * totalAssetOutputs + 34L), maxOf(satPerByte, 200L)) val dustEstimate = 600L * totalAssetOutputs val totalRvnIn = rvnKeyed.sumOf { it.utxo.satoshis } + @@ -1918,7 +2132,11 @@ class WalletManager(private val context: Context) { "have ${totalRvnIn / 1e8} RVN. Fund your wallet with at least 0.01 RVN.") } - val tx = RavencoinTxBuilder.buildAndSignMultiAddressAssetTransfer( + val tx = signAndBroadcastReserved( + inputs = (primaryKeyed).map { it.assetUtxo.utxo } + (otherKeyed).values.flatten().map { it.assetUtxo.utxo } + (rvnKeyed).map { it.utxo } + (secondaryKeyed).values.flatten().map { it.assetUtxo.utxo }, + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSignMultiAddressAssetTransfer( primaryAssetInputs = primaryKeyed, otherAssetInputs = otherKeyed, rvnInputs = rvnKeyed, @@ -1928,22 +2146,12 @@ class WalletManager(private val context: Context) { changeAddress = nextAddress, secondaryAssetsToToAddress = secondaryKeyed ) - val txid = node.broadcastWithAllServers(tx.hex) - - // Reserved-UTXO + pending-consolidation bookkeeping (D-20, D-21). - val allConsumedUtxos = allFunds.flatMap { af -> - af.rvnUtxos + af.assetUtxos.values.flatten().map { it.utxo } } - val xferNow = System.currentTimeMillis() - ReservedUtxoDao.reserve(allConsumedUtxos.map { - ReservedUtxoDao.ReservedUtxo( - txidIn = it.txid, vout = it.outputIndex, valueSat = it.satoshis, - submittedTxid = txid, submittedAt = xferNow - ) - }) + val txid = tx.txid + PendingConsolidationDao.upsert( PendingConsolidationDao.PendingConsolidation( - submittedTxid = txid, submittedAt = xferNow, + submittedTxid = txid, submittedAt = System.currentTimeMillis(), lastRetryAt = null, retryCount = 0, lastError = null ) ) @@ -2048,7 +2256,7 @@ class WalletManager(private val context: Context) { assetName.contains('/') -> 5 else -> 4 } - val feeSat = (10 + 148 * totalInputs + 70 * (outputCountForIssuance + totalAssetSweepOutputs) + 34) * satPerByte + val feeSat = FeeSafetyPolicy.calculateFee((10 + 148 * totalInputs + 70 * (outputCountForIssuance + totalAssetSweepOutputs) + 34).toLong(), satPerByte) val qtyRaw = if (assetName.contains('#')) { RavencoinTxBuilder.ASSET_UNIT_RAW @@ -2063,7 +2271,13 @@ class WalletManager(private val context: Context) { return@withContext try { android.util.Log.i("WalletManager", "issueAsset: ownerUtxo=${ownerAssetUtxos.map { "${it.txid}:${it.outputIndex}" }}, " + "rvnUtxos=${rvnUtxos.map { "${it.txid}:${it.outputIndex}" }}, burnSat=$burnSat, feeSat=$feeSat") - val tx = RavencoinTxBuilder.buildAndSignAssetIssueWithAssetSweep( + val tx = signAndBroadcastReserved( + inputs = (rvnUtxos.filterNot { rvn -> + ownerAssetUtxos.any { owner -> owner.txid == rvn.txid && owner.outputIndex == rvn.outputIndex } + }) + (ownerAssetUtxos) + (otherAssetUtxos).values.flatten().map { it.utxo }, + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSignAssetIssueWithAssetSweep( utxos = rvnUtxos.filterNot { rvn -> ownerAssetUtxos.any { owner -> owner.txid == rvn.txid && owner.outputIndex == rvn.outputIndex } }, @@ -2081,7 +2295,8 @@ class WalletManager(private val context: Context) { privKeyBytes = privKey!!, pubKeyBytes = pubKey ) - val txid = node.broadcastWithAllServers(tx.hex) + } + val txid = tx.txid android.util.Log.i("WalletManager", "issueAsset: issued $qty $assetName to $actualToAddress, " + "RVN change + current-index assets to $nextAddress in the same tx, txid=$txid") @@ -2171,7 +2386,7 @@ class WalletManager(private val context: Context) { val totalAssetSweepOutputs = otherAssetUtxos.size val totalInputs = rvnUtxos.size + ownerAssetUtxos.size + otherAssetUtxos.values.sumOf { it.size } val outputCount = 4 + totalAssetSweepOutputs // burn + change + owner + reissue + sweep - val feeSat = (10L + 148L * totalInputs + 70L * outputCount + 34L) * maxOf(satPerByte, 200L) + val feeSat = FeeSafetyPolicy.calculateFee((10L + 148L * totalInputs + 70L * outputCount + 34L), maxOf(satPerByte, 200L)) val addQtyRaw = (addQty * RavencoinTxBuilder.ASSET_UNIT_RAW.toDouble()).toLong() @@ -2189,7 +2404,11 @@ class WalletManager(private val context: Context) { "reissueAsset: $assetName addQty=$addQty newUnits=$newUnits reissuable=$reissuable " + "ipfs=${newIpfsHash != null} ownerIndex=${ownerHolder.index} feeSat=$feeSat") - val tx = if (!needsMultiKey) { + val tx = signAndBroadcastReserved( + inputs = rvnUtxos + ownerAssetUtxos + otherAssetUtxos.values.flatten().map { it.utxo }, + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + if (!needsMultiKey) { RavencoinTxBuilder.buildAndSignAssetReissue( utxos = rvnUtxos, ownerAssetUtxos = ownerAssetUtxos, @@ -2210,7 +2429,8 @@ class WalletManager(private val context: Context) { error("Owner token on a different address than the current index is not yet supported. " + "Consolidate the wallet first so the owner token sits on the active address.") } - val txid = node.broadcastWithAllServers(tx.hex) + } + val txid = tx.txid setCurrentAddressIndex(currentIndex + 1) android.util.Log.i("WalletManager", "reissueAsset: $assetName reissued, txid=$txid") txid @@ -2359,18 +2579,8 @@ class WalletManager(private val context: Context) { return words.joinToString(" ") } - private fun mnemonicToSeed(mnemonic: String, passphrase: String): ByteArray { - val mnemonicBytes = mnemonic.toByteArray(Charsets.UTF_8) - val saltBytes = ("mnemonic$passphrase").toByteArray(Charsets.UTF_8) - val spec = javax.crypto.spec.PBEKeySpec( - mnemonic.toCharArray(), saltBytes, 2048, 512 - ) - val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512") - return factory.generateSecret(spec).encoded.also { - spec.clearPassword() - mnemonicBytes.fill(0) - } - } + private fun mnemonicToSeed(mnemonic: String, passphrase: String): ByteArray = + Bip39Kdf.deriveSeed(mnemonic, passphrase) suspend fun healAndSweepTarget(index: Int) = withContext(Dispatchers.IO) { if (!ensureNoPendingSends(throwOnPending = false)) return@withContext @@ -2395,11 +2605,16 @@ class WalletManager(private val context: Context) { var curPrivKey: ByteArray? = curKeyPair.first try { val currentUtxos = node.getUtxos(currentAddr) - val fundFee = (10L + 148L * currentUtxos.size + 34L * 2) * satPerByte - val tx = RavencoinTxBuilder.buildAndSign( + val fundFee = FeeSafetyPolicy.calculateFee(10L + 148L * currentUtxos.size + 34L * 2, satPerByte) + val tx = signAndBroadcastReserved( + inputs = (currentUtxos), + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSign( currentUtxos, addr, 10000000L, fundFee, currentAddr, curPrivKey!!, curKeyPair.second ) - node.broadcastWithAllServers(tx.hex) + } + tx.txid // broadcast already completed inside reservation boundary // Poll until funding UTXOs are visible in mempool (up to 60s) var waited = 0 while (waited < 60) { @@ -2416,18 +2631,23 @@ class WalletManager(private val context: Context) { val targetAddr = getAddress(0, currentIndex) ?: return@withContext val sweepResult = node.getUtxosAndAllAssetUtxosBatch(addr) val totalSweepInputs = sweepResult.first.size + sweepResult.third.values.sumOf { it.size } - val sweepFee = (10L + 148L * totalSweepInputs + 34L * (1 + sweepResult.third.size)) * satPerByte - val tx = RavencoinTxBuilder.buildAndSignRvnSendWithAssetSweep( + val sweepFee = FeeSafetyPolicy.calculateFee(10L + 148L * totalSweepInputs + 34L * (1 + sweepResult.third.size), satPerByte) + val tx = signAndBroadcastReserved( + inputs = (sweepResult.first) + (sweepResult.third).values.flatten().map { it.utxo }, + broadcaster = { raw -> node.broadcastWithAllServers(raw) } + ) { + RavencoinTxBuilder.buildAndSignRvnSendWithAssetSweep( rvnUtxos = sweepResult.first, assetUtxos = sweepResult.third, toAddress = targetAddr, amountSat = 0L, feeSat = sweepFee, changeAddress = targetAddr, - privKeyBytes = privKey, + privKeyBytes = privKey!!, pubKeyBytes = pubKey ) - node.broadcastWithAllServers(tx.hex) + } + tx.txid // broadcast already completed inside reservation boundary android.util.Log.i("WalletManager", "AutoHeal/Sweep: Consolidated index $index to $currentIndex") } } catch (e: Exception) { @@ -2684,7 +2904,7 @@ suspend fun consolidateAllFundsToFreshAddress(): String? = withContext(Dispatche val oldAssetInputs = oldFundsOnly.sumOf { it.assetUtxos.values.sumOf { it.size } } val oldRvnInputs = oldFundsOnly.sumOf { it.rvnUtxos.size } val estimatedBytes = 10 + 148 * (oldRvnInputs + oldAssetInputs) + 70 * (1 + oldAssetTypes) + 34 - val feeSatEst = estimatedBytes * satPerByte + val feeSatEst = FeeSafetyPolicy.calculateFee(estimatedBytes, satPerByte) val dustEst = oldAssetTypes * 546L val needed = feeSatEst + dustEst + 546L canSelfPay = oldRvn >= needed @@ -2777,7 +2997,7 @@ suspend fun consolidateAllFundsToFreshAddress(): String? = withContext(Dispatche // Tight byte estimate: ~150 bytes per signed P2PKH input, ~34 bytes per RVN output, // ~85 bytes per asset output (extra OP_RVN_ASSET payload). +10 bytes header. val estimatedBytes = 10L + 150L * totalInputs + 34L + 85L * totalAssetOutputs - val feeSat = estimatedBytes * satPerByte + val feeSat = FeeSafetyPolicy.calculateFee(estimatedBytes, satPerByte) android.util.Log.i("WalletManager", "consolid: fee estimate : ${estimatedBytes} bytes at ${satPerByte} sat/byte = ${feeSat} sat (raw relay fee was ${rawSatPerByte})") @@ -2847,7 +3067,11 @@ suspend fun consolidateAllFundsToFreshAddress(): String? = withContext(Dispatche "rvnInputs=$totalRvnInputs, assetInputs=$totalAssetInputs, " + "assetOutputs=$totalAssetOutputs, amountSat=$amountSat, feeSat=$feeSat, assetDust=$totalAssetDust") - val tx = RavencoinTxBuilder.buildAndSignMultiAddressSend( + val tx = signAndBroadcastReserved( + inputs = (allRvnKeyed).map { it.utxo } + (allAssetKeyed).values.flatten().map { it.assetUtxo.utxo }, + broadcaster = { raw -> broadcastConsolidation(raw) } + ) { + RavencoinTxBuilder.buildAndSignMultiAddressSend( currentRvnInputs = allRvnKeyed, extraRvnInputs = emptyList(), assetInputsByName = allAssetKeyed, @@ -2856,7 +3080,8 @@ suspend fun consolidateAllFundsToFreshAddress(): String? = withContext(Dispatche feeSat = feeSat, changeAddress = useTargetAddress ) - txid = broadcastConsolidation(tx.hex) + } + txid = tx.txid } else { // Single address, RVN only @@ -2879,7 +3104,11 @@ suspend fun consolidateAllFundsToFreshAddress(): String? = withContext(Dispatche android.util.Log.i("WalletManager", "consolid: single-address RVN sweep : " + "totalIn=$totalSat, send=$sendAmount, fee=$feeSat") - val tx = RavencoinTxBuilder.buildAndSign( + val tx = signAndBroadcastReserved( + inputs = (utxos), + broadcaster = { raw -> broadcastConsolidation(raw) } + ) { + RavencoinTxBuilder.buildAndSign( utxos = utxos, toAddress = useTargetAddress, amountSat = sendAmount, @@ -2888,7 +3117,8 @@ suspend fun consolidateAllFundsToFreshAddress(): String? = withContext(Dispatche privKeyBytes = singleKeyPair.first, pubKeyBytes = singleKeyPair.second ) - txid = broadcastConsolidation(tx.hex) + } + txid = tx.txid } setCurrentAddressIndex(useNextIndex) diff --git a/android/app/src/main/java/io/raventag/app/wallet/cache/ReservedUtxoDao.kt b/android/app/src/main/java/io/raventag/app/wallet/cache/ReservedUtxoDao.kt index 23e7f8ae..08abcefc 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/cache/ReservedUtxoDao.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/cache/ReservedUtxoDao.kt @@ -3,6 +3,7 @@ package io.raventag.app.wallet.cache import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteConstraintException /** * DAO for the reserved_utxos table (D-20). @@ -30,11 +31,16 @@ object ReservedUtxoDao { WalletReliabilityDb.getDatabase().execSQL("DELETE FROM $TABLE") } - fun reserve(entries: List) { - if (entries.isEmpty()) return + /** + * Atomically reserve every outpoint. Existing reservations are never replaced. + * Returns false for a conflicting outpoint or any database failure; callers must + * fail closed and MUST NOT sign when this returns false. + */ + fun tryReserve(entries: List): Boolean { + if (entries.isEmpty()) return false val db = WalletReliabilityDb.getDatabase() db.beginTransaction() - try { + return try { for (e in entries) { val cv = ContentValues().apply { put("txid_in", e.txidIn) @@ -43,14 +49,30 @@ object ReservedUtxoDao { put("submitted_txid", e.submittedTxid) put("submitted_at", e.submittedAt) } - db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + val rowId = db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_ABORT) + if (rowId == -1L) throw SQLiteConstraintException("outpoint already reserved") } db.setTransactionSuccessful() + true + } catch (_: Exception) { + false } finally { db.endTransaction() } } + /** Backward-compatible strict wrapper used by older call sites/tests. */ + fun reserve(entries: List) { + check(tryReserve(entries)) { "UTXO reservation conflict or database failure" } + } + + /** Associate a pre-sign intent reservation with the locally computed txid. */ + fun retagReservation(fromId: String, submittedTxid: String): Boolean { + val db = WalletReliabilityDb.getDatabase() + val cv = ContentValues().apply { put("submitted_txid", submittedTxid) } + return db.update(TABLE, cv, "submitted_txid = ?", arrayOf(fromId)) > 0 + } + fun releaseFor(submittedTxid: String) { val db = WalletReliabilityDb.getDatabase() db.delete(TABLE, "submitted_txid = ?", arrayOf(submittedTxid)) diff --git a/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionManager.kt b/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionManager.kt index a7200891..1548b0e6 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionManager.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionManager.kt @@ -62,6 +62,7 @@ class SubscriptionManager( companion object { private const val TAG = "SubscriptionManager" + private const val MAX_RESPONSE_LINE_CHARS = 1_048_576 /** * Kept for binary/call-site compatibility; the runtime pool is now @@ -180,7 +181,7 @@ class SubscriptionManager( private suspend fun readLoop(s: Session) { try { while (coroutineContext.isActive) { - val line = withContext(Dispatchers.IO) { s.reader.readLine() } + val line = withContext(Dispatchers.IO) { readBoundedLine(s.reader, s.host) } if (line == null) { io.raventag.app.wallet.health.NodeHealthMonitor.reportFailure( sessionKey(s), @@ -212,6 +213,19 @@ class SubscriptionManager( } } + private fun readBoundedLine(reader: BufferedReader, host: String): String? { + val out = StringBuilder() + while (true) { + val ch = reader.read() + if (ch == -1) return if (out.isEmpty()) null else out.toString() + if (ch == '\n'.code) return out.toString() + if (ch != '\r'.code) { + require(out.length < MAX_RESPONSE_LINE_CHARS) { "Oversized ElectrumX response from $host" } + out.append(ch.toChar()) + } + } + } + private suspend fun heartbeatLoop(s: Session) { try { while (coroutineContext.isActive) { diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index d7b4192e..683208ff 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -1,6 +1,6 @@ - + diff --git a/android/app/src/test/java/io/raventag/app/nfc/SunVerifierTest.kt b/android/app/src/test/java/io/raventag/app/nfc/SunVerifierTest.kt index f703cc78..da977198 100644 --- a/android/app/src/test/java/io/raventag/app/nfc/SunVerifierTest.kt +++ b/android/app/src/test/java/io/raventag/app/nfc/SunVerifierTest.kt @@ -74,7 +74,7 @@ class SunVerifierTest { // CMAC over empty input, then take odd-indexed bytes [1,3,5,...,15] val fullCmac = computeCmac(sessionKey, ByteArray(0)) val truncated = ByteArray(8) { i -> fullCmac[i * 2 + 1] } - val mHex = truncated.copyOf(4).joinToString("") { "%02x".format(it) } + val mHex = truncated.joinToString("") { "%02x".format(it) } return eHex to mHex } diff --git a/android/app/src/test/java/io/raventag/app/security/LegacySecretMigrationTest.kt b/android/app/src/test/java/io/raventag/app/security/LegacySecretMigrationTest.kt new file mode 100644 index 00000000..799a7803 --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/security/LegacySecretMigrationTest.kt @@ -0,0 +1,76 @@ +package io.raventag.app.security + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class LegacySecretMigrationTest { + private class Store(initial: Map = emptyMap()) { + val values = initial.toMutableMap() + var failWrite = false + var failRemoveOnce = false + fun read(k: String): String? = values[k] + fun write(k: String, v: String) { + if (failWrite) throw IllegalStateException("secure store unavailable") + values[k] = v + } + fun remove(k: String) { + if (failRemoveOnce) { + failRemoveOnce = false + throw IllegalStateException("interrupted before cleanup") + } + values.remove(k) + } + } + + private fun run(legacy: Store, secure: Store) = LegacySecretMigration.migrate( + legacy::read, secure::write, secure::read, legacy::remove + ) + + @Test fun noLegacyValuesIsNoOp() { + val out = run(Store(), Store()) + assertEquals(0, out.migrated) + assertEquals(0, out.cleanedUp) + } + + @Test fun successfulMigrationVerifiesThenDeletesLegacy() { + val legacy = Store(mapOf("operator_key" to "secret")) + val secure = Store() + val out = run(legacy, secure) + assertEquals("secret", secure.read("operator_key")) + assertNull(legacy.read("operator_key")) + assertEquals(1, out.migrated) + assertEquals(1, out.cleanedUp) + } + + @Test fun encryptedStoreFailurePreservesLegacy() { + val legacy = Store(mapOf("pinata_jwt" to "jwt")) + val secure = Store().also { it.failWrite = true } + assertThrows(IllegalStateException::class.java) { run(legacy, secure) } + assertEquals("jwt", legacy.read("pinata_jwt")) + } + + @Test fun interruptedMigrationIsSafelyResumed() { + val legacy = Store(mapOf("initial_master_key" to "master")) + val secure = Store() + legacy.failRemoveOnce = true + assertThrows(IllegalStateException::class.java) { run(legacy, secure) } + assertEquals("master", legacy.read("initial_master_key")) + assertEquals("master", secure.read("initial_master_key")) + val second = run(legacy, secure) + assertNull(legacy.read("initial_master_key")) + assertEquals(0, second.migrated) + assertEquals(1, second.cleanedUp) + } + + @Test fun repeatedMigrationIsIdempotent() { + val legacy = Store(mapOf("admin_key" to "admin")) + val secure = Store() + run(legacy, secure) + val second = run(legacy, secure) + assertEquals("admin", secure.read("admin_key")) + assertEquals(0, second.migrated) + assertEquals(0, second.cleanedUp) + } +} diff --git a/android/app/src/test/java/io/raventag/app/wallet/Bip39KdfTest.kt b/android/app/src/test/java/io/raventag/app/wallet/Bip39KdfTest.kt new file mode 100644 index 00000000..50e8bd4a --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/wallet/Bip39KdfTest.kt @@ -0,0 +1,26 @@ +package io.raventag.app.wallet + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class Bip39KdfTest { + private fun ByteArray.hex() = joinToString("") { "%02x".format(it) } + + @Test + fun officialBip39Vector_zeroEntropy_withTREZORPassphrase() { + val mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + val expected = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + assertEquals(expected, Bip39Kdf.deriveSeed(mnemonic, "TREZOR").hex()) + } + + @Test + fun unicodePassphraseIsNfkdNormalized() { + val mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + val composed = "caf\u00e9" + val decomposed = "cafe\u0301" + val a = Bip39Kdf.deriveSeed(mnemonic, composed) + val b = Bip39Kdf.deriveSeed(mnemonic, decomposed) + assertArrayEquals(a, b) + } +} diff --git a/android/app/src/test/java/io/raventag/app/wallet/FeeSafetyPolicyTest.kt b/android/app/src/test/java/io/raventag/app/wallet/FeeSafetyPolicyTest.kt new file mode 100644 index 00000000..bb17f955 --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/wallet/FeeSafetyPolicyTest.kt @@ -0,0 +1,57 @@ +package io.raventag.app.wallet + +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +class FeeSafetyPolicyTest { + @Test + fun malicious21RvnPerKbIsRejected() { + try { + FeeSafetyPolicy.sanitizeRelayFeeRvnPerKb(21.0) + fail("expected unreasonable relay fee rejection") + } catch (_: IllegalArgumentException) { } + } + + @Test + fun nonFiniteAndNegativeRelayFeesAreRejected() { + listOf(Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, 0.0, -1.0).forEach { value -> + try { + FeeSafetyPolicy.sanitizeRelayFeeRvnPerKb(value) + fail("expected rejection for $value") + } catch (_: IllegalArgumentException) { } + } + } + + @Test + fun ordinaryBoundedRelayFeeIsAccepted() { + // 0.01 RVN/kB -> 1,000 sat/B -> existing 2x RavenTag margin -> 2,000 sat/B. + assertEquals(2_000L, FeeSafetyPolicy.sanitizeRelayFeeRvnPerKb(0.01)) + } + + @Test + fun absoluteFeeCeilingRejectsAuditDrainScenario() { + // Audit scenario: 226 bytes * 4,200,000 sat/B = 9.492 RVN. + try { + FeeSafetyPolicy.calculateFee(226, 4_200_000L) + fail("expected malicious fee rejection") + } catch (_: IllegalArgumentException) { } + } + + @Test + fun normalSendFeeRatioIsBounded() { + FeeSafetyPolicy.requireSafeNormalSendFee(10_000_000L, 100_000_000L) // 0.1 fee on 1 RVN + try { + FeeSafetyPolicy.requireSafeNormalSendFee(10_000_000L, 20_000_000L) // 0.1 on 0.2 = 50% + fail("expected fee ratio rejection") + } catch (_: IllegalArgumentException) { } + } + + @Test + fun multiplicationOverflowFailsClosed() { + try { + FeeSafetyPolicy.calculateFee(Long.MAX_VALUE, FeeSafetyPolicy.MIN_SAT_PER_BYTE) + fail("expected overflow") + } catch (_: ArithmeticException) { } + } +} diff --git a/android/app/src/test/java/io/raventag/app/wallet/RavencoinTxBuilderTest.kt b/android/app/src/test/java/io/raventag/app/wallet/RavencoinTxBuilderTest.kt index 1fd05dd6..b9f7ac73 100644 --- a/android/app/src/test/java/io/raventag/app/wallet/RavencoinTxBuilderTest.kt +++ b/android/app/src/test/java/io/raventag/app/wallet/RavencoinTxBuilderTest.kt @@ -3,6 +3,8 @@ package io.raventag.app.wallet import org.bouncycastle.crypto.digests.RIPEMD160Digest import org.bouncycastle.jce.ECNamedCurveTable import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.fail import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test @@ -701,4 +703,32 @@ class RavencoinTxBuilderTest { } assertTrue("change output to fresh address must exist", foundChangeOutput) } + @Test + fun foreignNetworkBitcoinAddressIsRejected() { + assertFalse(RavencoinTxBuilder.isValidRavencoinMainnetP2pkh("1BoatSLRHtKNngkdXEeobR76b53LETtpyT")) + } + + @Test + fun builderNeverImplicitlyConvertsNormalSendToMax() { + val utxo = Utxo( + txid = "11".repeat(32), outputIndex = 0, satoshis = 1_000_000_000L, + script = "76a914" + "22".repeat(20) + "88ac", + height = 100 + ) + val priv = ByteArray(32).also { it[31] = 1 } + val pub = ByteArray(33).also { it[0] = 2; it[32] = 1 } + try { + RavencoinTxBuilder.buildAndSign( + utxos = listOf(utxo), + toAddress = "R9K8Qpz6rfMe9KvtB7w8KCpzNLKn3D2uGv", + amountSat = 990_000_000L, + feeSat = 20_000_000L, + changeAddress = "R9K8Qpz6rfMe9KvtB7w8KCpzNLKn3D2uGv", + privKeyBytes = priv, + pubKeyBytes = pub + ) + fail("normal send must fail instead of reducing the recipient amount") + } catch (_: IllegalArgumentException) { } + } + } diff --git a/backend/package-lock.json b/backend/package-lock.json index 17eeba31..cae26b18 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,15 +9,15 @@ "version": "1.0.0", "dependencies": { "@types/multer": "^2.1.0", - "axios": "^1.6.7", + "axios": "1.19.0", "better-sqlite3": "^9.4.3", "cors": "^2.8.5", "dotenv": "^16.4.1", - "express": "^4.18.2", - "express-rate-limit": "^8.3.0", - "form-data": "^4.0.5", + "express": "4.22.2", + "express-rate-limit": "8.6.2", + "form-data": "4.0.6", "helmet": "^7.1.0", - "multer": "^2.1.1", + "multer": "2.2.0", "zod": "^3.22.4" }, "devDependencies": { @@ -25,14 +25,14 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.11.5", - "tsx": "^4.7.0", + "tsx": "4.23.12", "typescript": "^5.3.3" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -47,9 +47,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -64,9 +64,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -81,9 +81,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -149,9 +149,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -166,9 +166,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -183,9 +183,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -200,9 +200,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -217,9 +217,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -234,9 +234,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -251,9 +251,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -268,9 +268,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -285,9 +285,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -302,9 +302,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -319,9 +319,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -336,9 +336,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -353,9 +353,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -370,9 +370,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -387,9 +387,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -404,9 +404,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -421,9 +421,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -438,9 +438,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -455,9 +455,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -619,6 +619,41 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -638,14 +673,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/base64-js": { @@ -700,9 +736,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -713,7 +749,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -1027,9 +1063,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1054,9 +1090,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1067,32 +1103,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escape-html": { @@ -1120,14 +1156,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -1146,7 +1182,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -1166,12 +1202,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", - "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -1183,6 +1220,29 @@ "express": ">= 4.11" } }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1208,9 +1268,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1228,16 +1288,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -1328,19 +1388,6 @@ "node": ">= 0.4" } }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -1387,9 +1434,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1427,6 +1474,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1472,9 +1555,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -1592,9 +1675,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", - "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -1689,9 +1772,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/prebuild-install": { @@ -1735,10 +1818,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pump": { "version": "3.0.4", @@ -1751,12 +1837,13 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1818,16 +1905,6 @@ "node": ">= 6" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1918,14 +1995,14 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1937,13 +2014,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -2107,14 +2184,13 @@ } }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" diff --git a/backend/package.json b/backend/package.json index 06d9d61f..251c2f87 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,21 +6,22 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "test": "tsx --test src/**/*.test.ts", "start": "node dist/index.js", "lint": "eslint src --ext .ts", "db:explore": "tsx src/db-explore.ts" }, "dependencies": { "@types/multer": "^2.1.0", - "axios": "^1.6.7", + "axios": "1.19.0", "better-sqlite3": "^9.4.3", "cors": "^2.8.5", "dotenv": "^16.4.1", - "express": "^4.18.2", - "express-rate-limit": "^8.3.0", - "form-data": "^4.0.5", + "express": "4.22.2", + "express-rate-limit": "8.6.2", + "form-data": "4.0.6", "helmet": "^7.1.0", - "multer": "^2.1.1", + "multer": "2.2.0", "zod": "^3.22.4" }, "devDependencies": { @@ -28,7 +29,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.11.5", - "tsx": "^4.7.0", + "tsx": "4.23.12", "typescript": "^5.3.3" } } diff --git a/backend/src/index.ts b/backend/src/index.ts index ae4f565d..ca16cc89 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -265,7 +265,11 @@ const server = app.listen(PORT, () => { console.log(`RavenTag API running on http://localhost:${PORT}`) console.log(`Protocol: RTP-1 | Env: ${process.env.NODE_ENV ?? 'development'}`) startLogCleanup() - startBackupScheduler() + if (process.env.ENABLE_INTERNAL_BACKUP === 'true') { + const backupKeyPath = process.env.INTERNAL_BACKUP_ENCRYPTION_KEY_FILE + if (!backupKeyPath) throw new Error('ENABLE_INTERNAL_BACKUP requires INTERNAL_BACKUP_ENCRYPTION_KEY_FILE') + startBackupScheduler(backupKeyPath) +} }) export default app diff --git a/backend/src/middleware/cache.ts b/backend/src/middleware/cache.ts index 0ab72eac..f63d10bb 100644 --- a/backend/src/middleware/cache.ts +++ b/backend/src/middleware/cache.ts @@ -107,15 +107,16 @@ export function unrevokeAsset(assetName: string): boolean { * @returns true if the counter is fresh and has been stored; false if replayed. */ export function checkAndUpdateCounter(nfcPubId: string, counter: number): boolean { - const database = getDb() - const row = database - .prepare('SELECT last_counter FROM nfc_counters WHERE nfc_pub_id = ?') - .get(nfcPubId) as { last_counter: number } | undefined - if (row && counter <= row.last_counter) return false // replay detected - database.prepare( - 'INSERT OR REPLACE INTO nfc_counters (nfc_pub_id, last_counter) VALUES (?, ?)' - ).run(nfcPubId, counter) - return true + if (!Number.isSafeInteger(counter) || counter < 0) return false + const result = db.prepare(` + INSERT INTO nfc_counters (nfc_pub_id, last_counter) + VALUES (?, ?) + ON CONFLICT(nfc_pub_id) DO UPDATE SET last_counter = excluded.last_counter + WHERE excluded.last_counter > nfc_counters.last_counter + `).run(nfcPubId, counter) + // SQLite serializes the conditional write: exactly one concurrent request can + // advance a given counter value. changes===0 means replay/stale counter. + return result.changes === 1 } /** diff --git a/backend/src/routes/registry.ts b/backend/src/routes/registry.ts index 9630a4b3..f4568fe2 100644 --- a/backend/src/routes/registry.ts +++ b/backend/src/routes/registry.ts @@ -1,32 +1,19 @@ /** - * Registry routes (routes/registry.ts) + * Public RTP-1 brand/emission registry. * - * Public registry for RTP-1 brands and on-chain asset emissions. - * Allows the community to discover brands using the RavenTag protocol and to - * verify that asset issuance transactions exist on the Ravencoin blockchain. - * - * All GET endpoints are public (no auth required). - * POST /register requires admin key to prevent spam registrations. - * POST /notify is public but strictly rate-limited (5 req/min per IP) and - * requires the txid to be verified on the Ravencoin blockchain via ElectrumX - * before the notification is stored. - * - * Routes: - * GET /api/registry/brands List all registered RTP-1 brands - * POST /api/registry/register Register a brand (admin key required) - * POST /api/registry/notify Notify a new on-chain asset emission (public, rate-limited) - * GET /api/registry/emissions List recorded on-chain emissions (last 200) + * POST /notify is public and rate-limited, but an emission is trusted only after + * Ravencoin Core has decoded the referenced transaction and the decoded outputs + * prove the claimed asset name/type, canonical burn, and owner-token semantics. */ import { Router, Request, Response } from 'express' import { rateLimit } from 'express-rate-limit' import { getDb } from '../middleware/cache.js' import { requireAdminKey } from '../middleware/auth.js' -import { electrumXService } from '../services/electrumx.js' +import { ravencoinService } from '../services/ravencoin.js' +import { validateRegistryIssuance, type RegistryAssetType } from '../services/registry-issuance.js' const router = Router() -// Strict limiter for the public notify endpoint: 5 req/min per IP -// Prevents spamming the registry with invalid or fictitious emission notifications const notifyLimiter = rateLimit({ windowMs: 60_000, max: 5, @@ -35,11 +22,6 @@ const notifyLimiter = rateLimit({ message: { error: 'Too many requests', code: 'RATE_LIMITED' } }) -/** - * Ensure the brand_registry table exists. - * Called as a guard before any brand_registry query because the table may not - * have been created yet if the migration has not run (e.g. in tests). - */ function ensureTable() { getDb().prepare(` CREATE TABLE IF NOT EXISTS brand_registry ( @@ -50,30 +32,15 @@ function ensureTable() { `).run() } -// Valid asset type values for the asset_emissions table constraint const VALID_ASSET_TYPES = ['root', 'sub', 'unique'] as const type AssetType = typeof VALID_ASSET_TYPES[number] -/** - * GET /api/registry/brands - * Public list of brands using RavenTag protocol, ordered by registration date ascending. - * Returns brand name, registration date, and protocol version for each entry. - */ router.get('/brands', (_req: Request, res: Response) => { ensureTable() const brands = getDb().prepare('SELECT brand_name as name, registered_at FROM brand_registry ORDER BY registered_at ASC').all() res.json({ brands, count: brands.length, protocol: 'RTP-1' }) }) -/** - * POST /api/registry/register - * Registers the brand in the public RavenTag registry. - * Requires admin key to prevent spam. - * - * Brand names are trimmed, must be 2-64 characters, and may only contain - * alphanumeric characters, spaces, underscores, dots, and hyphens. - * Duplicate registrations are silently ignored (INSERT OR IGNORE). - */ router.post('/register', requireAdminKey, (req: Request, res: Response) => { const { brand_name, protocol_version } = req.body if (!brand_name || typeof brand_name !== 'string' || brand_name.trim().length < 2) { @@ -95,22 +62,6 @@ router.post('/register', requireAdminKey, (req: Request, res: Response) => { res.json({ success: true, brand_name: name, registered_at: now, message: 'Brand registered in RavenTag public registry.' }) }) -/** - * POST /api/registry/notify - * Silent notification sent by RavenTag apps after on-chain asset emission. - * Public endpoint, no auth required. Rate-limited to 5 req/min per IP. - * Requires a valid txid that is verified to exist on the Ravencoin blockchain. - * - * This endpoint enables the public emissions registry to track asset issuance - * without requiring the brand to run an admin call. The txid verification via - * ElectrumX ensures only real, confirmed (or mempool) transactions are recorded, - * preventing fabricated emission records. - * - * When a root asset is notified, it is also auto-registered in brand_registry - * so the brand appears in the public brand list. - * - * Duplicate txid submissions are silently ignored (INSERT OR IGNORE on UNIQUE txid). - */ router.post('/notify', notifyLimiter, async (req: Request, res: Response) => { const { asset_name, asset_type, txid, issued_at, protocol_version } = req.body @@ -122,33 +73,46 @@ router.post('/notify', notifyLimiter, async (req: Request, res: Response) => { res.status(400).json({ error: 'asset_type must be root, sub, or unique' }) return } - // Validate txid format: must be a 64-character hex string (SHA-256 reversed) if (!txid || typeof txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(txid)) { res.status(400).json({ error: 'txid must be a valid 64-char hex transaction id' }) return } - // Verify the txid actually exists on the Ravencoin blockchain before recording it. - // This prevents the registry from being polluted with fictitious txids. - const exists = await electrumXService.txExists(txid) - if (!exists) { - res.status(422).json({ error: 'txid not found on blockchain', code: 'TX_NOT_FOUND' }) + let proof: ReturnType + try { + const rawTx = await ravencoinService.getRawTransactionVerbose(txid) + proof = validateRegistryIssuance( + rawTx, + txid, + asset_name, + asset_type as RegistryAssetType + ) + } catch (err) { + console.warn(`[Registry] Cannot obtain consensus-decoded tx ${txid}: ${(err as Error).message}`) + res.status(503).json({ error: 'Unable to verify issuance transaction', code: 'CHAIN_VERIFICATION_UNAVAILABLE' }) return } - const name = asset_name.trim().toUpperCase() - const now = issued_at || new Date().toISOString() - const version = protocol_version || 'RTP-1' + if (!proof.ok) { + res.status(422).json({ error: proof.reason, code: 'INVALID_ISSUANCE_PROOF' }) + return + } + + const name = proof.assetName + const now = typeof issued_at === 'string' && issued_at.length <= 64 + ? issued_at + : new Date().toISOString() + const version = typeof protocol_version === 'string' && protocol_version.length <= 32 + ? protocol_version + : 'RTP-1' const db = getDb() - // INSERT OR IGNORE: duplicate txid (same emission sent twice) is silently skipped db.prepare(` INSERT OR IGNORE INTO asset_emissions (asset_name, asset_type, txid, issued_at, protocol_version) VALUES (?, ?, ?, ?, ?) - `).run(name, asset_type, txid, now, version) + `).run(name, proof.assetType, txid.toLowerCase(), now, version) - // Also register the brand (root part of the asset name) if not already present - if (asset_type === 'root') { + if (proof.assetType === 'root') { ensureTable() db.prepare(` INSERT OR IGNORE INTO brand_registry (brand_name, registered_at, protocol_version) @@ -159,11 +123,6 @@ router.post('/notify', notifyLimiter, async (req: Request, res: Response) => { res.json({ success: true }) }) -/** - * DELETE /api/registry/brands/:brandName - * Remove a brand from the public registry. - * Requires admin key. Returns 404 if the brand does not exist. - */ router.delete('/brands/:brandName', requireAdminKey, (req: Request, res: Response) => { const brandName = req.params.brandName ensureTable() @@ -175,12 +134,6 @@ router.delete('/brands/:brandName', requireAdminKey, (req: Request, res: Respons res.json({ success: true, brand_name: brandName }) }) -/** - * GET /api/registry/emissions - * Public list of recorded on-chain emissions in reverse-chronological order. - * Limited to the 200 most recent to prevent excessively large responses. - * Returns asset_name, asset_type, txid, issued_at, and protocol_version for each entry. - */ router.get('/emissions', (_req: Request, res: Response) => { const emissions = getDb() .prepare('SELECT asset_name, asset_type, txid, issued_at, protocol_version FROM asset_emissions ORDER BY issued_at DESC LIMIT 200') diff --git a/backend/src/services/backup.ts b/backend/src/services/backup.ts index f07ec863..ce6f4048 100644 --- a/backend/src/services/backup.ts +++ b/backend/src/services/backup.ts @@ -1,21 +1,21 @@ /** - * SQLite backup service (backup.ts) + * Optional in-process SQLite backup scheduler. * - * Creates consistent database snapshots using better-sqlite3's .backup() API, - * which is safe under WAL mode concurrent writes. Encrypts output with openssl - * (preserving the existing encryption pattern from docker-compose.yml). - * - * Retention: keeps last 3 backups (18-hour rotating window at 6h intervals). + * The default Docker deployment uses the dedicated backup sidecar. If this + * scheduler is explicitly enabled, callers must supply a dedicated backup + * encryption-key file; the admin authentication key is never reused. */ -import { execSync } from 'child_process' +import { execFileSync } from 'child_process' import { unlinkSync, readdirSync } from 'fs' import { getDb } from '../middleware/cache.js' -const BACKUP_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours -const MAX_BACKUPS = 3 +const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 +const MAX_BACKUPS = 7 const BACKUP_DIR = process.env.BACKUP_DIR ?? '/backups' -export function startBackupScheduler(adminKeyPath = '/run/secrets/admin_key'): NodeJS.Timeout { +export function startBackupScheduler(backupEncryptionKeyPath: string): NodeJS.Timeout { + if (!backupEncryptionKeyPath) throw new Error('backup encryption key path is required') + const runBackup = () => { try { const now = new Date() @@ -23,41 +23,34 @@ export function startBackupScheduler(adminKeyPath = '/run/secrets/admin_key'): N const tmpFile = `${BACKUP_DIR}/raventag_${timestamp}.db.tmp` const encFile = `${BACKUP_DIR}/raventag_${timestamp}.db.enc` - // Step 1: Use better-sqlite3 .backup() for a consistent WAL snapshot const source = getDb() source.backup(tmpFile).then(() => { try { - // Step 2: Encrypt with openssl (same pattern as docker-compose backup) - execSync( - `openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass file:${adminKeyPath} -in ${tmpFile} -out ${encFile}`, - { timeout: 60000 } - ) + execFileSync('openssl', [ + 'enc', '-aes-256-cbc', '-pbkdf2', '-iter', '100000', + '-pass', `file:${backupEncryptionKeyPath}`, + '-in', tmpFile, + '-out', encFile + ], { timeout: 60000, stdio: 'ignore' }) - // Step 3: Remove unencrypted temp file unlinkSync(tmpFile) - - // Step 4: Prune old backups (keep last MAX_BACKUPS) const files = readdirSync(BACKUP_DIR) .filter(f => f.startsWith('raventag_') && f.endsWith('.db.enc')) .sort() while (files.length > MAX_BACKUPS) { - const oldFile = files.shift()! - unlinkSync(`${BACKUP_DIR}/${oldFile}`) + unlinkSync(`${BACKUP_DIR}/${files.shift()!}`) } - console.log(`[Backup] Created: ${encFile}`) } catch (err) { + try { unlinkSync(tmpFile) } catch { /* already removed */ } console.error('[Backup] Encrypt/prune failed:', err) } - }).catch((err: unknown) => { - console.error('[Backup] .backup() failed:', err) - }) + }).catch((err: unknown) => console.error('[Backup] .backup() failed:', err)) } catch (err) { console.error('[Backup] Failed:', err) } } - // First backup 30s after startup (let DB init complete) setTimeout(runBackup, 30000) return setInterval(runBackup, BACKUP_INTERVAL_MS) } diff --git a/backend/src/services/electrumx.ts b/backend/src/services/electrumx.ts index 3224147e..5a3c51e5 100644 --- a/backend/src/services/electrumx.ts +++ b/backend/src/services/electrumx.ts @@ -88,8 +88,14 @@ function addressToScripthash(address: string): string { // A valid P2PKH address decodes to: 1 byte version + 20 bytes hash160 + 4 bytes checksum = 25 bytes if (decoded.length !== 25) throw new Error(`Invalid address length: ${decoded.length}`) - - // Extract the 20-byte RIPEMD-160 hash (skip version byte, ignore 4-byte checksum at end) + if (decoded[0] !== 0x3c) throw new Error('Address is not Ravencoin mainnet P2PKH') + const payload = decoded.subarray(0, 21) + const expectedChecksum = createHash('sha256').update( + createHash('sha256').update(payload).digest() + ).digest().subarray(0, 4) + if (!decoded.subarray(21).equals(expectedChecksum)) throw new Error('Invalid Ravencoin address checksum') + + // Extract the 20-byte RIPEMD-160 hash const hash160 = decoded.slice(1, 21) // Build the P2PKH locking script: OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG @@ -129,6 +135,7 @@ const certCache = new Map() * correlated to their originating request on the same socket. */ let idCounter = 1 +const MAX_ELECTRUM_RESPONSE_BYTES = 1_048_576 /** * Compute the SHA-256 hex digest of a Buffer. @@ -212,6 +219,10 @@ async function callServer(server: ElectrumServer, method: string, params: unknow // ElectrumX sends newline-delimited JSON. Accumulate chunks and process // complete lines, keeping any partial final line in the buffer. buffer += chunk.toString() + if (Buffer.byteLength(buffer, 'utf8') > MAX_ELECTRUM_RESPONSE_BYTES) { + done(new Error(`Oversized ElectrumX response from ${server.host}`)) + return + } const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const line of lines) { diff --git a/backend/src/services/ipfs.ts b/backend/src/services/ipfs.ts index 59468b50..f5598b99 100644 --- a/backend/src/services/ipfs.ts +++ b/backend/src/services/ipfs.ts @@ -171,7 +171,8 @@ export async function fetchIpfsMetadata(ipfsUri: string): Promise { const response = await axios.get(url, { timeout: 15000, // 15-second read timeout headers: { Accept: 'application/json' }, - maxContentLength: 1024 * 1024 // reject responses larger than 1 MB + maxContentLength: 1024 * 1024, // reject responses larger than 1 MB + maxRedirects: 0 // do not allow redirect SSRF escapes }) return response.data } diff --git a/backend/src/services/ravencoin.ts b/backend/src/services/ravencoin.ts index ccbf5e28..6d1b318b 100644 --- a/backend/src/services/ravencoin.ts +++ b/backend/src/services/ravencoin.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance } from 'axios' import { electrumXService } from './electrumx.js' +import type { VerboseRavencoinTx } from './registry-issuance.js' export interface RaventagMetadata { raventag_version: 'RTP-1' @@ -66,7 +67,7 @@ function makeClient(baseUrl: string, user?: string, pass?: string): AxiosInstanc const token = Buffer.from(`${user}:${pass}`).toString('base64') headers['Authorization'] = `Basic ${token}` } - return axios.create({ baseURL: baseUrl, headers, timeout: 10000 }) + return axios.create({ baseURL: baseUrl, headers, timeout: 10000, maxRedirects: 0 }) } async function rpcCall(client: AxiosInstance, method: string, params: unknown[] = []): Promise { @@ -106,6 +107,12 @@ class RavencoinService { } } + /** Get a consensus-decoded transaction from Ravencoin Core. */ + async getRawTransactionVerbose(txid: string): Promise { + if (!/^[0-9a-fA-F]{64}$/.test(txid)) throw new Error('invalid txid') + return await this.call('getrawtransaction', [txid, true]) + } + /** * Get raw asset data from Ravencoin node. */ diff --git a/backend/src/services/registry-issuance.test.ts b/backend/src/services/registry-issuance.test.ts new file mode 100644 index 00000000..4e089f97 --- /dev/null +++ b/backend/src/services/registry-issuance.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { validateRegistryIssuance, type VerboseRavencoinTx } from './registry-issuance.js' + +function baseRootTx(): VerboseRavencoinTx { + return { + txid: 'aa'.repeat(32), + vout: [ + { + value: 500, + scriptPubKey: { + type: 'pubkeyhash', + addresses: ['RXissueAssetXXXXXXXXXXXXXXXXXhhZGt'] + } + }, + { + value: 0, + scriptPubKey: { + type: 'new_asset', + asset: { name: 'RAVENTAG', amount: 100, units: 0, reissuable: true } + } + }, + { + value: 0, + scriptPubKey: { + type: 'new_asset', + asset: { name: 'RAVENTAG!', amount: 1 } + } + } + ] + } +} + +test('accepts a canonical root issuance claim', () => { + const tx = baseRootTx() + const r = validateRegistryIssuance(tx, tx.txid!, 'RAVENTAG', 'root') + assert.equal(r.ok, true) +}) + +test('rejects an unrelated valid transaction id / ordinary payment', () => { + const tx: VerboseRavencoinTx = { + txid: 'bb'.repeat(32), + vout: [{ value: 1, scriptPubKey: { type: 'pubkeyhash', addresses: ['R9dummy'] } }] + } + assert.equal(validateRegistryIssuance(tx, tx.txid!, 'RAVENTAG', 'root').ok, false) +}) + +test('rejects asset-name mismatch', () => { + const tx = baseRootTx() + assert.equal(validateRegistryIssuance(tx, tx.txid!, 'OTHER', 'root').ok, false) +}) + +test('rejects type mismatch', () => { + const tx = baseRootTx() + assert.equal(validateRegistryIssuance(tx, tx.txid!, 'RAVENTAG', 'sub').ok, false) +}) + +test('rejects missing canonical burn', () => { + const tx = baseRootTx() + tx.vout = tx.vout!.filter(v => !v.scriptPubKey?.addresses?.includes('RXissueAssetXXXXXXXXXXXXXXXXXhhZGt')) + assert.equal(validateRegistryIssuance(tx, tx.txid!, 'RAVENTAG', 'root').ok, false) +}) + +test('rejects root issuance missing owner token', () => { + const tx = baseRootTx() + tx.vout = tx.vout!.filter(v => v.scriptPubKey?.asset?.name !== 'RAVENTAG!') + assert.equal(validateRegistryIssuance(tx, tx.txid!, 'RAVENTAG', 'root').ok, false) +}) + +test('accepts unique issuance without creating a unique owner token', () => { + const tx: VerboseRavencoinTx = { + txid: 'cc'.repeat(32), + vout: [ + { value: 5, scriptPubKey: { type: 'pubkeyhash', addresses: ['RXissueUniqueAssetXXXXXXXXXXWEAe58'] } }, + { value: 0, scriptPubKey: { type: 'new_asset', asset: { name: 'RAVENTAG/ITEM#001', amount: 1, units: 0, reissuable: false } } } + ] + } + assert.equal(validateRegistryIssuance(tx, tx.txid!, 'RAVENTAG/ITEM#001', 'unique').ok, true) +}) diff --git a/backend/src/services/registry-issuance.ts b/backend/src/services/registry-issuance.ts new file mode 100644 index 00000000..d361d7db --- /dev/null +++ b/backend/src/services/registry-issuance.ts @@ -0,0 +1,103 @@ +export type RegistryAssetType = 'root' | 'sub' | 'unique' + +export interface VerboseTxOutput { + value?: number + scriptPubKey?: { + type?: string + addresses?: string[] + address?: string + asset?: { + name?: string + amount?: number + units?: number + reissuable?: boolean + ipfs_hash?: string + } + } +} + +export interface VerboseRavencoinTx { + txid?: string + vout?: VerboseTxOutput[] +} + +const BURN_POLICY: Record = { + root: { address: 'RXissueAssetXXXXXXXXXXXXXXXXXhhZGt', satoshis: 50_000_000_000 }, + sub: { address: 'RXissueSubAssetXXXXXXXXXXXXXWcwhwL', satoshis: 10_000_000_000 }, + unique: { address: 'RXissueUniqueAssetXXXXXXXXXXWEAe58', satoshis: 500_000_000 } +} + +function classifyAssetName(name: string): RegistryAssetType | null { + if (!/^[A-Z0-9._/#]{2,30}$/.test(name)) return null + if (name.includes('#')) return 'unique' + if (name.includes('/')) return 'sub' + return 'root' +} + +function outputAddresses(out: VerboseTxOutput): string[] { + const script = out.scriptPubKey + if (!script) return [] + if (Array.isArray(script.addresses)) return script.addresses.filter((v): v is string => typeof v === 'string') + return typeof script.address === 'string' ? [script.address] : [] +} + +function valueToSatoshis(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null + const sat = Math.round(value * 100_000_000) + if (!Number.isSafeInteger(sat) || Math.abs(value * 100_000_000 - sat) > 0.0001) return null + return sat +} + +/** + * Validate a public registry notification against Ravencoin Core's verbose + * getrawtransaction representation. Raven Core itself decodes asset scripts into + * scriptPubKey.type + scriptPubKey.asset, so this code deliberately reuses that + * consensus-aware decoder instead of duplicating binary asset parsing here. + */ +export function validateRegistryIssuance( + tx: VerboseRavencoinTx, + claimedTxid: string, + claimedAssetName: string, + claimedAssetType: RegistryAssetType +): { ok: true; assetName: string; assetType: RegistryAssetType } | { ok: false; reason: string } { + const assetName = claimedAssetName.trim().toUpperCase() + const derivedType = classifyAssetName(assetName) + if (!derivedType) return { ok: false, reason: 'invalid asset name' } + if (derivedType !== claimedAssetType) return { ok: false, reason: 'asset type does not match asset name' } + if (typeof tx.txid === 'string' && tx.txid.toLowerCase() !== claimedTxid.toLowerCase()) { + return { ok: false, reason: 'transaction id mismatch' } + } + + const outputs = Array.isArray(tx.vout) ? tx.vout : [] + const issuanceOutputs = outputs.filter(out => { + const script = out.scriptPubKey + return script?.type === 'new_asset' && script.asset?.name === assetName + }) + if (issuanceOutputs.length !== 1) { + return { ok: false, reason: 'claimed new_asset issuance output not found exactly once' } + } + + // Owner tokens are created by consensus for root/sub issuance. Unique tokens do + // not create a new owner token for the unique name itself. + if (claimedAssetType !== 'unique') { + const ownerName = `${assetName}!` + const ownerOutputs = outputs.filter(out => + out.scriptPubKey?.type === 'new_asset' && + out.scriptPubKey?.asset?.name === ownerName && + out.scriptPubKey?.asset?.amount === 1 + ) + if (ownerOutputs.length !== 1) { + return { ok: false, reason: 'required owner-token issuance output missing' } + } + } + + const burn = BURN_POLICY[claimedAssetType] + const matchingBurns = outputs.filter(out => + valueToSatoshis(out.value) === burn.satoshis && outputAddresses(out).includes(burn.address) + ) + if (matchingBurns.length !== 1) { + return { ok: false, reason: 'canonical issuance burn output missing or duplicated' } + } + + return { ok: true, assetName, assetType: claimedAssetType } +} diff --git a/docker-compose.yml b/docker-compose.yml index 5bbec574..3d0a663d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,9 +41,9 @@ services: timeout: 10s retries: 3 - # SQLite backup: runs daily, keeps last 7 backups (MED-1: AES-256-CBC encrypted) + # SQLite backup: runs every 24 hours, keeps the 7 newest successful encrypted backups. # To decrypt: openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 \ - # -pass file:/path/to/admin_key -in raventag_TIMESTAMP.db.enc -out raventag.db + # -pass file:/path/to/backup_encryption_key -in raventag_TIMESTAMP.db.enc -out raventag.db backup: image: alpine:3.19 container_name: raventag-backup @@ -52,20 +52,20 @@ services: - raventag_data:/data:ro - raventag_backups:/backups secrets: - - admin_key + - backup_encryption_key command: > sh -c "apk add --no-cache openssl sqlite > /dev/null 2>&1; while true; do TIMESTAMP=$$(date +%Y%m%d_%H%M%S); sqlite3 /data/raventag.db \".backup /tmp/raventag_snap.db\"; openssl enc -aes-256-cbc -pbkdf2 -iter 100000 \ - -pass file:/run/secrets/admin_key \ + -pass file:/run/secrets/backup_encryption_key \ -in /tmp/raventag_snap.db \ -out /backups/raventag_$${TIMESTAMP}.db.enc 2>/dev/null \ && echo \"[Backup] raventag_$${TIMESTAMP}.db.enc (encrypted)\"; rm -f /tmp/raventag_snap.db; - ls -t /backups/raventag_*.db.enc 2>/dev/null | tail -n +4 | xargs rm -f; - sleep 21600; + ls -t /backups/raventag_*.db.enc 2>/dev/null | tail -n +8 | xargs rm -f; + sleep 86400; done" depends_on: backend: @@ -74,6 +74,8 @@ services: # Docker secrets: in compose dev mode, create ./secrets/ files with the secret value. # In Swarm mode, create secrets with: docker secret create - <<< "value" secrets: + backup_encryption_key: + file: ${BACKUP_ENCRYPTION_KEY_FILE:-./secrets/backup_encryption_key.txt} admin_key: file: ./secrets/admin_key operator_key: diff --git a/docs/deploy/en.md b/docs/deploy/en.md index f13a750e..df271c35 100644 --- a/docs/deploy/en.md +++ b/docs/deploy/en.md @@ -81,6 +81,9 @@ openssl rand -hex 16 > secrets/brand_master_key # Brand salt (for nfc_pub_id computation) openssl rand -hex 16 > secrets/brand_salt + +# Backup encryption key — MUST be independent from admin/operator/brand keys +openssl rand -hex 32 > secrets/backup_encryption_key.txt ``` **Important:** Each file should contain ONLY the hex string, no newline. The commands above handle this correctly. @@ -156,10 +159,15 @@ EOF ## Step 7: Start the backend ```bash -docker compose up -d backend +docker compose up -d docker compose logs backend +docker compose logs backup ``` +The `backup` sidecar snapshots SQLite every **24 hours**, encrypts each snapshot with +`secrets/backup_encryption_key.txt`, and retains the **7 newest** encrypted backups. +Never reuse `secrets/admin_key` (or any authentication credential) as the backup key. + Verify it is running: ```bash