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.
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
- Initialize Asset Store helpers (TextEncoder/Decoder; alias constant).
- Implement CRUD:
asset.add,asset.query,asset.update,asset.remove. - Build a wearable-friendly UI page with buttons: Save / Read / Update / Remove and a Show/Hide mask.
- 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);
}
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')
}
}
(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)
}
}
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.
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_UNLOCKEDfor 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.




Top comments (0)