Skip to content

Commit e91bcb1

Browse files
committed
fix: allow access to devices only by public ID
Device ID should never be allowed to use as access key, because it can be guessed.
1 parent f13e534 commit e91bcb1

25 files changed

+224
-163
lines changed

cdk/BackendStack.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export class BackendStack extends Stack {
7171
description: 'name of the public devices table',
7272
value: publicDevices.publicDevicesTable.tableName,
7373
})
74+
new CfnOutput(this, 'publicDevicesTableIdIndexName', {
75+
exportName: `${this.stackName}:publicDevicesTableIdIndexName`,
76+
description: 'name of the public devices table id index',
77+
value: publicDevices.idIndex,
78+
})
7479

7580
const api = new API(this)
7681
api.addRoute(
@@ -192,6 +197,7 @@ export type StackOutputs = {
192197
*/
193198
gatewayDomainName?: string
194199
publicDevicesTableName: string
200+
publicDevicesTableIdIndexName: string
195201
/**
196202
* Role ARN to use in the deploy GitHub Actions Workflow
197203
*/

cdk/resources/CustomDevicesAPI.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export class CustomDevicesAPI extends Construct {
6666
BACKEND_STACK_NAME: STACK_NAME,
6767
OPENSSL_LAMBDA_FUNCTION_NAME: openSSLFn.functionName,
6868
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
69+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
6970
},
7071
...new LambdaLogGroup(this, 'createCredentialsFnLogs'),
7172
initialPolicy: [SettingsPermissions(Stack.of(this))],

cdk/resources/DevicesAPI.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class DevicesAPI extends Construct {
3434
environment: {
3535
VERSION: this.node.getContext('version'),
3636
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
37+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
3738
PUBLIC_DEVICES_TABLE_MODEL_OWNER_CONFIRMED_INDEX_NAME:
3839
publicDevices.publicDevicesTableModelOwnerConfirmedIndex,
3940
NODE_NO_WARNINGS: '1',

cdk/resources/LwM2MShadow.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export class LwM2MShadow extends Construct {
4343
environment: {
4444
VERSION: this.node.getContext('version'),
4545
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
46+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
4647
},
4748
initialPolicy: [
4849
new IAM.PolicyStatement({

cdk/resources/SenMLMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export class SenMLMessages extends Construct {
6161
environment: {
6262
VERSION: this.node.getContext('version'),
6363
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
64+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
6465
IMPORT_LOGS_TABLE_NAME: importLogs.tableName,
6566
},
6667
initialPolicy: [

cdk/resources/ShareAPI.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class ShareAPI extends Construct {
4545
environment: {
4646
VERSION: this.node.getContext('version'),
4747
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
48+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
4849
FROM_EMAIL: `notification@${domain}`,
4950
NODE_NO_WARNINGS: '1',
5051
IS_TEST: this.node.getContext('isTest') === true ? '1' : '0',
@@ -83,6 +84,7 @@ export class ShareAPI extends Construct {
8384
environment: {
8485
VERSION: this.node.getContext('version'),
8586
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
87+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
8688
NODE_NO_WARNINGS: '1',
8789
},
8890
...new LambdaLogGroup(this, 'confirmOwnershipFnLogs'),
@@ -101,6 +103,7 @@ export class ShareAPI extends Construct {
101103
environment: {
102104
VERSION: this.node.getContext('version'),
103105
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
106+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
104107
NODE_NO_WARNINGS: '1',
105108
},
106109
...new LambdaLogGroup(this, 'sharingStatusFnLogs'),

cli/cli.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,20 @@ const CLI = async ({ isCI }: { isCI: boolean }) => {
6464
console.error('Running on CI...')
6565
} else {
6666
try {
67-
const mapOutputs = await stackOutput(cf)<StackOutputs>(STACK_NAME)
67+
const backendOutputs = await stackOutput(cf)<StackOutputs>(STACK_NAME)
6868
commands.push(
6969
registerCustomMapDevice({
7070
db,
71-
publicDevicesTableName: mapOutputs.publicDevicesTableName,
71+
publicDevicesTableName: backendOutputs.publicDevicesTableName,
72+
idIndex: backendOutputs.publicDevicesTableIdIndexName,
7273
ssm,
7374
env: accountEnv,
7475
stackName: STACK_NAME,
7576
}),
7677
shareDevice({
7778
db,
78-
publicDevicesTableName: mapOutputs.publicDevicesTableName,
79+
publicDevicesTableName: backendOutputs.publicDevicesTableName,
80+
idIndex: backendOutputs.publicDevicesTableIdIndexName,
7981
}),
8082
)
8183
} catch (error) {

cli/commands/register-custom-device.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import chalk from 'chalk'
66
import { randomUUID } from 'node:crypto'
77
import fs from 'node:fs/promises'
88
import path from 'node:path'
9-
import { publicDevicesRepo } from '../../sharing/publicDevicesRepo.js'
9+
import { publicDevicesRepo } from '../../devices/publicDevicesRepo.js'
1010
import { createCA } from '@hello.nrfcloud.com/certificate-helpers/ca'
1111
import { createDeviceCertificate } from '@hello.nrfcloud.com/certificate-helpers/device'
1212
import type { CommandDefinition } from './CommandDefinition.js'
@@ -21,12 +21,14 @@ export const registerCustomMapDevice = ({
2121
stackName,
2222
db,
2323
publicDevicesTableName,
24+
idIndex,
2425
env,
2526
}: {
2627
ssm: SSMClient
2728
stackName: string
2829
db: DynamoDBClient
2930
publicDevicesTableName: string
31+
idIndex: string
3032
env: Required<Environment>
3133
}): CommandDefinition => ({
3234
command: 'register-custom-map-device <model> <email>',
@@ -43,6 +45,7 @@ export const registerCustomMapDevice = ({
4345
const publicDevice = publicDevicesRepo({
4446
db,
4547
TableName: publicDevicesTableName,
48+
idIndex,
4649
})
4750
const maybePublished = await publicDevice.share({
4851
deviceId,

cli/commands/share-device.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import type { DynamoDBClient } from '@aws-sdk/client-dynamodb'
22
import { models } from '@hello.nrfcloud.com/proto-map'
33
import chalk from 'chalk'
4-
import { publicDevicesRepo } from '../../sharing/publicDevicesRepo.js'
4+
import { publicDevicesRepo } from '../../devices/publicDevicesRepo.js'
55
import type { CommandDefinition } from './CommandDefinition.js'
66

77
const modelIDs = Object.keys(models)
88

99
export const shareDevice = ({
1010
db,
1111
publicDevicesTableName,
12+
idIndex,
1213
}: {
1314
db: DynamoDBClient
1415
publicDevicesTableName: string
16+
idIndex: string
1517
}): CommandDefinition => ({
1618
command: `share-device <deviceId> <model> <email>`,
1719
action: async (deviceId, model, email) => {
@@ -27,6 +29,7 @@ export const shareDevice = ({
2729
const publicDevice = publicDevicesRepo({
2830
db,
2931
TableName: publicDevicesTableName,
32+
idIndex,
3033
})
3134
const maybePublished = await publicDevice.share({
3235
deviceId,
File renamed without changes.

sharing/publicDevicesRepo.spec.ts renamed to devices/publicDevicesRepo.spec.ts

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, mock } from 'node:test'
22
import assert from 'node:assert/strict'
3-
import { getDeviceById, publicDevicesRepo } from './publicDevicesRepo.js'
3+
import { publicDevicesRepo, toPublic } from './publicDevicesRepo.js'
44
import { marshall } from '@aws-sdk/util-dynamodb'
55
import { assertCall } from '../util/test/assertCall.js'
66
import { randomUUID } from 'node:crypto'
@@ -10,18 +10,20 @@ import {
1010
alphabet,
1111
numbers,
1212
} from '@hello.nrfcloud.com/proto/fingerprint'
13+
import { ModelID } from '@hello.nrfcloud.com/proto-map'
1314

1415
void describe('publicDevicesRepo()', () => {
1516
void describe('getByDeviceId()', () => {
1617
void it('should fetch device data', async () => {
18+
const ownerConfirmed = new Date()
1719
const id = randomUUID()
1820
const send = mock.fn(async () =>
1921
Promise.resolve({
2022
Item: marshall({
2123
id,
2224
secret__deviceId: 'some-device',
2325
model: 'asset_tracker_v2+AWS',
24-
ownerConfirmed: new Date().toISOString(),
26+
ownerConfirmed: ownerConfirmed.toISOString(),
2527
}),
2628
}),
2729
)
@@ -31,11 +33,14 @@ void describe('publicDevicesRepo()', () => {
3133
send,
3234
} as any,
3335
TableName: 'some-table',
36+
idIndex: 'idIndex',
3437
}).getByDeviceId('some-device'),
3538
{
36-
publicDevice: {
39+
device: {
3740
id,
3841
model: 'asset_tracker_v2+AWS',
42+
secret__deviceId: 'some-device',
43+
ownerConfirmed: ownerConfirmed.toISOString(),
3944
},
4045
},
4146
)
@@ -54,6 +59,7 @@ void describe('publicDevicesRepo()', () => {
5459
send: async () => Promise.resolve({}),
5560
} as any,
5661
TableName: 'some-table',
62+
idIndex: 'idIndex',
5763
}).getByDeviceId('some-device'),
5864
{ error: 'not_found' },
5965
))
@@ -70,13 +76,14 @@ void describe('publicDevicesRepo()', () => {
7076
} as any,
7177
TableName: 'some-table',
7278
now,
79+
idIndex: 'idIndex',
7380
}).share({
7481
deviceId: 'some-device',
7582
model: 'asset_tracker_v2+AWS',
7683
7784
})
7885

79-
const id = ('publicDevice' in res && res.publicDevice.id) as string
86+
const id = ('device' in res && res.device.id) as string
8087

8188
assert.match(id, /^[a-z0-9]{8}-[a-z0-9]{8}-[a-z0-9]{8}$/) // e.g. mistrist-manicate-lunation
8289

@@ -120,13 +127,14 @@ void describe('publicDevicesRepo()', () => {
120127
} as any,
121128
TableName: 'some-table',
122129
now,
130+
idIndex: 'idIndex',
123131
}).confirmOwnership({
124132
deviceId: id,
125133
ownershipConfirmationToken,
126134
})
127135

128136
assert.deepEqual(res, {
129-
publicDevice: {
137+
device: {
130138
id,
131139
},
132140
})
@@ -154,36 +162,41 @@ void describe('publicDevicesRepo()', () => {
154162
})
155163
})
156164

157-
void describe('getDeviceById()', () => {
165+
void describe('getById()', () => {
158166
void it(`it should return a device by it's public ID`, async () => {
159167
const id = randomUUID()
160-
const send = mock.fn(async () =>
161-
Promise.resolve({
162-
Items: [
163-
marshall({
168+
const send = mock.fn()
169+
send.mock.mockImplementationOnce(
170+
async () =>
171+
Promise.resolve({
172+
Items: [
173+
marshall({
174+
id,
175+
secret__deviceId: 'some-device',
176+
}),
177+
],
178+
}),
179+
0,
180+
)
181+
send.mock.mockImplementationOnce(
182+
async () =>
183+
Promise.resolve({
184+
Item: marshall({
164185
id,
165186
secret__deviceId: 'some-device',
187+
model: 'asset_tracker_v2+AWS',
188+
ownerConfirmed: new Date().toISOString(),
166189
}),
167-
],
168-
}),
169-
)
170-
const getByDeviceId = mock.fn(async () =>
171-
Promise.resolve({
172-
publicDevice: {
173-
id,
174-
model: 'asset_tracker_v2+AWS',
175-
},
176-
}),
190+
}),
191+
1,
177192
)
178-
179-
const res = await getDeviceById({
193+
const res = await publicDevicesRepo({
180194
db: {
181195
send,
182196
} as any,
183197
TableName: 'some-table',
184198
idIndex: 'id-index',
185-
getByDeviceId: getByDeviceId as any,
186-
})(id)
199+
}).getById(id)
187200

188201
assertCall(send, {
189202
input: {
@@ -203,13 +216,44 @@ void describe('getDeviceById()', () => {
203216
},
204217
})
205218

206-
assert.deepEqual(getByDeviceId.mock.calls[0]?.arguments, ['some-device'])
207-
208-
assert.deepEqual(res, {
209-
publicDevice: {
210-
id,
211-
model: 'asset_tracker_v2+AWS',
219+
assertCall(
220+
send,
221+
{
222+
input: {
223+
TableName: 'some-table',
224+
Key: { secret__deviceId: { S: 'some-device' } },
225+
},
212226
},
227+
1,
228+
)
229+
230+
assert.deepEqual('device' in res && toPublic(res.device), {
231+
id,
232+
model: 'asset_tracker_v2+AWS',
213233
})
214234
})
215235
})
236+
237+
void describe('toPublic()', () => {
238+
void it('should convert a record to a publicly shareable record', () => {
239+
const id = randomUUID()
240+
const record = toPublic({
241+
id,
242+
secret__deviceId: 'some-device',
243+
model: ModelID.PCA20035_solar,
244+
ownerConfirmed: new Date(),
245+
ownerEmail: '[email protected]',
246+
ownershipConfirmationToken: '123456',
247+
ownershipConfirmationTokenCreated: new Date(),
248+
ttl: Date.now(),
249+
})
250+
assert.deepEqual(record, {
251+
id,
252+
model: ModelID.PCA20035_solar,
253+
})
254+
assert.equal(
255+
Object.values(record as Record<string, any>).includes('some-device'),
256+
false,
257+
)
258+
})
259+
})

0 commit comments

Comments
 (0)