Skip to content
Merged
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
## 0.8.0 (2026-05-21)

### 🚀 Features

- **host-papp:** multi-device SSO. `createAuth` / `pappAdapter.sso` keeps the V1 `pairingStatus` / `authenticate()` / `abortAuthentication()` surface — same state transitions, `authenticate()` still resolves to a `StoredUserSession | null`, `pairingStatus.finished` still carries `session`. What's new is the protocol underneath: pairing now runs the V2 multi-device handshake (`VersionedHandshakeProposal::V2` QR proposal, ECDH-encrypted Statement Store response envelope, on-chain `Pending(AllowanceAllocation)` flow). Desktop and web hosts pair with the multi-device iOS/Android Polkadot Mobile builds through the same entry point you've been calling. The V2 codecs, pairing service, and state machine are SDK internals.
- **host-papp:** the SDK persists the V2 device identity itself. `createPappAdapter` creates and persists a fresh device identity to the configured `StorageAdapter` on first run and reuses it on subsequent launches — no consumer wiring needed. Hosts that want a different persistence backend (Electron Keychain, native secure storage) can override with an optional `deviceIdentity` factory.
- **host-papp:** the SDK persists pairing-topic statement dedupe state internally too, so stale `Success` statements on chain don't get replayed across launches without consumer plumbing.
- **host-papp:** `StoredUserSession` gains three optional V2 fields — `identityAccountId` (user identity sr25519 account), `identityChatPublicKey` (P-256 65-byte, derived locally from `identityChatPrivateKey`), and the peer device statement account exposed via `remoteAccount.accountId` — so device-sync and the chat layer can read peer state straight off the session. The matching `identityChatPrivateKey` is persisted into `UserSecretRepository` and passed to the optional `onAuthSuccess` hook for consumers that need it.
- **host-papp:** new optional `onAuthSuccess` hook on `createPappAdapter` for consumer-specific post-pairing work (telemetry, custom peer caches, device-sync seeding). Fires after the SDK has written the session + secrets to its own repositories. Receives `{ session: StoredUserSession, identityChatPrivateKey: Uint8Array }`.
- **host-chat:** new `MessageContent` variants at the spec'd indices — `chatAccepted` (14) now carries `{ messageId }` for iOS V1 backward decode; `deviceAdded` (17) `{ statementAccountId, encryptionPublicKey }`; `deviceRemoved` (18) `{ statementAccountId }`; `deviceChatAccepted` (20) `{ requestId, device }` sent on the identity-level session `SessionId(B, A)` encrypted with `K(A, B)` so all of a peer's devices can decrypt without a per-device envelope.

### 🩹 Fixes

- **host-papp / host-chat:** `identity/rpcAdapter` and `accountService.getConsumerInfo` tolerate both camelCase and snake_case `Resources.Consumers` metadata fields — the V2 multi-device runtime metadata emits camelCase, which previously crashed `raw.stmt_store_slots.map(...)`.
- **host-chat:** `DeviceAdded` / `DeviceRemoved` use length-prefixed `Bytes()` rather than fixed-size codecs, matching `substrate-sdk-android`'s wire shape for `AccountId` / `EncodedPublicKey` wrapper types (no `@FixedLength`).

### ⚠️ Breaking Changes

The migration is essentially two field renames; the auth surface is otherwise unchanged.

- **host-papp:** `createPappAdapter` no longer accepts `metadata: string` (the V1 metadata URL) — host name / icon / platform now ride inside `hostMetadata` (sent inline with the V2 QR proposal).
- **host-papp:** `HostMetadata` reshape — was `{ hostVersion?, osType?, osVersion? }`, now `{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }`. Map `osType → platformType` and `osVersion → platformVersion` when upgrading.
- **host-papp:** the V1 SSO handshake is gone. Paired clients on the V1 wire format will not pair against this build — both ends must run the multi-device V2 handshake. (Polkadot Mobile builds with multi-device support are V2.) Persisted V1 SSO sessions don't migrate; they decode short against the extended V2 codec and are wiped on first read, so users need to re-pair.
- **host-chat:** `MessageContent.chatAccepted` (14) payload changed from `_void` to `{ messageId: string }`. Older clients that emit `_void` will not decode.

### ❤️ Thank You

- Ilya Kalinin @kalininilya

## 0.7.9 (2026-05-15)

### 🚀 Features
Expand Down
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions packages/host-chat/src/accountService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,16 @@ export const createAccountService = (network: Network, lazyClient: LazyClient):

const consumerInfo = fromPromise(api.query.Resources?.Consumers?.getValue(address), toError);

return consumerInfo.map<Identity | null>(raw => {
if (!raw) return null;
return consumerInfo.map<Identity | null>(typedRaw => {
if (!typedRaw) return null;

// Runtime metadata may expose fields in snake_case (V1) or
// camelCase (V2 multi-device). Read defensively.
const raw = typedRaw as unknown as Record<string, unknown> & typeof typedRaw;
const fullUsername =
(raw.full_username as Uint8Array | undefined) ?? (raw.fullUsername as Uint8Array | undefined);
const liteUsername =
(raw.lite_username as Uint8Array | undefined) ?? (raw.liteUsername as Uint8Array | undefined);

const credibility: Credibility =
raw.credibility.type === 'Lite'
Expand All @@ -104,13 +112,14 @@ export const createAccountService = (network: Network, lazyClient: LazyClient):
: {
type: 'Person',
alias: raw.credibility.value.alias as HexString,
lastUpdate: raw.credibility.value.last_update.toString(),
lastUpdate: ((raw.credibility.value as Record<string, unknown>).last_update ??
(raw.credibility.value as Record<string, unknown>).lastUpdate)!.toString(),
};

return {
accountId: toHex(accountId.enc(address)),
fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null,
liteUsername: textDecoder.decode(raw.lite_username),
fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null,
liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '',
credibility: credibility,
};
});
Expand Down
54 changes: 52 additions & 2 deletions packages/host-chat/src/codec/message.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Enum, Hex, Nullable, Status } from '@novasamatech/scale';
import { Option, Struct, Vector, _void, compact, str, u64 } from 'scale-ts';
import { Bytes, Option, Struct, Vector, _void, compact, str, u64 } from 'scale-ts';

import { FileVariant } from './attachment.js';

const AccountIdCodec = Bytes(32);
const PublicKeyCodec = Bytes(65);

export const TextContent = str;

export const RichTextContent = Struct({
Expand Down Expand Up @@ -39,6 +42,48 @@ export const EditContent = Struct({
newContent: RichTextContent,
});

// V2 multi-device roster mutations carried as chat-content variants.
// Android's `ChatMessageStatementContent.DeviceAdded`/`DeviceRemoved` use
// the `AccountId` / `EncodedPublicKey` wrapper types without `@FixedLength`,
// so substrate-sdk-android falls through to length-prefixed `Vec<u8>` on the
// wire. We accept that shape here (`Bytes()` = compact-length-prefixed bytes)
// instead of fixed-size to stay tolerant of Android's emitter. Other variants
// that use `@FixedLength` on raw `ByteArray` (e.g. `DeviceInfoContent` below)
// stay fixed-size and continue to use AccountIdCodec/PublicKeyCodec.
export const DeviceAddedContent = Struct({
statementAccountId: Bytes(),
encryptionPublicKey: Bytes(),
});

export const DeviceRemovedContent = Struct({
statementAccountId: Bytes(),
});

// Legacy single-device accept (index 14, iOS V1). Wire: bare `String`.
// Superseded by `deviceChatAccepted` (index 20); keep for backward decode.
export const ChatAcceptedContent = Struct({
messageId: str,
});

// Per-device descriptor for the multi-device accept. Matches iOS `Chat.PeerDevice`
// and Android `DeviceInfoScale`.
export const DeviceInfoContent = Struct({
statementAccountId: AccountIdCodec,
encryptionPublicKey: PublicKeyCodec,
});

// Multi-device accept (index 20, per chat spec v0.1
// https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/Ski9naYdWe):
// DeviceChatAccepted = { requestId: String, device: DeviceInfo }
// Sent via identity-level session SessionId(B, A) encrypted with K(A,B);
// identity-level encryption lets all of A's devices decrypt without per-device
// envelope. Index 19 is reserved (placeholder slot between deviceRemoved=18
// and deviceChatAccepted=20).
export const DeviceChatAcceptedContent = Struct({
requestId: str,
device: DeviceInfoContent,
});

// Note: enum indices MUST match iOS/Android SCALE codecs.
// Indices are auto-assigned sequentially, so order matters.
export const MessageContent = Enum({
Expand All @@ -56,8 +101,13 @@ export const MessageContent = Enum({
_reserved11: _void, // 11 — reserved (unused)
edit: EditContent, // 12
leftChat: _void, // 13
chatAccepted: _void, // 14
chatAccepted: ChatAcceptedContent, // 14 (legacy single-device accept, iOS V1)
richText: RichTextContent, // 15
_reserved16: _void, // 16 — reserved (android `coinagePayment`, unused on desktop)
deviceAdded: DeviceAddedContent, // 17
deviceRemoved: DeviceRemovedContent, // 18
_reserved19: _void, // 19 — reserved (placeholder so deviceChatAccepted lands on the spec'd index 20)
deviceChatAccepted: DeviceChatAcceptedContent, // 20 (multi-device accept, spec v0.1)
});

export const VersionedMessageContent = Enum({
Expand Down
6 changes: 3 additions & 3 deletions packages/host-papp-react-ui/src/Flow.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ const meta: Meta<typeof PappProvider> = {
args: {
adapter: createPappAdapter({
appId: 'https://test.com',
metadata: 'https://shorturl.at/zGkir',
adapters: {
lazyClient: createLazyClient(getWsProvider(SS_PASEO_STABLE_STAGE_ENDPOINTS)),
},
hostMetadata: {
hostName: 'Storybook',
hostVersion: '1.2.3',
osType: 'macOS',
osVersion: '14.4.1',
platformType: 'macOS',
platformVersion: '14.4.1',
} satisfies HostMetadata,
}),
},
Expand Down
2 changes: 1 addition & 1 deletion packages/host-papp-react-ui/src/hooks/authStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const useAuthStatus = () => {
return pairingStatus.session;
}
return null;
}, [pairingStatus.step]);
}, [pairingStatus]);

return {
status: pairingStatus,
Expand Down
171 changes: 170 additions & 1 deletion packages/host-papp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ const papp = createPappAdapter({
Custom adapters (statement store, identity RPC, storage, lazy chain client) can be supplied
via the `adapters` option for testing or non-browser environments.

## Authentication and pairing
## Authentication and pairing (V1)

The V1 SSO flow described in this section is the single-device pairing protocol used by
`papp.sso.authenticate()`. For the multi-device V2 protocol see
[V2 SSO handshake](#v2-sso-handshake) below.

`papp.sso.authenticate()` runs the full pairing + attestation flow and resolves with the
stored user session, or `null` if the flow was aborted. The flow is idempotent — calling it
Expand Down Expand Up @@ -216,3 +220,168 @@ const lookup = async (accountId: string) => {
// Batch lookup
await papp.identity.getIdentities([accountIdA, accountIdB]);
```

## V2 SSO handshake

V2 is a redesign of the SSO pairing flow that supports the same user identity across
multiple devices. The host generates a stable device keypair locally, emits a
`VersionedHandshakeProposal::V2` via QR/deeplink, and an authorising peer (e.g. the user's
existing Polkadot App) responds over the Statement Store with the user identity keys signed
to authorise this device. Subsequent devices belonging to the same user reuse the same
identity, so contacts, chats, and roster events are shared between them.

V2 is **not interoperable with V1**: a V1-only peer can't decode a V2 proposal QR and vice
versa. Hosts that want to support both should branch on which protocol the peer advertises.

### Shape of the flow

```
host peer (authorising device)
────────────────────────────────────────────────────────────────────────
buildPairingDeeplink(device, metadata)
→ polkadotapp://pair?handshake=<hex>
scan QR, decode proposal
compute pairing topic from
the host's pubkeys
ECDH-encrypt + post:
Pending(AllowanceAllocation)
Success { encryptionKey,
accountId,
identitySignature }
Failed(reason)
service.subscribeStatements(topic) +
poll the topic every 2s
decode VersionedHandshakeResponse::V2
→ ECDH-decrypt envelope with the
device encryption private key
→ SCALE-decode inner payload
→ state machine: Submitted → Pending →
Success | Failed
→ on Success persist user identity
```

The user identity carried in `Success` is the chat encryption pubkey + the user's identity
sr25519 accountId. The host verifies the 64-byte sr25519 `identitySignature` against the
canonical 97 bytes `statementAccountId || encryptionPublicKey`
(see `IDENTITY_SIGNATURE_PAYLOAD_BYTES`).

### Building and rendering the QR

```ts
import { buildPairingDeeplink } from '@novasamatech/host-papp';

const deeplink = buildPairingDeeplink(
{
statementAccountPublicKey: device.statementAccountPublicKey, // sr25519 device pubkey, 32 bytes
encryptionPublicKey: device.encryptionPublicKey, // P-256 device pubkey, 65 bytes uncompressed
},
{
hostName: 'My Host App',
hostVersion: '1.0.0',
platformType: 'macOS',
platformVersion: '15.4',
},
);

renderQrCode(deeplink); // 'polkadotapp://pair?handshake=<hex>'
```

### Driving the handshake

```ts
import { startPairingV2 } from '@novasamatech/host-papp';

const pairing = startPairingV2({
statementStore: papp.adapters.statementStore, // any StatementStoreAdapter
deviceIdentity: {
statementAccountPublicKey: device.statementAccountPublicKey,
encryptionPublicKey: device.encryptionPublicKey,
encryptionPrivateKey: device.encryptionPrivateKey, // P-256 priv key, 32 bytes
},
metadata: {
hostName: 'My Host App',
hostVersion: '1.0.0',
platformType: 'macOS',
platformVersion: '15.4',
},
persistOnSuccess: async success => {
// success.identityChatPublicKey, success.userIdentityAccountId,
// success.identitySignature — persist in your secureStore.
},
});

pairing.qrPayload; // 'polkadotapp://pair?handshake=<hex>' for the QR UI

pairing.state$.subscribe(state => {
switch (state.tag) {
case 'Submitted':
// QR shown, waiting for the peer to scan
return;
case 'Pending':
// peer acknowledged; allocating Statement Store allowance on-chain
return;
case 'Success':
// identity received, device authorised
return;
case 'Failed':
// peer rejected (declined / duplicate / no-slot / tx-failed)
console.error(state.reason);
return;
}
});

// Cancel mid-flight (the Observable completes, polling stops, subscription closes):
pairing.abort();
```

### Surviving reloads / proper logout

The chain holds the most recent statement on the pairing topic indefinitely, so on cold
start the service will see the previous Success and replay it. To distinguish a stale
replay from a fresh re-pair, callers can pass byte-level dedupe state:

```ts
const pairing = startPairingV2({
// ...
initialProcessedDataHex: await secureStore.get('lastProcessedHandshakeStatement'),
onStatementProcessed: hex => {
void secureStore.set('lastProcessedHandshakeStatement', hex);
},
});
```

The service skips any incoming statement whose bytes match `initialProcessedDataHex`. PApp
re-encrypts every Success with a fresh ephemeral key + AES-GCM nonce, so a genuine re-pair
always produces different bytes and passes the dedupe.

### Pairing topic / channel

If the host needs to derive the pairing topic or channel itself (for example to subscribe
in-line, or to verify a statement source):

```ts
import { computePairingTopic, computePairingChannel } from '@novasamatech/host-papp';

const topic = computePairingTopic(statementAccountId, encryptionPublicKey);
const channel = computePairingChannel(statementAccountId, encryptionPublicKey);
// topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId)
// channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId)
```

### Codec exports

The SCALE codecs are exported as plain `Codec<T>` values for callers that need to
encode/decode statements outside the orchestrator:

| Export | Description |
| --------------------------------- | -------------------------------------------------------------------------------------------- |
| `VersionedHandshakeProposal` | Outer enum; V2 at SCALE discriminant 1, with `_v1Reserved` at 0. |
| `HandshakeProposalV2` | `{ device, metadata }` — what the QR encodes. |
| `Device` | `{ statementAccountId(32), encryptionPublicKey(65) }`. |
| `MetadataKey`, `MetadataEntry` | Metadata enum + `(MetadataKey, str)` tuple. |
| `VersionedHandshakeResponse` | Outer enum for the answer; `V1` legacy + `V2`. |
| `HandshakeResponseV2` | `{ encrypted, tmpKey(65) }` — the ECDH-wrapped envelope. |
| `EncryptedHandshakeResponseV2` | Inner payload after envelope decrypt: `Pending` (1 byte), `Success` (161 bytes), `Failed`. |
| `HandshakeSuccessV2` | `{ encryptionKey(65), accountId(32), identitySignature(64) }`. |
| `IDENTITY_SIGNATURE_PAYLOAD_BYTES`| `97` — the bytes the user identity sr25519 signs over. |
Loading