From ee9f4c09a292de12c4b9dea2df37fb1c30e7997a Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Wed, 14 Oct 2026 11:45:57 -0600 Subject: [PATCH 1/8] Return and accept the standard IronCore device format --- CHANGELOG.md | 7 ++++++ examples/.device_keys | 2 +- examples/dek-reuse/package.json | 2 +- examples/dek-reuse/src/index.ts | 8 +++---- integration/sdkOperation.ts | 2 +- ironnode.d.ts | 20 +++++++++++++++- src/index.ts | 31 +++++++++++++++++------- src/sdk/Initialization.ts | 36 ++++++++++++++++++---------- src/sdk/tests/Initialization.test.ts | 30 +++++++++++++++++++---- src/tests/index.test.ts | 20 ++++++++++++++++ 10 files changed, 123 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb0bd4..55f5ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.3.0 + +### Changed + +- `IronNode.User.generateDeviceKeys()` now also returns the standard IronCore device fields (`accountId`, `segmentId`, `devicePrivateKey`, `signingPrivateKey`) plus the new device's `deviceId`, `name`, `created`, and `lastUpdated`. The existing `accountID`, `segmentID`, `deviceKeys`, and `signingKeys` fields are deprecated. +- `IronNode.initialize()` accepts a `DeviceContext` object, such as the result of `generateDeviceKeys()` or a device JSON produced by another IronCore SDK. + ## 1.2.0 ### Changed diff --git a/examples/.device_keys b/examples/.device_keys index 6051f04..9daedd8 100644 --- a/examples/.device_keys +++ b/examples/.device_keys @@ -1 +1 @@ -{"accountID":"demos@ironcorelabs.com","segmentID":1682,"deviceKeys":{"publicKey":{"x":"flWAeCL79abDGzUKthGWjw/CyDlNL203nNzOLxpi9a8=","y":"aO2LVVaf5w8m7LYN/FNz//daLBQv41ub46IgOUKlXag="},"privateKey":"Gw0rAdswdoiTTPO0v3wAWUDS5JOsjrHINLx2ma9Wf2Y="},"signingKeys":{"publicKey":"YZJbEAkk5MFCbBhyTueeUluruvsmjENdNBW8nEm7lJE=","privateKey":"yRvXk23hSua7YjHUdFoFaHR4qWSOBuWSHyPDMVksWPxhklsQCSTkwUJsGHJO555SW6u6+yaMQ100FbycSbuUkQ=="}} \ No newline at end of file +{"accountId":"demos@ironcorelabs.com","segmentId":1682,"devicePrivateKey":"Gw0rAdswdoiTTPO0v3wAWUDS5JOsjrHINLx2ma9Wf2Y=","signingPrivateKey":"yRvXk23hSua7YjHUdFoFaHR4qWSOBuWSHyPDMVksWPxhklsQCSTkwUJsGHJO555SW6u6+yaMQ100FbycSbuUkQ=="} \ No newline at end of file diff --git a/examples/dek-reuse/package.json b/examples/dek-reuse/package.json index 20dae00..fbd4c4d 100644 --- a/examples/dek-reuse/package.json +++ b/examples/dek-reuse/package.json @@ -7,7 +7,7 @@ "typescript": "^4.9.5" }, "dependencies": { - "@ironcorelabs/ironnode": "^1.1.2-pre", + "@ironcorelabs/ironnode": "^1.3.0", "@types/node": "^20.14.8" }, "scripts": { diff --git a/examples/dek-reuse/src/index.ts b/examples/dek-reuse/src/index.ts index d2c4aa1..0e28199 100644 --- a/examples/dek-reuse/src/index.ts +++ b/examples/dek-reuse/src/index.ts @@ -1,4 +1,4 @@ -import {SDK, DeviceDetails, initialize} from "@ironcorelabs/ironnode" +import {SDK, DeviceContext, initialize} from "@ironcorelabs/ironnode" import * as crypto from "crypto" import * as fs from "fs" @@ -70,11 +70,9 @@ async function checkDek(dek: Buffer, fileNum: number) { (async () => { const configPath = "../.device_keys" fs.accessSync(configPath, fs.constants.R_OK) - const deviceKeys: DeviceDetails = JSON.parse(fs.readFileSync(configPath, "utf8")) + const deviceContext: DeviceContext = JSON.parse(fs.readFileSync(configPath, "utf8")) - const ironnode = await initialize(deviceKeys.accountID, deviceKeys.segmentID, - deviceKeys.deviceKeys.privateKey, - deviceKeys.signingKeys.privateKey) + const ironnode = await initialize(deviceContext) await writeInitialData(ironnode) diff --git a/integration/sdkOperation.ts b/integration/sdkOperation.ts index 51043c1..2f9ff29 100644 --- a/integration/sdkOperation.ts +++ b/integration/sdkOperation.ts @@ -130,7 +130,7 @@ function askForOperation(IronNode: SDK): Promise { export function initializeSDKWithLocalDevice() { const Config = require(path.join(__dirname, "./.device.json")); - return initialize(Config.accountID, Config.segmentID, Config.deviceKeys.privateKey, Config.signingKeys.privateKey) + return initialize(Config) .then((IronNode) => { log(IronNode.userContext); return askForOperation(IronNode); diff --git a/ironnode.d.ts b/ironnode.d.ts index bf29697..1780853 100644 --- a/ironnode.d.ts +++ b/ironnode.d.ts @@ -191,15 +191,33 @@ export class SDKError extends Error { export const ErrorCodes: {[key: string]: number}; +/** Device keys in the JSON format shared by all IronCore SDKs. */ +export interface DeviceContext { + accountId: string; + segmentId: number; + devicePrivateKey: Base64String; + /** Ed25519 expanded private key: the 32 byte seed followed by the 32 byte public key. */ + signingPrivateKey: Base64String; +} + +export function initialize(deviceContext: DeviceContext): Promise; export function initialize(accountID: string, segmentID: number, privateDeviceKey: Base64String, privateSigningKey: Base64String): Promise; -export interface DeviceDetails { +export interface DeviceDetails extends DeviceContext { + deviceId: number; + name: string | null; + created: RFC3339Date; + lastUpdated: RFC3339Date; + /** @deprecated Use `accountId`. */ accountID: string; + /** @deprecated Use `segmentId`. */ segmentID: number; + /** @deprecated Use `devicePrivateKey`. */ deviceKeys: { publicKey: PublicKey; privateKey: Base64String; }; + /** @deprecated Use `signingPrivateKey`. */ signingKeys: { publicKey: Base64String; privateKey: Base64String; diff --git a/src/index.ts b/src/index.ts index 587dc49..b915c36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import {DeviceCreateOptions, UserCreateOptions} from "../ironnode"; +import {DeviceContext, DeviceCreateOptions, SDK, UserCreateOptions} from "../ironnode"; import {Base64String} from "./commonTypes"; import {UserStatus} from "./Constants"; import * as Utils from "./lib/Utils"; @@ -6,14 +6,29 @@ import * as Initialization from "./sdk/Initialization"; import * as UserOperations from "./operations/UserOperations"; /** - * Initialize the Node SDK by providing account information necessary to run operations from a server side context. Returns a Promise which - * will resolve with the IronWeb Node SDK object. + * Initialize the Node SDK by providing account information necessary to run operations from a server side context. Accepts either a + * `DeviceContext` or its fields as separate arguments. * @param {string} accountID ID of the account to perform actions as * @param {number} segmentID Internal segment ID the account is part of * @param {Base64String} privateDeviceKey Base64 encoded device key bytes associated to the account in use * @param {Base64String} privateSigningKey Base64 encoded signing key bytes associated to the account in use */ -export function initialize(accountID: string, segmentID: number, privateDeviceKey: Base64String, privateSigningKey: Base64String) { +export function initialize(deviceContext: DeviceContext): Promise; +export function initialize(accountID: string, segmentID: number, privateDeviceKey: Base64String, privateSigningKey: Base64String): Promise; +export function initialize( + accountIDOrContext: string | DeviceContext, + segmentID?: number, + privateDeviceKey?: Base64String, + privateSigningKey?: Base64String +): Promise { + if (typeof accountIDOrContext === "object" && accountIDOrContext !== null) { + const {accountId, segmentId, devicePrivateKey, signingPrivateKey} = accountIDOrContext; + return initializeFromKeys(accountId, segmentId, devicePrivateKey, signingPrivateKey); + } + return initializeFromKeys(accountIDOrContext, segmentID, privateDeviceKey, privateSigningKey); +} + +function initializeFromKeys(accountID: string, segmentID?: number, privateDeviceKey?: Base64String, privateSigningKey?: Base64String) { Utils.validateID(accountID); if (typeof segmentID !== "number") { throw new Error(`Expected a numerical segment ID but instead got '${segmentID}`); @@ -45,13 +60,11 @@ export const User = { }, /** * Generate a new pair of device keys for the user specified in the provided signed JWT. Takes the users password in order to decrypt their master - * keys and generates a new device key pair as well as a transform key between their master key pair and their device key pair. Returns both the - * device key pair and a signing key pair as base64 encoded strings as well as the users ID and the segment ID for the user. All of these fields can - * then be stored somewhere secure and passed in to run the `initialize` function in order for the current user to interact with the SDK. Also allows - * the call to provide a readable "name" for this device. + * keys and generates a new device key pair as well as a transform key between their master key pair and their device key pair. Resolves with a + * `DeviceDetails`, a `DeviceContext` plus the new device's ID, name, and timestamps. Store it somewhere secure and pass it to `initialize`. Also + * allows the call to provide a readable "name" for this device. * @param {string} jwt Signed JWT for the user to sync * @param {string} password Password to use to escrow the users private key - * @param {DeviceCreateOptions} options Device create options. */ generateDeviceKeys(jwt: string, password: string, deviceOptions: DeviceCreateOptions = {deviceName: ""}) { return Initialization.generateDevice(jwt, password, deviceOptions).toPromise(); diff --git a/src/sdk/Initialization.ts b/src/sdk/Initialization.ts index f761695..02a89ac 100644 --- a/src/sdk/Initialization.ts +++ b/src/sdk/Initialization.ts @@ -134,18 +134,30 @@ export function generateDevice(jwt: string, password: string, options: DeviceCre deviceAdd.signature.signature, deviceAdd.signature.ts, options - ).map(() => ({ - accountID: user.id, - segmentID: user.segmentId, - deviceKeys: { - publicKey: Codec.PublicKey.toBase64(deviceAdd.deviceKeys.publicKey), - privateKey: Codec.Buffer.toBase64(deviceAdd.deviceKeys.privateKey), - }, - signingKeys: { - publicKey: Codec.Buffer.toBase64(deviceAdd.signingKeys.publicKey), - privateKey: Codec.Buffer.toBase64(deviceAdd.signingKeys.privateKey), - }, - })) + ).map((device) => { + const devicePrivateKey = Codec.Buffer.toBase64(deviceAdd.deviceKeys.privateKey); + const signingPrivateKey = Codec.Buffer.toBase64(deviceAdd.signingKeys.privateKey); + return { + accountId: user.id, + segmentId: user.segmentId, + devicePrivateKey, + signingPrivateKey, + deviceId: device.id, + name: device.name, + created: device.created, + lastUpdated: device.updated, + accountID: user.id, + segmentID: user.segmentId, + deviceKeys: { + publicKey: Codec.PublicKey.toBase64(deviceAdd.deviceKeys.publicKey), + privateKey: devicePrivateKey, + }, + signingKeys: { + publicKey: Codec.Buffer.toBase64(deviceAdd.signingKeys.publicKey), + privateKey: signingPrivateKey, + }, + }; + }) ); }); }); diff --git a/src/sdk/tests/Initialization.test.ts b/src/sdk/tests/Initialization.test.ts index bd01b9c..2e4884e 100644 --- a/src/sdk/tests/Initialization.test.ts +++ b/src/sdk/tests/Initialization.test.ts @@ -263,24 +263,44 @@ describe("Initialization", () => { const deviceAddSpy = jest.spyOn(UserApi, "callUserDeviceAdd"); deviceAddSpy.mockReturnValue( Future.of({ - devicePublicKey: TestUtils.getEmptyPublicKey(), - }) as any + id: 42, + devicePublicKey: Codec.PublicKey.toBase64(TestUtils.getEmptyPublicKey()), + name: "laptop", + created: "2026-10-14T12:00:00Z", + updated: "2026-10-14T12:00:01Z", + }) ); Initialization.generateDevice("jwt", "password", {deviceName: ""}).engage( (e) => fail(e), - (result: any) => { + (result) => { + expect(result).toMatchObject({ + accountId: "353", + segmentId: 3, + devicePrivateKey: expect.any(String), + signingPrivateKey: expect.any(String), + deviceId: 42, + name: "laptop", + created: "2026-10-14T12:00:00Z", + lastUpdated: "2026-10-14T12:00:01Z", + }); + expect(Buffer.from(result.devicePrivateKey, "base64")).toHaveLength(32); + const signingPrivateKey = Buffer.from(result.signingPrivateKey, "base64"); + expect(signingPrivateKey).toHaveLength(64); + expect(signingPrivateKey.slice(32).toString("base64")).toEqual(result.signingKeys.publicKey); + expect(result.accountID).toEqual("353"); + expect(result.segmentID).toEqual(3); expect(result.deviceKeys).toEqual({ publicKey: { x: expect.any(String), y: expect.any(String), }, - privateKey: expect.any(String), + privateKey: result.devicePrivateKey, }); expect(result.signingKeys).toEqual({ publicKey: expect.any(String), - privateKey: expect.any(String), + privateKey: result.signingPrivateKey, }); expect(deviceAddSpy).toHaveBeenCalledWith( "jwt", diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index 982c80e..c26a614 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -1,4 +1,5 @@ import Future from "futurejs"; +import {SDK} from "../../ironnode"; import * as IronNode from "../index"; import * as UserOperations from "../operations/UserOperations"; import * as Initialization from "../sdk/Initialization"; @@ -24,6 +25,25 @@ describe("IronNode", () => { }) .catch((e) => fail(e)); }); + + test("should validate DeviceContext fields", () => { + const valid = {accountId: "abc", segmentId: 3, devicePrivateKey: "aaaa", signingPrivateKey: "bbbb"}; + expect(() => IronNode.initialize({...valid, accountId: ""})).toThrow(); + expect(() => IronNode.initialize({...valid, segmentId: "3" as unknown as number})).toThrow(); + expect(() => IronNode.initialize({...valid, devicePrivateKey: ""})).toThrow(); + expect(() => IronNode.initialize({...valid, signingPrivateKey: ""})).toThrow(); + expect(() => IronNode.initialize({accountID: "abc", segmentID: 3} as unknown as typeof valid)).toThrow(); + }); + + test("should initialize from the fields of a DeviceContext", () => { + const sdk = {} as SDK; + const initSpy = jest.spyOn(Initialization, "initialize"); + initSpy.mockReturnValue(Future.of(sdk)); + return IronNode.initialize({accountId: "abc", segmentId: 3, devicePrivateKey: "aaaa", signingPrivateKey: "bbbb"}).then((res) => { + expect(res).toBe(sdk); + expect(initSpy).toHaveBeenCalledWith("abc", 3, "aaaa", "bbbb"); + }); + }); }); describe("User", () => { From 2bd284ed02d0ced8a87be9f8c51567efb3762952 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Wed, 14 Oct 2026 11:54:23 -0600 Subject: [PATCH 2/8] Type publicSigningKey on listed devices --- CHANGELOG.md | 1 + ironnode.d.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f5ea7..c7ce674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - `IronNode.User.generateDeviceKeys()` now also returns the standard IronCore device fields (`accountId`, `segmentId`, `devicePrivateKey`, `signingPrivateKey`) plus the new device's `deviceId`, `name`, `created`, and `lastUpdated`. The existing `accountID`, `segmentID`, `deviceKeys`, and `signingKeys` fields are deprecated. - `IronNode.initialize()` accepts a `DeviceContext` object, such as the result of `generateDeviceKeys()` or a device JSON produced by another IronCore SDK. +- `SDK.user.listDevices()` is typed to include each device's `publicSigningKey`, which the API already returns. ## 1.2.0 diff --git a/ironnode.d.ts b/ironnode.d.ts index 1780853..2a38393 100644 --- a/ironnode.d.ts +++ b/ironnode.d.ts @@ -126,14 +126,16 @@ export interface UserUpdateResult { export interface UserPublicKeyGetResponse { [userID: string]: PublicKey | null; } +export interface UserDevice { + id: number; + name: string; + created: RFC3339Date; + updated: RFC3339Date; + publicSigningKey: Base64String; + isCurrentDevice: boolean; +} export interface UserDeviceListResponse { - result: Array<{ - id: number; - name: string; - created: string; - updated: string; - isCurrentDevice: boolean; - }>; + result: UserDevice[]; } export interface Document { From e3c701f3533a84f4c946f5cf4c8b6c7bff467325 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Wed, 14 Oct 2026 12:09:46 -0600 Subject: [PATCH 3/8] Add delete device by public signing key --- CHANGELOG.md | 1 + ironnode.d.ts | 2 + src/api/UserApi.ts | 39 +++++++++++++++++ src/api/tests/UserApi.test.ts | 45 ++++++++++++++++++++ src/index.ts | 12 ++++++ src/operations/UserOperations.ts | 35 ++++++++++++--- src/operations/tests/UserOperations.test.ts | 47 +++++++++++++++++++++ src/sdk/UserSDK.ts | 12 ++++++ src/sdk/tests/UserSDK.test.ts | 8 ++++ src/tests/index.test.ts | 5 +++ 10 files changed, 200 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ce674..d2da9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - `IronNode.User.generateDeviceKeys()` now also returns the standard IronCore device fields (`accountId`, `segmentId`, `devicePrivateKey`, `signingPrivateKey`) plus the new device's `deviceId`, `name`, `created`, and `lastUpdated`. The existing `accountID`, `segmentID`, `deviceKeys`, and `signingKeys` fields are deprecated. - `IronNode.initialize()` accepts a `DeviceContext` object, such as the result of `generateDeviceKeys()` or a device JSON produced by another IronCore SDK. - `SDK.user.listDevices()` is typed to include each device's `publicSigningKey`, which the API already returns. +- Added `SDK.user.deleteDeviceByPublicSigningKey()` and `IronNode.User.deleteDeviceByPublicSigningKey()` (JWT authorized) to delete a device given its public signing key. Deleting the SDK's own device through `SDK.user` ends the session, as `deleteDevice()` does. ## 1.2.0 diff --git a/ironnode.d.ts b/ironnode.d.ts index 2a38393..d538d48 100644 --- a/ironnode.d.ts +++ b/ironnode.d.ts @@ -170,6 +170,7 @@ export interface User { getPublicKey(users: string | string[]): Promise; listDevices(): Promise; deleteDevice(id?: number): Promise<{id: number}>; + deleteDeviceByPublicSigningKey(publicSigningKey: Base64String): Promise<{id: number}>; rotateMasterKey(password: string): Promise<{needsRotation: boolean}>; changePassword(currentPassword: string, newPassword: string): Promise; disableSelf(): Promise; @@ -231,6 +232,7 @@ export namespace User { export function create(jwt: string, password: string, options?: UserCreateOptions): Promise; export function generateDeviceKeys(jwt: string, password: string, options?: DeviceCreateOptions): Promise; export function updateStatus(jwt: string, status: (typeof UserStatus)[keyof typeof UserStatus]): Promise; + export function deleteDeviceByPublicSigningKey(jwt: string, publicSigningKey: Base64String): Promise<{id: number}>; } export const UserStatus: { diff --git a/src/api/UserApi.ts b/src/api/UserApi.ts index da159c9..f77315b 100644 --- a/src/api/UserApi.ts +++ b/src/api/UserApi.ts @@ -210,6 +210,28 @@ const userDeviceDelete = (sign: MessageSignature, accountID: string, deviceID?: errorCode: ErrorCodes.USER_DEVICE_DELETE_REQUEST_FAILURE, }); +const userDeviceDeleteBySigningKey = (sign: MessageSignature, accountID: string, publicSigningKey: Base64String) => ({ + url: `users/${encodeURIComponent(accountID)}/devices/${encodeURIComponent(publicSigningKey)}`, + options: { + method: "DELETE", + headers: { + Authorization: ApiRequest.getAuthHeader(sign), + }, + }, + errorCode: ErrorCodes.USER_DEVICE_DELETE_REQUEST_FAILURE, +}); + +const userDeviceDeleteBySigningKeyWithJwt = (jwt: string, publicSigningKey: Base64String) => ({ + url: `users/devices/${encodeURIComponent(publicSigningKey)}`, + options: { + method: "DELETE", + headers: { + Authorization: `jwt ${jwt}`, + }, + }, + errorCode: ErrorCodes.USER_DEVICE_DELETE_REQUEST_FAILURE, +}); + /** * Update the encrypted private key for the currently logged in user */ @@ -361,6 +383,23 @@ export default { return ApiRequest.fetchJSON<{id: number}>(url, errorCode, options); }, + /** + * Make request to delete one of the current user's devices given its public signing key. + */ + callUserDeviceDeleteBySigningKeyApi(publicSigningKey: Base64String) { + const {accountID} = ApiState.accountAndSegmentIDs(); + const {url, options, errorCode} = userDeviceDeleteBySigningKey(getSignatureHeader(), accountID, publicSigningKey); + return ApiRequest.fetchJSON<{id: number}>(url, errorCode, options); + }, + + /** + * Make request to delete a device of the user in the JWT given its public signing key. + */ + callUserDeviceDeleteBySigningKeyWithJwtApi(jwt: string, publicSigningKey: Base64String) { + const {url, options, errorCode} = userDeviceDeleteBySigningKeyWithJwt(jwt, publicSigningKey); + return ApiRequest.fetchJSON<{id: number}>(url, errorCode, options); + }, + /** * Update the status of the current SDK user */ diff --git a/src/api/tests/UserApi.test.ts b/src/api/tests/UserApi.test.ts index 054975b..68c3bb5 100644 --- a/src/api/tests/UserApi.test.ts +++ b/src/api/tests/UserApi.test.ts @@ -1,4 +1,5 @@ import Future from "futurejs"; +import {ErrorCodes} from "../../Constants"; import ApiState from "../../lib/ApiState"; import * as TestUtils from "../../tests/TestUtils"; import * as ApiRequest from "../ApiRequest"; @@ -391,4 +392,48 @@ describe("UserApi", () => { ); }); }); + + describe("callUserDeviceDeleteBySigningKeyApi", () => { + test("sends a signed DELETE for the URL-encoded signing key", (done) => { + (ApiRequest.fetchJSON as jest.Mock).mockReturnValue(Future.of({id: 35352})); + + UserApi.callUserDeviceDeleteBySigningKeyApi("ab/c+d==").engage( + (error) => done.fail(error), + (deletedDevice) => { + expect(deletedDevice).toEqual({id: 35352}); + expect(ApiRequest.fetchJSON).toHaveBeenCalledWith( + `users/${TestUtils.testAccountID}/devices/ab%2Fc%2Bd%3D%3D`, + ErrorCodes.USER_DEVICE_DELETE_REQUEST_FAILURE, + jasmine.any(Object) + ); + const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2]; + expect(request.headers.Authorization).toMatch(/IronCore\s{1}\d{1}[.][a-zA-Z0-9=\/+]+[.][a-zA-Z0-9=\/+]+/); + expect(request.method).toEqual("DELETE"); + done(); + } + ); + }); + }); + + describe("callUserDeviceDeleteBySigningKeyWithJwtApi", () => { + test("sends a JWT authorized DELETE for the URL-encoded signing key", (done) => { + (ApiRequest.fetchJSON as jest.Mock).mockReturnValue(Future.of({id: 35352})); + + UserApi.callUserDeviceDeleteBySigningKeyWithJwtApi("the.jwt", "ab/c+d==").engage( + (error) => done.fail(error), + (deletedDevice) => { + expect(deletedDevice).toEqual({id: 35352}); + expect(ApiRequest.fetchJSON).toHaveBeenCalledWith( + "users/devices/ab%2Fc%2Bd%3D%3D", + ErrorCodes.USER_DEVICE_DELETE_REQUEST_FAILURE, + jasmine.any(Object) + ); + const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2]; + expect(request.headers.Authorization).toEqual("jwt the.jwt"); + expect(request.method).toEqual("DELETE"); + done(); + } + ); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index b915c36..81bca7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,18 @@ export const User = { } return UserOperations.updateUserStatus(jwt, status).toPromise(); }, + /** + * Does not require an initialized SDK. + */ + deleteDeviceByPublicSigningKey(jwt: string, publicSigningKey: Base64String) { + if (typeof jwt !== "string" || !jwt.length) { + throw new Error("Expected a non-empty JWT string."); + } + if (typeof publicSigningKey !== "string" || !publicSigningKey.length) { + throw new Error(`Invalid public signing key provided. Expected a non-empty base64 string but got ${publicSigningKey}`); + } + return UserOperations.deleteUserDeviceByPublicSigningKeyWithJwt(jwt, publicSigningKey).toPromise(); + }, }; /** diff --git a/src/operations/UserOperations.ts b/src/operations/UserOperations.ts index 4388875..bf47403 100644 --- a/src/operations/UserOperations.ts +++ b/src/operations/UserOperations.ts @@ -1,12 +1,12 @@ import Future from "futurejs"; import {UserPublicKeyGetResponse, UserUpdateResult} from "../../ironnode"; import UserApi, {UserUpdateStatusApiResponse} from "../api/UserApi"; -import {PublicKey} from "../commonTypes"; +import {Base64String, PublicKey} from "../commonTypes"; import {UserStatus} from "../Constants"; import ApiState from "../lib/ApiState"; import SDKError from "../lib/SDKError"; import {clearSDKInitialized} from "../lib/SDKState"; -import {getUserIdFromJwt} from "../lib/Utils"; +import {Codec, getUserIdFromJwt} from "../lib/Utils"; import * as UserCrypto from "./UserCrypto"; const toUserUpdateResult = (resp: UserUpdateStatusApiResponse): UserUpdateResult => ({ @@ -17,6 +17,11 @@ const toUserUpdateResult = (resp: UserUpdateStatusApiResponse): UserUpdateResult needsRotation: resp.needsRotation, }); +const clearCurrentDevice = () => { + ApiState.clearCurrentUser(); + clearSDKInitialized(); +}; + /** * Get a list of all groups that the current user is either a member or admin of. */ @@ -53,13 +58,32 @@ export function getUserDevices() { export function deleteUserDevice(deviceID?: number) { return UserApi.callUserDeviceDeleteApi(deviceID).map((resp) => { if (deviceID === undefined) { - ApiState.clearCurrentUser(); - clearSDKInitialized(); + clearCurrentDevice(); + } + return resp; + }); +} + +/** + * Delete device keys from the current user's account. Deleting the device used to make the request ends the session. + */ +export function deleteUserDeviceByPublicSigningKey(publicSigningKey: Base64String) { + const isCurrentDevice = Codec.Buffer.fromBase64(publicSigningKey).equals(ApiState.signingKeys().publicKey); + return UserApi.callUserDeviceDeleteBySigningKeyApi(publicSigningKey).map((resp) => { + if (isCurrentDevice) { + clearCurrentDevice(); } return resp; }); } +/** + * Delete a device of the user in the provided JWT given the device's public signing key. + */ +export function deleteUserDeviceByPublicSigningKeyWithJwt(jwt: string, publicSigningKey: Base64String) { + return UserApi.callUserDeviceDeleteBySigningKeyWithJwtApi(jwt, publicSigningKey); +} + /** * Rotate the current users master key. Takes their escrow password, decrypts their master private key, rotates the key, then re-encrypts the users * key and saves it to ironcore-id. @@ -85,8 +109,7 @@ export function rotateMasterKey(password: string): Future { return UserApi.callUserDisableSelfApi().map((resp) => { - ApiState.clearCurrentUser(); - clearSDKInitialized(); + clearCurrentDevice(); return toUserUpdateResult(resp); }); } diff --git a/src/operations/tests/UserOperations.test.ts b/src/operations/tests/UserOperations.test.ts index b329a97..f37308c 100644 --- a/src/operations/tests/UserOperations.test.ts +++ b/src/operations/tests/UserOperations.test.ts @@ -3,6 +3,7 @@ import UserApi from "../../api/UserApi"; import {ErrorCodes} from "../../Constants"; import ApiState from "../../lib/ApiState"; import * as SDKState from "../../lib/SDKState"; +import SDKError from "../../lib/SDKError"; import * as TestUtils from "../../tests/TestUtils"; import * as UserCrypto from "../UserCrypto"; import * as UserOperations from "../UserOperations"; @@ -161,6 +162,52 @@ describe("UserOperations", () => { }); }); + describe("deleteUserDeviceByPublicSigningKey", () => { + const currentSigningKey = TestUtils.signingPrivateBytes.slice(32).toString("base64"); + + test("keeps the session when deleting another device", (done) => { + jest.spyOn(UserApi, "callUserDeviceDeleteBySigningKeyApi").mockReturnValue(Future.of({id: 352})); + + UserOperations.deleteUserDeviceByPublicSigningKey(Buffer.alloc(32, 7).toString("base64")).engage( + (e) => done.fail(e), + (result) => { + expect(result).toEqual({id: 352}); + expect(ApiState.devicePrivateKey()).toEqual(TestUtils.devicePrivateBytes); + expect(SDKState.isSDKInitialized()).toBe(true); + done(); + } + ); + }); + + test("ends the session when deleting the current device", (done) => { + jest.spyOn(UserApi, "callUserDeviceDeleteBySigningKeyApi").mockReturnValue(Future.of({id: 99})); + + UserOperations.deleteUserDeviceByPublicSigningKey(currentSigningKey).engage( + (e) => done.fail(e), + (result) => { + expect(result).toEqual({id: 99}); + expect(ApiState.devicePrivateKey()).toBeUndefined(); + expect(ApiState.signingKeys().privateKey).toBeUndefined(); + expect(SDKState.isSDKInitialized()).toBe(false); + done(); + } + ); + }); + + test("keeps the session when deleting the current device fails", (done) => { + jest.spyOn(UserApi, "callUserDeviceDeleteBySigningKeyApi").mockReturnValue(Future.reject(new SDKError(new Error("server error"), 0))); + + UserOperations.deleteUserDeviceByPublicSigningKey(currentSigningKey).engage( + () => { + expect(ApiState.devicePrivateKey()).toEqual(TestUtils.devicePrivateBytes); + expect(SDKState.isSDKInitialized()).toBe(true); + done(); + }, + () => done.fail("Expected deleteUserDeviceByPublicSigningKey to fail when the API rejects") + ); + }); + }); + describe("rotateMasterKey", () => { test("rotates key, saves it to the API, and then sets result in ApiState", () => { expect(ApiState.accountEncryptedPrivateKey()).toEqual(TestUtils.accountEncryptedPrivateKeyBytes); diff --git a/src/sdk/UserSDK.ts b/src/sdk/UserSDK.ts index 8f2610e..5664f1c 100644 --- a/src/sdk/UserSDK.ts +++ b/src/sdk/UserSDK.ts @@ -1,3 +1,4 @@ +import {Base64String} from "../commonTypes"; import {checkSDKInitialized} from "../lib/SDKState"; import * as UserOperations from "../operations/UserOperations"; @@ -33,6 +34,17 @@ export function deleteDevice(deviceID?: number) { return UserOperations.deleteUserDevice(deviceID).toPromise(); } +/** + * Delete a device given its public signing key, as returned by `listDevices`. Deleting the device used to make this request ends the session. + */ +export function deleteDeviceByPublicSigningKey(publicSigningKey: Base64String) { + checkSDKInitialized(); + if (typeof publicSigningKey !== "string" || !publicSigningKey.length) { + throw new Error(`Invalid public signing key provided. Expected a non-empty base64 string but got ${publicSigningKey}`); + } + return UserOperations.deleteUserDeviceByPublicSigningKey(publicSigningKey).toPromise(); +} + /** * Rotate the current users master private key. Requires the users escrow password so that we can decrypt their private key * before performing rotation. diff --git a/src/sdk/tests/UserSDK.test.ts b/src/sdk/tests/UserSDK.test.ts index 6dcc888..e818659 100644 --- a/src/sdk/tests/UserSDK.test.ts +++ b/src/sdk/tests/UserSDK.test.ts @@ -103,6 +103,14 @@ describe("UserSDK", () => { }); }); + describe("deleteDeviceByPublicSigningKey", () => { + test("rejects a missing or non-string signing key", () => { + SDKState.setSDKInitialized(); + expect(() => UserSDK.deleteDeviceByPublicSigningKey("")).toThrow(/signing key/); + expect(() => UserSDK.deleteDeviceByPublicSigningKey(35 as unknown as string)).toThrow(/signing key/); + }); + }); + describe("rotateMasterKey", () => { test("should call into UserOperations", () => { const spy = jest.spyOn(UserOperations, "rotateMasterKey"); diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index c26a614..5aad5fd 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -111,6 +111,11 @@ describe("IronNode", () => { .catch((e) => done.fail(e)); }); }); + + test("deleteDeviceByPublicSigningKey rejects an empty JWT or signing key", () => { + expect(() => IronNode.User.deleteDeviceByPublicSigningKey("", "AAAA")).toThrow(/JWT/); + expect(() => IronNode.User.deleteDeviceByPublicSigningKey("the.jwt", "")).toThrow(/signing key/); + }); }); describe("UserStatus", () => { From 8000e1024aea83bdacbe9d06a825b1fbbc36c096 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Wed, 14 Oct 2026 12:12:03 -0600 Subject: [PATCH 4/8] Type listed device name as nullable --- CHANGELOG.md | 1 + ironnode.d.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2da9a9..c039445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `IronNode.initialize()` accepts a `DeviceContext` object, such as the result of `generateDeviceKeys()` or a device JSON produced by another IronCore SDK. - `SDK.user.listDevices()` is typed to include each device's `publicSigningKey`, which the API already returns. - Added `SDK.user.deleteDeviceByPublicSigningKey()` and `IronNode.User.deleteDeviceByPublicSigningKey()` (JWT authorized) to delete a device given its public signing key. Deleting the SDK's own device through `SDK.user` ends the session, as `deleteDevice()` does. +- Fixed the type of `name` on devices returned by `SDK.user.listDevices()` to `string | null`. Devices created without a `deviceName` have a `null` name. ## 1.2.0 diff --git a/ironnode.d.ts b/ironnode.d.ts index d538d48..e3edd4c 100644 --- a/ironnode.d.ts +++ b/ironnode.d.ts @@ -128,7 +128,7 @@ export interface UserPublicKeyGetResponse { } export interface UserDevice { id: number; - name: string; + name: string | null; created: RFC3339Date; updated: RFC3339Date; publicSigningKey: Base64String; From d71cb63dc616439fbed22d0f1b3acb7ec9a99ebb Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Thu, 15 Oct 2026 11:42:44 -0600 Subject: [PATCH 5/8] Accept the pre-1.3.0 device format in initialize --- CHANGELOG.md | 2 +- ironnode.d.ts | 10 ++++ src/index.ts | 15 ++++-- src/tests/index.test.ts | 106 +++++++++++++++++++++++++++++++++------- 4 files changed, 110 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c039445..1bf3f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### Changed - `IronNode.User.generateDeviceKeys()` now also returns the standard IronCore device fields (`accountId`, `segmentId`, `devicePrivateKey`, `signingPrivateKey`) plus the new device's `deviceId`, `name`, `created`, and `lastUpdated`. The existing `accountID`, `segmentID`, `deviceKeys`, and `signingKeys` fields are deprecated. -- `IronNode.initialize()` accepts a `DeviceContext` object, such as the result of `generateDeviceKeys()` or a device JSON produced by another IronCore SDK. +- `IronNode.initialize()` accepts a `DeviceContext` object, such as the result of `generateDeviceKeys()` or a device JSON produced by another IronCore SDK. It also still accepts the pre-1.3.0 `generateDeviceKeys()` result (`accountID`, `segmentID`, `deviceKeys`, `signingKeys`), typed as the deprecated `LegacyDeviceContext`. - `SDK.user.listDevices()` is typed to include each device's `publicSigningKey`, which the API already returns. - Added `SDK.user.deleteDeviceByPublicSigningKey()` and `IronNode.User.deleteDeviceByPublicSigningKey()` (JWT authorized) to delete a device given its public signing key. Deleting the SDK's own device through `SDK.user` ends the session, as `deleteDevice()` does. - Fixed the type of `name` on devices returned by `SDK.user.listDevices()` to `string | null`. Devices created without a `deviceName` have a `null` name. diff --git a/ironnode.d.ts b/ironnode.d.ts index e3edd4c..4f27aa2 100644 --- a/ironnode.d.ts +++ b/ironnode.d.ts @@ -203,7 +203,17 @@ export interface DeviceContext { signingPrivateKey: Base64String; } +/** @deprecated The device format `generateDeviceKeys()` returned before 1.3.0. Use `DeviceContext`. */ +export interface LegacyDeviceContext { + accountID: string; + segmentID: number; + deviceKeys: {privateKey: Base64String}; + signingKeys: {privateKey: Base64String}; +} + export function initialize(deviceContext: DeviceContext): Promise; +/** @deprecated Pass a `DeviceContext`. */ +export function initialize(legacyDeviceContext: LegacyDeviceContext): Promise; export function initialize(accountID: string, segmentID: number, privateDeviceKey: Base64String, privateSigningKey: Base64String): Promise; export interface DeviceDetails extends DeviceContext { diff --git a/src/index.ts b/src/index.ts index 81bca7e..cbaccc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import {DeviceContext, DeviceCreateOptions, SDK, UserCreateOptions} from "../ironnode"; +import {DeviceContext, DeviceCreateOptions, LegacyDeviceContext, SDK, UserCreateOptions} from "../ironnode"; import {Base64String} from "./commonTypes"; import {UserStatus} from "./Constants"; import * as Utils from "./lib/Utils"; @@ -14,16 +14,23 @@ import * as UserOperations from "./operations/UserOperations"; * @param {Base64String} privateSigningKey Base64 encoded signing key bytes associated to the account in use */ export function initialize(deviceContext: DeviceContext): Promise; +/** @deprecated Pass a `DeviceContext`. */ +//tslint:disable-next-line:unified-signatures +export function initialize(legacyDeviceContext: LegacyDeviceContext): Promise; export function initialize(accountID: string, segmentID: number, privateDeviceKey: Base64String, privateSigningKey: Base64String): Promise; export function initialize( - accountIDOrContext: string | DeviceContext, + accountIDOrContext: string | DeviceContext | LegacyDeviceContext, segmentID?: number, privateDeviceKey?: Base64String, privateSigningKey?: Base64String ): Promise { if (typeof accountIDOrContext === "object" && accountIDOrContext !== null) { - const {accountId, segmentId, devicePrivateKey, signingPrivateKey} = accountIDOrContext; - return initializeFromKeys(accountId, segmentId, devicePrivateKey, signingPrivateKey); + if ("accountId" in accountIDOrContext) { + const {accountId, segmentId, devicePrivateKey, signingPrivateKey} = accountIDOrContext; + return initializeFromKeys(accountId, segmentId, devicePrivateKey, signingPrivateKey); + } + const {accountID, segmentID: legacySegmentID, deviceKeys, signingKeys} = accountIDOrContext; + return initializeFromKeys(accountID, legacySegmentID, deviceKeys?.privateKey, signingKeys?.privateKey); } return initializeFromKeys(accountIDOrContext, segmentID, privateDeviceKey, privateSigningKey); } diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index 5aad5fd..564b1c9 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -1,10 +1,71 @@ import Future from "futurejs"; import {SDK} from "../../ironnode"; import * as IronNode from "../index"; +import * as SDKState from "../lib/SDKState"; import * as UserOperations from "../operations/UserOperations"; import * as Initialization from "../sdk/Initialization"; +import * as TestUtils from "./TestUtils"; + +interface StubResponse { + status: number; + body?: unknown; +} +interface SentRequest { + method: string; + path: string; + authorization: string; +} + +/** + * Replaces `fetch` with a server that answers `"METHOD path"` routes, `path` relative to `/api/1/`, and records every request it receives. + */ +const stubApi = (routes: Record) => { + const sent: SentRequest[] = []; + const fakeFetch = (url: string, init: RequestInit = {}) => { + const method = init.method ?? "GET"; + const path = url.replace(/^.*\/api\/1\//, ""); + const headers: HeadersInit = init.headers ?? {}; + sent.push({method, path, authorization: "Authorization" in headers ? String(headers.Authorization) : ""}); + const route = routes[`${method} ${path}`]; + if (!route) { + return Promise.reject(new Error(`Unexpected request ${method} ${path}`)); + } + return Promise.resolve({ok: route.status < 300, status: route.status, statusText: "", json: () => Promise.resolve(route.body)}); + }; + // Jest 26's node environment has no `fetch` global to spy on. + global.fetch = fakeFetch as unknown as typeof fetch; + return sent; +}; + +const ownPublicSigningKey = TestUtils.signingPrivateBytes.slice(32).toString("base64"); +const deviceContext = { + accountId: TestUtils.testAccountID, + segmentId: TestUtils.testSegmentID, + devicePrivateKey: TestUtils.devicePrivateBytes.toString("base64"), + signingPrivateKey: TestUtils.signingPrivateBytes.toString("base64"), +}; +const currentUserRoute: Record = { + "GET users/current": { + status: 200, + body: { + id: TestUtils.testAccountID, + segmentId: TestUtils.testSegmentID, + status: 1, + userMasterPublicKey: TestUtils.accountPublicBytesBase64, + userPrivateKey: TestUtils.accountEncryptedPrivateKeyBytes.toString("base64"), + currentKeyId: TestUtils.testCurrentKeyId, + needsRotation: false, + groupsNeedingRotation: [], + }, + }, +}; describe("IronNode", () => { + afterEach(() => { + Reflect.deleteProperty(global, "fetch"); + SDKState.clearSDKInitialized(); + }); + describe("initialize", () => { test("should fail when provided invalid values", () => { expect(() => IronNode.initialize("abc def", 3, "aaaa", "aaaa")).toThrow(); @@ -16,14 +77,33 @@ describe("IronNode", () => { expect(() => IronNode.initialize("abcdef", 3, "aaaa", 94 as any)).toThrow(); }); - test("should call into init when all parameters appear valid", () => { - const initSpy = jest.spyOn(Initialization, "initialize"); - initSpy.mockReturnValue(Future.of("init") as any); - IronNode.initialize("abc", 3, "aaaa", "aaaa") - .then((res) => { - expect(res).toEqual("init"); - }) - .catch((e) => fail(e)); + test.each<[string, () => Promise]>([ + ["a DeviceContext", () => IronNode.initialize(deviceContext)], + [ + "a pre-1.3.0 device", + () => + IronNode.initialize({ + accountID: deviceContext.accountId, + segmentID: deviceContext.segmentId, + deviceKeys: {privateKey: deviceContext.devicePrivateKey}, + signingKeys: {privateKey: deviceContext.signingPrivateKey}, + }), + ], + [ + "separate arguments", + () => IronNode.initialize(deviceContext.accountId, deviceContext.segmentId, deviceContext.devicePrivateKey, deviceContext.signingPrivateKey), + ], + ])("initializes from %s, signing requests as that device", async (_, initialize) => { + const sent = stubApi(currentUserRoute); + const sdk = await initialize(); + expect(sdk.userContext).toEqual({userNeedsRotation: false, groupsNeedingRotation: []}); + expect(sent).toHaveLength(1); + const [, signedMessage] = sent[0].authorization.split("."); + expect(JSON.parse(Buffer.from(signedMessage, "base64").toString("utf8"))).toMatchObject({ + uid: TestUtils.testAccountID, + sid: TestUtils.testSegmentID, + x: ownPublicSigningKey, + }); }); test("should validate DeviceContext fields", () => { @@ -34,16 +114,6 @@ describe("IronNode", () => { expect(() => IronNode.initialize({...valid, signingPrivateKey: ""})).toThrow(); expect(() => IronNode.initialize({accountID: "abc", segmentID: 3} as unknown as typeof valid)).toThrow(); }); - - test("should initialize from the fields of a DeviceContext", () => { - const sdk = {} as SDK; - const initSpy = jest.spyOn(Initialization, "initialize"); - initSpy.mockReturnValue(Future.of(sdk)); - return IronNode.initialize({accountId: "abc", segmentId: 3, devicePrivateKey: "aaaa", signingPrivateKey: "bbbb"}).then((res) => { - expect(res).toBe(sdk); - expect(initSpy).toHaveBeenCalledWith("abc", 3, "aaaa", "bbbb"); - }); - }); }); describe("User", () => { From 91ee4a72fc45d01922956491b72f71f3bba59646 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Thu, 15 Oct 2026 11:42:56 -0600 Subject: [PATCH 6/8] End the session when any device delete removes the current device --- CHANGELOG.md | 4 +- src/api/UserApi.ts | 2 +- src/api/tests/UserApi.test.ts | 86 --------------- src/index.ts | 5 +- src/operations/UserOperations.ts | 43 +++++--- src/operations/tests/UserOperations.test.ts | 112 -------------------- src/sdk/UserSDK.ts | 6 +- src/sdk/tests/UserSDK.test.ts | 19 +--- src/tests/index.test.ts | 85 +++++++++++++++ 9 files changed, 126 insertions(+), 236 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf3f8d..7fd8f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ - `IronNode.User.generateDeviceKeys()` now also returns the standard IronCore device fields (`accountId`, `segmentId`, `devicePrivateKey`, `signingPrivateKey`) plus the new device's `deviceId`, `name`, `created`, and `lastUpdated`. The existing `accountID`, `segmentID`, `deviceKeys`, and `signingKeys` fields are deprecated. - `IronNode.initialize()` accepts a `DeviceContext` object, such as the result of `generateDeviceKeys()` or a device JSON produced by another IronCore SDK. It also still accepts the pre-1.3.0 `generateDeviceKeys()` result (`accountID`, `segmentID`, `deviceKeys`, `signingKeys`), typed as the deprecated `LegacyDeviceContext`. - `SDK.user.listDevices()` is typed to include each device's `publicSigningKey`, which the API already returns. -- Added `SDK.user.deleteDeviceByPublicSigningKey()` and `IronNode.User.deleteDeviceByPublicSigningKey()` (JWT authorized) to delete a device given its public signing key. Deleting the SDK's own device through `SDK.user` ends the session, as `deleteDevice()` does. +- Added `SDK.user.deleteDeviceByPublicSigningKey()` and `IronNode.User.deleteDeviceByPublicSigningKey()` (JWT authorized) to delete a device given its public signing key. +- Deleting the SDK's own device now ends the session through every delete path: `SDK.user.deleteDevice(id)` with the current device's ID, `SDK.user.deleteDeviceByPublicSigningKey()`, and `IronNode.User.deleteDeviceByPublicSigningKey()`. Previously only `SDK.user.deleteDevice()` with no ID did. `SDK.user.deleteDevice(id)` makes an extra device list request to find out whether `id` is the current device. +- `SDK.user.deleteDevice()` rejects a device ID that is not a positive number. Previously `0` and `null` deleted the current device without ending the session. - Fixed the type of `name` on devices returned by `SDK.user.listDevices()` to `string | null`. Devices created without a `deviceName` have a `null` name. ## 1.2.0 diff --git a/src/api/UserApi.ts b/src/api/UserApi.ts index f77315b..d6198cc 100644 --- a/src/api/UserApi.ts +++ b/src/api/UserApi.ts @@ -200,7 +200,7 @@ const userDeviceList = (sign: MessageSignature, accountID: string) => ({ * Delete a users device keys */ const userDeviceDelete = (sign: MessageSignature, accountID: string, deviceID?: number) => ({ - url: `users/${encodeURIComponent(accountID)}/devices/${deviceID || "current"}`, + url: `users/${encodeURIComponent(accountID)}/devices/${deviceID ?? "current"}`, options: { method: "DELETE", headers: { diff --git a/src/api/tests/UserApi.test.ts b/src/api/tests/UserApi.test.ts index 68c3bb5..6cc9714 100644 --- a/src/api/tests/UserApi.test.ts +++ b/src/api/tests/UserApi.test.ts @@ -1,5 +1,4 @@ import Future from "futurejs"; -import {ErrorCodes} from "../../Constants"; import ApiState from "../../lib/ApiState"; import * as TestUtils from "../../tests/TestUtils"; import * as ApiRequest from "../ApiRequest"; @@ -351,89 +350,4 @@ describe("UserApi", () => { ); }); }); - - describe("callUserDeviceDeleteApi", () => { - test("calls delete API and returns response", (done) => { - const deviceDeleteResult = {id: 35352}; - (ApiRequest.fetchJSON as jest.Mock).mockReturnValue(Future.of(deviceDeleteResult)); - - UserApi.callUserDeviceDeleteApi(35352).engage( - (error) => done.fail(error), - (deletedDevice: any) => { - expect(deletedDevice).toEqual(deviceDeleteResult); - expect(ApiRequest.fetchJSON).toHaveBeenCalledWith( - `users/${TestUtils.testAccountID}/devices/35352`, - jasmine.any(Number), - jasmine.any(Object) - ); - const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2]; - expect(request.headers.Authorization).toMatch(/IronCore\s{1}\d{1}[.][a-zA-Z0-9=\/+]+[.][a-zA-Z0-9=\/+]+/); - expect(request.method).toEqual("DELETE"); - done(); - } - ); - }); - - test("deletes the current device if no ID provided", (done) => { - const deviceDeleteResult = {id: 35352}; - (ApiRequest.fetchJSON as jest.Mock).mockReturnValue(Future.of(deviceDeleteResult)); - - UserApi.callUserDeviceDeleteApi().engage( - (error) => done.fail(error), - (deletedDevice: any) => { - expect(deletedDevice).toEqual(deviceDeleteResult); - expect(ApiRequest.fetchJSON).toHaveBeenCalledWith( - `users/${TestUtils.testAccountID}/devices/current`, - jasmine.any(Number), - jasmine.any(Object) - ); - done(); - } - ); - }); - }); - - describe("callUserDeviceDeleteBySigningKeyApi", () => { - test("sends a signed DELETE for the URL-encoded signing key", (done) => { - (ApiRequest.fetchJSON as jest.Mock).mockReturnValue(Future.of({id: 35352})); - - UserApi.callUserDeviceDeleteBySigningKeyApi("ab/c+d==").engage( - (error) => done.fail(error), - (deletedDevice) => { - expect(deletedDevice).toEqual({id: 35352}); - expect(ApiRequest.fetchJSON).toHaveBeenCalledWith( - `users/${TestUtils.testAccountID}/devices/ab%2Fc%2Bd%3D%3D`, - ErrorCodes.USER_DEVICE_DELETE_REQUEST_FAILURE, - jasmine.any(Object) - ); - const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2]; - expect(request.headers.Authorization).toMatch(/IronCore\s{1}\d{1}[.][a-zA-Z0-9=\/+]+[.][a-zA-Z0-9=\/+]+/); - expect(request.method).toEqual("DELETE"); - done(); - } - ); - }); - }); - - describe("callUserDeviceDeleteBySigningKeyWithJwtApi", () => { - test("sends a JWT authorized DELETE for the URL-encoded signing key", (done) => { - (ApiRequest.fetchJSON as jest.Mock).mockReturnValue(Future.of({id: 35352})); - - UserApi.callUserDeviceDeleteBySigningKeyWithJwtApi("the.jwt", "ab/c+d==").engage( - (error) => done.fail(error), - (deletedDevice) => { - expect(deletedDevice).toEqual({id: 35352}); - expect(ApiRequest.fetchJSON).toHaveBeenCalledWith( - "users/devices/ab%2Fc%2Bd%3D%3D", - ErrorCodes.USER_DEVICE_DELETE_REQUEST_FAILURE, - jasmine.any(Object) - ); - const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2]; - expect(request.headers.Authorization).toEqual("jwt the.jwt"); - expect(request.method).toEqual("DELETE"); - done(); - } - ); - }); - }); }); diff --git a/src/index.ts b/src/index.ts index cbaccc9..9054bd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,7 +93,10 @@ export const User = { return UserOperations.updateUserStatus(jwt, status).toPromise(); }, /** - * Does not require an initialized SDK. + * Delete a device of the user in the provided signed JWT. Does not require an initialized SDK. Deleting the initialized SDK's own device ends + * its session. + * @param {string} jwt Signed JWT for the user who owns the device + * @param {Base64String} publicSigningKey Public signing key of the device to delete, as returned by `SDK.user.listDevices()` */ deleteDeviceByPublicSigningKey(jwt: string, publicSigningKey: Base64String) { if (typeof jwt !== "string" || !jwt.length) { diff --git a/src/operations/UserOperations.ts b/src/operations/UserOperations.ts index bf47403..f41c8f3 100644 --- a/src/operations/UserOperations.ts +++ b/src/operations/UserOperations.ts @@ -5,7 +5,7 @@ import {Base64String, PublicKey} from "../commonTypes"; import {UserStatus} from "../Constants"; import ApiState from "../lib/ApiState"; import SDKError from "../lib/SDKError"; -import {clearSDKInitialized} from "../lib/SDKState"; +import {clearSDKInitialized, isSDKInitialized} from "../lib/SDKState"; import {Codec, getUserIdFromJwt} from "../lib/Utils"; import * as UserCrypto from "./UserCrypto"; @@ -22,6 +22,17 @@ const clearCurrentDevice = () => { clearSDKInitialized(); }; +const isCurrentSigningKey = (publicSigningKey: Base64String) => + isSDKInitialized() && Codec.Buffer.fromBase64(publicSigningKey).equals(ApiState.signingKeys().publicKey); + +const endSessionAfterDelete = (isCurrentDevice: boolean, deleteRequest: Future) => + deleteRequest.map((resp) => { + if (isCurrentDevice) { + clearCurrentDevice(); + } + return resp; + }); + /** * Get a list of all groups that the current user is either a member or admin of. */ @@ -53,35 +64,33 @@ export function getUserDevices() { } /** - * Delete device keys from the users account given the ID or omit to delete the device used to make the delete request. + * Delete device keys from the users account given the ID or omit to delete the device used to make the delete request. Deleting the device used to + * make the request ends the session. */ export function deleteUserDevice(deviceID?: number) { - return UserApi.callUserDeviceDeleteApi(deviceID).map((resp) => { - if (deviceID === undefined) { - clearCurrentDevice(); - } - return resp; - }); + if (deviceID === undefined) { + return endSessionAfterDelete(true, UserApi.callUserDeviceDeleteApi()); + } + return UserApi.callUserDeviceListApi().flatMap(({result}) => + endSessionAfterDelete( + result.some((device) => device.id === deviceID && device.isCurrentDevice), + UserApi.callUserDeviceDeleteApi(deviceID) + ) + ); } /** * Delete device keys from the current user's account. Deleting the device used to make the request ends the session. */ export function deleteUserDeviceByPublicSigningKey(publicSigningKey: Base64String) { - const isCurrentDevice = Codec.Buffer.fromBase64(publicSigningKey).equals(ApiState.signingKeys().publicKey); - return UserApi.callUserDeviceDeleteBySigningKeyApi(publicSigningKey).map((resp) => { - if (isCurrentDevice) { - clearCurrentDevice(); - } - return resp; - }); + return endSessionAfterDelete(isCurrentSigningKey(publicSigningKey), UserApi.callUserDeviceDeleteBySigningKeyApi(publicSigningKey)); } /** - * Delete a device of the user in the provided JWT given the device's public signing key. + * Deleting the initialized SDK's own device ends its session. */ export function deleteUserDeviceByPublicSigningKeyWithJwt(jwt: string, publicSigningKey: Base64String) { - return UserApi.callUserDeviceDeleteBySigningKeyWithJwtApi(jwt, publicSigningKey); + return endSessionAfterDelete(isCurrentSigningKey(publicSigningKey), UserApi.callUserDeviceDeleteBySigningKeyWithJwtApi(jwt, publicSigningKey)); } /** diff --git a/src/operations/tests/UserOperations.test.ts b/src/operations/tests/UserOperations.test.ts index f37308c..a248d50 100644 --- a/src/operations/tests/UserOperations.test.ts +++ b/src/operations/tests/UserOperations.test.ts @@ -3,7 +3,6 @@ import UserApi from "../../api/UserApi"; import {ErrorCodes} from "../../Constants"; import ApiState from "../../lib/ApiState"; import * as SDKState from "../../lib/SDKState"; -import SDKError from "../../lib/SDKError"; import * as TestUtils from "../../tests/TestUtils"; import * as UserCrypto from "../UserCrypto"; import * as UserOperations from "../UserOperations"; @@ -97,117 +96,6 @@ describe("UserOperations", () => { }); }); - describe("deleteUserDevice", () => { - test("calls user device delete API", () => { - const deviceDelete = jest.spyOn(UserApi, "callUserDeviceDeleteApi"); - deviceDelete.mockReturnValue(Future.of({id: 352})); - - UserOperations.deleteUserDevice(352).engage( - (e) => fail(e), - (result: any) => { - expect(result).toEqual({id: 352}); - expect(UserApi.callUserDeviceDeleteApi).toHaveBeenCalledWith(352); - } - ); - }); - - test("does not clear ApiState when deleting an arbitrary device by ID", (done) => { - // Deleting a different device must not revoke the current session, so the - // local keys remain intact and subsequent SDK calls keep working. - jest.spyOn(UserApi, "callUserDeviceDeleteApi").mockReturnValue(Future.of({id: 352})); - - UserOperations.deleteUserDevice(352).engage( - (e) => done.fail(e), - () => { - expect(ApiState.accountAndSegmentIDs().accountID).toEqual(TestUtils.testAccountID); - expect(ApiState.devicePrivateKey()).toEqual(TestUtils.devicePrivateBytes); - done(); - } - ); - }); - - test("clears ApiState and the init flag when called with no device ID (current-device delete)", (done) => { - // The server revokes the current device's keys, so leaving them in process - // memory would let the next SDK call sign with revoked keys and fail with a - // confusing 401 instead of a clean local error. - expect(ApiState.accountAndSegmentIDs().accountID).toEqual(TestUtils.testAccountID); - expect(SDKState.isSDKInitialized()).toBe(true); - - jest.spyOn(UserApi, "callUserDeviceDeleteApi").mockReturnValue(Future.of({id: 99})); - - UserOperations.deleteUserDevice().engage( - (e) => done.fail(e), - (result: any) => { - expect(result).toEqual({id: 99}); - expect(UserApi.callUserDeviceDeleteApi).toHaveBeenCalledWith(undefined); - expect(ApiState.accountAndSegmentIDs()).toEqual({accountID: undefined, segmentID: undefined}); - expect(ApiState.devicePrivateKey()).toBeUndefined(); - expect(ApiState.signingKeys().privateKey).toBeUndefined(); - expect(SDKState.isSDKInitialized()).toBe(false); - done(); - } - ); - }); - - test("does not clear ApiState when the current-device delete API call fails", (done) => { - jest.spyOn(UserApi, "callUserDeviceDeleteApi").mockReturnValue(Future.reject(new Error("server error")) as any); - - UserOperations.deleteUserDevice().engage( - () => { - expect(ApiState.accountAndSegmentIDs().accountID).toEqual(TestUtils.testAccountID); - done(); - }, - () => done.fail("Expected deleteUserDevice to fail when the API rejects") - ); - }); - }); - - describe("deleteUserDeviceByPublicSigningKey", () => { - const currentSigningKey = TestUtils.signingPrivateBytes.slice(32).toString("base64"); - - test("keeps the session when deleting another device", (done) => { - jest.spyOn(UserApi, "callUserDeviceDeleteBySigningKeyApi").mockReturnValue(Future.of({id: 352})); - - UserOperations.deleteUserDeviceByPublicSigningKey(Buffer.alloc(32, 7).toString("base64")).engage( - (e) => done.fail(e), - (result) => { - expect(result).toEqual({id: 352}); - expect(ApiState.devicePrivateKey()).toEqual(TestUtils.devicePrivateBytes); - expect(SDKState.isSDKInitialized()).toBe(true); - done(); - } - ); - }); - - test("ends the session when deleting the current device", (done) => { - jest.spyOn(UserApi, "callUserDeviceDeleteBySigningKeyApi").mockReturnValue(Future.of({id: 99})); - - UserOperations.deleteUserDeviceByPublicSigningKey(currentSigningKey).engage( - (e) => done.fail(e), - (result) => { - expect(result).toEqual({id: 99}); - expect(ApiState.devicePrivateKey()).toBeUndefined(); - expect(ApiState.signingKeys().privateKey).toBeUndefined(); - expect(SDKState.isSDKInitialized()).toBe(false); - done(); - } - ); - }); - - test("keeps the session when deleting the current device fails", (done) => { - jest.spyOn(UserApi, "callUserDeviceDeleteBySigningKeyApi").mockReturnValue(Future.reject(new SDKError(new Error("server error"), 0))); - - UserOperations.deleteUserDeviceByPublicSigningKey(currentSigningKey).engage( - () => { - expect(ApiState.devicePrivateKey()).toEqual(TestUtils.devicePrivateBytes); - expect(SDKState.isSDKInitialized()).toBe(true); - done(); - }, - () => done.fail("Expected deleteUserDeviceByPublicSigningKey to fail when the API rejects") - ); - }); - }); - describe("rotateMasterKey", () => { test("rotates key, saves it to the API, and then sets result in ApiState", () => { expect(ApiState.accountEncryptedPrivateKey()).toEqual(TestUtils.accountEncryptedPrivateKeyBytes); diff --git a/src/sdk/UserSDK.ts b/src/sdk/UserSDK.ts index 5664f1c..0b86f74 100644 --- a/src/sdk/UserSDK.ts +++ b/src/sdk/UserSDK.ts @@ -22,13 +22,13 @@ export function listDevices() { } /** - * Delete a device given its ID. If no ID is used it will delete the device being used to make this request. Removes device keys - * and makes them unusable for future API requests. + * If no ID is used it will delete the device being used to make this request. Removes device keys + * and makes them unusable for future API requests. Deleting the device used to make this request ends the session. * @param {number} deviceID ID of device to delete. Omit to delete the current device. */ export function deleteDevice(deviceID?: number) { checkSDKInitialized(); - if (deviceID && typeof deviceID !== "number") { + if (deviceID !== undefined && (typeof deviceID !== "number" || deviceID <= 0)) { throw new Error(`Invalid device ID provided. Expected a number greater than zero but got ${deviceID}`); } return UserOperations.deleteUserDevice(deviceID).toPromise(); diff --git a/src/sdk/tests/UserSDK.test.ts b/src/sdk/tests/UserSDK.test.ts index e818659..14ce2a2 100644 --- a/src/sdk/tests/UserSDK.test.ts +++ b/src/sdk/tests/UserSDK.test.ts @@ -85,21 +85,10 @@ describe("UserSDK", () => { }); describe("deleteDevice", () => { - test("fails when device ID isnt provided or isnt a number", () => { - expect(() => (UserSDK as any).deleteDevice()).toThrow(); - expect(() => (UserSDK as any).deleteDevice("35")).toThrow(); - expect(() => (UserSDK as any).deleteDevice([])).toThrow(); - }); - - test("calls user operations method with device ID", () => { - const spy = jest.spyOn(UserOperations, "deleteUserDevice"); - spy.mockReturnValue(Future.of("resp") as any); - UserSDK.deleteDevice(34) - .then((resp) => { - expect(resp).toEqual("resp"); - expect(UserOperations.deleteUserDevice).toHaveBeenCalledWith(34); - }) - .catch((e) => fail(e)); + test("rejects a device ID that is not a positive number", () => { + [0, -1, null, "35", []].forEach((invalid) => { + expect(() => UserSDK.deleteDevice(invalid as unknown as number)).toThrow(/Invalid device ID/); + }); }); }); diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index 564b1c9..0e45f45 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -1,5 +1,6 @@ import Future from "futurejs"; import {SDK} from "../../ironnode"; +import {ErrorCodes} from "../Constants"; import * as IronNode from "../index"; import * as SDKState from "../lib/SDKState"; import * as UserOperations from "../operations/UserOperations"; @@ -38,6 +39,7 @@ const stubApi = (routes: Record) => { }; const ownPublicSigningKey = TestUtils.signingPrivateBytes.slice(32).toString("base64"); +const otherPublicSigningKey = Buffer.alloc(32, 7).toString("base64"); const deviceContext = { accountId: TestUtils.testAccountID, segmentId: TestUtils.testSegmentID, @@ -116,6 +118,89 @@ describe("IronNode", () => { }); }); + describe("deleting a device", () => { + const deviceListRoute: Record = { + [`GET users/${TestUtils.testAccountID}/devices`]: { + status: 200, + body: { + result: [ + {id: 352, name: null, created: "", updated: "", publicSigningKey: otherPublicSigningKey, isCurrentDevice: false}, + {id: 99, name: null, created: "", updated: "", publicSigningKey: ownPublicSigningKey, isCurrentDevice: true}, + ], + }, + }, + }; + const deleteRoute = (path: string, response: StubResponse = {status: 200, body: {id: 1}}) => ({[`DELETE ${path}`]: response}); + const devicePath = (device: string) => `users/${TestUtils.testAccountID}/devices/${encodeURIComponent(device)}`; + + const signIn = async (routes: Record) => { + const sent = stubApi({...currentUserRoute, ...deviceListRoute, ...routes}); + return {sdk: await IronNode.initialize(deviceContext), sent}; + }; + const expectSessionEnded = (sdk: SDK) => expect(() => sdk.user.listDevices()).toThrow(/initialize/); + const expectSessionOpen = (sdk: SDK) => expect(sdk.user.listDevices()).resolves.toMatchObject({result: expect.any(Array)}); + + test("deleteDevice() deletes the current device and ends the session", async () => { + const {sdk, sent} = await signIn(deleteRoute(devicePath("current"))); + await sdk.user.deleteDevice(); + expect(sent).toContainEqual({method: "DELETE", path: devicePath("current"), authorization: expect.stringMatching(/^IronCore /)}); + expectSessionEnded(sdk); + }); + + test("deleteDevice(id) of the current device ends the session", async () => { + const {sdk, sent} = await signIn(deleteRoute(devicePath("99"))); + await sdk.user.deleteDevice(99); + expect(sent).toContainEqual({method: "DELETE", path: devicePath("99"), authorization: expect.stringMatching(/^IronCore /)}); + expectSessionEnded(sdk); + }); + + test("deleteDevice(id) of another device keeps the session", async () => { + const {sdk, sent} = await signIn(deleteRoute(devicePath("352"))); + await sdk.user.deleteDevice(352); + expect(sent).toContainEqual({method: "DELETE", path: devicePath("352"), authorization: expect.stringMatching(/^IronCore /)}); + await expectSessionOpen(sdk); + }); + + test("deleteDeviceByPublicSigningKey of the current device ends the session", async () => { + const {sdk, sent} = await signIn(deleteRoute(devicePath(ownPublicSigningKey))); + await sdk.user.deleteDeviceByPublicSigningKey(ownPublicSigningKey); + expect(sent).toContainEqual({method: "DELETE", path: devicePath(ownPublicSigningKey), authorization: expect.stringMatching(/^IronCore /)}); + expectSessionEnded(sdk); + }); + + test("deleteDeviceByPublicSigningKey of another device keeps the session", async () => { + const {sdk} = await signIn(deleteRoute(devicePath(otherPublicSigningKey))); + await sdk.user.deleteDeviceByPublicSigningKey(otherPublicSigningKey); + await expectSessionOpen(sdk); + }); + + test("a failed delete of the current device rejects and keeps the session", async () => { + const {sdk} = await signIn(deleteRoute(devicePath("current"), {status: 500, body: [{message: "nope", code: 0}]})); + await expect(sdk.user.deleteDevice()).rejects.toMatchObject({code: ErrorCodes.USER_DEVICE_DELETE_REQUEST_FAILURE}); + await expectSessionOpen(sdk); + }); + + test("User.deleteDeviceByPublicSigningKey sends a JWT authorized delete and ends the session of the deleted device", async () => { + const {sdk, sent} = await signIn(deleteRoute(`users/devices/${encodeURIComponent(ownPublicSigningKey)}`)); + await IronNode.User.deleteDeviceByPublicSigningKey("the.jwt", ownPublicSigningKey); + expect(sent).toContainEqual({method: "DELETE", path: `users/devices/${encodeURIComponent(ownPublicSigningKey)}`, authorization: "jwt the.jwt"}); + expectSessionEnded(sdk); + }); + + test("User.deleteDeviceByPublicSigningKey of another device keeps the session", async () => { + const {sdk} = await signIn(deleteRoute(`users/devices/${encodeURIComponent(otherPublicSigningKey)}`)); + await IronNode.User.deleteDeviceByPublicSigningKey("the.jwt", otherPublicSigningKey); + await expectSessionOpen(sdk); + }); + + test("User.deleteDeviceByPublicSigningKey works after the SDK session has ended", async () => { + const jwtDeletePath = `users/devices/${encodeURIComponent(ownPublicSigningKey)}`; + const {sdk} = await signIn({...deleteRoute(devicePath("current")), ...deleteRoute(jwtDeletePath, {status: 200, body: {id: 99}})}); + await sdk.user.deleteDevice(); + await expect(IronNode.User.deleteDeviceByPublicSigningKey("the.jwt", ownPublicSigningKey)).resolves.toEqual({id: 99}); + }); + }); + describe("User", () => { test("verify", () => { const verifySpy = jest.spyOn(Initialization, "userVerify"); From e1f1e45effec36fa2ffcfd6b57d5884940384e61 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Thu, 15 Oct 2026 11:43:08 -0600 Subject: [PATCH 7/8] Return publicSigningKey from generateDeviceKeys and fix device JSDoc --- CHANGELOG.md | 2 +- ironnode.d.ts | 4 +++- src/index.ts | 23 +++++++++++++---------- src/sdk/Initialization.ts | 4 +++- src/sdk/tests/Initialization.test.ts | 7 +++++-- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd8f54..f561ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Changed -- `IronNode.User.generateDeviceKeys()` now also returns the standard IronCore device fields (`accountId`, `segmentId`, `devicePrivateKey`, `signingPrivateKey`) plus the new device's `deviceId`, `name`, `created`, and `lastUpdated`. The existing `accountID`, `segmentID`, `deviceKeys`, and `signingKeys` fields are deprecated. +- `IronNode.User.generateDeviceKeys()` now also returns the standard IronCore device fields (`accountId`, `segmentId`, `devicePrivateKey`, `signingPrivateKey`) plus the new device's `deviceId`, `name`, `created`, `lastUpdated`, and `publicSigningKey`. The existing `accountID`, `segmentID`, `deviceKeys`, and `signingKeys` fields are deprecated. - `IronNode.initialize()` accepts a `DeviceContext` object, such as the result of `generateDeviceKeys()` or a device JSON produced by another IronCore SDK. It also still accepts the pre-1.3.0 `generateDeviceKeys()` result (`accountID`, `segmentID`, `deviceKeys`, `signingKeys`), typed as the deprecated `LegacyDeviceContext`. - `SDK.user.listDevices()` is typed to include each device's `publicSigningKey`, which the API already returns. - Added `SDK.user.deleteDeviceByPublicSigningKey()` and `IronNode.User.deleteDeviceByPublicSigningKey()` (JWT authorized) to delete a device given its public signing key. diff --git a/ironnode.d.ts b/ironnode.d.ts index 4f27aa2..0280924 100644 --- a/ironnode.d.ts +++ b/ironnode.d.ts @@ -221,6 +221,8 @@ export interface DeviceDetails extends DeviceContext { name: string | null; created: RFC3339Date; lastUpdated: RFC3339Date; + /** Identifies this device to `deleteDeviceByPublicSigningKey`, matching `UserDevice.publicSigningKey`. */ + publicSigningKey: Base64String; /** @deprecated Use `accountId`. */ accountID: string; /** @deprecated Use `segmentId`. */ @@ -230,7 +232,7 @@ export interface DeviceDetails extends DeviceContext { publicKey: PublicKey; privateKey: Base64String; }; - /** @deprecated Use `signingPrivateKey`. */ + /** @deprecated Use `signingPrivateKey` and `publicSigningKey`. */ signingKeys: { publicKey: Base64String; privateKey: Base64String; diff --git a/src/index.ts b/src/index.ts index 9054bd4..07af026 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,17 +6,19 @@ import * as Initialization from "./sdk/Initialization"; import * as UserOperations from "./operations/UserOperations"; /** - * Initialize the Node SDK by providing account information necessary to run operations from a server side context. Accepts either a - * `DeviceContext` or its fields as separate arguments. - * @param {string} accountID ID of the account to perform actions as - * @param {number} segmentID Internal segment ID the account is part of - * @param {Base64String} privateDeviceKey Base64 encoded device key bytes associated to the account in use - * @param {Base64String} privateSigningKey Base64 encoded signing key bytes associated to the account in use + * Initialize the Node SDK by providing account information necessary to run operations from a server side context. + * @param {DeviceContext} deviceContext Device keys, such as the result of `User.generateDeviceKeys()` */ export function initialize(deviceContext: DeviceContext): Promise; /** @deprecated Pass a `DeviceContext`. */ //tslint:disable-next-line:unified-signatures export function initialize(legacyDeviceContext: LegacyDeviceContext): Promise; +/** + * @param {string} accountID ID of the account to perform actions as + * @param {number} segmentID Internal segment ID the account is part of + * @param {Base64String} privateDeviceKey Base64 encoded device key bytes associated to the account in use + * @param {Base64String} privateSigningKey Base64 encoded signing key bytes associated to the account in use + */ export function initialize(accountID: string, segmentID: number, privateDeviceKey: Base64String, privateSigningKey: Base64String): Promise; export function initialize( accountIDOrContext: string | DeviceContext | LegacyDeviceContext, @@ -68,10 +70,11 @@ export const User = { /** * Generate a new pair of device keys for the user specified in the provided signed JWT. Takes the users password in order to decrypt their master * keys and generates a new device key pair as well as a transform key between their master key pair and their device key pair. Resolves with a - * `DeviceDetails`, a `DeviceContext` plus the new device's ID, name, and timestamps. Store it somewhere secure and pass it to `initialize`. Also - * allows the call to provide a readable "name" for this device. - * @param {string} jwt Signed JWT for the user to sync - * @param {string} password Password to use to escrow the users private key + * `DeviceDetails`, a `DeviceContext` plus the new device's ID, name, timestamps, and public signing key. Store it somewhere secure and pass it to + * `initialize`. Also allows the call to provide a readable "name" for this device. + * @param {string} jwt Signed JWT for the user to sync + * @param {string} password Password to use to escrow the users private key + * @param {DeviceCreateOptions} deviceOptions Device create options. */ generateDeviceKeys(jwt: string, password: string, deviceOptions: DeviceCreateOptions = {deviceName: ""}) { return Initialization.generateDevice(jwt, password, deviceOptions).toPromise(); diff --git a/src/sdk/Initialization.ts b/src/sdk/Initialization.ts index 02a89ac..94edf3f 100644 --- a/src/sdk/Initialization.ts +++ b/src/sdk/Initialization.ts @@ -137,6 +137,7 @@ export function generateDevice(jwt: string, password: string, options: DeviceCre ).map((device) => { const devicePrivateKey = Codec.Buffer.toBase64(deviceAdd.deviceKeys.privateKey); const signingPrivateKey = Codec.Buffer.toBase64(deviceAdd.signingKeys.privateKey); + const publicSigningKey = Codec.Buffer.toBase64(deviceAdd.signingKeys.publicKey); return { accountId: user.id, segmentId: user.segmentId, @@ -146,6 +147,7 @@ export function generateDevice(jwt: string, password: string, options: DeviceCre name: device.name, created: device.created, lastUpdated: device.updated, + publicSigningKey, accountID: user.id, segmentID: user.segmentId, deviceKeys: { @@ -153,7 +155,7 @@ export function generateDevice(jwt: string, password: string, options: DeviceCre privateKey: devicePrivateKey, }, signingKeys: { - publicKey: Codec.Buffer.toBase64(deviceAdd.signingKeys.publicKey), + publicKey: publicSigningKey, privateKey: signingPrivateKey, }, }; diff --git a/src/sdk/tests/Initialization.test.ts b/src/sdk/tests/Initialization.test.ts index 2e4884e..e31b6df 100644 --- a/src/sdk/tests/Initialization.test.ts +++ b/src/sdk/tests/Initialization.test.ts @@ -284,10 +284,13 @@ describe("Initialization", () => { created: "2026-10-14T12:00:00Z", lastUpdated: "2026-10-14T12:00:01Z", }); - expect(Buffer.from(result.devicePrivateKey, "base64")).toHaveLength(32); + Recrypt.derivePublicKey(Buffer.from(result.devicePrivateKey, "base64")).engage(fail, (devicePublicKey) => { + expect(Codec.PublicKey.toBase64(devicePublicKey)).toEqual(result.deviceKeys.publicKey); + }); const signingPrivateKey = Buffer.from(result.signingPrivateKey, "base64"); expect(signingPrivateKey).toHaveLength(64); - expect(signingPrivateKey.slice(32).toString("base64")).toEqual(result.signingKeys.publicKey); + expect(signingPrivateKey.slice(32).toString("base64")).toEqual(result.publicSigningKey); + expect(result.publicSigningKey).toEqual(result.signingKeys.publicKey); expect(result.accountID).toEqual("353"); expect(result.segmentID).toEqual(3); From d96dd438148e010d419b9007a68b2f5e3840ff54 Mon Sep 17 00:00:00 2001 From: Colt Frederickson Date: Fri, 16 Oct 2026 11:53:30 -0600 Subject: [PATCH 8/8] Small hardening for NaN and Infinity --- src/sdk/UserSDK.ts | 2 +- src/sdk/tests/UserSDK.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sdk/UserSDK.ts b/src/sdk/UserSDK.ts index 0b86f74..059c652 100644 --- a/src/sdk/UserSDK.ts +++ b/src/sdk/UserSDK.ts @@ -28,7 +28,7 @@ export function listDevices() { */ export function deleteDevice(deviceID?: number) { checkSDKInitialized(); - if (deviceID !== undefined && (typeof deviceID !== "number" || deviceID <= 0)) { + if (deviceID !== undefined && (!Number.isInteger(deviceID) || deviceID <= 0)) { throw new Error(`Invalid device ID provided. Expected a number greater than zero but got ${deviceID}`); } return UserOperations.deleteUserDevice(deviceID).toPromise(); diff --git a/src/sdk/tests/UserSDK.test.ts b/src/sdk/tests/UserSDK.test.ts index 14ce2a2..672b70c 100644 --- a/src/sdk/tests/UserSDK.test.ts +++ b/src/sdk/tests/UserSDK.test.ts @@ -85,8 +85,8 @@ describe("UserSDK", () => { }); describe("deleteDevice", () => { - test("rejects a device ID that is not a positive number", () => { - [0, -1, null, "35", []].forEach((invalid) => { + test("rejects a device ID that is not a positive integer", () => { + [0, -1, 1.5, NaN, Infinity, null, "35", []].forEach((invalid) => { expect(() => UserSDK.deleteDevice(invalid as unknown as number)).toThrow(/Invalid device ID/); }); });