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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/curly-cups-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@getodk/xforms-engine': minor
'@getodk/scenario': minor
'@getodk/common': minor
'@getodk/web-forms': minor
---

Added support for submission encryption
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run
<summary>

<!-- prettier-ignore -->
##### Misc<br/>⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜ 0\%
##### Misc<br/>🟩⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜ 11\%

</summary>
<br/>
Expand All @@ -349,7 +349,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run
| save as draft | |
| offline entities | |
| MBtiles / offline map layers | |
| [submission encryption](https://github.com/getodk/web-forms/issues/448) | |
| [submission encryption](https://github.com/getodk/web-forms/issues/448) | |

</details>

Expand Down
2 changes: 1 addition & 1 deletion feature-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,6 @@
"save as draft": "",
"offline entities": "",
"MBtiles / offline map layers": "",
"[submission encryption](https://github.com/getodk/web-forms/issues/448)": ""
"[submission encryption](https://github.com/getodk/web-forms/issues/448)": ""
}
}
3 changes: 3 additions & 0 deletions packages/common/src/constants/xmlns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export type JAVAROSA_NAMESPACE_URI = typeof JAVAROSA_NAMESPACE_URI;
export const ODK_NAMESPACE_URI = 'http://www.opendatakit.org/xforms';
export type ODK_NAMESPACE_URI = typeof ODK_NAMESPACE_URI;

export const ODK_SUBMISSIONS_NAMESPACE_URI = 'http://opendatakit.org/submissions';
export type ODK_SUBMISSIONS_NAMESPACE_URI = typeof ODK_SUBMISSIONS_NAMESPACE_URI;

export const OPENROSA_XFORMS_NAMESPACE_URI = 'http://openrosa.org/xforms';
export type OPENROSA_XFORMS_NAMESPACE_URI = typeof OPENROSA_XFORMS_NAMESPACE_URI;

Expand Down
24 changes: 24 additions & 0 deletions packages/common/src/fixtures/encryption/encryption.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:jr="http://openrosa.org/javarosa" xmlns:odk="http://www.opendatakit.org/xforms" xmlns:orx="http://openrosa.org/xforms" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<h:head>
<h:title>encrypted</h:title>
<model>
<submission base64RsaPublicKey="MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsrq8xrFswX/Ht1X4UVeLjupqz8hCMo+GR3hwqZdx7BQjvw9KBcCL+J6CI/2yyTqprVmeWZZu/i9qjuYcyWir3ZonZIDaVvClP8LN7e0+0SgQgEV+v9bjGVTDMQIKY2vq2ZNEbuy4UAHFLJCwGaUE370w76r/Da4YbAgfGVQn1sHarJ8Zp1o/6RE1IckxA8L2spo8oSU23KnttLIaR2qIS7mY+BkZPItyyNjulpJUZlxf4AgO7T8S4grmOC5TW4laB25vjbPw4KzB3L8bm+oK5JjlocazOiyUVDz8UwYMQke4ybEwSJbu3gl7DJzlwwQ1u3AbtjZk2T7LKUotrkVzAQIDAQAB"/>
<instance>
<encrypted id="encrypted">
<question/>
<meta>
<instanceID/>
</meta>
</encrypted>
</instance>
<bind nodeset="/encrypted/question" type="string"/>
<bind jr:preload="uid" nodeset="/encrypted/meta/instanceID" readonly="true()" type="string"/>
</model>
</h:head>
<h:body>
<input ref="/encrypted/question">
<label>Question 1</label>
</input>
</h:body>
</h:html>
1 change: 1 addition & 0 deletions packages/common/src/fixtures/upload/file-upload.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<bind nodeset="/data/doc" type="binary" />
<bind nodeset="/data/anything" type="binary" required="true()" />
<bind nodeset="/data/disabled" type="binary" readonly="true()" />
<bind jr:preload="uid" nodeset="/data/meta/instanceID" readonly="true()" type="string"/>
</model>
</h:head>
<h:body>
Expand Down
81 changes: 81 additions & 0 deletions packages/scenario/test/submission-encryption.test.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There doesn't seem to be any test here that the encrypted content can be de-crypted. Is that expected?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that is the glaring hole in this PR... The reason is that WF doesn't currently have the testing infrastructure to start a Central instance to test against. I think we should build that out for this and many other tests that would benefit from full e2e testing. However if the code ends up in the central-frontend repo a lot of this work is already done, so I'm delaying it till then. Issue raised to capture this: #775

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about copy/pasting the central code here? Or cloning the central repo, and running the relevant code directly with node?

Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts';
import {
bind,
body,
head,
html,
input,
label,
mainInstance,
model,
t,
title,
} from '@getodk/common/test-utils/xform-dsl/index.ts';
import type { XFormsElement } from '@getodk/common/test-utils/xform-dsl/XFormsElement.ts';
import { describe, expect, it } from 'vitest';
import { Scenario } from '../src/jr/Scenario.ts';

describe('Form submission encryption', () => {
const DEFAULT_INSTANCE_ID = 'uuid:TODO-mock-xpath-functions';

// prettier-ignore
type SubmissionFixtureElements =
| readonly []
| readonly [XFormsElement];

interface BuildSubmissionPayloadScenario {
readonly submissionElements?: SubmissionFixtureElements;
}

const buildSubmissionPayloadScenario = async (
options?: BuildSubmissionPayloadScenario
): Promise<Scenario> => {
const scenario = await Scenario.init(
'Encrypted',
html(
head(
title('Encrypted'),
model(
mainInstance(t('data id="encrypted"', t('inp', 'test'), t('meta', t('instanceID')))),
...(options?.submissionElements ?? []),
bind('/data/inp').required(),
bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`)
)
),
body(input('/data/rep/inp', label('inp')))
)
);

return scenario;
};

it('includes a form-specified `base64RsaPublicKey` as encryptionKey', async () => {
const base64RsaPublicKey =
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwkP+HQEqkyb4HPLOekvn6imYW6Ze2dF2sLCspnzimOnbiF7C1mcd01xiau+9WgU23kM35URhBQVbDHtbQMgZL/Ol+xdA0zdbcUW00Z7EkYmM4sGu4wwJA2eQ6yhBbY2np+kDTvmVHlhP8DDYsXJKqtm+8bXlI36qjVgkVPXjT9YNAA4vRxPReP5wuXHrMGjclPyU6SlFZZm8QLknYV9cmGh1CquKxK7/hIoGIZ3j+edh2GZg8XJo3ZkgAwOwNUqF9b4kXw+tnbpqLXfcETX3fp6iXqLqNMt3E1MXXMnePfDqsa9wrcykUMKfxLXF/EyhIZ+2+iBoyRKeIkExwJRMdQIDAQAB';
const scenario = await buildSubmissionPayloadScenario({
submissionElements: [t(`submission base64RsaPublicKey="${base64RsaPublicKey}"`)],
});
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult.submissionMeta).toMatchObject({
encryptionKey: base64RsaPublicKey,
});

expect(submissionResult.data.length).to.equal(1);
const entries = submissionResult.data[0].entries();

const [submissionFilename, file] = entries.next().value!;
expect(submissionFilename).to.equal('xml_submission_file');
const submission = await getBlobText(file);
expect(submission).to.contain(
'<data xmlns="http://opendatakit.org/submissions" encrypted="yes" id="encrypted">'
);
expect(submission).to.contain('<encryptedXmlFile>submission.xml.enc</encryptedXmlFile>');
expect(submission).to.contain(
'<meta xmlns="http://openrosa.org/xforms"><instanceID>uuid:TODO-mock-xpath-functions</instanceID></meta>'
);

const [encodedFilename] = entries.next().value!;
expect(encodedFilename).to.equal('submission.xml.enc');
});
});
23 changes: 0 additions & 23 deletions packages/scenario/test/submission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ import {
t,
title,
} from '@getodk/common/test-utils/xform-dsl/index.ts';
import { TagXFormsElement } from '@getodk/common/test-utils/xform-dsl/TagXFormsElement.ts';
import type { XFormsElement } from '@getodk/common/test-utils/xform-dsl/XFormsElement.ts';
import { createUniqueId } from 'solid-js';
import { beforeEach, describe, expect, it } from 'vitest';
import { Scenario } from '../src/jr/Scenario.ts';
import { ANSWER_OK, ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts';
Expand Down Expand Up @@ -808,27 +806,6 @@ describe('Form submission', () => {
await expect(init).rejects.toThrow();
}
);

it('includes a form-specified `base64RsaPublicKey` as encryptionKey', async () => {
const base64RsaPublicKey = btoa(createUniqueId());
const scenario = await buildSubmissionPayloadScenario({
submissionElements: [
// Note: `t()` fails here, presumably because the ported JavaRosa
// `parseAttributes` doesn't expect equals signs as produced in
// the trailing base64 value.
Comment thread
alxndrsn marked this conversation as resolved.
new TagXFormsElement(
'submission',
new Map([['base64RsaPublicKey', base64RsaPublicKey]]),
[]
),
],
});
const submissionResult = await scenario.prepareWebFormsInstancePayload();

expect(submissionResult.submissionMeta).toMatchObject({
encryptionKey: base64RsaPublicKey,
});
});
});

describe('for a single (monolithic) request', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ import type { DescendantNodeViolationReference } from '../../../client/validatio
import { ErrorProductionDesignPendingError } from '../../../error/ErrorProductionDesignPendingError.ts';
import type { InstanceAttachmentsState } from '../../../instance/attachments/InstanceAttachmentsState.ts';
import type { ClientReactiveSerializableInstance } from '../../../instance/internal-api/serialization/ClientReactiveSerializableInstance.ts';
import type { Root } from '../../../instance/Root.ts';
import { encryptSubmission } from './quarantine/encryption.ts';

const getAttribute = (root: Root, name: string) => {
const attribute = root.getAttributes().find((a) => a.definition.qualifiedName.localName === name);
return attribute?.definition.value;
};

const getInstanceID = (root: Root) => {
const meta = root.getChildren().find((c) => c.definition.qualifiedName.localName === 'meta');
const instanceID = meta
?.getChildren()
.find((c) => c.definition.qualifiedName.localName === 'instanceID');
return instanceID?.getXPathValue();
};

const collectInstanceAttachmentFiles = (attachments: InstanceAttachmentsState): readonly File[] => {
const files = Array.from(attachments.entries()).map(([context, attachment]) => {
Expand All @@ -31,15 +46,47 @@ class InstanceFile extends File implements ClientInstanceFile {
override readonly name = INSTANCE_FILE_NAME;
override readonly type = INSTANCE_FILE_TYPE;

constructor(instanceRoot: ClientReactiveSerializableInstance) {
const { instanceXML } = instanceRoot.instanceState;

constructor(instanceXML: string) {
super([instanceXML], INSTANCE_FILE_NAME, {
type: INSTANCE_FILE_TYPE,
});
}
}

export interface Submission {
readonly instanceXML: string;
readonly attachments: readonly File[];
}

const collectInstanceFiles = async (
instanceRoot: ClientReactiveSerializableInstance,
submissionMeta: SubmissionMeta
): Promise<Submission> => {
const instanceXML = instanceRoot.instanceState.instanceXML;
const attachments = collectInstanceAttachmentFiles(instanceRoot.attachments);
if (submissionMeta.encryptionKey) {
const root = instanceRoot.root;
const formId = getAttribute(root, 'id');
const instanceId = getInstanceID(root);
const formVersion = getAttribute(root, 'version');
if (!formId) {
throw new Error('Encrypted submissions are required to have a form ID');
}
if (!instanceId) {
throw new Error('Encrypted submissions are required to have an instance ID');
}
return await encryptSubmission(
formId,
formVersion,
instanceId,
instanceXML,
attachments,
submissionMeta.encryptionKey
);
}
return { instanceXML, attachments };
};

type AssertFile = (value: FormDataEntryValue) => asserts value is File;

const assertFile: AssertFile = (value) => {
Expand Down Expand Up @@ -204,15 +251,16 @@ export interface PrepareInstancePayloadOptions<PayloadType extends InstancePaylo
readonly maxSize: number;
}

export const prepareInstancePayload = <PayloadType extends InstancePayloadType>(
export const prepareInstancePayload = async <PayloadType extends InstancePayloadType>(
instanceRoot: ClientReactiveSerializableInstance,
options: PrepareInstancePayloadOptions<PayloadType>
): InstancePayload<PayloadType> => {
): Promise<InstancePayload<PayloadType>> => {
instanceRoot.root.parent.model.triggerXformsRevalidateListeners();
const validation = validateInstance(instanceRoot);
const submissionMeta = instanceRoot.definition.submission;
const instanceFile = new InstanceFile(instanceRoot);
const attachments = collectInstanceAttachmentFiles(instanceRoot.attachments);

const { instanceXML, attachments } = await collectInstanceFiles(instanceRoot, submissionMeta);
const instanceFile = new InstanceFile(instanceXML);

switch (options.payloadType) {
case 'chunked':
Expand Down
Comment thread
garethbowen marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Encryption

> [!CAUTION]
> Custom encryption is a bad idea. Do not use this unless absolutely necessary.

> [!CAUTION]
> Modification of this code requires great care as a bug in the encryption algorithm will make submissions unrecoverable.

The code in this directory implements the [ODK Spec](https://getodk.github.io/xforms-spec/encryption) which is very particular about how it's done so as to be compatible with other implementations.

## Implementation

The symmetric encryption parts of the spec are implemented using CryptoJS because the particular algorithm required by the spec is not supported by Subtle Crypto, and we use CryptoJS elsewhere.

The asymmetric components of the spec are implemented using the [Subtle Crypto web spec](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) because CryptoJS doesn't implement asymmetric encryption.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* WARNING: DO NOT USE
*
* More info: README.md
*/
const ASYMMETRIC_ALGORITHM = 'RSA-OAEP';
const HASH_FUNCTION = 'SHA-256';
const KEY_FORMAT = 'spki';

// Equivalent to "RSA/NONE/OAEPWithSHA256AndMGF1Padding"
const rsaEncrypt = async (symmetricKey: Uint8Array<ArrayBuffer>, publicKey: CryptoKey) => {
const encrypted = await crypto.subtle.encrypt(
{ name: ASYMMETRIC_ALGORITHM },
publicKey,
symmetricKey
);
return btoa(String.fromCharCode(...new Uint8Array(encrypted)));
Comment thread
alxndrsn marked this conversation as resolved.
};

const generatePublicKey = async (encryptionKey: string) => {
const binaryKey = atob(encryptionKey);
const data = new Uint8Array(binaryKey.length);
for (let i = 0; i < binaryKey.length; i++) {
data[i] = binaryKey.charCodeAt(i);
}
return await crypto.subtle.importKey(
KEY_FORMAT,
data,
{
name: ASYMMETRIC_ALGORITHM,
hash: HASH_FUNCTION,
},
false,
['encrypt']
);
};

export const getEncryptedSymmetricKey = async (
encryptionKey: string,
symmetricKey: Uint8Array<ArrayBuffer>
): Promise<string> => {
const publicKey = await generatePublicKey(encryptionKey);
return await rsaEncrypt(symmetricKey, publicKey);
};
Loading
Loading