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