@@ -38,34 +38,40 @@ import {
3838import { mkdir , writeFile } from "node:fs/promises" ;
3939import { join } from "node:path" ;
4040import { nanoid } from "nanoid" ;
41- import { Bytes , type Codec , Option , Struct , Vector , str } from "scale-ts" ;
41+ import { Bytes , type Codec , Struct , Vector , str } from "scale-ts" ;
4242import type { StoredUserSession } from "@novasamatech/host-papp" ;
4343
4444import { sanitizeKey } from "./node-storage.js" ;
4545
4646// Mirror of host-papp's internal stored-session codec (not exported — only the
4747// `StoredUserSession` type is). `satisfies` guards drift at build time, the
48- // interop test at runtime; field order and the `Bytes(65)` chat key must match.
48+ // interop test at runtime; field order and the fixed-width fields must match.
4949//
50- // host-papp 0.8.5 appended `ssoEncPubKey` (Mobile SSO spec v0.2.2):
51- // the peer's 65-byte uncompressed P-256 encryption pubkey. `None` for
52- // pre-v0.2.2 peers; we synthesize as `undefined` below.
50+ // host-papp 0.8.6 (RFC-0007, PR #205) dropped the `Option` wrapper on
51+ // `identityAccountId`, `identityChatPublicKey`, and `ssoEncPubKey` — all
52+ // three are now required — and appended `rootEntropySource: Bytes(32)`,
53+ // the layer-1 entropy the host consumes via `deriveProductEntropyFromSource`.
5354const storedUserSessionCodec = Struct ( {
5455 id : str ,
5556 localAccount : LocalSessionAccountCodec ,
5657 remoteAccount : RemoteSessionAccountCodec ,
5758 rootAccountId : AccountIdCodec ,
58- identityAccountId : Option ( AccountIdCodec ) ,
59- identityChatPublicKey : Option ( Bytes ( 65 ) ) ,
60- ssoEncPubKey : Option ( Bytes ( 65 ) ) ,
59+ identityAccountId : AccountIdCodec ,
60+ identityChatPublicKey : Bytes ( 65 ) ,
61+ ssoEncPubKey : Bytes ( 65 ) ,
62+ rootEntropySource : Bytes ( 32 ) ,
6163} ) satisfies Codec < StoredUserSession > ;
6264const sessionsCodec = Vector ( storedUserSessionCodec ) ;
6365
6466// Mirrors the internal StoredUserSecretsCodec in host-papp's userSecretRepository.
67+ //
68+ // host-papp 0.8.6 dropped `entropy` (root entropy moved onto the session as
69+ // `rootEntropySource`) and added the V2 `identityChatPrivateKey` field.
70+ // The storage key was also renamed `UserSecrets` → `UserSecretsV2`.
6571const storedUserSecretsCodec = Struct ( {
6672 ssSecret : Bytes ( ) ,
6773 encrSecret : Bytes ( ) ,
68- entropy : Bytes ( ) ,
74+ identityChatPrivateKey : Bytes ( 32 ) ,
6975} ) ;
7076
7177// Mirrors host-papp's userSecretRepository AES-GCM wrapper: the encryption
@@ -105,7 +111,7 @@ export interface CreateTestSessionOptions {
105111 /** Stable session id. Default: random nanoid(12). */
106112 sessionId ?: string ;
107113 /**
108- * Whether to write the encrypted `UserSecrets_ <id>` file. Default: `true`.
114+ * Whether to write the encrypted `UserSecretsV2_ <id>` file. Default: `true`.
109115 * Set to `false` to exercise recovery paths where a session exists on disk
110116 * but its secrets are missing.
111117 */
@@ -154,9 +160,9 @@ export interface TestSession {
154160 * tracked via on-chain attestation state. See above for how expiry-path
155161 * tests still work in practice.
156162 * - **Corrupted-session cases** don't need a helper — write garbage to
157- * `<storageDir>/<appId>_SsoSessions .json` with `fs.writeFile` directly.
163+ * `<storageDir>/<appId>_SsoSessionsV2 .json` with `fs.writeFile` directly.
158164 * - **Repeated calls replace the session list.** Each call writes a fresh
159- * single-entry `SsoSessions ` file, so calling twice on the same
165+ * single-entry `SsoSessionsV2 ` file, so calling twice on the same
160166 * `storageDir`+`appId` leaves only the second session on disk. Use a
161167 * fresh `mkdtempSync` per test to keep cases isolated.
162168 *
@@ -201,6 +207,13 @@ export async function createTestSession(options: CreateTestSessionOptions): Prom
201207
202208 const sessionId = options . sessionId ?? nanoid ( 12 ) ;
203209
210+ // host-papp 0.8.6 (RFC-0007) requires all of identityAccountId,
211+ // identityChatPublicKey, ssoEncPubKey, and rootEntropySource on the
212+ // persisted session. For a synthesized test session we collapse the
213+ // identity-side keys onto the remote account (the wallet doubles as
214+ // identity), reuse the peer's P-256 encryption pubkey for both chat
215+ // and SSO transports, and use the remote entropy as the RFC-0007
216+ // root entropy source so the value is reproducible.
204217 const session = {
205218 id : sessionId ,
206219 localAccount : createLocalSessionAccount ( createAccountId ( localPublicKey ) , undefined ) ,
@@ -209,31 +222,34 @@ export async function createTestSession(options: CreateTestSessionOptions): Prom
209222 sharedSecret ,
210223 undefined ,
211224 ) ,
212- // synthesized session: the remote (wallet) account doubles as root.
213225 rootAccountId : createAccountId ( remotePublicKey ) ,
214- identityAccountId : undefined ,
215- identityChatPublicKey : undefined ,
216- // pre-v0.2.2 peer: no SSO encryption pubkey on the wire.
217- ssoEncPubKey : undefined ,
226+ identityAccountId : createAccountId ( remotePublicKey ) ,
227+ identityChatPublicKey : remoteEncrPublicKey ,
228+ ssoEncPubKey : remoteEncrPublicKey ,
229+ rootEntropySource : remoteEntropy ,
218230 } ;
219231
220232 await writeFile (
221- join ( options . storageDir , `${ sanitizeKey ( options . appId , "SsoSessions " ) } .json` ) ,
233+ join ( options . storageDir , `${ sanitizeKey ( options . appId , "SsoSessionsV2 " ) } .json` ) ,
222234 toHex ( sessionsCodec . enc ( [ session ] ) ) ,
223235 "utf-8" ,
224236 ) ;
225237
226238 const includeSecrets = options . includeSecrets ?? true ;
227239 if ( includeSecrets ) {
240+ // host-papp 0.8.6 dropped `entropy` (now lives on the session as
241+ // `rootEntropySource`) and requires a 32-byte `identityChatPrivateKey`
242+ // (V2 — P-256 raw scalar). Reuse the local P-256 encryption secret
243+ // for both — same domain (P-256, 32-byte scalar).
228244 const encoded = storedUserSecretsCodec . enc ( {
229245 ssSecret : localSecret ,
230246 encrSecret : localEncrSecret ,
231- entropy : localEntropy ,
247+ identityChatPrivateKey : localEncrSecret ,
232248 } ) ;
233249 await writeFile (
234250 join (
235251 options . storageDir ,
236- `${ sanitizeKey ( options . appId , `UserSecrets_ ${ sessionId } ` ) } .json` ,
252+ `${ sanitizeKey ( options . appId , `UserSecretsV2_ ${ sessionId } ` ) } .json` ,
237253 ) ,
238254 encryptSecrets ( options . appId , encoded ) ,
239255 "utf-8" ,
@@ -279,25 +295,25 @@ if (import.meta.vitest) {
279295 } ) ;
280296
281297 describe ( "createTestSession" , ( ) => {
282- test ( "writes both SsoSessions and UserSecrets files by default" , async ( ) => {
298+ test ( "writes both SsoSessionsV2 and UserSecretsV2 files by default" , async ( ) => {
283299 const result = await createTestSession ( {
284300 appId : "my-app" ,
285301 storageDir,
286302 localMnemonic : LOCAL_MNEMONIC ,
287303 remoteMnemonic : REMOTE_MNEMONIC ,
288304 } ) ;
289305
290- const sessions = await readFile ( join ( storageDir , "my-app_SsoSessions .json" ) , "utf-8" ) ;
306+ const sessions = await readFile ( join ( storageDir , "my-app_SsoSessionsV2 .json" ) , "utf-8" ) ;
291307 expect ( sessions ) . toMatch ( / ^ 0 x [ 0 - 9 a - f ] + $ / ) ;
292308
293309 const secrets = await readFile (
294- join ( storageDir , `my-app_UserSecrets_ ${ result . sessionId } .json` ) ,
310+ join ( storageDir , `my-app_UserSecretsV2_ ${ result . sessionId } .json` ) ,
295311 "utf-8" ,
296312 ) ;
297313 expect ( secrets ) . toMatch ( / ^ 0 x [ 0 - 9 a - f ] + $ / ) ;
298314 } ) ;
299315
300- test ( "omits UserSecrets file when includeSecrets is false" , async ( ) => {
316+ test ( "omits UserSecretsV2 file when includeSecrets is false" , async ( ) => {
301317 const result = await createTestSession ( {
302318 appId : "my-app" ,
303319 storageDir,
@@ -307,15 +323,18 @@ if (import.meta.vitest) {
307323 } ) ;
308324
309325 await expect (
310- readFile ( join ( storageDir , "my-app_SsoSessions .json" ) , "utf-8" ) ,
326+ readFile ( join ( storageDir , "my-app_SsoSessionsV2 .json" ) , "utf-8" ) ,
311327 ) . resolves . toMatch ( / ^ 0 x / ) ;
312328
313329 await expect (
314- readFile ( join ( storageDir , `my-app_UserSecrets_${ result . sessionId } .json` ) , "utf-8" ) ,
330+ readFile (
331+ join ( storageDir , `my-app_UserSecretsV2_${ result . sessionId } .json` ) ,
332+ "utf-8" ,
333+ ) ,
315334 ) . rejects . toThrow ( / E N O E N T / ) ;
316335 } ) ;
317336
318- test ( "SsoSessions file decodes with the host-papp session codec shape" , async ( ) => {
337+ test ( "SsoSessionsV2 file decodes with the host-papp session codec shape" , async ( ) => {
319338 const result = await createTestSession ( {
320339 appId : "my-app" ,
321340 storageDir,
@@ -324,7 +343,7 @@ if (import.meta.vitest) {
324343 sessionId : "stable-test-id" ,
325344 } ) ;
326345
327- const hex = await readFile ( join ( storageDir , "my-app_SsoSessions .json" ) , "utf-8" ) ;
346+ const hex = await readFile ( join ( storageDir , "my-app_SsoSessionsV2 .json" ) , "utf-8" ) ;
328347 const decoded = sessionsCodec . dec ( fromHex ( hex ) ) ;
329348
330349 expect ( decoded ) . toHaveLength ( 1 ) ;
@@ -351,7 +370,7 @@ if (import.meta.vitest) {
351370 expect ( result . localAccountId ) . toHaveLength ( 32 ) ;
352371 } ) ;
353372
354- test ( "UserSecrets file decrypts and decodes with the host-papp secret codec shape" , async ( ) => {
373+ test ( "UserSecretsV2 file decrypts and decodes with the host-papp secret codec shape" , async ( ) => {
355374 const appId = "my-app" ;
356375 const result = await createTestSession ( {
357376 appId,
@@ -361,30 +380,31 @@ if (import.meta.vitest) {
361380 } ) ;
362381
363382 const hex = await readFile (
364- join ( storageDir , `${ appId } _UserSecrets_ ${ result . sessionId } .json` ) ,
383+ join ( storageDir , `${ appId } _UserSecretsV2_ ${ result . sessionId } .json` ) ,
365384 "utf-8" ,
366385 ) ;
367386 const key = blake2b ( new TextEncoder ( ) . encode ( appId ) , { dkLen : 16 } ) ;
368387 const nonce = blake2b ( new TextEncoder ( ) . encode ( "nonce" ) , { dkLen : 32 } ) ;
369388 const decrypted = gcm ( key , nonce ) . decrypt ( fromHex ( hex ) ) ;
370389 const decoded = storedUserSecretsCodec . dec ( decrypted ) ;
371390
372- expect ( decoded . entropy ) . toEqual ( mnemonicToEntropy ( LOCAL_MNEMONIC ) ) ;
373391 // Sr25519 secret is 64 bytes (32-byte secret + 32-byte nonce).
374392 expect ( decoded . ssSecret ) . toHaveLength ( 64 ) ;
375393 // P256 secret is 32 bytes.
376394 expect ( decoded . encrSecret ) . toHaveLength ( 32 ) ;
395+ // V2 chat private key (P-256 raw scalar) is 32 bytes.
396+ expect ( decoded . identityChatPrivateKey ) . toHaveLength ( 32 ) ;
377397 } ) ;
378398
379399 test ( "different appIds produce files under different prefixes" , async ( ) => {
380400 await createTestSession ( { appId : "app-a" , storageDir, sessionId : "id" } ) ;
381401 await createTestSession ( { appId : "app-b" , storageDir, sessionId : "id" } ) ;
382402
383403 await expect (
384- readFile ( join ( storageDir , "app-a_SsoSessions .json" ) , "utf-8" ) ,
404+ readFile ( join ( storageDir , "app-a_SsoSessionsV2 .json" ) , "utf-8" ) ,
385405 ) . resolves . toMatch ( / ^ 0 x / ) ;
386406 await expect (
387- readFile ( join ( storageDir , "app-b_SsoSessions .json" ) , "utf-8" ) ,
407+ readFile ( join ( storageDir , "app-b_SsoSessionsV2 .json" ) , "utf-8" ) ,
388408 ) . resolves . toMatch ( / ^ 0 x / ) ;
389409 } ) ;
390410
@@ -395,15 +415,15 @@ if (import.meta.vitest) {
395415 sessionId : "id" ,
396416 } ) ;
397417 await expect (
398- readFile ( join ( storageDir , "app_with_spaces_SsoSessions .json" ) , "utf-8" ) ,
418+ readFile ( join ( storageDir , "app_with_spaces_SsoSessionsV2 .json" ) , "utf-8" ) ,
399419 ) . resolves . toMatch ( / ^ 0 x / ) ;
400420 } ) ;
401421
402422 test ( "creates storageDir when it does not yet exist" , async ( ) => {
403423 const nested = join ( storageDir , "does" , "not" , "exist" ) ;
404424 await createTestSession ( { appId : "my-app" , storageDir : nested } ) ;
405425 await expect (
406- readFile ( join ( nested , "my-app_SsoSessions .json" ) , "utf-8" ) ,
426+ readFile ( join ( nested , "my-app_SsoSessionsV2 .json" ) , "utf-8" ) ,
407427 ) . resolves . toMatch ( / ^ 0 x / ) ;
408428 } ) ;
409429
@@ -423,7 +443,7 @@ if (import.meta.vitest) {
423443 } ) ;
424444 expect ( result . sessionId ) . toBe ( "pinned-id" ) ;
425445 await expect (
426- readFile ( join ( storageDir , "my-app_UserSecrets_pinned -id.json" ) , "utf-8" ) ,
446+ readFile ( join ( storageDir , "my-app_UserSecretsV2_pinned -id.json" ) , "utf-8" ) ,
427447 ) . resolves . toMatch ( / ^ 0 x / ) ;
428448 } ) ;
429449
@@ -457,17 +477,20 @@ if (import.meta.vitest) {
457477 sessionId : "second" ,
458478 } ) ;
459479
460- const hex = await readFile ( join ( storageDir , "my-app_SsoSessions .json" ) , "utf-8" ) ;
480+ const hex = await readFile ( join ( storageDir , "my-app_SsoSessionsV2 .json" ) , "utf-8" ) ;
461481 const decoded = sessionsCodec . dec ( fromHex ( hex ) ) ;
462482 expect ( decoded ) . toHaveLength ( 1 ) ;
463483 expect ( decoded [ 0 ] . id ) . toBe ( "second" ) ;
464- // The first session's UserSecrets file is left behind (not cleaned).
484+ // The first session's UserSecretsV2 file is left behind (not cleaned).
465485 // This matches a real logout flow as well, so we don't try to hide it.
466486 await expect (
467- readFile ( join ( storageDir , `my-app_UserSecrets_ ${ first . sessionId } .json` ) , "utf-8" ) ,
487+ readFile ( join ( storageDir , `my-app_UserSecretsV2_ ${ first . sessionId } .json` ) , "utf-8" ) ,
468488 ) . resolves . toMatch ( / ^ 0 x / ) ;
469489 await expect (
470- readFile ( join ( storageDir , `my-app_UserSecrets_${ second . sessionId } .json` ) , "utf-8" ) ,
490+ readFile (
491+ join ( storageDir , `my-app_UserSecretsV2_${ second . sessionId } .json` ) ,
492+ "utf-8" ,
493+ ) ,
471494 ) . resolves . toMatch ( / ^ 0 x / ) ;
472495 } ) ;
473496
0 commit comments