Skip to content

Commit fffd8b3

Browse files
committed
fix(sharing): share OOB devices only using fingerprint
Otherwise IMEI could be guessed.
1 parent 5d48928 commit fffd8b3

File tree

10 files changed

+296
-52
lines changed

10 files changed

+296
-52
lines changed

.github/workflows/test-and-release.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ jobs:
205205
run: |
206206
./cli.sh configure-nrfcloud-account apiEndpoint ${{ env.HTTP_API_MOCK_API_URL }}api.nrfcloud.com/
207207
./cli.sh configure-nrfcloud-account apiKey apiKey_Nordic
208+
./cli.sh configure-hello apiEndpoint ${{ env.HTTP_API_MOCK_API_URL }}hello-api/
208209
209210
- name: Deploy solution stack
210211
env:

cdk/resources/ShareAPI.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Construct } from 'constructs'
88
import type { PublicDevices } from './PublicDevices.js'
99
import { LambdaLogGroup } from '@bifravst/aws-cdk-lambda-helpers/cdk'
1010
import type { BackendLambdas } from '../BackendLambdas.js'
11+
import { Permissions } from '@hello.nrfcloud.com/nrfcloud-api-helpers/cdk'
1112

1213
export class ShareAPI extends Construct {
1314
public readonly shareFn: Lambda.IFunction
@@ -47,6 +48,7 @@ export class ShareAPI extends Construct {
4748
FROM_EMAIL: `notification@${domain}`,
4849
NODE_NO_WARNINGS: '1',
4950
IS_TEST: this.node.getContext('isTest') === true ? '1' : '0',
51+
STACK_NAME: Stack.of(this).stackName,
5052
},
5153
...new LambdaLogGroup(this, 'shareFnLogs'),
5254
initialPolicy: [
@@ -63,6 +65,7 @@ export class ShareAPI extends Construct {
6365
},
6466
},
6567
}),
68+
Permissions(Stack.of(this)),
6669
],
6770
})
6871
publicDevices.publicDevicesTable.grantWriteData(this.shareFn)

cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { env } from '../aws/env.js'
1616
import { configureNrfCloudAccount } from './commands/configure-nrfcloud-account.js'
1717
import { logsCommand } from './commands/logs.js'
1818
import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'
19+
import { configureHello } from './commands/configure-hello.js'
1920

2021
const ssm = new SSMClient({})
2122
const db = new DynamoDBClient({})
@@ -49,6 +50,7 @@ const CLI = async ({ isCI }: { isCI: boolean }) => {
4950
buildContainersCommand({
5051
ecr,
5152
}),
53+
configureHello({ ssm }),
5254
configureNrfCloudAccount({ ssm }),
5355
logsCommand({
5456
stackName: STACK_NAME,

cli/commands/configure-hello.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { SSMClient } from '@aws-sdk/client-ssm'
2+
import chalk from 'chalk'
3+
import { STACK_NAME } from '../../cdk/stacks/stackConfig.js'
4+
import type { CommandDefinition } from './CommandDefinition.js'
5+
import {
6+
deleteSettings,
7+
putSetting,
8+
type Settings,
9+
} from '../../settings/hello.js'
10+
11+
export const configureHello = ({
12+
ssm,
13+
}: {
14+
ssm: SSMClient
15+
}): CommandDefinition => ({
16+
command: 'configure-hello <property> [value]',
17+
options: [
18+
{
19+
flags: '-d, --deleteBeforeUpdate',
20+
description: `Useful when depending on the parameter having version 1, e.g. for use in CloudFormation`,
21+
},
22+
{
23+
flags: '-X, --deleteParameter',
24+
description: 'Deletes the parameter.',
25+
},
26+
],
27+
action: async (
28+
property: keyof Settings,
29+
value: string | undefined,
30+
{ deleteBeforeUpdate, deleteParameter },
31+
) => {
32+
if (deleteParameter !== undefined) {
33+
// Delete
34+
const { name } = await deleteSettings({
35+
ssm,
36+
stackName: STACK_NAME,
37+
})(property)
38+
console.log()
39+
console.log(
40+
chalk.green('Deleted the parameters from'),
41+
chalk.blueBright(name),
42+
)
43+
return
44+
}
45+
46+
if (value === undefined || value.length === 0) {
47+
throw new Error(`Must provide value!`)
48+
}
49+
50+
const { name } = await putSetting({
51+
ssm,
52+
stackName: STACK_NAME,
53+
})(property as keyof Settings, new URL(value), deleteBeforeUpdate)
54+
55+
console.log()
56+
console.log(
57+
chalk.green('Updated the configuration'),
58+
chalk.blueBright(name),
59+
chalk.green('to'),
60+
chalk.yellow(value),
61+
)
62+
},
63+
help: 'Configure the system.',
64+
})

cli/commands/configure-nrfcloud-account.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const configureNrfCloudAccount = ({
4646
}
4747

4848
if (value === undefined || value.length === 0) {
49-
throw new Error(`Must provide value either as argument or via stdin!`)
49+
throw new Error(`Must provide value!`)
5050
}
5151

5252
const { name } = await putSetting({

feature-runner/run-features.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ import path from 'node:path'
66
import type { StackOutputs as BackendStackOutputs } from '../cdk/stacks/BackendStack.js'
77
import { STACK_NAME } from '../cdk/stacks/stackConfig.js'
88
import { steps as storageSteps } from '@hello.nrfcloud.com/bdd-markdown-steps/storage'
9-
import {
10-
steps as randomSteps,
11-
email,
12-
IMEI,
13-
} from '@hello.nrfcloud.com/bdd-markdown-steps/random'
9+
import { steps as randomSteps } from '@hello.nrfcloud.com/bdd-markdown-steps/random'
1410
import { steps as deviceSteps } from './steps/device.js'
1511
import { steps as RESTSteps } from '@hello.nrfcloud.com/bdd-markdown-steps/REST'
1612
import { IoTDataPlaneClient } from '@aws-sdk/client-iot-data-plane'
13+
import { fromEnv } from '@nordicsemiconductor/from-env'
14+
import { mock as httpApiMock } from '@bifravst/http-api-mock/mock'
15+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
1716
import { slashless } from '@hello.nrfcloud.com/nrfcloud-api-helpers/api'
1817

1918
/**
@@ -24,11 +23,18 @@ import { slashless } from '@hello.nrfcloud.com/nrfcloud-api-helpers/api'
2423
*/
2524

2625
const iotData = new IoTDataPlaneClient({})
26+
const db = new DynamoDBClient({})
2727

2828
const backendConfig = await stackOutput(
2929
new CloudFormationClient({}),
3030
)<BackendStackOutputs>(STACK_NAME)
3131

32+
const { mockApiEndpoint, responsesTableName } = fromEnv({
33+
mockApiEndpoint: 'HTTP_API_MOCK_API_URL',
34+
responsesTableName: 'HTTP_API_MOCK_RESPONSES_TABLE_NAME',
35+
requestsTableName: 'HTTP_API_MOCK_REQUESTS_TABLE_NAME',
36+
})(process.env)
37+
3238
const print = (arg: unknown) =>
3339
typeof arg === 'object' ? JSON.stringify(arg) : arg
3440
const start = Date.now()
@@ -74,23 +80,25 @@ const runner = await runFolder({
7480

7581
const cleaners: (() => Promise<void>)[] = []
7682

83+
const helloAPIBasePath = 'hello-api'
7784
runner
7885
.addStepRunners(...storageSteps)
79-
.addStepRunners(
80-
...randomSteps({
81-
email,
82-
'device ID': () => `oob-${IMEI()}`,
83-
}),
84-
)
86+
.addStepRunners(...randomSteps())
8587
.addStepRunners(...RESTSteps)
8688
.addStepRunners(
8789
...deviceSteps({
8890
iotData,
91+
httpApiMock: httpApiMock({
92+
db,
93+
responsesTable: responsesTableName,
94+
}),
95+
helloAPIBasePath,
8996
}),
9097
)
9198

9299
const res = await runner.run({
93100
API: slashless(new URL(backendConfig.APIURL)),
101+
helloAPI: new URL(`${mockApiEndpoint}${helloAPIBasePath}`),
94102
})
95103

96104
await Promise.all(cleaners.map(async (fn) => fn()))

feature-runner/steps/device.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,63 @@ import {
99
type StepRunner,
1010
} from '@nordicsemiconductor/bdd-markdown'
1111
import { Type } from '@sinclair/typebox'
12+
import { fingerprintGenerator } from '@hello.nrfcloud.com/proto/fingerprint'
13+
import { IMEI } from '@hello.nrfcloud.com/bdd-markdown-steps/random'
14+
import type { HttpAPIMock } from '@bifravst/http-api-mock/mock'
15+
16+
const getCurrentWeekNumber = (): number => {
17+
const now = new Date()
18+
const firstOfJanuary = new Date(now.getFullYear(), 0, 1)
19+
return Math.ceil(
20+
((now.getTime() - firstOfJanuary.getTime()) / 86400000 +
21+
firstOfJanuary.getDay() +
22+
1) /
23+
7,
24+
)
25+
}
26+
27+
const oobDeviceWithFingerprint = (
28+
httpApiMock: HttpAPIMock,
29+
helloAPIBasePath: string,
30+
) =>
31+
regExpMatchedStep(
32+
{
33+
regExp:
34+
/^I have the fingerprint for my device in `(?<storageName>[^`]+)`$/,
35+
schema: Type.Object({
36+
storageName: Type.String({ minLength: 1 }),
37+
}),
38+
},
39+
async ({ match: { storageName }, log: { progress }, context }) => {
40+
const now = new Date()
41+
const fingerprint = fingerprintGenerator(
42+
parseInt(
43+
`${(now.getFullYear() - 2000).toString()}${getCurrentWeekNumber()}`,
44+
10,
45+
),
46+
)()
47+
progress(`Fingerprint: ${fingerprint}`)
48+
context[storageName] = fingerprint
49+
const deviceId = `oob-${IMEI()}`
50+
progress(`DeviceID: ${deviceId}`)
51+
52+
await httpApiMock.response(
53+
`GET ${helloAPIBasePath}/device?fingerprint=${fingerprint}`,
54+
{
55+
status: 200,
56+
headers: new Headers({
57+
'content-type': 'application/json; charset=utf-8',
58+
}),
59+
body: JSON.stringify({
60+
'@context':
61+
'https://github.com/hello-nrfcloud/proto/deviceIdentity',
62+
id: deviceId,
63+
model: 'PCA20035+solar',
64+
}),
65+
},
66+
)
67+
},
68+
)
1269

1370
const publishDeviceMessage = (iotData: IoTDataPlaneClient) =>
1471
regExpMatchedStep(
@@ -40,6 +97,13 @@ const publishDeviceMessage = (iotData: IoTDataPlaneClient) =>
4097

4198
export const steps = ({
4299
iotData,
100+
httpApiMock,
101+
helloAPIBasePath,
43102
}: {
44103
iotData: IoTDataPlaneClient
45-
}): Array<StepRunner<Record<string, any>>> => [publishDeviceMessage(iotData)]
104+
httpApiMock: HttpAPIMock
105+
helloAPIBasePath: string
106+
}): Array<StepRunner<Record<string, any>>> => [
107+
publishDeviceMessage(iotData),
108+
oobDeviceWithFingerprint(httpApiMock, helloAPIBasePath),
109+
]

features/OOBMapShare.feature.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
exampleContext:
3+
fingerprint: 92b.y7i24q
34
deviceId: oob-352656108602296
45
publicDeviceId: outfling-swanherd-attaghan
56
API: "https://iiet67bnlmbtuhiblik4wcy4ni0oujot.execute-api.eu-west-1.amazonaws.com/2024-04-12"
@@ -16,26 +17,25 @@ exampleContext:
1617
> This works for the Thingy:91 X because they have credentials that already tie
1718
> them to the nRF Cloud account that is used by `hello.nrfcloud.com/map`. The
1819
> only thing the user needs to do is to consent to the sharing.
20+
>
21+
> Note: Users need to know the fingerprint of the device in order to prevent
22+
> them sharing devices they don't have access to by guessing the IMEI.
1923
2024
## Background
2125

22-
> The `deviceId` and `model` is provided in a link from the device page on
23-
> `hello.nrfcloud.com`.
24-
25-
Given I have a random device ID in `deviceId`
26+
Given I have the fingerprint for my device in `fingerprint`
2627

2728
And I have a random email in `email`
2829

2930
## Share the device
3031

31-
> Using the device ID I can share the device
32+
> Using the device fingerprint I can share the device
3233
3334
When I `POST` to `${API}/share` with
3435

3536
```json
3637
{
37-
"deviceId": "${deviceId}",
38-
"model": "PCA20035+solar",
38+
"fingerprint": "${fingerprint}",
3939
"email": "${email}"
4040
}
4141
```
@@ -45,6 +45,8 @@ Then I should receive a
4545

4646
And I store `id` of the last response into `publicDeviceId`
4747

48+
And I store `deviceId` of the last response into `deviceId`
49+
4850
## Confirm the email
4951

5052
When I `POST` to `${API}/share/confirm` with

0 commit comments

Comments
 (0)