Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,36 @@ jobs:
id: sim
run: |
set -euo pipefail
sdk_version=$(xcrun --sdk iphonesimulator --show-sdk-version)
udid=$(xcrun simctl list devices available --json \
| python3 -c "
import json,sys
data = json.load(sys.stdin)['devices']
sdk = tuple(int(part) for part in sys.argv[1].split('.'))
best = None
for runtime, devices in data.items():
if 'iOS' not in runtime:
marker = '.iOS-'
if marker not in runtime:
continue
try:
version = tuple(int(part) for part in runtime.split(marker, 1)[1].split('-'))
except ValueError:
continue
# macos runners can carry runtimes newer than the selected
# Xcode. They can compile a destination but hang when tests try
# to boot it, so never choose newer than the active SDK.
if version > sdk:
continue
for d in devices:
if d.get('isAvailable') and 'iPhone' in d['name']:
# Prefer the highest iOS runtime available.
key = runtime
# Prefer the newest runtime this Xcode can actually run.
key = version
if best is None or key > best[0]:
best = (key, d['udid'], d['name'], runtime)
if best is None:
sys.exit('no available iPhone simulator on this runner')
sys.exit(f'no iPhone simulator supported by iOS SDK {sys.argv[1]}')
print(f'{best[1]}\t{best[2]}\t{best[3]}')
")
" "$sdk_version")
echo "udid=$(echo "$udid" | cut -f1)" >> "$GITHUB_OUTPUT"
echo "Using: $(echo "$udid" | cut -f2) on $(echo "$udid" | cut -f3)"

Expand Down Expand Up @@ -118,7 +130,8 @@ jobs:
CODE_SIGN_IDENTITY="-" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="" \
PROVISIONING_PROFILE_SPECIFIER=""
PROVISIONING_PROFILE_SPECIFIER="" \
ONLY_ACTIVE_ARCH=YES
binary="$derived/Build/Products/Release-iphonesimulator/backupTW.app/backupTW"
test -x "$binary"
info_plist="$derived/Build/Products/Release-iphonesimulator/backupTW.app/Info.plist"
Expand Down
38 changes: 38 additions & 0 deletions Native/OpenACAge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# OpenAC age-predicate native binding

This directory is the reproducible source overlay for the field-level age proof
used by 有備而來. It is based on Ethereum Privacy and Scaling Explorations'
`ethereum/zkID` commit `b395e09c225ff45b003f0087c28e2e208e22f944` and Mopro 0.3.5.

The overlay deliberately exposes only one application profile:

- verify an ES256 SD-JWT issuer signature and its committed birth-date disclosure;
- bind the credential's `cnf.jwk` key to a fresh verifier nonce;
- prove that the hidden ISO or Taiwan ROC birth date is not later than the
verifier-supplied cutoff;
- link the Prepare and Show proofs and compare all public inputs against values
supplied independently by the verifier.

`predicate.rs` is copied into the upstream mobile crate. `zkid-mobile.patch`
adds its UniFFI exports and pinned dependencies, and runs each native prover on
a dedicated 64 MB stack; the upstream 2K secp256r1 witness calculator otherwise
crosses the default macOS/iOS thread stack guard. `witnesscalc-adapter.patch`
keeps the iOS 16 deployment floor consistent and prevents an Apple Silicon
build from silently compiling the unnecessary Intel simulator slice.

Run `./build-ios.sh /path/to/clean/zkID` after compiling the upstream Circom
`jwt_2k` and `show` circuits. The script refuses any upstream revision other
than the reviewed commit. The resulting XCFramework must be zipped and its
SwiftPM checksum and SHA-256 recorded before publication; runtime circuit/key
files are separately pinned by `AgePredicateCircuitAssets.swift`.

`age_assets.rs` is the release gate for those runtime files. It creates the
deterministic circuit keys and then signs a fixed ES256 SD-JWT, proves a hidden
birth-date predicate, reblinds both linked proofs, and checks the exact verifier
statement. A release whose vector fails is not publishable.

Do not treat the self-issued MyData derivative as a government assertion. The
same proof mechanics hide its birth date, but the verifier result must keep the
source label `selfIssued` visible.

Upstream licenses remain Apache-2.0/MIT as declared by zkID and Mopro.
30 changes: 30 additions & 0 deletions Native/OpenACAge/RELEASE-openac-age-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# OpenAC age assets v1

Reproducible inputs:

- ethereum/zkID: `b395e09c225ff45b003f0087c28e2e208e22f944`
- witnesscalc_adapter: `e5a82bcb7d54a4694fc0662c51b01d99134e686c`
- Mopro: `0.3.5`
- iOS deployment target: `16.0`
- architectures: `arm64-apple-ios`, `arm64-apple-ios-simulator`
- SwiftPM XCFramework checksum: `9eb080736b4aa73211a8ba1bdc057955edda8d430a0ac9e088e5aa31c4ac76f4`

The release gate generated fresh circuit keys, signed a fixed ES256 SD-JWT,
created Prepare and Show proofs for a hidden birth date, reblinded both proofs,
and accepted the linked verifier statement. The final normal-stack run measured
19,203 ms for Prepare and 777 ms for Show on the build Mac. These numbers are a
release check, not the iPhone/iPad timing result.

| Asset | gzip bytes | gzip SHA-256 | installed bytes | installed SHA-256 |
|---|---:|---|---:|---|
| `jwt_2k.r1cs.gz` | 28,202,219 | `efb45ed790e81fb6e1e3947f3749ee6ee1b3f03c069fff0227bd7ccb94d974a6` | 374,342,852 | `d4f8b34dfd454234872a34f47ea486545cb4d989c41ed75f856268718251dc6a` |
| `show.r1cs.gz` | 590,365 | `20d785277560fa96309832926bd7efc927e3976c0385b3e2bcae455ad4ad8c7d` | 4,017,428 | `3809e70502fa90f2038760da5f1399a1e3eb17923e5af872edf3dfa0b7d37a9a` |
| `prepare_proving.key.gz` | 23,609,142 | `3b45f8b1c24e5e82fc2462ed819a73fab0167dcce303e15e272fc6f99e44a277` | 431,866,474 | `853657d2e701215a65c5d97ab3cf5640e9aa8379ac6d106b7c82dc9b9d078e79` |
| `prepare_verifying.key.gz` | 23,609,093 | `d84ef20b28f0dd26b836022fc023424592d476a80b54d9ab80d51e43f698ee6a` | 431,866,442 | `9b45cc7462a236b1056d21c19e1e4dfc2cf52fd20538d43fbe072d9ed106e9d6` |
| `show_proving.key.gz` | 575,666 | `fa34e2cefe8da70476843f0a7037e249c7b1cf13c5c26a3f09a268393de61223` | 4,862,778 | `809f24ca6ee003b684e2282b77f5a47279528edee7654a3801770a2ffca67831` |
| `show_verifying.key.gz` | 575,630 | `b6daa9cefd23d27ce80bd182ced987caa1a4eeb91083fc6ceafbeb1210dfbad0` | 4,862,746 | `f0c447a9757d182e8aa23083bc3dba5a9a22f3e0fcbb344724568cc3c83352d8` |

The app independently checks both the compressed transport and installed bytes.
The holder downloads both R1CS files and both key pairs; the verifier downloads
only the two public verifying keys. Downloads are not included in proof or
verification timing.
103 changes: 103 additions & 0 deletions Native/OpenACAge/age_assets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Generates the immutable OpenAC age-profile keys and exercises the complete
//! linked proof before anything is published for the iOS app.

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use openac_age_mobile_app::{
create_age_prepare_input, create_age_show_input, generate_shared_blinds, prove_jwt,
prove_show, reblind_jwt, reblind_show, setup_jwt_keys, setup_show_keys,
verify_age_presentation,
};
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
use rand_core::OsRng;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::{env, fs, os::unix::fs::symlink, path::PathBuf, time::Instant};

fn b64(bytes: impl AsRef<[u8]>) -> String {
URL_SAFE_NO_PAD.encode(bytes)
}

fn coordinates(key: &SigningKey) -> (String, String) {
let point = key.verifying_key().to_encoded_point(false);
(b64(point.x().expect("P-256 x")), b64(point.y().expect("P-256 y")))
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = env::args_os().skip(1);
let zkid = PathBuf::from(args.next().ok_or("usage: age_assets ZKID OUTPUT")?);
let output = PathBuf::from(args.next().ok_or("usage: age_assets ZKID OUTPUT")?);
if args.next().is_some() {
return Err("usage: age_assets ZKID OUTPUT".into());
}
let documents = output.join("circom");
let build = documents.join("build");
fs::create_dir_all(documents.join("keys"))?;
fs::create_dir_all(&build)?;
for (source, destination) in [
(
zkid.join("wallet-unit-poc/circom/build/jwt_2k/jwt_2k_js/jwt_2k.r1cs"),
build.join("jwt/jwt_js/jwt.r1cs"),
),
(
zkid.join("wallet-unit-poc/circom/build/show/show_js/show.r1cs"),
build.join("show/show_js/show.r1cs"),
),
] {
fs::create_dir_all(destination.parent().ok_or("invalid circuit destination")?)?;
if destination.symlink_metadata().is_ok() {
fs::remove_file(&destination)?;
}
symlink(source, destination)?;
}
let docs = documents.to_string_lossy().into_owned();

let started = Instant::now();
println!("{}", setup_jwt_keys(docs.clone())?);
println!("{}", setup_show_keys(docs.clone())?);
println!("key setup: {} ms", started.elapsed().as_millis());

let issuer = SigningKey::random(&mut OsRng);
let holder = SigningKey::random(&mut OsRng);
let (issuer_x, issuer_y) = coordinates(&issuer);
let (holder_x, holder_y) = coordinates(&holder);
let disclosure = b64(br#"["fixed-test-salt","birthdate","1990-01-01"]"#);
let digest = b64(Sha256::digest(disclosure.as_bytes()));
let header = b64(serde_json::to_vec(&json!({"alg":"ES256","typ":"vc+sd-jwt"}))?);
let payload = b64(serde_json::to_vec(&json!({
"iss": "did:key:openac-age-test-issuer",
"nbf": 1,
"exp": 4_102_444_800u64,
"cnf": {"jwk": {"kty":"EC", "crv":"P-256", "x":holder_x, "y":holder_y}},
"vc": {"credentialSubject": {"_sd_alg":"sha-256", "_sd":[digest]}}
}))?);
let signing_input = format!("{header}.{payload}");
let signature: Signature = issuer.sign(signing_input.as_bytes());
let sd_jwt = format!("{signing_input}.{}~{disclosure}~", b64(signature.to_bytes()));

let prepared = create_age_prepare_input(
docs.clone(), sd_jwt, issuer_x.clone(), issuer_y.clone(),
)?;
let jwt_timing = prove_jwt(docs.clone())?;
let nonce = "fixed-openac-age-request-nonce-0123456789".to_owned();
let holder_signature: Signature = holder.sign(nonce.as_bytes());
create_age_show_input(
docs.clone(), nonce.clone(), b64(holder_signature.to_bytes()),
prepared.claim_name.clone(), prepared.claim_format, 2008_0901,
)?;
let show_timing = prove_show(docs.clone())?;
generate_shared_blinds(docs.clone())?;
reblind_jwt(docs.clone())?;
reblind_show(docs.clone())?;
let accepted = verify_age_presentation(
docs.clone(), nonce, prepared.claim_name, prepared.claim_format, 2008_0901,
issuer_x, issuer_y,
)?;
if !accepted {
return Err("linked age proof rejected its own fixed vector".into());
}
println!(
"linked proof accepted; prepare={} ms show={} ms",
jwt_timing.total_ms, show_timing.total_ms
);
Ok(())
}
101 changes: 101 additions & 0 deletions Native/OpenACAge/build-ios.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail

if [ "$#" -ne 1 ]; then
echo "usage: $0 /path/to/clean/zkID" >&2
exit 64
fi

overlay_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
zkid_dir="$1"
expected_commit="b395e09c225ff45b003f0087c28e2e208e22f944"

actual_commit="$(git -C "$zkid_dir" rev-parse HEAD)"
if [ "$actual_commit" != "$expected_commit" ]; then
echo "refusing unreviewed zkID revision: $actual_commit" >&2
exit 65
fi
if ! git -C "$zkid_dir" diff --quiet || ! git -C "$zkid_dir" diff --cached --quiet; then
echo "zkID checkout must be clean before applying the overlay" >&2
exit 65
fi

git -C "$zkid_dir" apply "$overlay_dir/zkid-mobile.patch"
install -m 0644 "$overlay_dir/predicate.rs" \
"$zkid_dir/wallet-unit-poc/mobile/src/predicate.rs"
install -m 0644 "$overlay_dir/age_assets.rs" \
"$zkid_dir/wallet-unit-poc/mobile/src/bin/age_assets.rs"
mkdir -p "$zkid_dir/wallet-unit-poc/mobile/.cargo"
install -m 0644 "$overlay_dir/cargo-config.toml" \
"$zkid_dir/wallet-unit-poc/mobile/.cargo/config.toml"

mobile_dir="$zkid_dir/wallet-unit-poc/mobile"
cargo fetch --manifest-path "$mobile_dir/Cargo.toml"
adapter_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" \
-path '*/witnesscalc_adapter-*/e5a82bc' -type d -print -quit)"
if [ -z "$adapter_dir" ]; then
echo "reviewed witnesscalc_adapter checkout e5a82bc was not fetched" >&2
exit 66
fi
if git -C "$adapter_dir" apply --check "$overlay_dir/witnesscalc-adapter.patch"; then
git -C "$adapter_dir" apply "$overlay_dir/witnesscalc-adapter.patch"
elif ! git -C "$adapter_dir" apply --reverse --check "$overlay_dir/witnesscalc-adapter.patch"; then
echo "witnesscalc_adapter overlay no longer applies cleanly" >&2
exit 66
fi

for artifact in \
"$zkid_dir/wallet-unit-poc/circom/build/cpp/jwt_2k.cpp" \
"$zkid_dir/wallet-unit-poc/circom/build/cpp/show.cpp" \
"$zkid_dir/wallet-unit-poc/circom/build/jwt_2k/jwt_2k_js/jwt_2k.r1cs" \
"$zkid_dir/wallet-unit-poc/circom/build/show/show_js/show.r1cs"
do
if [ ! -f "$artifact" ]; then
echo "missing compiled circuit: $artifact" >&2
exit 66
fi
done

cd "$mobile_dir"
cargo build --release --bin ios

# The generated 2K witness calculator is large. Building both slices in one
# Mopro invocation keeps every target directory alive at once and can consume
# more than 8 GB, even though the finished static libraries are only a fraction
# of that. Preserve each finished slice, discard only its rebuildable Cargo
# output, then combine the two libraries ourselves.
artifact_root="$(mktemp -d "${TMPDIR:-/tmp}/openac-age-ios.XXXXXX")"
cleanup_artifacts()
{
if [ -d "$artifact_root" ]; then
find "$artifact_root" -depth -delete
fi
}
trap cleanup_artifacts EXIT

build_slice()
{
local target="$1"
local label="$2"
PATH="${HOME}/Library/Python/3.9/bin:${PATH}" \
CONFIGURATION=release \
IOS_ARCHS="$target" \
IPHONEOS_DEPLOYMENT_TARGET=16.0 \
./target/release/ios
mv MoproiOSBindings "$artifact_root/$label"
find "$mobile_dir/build" -depth -delete
}

build_slice aarch64-apple-ios device
build_slice aarch64-apple-ios-sim simulator

mkdir -p MoproiOSBindings
install -m 0644 "$artifact_root/device/mopro.swift" MoproiOSBindings/mopro.swift
xcodebuild -create-xcframework \
-library "$artifact_root/device/MoproBindings.xcframework/ios-arm64/libopenac_age_mobile_app.a" \
-headers "$artifact_root/device/MoproBindings.xcframework/ios-arm64/Headers" \
-library "$artifact_root/simulator/MoproBindings.xcframework/ios-arm64-simulator/libopenac_age_mobile_app.a" \
-headers "$artifact_root/simulator/MoproBindings.xcframework/ios-arm64-simulator/Headers" \
-output MoproiOSBindings/MoproBindings.xcframework

echo "built $mobile_dir/MoproiOSBindings"
6 changes: 6 additions & 0 deletions Native/OpenACAge/cargo-config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[target.aarch64-apple-ios]
rustflags = ["-C", "link-arg=-miphoneos-version-min=16.0"]

[target.aarch64-apple-ios-sim]
rustflags = ["-C", "link-arg=-mios-simulator-version-min=16.0"]

Loading
Loading