Skip to content

Commit 5f6211a

Browse files
committed
fix: allow to query sharing status using fingerprint
This way there is no posibility for someone to squat OOB devices by guessing their IMEI.
1 parent cf3f38c commit 5f6211a

File tree

13 files changed

+299
-21
lines changed

13 files changed

+299
-21
lines changed

cdk/BackendLambdas.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ type BackendLambdas = {
44
updatesToLwM2M: PackedLambda
55
shareDevice: PackedLambda
66
sharingStatus: PackedLambda
7+
sharingStatusFingerprint: PackedLambda
78
confirmOwnership: PackedLambda
89
connectionInformationGeoLocation: PackedLambda
910
devicesData: PackedLambda

cdk/BackendStack.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export class BackendStack extends Stack {
133133
})
134134
api.addRoute('POST /share', shareAPI.shareFn)
135135
api.addRoute('POST /share/confirm', shareAPI.confirmOwnershipFn)
136+
api.addRoute('GET /share/status', shareAPI.sharingStatusFingerprintFn)
136137
api.addRoute('GET /device/{id}', shareAPI.sharingStatusFn)
137138

138139
const devicesAPI = new DevicesAPI(this, {

cdk/packBackendLambdas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const packBackendLambdas = async (): Promise<BackendLambdas> => ({
77
updatesToLwM2M: await pack('updatesToLwM2M'),
88
shareDevice: await pack('shareDevice'),
99
sharingStatus: await pack('sharingStatus'),
10+
sharingStatusFingerprint: await pack('sharingStatusFingerprint'),
1011
confirmOwnership: await pack('confirmOwnership'),
1112
connectionInformationGeoLocation: await pack(
1213
'connectionInformationGeoLocation',

cdk/resources/ShareAPI.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class ShareAPI extends Construct {
1414
public readonly shareFn: Lambda.IFunction
1515
public readonly confirmOwnershipFn: Lambda.IFunction
1616
public readonly sharingStatusFn: Lambda.IFunction
17+
public readonly sharingStatusFingerprintFn: Lambda.IFunction
1718
constructor(
1819
parent: Construct,
1920
{
@@ -27,7 +28,10 @@ export class ShareAPI extends Construct {
2728
baseLayer: Lambda.ILayerVersion
2829
lambdaSources: Pick<
2930
BackendLambdas,
30-
'shareDevice' | 'confirmOwnership' | 'sharingStatus'
31+
| 'shareDevice'
32+
| 'confirmOwnership'
33+
| 'sharingStatus'
34+
| 'sharingStatusFingerprint'
3135
>
3236
},
3337
) {
@@ -109,5 +113,35 @@ export class ShareAPI extends Construct {
109113
...new LambdaLogGroup(this, 'sharingStatusFnLogs'),
110114
})
111115
publicDevices.publicDevicesTable.grantReadData(this.sharingStatusFn)
116+
117+
this.sharingStatusFingerprintFn = new Lambda.Function(
118+
this,
119+
'sharingStatusFingerprintFn',
120+
{
121+
handler: lambdaSources.sharingStatusFingerprint.handler,
122+
architecture: Lambda.Architecture.ARM_64,
123+
runtime: Lambda.Runtime.NODEJS_20_X,
124+
timeout: Duration.seconds(10),
125+
memorySize: 1792,
126+
code: Lambda.Code.fromAsset(
127+
lambdaSources.sharingStatusFingerprint.zipFile,
128+
),
129+
description:
130+
'Returns the sharing status of a device using the fingerprint.',
131+
layers: [baseLayer],
132+
environment: {
133+
VERSION: this.node.getContext('version'),
134+
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
135+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
136+
NODE_NO_WARNINGS: '1',
137+
STACK_NAME: Stack.of(this).stackName,
138+
},
139+
...new LambdaLogGroup(this, 'sharingStatusFingerprintFnLogs'),
140+
initialPolicy: [Permissions(Stack.of(this))],
141+
},
142+
)
143+
publicDevices.publicDevicesTable.grantReadData(
144+
this.sharingStatusFingerprintFn,
145+
)
112146
}
113147
}

cli/commands/configure-hello.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
deleteSettings,
77
putSetting,
88
type Settings,
9-
} from '../../settings/hello.js'
9+
} from '../../hello/settings.js'
1010

1111
export const configureHello = ({
1212
ssm,

feature-runner/steps/device.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const oobDeviceWithFingerprint = (
6666
model: 'PCA20035+solar',
6767
}),
6868
},
69+
true,
6970
)
7071
},
7172
)

features/OOBMapShare.feature.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,21 @@ And the last response should match
133133
"model": "PCA20035+solar"
134134
}
135135
```
136+
137+
## The sharing status of a device can be checked using the fingerprint
138+
139+
> Users should be able to determine whether a certain device is sharing data
140+
141+
When I `GET` to `${API}/share/status?fingerprint=${fingerprint}`
142+
143+
Then I should receive a `https://github.com/hello-nrfcloud/proto-map/device`
144+
response
145+
146+
And the last response should match
147+
148+
```json
149+
{
150+
"id": "${publicDeviceId}",
151+
"model": "PCA20035+solar"
152+
}
153+
```

hello/api.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { models } from '@hello.nrfcloud.com/proto-map'
2+
import { DeviceId } from '@hello.nrfcloud.com/proto-map/api'
3+
import type { ProblemDetail } from '@hello.nrfcloud.com/proto/hello'
4+
import { Type, type Static } from '@sinclair/typebox'
5+
import { typedFetch } from './typedFetch.js'
6+
7+
const Model = Type.Union(
8+
Object.keys(models).map((model) => Type.Literal(model)),
9+
)
10+
11+
export const helloApi = ({
12+
endpoint,
13+
fetchImplementation,
14+
}: {
15+
endpoint: URL
16+
fetchImplementation?: typeof fetch
17+
}): {
18+
getDeviceByFingerprint: (fingerprint: string) => Promise<
19+
| {
20+
error: Omit<Static<typeof ProblemDetail>, '@context'>
21+
}
22+
| {
23+
result: {
24+
id: string
25+
model: Static<typeof Model>
26+
}
27+
}
28+
>
29+
} => ({
30+
getDeviceByFingerprint: async (fingerprint: string) =>
31+
typedFetch({
32+
url: new URL(
33+
`./device?${new URLSearchParams({ fingerprint }).toString()}`,
34+
endpoint,
35+
),
36+
RequestBodySchema: Type.Undefined(),
37+
ResponseBodySchema: Type.Object({
38+
id: DeviceId,
39+
model: Model,
40+
}),
41+
fetchImplementation,
42+
})(),
43+
})
File renamed without changes.

hello/typedFetch.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { validateWithTypeBox } from '@hello.nrfcloud.com/proto'
2+
import { ProblemDetail } from '@hello.nrfcloud.com/proto/hello'
3+
import { type Static, type TSchema } from '@sinclair/typebox'
4+
5+
export const typedFetch = <Body, ResponseBodySchemaType extends TSchema>({
6+
url,
7+
RequestBodySchema,
8+
ResponseBodySchema,
9+
fetchImplementation,
10+
...init
11+
}: {
12+
url: URL
13+
RequestBodySchema: TSchema
14+
ResponseBodySchema: ResponseBodySchemaType
15+
fetchImplementation?: typeof fetch
16+
} & RequestInit) => {
17+
const validateRequestBody = validateWithTypeBox(RequestBodySchema)
18+
const validateResponseBody = validateWithTypeBox(ResponseBodySchema)
19+
20+
return async (
21+
requestBody?: Body,
22+
): Promise<
23+
| {
24+
error: Omit<Static<typeof ProblemDetail>, '@context'>
25+
}
26+
| { result: Static<ResponseBodySchemaType> }
27+
> => {
28+
if (requestBody !== undefined) {
29+
const maybeValidRequestBody = validateRequestBody(requestBody)
30+
if ('errors' in maybeValidRequestBody) {
31+
return {
32+
error: {
33+
title: 'Request body validation failed',
34+
detail: JSON.stringify({
35+
body: requestBody,
36+
errors: maybeValidRequestBody.errors,
37+
}),
38+
},
39+
}
40+
}
41+
}
42+
const res = await (fetchImplementation ?? fetch)(url, init)
43+
const hasContent =
44+
parseInt(res.headers.get('content-length') ?? '0', 10) > 0
45+
if (!res.ok) {
46+
return {
47+
error: {
48+
title: `Request failed (${res.status})`,
49+
detail: hasContent ? await res.text() : undefined,
50+
},
51+
}
52+
}
53+
let responseBody = undefined
54+
if (hasContent) {
55+
responseBody = await res.text()
56+
const isJSON =
57+
res.headers.get('content-type')?.includes('application/json') ?? false
58+
if (isJSON) {
59+
try {
60+
responseBody = JSON.parse(responseBody)
61+
} catch {
62+
return {
63+
error: {
64+
title: `Failed to parse response as JSON!`,
65+
detail: responseBody,
66+
},
67+
}
68+
}
69+
}
70+
}
71+
const maybeValidResponseBody = validateResponseBody(responseBody)
72+
if ('errors' in maybeValidResponseBody) {
73+
return {
74+
error: {
75+
title: 'Response body validation failed',
76+
detail: JSON.stringify({
77+
body: responseBody,
78+
errors: maybeValidResponseBody.errors,
79+
}),
80+
},
81+
}
82+
}
83+
return {
84+
result: responseBody,
85+
}
86+
}
87+
}

0 commit comments

Comments
 (0)