Skip to content

Commit 1b6e0e6

Browse files
committed
refactor: enhance key rotation and secure storage hooks with eager re-encryption support
1 parent 41be56a commit 1b6e0e6

6 files changed

Lines changed: 54 additions & 22 deletions

File tree

example/src/components/KeyRotationPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const KeyRotationPanel: React.FC<KeyRotationPanelProps> = ({ service }) => {
3232
}, [refreshVersion, rotate])
3333

3434
const handleRotateEager = useCallback(async () => {
35-
const ack = await rotate()
35+
const ack = await rotate({ reEncryptEagerly: true })
3636
if (ack.success) {
3737
await refreshVersion()
3838
}

ios/HybridSensitiveInfo.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
5959
var valueData = Data(request.value.utf8)
6060
defer { zeroize(&valueData) }
6161

62-
let tag = (try? integrity.sign(
62+
let tag = try integrity.sign(
6363
integrityInput(
6464
service: service,
6565
account: request.key,
@@ -69,7 +69,7 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
6969
timestamp: timestamp,
7070
value: valueData
7171
)
72-
))
72+
)
7373

7474
let metadata = buildMetadata(
7575
securityLevel: resolved.securityLevel,
@@ -104,7 +104,7 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
104104
}
105105

106106
if status == errSecParam, resolved.accessControlRef != nil {
107-
let fallbackTag = (try? integrity.sign(
107+
let fallbackTag = try integrity.sign(
108108
integrityInput(
109109
service: service,
110110
account: request.key,
@@ -114,7 +114,7 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
114114
timestamp: timestamp,
115115
value: valueData
116116
)
117-
))
117+
)
118118
let fallbackMetadata = buildMetadata(
119119
securityLevel: .software,
120120
accessControl: .none,

ios/Internal/Security/MetadataIntegrity.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ struct MetadataIntegrity {
2828
}
2929

3030
/// Process-local cache so we don't pay a Keychain round-trip on every read. Guarded by a
31-
/// recursive lock — first-touch creation is rare, but reads are on the hot path.
31+
/// non-recursive lock — first-touch creation is rare, callers must not re-enter `getOrCreateKey`.
3232
private static let cacheLock = NSLock()
3333
private static var keyCache: [String: SymmetricKey] = [:]
3434

@@ -121,10 +121,23 @@ struct MetadataIntegrity {
121121
addAttributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
122122
addAttributes[kSecValueData as String] = rawKey
123123
let addStatus = SecItemAdd(addAttributes as CFDictionary, nil)
124+
125+
if addStatus == errSecDuplicateItem {
126+
// Cross-process race: another writer added the key between our lookup and add.
127+
// Re-fetch the canonical stored key so HMACs stay consistent across producers.
128+
zeroize(&rawKey)
129+
var raceResult: CFTypeRef?
130+
let raceStatus = SecItemCopyMatching(fetchQuery as CFDictionary, &raceResult)
131+
if raceStatus == errSecSuccess, let data = raceResult as? Data {
132+
return SymmetricKey(data: data)
133+
}
134+
throw IntegrityError.keyUnavailable(service: service)
135+
}
136+
124137
let key = SymmetricKey(data: rawKey)
125138
zeroize(&rawKey)
126139

127-
if addStatus != errSecSuccess && addStatus != errSecDuplicateItem {
140+
if addStatus != errSecSuccess {
128141
throw IntegrityError.keyUnavailable(service: service)
129142
}
130143
return key

src/errors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ const extractCode = (error: unknown): ErrorCodeValue | null => {
133133
const extractMessage = (error: unknown, fallback: string): string => {
134134
if (error instanceof Error && error.message) return error.message
135135
if (typeof error === 'string' && error.length > 0) return error
136+
if (error !== null && typeof error === 'object' && 'message' in error) {
137+
const candidate = (error as { message?: unknown }).message
138+
if (typeof candidate === 'string' && candidate.length > 0) return candidate
139+
}
136140
return fallback
137141
}
138142

src/hooks/useKeyRotation.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,25 @@ export interface UseKeyRotationOptions extends SensitiveInfoOptions {
1919
readonly reEncryptEagerly?: boolean
2020
}
2121

22+
/** Per-call overrides accepted by {@link UseKeyRotationResult.rotate}. */
23+
export interface RotateCallOptions {
24+
readonly reEncryptEagerly?: boolean
25+
}
26+
2227
export interface UseKeyRotationResult {
2328
/** Most recent rotation result, if any. */
2429
readonly lastResult: RotationResult | null
2530
/** Current error bag for rotation operations. */
2631
readonly error: HookError | null
2732
/** True while a rotation call is in flight. */
2833
readonly isRotating: boolean
29-
/** Trigger a master-key rotation for the configured service. */
30-
readonly rotate: () => Promise<HookMutationResult>
34+
/**
35+
* Trigger a master-key rotation for the configured service. Pass
36+
* `{ reEncryptEagerly: true }` to override the hook-level default for this call only.
37+
*/
38+
readonly rotate: (
39+
overrides?: RotateCallOptions
40+
) => Promise<HookMutationResult>
3141
/** Imperatively read the active key version from the native module. */
3242
readonly readVersion: () => Promise<number | null>
3343
}
@@ -51,18 +61,22 @@ export function useKeyRotation(
5161
'Check that the service exists and that no auth-gated entries are blocking eager rotation.'
5262
)
5363

54-
const rotate = useCallback(async (): Promise<HookMutationResult> => {
55-
const request: RotateKeysRequest = {
56-
...options,
57-
reEncryptEagerly: options?.reEncryptEagerly ?? false,
58-
}
59-
const outcome = await mutate(() => rotateKeys(request))
60-
if (outcome.success) {
61-
setLastResult(outcome.data)
62-
return createHookSuccessResult()
63-
}
64-
return createHookFailureResult(outcome.error)
65-
}, [mutate, options])
64+
const rotate = useCallback(
65+
async (overrides?: RotateCallOptions): Promise<HookMutationResult> => {
66+
const request: RotateKeysRequest = {
67+
...options,
68+
reEncryptEagerly:
69+
overrides?.reEncryptEagerly ?? options?.reEncryptEagerly ?? false,
70+
}
71+
const outcome = await mutate(() => rotateKeys(request))
72+
if (outcome.success) {
73+
setLastResult(outcome.data)
74+
return createHookSuccessResult()
75+
}
76+
return createHookFailureResult(outcome.error)
77+
},
78+
[mutate, options]
79+
)
6680

6781
const readVersion = useCallback(async () => {
6882
try {

src/hooks/useSecureStorage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ export function useSecureStorage(
9999

100100
const [localItems, setLocalItems] = useState<SensitiveInfoItem[] | null>(null)
101101

102-
// Reset any local override whenever a fresh fetch lands.
102+
// Drop the local override whenever a fresh fetch result arrives, so the next
103+
// `refetch()` (or option change) is always reflected in the rendered list.
103104
useEffect(() => {
104105
setLocalItems(null)
105106
}, [])

0 commit comments

Comments
 (0)