DEV Community

HarmonyOS
HarmonyOS

Posted on

Designing a Secure Token Vault on HarmonyOS Wearable Using Asset Store Service

Read the original article:Designing a Secure Token Vault on HarmonyOS Wearable Using Asset Store Service

Designing a Secure Token Vault on HarmonyOS Wearable Using Asset Store Service

Requirement Description

Securely persist API tokens on HarmonyOS so users don’t need to re-authenticate on every app launch. The token must be protected against unauthorized access and support save, read, update, remove operations.

kbs--8c2572ea9a0c4eacbfae51afe2db8c57-10859b.png

Background Knowledge

HarmonyOS provides Asset Store Service (Secure Kit) under @kit.AssetStoreKit for hardware-backed secure storage. Data is encrypted and bound to the device/unlock state.
Key concepts: ALIAS, SECRET, ACCESSIBILITY, asset.add/query/update/remove.

Implementation Steps

  1. Initialize Asset Store helpers (TextEncoder/Decoder; alias constant).
  2. Implement CRUD: asset.add, asset.query, asset.update, asset.remove.
  3. Build a wearable-friendly UI page with buttons: Save / Read / Update / Remove and a Show/Hide mask.
  4. Handle errors (alias exists, not found) and keep sensitive values out of logs.

Code Snippet / Configuration

A) Asset Store service layer
entry/src/main/ets/services/assetService.ets

import { asset } from '@kit.AssetStoreKit';
import util from '@ohos.util';

const enc = new util.TextEncoder();
const dec = new util.TextDecoder();

const toBytes = (s: string): Uint8Array => enc.encode(s);
const toStr = (b?: Uint8Array): string => (b ? dec.decode(b) : '');

export const TOKEN_ALIAS = 'secure_api_token_v1';

// Save (create)
export async function assetSaveToken(secret: string): Promise<void> {
  const attrs: asset.AssetMap = new Map();
  attrs.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS));
  attrs.set(asset.Tag.SECRET, toBytes(secret));
  attrs.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_FIRST_UNLOCKED);
  await asset.add(attrs);
}

// Read
export async function assetReadToken(): Promise<string | null> {
  const query: asset.AssetMap = new Map();
  query.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS));
  const res = await asset.query(query);
  if (!res?.length) return null;
  return toStr(res[0].get(asset.Tag.SECRET) as Uint8Array | undefined);
}

// Update
export async function assetUpdateToken(newSecret: string): Promise<void> {
  const attrs: asset.AssetMap = new Map();
  attrs.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS));
  attrs.set(asset.Tag.SECRET, toBytes(newSecret));
  attrs.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_FIRST_UNLOCKED);
  await asset.update(attrs);
}

// Remove
export async function assetRemoveToken(): Promise<void> {
  const query: asset.AssetMap = new Map();
  query.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS));
  await asset.remove(query);
}
Enter fullscreen mode Exit fullscreen mode

B) UI page (wearable-friendly, uses Asset Store)
entry/src/main/ets/pages/HomePage.ets

import router from '@ohos.router';
import type common from '@ohos.app.ability.common';

import {
  assetSaveToken,
  assetReadToken,
  assetUpdateToken,
  assetRemoveToken
} from '../services/assetService';

import { TokenCard } from '../components/TokenCard';

@Entry
@Component
export struct HomePage {
  @State status: string = 'Tap Read to load token';
  @State tokenPreview: string = '';
  @State masked: boolean = true;

  aboutToAppear() {
    this.tokenPreview = '';
    this.masked = true;
    this.status = 'Tap Read to load token';
  }

  // --- Actions (Asset Store) ---
  private async onSave(): Promise<void> {
    try {
      const _ctx: common.Context = this.getUIContext().getHostContext()!;
      const val: string = `sk_${Date.now()}`;
      await assetSaveToken(val);
      this.tokenPreview = val;
      this.status = 'Saved token (Asset Store)';
    } catch (e) {
      const msg: string = (e as Error).message ?? '';
      this.status = 'Save failed' + (msg ? ', message=' + msg : '');
    }
  }

  private async onRead(): Promise<void> {
    try {
      const _ctx: common.Context = this.getUIContext().getHostContext()!;
      const v: string | null = await assetReadToken();
      this.tokenPreview = v ?? '';
      this.status = v ? 'Read token OK' : 'No token stored';
      this.masked = true;
    } catch (e) {
      const msg: string = (e as Error).message ?? '';
      this.status = 'Read failed' + (msg ? ', message=' + msg : '');
    }
  }

  private async onUpdate(): Promise<void> {
    try {
      const _ctx: common.Context = this.getUIContext().getHostContext()!;
      const newVal: string = `sk_${Date.now()}`;
      await assetUpdateToken(newVal);
      this.tokenPreview = newVal;
      this.status = 'Updated token (Asset Store)';
    } catch (e) {
      const msg: string = (e as Error).message ?? '';
      this.status = 'Update failed' + (msg ? ', message=' + msg : '');
    }
  }

  private async onRemove(): Promise<void> {
    try {
      const _ctx: common.Context = this.getUIContext().getHostContext()!;
      await assetRemoveToken();
      this.tokenPreview = '';
      this.status = 'Removed token (Asset Store)';
      this.masked = true;
    } catch (e) {
      const msg: string = (e as Error).message ?? '';
      this.status = 'Remove failed' + (msg ? ', message=' + msg : '');
    }
  }

  // ---- Wearable XS button style ----
  @Builder private XSButton(label: string, onTap: () => void, fullWidth: boolean = false, topMargin: number = 0) {
    Button(label)
      .height(26)
      .width(fullWidth ? '100%' : '46%')
      .borderRadius(10)
      .fontSize(11)
      .padding({ left: 6, right: 6 })
      .margin({ top: topMargin })
      .opacity(0.95)
      .onClick(onTap)
  }

  build() {
    Scroll() {
      Column() {
        Column() {
          Text('Secure Token Locker')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .margin({ bottom: 2 })
          Text('Wearable-friendly demo (Asset Store)')
            .fontSize(8.5)
            .opacity(0.7)
        }
        .width('100%')
        .padding(6)
        .backgroundColor('#11161a')
        .borderRadius(10)

        TokenCard({
          token: this.tokenPreview,
          status: this.status,
          masked: $masked
        })
          .margin({ top: 6 })

        Row() {
          this.XSButton('Save', () => this.onSave())
          this.XSButton('Read', () => this.onRead())
        }
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 6 })

        Row() {
          this.XSButton('Update', () => this.onUpdate())
          this.XSButton('Remove', () => this.onRemove())
        }
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 4 })

        this.XSButton('Go to Details', () => {
          router.pushUrl({
            url: 'pages/DetailsPage',
            params: { token: this.tokenPreview }
          }).catch((e: Error) => {
            const msg: string = e.message ?? '';
            this.status = 'pushUrl failed' + (msg ? ', message=' + msg : '');
          });
        }, true, 6)

        this.XSButton('Saved Tokens', () => {
          router.pushUrl({ url: 'pages/TokensPage' })
            .catch((e: Error) => {
              const msg: string = e.message ?? '';
              this.status = 'pushUrl failed' + (msg ? ', message=' + msg : '');
            });
        }, true, 4)
      }
      .width('100%')
      .padding(6)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0b0f12')
  }
}
Enter fullscreen mode Exit fullscreen mode

(Optional) Minimal TokenCard stub for compatibility
entry/src/main/ets/components/TokenCard.ets

@Component
export struct TokenCard {
  @Prop token: string;
  @Prop status: string;
  @State masked: boolean;

  build() {
    Column() {
      Row() {
        Text('Status: ').fontWeight(FontWeight.Bold).fontSize(10)
        Text(this.status).fontSize(10).opacity(0.85)
      }.margin({ bottom: 4 })

      Row() {
        Text('Token: ').fontWeight(FontWeight.Bold).fontSize(10)
        Text(this.masked && this.token ? '••••••••••' : (this.token || '(empty)')).fontSize(10)
      }

      Button(this.masked ? 'Show' : 'Hide')
        .margin({ top: 6 })
        .height(26)
        .fontSize(11)
        .onClick(() => { this.masked = !this.masked; })
    }
    .width('100%')
    .padding(8)
    .backgroundColor('#161b20')
    .borderRadius(10)
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Results

  • Save: Token stored; persists after device restart.
  • Read: Retrieved correctly; UI shows masked value by default.
  • Update: Overwrites existing secret via asset.update; UI updates preview.
  • Remove: Deletes asset; subsequent read returns null; UI cleared.

cke_5522.png cke_7134.png

Limitations or Considerations

  • Device capability: Some wearables may lack required system capabilities for Asset Store.

  • Fallback caution: Preferences is not secure; if used, wrap with app-level encryption and clear risk notes.

  • Accessibility policy: DEVICE_FIRST_UNLOCKED for convenience; choose stricter options if UX allows.

  • No logging/secrets exposure: Avoid logging tokens; keep UI masked by default; avoid clipboard use.

  • Rotation/expiry: Handle on server; client should remove stale token and save the fresh one.

cke_11399.png

Related Documents or Links

Written by Omer Basri Okcu

Top comments (0)