@novasamatech/host-papp 0.7.9 → 0.8.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Handshake V2 state machine — the public-facing observable shape of an
3
+ * in-flight SSO pairing exchange.
4
+ *
5
+ * Maps directly onto the inner `EncryptedHandshakeResponseV2` enum:
6
+ *
7
+ * Idle — no proposal emitted yet
8
+ * Submitted — proposal QR shown, waiting for the first response statement
9
+ * Pending — peer acknowledged; allocating Statement Store allowance on-chain
10
+ * Success — final state; identity keys received, device authorised
11
+ * Failed — final state; peer rejected (declined / duplicate / no-slot / tx-failed)
12
+ *
13
+ * Transitions are unidirectional except for Failed → Idle (user retries).
14
+ * The state object is what UIs render and what the chat layer gates on
15
+ * before submitting any V2 statements.
16
+ */
17
+ import { deriveIdentityChatPublicKey } from '../scale/handshakeV2.js';
18
+ export const idle = () => ({ tag: 'Idle' });
19
+ export const submitted = () => ({ tag: 'Submitted' });
20
+ /**
21
+ * Translate the length-dispatched-decoded `EncryptedHandshakeResponseV2` into
22
+ * the public state. Pure — no I/O. The caller decrypts the outer envelope and
23
+ * runs `decodeEncryptedHandshakeResponseV2` first.
24
+ */
25
+ export const fromInnerResponse = (response, peerStatementAccountId = null) => {
26
+ switch (response.tag) {
27
+ case 'Pending':
28
+ // Only AllowanceAllocation today; widen here when the spec adds more variants.
29
+ return { tag: 'Pending', reason: 'AllowanceAllocation' };
30
+ case 'Success':
31
+ return {
32
+ tag: 'Success',
33
+ identityAccountId: response.value.identityAccountId,
34
+ rootAccountId: response.value.rootAccountId,
35
+ identityChatPrivateKey: response.value.identityChatPrivateKey,
36
+ identityChatPublicKey: deriveIdentityChatPublicKey(response.value.identityChatPrivateKey),
37
+ deviceEncPubKey: response.value.deviceEncPubKey,
38
+ peerStatementAccountId,
39
+ };
40
+ case 'Failed':
41
+ return { tag: 'Failed', reason: response.value };
42
+ }
43
+ };
44
+ /**
45
+ * Forward-only transition guard: rejects regressions like Success → Pending.
46
+ * Idempotent on same-tag transitions (returns current by reference) so callers
47
+ * can use `next === current` to detect "no change" without per-call equality.
48
+ */
49
+ export const advance = (current, next) => {
50
+ if (isTerminal(current))
51
+ return current;
52
+ if (current.tag === 'Idle' && next.tag !== 'Submitted')
53
+ return current;
54
+ if (current.tag === 'Submitted' && next.tag === 'Idle')
55
+ return current;
56
+ if (current.tag === next.tag)
57
+ return current;
58
+ return next;
59
+ };
60
+ export const isTerminal = (state) => state.tag === 'Success' || state.tag === 'Failed';
61
+ /**
62
+ * True only when the device is authorised to submit V2 statements. The
63
+ * chat-send path gates on this — V2 messages must wait for allowance.
64
+ */
65
+ export const canSubmitV2Statements = (state) => state.tag === 'Success';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Pairing topic + channel derivation for the V2 SSO handshake.
3
+ *
4
+ * topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId)
5
+ * channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId)
6
+ *
7
+ * Where:
8
+ * - `statementAccountId` = the host's sr25519 device public key (32 bytes)
9
+ * - `encryptionPublicKey` = the host's P-256 device public key (65 bytes uncompressed)
10
+ *
11
+ * Both sides compute the same topic/channel deterministically from the same
12
+ * pubkeys carried in the QR-coded `VersionedHandshakeProposal::V2`, so they
13
+ * agree on where the response statement is delivered without any shared
14
+ * secret negotiation.
15
+ */
16
+ export declare const computePairingTopic: (statementAccountId: Uint8Array, encryptionPublicKey: Uint8Array) => Uint8Array;
17
+ export declare const computePairingChannel: (statementAccountId: Uint8Array, encryptionPublicKey: Uint8Array) => Uint8Array;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pairing topic + channel derivation for the V2 SSO handshake.
3
+ *
4
+ * topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId)
5
+ * channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId)
6
+ *
7
+ * Where:
8
+ * - `statementAccountId` = the host's sr25519 device public key (32 bytes)
9
+ * - `encryptionPublicKey` = the host's P-256 device public key (65 bytes uncompressed)
10
+ *
11
+ * Both sides compute the same topic/channel deterministically from the same
12
+ * pubkeys carried in the QR-coded `VersionedHandshakeProposal::V2`, so they
13
+ * agree on where the response statement is delivered without any shared
14
+ * secret negotiation.
15
+ */
16
+ import { khash } from '@novasamatech/statement-store';
17
+ import { mergeUint8 } from '@polkadot-api/utils';
18
+ const TOPIC_SUFFIX = new TextEncoder().encode('topic');
19
+ const CHANNEL_SUFFIX = new TextEncoder().encode('channel');
20
+ export const computePairingTopic = (statementAccountId, encryptionPublicKey) => khash(statementAccountId, mergeUint8([encryptionPublicKey, TOPIC_SUFFIX]));
21
+ export const computePairingChannel = (statementAccountId, encryptionPublicKey) => khash(statementAccountId, mergeUint8([encryptionPublicKey, CHANNEL_SUFFIX]));
@@ -0,0 +1,14 @@
1
+ import type { StorageAdapter } from '@novasamatech/storage-adapter';
2
+ import type { ResultAsync } from 'neverthrow';
3
+ import type { SsSecret } from '../crypto.js';
4
+ import type { DeviceIdentityForPairing } from './auth/v2/service.js';
5
+ export type DeviceIdentity = DeviceIdentityForPairing & {
6
+ statementAccountSecret: SsSecret;
7
+ };
8
+ export type DeviceIdentityStore = {
9
+ loadOrCreate(): ResultAsync<DeviceIdentity, Error>;
10
+ readLastProcessedHandshakeStatement(): ResultAsync<string | null, Error>;
11
+ writeLastProcessedHandshakeStatement(hex: string): ResultAsync<void, Error>;
12
+ };
13
+ export declare function createDeviceIdentityStore(salt: string, storage: StorageAdapter): DeviceIdentityStore;
14
+ export declare const awaitDeviceIdentity: (store: DeviceIdentityStore) => Promise<DeviceIdentity>;
@@ -0,0 +1,76 @@
1
+ import { gcm } from '@noble/ciphers/aes.js';
2
+ import { blake2b } from '@noble/hashes/blake2.js';
3
+ import { createSr25519Secret, deriveSr25519PublicKey } from '@novasamatech/statement-store';
4
+ import { errAsync, fromPromise, okAsync } from 'neverthrow';
5
+ import { fromHex, toHex } from 'polkadot-api/utils';
6
+ import { Bytes, Option, Struct, str } from 'scale-ts';
7
+ import { getEncrPub, stringToBytes } from '../crypto.js';
8
+ import { toError } from '../helpers/utils.js';
9
+ const KEY = 'DeviceIdentity';
10
+ // Persisted shape — kept under appId-derived AES-GCM at rest, same pattern as
11
+ // UserSecretRepository.
12
+ const StoredDeviceCodec = Struct({
13
+ statementAccountSeed: Bytes(32),
14
+ encryptionPrivateKey: Bytes(32),
15
+ lastProcessedHandshakeStatement: Option(str),
16
+ });
17
+ export function createDeviceIdentityStore(salt, storage) {
18
+ const aes = () => gcm(blake2b(stringToBytes(salt), { dkLen: 16 }), blake2b(stringToBytes('nonce'), { dkLen: 32 }));
19
+ const decode = (raw) => {
20
+ if (!raw)
21
+ return null;
22
+ try {
23
+ const decrypted = aes().decrypt(fromHex(raw));
24
+ return StoredDeviceCodec.dec(decrypted);
25
+ }
26
+ catch {
27
+ // 0.7.x had no DeviceIdentity key; any decode failure here means a
28
+ // schema rev or tampered blob — drop it and regenerate.
29
+ return null;
30
+ }
31
+ };
32
+ const encode = (stored) => toHex(aes().encrypt(StoredDeviceCodec.enc(stored)));
33
+ const read = () => storage.read(KEY).map(decode);
34
+ const write = (stored) => storage.write(KEY, encode(stored)).map(() => undefined);
35
+ const expand = (stored) => {
36
+ const statementAccountSecret = createSr25519Secret(stored.statementAccountSeed);
37
+ const statementAccountPublicKey = deriveSr25519PublicKey(statementAccountSecret);
38
+ const encryptionPrivateKey = stored.encryptionPrivateKey;
39
+ const encryptionPublicKey = getEncrPub(encryptionPrivateKey);
40
+ return { statementAccountPublicKey, statementAccountSecret, encryptionPublicKey, encryptionPrivateKey };
41
+ };
42
+ const generate = () => ({
43
+ statementAccountSeed: crypto.getRandomValues(new Uint8Array(32)),
44
+ encryptionPrivateKey: crypto.getRandomValues(new Uint8Array(32)),
45
+ lastProcessedHandshakeStatement: undefined,
46
+ });
47
+ return {
48
+ loadOrCreate() {
49
+ return read().andThen(existing => {
50
+ if (existing)
51
+ return okAsync(expand(existing));
52
+ const fresh = generate();
53
+ return write(fresh).map(() => expand(fresh));
54
+ });
55
+ },
56
+ readLastProcessedHandshakeStatement() {
57
+ return read().map(stored => stored?.lastProcessedHandshakeStatement ?? null);
58
+ },
59
+ writeLastProcessedHandshakeStatement(hex) {
60
+ return read().andThen(existing => {
61
+ if (!existing) {
62
+ // No identity yet — caller will populate via loadOrCreate first.
63
+ return errAsync(new Error('writeLastProcessedHandshakeStatement: no device identity persisted'));
64
+ }
65
+ return write({ ...existing, lastProcessedHandshakeStatement: hex });
66
+ });
67
+ },
68
+ };
69
+ }
70
+ // Re-export the awaitable form for convenience, since most call sites already
71
+ // live in async functions.
72
+ export const awaitDeviceIdentity = (store) => fromPromise(Promise.resolve(), toError)
73
+ .andThen(() => store.loadOrCreate())
74
+ .match(ok => ok, err => {
75
+ throw err;
76
+ });
@@ -78,10 +78,7 @@ export function createUserSession({ userSession, statementStore, encryption, sto
78
78
  to: JSON.stringify,
79
79
  });
80
80
  return {
81
- id: userSession.id,
82
- localAccount: userSession.localAccount,
83
- remoteAccount: userSession.remoteAccount,
84
- rootAccountId: userSession.rootAccountId,
81
+ ...userSession,
85
82
  signPayload(payload) {
86
83
  return requestQueue.call(() => {
87
84
  const messageId = nanoid();
@@ -7,6 +7,7 @@ declare const StoredUserSecretsCodec: import("scale-ts").Codec<{
7
7
  ssSecret: SsSecret;
8
8
  encrSecret: EncrSecret;
9
9
  entropy: Uint8Array<ArrayBufferLike>;
10
+ identityChatPrivateKey: Uint8Array<ArrayBufferLike>;
10
11
  }>;
11
12
  export type UserSecretRepository = ReturnType<typeof createUserSecretRepository>;
12
13
  export declare function createUserSecretRepository(salt: string, storage: StorageAdapter): {
@@ -9,11 +9,26 @@ const StoredUserSecretsCodec = Struct({
9
9
  ssSecret: BrandedBytesCodec(),
10
10
  encrSecret: BrandedBytesCodec(),
11
11
  entropy: Bytes(),
12
+ // V2 addition: user identity chat private key (P-256 raw scalar, 32 bytes).
13
+ // Sensitive — kept encrypted at rest alongside the per-session ss/encr secrets.
14
+ identityChatPrivateKey: Bytes(32),
12
15
  });
13
16
  export function createUserSecretRepository(salt, storage) {
14
17
  const baseKey = 'UserSecrets';
15
18
  const encode = fromThrowable(StoredUserSecretsCodec.enc, toError);
16
- const decode = fromThrowable((value) => (value ? StoredUserSecretsCodec.dec(value) : null), toError);
19
+ const decode = fromThrowable((value) => {
20
+ if (!value)
21
+ return null;
22
+ try {
23
+ return StoredUserSecretsCodec.dec(value);
24
+ }
25
+ catch {
26
+ // 0.7.x V1 blobs are missing `identityChatPrivateKey` and decode short.
27
+ // Treat as absent — a fresh handshake (required after 0.8.0 upgrade)
28
+ // will overwrite.
29
+ return null;
30
+ }
31
+ }, toError);
17
32
  const encrypt = fromThrowable((value) => {
18
33
  const aes = getAes(salt);
19
34
  return toHex(aes.encrypt(value));
@@ -15,8 +15,14 @@ declare const storedUserSessionCodec: import("scale-ts").Codec<{
15
15
  pin: string | undefined;
16
16
  };
17
17
  rootAccountId: AccountId;
18
+ identityAccountId: AccountId | undefined;
19
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
18
20
  }>;
19
- export declare function createStoredUserSession(localAccount: LocalSessionAccount, remoteAccount: RemoteSessionAccount, rootAccountId: AccountId): StoredUserSession;
21
+ type StoredUserSessionV2Extras = {
22
+ identityAccountId?: AccountId;
23
+ identityChatPublicKey?: Uint8Array;
24
+ };
25
+ export declare function createStoredUserSession(localAccount: LocalSessionAccount, remoteAccount: RemoteSessionAccount, rootAccountId: AccountId, extras?: StoredUserSessionV2Extras): StoredUserSession;
20
26
  export declare const createUserSessionRepository: (storage: StorageAdapter) => {
21
27
  add(value: {
22
28
  id: string;
@@ -30,6 +36,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
30
36
  pin: string | undefined;
31
37
  };
32
38
  rootAccountId: AccountId;
39
+ identityAccountId: AccountId | undefined;
40
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
33
41
  }): import("neverthrow").ResultAsync<{
34
42
  id: string;
35
43
  localAccount: {
@@ -42,6 +50,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
42
50
  pin: string | undefined;
43
51
  };
44
52
  rootAccountId: AccountId;
53
+ identityAccountId: AccountId | undefined;
54
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
45
55
  }, Error>;
46
56
  filter(fn: (value: {
47
57
  id: string;
@@ -55,6 +65,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
55
65
  pin: string | undefined;
56
66
  };
57
67
  rootAccountId: AccountId;
68
+ identityAccountId: AccountId | undefined;
69
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
58
70
  }) => boolean): import("neverthrow").ResultAsync<{
59
71
  id: string;
60
72
  localAccount: {
@@ -67,6 +79,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
67
79
  pin: string | undefined;
68
80
  };
69
81
  rootAccountId: AccountId;
82
+ identityAccountId: AccountId | undefined;
83
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
70
84
  }[], Error>;
71
85
  mutate(fn: (value: {
72
86
  id: string;
@@ -80,6 +94,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
80
94
  pin: string | undefined;
81
95
  };
82
96
  rootAccountId: AccountId;
97
+ identityAccountId: AccountId | undefined;
98
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
83
99
  }[]) => {
84
100
  id: string;
85
101
  localAccount: {
@@ -92,6 +108,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
92
108
  pin: string | undefined;
93
109
  };
94
110
  rootAccountId: AccountId;
111
+ identityAccountId: AccountId | undefined;
112
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
95
113
  }[]): import("neverthrow").ResultAsync<{
96
114
  id: string;
97
115
  localAccount: {
@@ -104,6 +122,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
104
122
  pin: string | undefined;
105
123
  };
106
124
  rootAccountId: AccountId;
125
+ identityAccountId: AccountId | undefined;
126
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
107
127
  }[], Error>;
108
128
  read(): import("neverthrow").ResultAsync<{
109
129
  id: string;
@@ -117,6 +137,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
117
137
  pin: string | undefined;
118
138
  };
119
139
  rootAccountId: AccountId;
140
+ identityAccountId: AccountId | undefined;
141
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
120
142
  }[], Error>;
121
143
  write(value: {
122
144
  id: string;
@@ -130,6 +152,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
130
152
  pin: string | undefined;
131
153
  };
132
154
  rootAccountId: AccountId;
155
+ identityAccountId: AccountId | undefined;
156
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
133
157
  }[]): import("neverthrow").ResultAsync<null, Error> | import("neverthrow").ResultAsync<{
134
158
  id: string;
135
159
  localAccount: {
@@ -142,6 +166,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
142
166
  pin: string | undefined;
143
167
  };
144
168
  rootAccountId: AccountId;
169
+ identityAccountId: AccountId | undefined;
170
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
145
171
  }[], Error>;
146
172
  clear(): import("neverthrow").ResultAsync<void, Error>;
147
173
  subscribe(fn: (value: {
@@ -156,6 +182,8 @@ export declare const createUserSessionRepository: (storage: StorageAdapter) => {
156
182
  pin: string | undefined;
157
183
  };
158
184
  rootAccountId: AccountId;
185
+ identityAccountId: AccountId | undefined;
186
+ identityChatPublicKey: Uint8Array<ArrayBufferLike> | undefined;
159
187
  }[]) => void): VoidFunction;
160
188
  };
161
189
  export {};
@@ -2,19 +2,25 @@ import { AccountIdCodec, LocalSessionAccountCodec, RemoteSessionAccountCodec } f
2
2
  import { fieldListView } from '@novasamatech/storage-adapter';
3
3
  import { nanoid } from 'nanoid';
4
4
  import { fromHex, toHex } from 'polkadot-api/utils';
5
- import { Struct, Vector, str } from 'scale-ts';
5
+ import { Bytes, Option, Struct, Vector, str } from 'scale-ts';
6
+ // V2 fields trail V1 fields so a future schema rev can append further
7
+ // `Option`-wrapped fields without breaking decode of 0.8.0 blobs.
6
8
  const storedUserSessionCodec = Struct({
7
9
  id: str,
8
10
  localAccount: LocalSessionAccountCodec,
9
11
  remoteAccount: RemoteSessionAccountCodec,
10
12
  rootAccountId: AccountIdCodec,
13
+ identityAccountId: Option(AccountIdCodec),
14
+ identityChatPublicKey: Option(Bytes(65)),
11
15
  });
12
- export function createStoredUserSession(localAccount, remoteAccount, rootAccountId) {
16
+ export function createStoredUserSession(localAccount, remoteAccount, rootAccountId, extras = {}) {
13
17
  return {
14
18
  id: nanoid(12),
15
- localAccount: localAccount,
16
- remoteAccount: remoteAccount,
19
+ localAccount,
20
+ remoteAccount,
17
21
  rootAccountId,
22
+ identityAccountId: extras.identityAccountId,
23
+ identityChatPublicKey: extras.identityChatPublicKey,
18
24
  };
19
25
  }
20
26
  export const createUserSessionRepository = (storage) => {
@@ -22,7 +28,18 @@ export const createUserSessionRepository = (storage) => {
22
28
  return fieldListView({
23
29
  storage,
24
30
  key: 'SsoSessions',
25
- from: x => codec.dec(fromHex(x)),
31
+ from: x => {
32
+ try {
33
+ return codec.dec(fromHex(x));
34
+ }
35
+ catch {
36
+ // 0.7.x V1 blobs use the prior codec shape and won't decode against
37
+ // V2's extended struct. Treat as empty so the caller (and the
38
+ // fieldListView mutate machinery) start clean; the next write
39
+ // overwrites the bad blob.
40
+ return [];
41
+ }
42
+ },
26
43
  to: x => toHex(codec.enc(x)),
27
44
  });
28
45
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/host-papp",
3
3
  "type": "module",
4
- "version": "0.7.9",
4
+ "version": "0.8.0-0",
5
5
  "description": "Polkadot app integration",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -34,16 +34,19 @@
34
34
  "@noble/ciphers": "2.2.0",
35
35
  "@noble/curves": "2.2.0",
36
36
  "@noble/hashes": "2.2.0",
37
- "@novasamatech/host-api": "0.7.9",
38
- "@novasamatech/scale": "0.7.9",
39
- "@novasamatech/statement-store": "0.7.9",
40
- "@novasamatech/storage-adapter": "0.7.9",
37
+ "@novasamatech/host-api": "0.8.0-0",
38
+ "@novasamatech/scale": "0.8.0-0",
39
+ "@novasamatech/statement-store": "0.8.0-0",
40
+ "@novasamatech/storage-adapter": "0.8.0-0",
41
+ "@polkadot-api/utils": "^0.4.0",
41
42
  "@polkadot-labs/hdkd-helpers": "^0.0.30",
42
43
  "nanoevents": "9.1.0",
43
44
  "nanoid": "5.1.9",
44
45
  "neverthrow": "^8.2.0",
45
46
  "polkadot-api": ">=2",
46
- "scale-ts": "1.6.1"
47
+ "rxjs": "^7.8.2",
48
+ "scale-ts": "1.6.1",
49
+ "verifiablejs": "1.2.0"
47
50
  },
48
51
  "publishConfig": {
49
52
  "access": "public"