Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased (develop)

- added: App/device attestation for gated info-server requests
- added: CTX spend-api pubkey auth prototype, with the anonymous session readable from the gift card account info scene
- added: "-m" tag on the version number in the Help scene for Maestro test builds
- changed: Target Android 16 (API level 36), which Google Play requires for app updates submitted after Aug 30, 2026. Predictive back is opted out of for now, since React Native 0.79 cannot handle it, so the back button behaves exactly as it did before.
- changed: Style the entire "Already have an account? Sign in" line in the getting-started USP carousel with the tertiary link color, not just "Sign in".
Expand Down
118 changes: 118 additions & 0 deletions src/__tests__/ctxSpendCrypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, test } from '@jest/globals'

import {
bytesToHex,
getJwtExpiryMs,
getPublicKeyHex,
hexToBytes,
isValidPrivateKey,
makeLoginNonceHash,
recoverLoginPublicKeyHex,
signLoginNonce,
uint64BE
} from '../plugins/gift-cards/ctxSpendCrypto'

// A fixed key, so every expectation below is a reproducible vector rather than
// a property of whatever key the run happened to draw.
const PRIVATE_KEY_HEX =
'0000000000000000000000000000000000000000000000000000000000000001'
const privateKey = hexToBytes(PRIVATE_KEY_HEX)

describe('uint64BE', () => {
test('encodes big-endian across byte boundaries', () => {
expect(bytesToHex(uint64BE(0))).toBe('0000000000000000')
expect(bytesToHex(uint64BE(1))).toBe('0000000000000001')
expect(bytesToHex(uint64BE(255))).toBe('00000000000000ff')
expect(bytesToHex(uint64BE(256))).toBe('0000000000000100')
expect(bytesToHex(uint64BE(4294967296))).toBe('0000000100000000')
// Number.MAX_SAFE_INTEGER, the top of the exact-integer range.
expect(bytesToHex(uint64BE(9007199254740991))).toBe('001fffffffffffff')
})

test('rejects values that cannot be represented exactly', () => {
expect(() => uint64BE(-1)).toThrow()
expect(() => uint64BE(1.5)).toThrow()
expect(() => uint64BE(Number.MAX_SAFE_INTEGER + 2)).toThrow()
})
})

describe('makeLoginNonceHash', () => {
test('matches sha256 of the big-endian nonce', () => {
// sha256(0000000000000002), independently computable from the spec.
expect(bytesToHex(makeLoginNonceHash(2))).toBe(
'cd04a4754498e06db5a13c5f371f1f04ff6d2470f24aa9bd886540e5dce77f70'
)
})
})

describe('signLoginNonce', () => {
test('produces the 65-byte recoverable encoding the server expects', () => {
const signature = hexToBytes(signLoginNonce(privateKey, 2))
expect(signature.length).toBe(65)
// [27 + recoveryId + 4], where +4 marks a compressed public key.
expect(signature[0]).toBeGreaterThanOrEqual(31)
expect(signature[0]).toBeLessThanOrEqual(34)
})

test('is deterministic for a given key and nonce', () => {
expect(signLoginNonce(privateKey, 2)).toBe(signLoginNonce(privateKey, 2))
})

test('signs a different nonce differently', () => {
expect(signLoginNonce(privateKey, 2)).not.toBe(
signLoginNonce(privateKey, 3)
)
})

test('recovers the signing public key, which is how the server authenticates', () => {
const publicKeyHex = getPublicKeyHex(privateKey)
for (const nonce of [1, 2, 42, 65536]) {
const signature = signLoginNonce(privateKey, nonce)
expect(recoverLoginPublicKeyHex(signature, nonce)).toBe(publicKeyHex)
}
})

test('does not recover the signing key against the wrong nonce', () => {
const signature = signLoginNonce(privateKey, 2)
expect(recoverLoginPublicKeyHex(signature, 3)).not.toBe(
getPublicKeyHex(privateKey)
)
})
})

describe('recoverLoginPublicKeyHex', () => {
test('rejects a signature of the wrong length', () => {
expect(() => recoverLoginPublicKeyHex('00'.repeat(64), 1)).toThrow()
})

test('rejects an out-of-range header byte', () => {
const signature = hexToBytes(signLoginNonce(privateKey, 2))
signature[0] = 99
expect(() => recoverLoginPublicKeyHex(bytesToHex(signature), 2)).toThrow()
})
})

describe('getJwtExpiryMs', () => {
test('reads exp out of an unpadded base64url payload', () => {
// {"exp":1786421416} — the base64url payload is unpadded, as JWTs are.
const token = `header.eyJleHAiOjE3ODY0MjE0MTZ9.signature`
expect(getJwtExpiryMs(token)).toBe(1786421416000)
})

test('returns undefined for malformed tokens, so they read as expired', () => {
expect(getJwtExpiryMs('not-a-jwt')).toBeUndefined()
expect(getJwtExpiryMs('a.b.c')).toBeUndefined()
// Valid base64url JSON, but no exp claim.
expect(
getJwtExpiryMs('header.eyJmb28iOiJiYXIifQ.signature')
).toBeUndefined()
})
})

describe('isValidPrivateKey', () => {
test('accepts a valid scalar and rejects degenerate ones', () => {
expect(isValidPrivateKey(privateKey)).toBe(true)
expect(isValidPrivateKey(new Uint8Array(32))).toBe(false)
expect(isValidPrivateKey(new Uint8Array(31))).toBe(false)
})
})
151 changes: 150 additions & 1 deletion src/components/scenes/GiftCardAccountInfoScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { ENV } from '../../env'
import { useGiftCardProvider } from '../../hooks/useGiftCardProvider'
import { useHandler } from '../../hooks/useHandler'
import { lstrings } from '../../locales/strings'
import { makeCtxSpendApi } from '../../plugins/gift-cards/ctxSpendApi'
import type { CtxSpendAuthContext } from '../../plugins/gift-cards/ctxSpendTypes'
import { useSelector } from '../../types/reactRedux'
import type { EdgeAppSceneProps } from '../../types/routerTypes'
import { SceneButtons } from '../buttons/SceneButtons'
Expand All @@ -22,6 +24,19 @@ export interface GiftCardAccountInfoParams {
quoteId?: string
}

/**
* Outcome of a CTX spend-api session attempt. `isSupported: false` means the
* account cannot hold a signing key, which is a different thing from an error.
*/
type CtxSpendStatus =
| { isSupported: false }
| {
isSupported: true
publicKeyHex: string | undefined
authContext: CtxSpendAuthContext
merchantCount: number
}

/**
* Displays Phaze gift card account credentials behind a confirmation wall.
* Accessible from the kebab menu (with quoteId context) or developer settings.
Expand Down Expand Up @@ -61,6 +76,68 @@ export const GiftCardAccountInfoScene: React.FC<
if (error != null) showError(error)
}, [error])

// ---------------------------------------------------------------------------
// CTX Spend prototype
// ---------------------------------------------------------------------------

const ctxSpendConfig = ENV.PLUGIN_API_KEYS?.ctxSpend
const [isCtxRequested, setIsCtxRequested] = React.useState(false)

const {
data: ctxStatus,
error: ctxError,
isFetching: isCtxFetching,
refetch: refetchCtxStatus
} = useQuery({
queryKey: ['ctxSpendStatus', account.id],
queryFn: async (): Promise<CtxSpendStatus> => {
if (ctxSpendConfig == null) throw new Error('CTX Spend is not configured')
const api = makeCtxSpendApi({
clientId: ctxSpendConfig.clientId,
baseUrl: ctxSpendConfig.baseUrl
})
// A light account has nowhere to persist the signing key, so the
// identity step is what gates the feature, not the network. Anything
// else that goes wrong throws and surfaces through the query error.
if ((await api.ensureIdentity(account)) === 'light-account') {
return { isSupported: false }
}

const authContext = await api.getMe()
const merchants = await api.getMerchants()
return {
isSupported: true,
publicKeyHex: api.getPublicKeyHex(),
authContext,
merchantCount: merchants.pagination.total
}
},
enabled: isCtxRequested && ctxSpendConfig != null,
staleTime: 60000,
// The app-wide default is `retry: 2`, which would turn one failed connect
// into three full login handshakes against a rate-limited API. Retrying is
// the Connect button's job, where the user decides when.
retry: false
})
Comment thread
j0ntz marked this conversation as resolved.

React.useEffect(() => {
if (ctxError != null) showError(ctxError)
}, [ctxError])

const handleCtxConnect = useHandler(() => {
// The button is disabled while fetching, so this cannot stack sessions.
if (isCtxFetching) return
// Already requested means a previous attempt resolved or failed, and
// flipping the flag again would not re-run the query, so retry explicitly.
if (isCtxRequested) {
refetchCtxStatus().catch((err: unknown) => {
showError(err)
})
return
}
setIsCtxRequested(true)
})
Comment thread
cursor[bot] marked this conversation as resolved.

const handleReveal = useHandler(async () => {
const confirmed = await Airship.show<boolean>(bridge => (
<ConfirmContinueModal
Expand Down Expand Up @@ -107,7 +184,7 @@ export const GiftCardAccountInfoScene: React.FC<
})

return (
<SceneWrapper scroll={isRevealed}>
<SceneWrapper scroll>
<View style={styles.container}>
<Paragraph>{lstrings.gift_card_account_info_body}</Paragraph>

Expand All @@ -129,6 +206,12 @@ export const GiftCardAccountInfoScene: React.FC<
</EdgeCard>
)}

<CtxSpendSection
isConfigured={ctxSpendConfig != null}
isFetching={isCtxFetching}
status={ctxStatus}
/>

<SceneButtons
primary={
isRevealed
Expand All @@ -141,12 +224,78 @@ export const GiftCardAccountInfoScene: React.FC<
onPress: handleReveal
}
}
secondary={{
label: isCtxFetching
? lstrings.ctx_spend_connecting
: lstrings.ctx_spend_connect_button,
onPress: handleCtxConnect,
// Each run builds a fresh session and repeats the full login, so
// stacked taps would burn CTX's rate limit for no benefit.
disabled: isCtxFetching,
spinner: isCtxFetching
}}
Comment thread
j0ntz marked this conversation as resolved.
/>
</View>
</SceneWrapper>
)
}

interface CtxSpendSectionProps {
isConfigured: boolean
isFetching: boolean
status: CtxSpendStatus | undefined
}

/**
* Prototype readout for the CTX spend-api pubkey session: proves the app can
* establish an anonymous keypair identity and read authenticated data.
*/
const CtxSpendSection: React.FC<CtxSpendSectionProps> = props => {
const { isConfigured, isFetching, status } = props

if (!isConfigured) {
return <Paragraph>{lstrings.ctx_spend_not_configured}</Paragraph>
}
if (status == null) {
return isFetching ? (
<Paragraph>{lstrings.ctx_spend_connecting}</Paragraph>
) : null
}
if (!status.isSupported) {
return <Paragraph>{lstrings.ctx_spend_unavailable_light_account}</Paragraph>
}

const { authContext, merchantCount, publicKeyHex } = status
return (
<EdgeCard sections>
<EdgeRow
title={lstrings.ctx_spend_section_title}
body={authContext.client?.name ?? ''}
/>
{publicKeyHex != null && (
<EdgeRow title={lstrings.ctx_spend_public_key} body={publicKeyHex} />
)}
<EdgeRow title={lstrings.ctx_spend_user_id} body={authContext.user.id} />
<EdgeRow
title={lstrings.ctx_spend_user_name}
body={authContext.user.name}
/>
<EdgeRow
title={lstrings.ctx_spend_company}
body={authContext.company.name}
/>
<EdgeRow
title={lstrings.ctx_spend_permission_count}
body={String(authContext.permissions.length)}
/>
<EdgeRow
title={lstrings.ctx_spend_merchant_count}
body={String(merchantCount)}
/>
</EdgeCard>
)
}

const getStyles = cacheStyles((theme: Theme) => ({
container: {
padding: theme.rem(0.5)
Expand Down
Loading
Loading