From 8b42eaf98cc192c354da6b9614a3590b01fb26a3 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 20 Aug 2026 09:35:28 -0400 Subject: [PATCH 1/4] feat(main): surface wrap and rewrap algorithm choice in web-app demo (DSPX-3229) Populate the Manifest Inspector from the encrypt-side manifest so the chosen KAO wrap algorithm is observable without decrypting first, and add a rewrap key algorithm selector so the client's ephemeral keypair for the rewrap exchange can be post-quantum too. Also add a .gitignore for the roundtrip harness: the root one anchors /platform and /*.pem to the repo root, so local backend bring-up artifacts would otherwise be snapshotted into the working copy. Signed-off-by: Dave Mihalcik --- .github/workflows/roundtrip/.gitignore | 16 +++++++ web-app/src/App.tsx | 62 ++++++++++++++++++++------ web-app/tests/tests/roundtrip.spec.ts | 13 +++++- 3 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/roundtrip/.gitignore diff --git a/.github/workflows/roundtrip/.gitignore b/.github/workflows/roundtrip/.gitignore new file mode 100644 index 000000000..69bc1166b --- /dev/null +++ b/.github/workflows/roundtrip/.gitignore @@ -0,0 +1,16 @@ +# Local backend bring-up artifacts. In CI these live on a throwaway runner, but +# when running the harness locally they land in this directory. See the +# roundtrip README steps in web-app/tests/README.md. + +# Platform checkout (CI clones it here; locally it is usually a symlink) +/platform + +# KAS keys and CA material from ./init-temp-keys.sh +/*.pem +/*.srl +/ecparams.tmp +/keys/ + +# Keycloak admin CLI downloaded by ./config-demo-idp.sh +/kc.zip +/keycloak-*/ diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index e21a16f85..2166a118b 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -3,7 +3,14 @@ import { useState, useEffect, type ChangeEvent } from 'react'; import streamsaver from 'streamsaver'; import { showSaveFilePicker } from 'native-file-system-adapter'; import './App.css'; -import { type Chunker, type KasPublicKeyAlgorithm, type Source, OpenTDF } from '@opentdf/sdk'; +import { + type Chunker, + type DecoratedStream, + type KasPublicKeyAlgorithm, + type Manifest, + type Source, + OpenTDF, +} from '@opentdf/sdk'; import { type SessionInformation, OidcClient } from './session.js'; import { config } from './config.js'; @@ -141,6 +148,19 @@ function decodedBase64Length(value: string): number { return Math.floor((value.length * 3) / 4) - paddingLength; } +function kaoMetadataFrom(manifest: Manifest): KaoMetadata[] { + return manifest.encryptionInformation.keyAccess.map((kao) => { + const wrappedKeyBytes = kao.wrappedKey ? decodedBase64Length(kao.wrappedKey) : 0; + return { + kid: kao.kid ?? '(no kid)', + type: kao.type, + url: kao.url, + protocol: kao.protocol, + wrappedKeyBytes, + } satisfies KaoMetadata; + }); +} + function fileNameFor(inputSource: InputSource) { if (!inputSource) { return 'undefined.bin'; @@ -256,6 +276,7 @@ function App() { const [inputSource, setInputSource] = useState(); const [sinkType, setSinkType] = useState('file'); const [encapAlgorithm, setEncapAlgorithm] = useState('ec:secp256r1'); + const [rewrapAlgorithm, setRewrapAlgorithm] = useState('rsa:2048'); const [kaoMetadata, setKaoMetadata] = useState(); const [streamController, setStreamController] = useState(); @@ -419,7 +440,7 @@ function App() { } const progressTransformers = makeProgressPair(size, 'Encrypt'); - let cipherText: ReadableStream; + let cipherText: DecoratedStream; try { cipherText = await client.createZTDF({ autoconfigure: false, @@ -431,6 +452,12 @@ function App() { console.error('Encrypt Failed', e); return; } + // Surface the key access objects we just wrote, so the wrap algorithm choice + // is visible without having to decrypt first. Don't await; this shouldn't + // hold up the download. + cipherText.manifest + ?.then((manifest) => setKaoMetadata(kaoMetadataFrom(manifest))) + .catch((e) => console.warn('failed to read manifest after encrypt', e)); const cipherTextWithProgress = cipherText.pipeThrough(progressTransformers.writer); try { switch (sinkType) { @@ -467,6 +494,9 @@ function App() { } const dfn = decryptedFileName(fileNameFor(inputSource)); console.log(`Decrypting ${JSON.stringify(inputSource)} to ${sinkType} ${dfn}`); + // Drop anything left over from a previous encrypt or decrypt so the panel + // always reflects the file we're reading now. + setKaoMetadata(undefined); let f: FileSystemFileHandle | undefined; if (sinkType === 'fsapi') { f = await getNewFileHandle(decryptedFileExtension(fileNameFor(inputSource)), dfn); @@ -479,6 +509,7 @@ function App() { authProvider: oidcClient, defaultReadOptions: { allowedKASEndpoints: [config.kas], + wrappingKeyAlgorithm: rewrapAlgorithm, ...decryptReadTuning, }, dpopKeys: oidcClient.getSigningKey(), @@ -512,17 +543,7 @@ function App() { const reader = client.open({ source }); try { const manifest = await reader.manifest(); - const kaos = manifest.encryptionInformation.keyAccess.map((kao) => { - const wrappedKeyBytes = kao.wrappedKey ? decodedBase64Length(kao.wrappedKey) : 0; - return { - kid: kao.kid ?? '(no kid)', - type: kao.type, - url: kao.url, - protocol: kao.protocol, - wrappedKeyBytes, - } satisfies KaoMetadata; - }); - setKaoMetadata(kaos); + setKaoMetadata(kaoMetadataFrom(manifest)); } catch (e) { console.warn('failed to read manifest for KAO inspection', e); setKaoMetadata(undefined); @@ -682,7 +703,7 @@ function App() {
Encapsulation Algorithm
- {' '} + {' '}
+
+ {' '} + +
{kaoMetadata && kaoMetadata.length > 0 && (
diff --git a/web-app/tests/tests/roundtrip.spec.ts b/web-app/tests/tests/roundtrip.spec.ts index c017d7fb7..437c3e399 100644 --- a/web-app/tests/tests/roundtrip.spec.ts +++ b/web-app/tests/tests/roundtrip.spec.ts @@ -71,7 +71,10 @@ for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { await authorize(page); await loadFile(page, 'README.md'); + // Both legs post-quantum: the KAO wrap on encrypt, and the client's + // ephemeral key for the rewrap exchange on decrypt. await page.locator('#encapAlgorithm').selectOption(algorithm); + await page.locator('#rewrapAlgorithm').selectOption(algorithm); const downloadPromise = page.waitForEvent('download'); await page.locator('#fileSink').click(); @@ -84,6 +87,12 @@ for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { throw new Error(); } + // The inspector is populated straight off the encrypt manifest, so the + // chosen wrap algorithm is observable without decrypting first. + await expect(page.locator('#kao-kid-0')).toHaveText(expectedKid); + await expect(page.locator('#kao-type-0')).toHaveText('mlkem-wrapped'); + await expect(page.locator('#kao-wrapped-bytes-0')).toHaveText(String(expectedWrappedKeyBytes)); + await page.locator('#clearFile').click(); await loadFile(page, cipherTextPath); const plainDownloadPromise = page.waitForEvent('download'); @@ -100,8 +109,8 @@ for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { 'try encrypting some of your own files' ); - // Manifest inspector should display the expected ML-KEM kid (mlkem768/1024) - // populated during the decrypt flow above. + // Decrypt clears the panel and repopulates it from the manifest it just + // read, so these assertions cannot pass on leftover encrypt-side state. await expect(page.locator('#kao-kid-0')).toHaveText(expectedKid); await expect(page.locator('#kao-type-0')).toHaveText('mlkem-wrapped'); await expect(page.locator('#kao-wrapped-bytes-0')).toHaveText(String(expectedWrappedKeyBytes)); From 15be776e2deeee865dd5337794eb35d365947473 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 20 Aug 2026 09:55:40 -0400 Subject: [PATCH 2/4] style(main): restructure web-app demo layout for narrow displays The page read as one wide row of unrelated fieldsets, and the Manifest Inspector was a six-column table that wrapped into itself below about a laptop width. Neither survives being recorded for a tablet or phone. Lay the page out as vertical flow with horizontal detail: source + sink -> encrypt | decrypt -> output + manifest Each action now carries the algorithm that affects it, so the wrap key selector sits with Encrypt and the rewrap key selector with Decrypt rather than in a shared config block. Encrypt and decrypt pair up on one row when there is room and stack when there isn't. Replace the inspector table with label/value rows per key access object, and give the session token pre its own scroll box: unconstrained it set the page's minimum width and forced the whole layout to scroll sideways. Signed-off-by: Dave Mihalcik --- web-app/src/App.css | 132 +++++++++++++++- web-app/src/App.tsx | 358 +++++++++++++++++++++++--------------------- 2 files changed, 315 insertions(+), 175 deletions(-) diff --git a/web-app/src/App.css b/web-app/src/App.css index 18d8c5d25..d09c3f7bb 100644 --- a/web-app/src/App.css +++ b/web-app/src/App.css @@ -13,16 +13,94 @@ margin-left: 10px; } +/* The session tokens are long unbroken strings. Keep them in their own scroll + box: unconstrained they set the page's minimum width and the whole layout + scrolls sideways, but wrapping them instead turns the header into thousands + of lines. */ +#user_token { + flex: 1; + max-height: 6em; + min-width: 0; + overflow: auto; + white-space: pre; +} + +.body { + padding: 0 24px 2em; +} + +/* Steps stack top to bottom in the order you work through them: choose a + source and sink, encrypt or decrypt, then read the output. Within a step, + left to right is action then the options that affect it. */ +.step { + margin: 1.5em 0; +} + +.step > h2 { + font-size: 1.1em; + letter-spacing: 0.04em; + margin: 0 0 0.5em; + text-transform: uppercase; +} + +/* Encrypt and decrypt are peers: pair them on one row when the viewport can + fit both, otherwise let them stack. */ +.actions { + display: flex; + flex-wrap: wrap; + gap: 1.5em 2em; + margin: 1.5em 0; +} + +/* Spacing comes from the container's gap; flex children don't collapse + margins, so leaving the .step margin on would double it when stacked. */ +.actions > .step { + flex: 0 1 auto; + margin: 0; +} + +.step-body { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: 1em; +} + +.step-body > fieldset { + flex: 1 1 15em; + min-width: 0; +} + +/* inline-flex so the action bar hugs its button and options instead of + stretching into a wide empty slab on a desktop viewport. */ .card { - border-radius: 1em; - padding: 2em; - margin: 1em; + align-items: center; background-color: cadetblue; + border-radius: 1em; + display: inline-flex; + padding: 1em; +} + +/* Per-step options, sitting on the action card. */ +.options { + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 0.5em; +} + +.options legend { + font-size: 0.8em; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status { + flex: 0 1 auto; } .horizontal-flow { display: flex; align-items: bottom; + flex-wrap: wrap; gap: 1em; } @@ -33,4 +111,50 @@ textarea { width: 100%; height: 200px; -} \ No newline at end of file +} + +/* Manifest Inspector */ +/* Needs to outrank `.step-body > fieldset` on specificity, hence the child + selector: the inspector should size to its content, not stretch. */ +.step-body > .inspector { + flex: 0 1 28em; +} + +.kao-list { + list-style: none; + margin: 0; + padding: 0; +} + +.kao + .kao { + border-top: 1px solid #ccc; + margin-top: 0.75em; + padding-top: 0.75em; +} + +.kao h3 { + font-size: 0.8em; + letter-spacing: 0.06em; + margin: 0 0 0.6em; + opacity: 0.7; + text-transform: uppercase; +} + +.kao dl { + display: grid; + /* minmax(0, …) so a long kas url wraps rather than widening the column. */ + grid-template-columns: auto minmax(0, 1fr); + gap: 0.4em 0.8em; + margin: 0; +} + +.kao dt { + opacity: 0.7; + white-space: nowrap; +} + +.kao dd { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + margin: 0; + overflow-wrap: anywhere; +} diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 2166a118b..108c0cda4 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -608,195 +608,211 @@ function App() { {SessionInfo}
-
-
- Source - {hasFileInput ? ( -
-

{'file' in inputSource ? inputSource.file.name : '[rand]'}

- {'file' in inputSource && ( - <> -
Content Type: {inputSource.file.type}
-
- Last Modified: {new Date(inputSource.file.lastModified).toLocaleString()} -
-
Size: {new Intl.NumberFormat().format(inputSource.file.size)} bytes
- - )} - -
- ) : ( - <> - - -
OR
-
- - -
-
OR:
-
- - +
+
+
+ Source + {hasFileInput ? ( +
+

{'file' in inputSource ? inputSource.file.name : '[rand]'}

+ {'file' in inputSource && ( + <> +
Content Type: {inputSource.file.type}
+
+ Last Modified: {new Date(inputSource.file.lastModified).toLocaleString()} +
+
Size: {new Intl.NumberFormat().format(inputSource.file.size)} bytes
+ + )} +
- - )} -
- -
- Sink -
- setSinkType(e.target.value as SinkType)} - checked={sinkType === 'file'} - />{' '} - -
- setSinkType(e.target.value as SinkType)} - checked={sinkType === 'fsapi'} - />{' '} - -
- setSinkType(e.target.value as SinkType)} - checked={sinkType === 'none'} - />{' '} - -
-
-
- Encapsulation Algorithm -
- {' '} - -
-
- {' '} - -
-
- {kaoMetadata && kaoMetadata.length > 0 && ( -
- Manifest Inspector - - - - - - - - - - - - - {kaoMetadata.map((kao, idx) => ( - - - - - - - - - ))} - -
#kidtypeprotocolwrappedKey byteskas url
{idx}{kao.kid}{kao.type}{kao.protocol}{kao.wrappedKeyBytes}{kao.url}
+ ) : ( + <> + + +
OR
+
+ + +
+
OR:
+
+ + +
+ + )}
- )} -
- {streamController && ( -
- +
+ Sink +
+ setSinkType(e.target.value as SinkType)} + checked={sinkType === 'file'} + />{' '} + +
+ setSinkType(e.target.value as SinkType)} + checked={sinkType === 'fsapi'} + />{' '} + +
+ setSinkType(e.target.value as SinkType)} + checked={sinkType === 'none'} + />{' '} + +
+
+
+ + {streamController && ( +
+
+ +
+
)} {inputSource && !streamController && ( -
-
+
+ {/* Encrypt and decrypt are alternatives rather than sequential steps, + so they sit side by side when there is room and stack when there + isn't. Each carries the options that affect it to its right. */} +

Encrypt

-
+
+
+ Options + {' '} + +
- -
+
+

Decrypt

-
+
+
+ Options + {' '} + +
- - {downloadState &&
{downloadState}
} +
)} + {(!!downloadState || !!kaoMetadata?.length) && ( +
+

Output

+
+ {downloadState && ( +
+ {downloadState} +
+ )} + {kaoMetadata?.length ? ( +
+ Manifest Inspector + {/* Label/value rows rather than a wide table: one key access + object is the common case, and this stays readable when the + viewport is too narrow for six columns. */} +
    + {kaoMetadata.map((kao, idx) => ( +
  1. +

    Key access object {idx}

    +
    +
    kid
    +
    {kao.kid}
    +
    type
    +
    {kao.type}
    +
    protocol
    +
    {kao.protocol}
    + {/* Unit is in the label so the value stays a bare number. */} +
    wrappedKey bytes
    +
    {kao.wrappedKeyBytes}
    +
    kas url
    +
    {kao.url}
    +
    +
  2. + ))} +
+
+ ) : null} +
+
+ )}
); From 15c62760ade20592b5a7e5d5638a71ff1fc137ed Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 20 Aug 2026 10:50:39 -0400 Subject: [PATCH 3/4] feat(main): tag web-app download names with wrap and rewrap mechanisms (DSPX-3229) Encrypt appends the wrap algorithm to the container name, e.g. README.md-mlkem768.tdf. Decrypt carries that qualifier through and appends the mechanism used for the rewrap exchange, so a folder of demo output says which encapsulation produced which file on both post-quantum legs. Signed-off-by: Dave Mihalcik --- web-app/src/App.tsx | 61 +++++++++++++++++++-------- web-app/tests/tests/roundtrip.spec.ts | 41 ++++++++++++++++-- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 108c0cda4..5ec33353e 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -27,28 +27,51 @@ async function toFile( return stream.pipeTo(fileStream, options); } -function decryptedFileName(encryptedFileName: string): string { - // Groups: 1 file 'name' bit - // 2: original file extension - // [non-capture group] - match how safari and chrome insert counters before extension. - // I'm guessing this has some fascinating internationalizations but for now WFM is enough. - // 3: TDF container type extension - const m = encryptedFileName.match(/^(.+)\.(\w+)(?:-\d+| \(\d+\))?\.(tdf|ztdf)$/); - console.log(encryptedFileName, m); +/** + * `mlkem:768` -> `mlkem768`. Colons are legal in a file name but awkward on + * Windows and in shell paths, and this matches the KAS kid convention. + */ +function algorithmSlug(algorithm: KasPublicKeyAlgorithm): string { + return algorithm.replace(':', ''); +} + +// Groups: 1 file 'name' bit +// 2: original file extension +// 3: the wrap qualifier we appended on encrypt, e.g. `-mlkem768`. Lazy so that a +// browser-inserted counter is left to the group below rather than swallowed. +// [non-capture group] - match how safari and chrome insert counters before extension. +// I'm guessing this has some fascinating internationalizations but for now WFM is enough. +// 4: TDF container type extension +const ENCRYPTED_FILE_NAME = /^(.+)\.(\w+)((?:-[\w=]+)*?)(?:-\d+| \(\d+\))?\.(tdf|ztdf)$/; + +function parseEncryptedFileName(encryptedFileName: string) { + const m = encryptedFileName.match(ENCRYPTED_FILE_NAME); if (!m) { console.warn(`Unable to extract raw file name from ${encryptedFileName}`); - return `${encryptedFileName}.decrypted`; + return undefined; } - return `${m[1]}.decrypted.${m[2]}`; + return { base: m[1], extension: m[2], wrapQualifier: m[3] }; } -function decryptedFileExtension(encryptedFileName: string): string { - const m = encryptedFileName.match(/^(.+)\.(\w+)\.(tdf|ztdf)$/); - if (!m) { - console.warn(`Unable to extract raw file name from ${encryptedFileName}`); - return `${encryptedFileName}.decrypted`; +/** + * Keeps the wrap qualifier the encrypt side added and records the mechanism the + * client used for the rewrap exchange, so the two post-quantum legs are both + * visible in the file name. + */ +function decryptedFileName( + encryptedFileName: string, + rewrapAlgorithm: KasPublicKeyAlgorithm +): string { + const rewrapQualifier = `-rwk-p=${algorithmSlug(rewrapAlgorithm)}`; + const parts = parseEncryptedFileName(encryptedFileName); + if (!parts) { + return `${encryptedFileName}${rewrapQualifier}.decrypted`; } - return m[2]; + return `${parts.base}${parts.wrapQualifier}${rewrapQualifier}.decrypted.${parts.extension}`; +} + +function decryptedFileExtension(encryptedFileName: string): string { + return parseEncryptedFileName(encryptedFileName)?.extension ?? 'decrypted'; } const oidcClient = new OidcClient(config.oidc.host, config.oidc.clientId, 'otdf-sample-web-app'); @@ -434,7 +457,9 @@ function App() { setDownloadState('Encrypting...'); setKaoMetadata(undefined); let f: FileSystemFileHandle | undefined; - const downloadName = `${inputFileName}.tdf`; + // Tag the container with the wrap algorithm so a folder full of demo output + // says which encapsulation produced which file. + const downloadName = `${inputFileName}-${algorithmSlug(encapAlgorithm)}.tdf`; if (sinkType === 'fsapi') { f = await getNewFileHandle('tdf', downloadName); } @@ -492,7 +517,7 @@ function App() { console.error('decrypt while logged out doesnt work'); return false; } - const dfn = decryptedFileName(fileNameFor(inputSource)); + const dfn = decryptedFileName(fileNameFor(inputSource), rewrapAlgorithm); console.log(`Decrypting ${JSON.stringify(inputSource)} to ${sinkType} ${dfn}`); // Drop anything left over from a previous encrypt or decrypt so the panel // always reflects the file we're reading now. diff --git a/web-app/tests/tests/roundtrip.spec.ts b/web-app/tests/tests/roundtrip.spec.ts index 437c3e399..65a761751 100644 --- a/web-app/tests/tests/roundtrip.spec.ts +++ b/web-app/tests/tests/roundtrip.spec.ts @@ -34,7 +34,8 @@ test('roundtrip ztdf', async ({ page }) => { await page.locator('#fileSink').click(); await page.locator('#encryptButton').click(); const download = await downloadPromise; - expect(download.suggestedFilename()).toContain('README.md.'); + // Encrypt tags the container with the default wrap algorithm. + expect(download.suggestedFilename()).toContain('README.md-ecsecp256r1'); const cipherTextPath = await download.path(); expect(cipherTextPath).toBeTruthy(); if (!cipherTextPath) { @@ -80,7 +81,9 @@ for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { await page.locator('#fileSink').click(); await page.locator('#encryptButton').click(); const download = await downloadPromise; - expect(download.suggestedFilename()).toContain('README.md.'); + // The wrap qualifier is the algorithm token without its colon, which for + // ML-KEM is also the KAS kid. + expect(download.suggestedFilename()).toContain(`README.md-${expectedKid}`); const cipherTextPath = await download.path(); expect(cipherTextPath).toBeTruthy(); if (!cipherTextPath) { @@ -117,6 +120,38 @@ for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { }); } +test('download names record both key wrap legs', async ({ page }) => { + await authorize(page); + await loadFile(page, 'README.md'); + await page.locator('#encapAlgorithm').selectOption('mlkem:768'); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#fileSink').click(); + await page.locator('#encryptButton').click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('README.md-mlkem768.tdf'); + const cipherTextPath = await download.path(); + if (!cipherTextPath) { + throw new Error(); + } + + // Re-upload under the name encrypt suggested. The other tests hand back + // playwright's temp uuid, which can't exercise the name parser at all. + const staged = test.info().outputPath(download.suggestedFilename()); + fs.copyFileSync(cipherTextPath, staged); + + await page.locator('#clearFile').click(); + await loadFile(page, staged); + await page.locator('#rewrapAlgorithm').selectOption('mlkem:1024'); + + const plainDownloadPromise = page.waitForEvent('download'); + await page.locator('#fileSink').click(); + await page.locator('#decryptButton').click(); + const download2 = await plainDownloadPromise; + // Wrap qualifier is carried through; the rewrap mechanism is appended. + expect(download2.suggestedFilename()).toBe('README-mlkem768-rwk-p=mlkem1024.decrypted.md'); +}); + test('Remote Source Streaming', async ({ page }) => { const server = await serve('.', 8086); @@ -130,7 +165,7 @@ test('Remote Source Streaming', async ({ page }) => { const download = await downloadPromise; const cipherTextPath = await download.path(); - expect(download.suggestedFilename()).toContain('README.md.'); + expect(download.suggestedFilename()).toContain('README.md-ecsecp256r1'); expect(cipherTextPath).toBeTruthy(); if (!cipherTextPath) { throw new Error(); From 2ae5da9135c7d8be5e5e19d0f23148ada90d250b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 20 Aug 2026 11:37:45 -0400 Subject: [PATCH 4/4] fix(main): name encrypted files after the wrap the KAS actually used (DSPX-3229) The encrypt-side download name was built from the dropdown value, but the requested algorithm is not guaranteed to be the one that wrapped the DEK: fetchKasPubKey tries fetchKasBasePubKey first with the algorithm argument dropped, and on mismatch the SDK only console.warns and proceeds. Selecting ML-KEM against a platform with a base key configured produced an RSA-wrapped container named README.md-mlkem768.tdf -- a false claim that outlives the console. CI cannot catch this: the roundtrip harness configures no base key, so it only ever exercises the algorithm-aware path. Compare the manifest's key access object type against the requested algorithm. On disagreement, name the container after the kid that actually wrapped it and show a warning in the Output panel. The Save As picker has to open while the click activation is live, so on that path we can only warn. Also from review of #1000: - Clear the Manifest Inspector when the file is cleared. It described the file being removed, and stale panel state made the decrypt-side assertions in the ML-KEM roundtrip satisfiable by leftover encrypt-side values. Assert the panel empties, and correct two test comments that claimed more than the test established. - Keep hyphenated source extensions intact. ENCRYPTED_FILE_NAME read every trailing hyphenated token as a wrap qualifier, so file.foo-bar-mlkem768.tdf decrypted back to a .foo file. The qualifier group now only matches slugs derived from PUBLIC_KEY_ALGORITHMS, which is what lets the extension keep its own hyphens. - Read the plaintext back in the cross-algorithm rewrap test. The download event fires when the stream opens, so a filename-only assertion would still have passed had the mlkem:768 -> mlkem:1024 exchange failed partway. - Ignore package-lock.json and the sample* round-trip fixtures in the harness directory; drop the dead /*.srl rule, since the CA serial lands in keys/. - Delete .horizontal-flow, orphaned by the layout restructure. This also removes its invalid `align-items: bottom`. - Extract the file name helpers to src/fileNames.ts and cover them with unit tests: browser counter suffixes, multi-dot and hyphenated extensions, the no-match fallback, and that every algorithm slug round-trips through the parser. - Narrow KaoMetadata.type to KeyAccessType. Signed-off-by: Dave Mihalcik --- .github/workflows/roundtrip/.gitignore | 13 +- web-app/src/App.css | 14 +- web-app/src/App.tsx | 115 +++++++-------- web-app/src/fileNames.test.ts | 187 +++++++++++++++++++++++++ web-app/src/fileNames.ts | 95 +++++++++++++ web-app/tests/tests/roundtrip.spec.ts | 22 ++- 6 files changed, 380 insertions(+), 66 deletions(-) create mode 100644 web-app/src/fileNames.test.ts create mode 100644 web-app/src/fileNames.ts diff --git a/.github/workflows/roundtrip/.gitignore b/.github/workflows/roundtrip/.gitignore index 69bc1166b..46fd67476 100644 --- a/.github/workflows/roundtrip/.gitignore +++ b/.github/workflows/roundtrip/.gitignore @@ -5,12 +5,21 @@ # Platform checkout (CI clones it here; locally it is usually a symlink) /platform -# KAS keys and CA material from ./init-temp-keys.sh +# KAS keys and CA material from ./init-temp-keys.sh. The CA serial lands in +# keys/ alongside the cert it numbers, so /keys/ already covers it. /*.pem -/*.srl /ecparams.tmp /keys/ # Keycloak admin CLI downloaded by ./config-demo-idp.sh /kc.zip /keycloak-*/ + +# ./wait-and-test.sh installs the packed CLI tarball here; only package.json is +# tracked, so the lock file it generates is throwaway. +/package-lock.json + +# Round-trip fixtures from ./encrypt-decrypt.sh. It only removes these on the +# success path, and it is `set -e`, so a failed run strands them. +/sample*.txt +/sample*.tdf diff --git a/web-app/src/App.css b/web-app/src/App.css index d09c3f7bb..4f7ff037c 100644 --- a/web-app/src/App.css +++ b/web-app/src/App.css @@ -97,11 +97,15 @@ flex: 0 1 auto; } -.horizontal-flow { - display: flex; - align-items: bottom; - flex-wrap: wrap; - gap: 1em; +/* The requested wrap algorithm and the one the KAS actually used can differ; + when they do, say so somewhere the progress counter won't overwrite. */ +.warning { + background-color: #fff3cd; + border: 1px solid #e0a800; + border-radius: 0.5em; + color: #664d03; + flex: 1 1 20em; + padding: 0.5em 0.75em; } .selected { diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 5ec33353e..7baf2e8c0 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -7,12 +7,19 @@ import { type Chunker, type DecoratedStream, type KasPublicKeyAlgorithm, + type KeyAccessType, type Manifest, type Source, OpenTDF, } from '@opentdf/sdk'; import { type SessionInformation, OidcClient } from './session.js'; import { config } from './config.js'; +import { + algorithmSlug, + decryptedFileExtension, + decryptedFileName, + expectedKaoType, +} from './fileNames.js'; async function toFile( stream: ReadableStream, @@ -27,53 +34,6 @@ async function toFile( return stream.pipeTo(fileStream, options); } -/** - * `mlkem:768` -> `mlkem768`. Colons are legal in a file name but awkward on - * Windows and in shell paths, and this matches the KAS kid convention. - */ -function algorithmSlug(algorithm: KasPublicKeyAlgorithm): string { - return algorithm.replace(':', ''); -} - -// Groups: 1 file 'name' bit -// 2: original file extension -// 3: the wrap qualifier we appended on encrypt, e.g. `-mlkem768`. Lazy so that a -// browser-inserted counter is left to the group below rather than swallowed. -// [non-capture group] - match how safari and chrome insert counters before extension. -// I'm guessing this has some fascinating internationalizations but for now WFM is enough. -// 4: TDF container type extension -const ENCRYPTED_FILE_NAME = /^(.+)\.(\w+)((?:-[\w=]+)*?)(?:-\d+| \(\d+\))?\.(tdf|ztdf)$/; - -function parseEncryptedFileName(encryptedFileName: string) { - const m = encryptedFileName.match(ENCRYPTED_FILE_NAME); - if (!m) { - console.warn(`Unable to extract raw file name from ${encryptedFileName}`); - return undefined; - } - return { base: m[1], extension: m[2], wrapQualifier: m[3] }; -} - -/** - * Keeps the wrap qualifier the encrypt side added and records the mechanism the - * client used for the rewrap exchange, so the two post-quantum legs are both - * visible in the file name. - */ -function decryptedFileName( - encryptedFileName: string, - rewrapAlgorithm: KasPublicKeyAlgorithm -): string { - const rewrapQualifier = `-rwk-p=${algorithmSlug(rewrapAlgorithm)}`; - const parts = parseEncryptedFileName(encryptedFileName); - if (!parts) { - return `${encryptedFileName}${rewrapQualifier}.decrypted`; - } - return `${parts.base}${parts.wrapQualifier}${rewrapQualifier}.decrypted.${parts.extension}`; -} - -function decryptedFileExtension(encryptedFileName: string): string { - return parseEncryptedFileName(encryptedFileName)?.extension ?? 'decrypted'; -} - const oidcClient = new OidcClient(config.oidc.host, config.oidc.clientId, 'otdf-sample-web-app'); async function getNewFileHandle( @@ -160,7 +120,7 @@ function getDecryptReadTuningFromLocation(): DecryptReadTuning { type KaoMetadata = { kid: string; - type: string; + type: KeyAccessType; url: string; protocol: string; wrappedKeyBytes: number; @@ -301,6 +261,9 @@ function App() { const [encapAlgorithm, setEncapAlgorithm] = useState('ec:secp256r1'); const [rewrapAlgorithm, setRewrapAlgorithm] = useState('rsa:2048'); const [kaoMetadata, setKaoMetadata] = useState(); + // Kept out of downloadState because the progress transformers overwrite that + // several times a second; a warning parked there would never be read. + const [algorithmWarning, setAlgorithmWarning] = useState(); const [streamController, setStreamController] = useState(); useEffect(() => { @@ -456,12 +419,17 @@ function App() { }); setDownloadState('Encrypting...'); setKaoMetadata(undefined); + setAlgorithmWarning(undefined); let f: FileSystemFileHandle | undefined; // Tag the container with the wrap algorithm so a folder full of demo output - // says which encapsulation produced which file. - const downloadName = `${inputFileName}-${algorithmSlug(encapAlgorithm)}.tdf`; + // says which encapsulation produced which file. This is only the algorithm + // we asked for: the Save As picker has to open while the click activation is + // still live, which is before the KAS has told us what it actually used. The + // download sink corrects the name below; for `fsapi` the user has already + // named the file, so there we can only warn. + const requestedName = `${inputFileName}-${algorithmSlug(encapAlgorithm)}.tdf`; if (sinkType === 'fsapi') { - f = await getNewFileHandle('tdf', downloadName); + f = await getNewFileHandle('tdf', requestedName); } const progressTransformers = makeProgressPair(size, 'Encrypt'); @@ -478,11 +446,36 @@ function App() { return; } // Surface the key access objects we just wrote, so the wrap algorithm choice - // is visible without having to decrypt first. Don't await; this shouldn't + // is visible without having to decrypt first. createZTDF attaches an + // already-resolved promise, so awaiting it costs a microtask and does not // hold up the download. - cipherText.manifest - ?.then((manifest) => setKaoMetadata(kaoMetadataFrom(manifest))) - .catch((e) => console.warn('failed to read manifest after encrypt', e)); + let downloadName = requestedName; + if (!cipherText.manifest) { + console.error('encrypt produced no manifest; cannot show key access objects'); + setAlgorithmWarning('Encrypted, but the SDK returned no manifest to inspect.'); + } else { + try { + const kaos = kaoMetadataFrom(await cipherText.manifest); + setKaoMetadata(kaos); + // What we asked for is not necessarily what wrapped the DEK: fetchKasPubKey + // prefers the platform base key and drops the requested algorithm, and the + // SDK only console.warns when the two disagree. Name the file after what + // actually happened rather than letting it assert something untrue. + const [kao] = kaos; + if (kao && kao.type !== expectedKaoType(encapAlgorithm)) { + downloadName = `${inputFileName}-${kao.kid}.tdf`; + setAlgorithmWarning( + `Requested ${encapAlgorithm}, but the KAS wrapped with ${kao.type} (kid ${kao.kid}). ` + + (sinkType === 'fsapi' + ? 'The name you chose does not reflect this.' + : `Saved as ${downloadName}.`) + ); + } + } catch (e) { + console.warn('failed to read manifest after encrypt', e); + setAlgorithmWarning(`Encrypted, but could not read the manifest to inspect it: ${e}`); + } + } const cipherTextWithProgress = cipherText.pipeThrough(progressTransformers.writer); try { switch (sinkType) { @@ -522,6 +515,7 @@ function App() { // Drop anything left over from a previous encrypt or decrypt so the panel // always reflects the file we're reading now. setKaoMetadata(undefined); + setAlgorithmWarning(undefined); let f: FileSystemFileHandle | undefined; if (sinkType === 'fsapi') { f = await getNewFileHandle(decryptedFileExtension(fileNameFor(inputSource)), dfn); @@ -654,6 +648,10 @@ function App() { onClick={() => { setInputSource(undefined); setDownloadState(undefined); + // The inspector describes the file being cleared, so it has + // to go too; otherwise it lingers over the next selection. + setKaoMetadata(undefined); + setAlgorithmWarning(undefined); }} type="button" > @@ -798,7 +796,7 @@ function App() {
)} - {(!!downloadState || !!kaoMetadata?.length) && ( + {(!!downloadState || !!kaoMetadata?.length || !!algorithmWarning) && (

Output

@@ -807,6 +805,11 @@ function App() { {downloadState}
)} + {algorithmWarning && ( + + )} {kaoMetadata?.length ? (
Manifest Inspector diff --git a/web-app/src/fileNames.test.ts b/web-app/src/fileNames.test.ts new file mode 100644 index 000000000..663bfce2c --- /dev/null +++ b/web-app/src/fileNames.test.ts @@ -0,0 +1,187 @@ +import { PUBLIC_KEY_ALGORITHMS } from '@opentdf/sdk'; +import { describe, expect, it, vi } from 'vitest'; +import { + algorithmSlug, + decryptedFileExtension, + decryptedFileName, + expectedKaoType, + parseEncryptedFileName, +} from './fileNames.js'; + +// The full KasPublicKeyAlgorithm union. Kept explicit rather than imported so +// that adding a member to the SDK shows up here as a missing case instead of +// silently widening the table. +const ALL_ALGORITHMS = [ + 'ec:secp256r1', + 'ec:secp384r1', + 'ec:secp521r1', + 'rsa:2048', + 'rsa:4096', + 'mlkem:768', + 'mlkem:1024', +] as const; + +// ENCRYPTED_FILE_NAME builds its qualifier alternation from PUBLIC_KEY_ALGORITHMS, +// so the table above has to stay in step with the SDK or the cases below stop +// covering what the parser actually accepts. +it('covers every algorithm the SDK exposes', () => { + expect([...ALL_ALGORITHMS]).toEqual([...PUBLIC_KEY_ALGORITHMS]); +}); + +describe('algorithmSlug', () => { + it.each([ + ['ec:secp256r1', 'ecsecp256r1'], + ['rsa:2048', 'rsa2048'], + ['mlkem:768', 'mlkem768'], + ['mlkem:1024', 'mlkem1024'], + ] as const)('%s -> %s', (algorithm, expected) => { + expect(algorithmSlug(algorithm)).toBe(expected); + }); + + // decryptedFileName concatenates the slug into a name that decrypt has to + // re-parse, so every slug must be one ENCRYPTED_FILE_NAME's qualifier group + // recognises -- including the three the dropdown offers but no test names. + it('produces a slug the file name parser can round-trip, for every algorithm', () => { + for (const algorithm of ALL_ALGORITHMS) { + const slug = algorithmSlug(algorithm); + expect(slug, `${algorithm} slug must be qualifier-safe`).toMatch(/^\w+$/); + expect(parseEncryptedFileName(`README.md-${slug}.tdf`)).toEqual({ + base: 'README', + extension: 'md', + wrapQualifier: `-${slug}`, + }); + } + }); +}); + +describe('expectedKaoType', () => { + it.each([ + ['ec:secp256r1', 'ec-wrapped'], + ['ec:secp521r1', 'ec-wrapped'], + ['rsa:2048', 'wrapped'], + ['rsa:4096', 'wrapped'], + ['mlkem:768', 'mlkem-wrapped'], + ['mlkem:1024', 'mlkem-wrapped'], + ] as const)('%s -> %s', (algorithm, expected) => { + expect(expectedKaoType(algorithm)).toBe(expected); + }); +}); + +describe('parseEncryptedFileName', () => { + it.each([ + // [input, base, extension, wrapQualifier] + ['README.md.tdf', 'README', 'md', ''], + ['README.md.ztdf', 'README', 'md', ''], + ['README.md-mlkem768.tdf', 'README', 'md', '-mlkem768'], + ['README.md-ecsecp256r1.tdf', 'README', 'md', '-ecsecp256r1'], + ['README.md-mlkem768.ztdf', 'README', 'md', '-mlkem768'], + // Chrome and Safari insert a counter when the name is already taken. It + // must land in the counter group, not be mistaken for a wrap qualifier. + ['README.md-mlkem768-1.tdf', 'README', 'md', '-mlkem768'], + ['README.md-mlkem768 (1).tdf', 'README', 'md', '-mlkem768'], + ['README.md-1.tdf', 'README', 'md', ''], + ['README.md (2).tdf', 'README', 'md', ''], + // Only the last dot-segment before the qualifier counts as the extension. + ['archive.tar.gz-mlkem768.tdf', 'archive.tar', 'gz', '-mlkem768'], + ['my file.md-mlkem768.tdf', 'my file', 'md', '-mlkem768'], + // A hyphen in the extension is not a wrap qualifier. Only slugs we emit are. + ['file.foo-bar-mlkem768.tdf', 'file', 'foo-bar', '-mlkem768'], + ['file.foo-bar.tdf', 'file', 'foo-bar', ''], + // A slug-shaped extension is read as the extension, because the qualifier + // group only gets what the extension leaves and the extension needs at least + // one character. `file.mlkem768` is the likelier source name anyway. + ['file.mlkem768.tdf', 'file', 'mlkem768', ''], + // Re-encrypting a decrypted file: the `=` in the rewrap qualifier survives. + [ + 'README-mlkem768-rwk-p=mlkem1024.decrypted.md-mlkem768.tdf', + 'README-mlkem768-rwk-p=mlkem1024.decrypted', + 'md', + '-mlkem768', + ], + ])('parses %s', (input, base, extension, wrapQualifier) => { + expect(parseEncryptedFileName(input)).toEqual({ base, extension, wrapQualifier }); + }); + + it.each([ + ['README.md', 'no container extension'], + ['Makefile-mlkem768.tdf', 'no inner extension to recover'], + ['sample.tdf', 'no inner extension to recover'], + ['README.md-mlkem768.TDF', 'container extension is matched case-sensitively'], + ['random-bytes-1048576-bytes-mlkem768.tdf', "the app's own random-source name"], + ])('returns undefined for %s (%s)', (input) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(parseEncryptedFileName(input)).toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); + + // Documents a known limitation rather than endorsing it: the counter + // alternative claims an all-digit token that the qualifier group has already + // declined. Unreachable, since every slug in PUBLIC_KEY_ALGORITHMS starts with + // a letter, and the assertion above keeps that true. + it('mistakes an all-digit wrap qualifier for a browser counter', () => { + expect(parseEncryptedFileName('README.md-2048.tdf')).toEqual({ + base: 'README', + extension: 'md', + wrapQualifier: '', + }); + }); +}); + +describe('decryptedFileName', () => { + it('carries the wrap qualifier through and appends the rewrap mechanism', () => { + expect(decryptedFileName('README.md-mlkem768.tdf', 'mlkem:1024')).toBe( + 'README-mlkem768-rwk-p=mlkem1024.decrypted.md' + ); + }); + + it('records the rewrap leg even when the container carries no wrap qualifier', () => { + expect(decryptedFileName('README.md.tdf', 'rsa:2048')).toBe( + 'README-rwk-p=rsa2048.decrypted.md' + ); + }); + + it('keeps a hyphenated extension whole', () => { + expect(decryptedFileName('file.foo-bar-mlkem768.tdf', 'mlkem:1024')).toBe( + 'file-mlkem768-rwk-p=mlkem1024.decrypted.foo-bar' + ); + }); + + it('drops a browser-inserted counter rather than treating it as a qualifier', () => { + expect(decryptedFileName('README.md-mlkem768 (1).tdf', 'rsa:2048')).toBe( + 'README-mlkem768-rwk-p=rsa2048.decrypted.md' + ); + }); + + it('falls back to suffixing the whole name when it cannot be parsed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(decryptedFileName('sample.tdf', 'mlkem:768')).toBe( + 'sample.tdf-rwk-p=mlkem768.decrypted' + ); + warn.mockRestore(); + }); +}); + +describe('decryptedFileExtension', () => { + it('recovers the original extension', () => { + expect(decryptedFileExtension('README.md-mlkem768.tdf')).toBe('md'); + }); + + // The picker's accept filter has to match the name decryptedFileName offers + // for the same input, or the Save As dialog rejects its own suggestion. + it.each(['README.md-mlkem768.tdf', 'README.md.tdf', 'sample.tdf', 'Makefile-mlkem768.tdf'])( + 'agrees with the suggested name for %s', + (input) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const suggested = decryptedFileName(input, 'rsa:2048'); + expect(suggested.endsWith(`.${decryptedFileExtension(input)}`)).toBe(true); + warn.mockRestore(); + } + ); + + it('falls back to `decrypted`, matching the unparseable-name suffix', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(decryptedFileExtension('sample.tdf')).toBe('decrypted'); + warn.mockRestore(); + }); +}); diff --git a/web-app/src/fileNames.ts b/web-app/src/fileNames.ts new file mode 100644 index 000000000..98798752b --- /dev/null +++ b/web-app/src/fileNames.ts @@ -0,0 +1,95 @@ +import { + type KasPublicKeyAlgorithm, + type KeyAccessType, + PUBLIC_KEY_ALGORITHMS, +} from '@opentdf/sdk'; + +/** + * `mlkem:768` -> `mlkem768`. Colons are legal in a file name but awkward on + * Windows and in shell paths. For ML-KEM the slug happens to equal the KAS kid + * (`mlkem:768` -> kid `mlkem768`); EC and RSA kids are unrelated (`e1`, `r1`). + * + * Every member of KasPublicKeyAlgorithm has exactly one colon and is otherwise + * alphanumeric, so a non-global replace is enough today; a hybrid identifier + * such as `ec:secp256r1+mlkem:768` would need `replaceAll`. + */ +export function algorithmSlug(algorithm: KasPublicKeyAlgorithm): string { + return algorithm.replace(':', ''); +} + +/** + * The key access object type the KAS should produce for a requested wrap + * algorithm. Used to detect the case where it produced something else. + */ +export function expectedKaoType(algorithm: KasPublicKeyAlgorithm): KeyAccessType { + if (algorithm.startsWith('mlkem:')) { + return 'mlkem-wrapped'; + } + return algorithm.startsWith('ec:') ? 'ec-wrapped' : 'wrapped'; +} + +// Longest first so a slug that is a prefix of another cannot win and strand the +// rest of the name. Nothing in the current list overlaps; this is insurance for +// whatever gets added next. +const ALGORITHM_SLUGS = PUBLIC_KEY_ALGORITHMS.map(algorithmSlug).sort( + (a, b) => b.length - a.length +); + +// Groups: 1 file 'name' bit +// 2: original file extension. Lazy, and allows `-`, so that a hyphenated +// extension is kept whole: without both, `file.foo-bar-mlkem768.tdf` parses as +// extension `foo` and the decrypted name silently loses the `-bar`. +// 3: the wrap qualifier we appended on encrypt, e.g. `-mlkem768`. Restricted to +// slugs we actually emit, which is what lets group 2 tell `-bar` (part of the +// extension) apart from `-mlkem768` (not). Repeated only for tolerance; a +// `.tdf` name never carries more than one, because the rewrap qualifier +// decrypt appends lands on a `.decrypted.` name and so ends up in group 1. +// [non-capture group] - match how safari and chrome insert counters before extension. +// I'm guessing this has some fascinating internationalizations but for now WFM is enough. +// 4: TDF container type extension +export const ENCRYPTED_FILE_NAME = new RegExp( + `^(.+)\\.([\\w-]+?)((?:-(?:${ALGORITHM_SLUGS.join('|')}))*)(?:-\\d+| \\(\\d+\\))?\\.(tdf|ztdf)$` +); + +export type ParsedEncryptedFileName = { + base: string; + extension: string; + /** Empty, or one or more `-token` segments *including* the leading `-`. */ + wrapQualifier: string; +}; + +export function parseEncryptedFileName( + encryptedFileName: string +): ParsedEncryptedFileName | undefined { + const m = encryptedFileName.match(ENCRYPTED_FILE_NAME); + if (!m) { + console.warn(`Unable to extract raw file name from ${encryptedFileName}`); + return undefined; + } + return { base: m[1], extension: m[2], wrapQualifier: m[3] }; +} + +/** + * Keeps the wrap qualifier the encrypt side added and records the mechanism the + * client used for the rewrap exchange, so the two post-quantum legs are both + * visible in the file name. + */ +export function decryptedFileName( + encryptedFileName: string, + rewrapAlgorithm: KasPublicKeyAlgorithm +): string { + const rewrapQualifier = `-rwk-p=${algorithmSlug(rewrapAlgorithm)}`; + const parts = parseEncryptedFileName(encryptedFileName); + if (!parts) { + return `${encryptedFileName}${rewrapQualifier}.decrypted`; + } + return `${parts.base}${parts.wrapQualifier}${rewrapQualifier}.decrypted.${parts.extension}`; +} + +/** + * The extension to offer the Save As picker. Falls back to `decrypted`, which + * matches the suffix {@link decryptedFileName} produces on the same input. + */ +export function decryptedFileExtension(encryptedFileName: string): string { + return parseEncryptedFileName(encryptedFileName)?.extension ?? 'decrypted'; +} diff --git a/web-app/tests/tests/roundtrip.spec.ts b/web-app/tests/tests/roundtrip.spec.ts index 65a761751..23f9b39b5 100644 --- a/web-app/tests/tests/roundtrip.spec.ts +++ b/web-app/tests/tests/roundtrip.spec.ts @@ -97,6 +97,11 @@ for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { await expect(page.locator('#kao-wrapped-bytes-0')).toHaveText(String(expectedWrappedKeyBytes)); await page.locator('#clearFile').click(); + // Clearing the file clears the inspector with it. Without this the decrypt + // assertions below could be satisfied by leftover encrypt-side state: both + // legs read the same manifest, so they expect identical values. + await expect(page.locator('#kaoMetadata')).toBeHidden(); + await loadFile(page, cipherTextPath); const plainDownloadPromise = page.waitForEvent('download'); await page.locator('#fileSink').click(); @@ -112,8 +117,8 @@ for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { 'try encrypting some of your own files' ); - // Decrypt clears the panel and repopulates it from the manifest it just - // read, so these assertions cannot pass on leftover encrypt-side state. + // Repopulated by the decrypt-side read. The panel was asserted empty after + // #clearFile above, so these cannot be the encrypt-side values lingering. await expect(page.locator('#kao-kid-0')).toHaveText(expectedKid); await expect(page.locator('#kao-type-0')).toHaveText('mlkem-wrapped'); await expect(page.locator('#kao-wrapped-bytes-0')).toHaveText(String(expectedWrappedKeyBytes)); @@ -136,7 +141,8 @@ test('download names record both key wrap legs', async ({ page }) => { } // Re-upload under the name encrypt suggested. The other tests hand back - // playwright's temp uuid, which can't exercise the name parser at all. + // playwright's temp uuid, which has no `.tdf` suffix, so they only ever reach + // the parser's no-match fallback and never its successful-parse branch. const staged = test.info().outputPath(download.suggestedFilename()); fs.copyFileSync(cipherTextPath, staged); @@ -150,6 +156,16 @@ test('download names record both key wrap legs', async ({ page }) => { const download2 = await plainDownloadPromise; // Wrap qualifier is carried through; the rewrap mechanism is appended. expect(download2.suggestedFilename()).toBe('README-mlkem768-rwk-p=mlkem1024.decrypted.md'); + + // The download event fires when the stream opens, not when it finishes, so the + // name alone would still be asserted had the mlkem:768 -> mlkem:1024 rewrap + // failed partway. Read the bytes back to pin that the exchange completed. + const plainTextPath = await download2.path(); + if (!plainTextPath) { + throw new Error(); + } + const text = await readFile(plainTextPath, 'utf8'); + expect(text).toContain('try encrypting some of your own files'); }); test('Remote Source Streaming', async ({ page }) => {