fix: clone public identity fields before cross-frame signer handoff
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
# IndeeHub native signer follow-up
|
||||||
|
|
||||||
|
## Reproduced on Yaya
|
||||||
|
|
||||||
|
The installed app and dashboard have the same provider SHA256
|
||||||
|
`529fe82e7c16b51c62678427f565048c3dd93ca28320bd8494c17236ff57bb81`.
|
||||||
|
A clean mobile Chromium session opens the identity picker. After selecting the
|
||||||
|
existing profile and Authenticate, the picker disappears but the broker stays
|
||||||
|
full-screen (`aria-hidden=false`, 390 by 844). Its document has no visible text
|
||||||
|
or buttons, and the app still shows Sign In. The trace contains signer-ready and
|
||||||
|
signer-show but no signer-identity or signer-hide through 18 seconds.
|
||||||
|
|
||||||
|
The picker emits an entry from a Vue reactive array. NostrTabSigner passes that
|
||||||
|
proxy object directly to cross-frame postMessage after hiding the picker.
|
||||||
|
Structured cloning rejects reactive proxies, interrupting the handoff before
|
||||||
|
it schedules the broker hide. Reload uses the already-serialized identity from
|
||||||
|
localStorage, explaining why refresh can appear to repair the problem.
|
||||||
|
|
||||||
|
## Candidate fix
|
||||||
|
|
||||||
|
Send an explicit plain object containing only the selected public identity
|
||||||
|
fields. The private signer remains on the node; unrelated metadata is excluded.
|
||||||
|
Consent behavior and the existing green completion animation are unchanged.
|
||||||
|
|
||||||
|
A regression uses a real Vue reactive identity and structuredClone, verifies
|
||||||
|
that the proxy itself is rejected, the public handoff is cloneable, metadata is
|
||||||
|
excluded, and the overlay closes. Eleven focused signer/provider tests pass,
|
||||||
|
and frontend typecheck passes. A browser qualification using candidate dashboard
|
||||||
|
assets with the actual Yaya app/auth backend is in progress. No deployment or
|
||||||
|
actual companion acceptance is claimed yet.
|
||||||
|
|
||||||
|
## App source and further review
|
||||||
|
|
||||||
|
The canonical app is the Vite/Vue source in the separate IndeeHub repository,
|
||||||
|
not the obsolete Next.js Dockerfile under apps/indeedhub. Its existing local
|
||||||
|
nginx edit and untracked workflow documentation were preserved; new app work
|
||||||
|
uses a separate worktree.
|
||||||
|
|
||||||
|
Review found additional app-side concerns to test: production network failures
|
||||||
|
can flip the app into mock mode and fabricate subscribed users, and the header
|
||||||
|
can discard a valid backend Nostr session when the local signer account has not
|
||||||
|
been restored. Do not claim these repaired by the broker object-cloning fix.
|
||||||
@@ -101,3 +101,23 @@ nodes before further restarts: only Shorty had an affected message store at the
|
|||||||
expected paths; its bytes were verified after backup. No message contents or
|
expected paths; its bytes were verified after backup. No message contents or
|
||||||
wallet data were exported. Actual candidate build/deployment and store reload
|
wallet data were exported. Actual candidate build/deployment and store reload
|
||||||
acceptance still remain; do not equate source-test success with delivery.
|
acceptance still remain; do not equate source-test success with delivery.
|
||||||
|
|
||||||
|
## Production storage candidate qualification
|
||||||
|
|
||||||
|
The cfd9a596 production binary (SHA256
|
||||||
|
3d720c6ddb2622ddef956b5ef0d54ecef64617e4bd16d07327b55ac737d00fe7)
|
||||||
|
was exercised in the disposable installed-system VM. Independent Python
|
||||||
|
ChaCha20-Poly1305 fixtures used the guest's key locally, without exporting it.
|
||||||
|
Encrypted nonces starting with `{` and `[` loaded through the real message RPC,
|
||||||
|
retained their exact ciphertext, and survived a second service restart. Legacy
|
||||||
|
JSON with leading whitespace migrated to authenticated ciphertext and survived
|
||||||
|
another restart with all message fields intact.
|
||||||
|
|
||||||
|
Fixture corrections were required: the first root-owned 0600 file was unreadable
|
||||||
|
by the service; the next legacy assertion omitted optional fields that serde
|
||||||
|
normally emits as null. Both initial failures remain in the qualification logs.
|
||||||
|
The corrected run passed all three data cases. SSH disconnected during final
|
||||||
|
cleanup, so restoration was completed as a guest systemd job and independently
|
||||||
|
checked: original 560aa600 backend, active service, and original absent message
|
||||||
|
store restored. The VM was then shut down. This is not live-fleet deployment or
|
||||||
|
proof of every corrupt-store recovery path.
|
||||||
|
|||||||
@@ -74,7 +74,19 @@ function hideSigner(delay = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendIdentity(identity: SelectedIdentity) {
|
function sendIdentity(identity: SelectedIdentity) {
|
||||||
parentPost({ type: 'archipelago:signer-identity', identity })
|
// The picker emits a Vue reactive object. Browser postMessage cannot clone
|
||||||
|
// that proxy: sending it closes the picker, then throws before the broker
|
||||||
|
// can hide, leaving a blank full-screen surface. Send public scalar fields
|
||||||
|
// explicitly, also keeping any unrelated identity metadata out of the app.
|
||||||
|
const publicIdentity: SelectedIdentity = {
|
||||||
|
id: identity.id,
|
||||||
|
name: identity.name,
|
||||||
|
did: identity.did,
|
||||||
|
pubkey: identity.pubkey,
|
||||||
|
nostr_pubkey: identity.nostr_pubkey,
|
||||||
|
nostr_npub: identity.nostr_npub,
|
||||||
|
}
|
||||||
|
parentPost({ type: 'archipelago:signer-identity', identity: publicIdentity })
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridge = useNostrBridge(getStoredIdentity, {
|
const bridge = useNostrBridge(getStoredIdentity, {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { shallowMount } from '@vue/test-utils'
|
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||||
import NostrTabSigner from '@/views/NostrTabSigner.vue'
|
import NostrTabSigner from '@/views/NostrTabSigner.vue'
|
||||||
|
|
||||||
describe('NostrTabSigner visibility', () => {
|
describe('NostrTabSigner visibility', () => {
|
||||||
@@ -8,7 +10,7 @@ describe('NostrTabSigner visibility', () => {
|
|||||||
window.history.replaceState({}, '', '/nostr-signer')
|
window.history.replaceState({}, '', '/nostr-signer')
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks())
|
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
|
||||||
|
|
||||||
function parentMessage(data: Record<string, unknown>) {
|
function parentMessage(data: Record<string, unknown>) {
|
||||||
const event = new MessageEvent('message', { data, origin: window.location.origin })
|
const event = new MessageEvent('message', { data, origin: window.location.origin })
|
||||||
@@ -46,4 +48,33 @@ describe('NostrTabSigner visibility', () => {
|
|||||||
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false)
|
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false)
|
||||||
expect(document.body.classList.contains('nostr-signer-route')).toBe(false)
|
expect(document.body.classList.contains('nostr-signer-route')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hands off a reactive picker identity as cloneable public fields and releases the overlay', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const sent: Array<Record<string, unknown>> = []
|
||||||
|
// Real cross-frame postMessage performs structured cloning. A normal spy
|
||||||
|
// would miss the browser-only DataCloneError that leaves a grey overlay.
|
||||||
|
vi.spyOn(window.parent, 'postMessage').mockImplementation((message) => {
|
||||||
|
sent.push(structuredClone(message))
|
||||||
|
})
|
||||||
|
const wrapper = shallowMount(NostrTabSigner)
|
||||||
|
parentMessage({type: 'archipelago:signer-init', appId: 'indeedhub', appName: 'IndeeHub'})
|
||||||
|
const selected = reactive({
|
||||||
|
id: 'identity-a', name: 'Alice', did: 'did:key:test', pubkey: 'public-node-key',
|
||||||
|
nostr_pubkey: 'a'.repeat(64), nostr_npub: 'npub-test', internal_metadata: 'must-stay-local',
|
||||||
|
})
|
||||||
|
expect(() => structuredClone(selected)).toThrow()
|
||||||
|
wrapper.findComponent(NostrIdentityPicker).vm.$emit('select', selected)
|
||||||
|
await flushPromises()
|
||||||
|
const identityMessage = sent.find(message => message.type === 'archipelago:signer-identity')
|
||||||
|
expect(identityMessage?.identity).toEqual({
|
||||||
|
id: 'identity-a', name: 'Alice', did: 'did:key:test', pubkey: 'public-node-key',
|
||||||
|
nostr_pubkey: 'a'.repeat(64), nostr_npub: 'npub-test',
|
||||||
|
})
|
||||||
|
await vi.advanceTimersByTimeAsync(400)
|
||||||
|
expect(sent).toContainEqual({type: 'archipelago:signer-hide'})
|
||||||
|
expect(wrapper.findComponent(NostrIdentityPicker).props('show')).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user