From 83ccfbcf0125a9ffbef25376c95aacd11f063d56 Mon Sep 17 00:00:00 2001 From: D4ryl00 Date: Mon, 29 Sep 2025 15:50:57 +0200 Subject: [PATCH 1/5] feat: store keybase in keychain Signed-off-by: D4ryl00 --- expo/ios/GnonativeModule.swift | 1 + expo/ios/KeychainManager.swift | 327 ++++++++++++++++++++++++++ framework/service/bridge.go | 17 +- framework/service/db.go | 409 +++++++++++++++++++++++++++++++++ service/config.go | 20 +- service/service.go | 12 +- 6 files changed, 778 insertions(+), 8 deletions(-) create mode 100644 expo/ios/KeychainManager.swift create mode 100644 framework/service/db.go diff --git a/expo/ios/GnonativeModule.swift b/expo/ios/GnonativeModule.swift index 3d44db64..3bc3d350 100644 --- a/expo/ios/GnonativeModule.swift +++ b/expo/ios/GnonativeModule.swift @@ -60,6 +60,7 @@ public class GnonativeModule: Module { } config.rootDir = self.appRootDir! config.tmpDir = self.tmpDir! + config.nativeDB = KeychainManager.shared // On simulator we can't create an UDS, see comment below #if targetEnvironment(simulator) diff --git a/expo/ios/KeychainManager.swift b/expo/ios/KeychainManager.swift new file mode 100644 index 00000000..f0ef8e0f --- /dev/null +++ b/expo/ios/KeychainManager.swift @@ -0,0 +1,327 @@ +// +// KeystoreDriver.swift +// Pods +// +// Created by Rémi BARBERO on 25/09/2025. +// + +import Foundation +import Security +import GnoCore + +public class KeychainManager: NSObject, GnoGnonativeNativeDBProtocol { + public static var shared: KeychainManager = KeychainManager() + + // MARK: - Private Properties + private let service: String + private let accessGroup: String? + + // MARK: - Initialization + init(service: String = Bundle.main.bundleIdentifier ?? "GnoNativeService", accessGroup: String? = nil) { + self.service = service + self.accessGroup = accessGroup + } + + // MARK: - Public Interface Implementation + + public func get(_ key: Data?) -> Data? { + guard let key = key else { return nil } + + let account = keyToAccount(key) + + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + return nil + } + + return result as? Data + } + + public func delete(_ key: Data?) { + DispatchQueue.global(qos: .utility).async { + self.deleteSync(key) + } + } + + public func deleteSync(_ key: Data?) { + guard let key = key else { return } + + let account = keyToAccount(key) + + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + SecItemDelete(query as CFDictionary) + } + + public func has(_ key: Data?) -> Bool { + guard let key = key else { return false } + + let account = keyToAccount(key) + + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } + + public func set(_ key: Data?, p1 value: Data?) { + DispatchQueue.global(qos: .utility).async { + self.setSync(key, p1: value) + } + } + + public func setSync(_ key: Data?, p1 value: Data?) { + guard let key = key, let value = value else { return } + + let account = keyToAccount(key) + + // First, try to update existing item + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + let attributes: [String: Any] = [ + kSecValueData as String: value + ] + + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + if updateStatus == errSecItemNotFound { + // Item doesn't exist, create new one + var newItem = query + newItem[kSecValueData as String] = value + newItem[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + + SecItemAdd(newItem as CFDictionary, nil) + } + } + + public func scanChunk(_ start: Data?, end: Data?, seekKey: Data?, limit: Int, reverse: Bool) throws -> Data { + // 1) fetch all items for this service + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + var pairs: [(key: Data, val: Data)] = [] + if status == errSecSuccess, let items = result as? [[String: Any]] { + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + let keyBytes = accountToKey(account), + let val = item[kSecValueData as String] as? Data else { continue } + if inRange(keyBytes, start: start, end: end) { + pairs.append((keyBytes, val)) + } + } + } + + // 2) sort + if reverse { + pairs.sort { a, b in + // descending: a > b + return lt(b.key, a.key) + } + } else { + pairs.sort { a, b in + // ascending: a < b + return lt(a.key, b.key) + } + } + + // 3) apply seekKey (exclusive) + if let sk = seekKey, !sk.isEmpty { + if reverse { + // keep items with key < seekKey + let idx = pairs.firstIndex(where: { lt($0.key, sk) }) ?? pairs.count + // pairs are descending, so drop while key >= seekKey + pairs = Array(pairs[idx...]) + } else { + // keep items with key > seekKey + let idx = pairs.lastIndex(where: { lte($0.key, sk) }) ?? -1 + let startIdx = idx + 1 + pairs = (startIdx < pairs.count) ? Array(pairs[startIdx...]) : [] + } + } + + // 4) limit + let lim = max(0, Int(limit)) + let chunk = (lim > 0 && lim < pairs.count) ? Array(pairs.prefix(lim)) : pairs + let hasMore = chunk.count < pairs.count + let nextSeek = chunk.last?.key ?? Data() + + // 6) Frame the blob + var blob = Data(capacity: 1 + 4) // will grow as needed + var flags: UInt8 = 0 + if hasMore { flags |= 0x01 } + blob.append(&flags, count: 1) + + var countBE = UInt32(chunk.count).bigEndian + withUnsafeBytes(of: &countBE) { blob.append($0.bindMemory(to: UInt8.self)) } + + for (k, v) in chunk { + var klen = UInt32(k.count).bigEndian + var vlen = UInt32(v.count).bigEndian + withUnsafeBytes(of: &klen) { blob.append($0.bindMemory(to: UInt8.self)) } + blob.append(k) + withUnsafeBytes(of: &vlen) { blob.append($0.bindMemory(to: UInt8.self)) } + blob.append(v) + } + + var nlen = UInt32(nextSeek.count).bigEndian + withUnsafeBytes(of: &nlen) { blob.append($0.bindMemory(to: UInt8.self)) } + blob.append(nextSeek) + + return blob + } + + // --- byte-wise comparisons on decoded keys --- + @inline(__always) + private func lt(_ a: Data, _ b: Data) -> Bool { + a.lexicographicallyPrecedes(b) + } + @inline(__always) + private func gte(_ a: Data, _ b: Data) -> Bool { !lt(a, b) } + @inline(__always) + private func lte(_ a: Data, _ b: Data) -> Bool { !lt(b, a) } + @inline(__always) + private func inRange(_ k: Data, start: Data?, end: Data?) -> Bool { + if let s = start, lt(k, s) { return false } // k >= s + if let e = end, !lt(k, e) { return false } // k < e + return true + } + + // Framing: [1 byte flags(hasMore)] [u32 count] (klen,k,vlen,v)* [u32 nextSeekLen][nextSeek] + @inline(__always) + private func be32(_ v: UInt32) -> [UInt8] { + withUnsafeBytes(of: v.bigEndian, Array.init) + } + + // Helpers + + func getAllKeys() -> [Data] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let items = result as? [[String: Any]] else { + // No items found or error occurred + return [] + } + + var keys: [Data] = [] + + for item in items { + if let account = item[kSecAttrAccount as String] as? String, + let keyData = accountToKey(account) { + keys.append(keyData) + } + } + + return keys + } + + // MARK: - Utility Methods + + /// Store a string value in keychain + public func setString(_ key: String, value: String) { + guard let keyData = key.data(using: .utf8), + let valueData = value.data(using: .utf8) else { return } + setSync(keyData, p1: valueData) + } + + /// Retrieve a string value from keychain + public func getString(_ key: String) -> String? { + guard let keyData = key.data(using: .utf8), + let valueData = get(keyData) else { return nil } + return String(data: valueData, encoding: .utf8) + } + + /// Delete all items for this service + public func deleteAll() { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + SecItemDelete(query as CFDictionary) + } + + private func keyToAccount(_ key: Data) -> String { + return String(data: key, encoding: .utf8) ?? key.base64EncodedString() + } + + private func accountToKey(_ account: String) -> Data? { + // Try UTF-8 first + if let utf8Data = account.data(using: .utf8) { + // Check if this was originally a base64 string by trying to decode it + if let base64Data = Data(base64Encoded: account), base64Data != utf8Data { + // This account was base64 encoded, return the decoded data + return base64Data + } else { + // This was a UTF-8 string, return the UTF-8 data + return utf8Data + } + } + + // Fallback: try base64 decoding + return Data(base64Encoded: account) + } +} diff --git a/framework/service/bridge.go b/framework/service/bridge.go index e1d7aa3c..d7c0e8da 100644 --- a/framework/service/bridge.go +++ b/framework/service/bridge.go @@ -19,6 +19,7 @@ import ( ) type BridgeConfig struct { + NativeDB NativeDB RootDir string TmpDir string UseTcpListener bool @@ -63,10 +64,18 @@ func NewBridge(config *BridgeConfig) (*Bridge, error) { // start gRPC service { - svcOpts = append(svcOpts, - service.WithRootDir(config.RootDir), - service.WithTmpDir(config.TmpDir), - ) + if config.NativeDB != nil { + // use provided NativeDB + svcOpts = append(svcOpts, + service.WithNativeDB(&db{NativeDB: config.NativeDB}), + ) + } else { + // store keybase in the root dir + svcOpts = append(svcOpts, + service.WithRootDir(config.RootDir), + service.WithTmpDir(config.TmpDir), + ) + } if config.UseTcpListener { svcOpts = append(svcOpts, service.WithUseTcpListener()) diff --git a/framework/service/db.go b/framework/service/db.go new file mode 100644 index 00000000..1fe2bdf7 --- /dev/null +++ b/framework/service/db.go @@ -0,0 +1,409 @@ +package gnonative + +import ( + "encoding/binary" + "fmt" + "sync" + "sync/atomic" + + mdb "github.com/gnolang/gno/tm2/pkg/db" +) + +// NativeDB is implemented in the native (Kotlin/Swift) layer. +type NativeDB interface { + Get([]byte) []byte + Has(key []byte) bool + Set([]byte, []byte) + SetSync([]byte, []byte) + Delete([]byte) + DeleteSync([]byte) + // GetAllKeys() [][]byte // needed for iteration support + ScanChunk(start, end, seekKey []byte, limit int, reverse bool) ([]byte, error) +} + +type db struct { + NativeDB + + closed atomic.Bool + mu sync.RWMutex // used for optional safety around Stats/Print +} + +func (d *db) Close() error { + d.closed.Store(true) + return nil +} + +func (d *db) ensureOpen() { + if d.closed.Load() { + panic("db: use after Close") + } +} + +func (db *db) Iterator(start, end []byte) mdb.Iterator { + db.ensureOpen() + it := &iterator{ + db: db, + start: append([]byte(nil), start...), + end: append([]byte(nil), end...), + reverse: false, + chunkLimit: 256, + } + it.fill() + return it +} + +func (db *db) ReverseIterator(start, end []byte) mdb.Iterator { + db.ensureOpen() + it := &iterator{ + db: db, + start: append([]byte(nil), start...), + end: append([]byte(nil), end...), + reverse: true, + chunkLimit: 256, + } + it.fill() + return it +} + +// Iterator creates a forward iterator over a domain of keys +// func (d *db) Iterator(start, end []byte) mdb.Iterator { +// d.ensureOpen() +// return d.createIterator(start, end, false) +// } + +// Iterator creates a forward iterator over a domain of keys +// func (d *db) ReverseIterator(start, end []byte) mdb.Iterator { +// d.ensureOpen() +// return d.createIterator(start, end, true) +// } + +func (d *db) Print() { + d.mu.RLock() + defer d.mu.RUnlock() + // With only point ops exposed, we can't enumerate keys here. + // Keep as a stub or log something useful for debugging: + fmt.Println("db.Print(): NativeDB has no range API; nothing to print") +} + +func (d *db) Stats() map[string]string { + d.mu.RLock() + defer d.mu.RUnlock() + // Return whatever you track on the Go side. Native side has no stats here. + return map[string]string{ + "closed": fmt.Sprintf("%v", d.closed.Load()), + } +} + +func (d *db) NewBatch() mdb.Batch { + d.ensureOpen() + return &batch{db: d} +} + +// --- Batch implementation (pure Go) --- + +type batch struct { + db *db + ops []op +} + +type op struct { + del bool + key []byte + value []byte // nil for delete +} + +func (b *batch) Set(key, value []byte) { + b.db.ensureOpen() + // Defensive copies: gomobile & callers must not mutate later. + k := append([]byte(nil), key...) + v := append([]byte(nil), value...) + b.ops = append(b.ops, op{del: false, key: k, value: v}) +} + +func (b *batch) Delete(key []byte) { + b.db.ensureOpen() + k := append([]byte(nil), key...) + b.ops = append(b.ops, op{del: true, key: k}) +} + +// Write applies ops using async variants. +func (b *batch) Write() { + b.db.ensureOpen() + for _, o := range b.ops { + if o.del { + b.db.Delete(o.key) + } else { + b.db.Set(o.key, o.value) + } + } + // Clear buffer to allow reuse if desired. + b.ops = b.ops[:0] +} + +// WriteSync applies ops using sync variants. +func (b *batch) WriteSync() { + b.db.ensureOpen() + for _, o := range b.ops { + if o.del { + b.db.DeleteSync(o.key) + } else { + b.db.SetSync(o.key, o.value) + } + } + b.ops = b.ops[:0] +} + +func (b *batch) Close() { + // Drop buffered ops; allow GC. + b.ops = nil + b.db = nil +} + +type kv struct { + k []byte + v []byte +} + +type iterator struct { + db *db + start []byte + end []byte + reverse bool + seekKey []byte + chunk []kv + i int + hasMore bool + closed bool + chunkLimit int +} + +func (it *iterator) Domain() (start, end []byte) { return it.start, it.end } + +func (it *iterator) Valid() bool { + if it.closed { + return false + } + for it.i >= len(it.chunk) && it.hasMore { + it.fill() + } + return it.i < len(it.chunk) +} + +func (it *iterator) Next() { + if !it.Valid() { + return + } + cur := it.chunk[it.i] + it.i++ + // keep seekKey strictly at the last returned key + it.seekKey = append(it.seekKey[:0], cur.k...) +} + +func (it *iterator) Key() []byte { + if !it.Valid() { + return nil + } + return it.chunk[it.i].k +} + +func (it *iterator) Value() []byte { + if !it.Valid() { + return nil + } + return it.chunk[it.i].v +} +func (it *iterator) Close() { it.closed = true; it.chunk = nil } + +func (it *iterator) fill() { + if it.closed { + return + } + blob, err := it.db.ScanChunk(it.start, it.end, it.seekKey, it.chunkLimit, it.reverse) + if err != nil { + it.chunk, it.i, it.hasMore = nil, 0, false + return + } + pairs, nextSeek, hasMore, err := decodeChunkBlob(blob) + if err != nil { + it.chunk, it.i, it.hasMore = nil, 0, false + return + } + it.chunk = pairs + it.i = 0 + it.hasMore = hasMore + if len(nextSeek) > 0 { + it.seekKey = append(it.seekKey[:0], nextSeek...) + } +} + +// --- framing decode --- + +func decodeChunkBlob(b []byte) (pairs []kv, nextSeek []byte, hasMore bool, err error) { + if len(b) < 1+4 { + return nil, nil, false, errShort + } + flags := b[0] + hasMore = (flags & 0x01) != 0 + b = b[1:] + + count := int(binary.BigEndian.Uint32(b[:4])) + b = b[4:] + + pairs = make([]kv, 0, count) + for i := 0; i < count; i++ { + if len(b) < 4 { + return nil, nil, false, errShort + } + klen := int(binary.BigEndian.Uint32(b[:4])) + b = b[4:] + if klen < 0 || len(b) < klen { + return nil, nil, false, errShort + } + k := append([]byte(nil), b[:klen]...) + b = b[klen:] + + if len(b) < 4 { + return nil, nil, false, errShort + } + vlen := int(binary.BigEndian.Uint32(b[:4])) + b = b[4:] + if vlen < 0 || len(b) < vlen { + return nil, nil, false, errShort + } + v := append([]byte(nil), b[:vlen]...) + b = b[vlen:] + + pairs = append(pairs, kv{k: k, v: v}) + } + + if len(b) < 4 { + return nil, nil, false, errShort + } + nlen := int(binary.BigEndian.Uint32(b[:4])) + b = b[4:] + if nlen < 0 || len(b) < nlen { + return nil, nil, false, errShort + } + if nlen > 0 { + nextSeek = append([]byte(nil), b[:nlen]...) + } + return pairs, nextSeek, hasMore, nil +} + +var errShort = fmt.Errorf("chunk blob: short buffer") + +// Old implementation of Iterator (in-memory, inefficient). + +// type dbIterator struct { +// db *db +// keys [][]byte +// index int +// start []byte +// end []byte +// reverse bool +// valid bool +// } +// +// func (d *db) createIterator(start, end []byte, reverse bool) mdb.Iterator { +// // Get all keys from the native database +// allKeys := d.NativeDB.GetAllKeys() +// +// // Filter keys within the domain +// var filteredKeys [][]byte +// for _, key := range allKeys { +// if d.keyInDomain(key, start, end) { +// filteredKeys = append(filteredKeys, key) +// } +// } +// +// // Sort keys +// sort.Slice(filteredKeys, func(i, j int) bool { +// if reverse { +// return bytes.Compare(filteredKeys[i], filteredKeys[j]) > 0 +// } +// return bytes.Compare(filteredKeys[i], filteredKeys[j]) < 0 +// }) +// +// iterator := &dbIterator{ +// db: d, +// keys: filteredKeys, +// index: 0, +// start: copyBytes(start), +// end: copyBytes(end), +// reverse: reverse, +// valid: len(filteredKeys) > 0, +// } +// +// return iterator +// } +// +// func (d *db) keyInDomain(key, start, end []byte) bool { +// // Handle nil start (empty byteslice) +// if start != nil && bytes.Compare(key, start) < 0 { +// return false +// } +// +// // Handle nil end (no upper limit) +// if end != nil && bytes.Compare(key, end) >= 0 { +// return false +// } +// +// return true +// } +// +// // Domain returns the start and end limits of the iterator +// func (it *dbIterator) Domain() (start []byte, end []byte) { +// return it.start, it.end +// } +// +// // Valid returns whether the current position is valid +// func (it *dbIterator) Valid() bool { +// return it.valid && it.index >= 0 && it.index < len(it.keys) +// } +// +// // Next moves the iterator to the next sequential key +// func (it *dbIterator) Next() { +// if !it.Valid() { +// panic("iterator is not valid") +// } +// +// it.index++ +// if it.index >= len(it.keys) { +// it.valid = false +// } +// } +// +// // Key returns the key of the cursor +// func (it *dbIterator) Key() []byte { +// if !it.Valid() { +// panic("iterator is not valid") +// } +// +// return it.keys[it.index] +// } +// +// // Value returns the value of the cursor +// func (it *dbIterator) Value() []byte { +// if !it.Valid() { +// panic("iterator is not valid") +// } +// +// key := it.keys[it.index] +// return it.db.Get(key) +// } +// +// // Close releases the Iterator +// func (it *dbIterator) Close() { +// it.valid = false +// it.keys = nil +// } + +// Helper function to copy byte slices +func copyBytes(src []byte) []byte { + if src == nil { + return nil + } + dst := make([]byte, len(src)) + copy(dst, src) + return dst +} diff --git a/service/config.go b/service/config.go index 031f401f..3edd5b46 100644 --- a/service/config.go +++ b/service/config.go @@ -4,20 +4,24 @@ import ( "os" "path/filepath" + "github.com/gnolang/gno/tm2/pkg/db" api_gen "github.com/gnolang/gnonative/v4/api/gen/go" "github.com/pkg/errors" "go.uber.org/zap" ) -const DEFAULT_TCP_ADDR = ":26658" -const DEFAULT_SOCKET_SUBDIR = "s" -const DEFAULT_SOCKET_FILE = "gno" +const ( + DEFAULT_TCP_ADDR = ":26658" + DEFAULT_SOCKET_SUBDIR = "s" + DEFAULT_SOCKET_FILE = "gno" +) // Config describes a set of settings for a GnoNativeService type Config struct { Logger *zap.Logger Remote string ChainID string + NativeDB db.DB RootDir string TmpDir string TcpAddr string @@ -160,6 +164,16 @@ var WithFallbacChainID GnoNativeOption = func(cfg *Config) error { return nil } +// --- NativeDB options --- + +// WithNativeDB sets the given native DB. +var WithNativeDB = func(db db.DB) GnoNativeOption { + return func(cfg *Config) error { + cfg.NativeDB = db + return nil + } +} + // --- RootDir options --- // WithRootDir sets the given root directory path. diff --git a/service/service.go b/service/service.go index 7a48c02a..f60761fa 100644 --- a/service/service.go +++ b/service/service.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "io" "net" "net/http" @@ -107,7 +108,16 @@ func initService(cfg *Config) (*gnoNativeService, error) { return nil, err } - svc.keybase, _ = keys.NewKeyBaseFromDir(cfg.RootDir) + if cfg.NativeDB != nil { + fmt.Println("remi: using provided native db") + svc.keybase = keys.NewDBKeybase(cfg.NativeDB) + } else { + var err error + svc.keybase, err = keys.NewKeyBaseFromDir(cfg.RootDir) + if err != nil { + return nil, err + } + } var err error svc.rpcClient, err = rpcclient.NewHTTPClient(cfg.Remote) From cae011aaff325570e2236137cd541fcb96ae586d Mon Sep 17 00:00:00 2001 From: D4ryl00 Date: Fri, 3 Oct 2025 17:05:10 +0200 Subject: [PATCH 2/5] feat: iOS nativeDB Signed-off-by: D4ryl00 --- expo/ios/KeychainManager.swift | 229 ++++++++++++--------------------- framework/service/db.go | 159 ++++------------------- 2 files changed, 110 insertions(+), 278 deletions(-) diff --git a/expo/ios/KeychainManager.swift b/expo/ios/KeychainManager.swift index f0ef8e0f..0da5d64c 100644 --- a/expo/ios/KeychainManager.swift +++ b/expo/ios/KeychainManager.swift @@ -135,30 +135,34 @@ public class KeychainManager: NSObject, GnoGnonativeNativeDBProtocol { } public func scanChunk(_ start: Data?, end: Data?, seekKey: Data?, limit: Int, reverse: Bool) throws -> Data { - // 1) fetch all items for this service - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecMatchLimit as String: kSecMatchLimitAll, - kSecReturnAttributes as String: true, - kSecReturnData as String: true, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - var pairs: [(key: Data, val: Data)] = [] - if status == errSecSuccess, let items = result as? [[String: Any]] { - for item in items { - guard let account = item[kSecAttrAccount as String] as? String, - let keyBytes = accountToKey(account), - let val = item[kSecValueData as String] as? Data else { continue } - if inRange(keyBytes, start: start, end: end) { - pairs.append((keyBytes, val)) - } + // 1) fetch all items for this service + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + var pairs: [(key: Data, val: Data)] = [] + if status == errSecSuccess, let items = result as? [[String: Any]] { + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + let keyBytes = accountToKey(account), + let val = item[kSecValueData as String] as? Data else { continue } + if inRange(keyBytes, start: start, end: end) { + pairs.append((keyBytes, val)) } } - - // 2) sort + } + + // 2) sort if reverse { pairs.sort { a, b in // descending: a > b @@ -170,140 +174,55 @@ public class KeychainManager: NSObject, GnoGnonativeNativeDBProtocol { return lt(a.key, b.key) } } - - // 3) apply seekKey (exclusive) + + // 3) apply seekKey (exclusive) if let sk = seekKey, !sk.isEmpty { - if reverse { - // keep items with key < seekKey - let idx = pairs.firstIndex(where: { lt($0.key, sk) }) ?? pairs.count - // pairs are descending, so drop while key >= seekKey - pairs = Array(pairs[idx...]) - } else { - // keep items with key > seekKey - let idx = pairs.lastIndex(where: { lte($0.key, sk) }) ?? -1 - let startIdx = idx + 1 - pairs = (startIdx < pairs.count) ? Array(pairs[startIdx...]) : [] - } - } - - // 4) limit + if reverse { + // keep items with key < seekKey + let idx = pairs.firstIndex(where: { lt($0.key, sk) }) ?? pairs.count + // pairs are descending, so drop while key >= seekKey + pairs = Array(pairs[idx...]) + } else { + // keep items with key > seekKey + let idx = pairs.lastIndex(where: { lte($0.key, sk) }) ?? -1 + let startIdx = idx + 1 + pairs = (startIdx < pairs.count) ? Array(pairs[startIdx...]) : [] + } + } + + // 4) limit let lim = max(0, Int(limit)) - let chunk = (lim > 0 && lim < pairs.count) ? Array(pairs.prefix(lim)) : pairs - let hasMore = chunk.count < pairs.count - let nextSeek = chunk.last?.key ?? Data() - + let chunk = (lim > 0 && lim < pairs.count) ? Array(pairs.prefix(lim)) : pairs + let hasMore = chunk.count < pairs.count + let nextSeek = chunk.last?.key ?? Data() + // 6) Frame the blob - var blob = Data(capacity: 1 + 4) // will grow as needed - var flags: UInt8 = 0 - if hasMore { flags |= 0x01 } - blob.append(&flags, count: 1) - - var countBE = UInt32(chunk.count).bigEndian - withUnsafeBytes(of: &countBE) { blob.append($0.bindMemory(to: UInt8.self)) } - - for (k, v) in chunk { - var klen = UInt32(k.count).bigEndian - var vlen = UInt32(v.count).bigEndian - withUnsafeBytes(of: &klen) { blob.append($0.bindMemory(to: UInt8.self)) } - blob.append(k) - withUnsafeBytes(of: &vlen) { blob.append($0.bindMemory(to: UInt8.self)) } - blob.append(v) - } - - var nlen = UInt32(nextSeek.count).bigEndian - withUnsafeBytes(of: &nlen) { blob.append($0.bindMemory(to: UInt8.self)) } - blob.append(nextSeek) - - return blob - } - - // --- byte-wise comparisons on decoded keys --- - @inline(__always) - private func lt(_ a: Data, _ b: Data) -> Bool { - a.lexicographicallyPrecedes(b) - } - @inline(__always) - private func gte(_ a: Data, _ b: Data) -> Bool { !lt(a, b) } - @inline(__always) - private func lte(_ a: Data, _ b: Data) -> Bool { !lt(b, a) } - @inline(__always) - private func inRange(_ k: Data, start: Data?, end: Data?) -> Bool { - if let s = start, lt(k, s) { return false } // k >= s - if let e = end, !lt(k, e) { return false } // k < e - return true - } - - // Framing: [1 byte flags(hasMore)] [u32 count] (klen,k,vlen,v)* [u32 nextSeekLen][nextSeek] - @inline(__always) - private func be32(_ v: UInt32) -> [UInt8] { - withUnsafeBytes(of: v.bigEndian, Array.init) - } - - // Helpers - - func getAllKeys() -> [Data] { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecReturnAttributes as String: true, - kSecMatchLimit as String: kSecMatchLimitAll - ] + var blob = Data(capacity: 1 + 4) // will grow as needed + var flags: UInt8 = 0 + if hasMore { flags |= 0x01 } + blob.append(&flags, count: 1) - if let accessGroup = accessGroup { - query[kSecAttrAccessGroup as String] = accessGroup - } - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, - let items = result as? [[String: Any]] else { - // No items found or error occurred - return [] - } - - var keys: [Data] = [] - - for item in items { - if let account = item[kSecAttrAccount as String] as? String, - let keyData = accountToKey(account) { - keys.append(keyData) - } - } - - return keys - } - - // MARK: - Utility Methods - - /// Store a string value in keychain - public func setString(_ key: String, value: String) { - guard let keyData = key.data(using: .utf8), - let valueData = value.data(using: .utf8) else { return } - setSync(keyData, p1: valueData) - } - - /// Retrieve a string value from keychain - public func getString(_ key: String) -> String? { - guard let keyData = key.data(using: .utf8), - let valueData = get(keyData) else { return nil } - return String(data: valueData, encoding: .utf8) - } - - /// Delete all items for this service - public func deleteAll() { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service - ] + var countBE = UInt32(chunk.count).bigEndian + withUnsafeBytes(of: &countBE) { blob.append($0.bindMemory(to: UInt8.self)) } - if let accessGroup = accessGroup { - query[kSecAttrAccessGroup as String] = accessGroup + for (k, v) in chunk { + var klen = UInt32(k.count).bigEndian + var vlen = UInt32(v.count).bigEndian + withUnsafeBytes(of: &klen) { blob.append($0.bindMemory(to: UInt8.self)) } + blob.append(k) + withUnsafeBytes(of: &vlen) { blob.append($0.bindMemory(to: UInt8.self)) } + blob.append(v) } - SecItemDelete(query as CFDictionary) + var nlen = UInt32(nextSeek.count).bigEndian + withUnsafeBytes(of: &nlen) { blob.append($0.bindMemory(to: UInt8.self)) } + blob.append(nextSeek) + + return blob } + // MARK: - Utility Methods + private func keyToAccount(_ key: Data) -> String { return String(data: key, encoding: .utf8) ?? key.base64EncodedString() } @@ -324,4 +243,20 @@ public class KeychainManager: NSObject, GnoGnonativeNativeDBProtocol { // Fallback: try base64 decoding return Data(base64Encoded: account) } + + // --- byte-wise comparisons on decoded keys --- + @inline(__always) + private func lt(_ a: Data, _ b: Data) -> Bool { + a.lexicographicallyPrecedes(b) + } + @inline(__always) + private func gte(_ a: Data, _ b: Data) -> Bool { !lt(a, b) } + @inline(__always) + private func lte(_ a: Data, _ b: Data) -> Bool { !lt(b, a) } + @inline(__always) + private func inRange(_ k: Data, start: Data?, end: Data?) -> Bool { + if let s = start, lt(k, s) { return false } // k >= s + if let e = end, !lt(k, e) { return false } // k < e + return true + } } diff --git a/framework/service/db.go b/framework/service/db.go index 1fe2bdf7..d2126189 100644 --- a/framework/service/db.go +++ b/framework/service/db.go @@ -9,6 +9,8 @@ import ( mdb "github.com/gnolang/gno/tm2/pkg/db" ) +var errShort = fmt.Errorf("chunk blob: short buffer") + // NativeDB is implemented in the native (Kotlin/Swift) layer. type NativeDB interface { Get([]byte) []byte @@ -17,7 +19,6 @@ type NativeDB interface { SetSync([]byte, []byte) Delete([]byte) DeleteSync([]byte) - // GetAllKeys() [][]byte // needed for iteration support ScanChunk(start, end, seekKey []byte, limit int, reverse bool) ([]byte, error) } @@ -65,18 +66,6 @@ func (db *db) ReverseIterator(start, end []byte) mdb.Iterator { return it } -// Iterator creates a forward iterator over a domain of keys -// func (d *db) Iterator(start, end []byte) mdb.Iterator { -// d.ensureOpen() -// return d.createIterator(start, end, false) -// } - -// Iterator creates a forward iterator over a domain of keys -// func (d *db) ReverseIterator(start, end []byte) mdb.Iterator { -// d.ensureOpen() -// return d.createIterator(start, end, true) -// } - func (d *db) Print() { d.mu.RLock() defer d.mu.RUnlock() @@ -238,10 +227,36 @@ func (it *iterator) fill() { // --- framing decode --- +// decodeChunkBlob parses a single binary blob produced by NativeDB.ScanChunk. +// +// Blob layout (all integers are big-endian): +// +// +---------+-------------------+---------------------------------------+--------------------------+------------------------+ +// | Offset | Field | Description | Type/Size | Notes | +// +---------+-------------------+---------------------------------------+--------------------------+------------------------+ +// | 0 | flags | bit0 = hasMore (1 => more pages) | uint8 (1 byte) | other bits reserved | +// | 1 | count | number of K/V pairs that follow | uint32 (4 bytes, BE) | N | +// | 5 | pairs[0..N-1] | repeated K/V frames: | | | +// | | - klen | key length | uint32 (4 bytes, BE) | | +// | | - key | key bytes | klen bytes | | +// | | - vlen | value length | uint32 (4 bytes, BE) | | +// | | - value | value bytes | vlen bytes | | +// | ... | nextSeekLen | length of the nextSeek key | uint32 (4 bytes, BE) | 0 if empty | +// | ... | nextSeek | nextSeek key bytes | nextSeekLen bytes | | +// +---------+-------------------+---------------------------------------+--------------------------+------------------------+ +// +// Semantics: +// - The iterator uses 'hasMore' to know if additional pages exist. +// - 'nextSeek' is typically the last key of this page; pass it back as 'seekKey' (exclusive) +// on the next ScanChunk call to continue from the next item. +// - Keys/values are raw bytes; ordering and range checks are done on the raw key bytes. +// +// On decode errors (short buffer / lengths out of range), the function returns errShort. func decodeChunkBlob(b []byte) (pairs []kv, nextSeek []byte, hasMore bool, err error) { if len(b) < 1+4 { return nil, nil, false, errShort } + flags := b[0] hasMore = (flags & 0x01) != 0 b = b[1:] @@ -289,121 +304,3 @@ func decodeChunkBlob(b []byte) (pairs []kv, nextSeek []byte, hasMore bool, err e } return pairs, nextSeek, hasMore, nil } - -var errShort = fmt.Errorf("chunk blob: short buffer") - -// Old implementation of Iterator (in-memory, inefficient). - -// type dbIterator struct { -// db *db -// keys [][]byte -// index int -// start []byte -// end []byte -// reverse bool -// valid bool -// } -// -// func (d *db) createIterator(start, end []byte, reverse bool) mdb.Iterator { -// // Get all keys from the native database -// allKeys := d.NativeDB.GetAllKeys() -// -// // Filter keys within the domain -// var filteredKeys [][]byte -// for _, key := range allKeys { -// if d.keyInDomain(key, start, end) { -// filteredKeys = append(filteredKeys, key) -// } -// } -// -// // Sort keys -// sort.Slice(filteredKeys, func(i, j int) bool { -// if reverse { -// return bytes.Compare(filteredKeys[i], filteredKeys[j]) > 0 -// } -// return bytes.Compare(filteredKeys[i], filteredKeys[j]) < 0 -// }) -// -// iterator := &dbIterator{ -// db: d, -// keys: filteredKeys, -// index: 0, -// start: copyBytes(start), -// end: copyBytes(end), -// reverse: reverse, -// valid: len(filteredKeys) > 0, -// } -// -// return iterator -// } -// -// func (d *db) keyInDomain(key, start, end []byte) bool { -// // Handle nil start (empty byteslice) -// if start != nil && bytes.Compare(key, start) < 0 { -// return false -// } -// -// // Handle nil end (no upper limit) -// if end != nil && bytes.Compare(key, end) >= 0 { -// return false -// } -// -// return true -// } -// -// // Domain returns the start and end limits of the iterator -// func (it *dbIterator) Domain() (start []byte, end []byte) { -// return it.start, it.end -// } -// -// // Valid returns whether the current position is valid -// func (it *dbIterator) Valid() bool { -// return it.valid && it.index >= 0 && it.index < len(it.keys) -// } -// -// // Next moves the iterator to the next sequential key -// func (it *dbIterator) Next() { -// if !it.Valid() { -// panic("iterator is not valid") -// } -// -// it.index++ -// if it.index >= len(it.keys) { -// it.valid = false -// } -// } -// -// // Key returns the key of the cursor -// func (it *dbIterator) Key() []byte { -// if !it.Valid() { -// panic("iterator is not valid") -// } -// -// return it.keys[it.index] -// } -// -// // Value returns the value of the cursor -// func (it *dbIterator) Value() []byte { -// if !it.Valid() { -// panic("iterator is not valid") -// } -// -// key := it.keys[it.index] -// return it.db.Get(key) -// } -// -// // Close releases the Iterator -// func (it *dbIterator) Close() { -// it.valid = false -// it.keys = nil -// } - -// Helper function to copy byte slices -func copyBytes(src []byte) []byte { - if src == nil { - return nil - } - dst := make([]byte, len(src)) - copy(dst, src) - return dst -} From 9b717ff0ef03decc5021d109370123cae93aecbc Mon Sep 17 00:00:00 2001 From: D4ryl00 Date: Tue, 7 Oct 2025 11:12:21 +0200 Subject: [PATCH 3/5] feat: add Android NativeDBManager module Signed-off-by: D4ryl00 --- .../land/gno/gnonative/GnonativeModule.kt | 4 +- .../land/gno/gnonative/NativeDBManager.kt | 338 ++++++++++++++++++ framework/service/bridge.go | 11 +- service/service.go | 2 - 4 files changed, 346 insertions(+), 9 deletions(-) create mode 100644 expo/android/src/main/java/land/gno/gnonative/NativeDBManager.kt diff --git a/expo/android/src/main/java/land/gno/gnonative/GnonativeModule.kt b/expo/android/src/main/java/land/gno/gnonative/GnonativeModule.kt index 196324cf..8d7bd31e 100644 --- a/expo/android/src/main/java/land/gno/gnonative/GnonativeModule.kt +++ b/expo/android/src/main/java/land/gno/gnonative/GnonativeModule.kt @@ -17,6 +17,7 @@ class GnonativeModule : Module() { private var rootDir: File? = null private var socketPort = 0 private var bridgeGnoNative: Bridge? = null + private var nativeDBManager: NativeDBManager? = null // Each module class must implement the definition function. The definition consists of components // that describes the module's functionality and behavior. @@ -33,6 +34,7 @@ class GnonativeModule : Module() { OnCreate { context = appContext.reactContext rootDir = context!!.filesDir + nativeDBManager = NativeDBManager(context!!) } OnDestroy { @@ -53,8 +55,8 @@ class GnonativeModule : Module() { try { val config: BridgeConfig = Gnonative.newBridgeConfig() ?: throw Exception("") config.rootDir = rootDir!!.absolutePath + config.nativeDB = nativeDBManager bridgeGnoNative = Gnonative.newBridge(config) - promise.resolve(true) } catch (err: CodedException) { promise.reject(err) diff --git a/expo/android/src/main/java/land/gno/gnonative/NativeDBManager.kt b/expo/android/src/main/java/land/gno/gnonative/NativeDBManager.kt new file mode 100644 index 00000000..f3ac2570 --- /dev/null +++ b/expo/android/src/main/java/land/gno/gnonative/NativeDBManager.kt @@ -0,0 +1,338 @@ +package land.gno.gnonative + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import gnolang.gno.gnonative.NativeDB +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.security.KeyStore +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.math.min +import androidx.core.content.edit + +/** + * Implements gomobile-generated NativeDB with exact lowercase signatures: + * delete, deleteSync, get, has, scanChunk, set, setSync + * + * - AES/GCM per-app key from Android Keystore (prefers StrongBox, falls back gracefully) + * - Values encrypted; ciphertext stored Base64 in SharedPreferences + * - Keys stored as lowercase hex; separate sorted index for range scans + * - scanChunk byte-for-byte matches framework/service/db.go format (BE u32 lengths) + */ +class NativeDBManager( + context: Context, + private val prefsName: String = "gnonative_secure_db", + private val keyAlias: String = "gnonative_aes_key" +) : NativeDB { + + // -------- storage / index -------- + private val prefs: SharedPreferences = + context.getSharedPreferences(prefsName, Context.MODE_PRIVATE) + private val entryPrefix = "kv:" // entryPrefix + hexKey -> Base64(encrypted blob) + private val idxKey = "__idx__" // CSV of hex keys in ascending order + + // -------- crypto -------- + private val ks: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + private val rnd = SecureRandom() + + // simple monitor for thread-safety (no coroutines needed) + private val lock = Any() + + init { + ensureAesKey() + if (!prefs.contains(idxKey)) prefs.edit { putString(idxKey, "") } + } + + // ========== NativeDB (exact signatures) ========== + + override fun delete(p0: ByteArray?) { + val key = requireKey(p0) + val hex = hex(key) + synchronized(lock) { + val idx = loadIndexAsc().toMutableList() + val pos = lowerBound(idx, hex) + if (pos < idx.size && idx[pos] == hex) { + idx.removeAt(pos) + saveIndexAsc(idx) + } + prefs.edit { remove("$entryPrefix$hex") } + } + } + + override fun deleteSync(p0: ByteArray?) { + // same semantics as delete in this backing store + delete(p0) + } + + override fun get(p0: ByteArray?): ByteArray { + val key = requireKey(p0) + val hex = hex(key) + val b64 = synchronized(lock) { prefs.getString("$entryPrefix$hex", null) } + ?: return ByteArray(0) // gomobile generated non-null return -> use empty on miss + val blob = Base64.decode(b64, Base64.NO_WRAP) + return decrypt(blob) ?: ByteArray(0) + } + + override fun has(p0: ByteArray?): Boolean { + val key = requireKey(p0) + val hex = hex(key) + return synchronized(lock) { prefs.contains("$entryPrefix$hex") } + } + + override fun scanChunk( + p0: ByteArray?, // start + p1: ByteArray?, // end + p2: ByteArray?, // seekKey + p3: Long, // limit + p4: Boolean // reverse + ): ByteArray { + val limit = if (p3 < 0) 0 else min(p3, Int.MAX_VALUE.toLong()).toInt() + return synchronized(lock) { + val asc = loadIndexAsc() // ascending hex keys + val startHex = p0?.let { hex(it) } + val endHex = p1?.let { hex(it) } + val seekHex = p2?.let { hex(it) } + + val loBase = startHex?.let { lowerBound(asc, it) } ?: 0 + val hiBase = endHex?.let { lowerBound(asc, it) } ?: asc.size + var slice: List = if (hiBase <= loBase) emptyList() else asc.subList(loBase, hiBase) + + // seek positioning & direction + slice = if (!p4) { + val from = seekHex?.let { upperBound(slice, it) } ?: 0 + if (from >= slice.size) emptyList() else slice.subList(from, slice.size) + } else { + val positioned = if (seekHex != null) { + val idx = upperBound(slice, seekHex) - 1 + if (idx < 0) emptyList() else slice.subList(0, idx + 1) + } else slice + positioned.asReversed() + } + + val page = if (limit == 0) emptyList() else slice.take(limit) + val hasMore = page.isNotEmpty() && page.size < slice.size + val nextSeekHex = if (hasMore) page.last() else null + + // materialize kv pairs in traversal order + val pairs = ArrayList>(page.size) + for (h in page) { + val b64 = prefs.getString("$entryPrefix$h", null) ?: continue + val v = decrypt(Base64.decode(b64, Base64.NO_WRAP)) ?: continue + pairs += (unhex(h) to v) + } + + // flags(1) | count(u32 BE) | [kLen k vLen v]* | nextSeekLen(u32 BE) | nextSeek + encodeChunkBlobBE(pairs, nextSeekHex?.let { unhex(it) }, hasMore) + } + } + + override fun set(p0: ByteArray?, p1: ByteArray?) { + val key = requireKey(p0) + val value = requireValue(p1) + val hex = hex(key) + val enc = encrypt(value) + val b64 = Base64.encodeToString(enc, Base64.NO_WRAP) + synchronized(lock) { + val idx = loadIndexAsc().toMutableList() + val pos = lowerBound(idx, hex) + if (pos == idx.size || idx[pos] != hex) { + idx.add(pos, hex) + saveIndexAsc(idx) + } + prefs.edit { putString("$entryPrefix$hex", b64) } + } + } + + override fun setSync(p0: ByteArray?, p1: ByteArray?) { + // same semantics as set in this backing store + set(p0, p1) + } + + // ========== helpers ========== + + private fun requireKey(b: ByteArray?): ByteArray { + require(!(b == null || b.isEmpty())) { "key must not be null/empty" } + return b + } + private fun requireValue(b: ByteArray?): ByteArray { + require(b != null) { "value must not be null" } + return b + } + + // ----- index (csv of hex keys, ascending) ----- + private fun loadIndexAsc(): List { + val csv = prefs.getString(idxKey, "") ?: "" + return if (csv.isEmpty()) emptyList() else csv.split(',').filter { it.isNotEmpty() } + } + private fun saveIndexAsc(keys: List) { + prefs.edit { putString(idxKey, if (keys.isEmpty()) "" else keys.joinToString(",")) } + } + + private fun lowerBound(list: List, key: String): Int { + var lo = 0; var hi = list.size + while (lo < hi) { + val mid = (lo + hi) ushr 1 + if (list[mid] < key) lo = mid + 1 else hi = mid + } + return lo + } + private fun upperBound(list: List, key: String): Int { + var lo = 0; var hi = list.size + while (lo < hi) { + val mid = (lo + hi) ushr 1 + if (list[mid] <= key) lo = mid + 1 else hi = mid + } + return lo + } + + // ----- crypto (AES/GCM, StrongBox preferred) ----- + private fun ensureAesKey() { + if (getAesKey() != null) return + val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val base = KeyGenParameterSpec.Builder( + keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + base.setIsStrongBoxBacked(true) + } + kg.init(base.build()) + kg.generateKey() + return + } catch (_: Throwable) { + // fall back + } + + kg.init( + KeyGenParameterSpec.Builder( + keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .build() + ) + kg.generateKey() + } + + private fun getAesKey(): SecretKey? { + val e = ks.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry + return e?.secretKey + } + + private fun encrypt(plain: ByteArray): ByteArray { + val key = getAesKey() ?: error("AES key missing") + + // IMPORTANT: Do NOT pass a GCMParameterSpec here. Let Keystore generate a fresh IV. + val c = Cipher.getInstance(AES_GCM) + c.init(Cipher.ENCRYPT_MODE, key) + + val iv = c.iv // Keystore-provided random IV (usually 12 bytes) + val ct = c.doFinal(plain) + + // payload: [v=1][ivLen][iv][ct] + val out = ByteArray(1 + 1 + iv.size + ct.size) + var i = 0 + out[i++] = 1 + out[i++] = iv.size.toByte() + System.arraycopy(iv, 0, out, i, iv.size); i += iv.size + System.arraycopy(ct, 0, out, i, ct.size) + return out + } + + private fun decrypt(blob: ByteArray?): ByteArray? { + if (blob == null || blob.size < 1 + 1 + 12) return null + var i = 0 + val ver = blob[i++] + require(ver.toInt() == 1) { "bad payload version=$ver" } + val ivLen = blob[i++].toInt() and 0xFF + require(ivLen in 12..32) { "bad iv length" } + require(blob.size >= 1 + 1 + ivLen + 1) { "short blob" } + val iv = ByteArray(ivLen) + System.arraycopy(blob, i, iv, 0, ivLen); i += ivLen + val ct = ByteArray(blob.size - i) + System.arraycopy(blob, i, ct, 0, ct.size) + + val key = getAesKey() ?: error("AES key missing") + val c = Cipher.getInstance(AES_GCM) + c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + return c.doFinal(ct) + } + + // ----- chunk framing (must match Go) ----- + private fun encodeChunkBlobBE( + entries: List>, + nextSeek: ByteArray?, + hasMore: Boolean + ): ByteArray { + val bos = ByteArrayOutputStream() + + // flags (bit0 = hasMore) + bos.write(if (hasMore) 0x01 else 0x00) + + // count (u32 BE) + bos.write(u32be(entries.size)) + + // entries + for ((k, v) in entries) { + bos.write(u32be(k.size)); bos.write(k) + bos.write(u32be(v.size)); bos.write(v) + } + + // nextSeek + val ns = nextSeek ?: ByteArray(0) + bos.write(u32be(ns.size)) + if (ns.isNotEmpty()) bos.write(ns) + + return bos.toByteArray() + } + + // ----- utils ----- + private fun u32be(n: Int): ByteArray { + val bb = ByteBuffer.allocate(4) + bb.putInt(n) // big-endian by default + return bb.array() + } + + private fun hex(b: ByteArray): String { + val out = CharArray(b.size * 2) + val h = "0123456789abcdef".toCharArray() + var i = 0 + for (v in b) { + val x = v.toInt() and 0xFF + out[i++] = h[x ushr 4]; out[i++] = h[x and 0x0F] + } + return String(out) + } + + private fun unhex(s: String): ByteArray { + require(s.length % 2 == 0) { "odd hex length" } + val out = ByteArray(s.length / 2) + var i = 0; var j = 0 + while (i < s.length) { + val hi = Character.digit(s[i++], 16) + val lo = Character.digit(s[i++], 16) + out[j++] = ((hi shl 4) or lo).toByte() + } + return out + } + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val AES_GCM = "AES/GCM/NoPadding" + } +} diff --git a/framework/service/bridge.go b/framework/service/bridge.go index d7c0e8da..47c15d51 100644 --- a/framework/service/bridge.go +++ b/framework/service/bridge.go @@ -69,14 +69,13 @@ func NewBridge(config *BridgeConfig) (*Bridge, error) { svcOpts = append(svcOpts, service.WithNativeDB(&db{NativeDB: config.NativeDB}), ) - } else { - // store keybase in the root dir - svcOpts = append(svcOpts, - service.WithRootDir(config.RootDir), - service.WithTmpDir(config.TmpDir), - ) } + svcOpts = append(svcOpts, + service.WithRootDir(config.RootDir), + service.WithTmpDir(config.TmpDir), + ) + if config.UseTcpListener { svcOpts = append(svcOpts, service.WithUseTcpListener()) svcOpts = append(svcOpts, service.WithTcpAddr("localhost:0")) diff --git a/service/service.go b/service/service.go index f60761fa..282d23f1 100644 --- a/service/service.go +++ b/service/service.go @@ -2,7 +2,6 @@ package service import ( "context" - "fmt" "io" "net" "net/http" @@ -109,7 +108,6 @@ func initService(cfg *Config) (*gnoNativeService, error) { } if cfg.NativeDB != nil { - fmt.Println("remi: using provided native db") svc.keybase = keys.NewDBKeybase(cfg.NativeDB) } else { var err error From 7e946ddeb6650a9f55855563c9d8ec46a34bec26 Mon Sep 17 00:00:00 2001 From: D4ryl00 Date: Wed, 8 Oct 2025 14:39:48 +0200 Subject: [PATCH 4/5] chore: cleanup Android NativeDBManager Signed-off-by: D4ryl00 --- .../land/gno/gnonative/NativeDBManager.kt | 30 +++++-------------- service/service.go | 2 ++ 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/expo/android/src/main/java/land/gno/gnonative/NativeDBManager.kt b/expo/android/src/main/java/land/gno/gnonative/NativeDBManager.kt index f3ac2570..f1ded00d 100644 --- a/expo/android/src/main/java/land/gno/gnonative/NativeDBManager.kt +++ b/expo/android/src/main/java/land/gno/gnonative/NativeDBManager.kt @@ -10,7 +10,6 @@ import gnolang.gno.gnonative.NativeDB import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.security.KeyStore -import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -18,15 +17,6 @@ import javax.crypto.spec.GCMParameterSpec import kotlin.math.min import androidx.core.content.edit -/** - * Implements gomobile-generated NativeDB with exact lowercase signatures: - * delete, deleteSync, get, has, scanChunk, set, setSync - * - * - AES/GCM per-app key from Android Keystore (prefers StrongBox, falls back gracefully) - * - Values encrypted; ciphertext stored Base64 in SharedPreferences - * - Keys stored as lowercase hex; separate sorted index for range scans - * - scanChunk byte-for-byte matches framework/service/db.go format (BE u32 lengths) - */ class NativeDBManager( context: Context, private val prefsName: String = "gnonative_secure_db", @@ -41,9 +31,7 @@ class NativeDBManager( // -------- crypto -------- private val ks: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - private val rnd = SecureRandom() - // simple monitor for thread-safety (no coroutines needed) private val lock = Any() init { @@ -51,7 +39,7 @@ class NativeDBManager( if (!prefs.contains(idxKey)) prefs.edit { putString(idxKey, "") } } - // ========== NativeDB (exact signatures) ========== + // ========== NativeDB implementation ========== override fun delete(p0: ByteArray?) { val key = requireKey(p0) @@ -68,7 +56,6 @@ class NativeDBManager( } override fun deleteSync(p0: ByteArray?) { - // same semantics as delete in this backing store delete(p0) } @@ -152,7 +139,6 @@ class NativeDBManager( } override fun setSync(p0: ByteArray?, p1: ByteArray?) { - // same semantics as set in this backing store set(p0, p1) } @@ -193,9 +179,10 @@ class NativeDBManager( return lo } - // ----- crypto (AES/GCM, StrongBox preferred) ----- + // crypto AES/GCM, StrongBox preferred private fun ensureAesKey() { if (getAesKey() != null) return + val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) val base = KeyGenParameterSpec.Builder( keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT @@ -213,7 +200,7 @@ class NativeDBManager( kg.generateKey() return } catch (_: Throwable) { - // fall back + // fall back below without StrongBox } kg.init( @@ -237,14 +224,13 @@ class NativeDBManager( private fun encrypt(plain: ByteArray): ByteArray { val key = getAesKey() ?: error("AES key missing") - // IMPORTANT: Do NOT pass a GCMParameterSpec here. Let Keystore generate a fresh IV. val c = Cipher.getInstance(AES_GCM) c.init(Cipher.ENCRYPT_MODE, key) - val iv = c.iv // Keystore-provided random IV (usually 12 bytes) + val iv = c.iv val ct = c.doFinal(plain) - // payload: [v=1][ivLen][iv][ct] + // payload: [version=1][ivLen][iv][ct] val out = ByteArray(1 + 1 + iv.size + ct.size) var i = 0 out[i++] = 1 @@ -255,7 +241,7 @@ class NativeDBManager( } private fun decrypt(blob: ByteArray?): ByteArray? { - if (blob == null || blob.size < 1 + 1 + 12) return null + if (blob == null || blob.size < 1 + 1 + 12) return null // iv is usually 12 bytes var i = 0 val ver = blob[i++] require(ver.toInt() == 1) { "bad payload version=$ver" } @@ -273,7 +259,7 @@ class NativeDBManager( return c.doFinal(ct) } - // ----- chunk framing (must match Go) ----- + // chunk framing (match Go format) private fun encodeChunkBlobBE( entries: List>, nextSeek: ByteArray?, diff --git a/service/service.go b/service/service.go index 282d23f1..34d94966 100644 --- a/service/service.go +++ b/service/service.go @@ -108,9 +108,11 @@ func initService(cfg *Config) (*gnoNativeService, error) { } if cfg.NativeDB != nil { + cfg.Logger.Debug("using nativeDB for keybase") svc.keybase = keys.NewDBKeybase(cfg.NativeDB) } else { var err error + cfg.Logger.Debug("using filesystem for keybase", zap.String("rootdir", cfg.RootDir)) svc.keybase, err = keys.NewKeyBaseFromDir(cfg.RootDir) if err != nil { return nil, err From be85b272c0717dc1ff89ba3a87c98de98407f407 Mon Sep 17 00:00:00 2001 From: D4ryl00 Date: Wed, 8 Oct 2025 14:55:23 +0200 Subject: [PATCH 5/5] chore: rename iOS native code Signed-off-by: D4ryl00 --- expo/ios/GnonativeModule.swift | 2 +- expo/ios/{KeychainManager.swift => NativeDBManager.swift} | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename expo/ios/{KeychainManager.swift => NativeDBManager.swift} (98%) diff --git a/expo/ios/GnonativeModule.swift b/expo/ios/GnonativeModule.swift index 3bc3d350..0d18388a 100644 --- a/expo/ios/GnonativeModule.swift +++ b/expo/ios/GnonativeModule.swift @@ -60,7 +60,7 @@ public class GnonativeModule: Module { } config.rootDir = self.appRootDir! config.tmpDir = self.tmpDir! - config.nativeDB = KeychainManager.shared + config.nativeDB = NativeDBManager.shared // On simulator we can't create an UDS, see comment below #if targetEnvironment(simulator) diff --git a/expo/ios/KeychainManager.swift b/expo/ios/NativeDBManager.swift similarity index 98% rename from expo/ios/KeychainManager.swift rename to expo/ios/NativeDBManager.swift index 0da5d64c..d79fd116 100644 --- a/expo/ios/KeychainManager.swift +++ b/expo/ios/NativeDBManager.swift @@ -1,5 +1,5 @@ // -// KeystoreDriver.swift +// NativeDBManager.swift // Pods // // Created by Rémi BARBERO on 25/09/2025. @@ -9,8 +9,8 @@ import Foundation import Security import GnoCore -public class KeychainManager: NSObject, GnoGnonativeNativeDBProtocol { - public static var shared: KeychainManager = KeychainManager() +public class NativeDBManager: NSObject, GnoGnonativeNativeDBProtocol { + public static var shared: NativeDBManager = NativeDBManager() // MARK: - Private Properties private let service: String