From 8cb4556524debd70de8e87d4349273f0895f1bb7 Mon Sep 17 00:00:00 2001 From: vthwang Date: Tue, 15 Sep 2026 11:25:18 -0700 Subject: [PATCH 1/6] feat(siop): add strict ID token verifier Establish the verification boundary needed before wiring SIOP login into HTTP handlers. - validate compact JWS, claims, and Ed25519 authentication keys - resolve did:key and verified did:webvh histories with SSRF controls - cover malformed tokens, key rotation, tampering, and Rust fixtures Signed-off-by: vthwang --- go.mod | 1 + go.sum | 2 + internal/siop/claims.go | 94 +++ internal/siop/didkey.go | 111 ++++ internal/siop/errors.go | 60 ++ internal/siop/resolver.go | 144 +++++ internal/siop/token.go | 165 ++++++ internal/siop/verifier.go | 96 +++ internal/siop/verifier_test.go | 343 +++++++++++ internal/siop/webvh/log.go | 548 ++++++++++++++++++ internal/siop/webvh/log_test.go | 108 ++++ internal/siop/webvh/resolver.go | 220 +++++++ internal/siop/webvh/resolver_test.go | 99 ++++ internal/siop/webvh/testdata/ATTRIBUTION.md | 7 + .../testdata/rust-chain-prerotation.jsonl | 2 + .../webvh/testdata/rust-chain-simple.jsonl | 3 + 16 files changed, 2003 insertions(+) create mode 100644 internal/siop/claims.go create mode 100644 internal/siop/didkey.go create mode 100644 internal/siop/errors.go create mode 100644 internal/siop/resolver.go create mode 100644 internal/siop/token.go create mode 100644 internal/siop/verifier.go create mode 100644 internal/siop/verifier_test.go create mode 100644 internal/siop/webvh/log.go create mode 100644 internal/siop/webvh/log_test.go create mode 100644 internal/siop/webvh/resolver.go create mode 100644 internal/siop/webvh/resolver_test.go create mode 100644 internal/siop/webvh/testdata/ATTRIBUTION.md create mode 100644 internal/siop/webvh/testdata/rust-chain-prerotation.jsonl create mode 100644 internal/siop/webvh/testdata/rust-chain-simple.jsonl diff --git a/go.mod b/go.mod index 90c8f63..8599acb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ic3software/vtafarm-api go 1.26.3 require ( + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 github.com/gin-contrib/cors v1.7.7 github.com/gin-gonic/gin v1.12.0 github.com/go-webauthn/webauthn v0.17.4 diff --git a/go.sum b/go.sum index 52e8327..c2c847c 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/internal/siop/claims.go b/internal/siop/claims.go new file mode 100644 index 0000000..10a5a21 --- /dev/null +++ b/internal/siop/claims.go @@ -0,0 +1,94 @@ +package siop + +import ( + "encoding/json" + "fmt" + "time" +) + +type claims struct { + Issuer string + Subject string + Audience string + Nonce string + IssuedAt int64 + ExpiresAt int64 +} + +func parseClaims(data []byte) (claims, error) { + fields, err := decodeUniqueObject(data) + if err != nil { + return claims{}, verificationError(ErrorInvalidClaims, "payload is not a unique-key JSON object", err) + } + for name := range fields { + switch name { + case "iss", "sub", "aud", "nonce", "iat", "exp": + default: + return claims{}, verificationError(ErrorInvalidClaims, "payload contains an unsupported claim", nil) + } + } + + var parsed claims + for _, field := range []struct { + name string + destination *string + }{ + {"iss", &parsed.Issuer}, + {"sub", &parsed.Subject}, + {"aud", &parsed.Audience}, + {"nonce", &parsed.Nonce}, + } { + if err := decodeRequiredString(fields, field.name, field.destination); err != nil { + return claims{}, verificationError(ErrorInvalidClaims, fmt.Sprintf("payload has an invalid %s", field.name), err) + } + } + if err := decodeRequiredInt64(fields, "iat", &parsed.IssuedAt); err != nil { + return claims{}, verificationError(ErrorInvalidClaims, "payload has an invalid iat", err) + } + if err := decodeRequiredInt64(fields, "exp", &parsed.ExpiresAt); err != nil { + return claims{}, verificationError(ErrorInvalidClaims, "payload has an invalid exp", err) + } + return parsed, nil +} + +func decodeRequiredInt64(fields map[string]json.RawMessage, name string, destination *int64) error { + raw, ok := fields[name] + if !ok { + return fmt.Errorf("missing %s", name) + } + if err := json.Unmarshal(raw, destination); err != nil { + return err + } + if *destination < 0 { + return fmt.Errorf("negative %s", name) + } + return nil +} + +func (c claims) validate(expectedDID, audience, nonce string, now time.Time, clockSkew time.Duration) error { + if c.Issuer != expectedDID { + return verificationError(ErrorIssuerMismatch, "issuer does not match the expected DID", nil) + } + if c.Subject != c.Issuer { + return verificationError(ErrorSubjectMismatch, "issuer and subject differ", nil) + } + if c.Audience != audience { + return verificationError(ErrorAudienceMismatch, "audience does not match the relying party", nil) + } + if c.Nonce != nonce { + return verificationError(ErrorNonceMismatch, "nonce does not match the challenge", nil) + } + if c.IssuedAt > c.ExpiresAt { + return verificationError(ErrorInvalidClaims, "iat is after exp", nil) + } + + skewSeconds := int64(clockSkew / time.Second) + nowSeconds := now.Unix() + if c.IssuedAt > nowSeconds+skewSeconds { + return verificationError(ErrorTokenNotYetValid, "iat is in the future", nil) + } + if c.ExpiresAt <= nowSeconds-skewSeconds { + return verificationError(ErrorTokenExpired, "id_token has expired", nil) + } + return nil +} diff --git a/internal/siop/didkey.go b/internal/siop/didkey.go new file mode 100644 index 0000000..3a7d03a --- /dev/null +++ b/internal/siop/didkey.go @@ -0,0 +1,111 @@ +package siop + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "math/big" + "strings" +) + +const base58BTCAlphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// DIDKeyResolver resolves the canonical Ed25519 verification method of a +// did:key without performing network I/O. +type DIDKeyResolver struct{} + +func (DIDKeyResolver) ResolveAuthenticationKey(_ context.Context, did, kid string) (ed25519.PublicKey, error) { + key, multibase, err := ed25519KeyFromDIDKey(did) + if err != nil { + return nil, err + } + if kid != did+"#"+multibase { + return nil, errors.New("kid is not the canonical did:key authentication method") + } + return key, nil +} + +func ed25519KeyFromDIDKey(did string) (ed25519.PublicKey, string, error) { + multibase, ok := strings.CutPrefix(did, "did:key:") + if !ok || multibase == "" { + return nil, "", errors.New("DID is not a did:key") + } + key, err := decodeEd25519Multikey(multibase) + if err != nil { + return nil, "", err + } + return key, multibase, nil +} + +func decodeEd25519Multikey(multibase string) (ed25519.PublicKey, error) { + if len(multibase) < 2 || multibase[0] != 'z' { + return nil, errors.New("key is not base58btc multibase") + } + decoded, err := DecodeBase58BTC(multibase[1:]) + if err != nil { + return nil, err + } + if EncodeBase58BTC(decoded) != multibase[1:] { + return nil, errors.New("key is not canonical base58btc") + } + if len(decoded) != 2+ed25519.PublicKeySize || decoded[0] != 0xed || decoded[1] != 0x01 { + return nil, errors.New("key is not a 32-byte Ed25519 multikey") + } + key := make(ed25519.PublicKey, ed25519.PublicKeySize) + copy(key, decoded[2:]) + return key, nil +} + +// DecodeBase58BTC decodes an unprefixed base58btc value. +func DecodeBase58BTC(value string) ([]byte, error) { + if value == "" { + return nil, errors.New("empty base58btc value") + } + alphabetIndexes := [256]int16{} + for i := range alphabetIndexes { + alphabetIndexes[i] = -1 + } + for i, char := range []byte(base58BTCAlphabet) { + alphabetIndexes[char] = int16(i) + } + + number := new(big.Int) + base := big.NewInt(58) + for i := 0; i < len(value); i++ { + char := value[i] + if alphabetIndexes[char] < 0 { + return nil, fmt.Errorf("invalid base58btc character at offset %d", i) + } + number.Mul(number, base) + number.Add(number, big.NewInt(int64(alphabetIndexes[char]))) + } + decoded := number.Bytes() + for i := 0; i < len(value) && value[i] == base58BTCAlphabet[0]; i++ { + decoded = append([]byte{0}, decoded...) + } + return decoded, nil +} + +// EncodeBase58BTC encodes bytes without adding the multibase z prefix. +func EncodeBase58BTC(value []byte) string { + number := new(big.Int).SetBytes(value) + base := big.NewInt(58) + zero := big.NewInt(0) + remainder := new(big.Int) + encoded := make([]byte, 0, len(value)*2) + for number.Cmp(zero) > 0 { + number.DivMod(number, base, remainder) + encoded = append(encoded, base58BTCAlphabet[remainder.Int64()]) + } + for _, b := range value { + if b != 0 { + break + } + encoded = append(encoded, base58BTCAlphabet[0]) + } + for left, right := 0, len(encoded)-1; left < right; left, right = left+1, right-1 { + encoded[left], encoded[right] = encoded[right], encoded[left] + } + return string(encoded) +} diff --git a/internal/siop/errors.go b/internal/siop/errors.go new file mode 100644 index 0000000..e517a34 --- /dev/null +++ b/internal/siop/errors.go @@ -0,0 +1,60 @@ +package siop + +import ( + "errors" + "fmt" +) + +// ErrorCode is safe for callers to use when mapping verification failures to +// an HTTP response or an audit event. It never contains token material. +type ErrorCode string + +const ( + ErrorMalformedToken ErrorCode = "malformed_token" + ErrorUnsupportedAlgorithm ErrorCode = "unsupported_algorithm" + ErrorInvalidHeader ErrorCode = "invalid_header" + ErrorInvalidClaims ErrorCode = "invalid_claims" + ErrorIssuerMismatch ErrorCode = "issuer_mismatch" + ErrorSubjectMismatch ErrorCode = "subject_mismatch" + ErrorAudienceMismatch ErrorCode = "audience_mismatch" + ErrorNonceMismatch ErrorCode = "nonce_mismatch" + ErrorTokenExpired ErrorCode = "token_expired" + ErrorTokenNotYetValid ErrorCode = "token_not_yet_valid" + ErrorKIDMismatch ErrorCode = "kid_mismatch" + ErrorResolverFailed ErrorCode = "resolver_failed" + ErrorSignatureInvalid ErrorCode = "signature_invalid" +) + +// VerificationError describes a caller-safe verification failure. Detail is +// intentionally limited to protocol field names and never includes the token, +// nonce, signature, or resolved URL. +type VerificationError struct { + Code ErrorCode + Detail string + Cause error +} + +func (e *VerificationError) Error() string { + if e.Detail == "" { + return string(e.Code) + } + return fmt.Sprintf("%s: %s", e.Code, e.Detail) +} + +func (e *VerificationError) Unwrap() error { return e.Cause } + +func verificationError(code ErrorCode, detail string, cause error) error { + return &VerificationError{Code: code, Detail: detail, Cause: cause} +} + +// ErrorCodeOf returns the stable code carried by a verification error. +func ErrorCodeOf(err error) ErrorCode { + if err == nil { + return "" + } + var target *VerificationError + if errors.As(err, &target) { + return target.Code + } + return ErrorResolverFailed +} diff --git a/internal/siop/resolver.go b/internal/siop/resolver.go new file mode 100644 index 0000000..b05a0a7 --- /dev/null +++ b/internal/siop/resolver.go @@ -0,0 +1,144 @@ +package siop + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// AuthenticationKeyResolver returns the exact Ed25519 authentication key +// selected by kid from did's verified DID document. +type AuthenticationKeyResolver interface { + ResolveAuthenticationKey(ctx context.Context, did, kid string) (ed25519.PublicKey, error) +} + +// MethodResolver dispatches supported DID methods without providing a fallback +// for unknown methods. +type MethodResolver struct { + WebVH AuthenticationKeyResolver + Peer AuthenticationKeyResolver +} + +func (r MethodResolver) ResolveAuthenticationKey(ctx context.Context, did, kid string) (ed25519.PublicKey, error) { + switch { + case strings.HasPrefix(did, "did:key:"): + return (DIDKeyResolver{}).ResolveAuthenticationKey(ctx, did, kid) + case strings.HasPrefix(did, "did:webvh:") && r.WebVH != nil: + return r.WebVH.ResolveAuthenticationKey(ctx, did, kid) + case strings.HasPrefix(did, "did:peer:") && r.Peer != nil: + return r.Peer.ResolveAuthenticationKey(ctx, did, kid) + default: + return nil, errors.New("unsupported DID method") + } +} + +type didDocument struct { + ID string `json:"id"` + VerificationMethod []verificationMethod `json:"verificationMethod"` + Authentication []json.RawMessage `json:"authentication"` +} + +type verificationMethod struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyMultibase string `json:"publicKeyMultibase"` + PublicKeyJWK *json.RawMessage `json:"publicKeyJwk"` +} + +// AuthenticationKeyFromDocument selects kid only when the DID document lists +// it in authentication. The document must already have passed method-specific +// history and proof verification. +func AuthenticationKeyFromDocument(document []byte, did, kid string) (ed25519.PublicKey, error) { + var parsed didDocument + if err := json.Unmarshal(document, &parsed); err != nil { + return nil, fmt.Errorf("decode DID document: %w", err) + } + if parsed.ID != did { + return nil, errors.New("resolved DID document id does not match DID") + } + + declared := make(map[string]verificationMethod, len(parsed.VerificationMethod)) + for _, method := range parsed.VerificationMethod { + method.ID = absoluteVerificationMethodID(method.ID, did) + if method.ID == "" { + return nil, errors.New("verification method has no id") + } + if _, exists := declared[method.ID]; exists { + return nil, errors.New("DID document has duplicate verification method ids") + } + declared[method.ID] = method + } + + for _, raw := range parsed.Authentication { + var reference string + if err := json.Unmarshal(raw, &reference); err == nil { + if absoluteVerificationMethodID(reference, did) != kid { + continue + } + method, ok := declared[kid] + if !ok { + return nil, errors.New("authentication references an undeclared verification method") + } + return ed25519KeyFromVerificationMethod(method, did) + } + + var embedded verificationMethod + if err := json.Unmarshal(raw, &embedded); err != nil { + return nil, errors.New("authentication entry is neither a reference nor a verification method") + } + embedded.ID = absoluteVerificationMethodID(embedded.ID, did) + if embedded.ID == kid { + return ed25519KeyFromVerificationMethod(embedded, did) + } + } + return nil, errors.New("kid is not an authentication verification method") +} + +func absoluteVerificationMethodID(id, did string) string { + if strings.HasPrefix(id, "#") { + return did + id + } + return id +} + +func ed25519KeyFromVerificationMethod(method verificationMethod, did string) (ed25519.PublicKey, error) { + if method.Controller != did { + return nil, errors.New("verification method controller does not match DID") + } + hasMultibase := method.PublicKeyMultibase != "" + hasJWK := method.PublicKeyJWK != nil + if hasMultibase == hasJWK { + return nil, errors.New("verification method must contain exactly one supported key representation") + } + if hasMultibase { + if method.Type != "Multikey" && method.Type != "Ed25519VerificationKey2020" { + return nil, errors.New("verification method type is not compatible with an Ed25519 multikey") + } + return decodeEd25519Multikey(method.PublicKeyMultibase) + } + if method.Type != "JsonWebKey2020" { + return nil, errors.New("verification method type is not JsonWebKey2020") + } + + var jwk struct { + KeyType string `json:"kty"` + Curve string `json:"crv"` + X string `json:"x"` + D string `json:"d"` + } + if err := json.Unmarshal(*method.PublicKeyJWK, &jwk); err != nil { + return nil, fmt.Errorf("decode public key JWK: %w", err) + } + if jwk.KeyType != "OKP" || jwk.Curve != "Ed25519" || jwk.X == "" || jwk.D != "" { + return nil, errors.New("JWK is not an Ed25519 public key") + } + decoded, err := decodeCanonicalBase64URL(jwk.X) + if err != nil || len(decoded) != ed25519.PublicKeySize { + return nil, errors.New("JWK x is not a canonical 32-byte Ed25519 key") + } + return ed25519.PublicKey(decoded), nil +} diff --git a/internal/siop/token.go b/internal/siop/token.go new file mode 100644 index 0000000..205c338 --- /dev/null +++ b/internal/siop/token.go @@ -0,0 +1,165 @@ +package siop + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +const maxCompactTokenBytes = 64 * 1024 + +type protectedHeader struct { + Algorithm string + KID string + Type string +} + +type parsedToken struct { + header protectedHeader + claims claims + signingInput string + signature []byte +} + +func parseCompactToken(compact string) (parsedToken, error) { + if compact == "" || len(compact) > maxCompactTokenBytes { + return parsedToken{}, verificationError(ErrorMalformedToken, "id_token has an invalid size", nil) + } + parts := strings.Split(compact, ".") + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return parsedToken{}, verificationError(ErrorMalformedToken, "id_token must have three non-empty compact JWS segments", nil) + } + + headerBytes, err := decodeCanonicalBase64URL(parts[0]) + if err != nil { + return parsedToken{}, verificationError(ErrorInvalidHeader, "protected header is not canonical base64url", err) + } + header, err := parseProtectedHeader(headerBytes) + if err != nil { + return parsedToken{}, err + } + + payloadBytes, err := decodeCanonicalBase64URL(parts[1]) + if err != nil { + return parsedToken{}, verificationError(ErrorInvalidClaims, "payload is not canonical base64url", err) + } + parsedClaims, err := parseClaims(payloadBytes) + if err != nil { + return parsedToken{}, err + } + + signature, err := decodeCanonicalBase64URL(parts[2]) + if err != nil { + return parsedToken{}, verificationError(ErrorMalformedToken, "signature is not canonical base64url", err) + } + + return parsedToken{ + header: header, + claims: parsedClaims, + signingInput: parts[0] + "." + parts[1], + signature: signature, + }, nil +} + +func parseProtectedHeader(data []byte) (protectedHeader, error) { + fields, err := decodeUniqueObject(data) + if err != nil { + return protectedHeader{}, verificationError(ErrorInvalidHeader, "protected header is not a unique-key JSON object", err) + } + for name := range fields { + switch name { + case "alg", "kid", "typ": + default: + return protectedHeader{}, verificationError(ErrorInvalidHeader, "protected header contains an unsupported field", nil) + } + } + + var header protectedHeader + if err := decodeRequiredString(fields, "alg", &header.Algorithm); err != nil { + return protectedHeader{}, verificationError(ErrorInvalidHeader, "protected header has an invalid alg", err) + } + if header.Algorithm != "EdDSA" { + return protectedHeader{}, verificationError(ErrorUnsupportedAlgorithm, "only EdDSA is supported", nil) + } + if err := decodeRequiredString(fields, "kid", &header.KID); err != nil || header.KID == "" { + return protectedHeader{}, verificationError(ErrorInvalidHeader, "protected header has an invalid kid", err) + } + if raw, ok := fields["typ"]; ok { + if err := json.Unmarshal(raw, &header.Type); err != nil || header.Type != "JWT" { + return protectedHeader{}, verificationError(ErrorInvalidHeader, "protected header typ must be JWT when present", err) + } + } + return header, nil +} + +func decodeCanonicalBase64URL(value string) ([]byte, error) { + decoded, err := base64.RawURLEncoding.Strict().DecodeString(value) + if err != nil { + return nil, err + } + if base64.RawURLEncoding.EncodeToString(decoded) != value { + return nil, errors.New("non-canonical base64url encoding") + } + return decoded, nil +} + +func decodeUniqueObject(data []byte) (map[string]json.RawMessage, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + first, err := decoder.Token() + if err != nil { + return nil, err + } + delim, ok := first.(json.Delim) + if !ok || delim != '{' { + return nil, errors.New("value is not an object") + } + + fields := make(map[string]json.RawMessage) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return nil, err + } + name, ok := token.(string) + if !ok { + return nil, errors.New("object field name is not a string") + } + if _, duplicate := fields[name]; duplicate { + return nil, fmt.Errorf("duplicate field %q", name) + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + fields[name] = value + } + if _, err := decoder.Token(); err != nil { + return nil, err + } + if token, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err != nil { + return nil, err + } + return nil, fmt.Errorf("unexpected trailing JSON token %v", token) + } + return fields, nil +} + +func decodeRequiredString(fields map[string]json.RawMessage, name string, destination *string) error { + raw, ok := fields[name] + if !ok { + return fmt.Errorf("missing %s", name) + } + if err := json.Unmarshal(raw, destination); err != nil { + return err + } + if *destination == "" { + return fmt.Errorf("empty %s", name) + } + return nil +} diff --git a/internal/siop/verifier.go b/internal/siop/verifier.go new file mode 100644 index 0000000..f0f4f78 --- /dev/null +++ b/internal/siop/verifier.go @@ -0,0 +1,96 @@ +package siop + +import ( + "context" + "crypto/ed25519" + "crypto/subtle" + "strings" + "time" +) + +const DefaultClockSkew = 60 * time.Second + +// VerifiedIDToken contains only identity data that passed key, signature, and +// claim verification. +type VerifiedIDToken struct { + Subject string + Kid string +} + +// VerifyIDToken verifies a compact VTA SIOPv2 id_token using the default +// 60-second clock skew. Applications with configured policy should call +// VerifyIDTokenAt and pass their clock and skew explicitly. +func VerifyIDToken( + ctx context.Context, + compact string, + expectedDID string, + audience string, + nonce string, + resolver AuthenticationKeyResolver, +) (VerifiedIDToken, error) { + return VerifyIDTokenAt(ctx, compact, expectedDID, audience, nonce, resolver, time.Now(), DefaultClockSkew) +} + +// VerifyIDTokenAt is the deterministic verification entry point. now and +// clockSkew are explicit policy inputs so tests and handlers do not depend on a +// package-global clock. +func VerifyIDTokenAt( + ctx context.Context, + compact string, + expectedDID string, + audience string, + nonce string, + resolver AuthenticationKeyResolver, + now time.Time, + clockSkew time.Duration, +) (VerifiedIDToken, error) { + if resolver == nil { + return VerifiedIDToken{}, verificationError(ErrorResolverFailed, "authentication key resolver is not configured", nil) + } + if expectedDID == "" || audience == "" || nonce == "" || clockSkew < 0 { + return VerifiedIDToken{}, verificationError(ErrorInvalidClaims, "verification policy is incomplete", nil) + } + + token, err := parseCompactToken(compact) + if err != nil { + return VerifiedIDToken{}, err + } + + // This check deliberately precedes resolver work. The value is still + // untrusted; it is used only to stop callers from turning resolution into an + // arbitrary DID-fetch proxy. + if token.claims.Issuer != expectedDID { + return VerifiedIDToken{}, verificationError(ErrorIssuerMismatch, "issuer does not match the expected DID", nil) + } + if !kidBelongsToDID(token.header.KID, token.claims.Issuer) { + return VerifiedIDToken{}, verificationError(ErrorKIDMismatch, "kid does not belong to issuer or has no fragment", nil) + } + + publicKey, err := resolver.ResolveAuthenticationKey(ctx, token.claims.Issuer, token.header.KID) + if err != nil { + return VerifiedIDToken{}, verificationError(ErrorResolverFailed, "authentication key resolution failed", err) + } + if len(publicKey) != ed25519.PublicKeySize { + return VerifiedIDToken{}, verificationError(ErrorResolverFailed, "resolver returned an invalid Ed25519 key", nil) + } + if strings.HasPrefix(token.claims.Issuer, "did:key:") { + pinned, _, err := ed25519KeyFromDIDKey(token.claims.Issuer) + if err != nil || subtle.ConstantTimeCompare(pinned, publicKey) != 1 { + return VerifiedIDToken{}, verificationError(ErrorResolverFailed, "resolved key does not match did:key", err) + } + } + + if len(token.signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, []byte(token.signingInput), token.signature) { + return VerifiedIDToken{}, verificationError(ErrorSignatureInvalid, "Ed25519 signature verification failed", nil) + } + if err := token.claims.validate(expectedDID, audience, nonce, now, clockSkew); err != nil { + return VerifiedIDToken{}, err + } + + return VerifiedIDToken{Subject: token.claims.Subject, Kid: token.header.KID}, nil +} + +func kidBelongsToDID(kid, did string) bool { + fragmentAt := strings.IndexByte(kid, '#') + return fragmentAt == len(did) && fragmentAt >= 0 && kid[:fragmentAt] == did && fragmentAt+1 < len(kid) +} diff --git a/internal/siop/verifier_test.go b/internal/siop/verifier_test.go new file mode 100644 index 0000000..563cc68 --- /dev/null +++ b/internal/siop/verifier_test.go @@ -0,0 +1,343 @@ +package siop + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "testing" + "time" + + projectdidkey "github.com/ic3software/vtafarm-api/internal/didkey" +) + +const ( + testAudience = "did:webvh:QmSiteScid:rp.example" + testNonce = "challenge-value" +) + +var testNow = time.Unix(1_700_000_000, 0) + +type testIdentity struct { + did string + kid string + privateKey ed25519.PrivateKey +} + +func newTestIdentity(t *testing.T, seed byte) testIdentity { + t.Helper() + privateKey := ed25519.NewKeyFromSeed(bytesOf(seed, ed25519.SeedSize)) + did, err := projectdidkey.FromPublicKey(privateKey.Public().(ed25519.PublicKey)) + if err != nil { + t.Fatal(err) + } + multibase := did[len("did:key:"):] + return testIdentity{did: did, kid: did + "#" + multibase, privateKey: privateKey} +} + +func bytesOf(value byte, length int) []byte { + result := make([]byte, length) + for i := range result { + result[i] = value + } + return result +} + +func mintToken(t *testing.T, signer testIdentity, header, payload map[string]any) string { + t.Helper() + if header == nil { + header = map[string]any{"alg": "EdDSA", "typ": "JWT", "kid": signer.kid} + } + if payload == nil { + payload = map[string]any{ + "iss": signer.did, "sub": signer.did, "aud": testAudience, + "nonce": testNonce, "iat": testNow.Unix(), "exp": testNow.Add(5 * time.Minute).Unix(), + } + } + headerJSON, err := json.Marshal(header) + if err != nil { + t.Fatal(err) + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + headerSegment := base64.RawURLEncoding.EncodeToString(headerJSON) + payloadSegment := base64.RawURLEncoding.EncodeToString(payloadJSON) + signingInput := headerSegment + "." + payloadSegment + signature := ed25519.Sign(signer.privateKey, []byte(signingInput)) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +func verifyForTest(token, expectedDID string, resolver AuthenticationKeyResolver) (VerifiedIDToken, error) { + return VerifyIDTokenAt( + context.Background(), token, expectedDID, testAudience, testNonce, + resolver, testNow, DefaultClockSkew, + ) +} + +func TestVerifyIDTokenAcceptsRustCompatibleDIDKeyToken(t *testing.T) { + identity := newTestIdentity(t, 42) + verified, err := verifyForTest(mintToken(t, identity, nil, nil), identity.did, DIDKeyResolver{}) + if err != nil { + t.Fatalf("VerifyIDTokenAt() error = %v", err) + } + if verified.Subject != identity.did || verified.Kid != identity.kid { + t.Fatalf("VerifyIDTokenAt() = %#v", verified) + } +} + +func TestVerifyIDTokenRejectsSecurityFailures(t *testing.T) { + identity := newTestIdentity(t, 42) + other := newTestIdentity(t, 7) + basePayload := func() map[string]any { + return map[string]any{ + "iss": identity.did, "sub": identity.did, "aud": testAudience, + "nonce": testNonce, "iat": testNow.Unix(), "exp": testNow.Add(5 * time.Minute).Unix(), + } + } + + tests := []struct { + name string + token func() string + expected ErrorCode + }{ + { + name: "alg none", + token: func() string { + return mintToken(t, identity, map[string]any{"alg": "none", "kid": identity.kid}, nil) + }, + expected: ErrorUnsupportedAlgorithm, + }, + { + name: "missing kid", + token: func() string { + return mintToken(t, identity, map[string]any{"alg": "EdDSA", "typ": "JWT"}, nil) + }, + expected: ErrorInvalidHeader, + }, + { + name: "unknown protected header", + token: func() string { + return mintToken(t, identity, map[string]any{"alg": "EdDSA", "kid": identity.kid, "crit": []string{"x"}, "x": true}, nil) + }, + expected: ErrorInvalidHeader, + }, + { + name: "issuer and subject differ", + token: func() string { + payload := basePayload() + payload["sub"] = other.did + return mintToken(t, identity, nil, payload) + }, + expected: ErrorSubjectMismatch, + }, + { + name: "wrong audience", + token: func() string { + payload := basePayload() + payload["aud"] = "did:webvh:other" + return mintToken(t, identity, nil, payload) + }, + expected: ErrorAudienceMismatch, + }, + { + name: "wrong nonce", + token: func() string { + payload := basePayload() + payload["nonce"] = "wrong" + return mintToken(t, identity, nil, payload) + }, + expected: ErrorNonceMismatch, + }, + { + name: "expired", + token: func() string { + payload := basePayload() + payload["iat"] = testNow.Add(-10 * time.Minute).Unix() + payload["exp"] = testNow.Add(-2 * time.Minute).Unix() + return mintToken(t, identity, nil, payload) + }, + expected: ErrorTokenExpired, + }, + { + name: "iat in future", + token: func() string { + payload := basePayload() + payload["iat"] = testNow.Add(2 * time.Minute).Unix() + return mintToken(t, identity, nil, payload) + }, + expected: ErrorTokenNotYetValid, + }, + { + name: "kid from another DID", + token: func() string { + return mintToken(t, identity, map[string]any{"alg": "EdDSA", "kid": other.kid}, nil) + }, + expected: ErrorKIDMismatch, + }, + { + name: "altered signature", + token: func() string { + token := mintToken(t, identity, nil, nil) + last := token[len(token)-1] + replacement := byte('A') + if last == replacement { + replacement = 'B' + } + return token[:len(token)-1] + string(replacement) + }, + expected: ErrorSignatureInvalid, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := verifyForTest(test.token(), identity.did, DIDKeyResolver{}) + if ErrorCodeOf(err) != test.expected { + t.Fatalf("error = %v, code = %q, want %q", err, ErrorCodeOf(err), test.expected) + } + }) + } +} + +func TestVerifyIDTokenRejectsMalformedCompactJWS(t *testing.T) { + identity := newTestIdentity(t, 42) + valid := mintToken(t, identity, nil, nil) + parts := splitToken(t, valid) + + tests := map[string]string{ + "two segments": "a.b", + "empty segment": "a..b", + "padded header": parts[0] + "=." + parts[1] + "." + parts[2], + "duplicate alg": rawSignedToken(t, identity, `{"alg":"EdDSA","alg":"none","kid":"`+identity.kid+`"}`, defaultPayloadJSON(identity)), + "duplicate issuer": rawSignedToken(t, identity, defaultHeaderJSON(identity), `{"iss":"`+identity.did+`","iss":"`+identity.did+`","sub":"`+identity.did+`","aud":"`+testAudience+`","nonce":"`+testNonce+`","iat":1700000000,"exp":1700000300}`), + "fractional issuedAt": rawSignedToken(t, identity, defaultHeaderJSON(identity), `{"iss":"`+identity.did+`","sub":"`+identity.did+`","aud":"`+testAudience+`","nonce":"`+testNonce+`","iat":1700000000.5,"exp":1700000300}`), + } + for name, token := range tests { + t.Run(name, func(t *testing.T) { + if _, err := verifyForTest(token, identity.did, DIDKeyResolver{}); err == nil { + t.Fatal("malformed token was accepted") + } + }) + } +} + +func TestIssuerMismatchDoesNotCallResolver(t *testing.T) { + identity := newTestIdentity(t, 42) + resolver := &countingResolver{} + _, err := verifyForTest(mintToken(t, identity, nil, nil), "did:key:z6MkExpected", resolver) + if ErrorCodeOf(err) != ErrorIssuerMismatch { + t.Fatalf("error = %v", err) + } + if resolver.calls != 0 { + t.Fatalf("resolver calls = %d, want 0", resolver.calls) + } +} + +type countingResolver struct{ calls int } + +func (r *countingResolver) ResolveAuthenticationKey(context.Context, string, string) (ed25519.PublicKey, error) { + r.calls++ + return nil, errors.New("unexpected call") +} + +func TestDIDKeyResolverRejectsNonCanonicalKidAndCodec(t *testing.T) { + identity := newTestIdentity(t, 42) + resolver := DIDKeyResolver{} + if _, err := resolver.ResolveAuthenticationKey(context.Background(), identity.did, identity.did+"#other"); err == nil { + t.Fatal("non-canonical kid was accepted") + } + + x25519 := append([]byte{0xec, 0x01}, bytesOf(1, ed25519.PublicKeySize)...) + did := "did:key:z" + EncodeBase58BTC(x25519) + if _, err := resolver.ResolveAuthenticationKey(context.Background(), did, did+"#"+did[len("did:key:"):]); err == nil { + t.Fatal("X25519 did:key was accepted") + } +} + +func TestAuthenticationKeyFromDocument(t *testing.T) { + identity := newTestIdentity(t, 42) + multibase := identity.did[len("did:key:"):] + did := "did:webvh:QmScid:persona.example" + kid := did + "#key-0" + document := []byte(fmt.Sprintf(`{ + "@context":["https://www.w3.org/ns/did/v1"], + "id":%q, + "verificationMethod":[ + {"id":"#key-0","type":"Multikey","controller":%q,"publicKeyMultibase":%q}, + {"id":"#assertion-only","type":"Multikey","controller":%q,"publicKeyMultibase":%q} + ], + "authentication":["#key-0"] + }`, did, did, multibase, did, multibase)) + + key, err := AuthenticationKeyFromDocument(document, did, kid) + if err != nil { + t.Fatalf("AuthenticationKeyFromDocument() error = %v", err) + } + if !key.Equal(identity.privateKey.Public()) { + t.Fatal("resolved key does not match") + } + if _, err := AuthenticationKeyFromDocument(document, did, did+"#assertion-only"); err == nil { + t.Fatal("key outside authentication was accepted") + } +} + +func TestAuthenticationKeyFromDocumentAcceptsEd25519JWK(t *testing.T) { + identity := newTestIdentity(t, 42) + did := "did:webvh:QmScid:persona.example" + kid := did + "#key-0" + x := base64.RawURLEncoding.EncodeToString(identity.privateKey.Public().(ed25519.PublicKey)) + document := []byte(fmt.Sprintf(`{ + "id":%q, + "verificationMethod":[{"id":"#key-0","type":"JsonWebKey2020","controller":%q,"publicKeyJwk":{"kty":"OKP","crv":"Ed25519","x":%q}}], + "authentication":["#key-0"] + }`, did, did, x)) + key, err := AuthenticationKeyFromDocument(document, did, kid) + if err != nil { + t.Fatalf("AuthenticationKeyFromDocument() error = %v", err) + } + if !key.Equal(identity.privateKey.Public()) { + t.Fatal("resolved JWK does not match") + } +} + +func splitToken(t *testing.T, token string) [3]string { + t.Helper() + var parts [3]string + count := 0 + start := 0 + for i := 0; i <= len(token); i++ { + if i == len(token) || token[i] == '.' { + if count >= len(parts) { + t.Fatal("invalid test token") + } + parts[count] = token[start:i] + count++ + start = i + 1 + } + } + if count != 3 { + t.Fatal("invalid test token") + } + return parts +} + +func rawSignedToken(t *testing.T, identity testIdentity, headerJSON, payloadJSON string) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(headerJSON)) + payload := base64.RawURLEncoding.EncodeToString([]byte(payloadJSON)) + input := header + "." + payload + return input + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(identity.privateKey, []byte(input))) +} + +func defaultHeaderJSON(identity testIdentity) string { + return fmt.Sprintf(`{"alg":"EdDSA","typ":"JWT","kid":%q}`, identity.kid) +} + +func defaultPayloadJSON(identity testIdentity) string { + return fmt.Sprintf(`{"iss":%q,"sub":%q,"aud":%q,"nonce":%q,"iat":1700000000,"exp":1700000300}`, + identity.did, identity.did, testAudience, testNonce) +} diff --git a/internal/siop/webvh/log.go b/internal/siop/webvh/log.go new file mode 100644 index 0000000..2c26c50 --- /dev/null +++ b/internal/siop/webvh/log.go @@ -0,0 +1,548 @@ +package webvh + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer" + + "github.com/ic3software/vtafarm-api/internal/siop" +) + +const scidPlaceholder = "{SCID}" + +type logEntry struct { + raw map[string]json.RawMessage + versionID string + version uint64 + versionHash string + versionTime time.Time + parameters map[string]json.RawMessage + state json.RawMessage + stateID string + proof dataIntegrityProof +} + +type dataIntegrityProof struct { + raw map[string]json.RawMessage + verificationMethod string + proofValue string + created *time.Time +} + +type parameterState struct { + scid string + updateKeys []string + nextKeyHashes []string + preRotation bool + portable bool + deactivated bool +} + +func validateLog(content []byte, requestedDID string, now time.Time, clockSkew time.Duration) ([]byte, error) { + if len(content) == 0 || clockSkew < 0 { + return nil, errors.New("empty DID log") + } + requestedSCID, err := scidFromDID(requestedDID) + if err != nil { + return nil, err + } + + lines := strings.Split(string(content), "\n") + entries := make([]logEntry, 0, len(lines)) + for lineNumber, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + entry, err := parseLogEntry([]byte(line), now, clockSkew) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNumber+1, err) + } + entries = append(entries, entry) + } + if len(entries) == 0 { + return nil, errors.New("empty DID log") + } + + var active parameterState + var previous *logEntry + matchedRequestedDID := false + for index := range entries { + entry := &entries[index] + if entry.version != uint64(index+1) { + return nil, errors.New("versionId sequence is not contiguous") + } + entrySCID, err := scidFromDID(entry.stateID) + if err != nil || entrySCID != requestedSCID { + return nil, errors.New("DID document SCID does not match requested DID") + } + if entry.stateID == requestedDID { + matchedRequestedDID = true + } + if previous != nil && !entry.versionTime.After(previous.versionTime) { + return nil, errors.New("versionTime is not strictly increasing") + } + if active.deactivated { + return nil, errors.New("DID log has entries after deactivation") + } + + previousState := active + active, err = applyParameters(entry.parameters, previousState, previous != nil) + if err != nil { + return nil, fmt.Errorf("version %d parameters: %w", entry.version, err) + } + if active.scid != requestedSCID { + return nil, errors.New("parameters.scid does not match requested DID") + } + + authorizedKeys := active.updateKeys + if previous != nil && !previousState.preRotation { + authorizedKeys = previousState.updateKeys + } + if !proofKeyAuthorized(entry.proof.verificationMethod, authorizedKeys) { + return nil, errors.New("log proof key is not authorized") + } + if err := verifyEntryProof(*entry); err != nil { + return nil, err + } + if err := verifyEntryHash(*entry, previous); err != nil { + return nil, err + } + if index == 0 { + if err := verifyGenesisSCID(*entry); err != nil { + return nil, err + } + } else if entry.stateID != previous.stateID { + if !active.portable || !stateAlsoKnownAs(entry.state, previous.stateID) { + return nil, errors.New("DID move does not satisfy portability requirements") + } + } + previous = entry + } + if !matchedRequestedDID || entries[len(entries)-1].stateID != requestedDID { + return nil, errors.New("requested DID is not the current DID document id") + } + if active.deactivated { + return nil, errors.New("DID is deactivated") + } + return entries[len(entries)-1].state, nil +} + +func parseLogEntry(data []byte, now time.Time, clockSkew time.Duration) (logEntry, error) { + if _, err := jsoncanonicalizer.Transform(data); err != nil { + return logEntry{}, fmt.Errorf("log entry is not valid I-JSON: %w", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return logEntry{}, err + } + for name := range raw { + switch name { + case "versionId", "versionTime", "parameters", "state", "proof": + default: + return logEntry{}, errors.New("log entry contains an unknown field") + } + } + var entry logEntry + entry.raw = raw + if err := requiredJSON(raw, "versionId", &entry.versionID); err != nil { + return logEntry{}, err + } + versionNumber, versionHash, ok := strings.Cut(entry.versionID, "-") + if !ok || versionHash == "" { + return logEntry{}, errors.New("invalid versionId") + } + entry.version, _ = strconv.ParseUint(versionNumber, 10, 32) + if entry.version == 0 || strconv.FormatUint(entry.version, 10) != versionNumber { + return logEntry{}, errors.New("invalid versionId number") + } + entry.versionHash = versionHash + + var versionTime string + if err := requiredJSON(raw, "versionTime", &versionTime); err != nil { + return logEntry{}, err + } + entry.versionTime, _ = time.Parse(time.RFC3339, versionTime) + if entry.versionTime.IsZero() || entry.versionTime.Format(time.RFC3339) != versionTime || entry.versionTime.After(now.Add(clockSkew)) { + return logEntry{}, errors.New("invalid or future versionTime") + } + if err := requiredJSON(raw, "parameters", &entry.parameters); err != nil { + return logEntry{}, err + } + if err := requiredJSON(raw, "state", &entry.state); err != nil { + return logEntry{}, err + } + var state struct { + ID string `json:"id"` + } + if err := json.Unmarshal(entry.state, &state); err != nil || state.ID == "" { + return logEntry{}, errors.New("DID document has no id") + } + entry.stateID = state.ID + + var proofs []json.RawMessage + if err := requiredJSON(raw, "proof", &proofs); err != nil || len(proofs) != 1 { + return logEntry{}, errors.New("log entry must contain exactly one proof") + } + proof, err := parseProof(proofs[0], now, clockSkew) + if err != nil { + return logEntry{}, err + } + entry.proof = proof + return entry, nil +} + +func parseProof(data []byte, now time.Time, clockSkew time.Duration) (dataIntegrityProof, error) { + if _, err := jsoncanonicalizer.Transform(data); err != nil { + return dataIntegrityProof{}, errors.New("proof is not valid I-JSON") + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return dataIntegrityProof{}, err + } + for name := range raw { + switch name { + case "type", "cryptosuite", "created", "verificationMethod", "proofPurpose", "proofValue", "@context": + default: + return dataIntegrityProof{}, errors.New("proof contains an unknown field") + } + } + var proofType, cryptosuite, purpose string + var proof dataIntegrityProof + proof.raw = raw + if requiredJSON(raw, "type", &proofType) != nil || proofType != "DataIntegrityProof" || + requiredJSON(raw, "cryptosuite", &cryptosuite) != nil || cryptosuite != "eddsa-jcs-2022" || + requiredJSON(raw, "proofPurpose", &purpose) != nil || purpose != "assertionMethod" || + requiredJSON(raw, "verificationMethod", &proof.verificationMethod) != nil || + requiredJSON(raw, "proofValue", &proof.proofValue) != nil { + return dataIntegrityProof{}, errors.New("proof has an invalid required field") + } + if createdRaw, ok := raw["created"]; ok { + var created string + if json.Unmarshal(createdRaw, &created) != nil { + return dataIntegrityProof{}, errors.New("proof created is invalid") + } + parsed, err := time.Parse(time.RFC3339, created) + if err != nil || parsed.Format(time.RFC3339) != created || parsed.After(now.Add(clockSkew)) { + return dataIntegrityProof{}, errors.New("proof created is invalid or in the future") + } + proof.created = &parsed + } + return proof, nil +} + +func applyParameters(raw map[string]json.RawMessage, previous parameterState, hasPrevious bool) (parameterState, error) { + for name := range raw { + switch name { + case "method", "scid", "updateKeys", "portable", "nextKeyHashes", "witness", "watchers", "deactivated", "ttl": + default: + return parameterState{}, errors.New("unknown DID method parameter") + } + } + current := previous + current.updateKeys = slices.Clone(previous.updateKeys) + current.nextKeyHashes = slices.Clone(previous.nextKeyHashes) + if methodRaw, ok := raw["method"]; ok { + var method string + if json.Unmarshal(methodRaw, &method) != nil || method != "did:webvh:1.0" { + return parameterState{}, errors.New("unsupported DID method version") + } + } else if !hasPrevious { + return parameterState{}, errors.New("genesis entry has no method") + } + if scidRaw, ok := raw["scid"]; ok { + if hasPrevious || json.Unmarshal(scidRaw, ¤t.scid) != nil || current.scid == "" { + return parameterState{}, errors.New("invalid scid parameter") + } + } else if !hasPrevious { + return parameterState{}, errors.New("genesis entry has no scid") + } + + previousPreRotation := previous.preRotation + if hashesRaw, ok := raw["nextKeyHashes"]; ok { + if err := json.Unmarshal(hashesRaw, ¤t.nextKeyHashes); err != nil { + return parameterState{}, errors.New("invalid nextKeyHashes") + } + if hasDuplicates(current.nextKeyHashes) { + return parameterState{}, errors.New("nextKeyHashes contains duplicates") + } + for _, hash := range current.nextKeyHashes { + if _, err := parseSHA256Multihash(hash); err != nil { + return parameterState{}, errors.New("nextKeyHashes contains an invalid SHA-256 multihash") + } + } + current.preRotation = len(current.nextKeyHashes) > 0 + } else if previousPreRotation { + return parameterState{}, errors.New("nextKeyHashes must be present during pre-rotation") + } + + if keysRaw, ok := raw["updateKeys"]; ok { + var keys []string + if err := json.Unmarshal(keysRaw, &keys); err != nil { + return parameterState{}, errors.New("invalid updateKeys") + } + if len(keys) == 0 { + if !hasPrevious || previousPreRotation { + return parameterState{}, errors.New("updateKeys cannot be empty here") + } + } else { + if hasDuplicates(keys) { + return parameterState{}, errors.New("updateKeys contains duplicates") + } + for _, key := range keys { + if _, err := decodeUpdateKey(key); err != nil { + return parameterState{}, err + } + } + if previousPreRotation { + for _, key := range keys { + if !contains(previous.nextKeyHashes, hashMultibaseString(key)) { + return parameterState{}, errors.New("updateKey was not pre-committed") + } + } + } + current.updateKeys = keys + } + } else if !hasPrevious || previousPreRotation { + return parameterState{}, errors.New("updateKeys must be present here") + } + if len(current.updateKeys) == 0 { + return parameterState{}, errors.New("no active update keys") + } + + if portableRaw, ok := raw["portable"]; ok { + var portable bool + if json.Unmarshal(portableRaw, &portable) != nil || (hasPrevious && portable) { + return parameterState{}, errors.New("portable may only be enabled in genesis") + } + current.portable = portable + } + if witnessRaw, ok := raw["witness"]; ok { + var witness map[string]json.RawMessage + if json.Unmarshal(witnessRaw, &witness) != nil { + return parameterState{}, errors.New("invalid witness parameter") + } + if len(witness) != 0 { + return parameterState{}, errors.New("witnessed DID logs are not supported") + } + } + if watchersRaw, ok := raw["watchers"]; ok { + var watchers []string + if json.Unmarshal(watchersRaw, &watchers) != nil { + return parameterState{}, errors.New("invalid watchers") + } + } + if ttlRaw, ok := raw["ttl"]; ok { + var ttl uint32 + if json.Unmarshal(ttlRaw, &ttl) != nil { + return parameterState{}, errors.New("invalid ttl") + } + } + if deactivatedRaw, ok := raw["deactivated"]; ok { + var deactivated bool + if json.Unmarshal(deactivatedRaw, &deactivated) != nil || (!hasPrevious && deactivated) { + return parameterState{}, errors.New("invalid deactivated parameter") + } + current.deactivated = deactivated + } + return current, nil +} + +func verifyEntryProof(entry logEntry) error { + proofConfig := cloneRawMap(entry.proof.raw) + delete(proofConfig, "proofValue") + proofCanonical, err := canonicalJSON(proofConfig) + if err != nil { + return errors.New("canonicalize proof config") + } + document := cloneRawMap(entry.raw) + delete(document, "proof") + documentCanonical, err := canonicalJSON(document) + if err != nil { + return errors.New("canonicalize log entry") + } + proofHash := sha256.Sum256(proofCanonical) + documentHash := sha256.Sum256(documentCanonical) + message := append(proofHash[:], documentHash[:]...) + + did, fragment, ok := strings.Cut(entry.proof.verificationMethod, "#") + if !ok || !strings.HasPrefix(did, "did:key:") || fragment != strings.TrimPrefix(did, "did:key:") { + return errors.New("proof verificationMethod is not a canonical did:key") + } + publicKey, err := (siop.DIDKeyResolver{}).ResolveAuthenticationKey( + context.Background(), did, entry.proof.verificationMethod, + ) + if err != nil { + return errors.New("resolve log proof key") + } + signature, err := decodeProofValue(entry.proof.proofValue) + if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, message, signature) { + return errors.New("log proof signature verification failed") + } + return nil +} + +func verifyEntryHash(entry logEntry, previous *logEntry) error { + working := cloneRawMap(entry.raw) + delete(working, "proof") + if previous == nil { + var scid string + _ = json.Unmarshal(entry.parameters["scid"], &scid) + working["versionId"], _ = json.Marshal(scid) + } else { + working["versionId"], _ = json.Marshal(previous.versionID) + } + hash, err := hashJSON(working) + if err != nil || hash != entry.versionHash { + return errors.New("versionId hash does not match log entry") + } + return nil +} + +func verifyGenesisSCID(entry logEntry) error { + var scid string + if json.Unmarshal(entry.parameters["scid"], &scid) != nil { + return errors.New("invalid genesis scid") + } + working := cloneRawMap(entry.raw) + delete(working, "proof") + working["versionId"], _ = json.Marshal(scidPlaceholder) + serialized, err := json.Marshal(working) + if err != nil { + return err + } + serialized = []byte(strings.ReplaceAll(string(serialized), scid, scidPlaceholder)) + canonical, err := jsoncanonicalizer.Transform(serialized) + if err != nil { + return err + } + digest := sha256.Sum256(canonical) + calculated := siop.EncodeBase58BTC(append([]byte{0x12, 0x20}, digest[:]...)) + if calculated != scid { + return errors.New("genesis SCID does not match log entry") + } + return nil +} + +func requiredJSON[T any](object map[string]json.RawMessage, name string, destination *T) error { + raw, ok := object[name] + if !ok || json.Unmarshal(raw, destination) != nil { + return fmt.Errorf("missing or invalid %s", name) + } + return nil +} + +func canonicalJSON(value any) ([]byte, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + return jsoncanonicalizer.Transform(encoded) +} + +func hashJSON(value any) (string, error) { + canonical, err := canonicalJSON(value) + if err != nil { + return "", err + } + digest := sha256.Sum256(canonical) + return siop.EncodeBase58BTC(append([]byte{0x12, 0x20}, digest[:]...)), nil +} + +func hashMultibaseString(value string) string { + digest := sha256.Sum256([]byte(value)) + return siop.EncodeBase58BTC(append([]byte{0x12, 0x20}, digest[:]...)) +} + +func parseSHA256Multihash(value string) ([]byte, error) { + decoded, err := siop.DecodeBase58BTC(value) + if err != nil || siop.EncodeBase58BTC(decoded) != value || len(decoded) != 34 || decoded[0] != 0x12 || decoded[1] != 0x20 { + return nil, errors.New("value is not a canonical SHA-256 multihash") + } + return decoded[2:], nil +} + +func decodeProofValue(value string) ([]byte, error) { + if !strings.HasPrefix(value, "z") { + return nil, errors.New("proofValue is not base58btc multibase") + } + decoded, err := siop.DecodeBase58BTC(value[1:]) + if err != nil || siop.EncodeBase58BTC(decoded) != value[1:] { + return nil, errors.New("proofValue is not canonical base58btc") + } + return decoded, nil +} + +func decodeUpdateKey(value string) (ed25519.PublicKey, error) { + did := "did:key:" + value + return (siop.DIDKeyResolver{}).ResolveAuthenticationKey(context.Background(), did, did+"#"+value) +} + +func proofKeyAuthorized(verificationMethod string, keys []string) bool { + did, fragment, ok := strings.Cut(verificationMethod, "#") + if !ok || !strings.HasPrefix(did, "did:key:") || strings.TrimPrefix(did, "did:key:") != fragment { + return false + } + return contains(keys, fragment) +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func hasDuplicates(values []string) bool { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + return true + } + seen[value] = struct{}{} + } + return false +} + +func cloneRawMap(source map[string]json.RawMessage) map[string]json.RawMessage { + result := make(map[string]json.RawMessage, len(source)) + for key, value := range source { + result[key] = value + } + return result +} + +func scidFromDID(did string) (string, error) { + rest, ok := strings.CutPrefix(did, "did:webvh:") + if !ok { + return "", errors.New("not a did:webvh DID") + } + scid, _, ok := strings.Cut(rest, ":") + if !ok || scid == "" { + return "", errors.New("did:webvh DID has no SCID") + } + if _, err := parseSHA256Multihash(scid); err != nil { + return "", errors.New("did:webvh DID has an invalid SCID") + } + return scid, nil +} + +func stateAlsoKnownAs(state json.RawMessage, wanted string) bool { + var document struct { + AlsoKnownAs []string `json:"alsoKnownAs"` + } + if json.Unmarshal(state, &document) != nil { + return false + } + return contains(document.AlsoKnownAs, wanted) +} diff --git a/internal/siop/webvh/log_test.go b/internal/siop/webvh/log_test.go new file mode 100644 index 0000000..4ad30d2 --- /dev/null +++ b/internal/siop/webvh/log_test.go @@ -0,0 +1,108 @@ +package webvh + +import ( + "net" + "os" + "strings" + "testing" + "time" +) + +const rustFixtureDID = "did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:fuzz.example.com" + +func TestValidateLogAcceptsRustReferenceFixture(t *testing.T) { + fixture, err := os.ReadFile("testdata/rust-chain-simple.jsonl") + if err != nil { + t.Fatal(err) + } + document, err := validateLog(fixture, rustFixtureDID, time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC), time.Minute) + if err != nil { + t.Fatalf("validateLog() error = %v", err) + } + if !strings.Contains(string(document), rustFixtureDID) { + t.Fatalf("resolved document = %s", document) + } +} + +func TestValidateLogAcceptsRustPreRotationFixture(t *testing.T) { + fixture, err := os.ReadFile("testdata/rust-chain-prerotation.jsonl") + if err != nil { + t.Fatal(err) + } + did := "did:webvh:QmeAxih2DFAh5nep13zBgm7jsczMMfsaR2T8NQrPp6eSyi:fuzz.example.com" + if _, err := validateLog(fixture, did, time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC), time.Minute); err != nil { + t.Fatalf("validateLog() error = %v", err) + } +} + +func TestValidateLogRejectsTamperingAndTruncationShapes(t *testing.T) { + fixture, err := os.ReadFile("testdata/rust-chain-simple.jsonl") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) + tests := map[string][]byte{ + "altered state": []byte(strings.Replace(string(fixture), "fuzz.example.com", "evil.example.com", 1)), + "altered proof": []byte(strings.Replace(string(fixture), "zGzCik", "zAzCik", 1)), + "broken sequence": []byte(strings.Replace(string(fixture), `"versionId":"2-`, `"versionId":"7-`, 1)), + "duplicate parameter": []byte(strings.Replace(string(fixture), `"parameters":{"method"`, `"parameters":{"method":"did:webvh:1.0","method"`, 1)), + } + for name, content := range tests { + t.Run(name, func(t *testing.T) { + if _, err := validateLog(content, rustFixtureDID, now, time.Minute); err == nil { + t.Fatal("tampered DID log was accepted") + } + }) + } +} + +func TestResolutionURL(t *testing.T) { + tests := []struct { + did string + want string + }{ + {"did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:example.com", "https://example.com/.well-known/did.jsonl"}, + {"did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:example.com%3A8443:people:alice", "https://example.com:8443/people/alice/did.jsonl"}, + } + for _, test := range tests { + resolved, _, err := resolutionURL(test.did) + if err != nil { + t.Fatalf("resolutionURL(%q) error = %v", test.did, err) + } + if resolved.String() != test.want { + t.Fatalf("resolutionURL(%q) = %q, want %q", test.did, resolved, test.want) + } + } + for _, invalid := range []string{ + "did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:localhost", + "did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:127.0.0.1", + "did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:%5B::1%5D", + "did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:example.com:%2e%2e", + "did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:example.com#fragment", + "did:webvh:QmNotAFullHash:example.com", + } { + if _, _, err := resolutionURL(invalid); err == nil { + t.Fatalf("resolutionURL(%q) accepted an unsafe DID", invalid) + } + } +} + +func TestPublicAddressPolicy(t *testing.T) { + for _, private := range []string{"127.0.0.1", "10.0.0.1", "169.254.1.1", "192.0.2.1", "::1", "fc00::1", "2001:db8::1"} { + if isPublicAddress(parseIP(t, private)) { + t.Fatalf("%s was considered public", private) + } + } + if !isPublicAddress(parseIP(t, "8.8.8.8")) || !isPublicAddress(parseIP(t, "2606:4700:4700::1111")) { + t.Fatal("public resolver addresses were rejected") + } +} + +func parseIP(t *testing.T, value string) []byte { + t.Helper() + parsed := net.ParseIP(value) + if parsed == nil { + t.Fatalf("invalid test IP %q", value) + } + return parsed +} diff --git a/internal/siop/webvh/resolver.go b/internal/siop/webvh/resolver.go new file mode 100644 index 0000000..3ba1456 --- /dev/null +++ b/internal/siop/webvh/resolver.go @@ -0,0 +1,220 @@ +package webvh + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strconv" + "strings" + "time" + + "golang.org/x/net/idna" + + "github.com/ic3software/vtafarm-api/internal/siop" +) + +const ( + DefaultMaxResponseBytes = 1 << 20 + DefaultTimeout = 5 * time.Second +) + +type httpClient interface { + Do(*http.Request) (*http.Response, error) +} + +// Resolver fetches and cryptographically validates a did:webvh v1.0 history +// before selecting the exact authentication key named by kid. +type Resolver struct { + client httpClient + lookupIP func(context.Context, string) ([]net.IPAddr, error) + maxResponseBytes int64 + now func() time.Time + clockSkew time.Duration +} + +// NewResolver constructs a resolver with normal TLS verification, no +// redirects, public-address-only dialing, and bounded response/time limits. +func NewResolver(timeout time.Duration) *Resolver { + if timeout <= 0 { + timeout = DefaultTimeout + } + dialer := &net.Dialer{Timeout: timeout} + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + for _, resolved := range addresses { + if !isPublicAddress(resolved.IP) { + continue + } + return dialer.DialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port)) + } + return nil, errors.New("DID host has no public IP address") + } + + return &Resolver{ + client: &http.Client{ + Timeout: timeout, + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + lookupIP: net.DefaultResolver.LookupIPAddr, + maxResponseBytes: DefaultMaxResponseBytes, + now: time.Now, + clockSkew: siop.DefaultClockSkew, + } +} + +func (r *Resolver) ResolveAuthenticationKey(ctx context.Context, did, kid string) (ed25519.PublicKey, error) { + if r == nil || r.client == nil || r.lookupIP == nil || r.now == nil || r.maxResponseBytes <= 0 || r.clockSkew < 0 { + return nil, errors.New("did:webvh resolver is not configured") + } + logURL, host, err := resolutionURL(did) + if err != nil { + return nil, err + } + addresses, err := r.lookupIP(ctx, host) + if err != nil { + return nil, errors.New("DID host DNS lookup failed") + } + if len(addresses) == 0 { + return nil, errors.New("DID host has no addresses") + } + for _, address := range addresses { + if !isPublicAddress(address.IP) { + return nil, errors.New("DID host resolves to a non-public address") + } + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, logURL.String(), nil) + if err != nil { + return nil, errors.New("create DID log request") + } + request.Header.Set("Accept", "application/jsonl, application/x-jsonlines;q=0.9") + response, err := r.client.Do(request) + if err != nil { + return nil, errors.New("fetch DID log") + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("DID log returned HTTP status %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, r.maxResponseBytes+1)) + if err != nil { + return nil, errors.New("read DID log") + } + if int64(len(body)) > r.maxResponseBytes { + return nil, errors.New("DID log exceeds the response limit") + } + + document, err := validateLog(body, did, r.now(), r.clockSkew) + if err != nil { + return nil, fmt.Errorf("validate DID log: %w", err) + } + return siop.AuthenticationKeyFromDocument(document, did, kid) +} + +func resolutionURL(did string) (*url.URL, string, error) { + if len(did) > 2048 { + return nil, "", errors.New("did:webvh identifier is too long") + } + rest, ok := strings.CutPrefix(did, "did:webvh:") + if !ok || strings.ContainsAny(did, "?#") { + return nil, "", errors.New("invalid did:webvh identifier") + } + parts := strings.Split(rest, ":") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return nil, "", errors.New("invalid did:webvh identifier") + } + if _, err := parseSHA256Multihash(parts[0]); err != nil { + return nil, "", errors.New("invalid did:webvh SCID") + } + hostPort, err := url.PathUnescape(parts[1]) + if err != nil || strings.ContainsAny(hostPort, "/?#@") { + return nil, "", errors.New("invalid did:webvh host") + } + + host := hostPort + port := "" + if strings.Contains(hostPort, ":") { + host, port, err = net.SplitHostPort(hostPort) + if err != nil || host == "" { + return nil, "", errors.New("invalid did:webvh host and port") + } + portNumber, parseErr := strconv.ParseUint(port, 10, 16) + if parseErr != nil || portNumber == 0 { + return nil, "", errors.New("invalid did:webvh port") + } + } + if net.ParseIP(host) != nil || strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") { + return nil, "", errors.New("IP literals and localhost are not valid did:webvh hosts") + } + host, err = idna.Lookup.ToASCII(strings.ToLower(host)) + if err != nil || host == "" || strings.Contains(host, "..") { + return nil, "", errors.New("invalid did:webvh domain") + } + + path := "/.well-known/did.jsonl" + if len(parts) > 2 { + segments := make([]string, 0, len(parts)-2) + for _, part := range parts[2:] { + decoded, err := url.PathUnescape(part) + if err != nil || decoded == "" || decoded == "." || decoded == ".." || strings.Contains(decoded, "/") { + return nil, "", errors.New("invalid did:webvh path") + } + segments = append(segments, decoded) + } + path = "/" + strings.Join(segments, "/") + "/did.jsonl" + } + authority := host + if port != "" { + authority = net.JoinHostPort(host, port) + } + return &url.URL{Scheme: "https", Host: authority, Path: path}, host, nil +} + +var blockedAddressPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("2001:db8::/32"), +} + +func isPublicAddress(ip net.IP) bool { + address, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() { + return false + } + for _, prefix := range blockedAddressPrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} + +var _ siop.AuthenticationKeyResolver = (*Resolver)(nil) diff --git a/internal/siop/webvh/resolver_test.go b/internal/siop/webvh/resolver_test.go new file mode 100644 index 0000000..cb900b1 --- /dev/null +++ b/internal/siop/webvh/resolver_test.go @@ -0,0 +1,99 @@ +package webvh + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strings" + "testing" + "time" +) + +type clientFunc func(*http.Request) (*http.Response, error) + +func (f clientFunc) Do(request *http.Request) (*http.Response, error) { return f(request) } + +func testResolver(client httpClient, lookup func(context.Context, string) ([]net.IPAddr, error)) *Resolver { + return &Resolver{ + client: client, + lookupIP: lookup, + maxResponseBytes: 32, + now: func() time.Time { return time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) }, + clockSkew: time.Minute, + } +} + +func TestResolverRejectsNonPublicDNSBeforeHTTP(t *testing.T) { + called := false + resolver := testResolver( + clientFunc(func(*http.Request) (*http.Response, error) { + called = true + return nil, errors.New("must not be called") + }), + func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("10.0.0.1")}}, nil + }, + ) + did := "did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:internal.example" + _, err := resolver.ResolveAuthenticationKey(context.Background(), did, did+"#key-0") + if err == nil || called { + t.Fatalf("error = %v, HTTP called = %v", err, called) + } +} + +func TestResolverBoundsResponseAndRejectsRedirect(t *testing.T) { + publicLookup := func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil + } + tests := []struct { + name string + client httpClient + match string + }{ + { + name: "oversized", + client: clientFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 33)))}, nil + }), + match: "response limit", + }, + { + name: "redirect", + client: clientFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusFound, Body: io.NopCloser(strings.NewReader(""))}, nil + }), + match: "HTTP status 302", + }, + { + name: "network timeout", + client: clientFunc(func(*http.Request) (*http.Response, error) { + return nil, context.DeadlineExceeded + }), + match: "fetch DID log", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver := testResolver(test.client, publicLookup) + did := "did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:persona.example" + _, err := resolver.ResolveAuthenticationKey(context.Background(), did, did+"#key-0") + if err == nil || !strings.Contains(err.Error(), test.match) { + t.Fatalf("error = %v, want match %q", err, test.match) + } + }) + } +} + +func TestNewResolverDisablesRedirectsAndProxy(t *testing.T) { + resolver := NewResolver(time.Second) + client := resolver.client.(*http.Client) + if err := client.CheckRedirect(nil, nil); !errors.Is(err, http.ErrUseLastResponse) { + t.Fatalf("CheckRedirect() error = %v", err) + } + transport := client.Transport.(*http.Transport) + if transport.Proxy != nil { + t.Fatal("resolver transport unexpectedly honors an HTTP proxy") + } +} diff --git a/internal/siop/webvh/testdata/ATTRIBUTION.md b/internal/siop/webvh/testdata/ATTRIBUTION.md new file mode 100644 index 0000000..b2cd057 --- /dev/null +++ b/internal/siop/webvh/testdata/ATTRIBUTION.md @@ -0,0 +1,7 @@ +# Test fixture attribution + +`rust-chain-simple.jsonl` and `rust-chain-prerotation.jsonl` are copied from +the Apache-2.0-licensed `affinidi-webvh-service` repository's +`fuzz/corpus/verify_did_log_proofs/` corpus. They are interoperability fixtures +for the Rust `didwebvh-rs` validation path described in +`docs/siop-login-design.md`. diff --git a/internal/siop/webvh/testdata/rust-chain-prerotation.jsonl b/internal/siop/webvh/testdata/rust-chain-prerotation.jsonl new file mode 100644 index 0000000..7f8e8b1 --- /dev/null +++ b/internal/siop/webvh/testdata/rust-chain-prerotation.jsonl @@ -0,0 +1,2 @@ +{"versionId":"1-QmRMtFc6MmFYUzSEJzCBfhuSd6L7h7B5m577vjjuf6kYAr","versionTime":"2024-01-01T00:00:00Z","parameters":{"method":"did:webvh:1.0","scid":"QmeAxih2DFAh5nep13zBgm7jsczMMfsaR2T8NQrPp6eSyi","updateKeys":["z6MktXn5fbubmwSFWptYCca9bzUn5ZQkHd2GMsKGg9sMhtQJ"],"nextKeyHashes":["QmTcCsq88dZxYuQnLQMHe6p56BDyZXDPxJemEJifHsUQ5o","QmaMnf5fZ44fW5X4mGZN9BhCjpXjiCkV5wQH62B5x34VoB"],"deactivated":false},"state":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:webvh:QmeAxih2DFAh5nep13zBgm7jsczMMfsaR2T8NQrPp6eSyi:fuzz.example.com"},"proof":[{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","created":"2026-06-14T10:22:35Z","verificationMethod":"did:key:z6MktXn5fbubmwSFWptYCca9bzUn5ZQkHd2GMsKGg9sMhtQJ#z6MktXn5fbubmwSFWptYCca9bzUn5ZQkHd2GMsKGg9sMhtQJ","proofPurpose":"assertionMethod","proofValue":"z55Sr8TUHHtYKNULerWhtCUaX1yEjthoxjf4b92tK6UkAM3BUCRHLeDvBiX8eHWCLBJaFfpcaQL5SLTeHBmFDVrQ9"}]} +{"versionId":"2-QmUQ8YvP9gMWdwhPYXnkRt7TT3Y7bLWvbnqnHFKBJ62zVQ","versionTime":"2024-01-02T00:00:00Z","parameters":{"updateKeys":["z6MkrxTVPvkFMiBDjhU47W2GGW8XzSHVeGajBdKYzJtYZdHd","z6MkrRYHty7UnKqJJ9v3NqxFSYhzHw3Y4JrBquXTPdKz6G4m"],"nextKeyHashes":["QmTip6LeJQsYRU39G4JkKEQCMFCjH4Pep55vp4UnZeF4zF","QmPJSpZq5NZNbWyDME91LV8KRt59pAwx5LALu1ki1exVtr"]},"state":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:webvh:QmeAxih2DFAh5nep13zBgm7jsczMMfsaR2T8NQrPp6eSyi:fuzz.example.com"},"proof":[{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","created":"2026-06-14T10:22:35Z","verificationMethod":"did:key:z6MkrxTVPvkFMiBDjhU47W2GGW8XzSHVeGajBdKYzJtYZdHd#z6MkrxTVPvkFMiBDjhU47W2GGW8XzSHVeGajBdKYzJtYZdHd","proofPurpose":"assertionMethod","proofValue":"z4ZgvGCxn1LibdLSrpEt8KCojNsF2yUV5MnSYQRUYtxzaBkgVshMHrJaaikEtazXbTswFi3MnsG6EfTQ9NkhLuPd"}]} diff --git a/internal/siop/webvh/testdata/rust-chain-simple.jsonl b/internal/siop/webvh/testdata/rust-chain-simple.jsonl new file mode 100644 index 0000000..2900c6d --- /dev/null +++ b/internal/siop/webvh/testdata/rust-chain-simple.jsonl @@ -0,0 +1,3 @@ +{"versionId":"1-QmPHirobPARoYcS3BkGjGC3h7zGuWCJYdg3As7iMZeKSpn","versionTime":"2024-01-01T00:00:00Z","parameters":{"method":"did:webvh:1.0","scid":"Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD","updateKeys":["z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj"],"deactivated":false},"state":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:fuzz.example.com"},"proof":[{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","created":"2026-06-14T10:22:35Z","verificationMethod":"did:key:z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj#z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj","proofPurpose":"assertionMethod","proofValue":"zGzCikTrGz8J9Cxg8dVpzKy34pYkxBboAU6BcvCQ4NA6WtxUomXJz3BTFDKnkYnB1jZzncxzvVKDjoPoNx312BgV"}]} +{"versionId":"2-QmS3HkerEdgoFmDyMmUTmYe1ygL9YYWHLsGRrstdagM23n","versionTime":"2024-01-02T00:00:00Z","parameters":{},"state":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:fuzz.example.com"},"proof":[{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","created":"2026-06-14T10:22:35Z","verificationMethod":"did:key:z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj#z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj","proofPurpose":"assertionMethod","proofValue":"zj6PWGpiqdiuq1T5SnCRzuK7ohKXQmLjYY8tKKpt9VvttUE82MhjEah6SSm2qPnTkHB1ew8KrHFThPTsBTRP7vbP"}]} +{"versionId":"3-QmNpwv6Bjsc45uNy9wxTHZFm8DrcyRaRJwUgBKoaBYYpQV","versionTime":"2024-01-03T00:00:00Z","parameters":{"updateKeys":["z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj"]},"state":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:webvh:Qmetio9KXzDkPXDpSQVyXSTcPVvj5ysHgMZt7y5ffRNDzD:fuzz.example.com"},"proof":[{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","created":"2026-06-14T10:22:35Z","verificationMethod":"did:key:z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj#z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj","proofPurpose":"assertionMethod","proofValue":"z5kyoVsY6H62cybqUXe8LonEruimAPvtewcsMojK2q959xB8mmsYbFLmwsD19hKscQfD5i5kMiqaqDvmL2dka4rUV"}]} From b30c622ce861ab5fb8124d9c09773433c38ae019 Mon Sep 17 00:00:00 2001 From: vthwang Date: Tue, 15 Sep 2026 15:49:50 -0700 Subject: [PATCH 2/6] feat(auth): add linked VTA Wallet login Add one-time SIOP challenges, account identity linking, and role-scoped wallet sessions. Document local and production RP DID setup, and wire migrations, OpenAPI, and Helm configuration. Signed-off-by: vthwang --- .env.example | 9 + README.md | 139 ++++- docs/siop-browser-testing.md | 57 ++ .../templates/vtafarm-api/configmap.yaml | 5 + helm/vtafarm-api/values.yaml | 9 + internal/apidocs/openapi.yaml | 207 +++++++- internal/config/config.go | 17 + internal/handler/passkey.go | 60 ++- internal/handler/siop.go | 486 ++++++++++++++++++ internal/handler/siop_test.go | 86 ++++ internal/middleware/auth.go | 2 + internal/middleware/ratelimit.go | 7 + internal/model/siop_identity.go | 40 ++ internal/model/siop_identity_test.go | 35 ++ internal/router/router.go | 25 + internal/siop/token.go | 11 + internal/siop/token_unverified_test.go | 18 + migrations/000029_siop_identities.down.sql | 3 + migrations/000029_siop_identities.up.sql | 52 ++ 19 files changed, 1257 insertions(+), 11 deletions(-) create mode 100644 docs/siop-browser-testing.md create mode 100644 internal/handler/siop.go create mode 100644 internal/handler/siop_test.go create mode 100644 internal/model/siop_identity.go create mode 100644 internal/model/siop_identity_test.go create mode 100644 internal/siop/token_unverified_test.go create mode 100644 migrations/000029_siop_identities.down.sql create mode 100644 migrations/000029_siop_identities.up.sql diff --git a/.env.example b/.env.example index 953627c..01cee59 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,15 @@ WEBAUTHN_RP_ID=localhost WEBAUTHN_RP_ORIGINS=http://localhost:5173 WEBAUTHN_RP_DISPLAY_NAME="VTA Farm" +# Optional VTA Wallet SIOPv2 login. Leave SIOP_RP_DID empty to keep the +# feature disabled. The DID must identify a dedicated RP identity whose +# did:webvh log is reachable over public HTTPS; do not reuse DID_HOSTING_DID. +SIOP_RP_DID= +SIOP_CHALLENGE_TTL_SECONDS=120 +SIOP_CLOCK_SKEW_SECONDS=60 +SIOP_DID_RESOLUTION_TIMEOUT_SECONDS=5 +SIOP_MAX_BODY_BYTES=70000 + # DID Hosting (optional — enables automatic did.jsonl upload after VTA setup) # # vtafarm-api's own keypair: `make gen-keypair`. Every full_stack session enrols diff --git a/README.md b/README.md index 81cac4f..5c7738d 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ The database being shared has consequences worth reading once: The API is now available at `http://localhost:8080`. API docs: `http://localhost:8080/docs` + To exercise optional VTA Wallet login in Chrome, follow + [`docs/siop-browser-testing.md`](docs/siop-browser-testing.md). + 4. (Optional) Generate a DID hosting keypair (required only if DID hosting is enabled): ```bash @@ -103,6 +106,10 @@ Copy `.env.example` and adjust as needed: | `DB_HOST` | `localhost` | The `make forward-db` tunnel to the shared dev database | | `DB_NAME` | `vtafarm` | | | `JWT_SECRET` | _(required)_ | HS256 signing secret — must match the team, see below | +| `SIOP_RP_DID` | _(empty)_ | Dedicated public RP DID; enables linked VTA Wallet login when set | +| `SIOP_CHALLENGE_TTL_SECONDS` | `120` | One-time wallet challenge lifetime | +| `SIOP_CLOCK_SKEW_SECONDS` | `60` | Allowed SIOP token clock skew | +| `SIOP_DID_RESOLUTION_TIMEOUT_SECONDS` | `5` | Public DID resolution timeout | | `ORCHESTRATOR_RESUME` | `true` | Re-attach interrupted sessions at startup. Set `false` locally — see [`docs/shared-dev-database.md`](docs/shared-dev-database.md) | | `CLUSTER_INGRESS_IP` | _(required)_ | External IP of the cluster's Traefik LoadBalancer | | `CLOUDFLARE_API_TOKEN` | _(optional)_ | Required for VTA setup wizard | @@ -110,7 +117,137 @@ Copy `.env.example` and adjust as needed: | `KUBECONFIG` | _(empty)_ | Auto-detects `~/.kube/config` when empty | | `K8S_NAMESPACE_PREFIX` | `vtafarm-user` | Per-user namespace: `vtafarm-user-{userID}` | -#### Generating JWT_SECRET +### SIOP RP DID + +`SIOP_RP_DID` is the public identity of VTA Farm as a SIOPv2 relying party. +The wallet puts this value in the login token's `aud` claim; the API does not +sign with it and has no `SIOP_RP_PRIVATE_KEY` setting. + +For local browser testing, put this development-only `did:key` in `.env`: + +```dotenv +SIOP_RP_DID=did:key:z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj +``` + +Restart the API and verify that wallet login is enabled: + +```bash +curl http://localhost:8080/api/v1/auth/siop/metadata +``` + +The response should contain `"enabled":true` and the same `rp_did`. This DID is +a public test fixture, carries no VTA Farm private key, and is not suitable for +production. + +#### Production RP DID + +Production should use a dedicated `vtafarm-auth` VTA identity with persistent +key storage and a `did:webvh` history that resolves over public HTTPS. It may +use the existing DID-hosting deployment; a separate hosting service is not +required. Do not reuse `DID_HOSTING_DID`: that `did:key` and its +`DID_HOSTING_PRIVATE_KEY` are the privileged machine credential that +vtafarm-api uses to upload DID logs and manage hosting ACLs. + +The simplest UI flow is through the VTA Wallet management console because the +VTA must create and retain the DID's keys: + +1. Connect the wallet to the dedicated `vtafarm-auth` VTA. From the extension + popup, select **Manage this agent**. +2. Under **Identity & custody → Contexts**, create or select the + `vtafarm-auth` context. +3. Under **Identity & custody → DIDs**, use **New DID**. Enter the registered + hosting server ID, or leave it blank when that VTA has a default server. + Enable **Portable** if the identity must be movable to another hosting + domain later; this choice cannot be added after creation. +4. Select **Create DID** and complete any consent request. The resulting DID is + already signed by the VTA, published to the hosting server, and displayed in + the DIDs table. Copy the full `did:webvh:...` value into the production + `siop.rpDID` Helm value. + +The hosting server must already be registered with the dedicated VTA. This is +a one-time prerequisite; the current wallet management console can create DIDs +but does not have a hosting-server registration form. Read the server's actual +DID from its public API and register it with PNM: + +```bash +curl 'https:///api/server-info' + +pnm did-mgmt servers add \ + --id primary \ + --did '' \ + --label 'VTA Farm DID host' +``` + +If the public hosting domain is not configured yet, log in to the DID-hosting +admin UI first: + +1. Open **Domains → New domain**, enter the canonical public hostname, and set + it as the default if this deployment should use it by default. +2. Open **Servers**, choose the hosting instance, and use **Assign domain**. +3. In **Access Control**, ensure the dedicated VTA identity is allowed to + publish to that domain. + +Do not use **DIDs → New DID** in the DID-hosting admin UI as the only creation +step. That screen calls `POST /api/dids` to reserve a path and displays +**Pending upload**; it does not generate keys or create the first signed +`did.jsonl`. The VTA Wallet management flow above performs the complete mint +and publish operation. + +The wallet UI currently lets the hosting server assign the DID path and choose +its configured/default domain. When an exact path such as `vtafarm-auth` or an +explicit domain is required, use PNM instead: + +```bash +pnm did-mgmt dids create \ + --context vtafarm-auth \ + --server primary \ + --domain '' \ + --path vtafarm-auth \ + --label 'VTA Farm SIOP relying party' \ + --pre-rotation 1 +``` + +Put the returned DID in the environment-specific Helm values used on every +production deployment: + +```yaml +siop: + rpDID: "did:webvh:::vtafarm-auth" +``` + +The chart renders this public identifier into the vtafarm-api ConfigMap as +`SIOP_RP_DID`; it is not a secret. Apply that values file with the normal Helm +upgrade, then restart the deployment because environment variables sourced +from a ConfigMap are read when the pod starts: + +```bash +VTAFARM_NAMESPACE=default +VTAFARM_PROD_VALUES=/path/to/production-values.yaml + +helm upgrade vtafarm-api ./helm/vtafarm-api \ + --install \ + --namespace "$VTAFARM_NAMESPACE" \ + --values "$VTAFARM_PROD_VALUES" \ + --atomic \ + --timeout 10m + +kubectl --namespace "$VTAFARM_NAMESPACE" rollout restart deployment/vtafarm-api +kubectl --namespace "$VTAFARM_NAMESPACE" rollout status deployment/vtafarm-api +``` + +Finally, fetch the returned DID's `did.jsonl` over public HTTPS from outside +the cluster, then verify the deployed API metadata: + +```bash +curl 'https:///vtafarm-auth/did.jsonl' +curl 'https:///api/v1/auth/siop/metadata' +``` + +The metadata response must contain `"enabled":true` and the same `rp_did`. +Only the DID string goes into `SIOP_RP_DID`; no RP private key is copied into +vtafarm-api. + +### Generating JWT_SECRET ```bash openssl rand -base64 32 diff --git a/docs/siop-browser-testing.md b/docs/siop-browser-testing.md new file mode 100644 index 0000000..33fb7e5 --- /dev/null +++ b/docs/siop-browser-testing.md @@ -0,0 +1,57 @@ +# VTA Wallet SIOP browser test + +VTA Wallet login is disabled until `SIOP_RP_DID` is set. Production must use +the dedicated VTA Farm RP `did:webvh` whose history resolves over public HTTPS. +Do not reuse `DID_HOSTING_DID`. + +For a local browser-contract test, a public `did:key` can be used as the +audience because the RP does not sign with it. This exercises wallet discovery, +challenge persistence, VTA token minting, persona verification, account +linking, and the role-matched cookie. It is not a substitute for the production +`did:webvh` consent and resolution check. + +## Start locally + +With the database tunnel running, start the API with a dev-only RP DID: + +```bash +SIOP_RP_DID=did:key:z6MkkVc5EPGcCa3ZWB5i2YGX7BnLBm8vgf1qUwqTb9i87wLj +make dev +``` + +Start the frontend in its repository: + +```bash +pnpm dev +``` + +Open `http://localhost:5173/login`. The API metadata can be checked without a +session: + +```bash +curl http://localhost:8080/api/v1/auth/siop/metadata +``` + +It should return `enabled: true`, and Chrome should show **Continue with VTA +Wallet** when the extension exposes both `walletProfile` and `proxyLogin`. + +## User flow + +1. Sign in with the existing passkey. +2. Open **Settings → VTA Wallet identities**. +3. Select **Link**, choose a wallet persona, and approve the one-time assertion. +4. Confirm the identity appears in the list, then log out. +5. On `/login`, select **Continue with VTA Wallet** and approve the assertion. +6. Confirm the browser reaches `/portal` using the `vtafarm_user` httpOnly + cookie. No SIOP token should appear in local or session storage. +7. Replay and wrong-persona attempts must fail. Unlink the identity and confirm + wallet login then reports that it is not linked. + +## Admin flow + +Repeat the same sequence at **Admin → Security** and `/admin/login`. The admin +flow uses only admin routes and sets only `vtafarm_admin`; linking a user +identity does not authorize the admin route. + +An account with a linked VTA Wallet identity cannot delete its final passkey. +Unlink every wallet identity first if the passkey must be removed. diff --git a/helm/vtafarm-api/templates/vtafarm-api/configmap.yaml b/helm/vtafarm-api/templates/vtafarm-api/configmap.yaml index 1e4eb0e..0f92bb3 100644 --- a/helm/vtafarm-api/templates/vtafarm-api/configmap.yaml +++ b/helm/vtafarm-api/templates/vtafarm-api/configmap.yaml @@ -35,4 +35,9 @@ data: WEBAUTHN_RP_ID: {{ .Values.webauthn.rpID | default (include "app.frontendHost" .) | quote }} WEBAUTHN_RP_ORIGINS: {{ .Values.webauthn.rpOrigins | default (include "app.frontendOrigin" .) | quote }} WEBAUTHN_RP_DISPLAY_NAME: {{ .Values.webauthn.rpDisplayName | quote }} + SIOP_RP_DID: {{ .Values.siop.rpDID | quote }} + SIOP_CHALLENGE_TTL_SECONDS: {{ .Values.siop.challengeTTLSeconds | quote }} + SIOP_CLOCK_SKEW_SECONDS: {{ .Values.siop.clockSkewSeconds | quote }} + SIOP_DID_RESOLUTION_TIMEOUT_SECONDS: {{ .Values.siop.didResolutionTimeoutSeconds | quote }} + SIOP_MAX_BODY_BYTES: {{ .Values.siop.maxBodyBytes | quote }} CORS_ALLOWED_ORIGINS: {{ .Values.cors.allowedOrigins | default (include "app.frontendOrigin" .) | quote }} diff --git a/helm/vtafarm-api/values.yaml b/helm/vtafarm-api/values.yaml index 6b6cffa..18f20e4 100644 --- a/helm/vtafarm-api/values.yaml +++ b/helm/vtafarm-api/values.yaml @@ -93,6 +93,15 @@ webauthn: rpID: "" rpOrigins: "" +# Optional VTA Wallet SIOPv2 login. rpDID must be a dedicated public RP DID; +# empty keeps metadata disabled and all SIOP routes fail closed. +siop: + rpDID: "" + challengeTTLSeconds: "120" + clockSkewSeconds: "60" + didResolutionTimeoutSeconds: "5" + maxBodyBytes: "70000" + # Browser origins allowed to call the API with credentials. Defaults to the # frontend's; the localhost dev servers are always allowed on top of it. cors: diff --git a/internal/apidocs/openapi.yaml b/internal/apidocs/openapi.yaml index 5fd8549..cefc987 100644 --- a/internal/apidocs/openapi.yaml +++ b/internal/apidocs/openapi.yaml @@ -8,7 +8,7 @@ info: ## Authentication - All accounts use **passkey-only** authentication. There are no passwords. + All accounts use passkeys, with optional linked **VTA Wallet SIOPv2** login. There are no passwords. Protected endpoints use **httpOnly cookies** set at login. Cookie `vtafarm_user` is required for User endpoints; `vtafarm_admin` for Admin endpoints. @@ -73,6 +73,10 @@ tags: 1. `POST /user/passkeys/register/begin` — receive WebAuthn creation options 2. Pass `options.publicKey` to `startRegistration()` 3. `POST /user/passkeys/register/complete?name=` — save passkey + - name: VTA Wallet + description: | + Optional SIOPv2 login and identity linking. Linking requires an existing + role-matched cookie and at least one registered passkey. components: securitySchemes: @@ -231,6 +235,60 @@ components: type: string enum: [admin, user] + SIOPMetadata: + type: object + required: [enabled, rp_did] + properties: + enabled: { type: boolean } + rp_did: { type: string, description: Dedicated relying-party DID; empty when disabled } + + SIOPChallengeRequest: + type: object + additionalProperties: false + required: [did] + properties: + did: { type: string, maxLength: 2048 } + + SIOPChallengeResponse: + type: object + required: [challenge, session_id, expires_at] + properties: + challenge: { type: string, description: One-time nonce passed to wallet.proxyLogin } + session_id: { type: string } + expires_at: { type: string, format: date-time } + + SIOPAuthenticateRequest: + type: object + additionalProperties: false + required: [id_token, session_id] + properties: + id_token: { type: string, description: Compact SIOPv2 JWS minted by the VTA Wallet } + session_id: { type: string } + label: { type: string, maxLength: 80, description: Link flow only } + + SIOPLoginResponse: + type: object + required: [user] + properties: + user: + type: object + required: [id, unique_id, role] + properties: + id: { type: integer } + unique_id: { type: string } + role: { type: string, enum: [admin, user] } + + SIOPIdentity: + type: object + required: [id, did, label, created_at, last_kid] + properties: + id: { type: integer } + did: { type: string } + label: { type: string } + created_at: { type: string, format: date-time } + last_authenticated_at: { type: [string, "null"], format: date-time } + last_kid: { type: string } + ValidateResponse: type: object properties: @@ -813,6 +871,153 @@ components: left open. paths: + /api/v1/auth/siop/metadata: + get: + summary: Discover VTA Wallet login configuration + tags: [VTA Wallet] + responses: + "200": + description: Feature state and RP audience DID + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPMetadata" } } } + + /api/v1/auth/user/siop/challenge: + post: + summary: "(public) Begin user VTA Wallet login" + tags: [VTA Wallet, Auth — User] + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPChallengeRequest" } } } + responses: + "200": { description: One-time challenge, content: { application/json: { schema: { $ref: "#/components/schemas/SIOPChallengeResponse" } } } } + "400": { description: Invalid DID, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + "503": { description: SIOP disabled, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/auth/user/siop/authenticate: + post: + summary: "(public) Complete user VTA Wallet login" + description: Consumes the challenge once and sets only the vtafarm_user httpOnly cookie. + tags: [VTA Wallet, Auth — User] + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPAuthenticateRequest" } } } + responses: + "200": { description: User login successful, content: { application/json: { schema: { $ref: "#/components/schemas/SIOPLoginResponse" } } } } + "401": { description: Challenge, assertion, or user link rejected, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/auth/admin/siop/challenge: + post: + summary: "(public) Begin admin VTA Wallet login" + tags: [VTA Wallet, Auth — Admin] + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPChallengeRequest" } } } + responses: + "200": { description: One-time challenge, content: { application/json: { schema: { $ref: "#/components/schemas/SIOPChallengeResponse" } } } } + "400": { description: Invalid DID, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + "503": { description: SIOP disabled, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/auth/admin/siop/authenticate: + post: + summary: "(public) Complete admin VTA Wallet login" + description: Consumes the challenge once and sets only the vtafarm_admin httpOnly cookie. + tags: [VTA Wallet, Auth — Admin] + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPAuthenticateRequest" } } } + responses: + "200": { description: Admin login successful, content: { application/json: { schema: { $ref: "#/components/schemas/SIOPLoginResponse" } } } } + "401": { description: Challenge, assertion, or admin link rejected, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/user/siop/link/challenge: + post: + summary: Begin linking a user VTA Wallet identity + tags: [VTA Wallet] + security: [{ CookieAuthUser: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPChallengeRequest" } } } + responses: + "200": { description: One-time link challenge, content: { application/json: { schema: { $ref: "#/components/schemas/SIOPChallengeResponse" } } } } + "409": { description: Account has no passkey, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/user/siop/link/authenticate: + post: + summary: Complete linking a user VTA Wallet identity + tags: [VTA Wallet] + security: [{ CookieAuthUser: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPAuthenticateRequest" } } } + responses: + "201": { description: Identity linked, content: { application/json: { schema: { $ref: "#/components/schemas/SIOPIdentity" } } } } + "409": { description: Identity already linked or account has no passkey, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/user/siop/identities: + get: + summary: List linked user VTA Wallet identities + tags: [VTA Wallet] + security: [{ CookieAuthUser: [] }] + responses: + "200": + description: Caller-owned identities + content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/SIOPIdentity" } } } } + + /api/v1/user/siop/identities/{id}: + delete: + summary: Unlink a user VTA Wallet identity + tags: [VTA Wallet] + security: [{ CookieAuthUser: [] }] + parameters: [{ name: id, in: path, required: true, schema: { type: integer } }] + responses: + "204": { description: Identity unlinked } + "404": { description: Identity not owned by caller, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/admin/siop/link/challenge: + post: + summary: Begin linking an admin VTA Wallet identity + tags: [VTA Wallet] + security: [{ CookieAuthAdmin: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPChallengeRequest" } } } + responses: + "200": { description: One-time link challenge, content: { application/json: { schema: { $ref: "#/components/schemas/SIOPChallengeResponse" } } } } + "409": { description: Account has no passkey, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/admin/siop/link/authenticate: + post: + summary: Complete linking an admin VTA Wallet identity + tags: [VTA Wallet] + security: [{ CookieAuthAdmin: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/SIOPAuthenticateRequest" } } } + responses: + "201": { description: Identity linked, content: { application/json: { schema: { $ref: "#/components/schemas/SIOPIdentity" } } } } + "409": { description: Identity already linked or account has no passkey, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + + /api/v1/admin/siop/identities: + get: + summary: List linked admin VTA Wallet identities + tags: [VTA Wallet] + security: [{ CookieAuthAdmin: [] }] + responses: + "200": + description: Caller-owned identities + content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/SIOPIdentity" } } } } + + /api/v1/admin/siop/identities/{id}: + delete: + summary: Unlink an admin VTA Wallet identity + tags: [VTA Wallet] + security: [{ CookieAuthAdmin: [] }] + parameters: [{ name: id, in: path, required: true, schema: { type: integer } }] + responses: + "204": { description: Identity unlinked } + "404": { description: Identity not owned by caller, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } + /health: get: summary: Health check diff --git a/internal/config/config.go b/internal/config/config.go index 7329e3b..3153cc0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "strings" + "time" ) type Config struct { @@ -42,10 +43,19 @@ type Config struct { GHCR GHCRConfig DidHosting DidHostingConfig WebAuthn WebAuthnConfig + SIOP SIOPConfig Vault VaultConfig Monitor MonitorConfig } +type SIOPConfig struct { + RPDID string + ChallengeTTL time.Duration + ClockSkew time.Duration + ResolutionTimeout time.Duration + MaxBodyBytes int64 +} + // MonitorConfig configures the token-gated /api/v1/monitor/* endpoints polled // by an external uptime service (UptimeRobot). Empty Token disables them. type MonitorConfig struct { @@ -213,6 +223,13 @@ func Load() *Config { RPOrigins: splitComma(getEnv("WEBAUTHN_RP_ORIGINS", "http://localhost:5173")), RPDisplayName: getEnv("WEBAUTHN_RP_DISPLAY_NAME", "VTA Farm"), }, + SIOP: SIOPConfig{ + RPDID: getEnv("SIOP_RP_DID", ""), + ChallengeTTL: time.Duration(getEnvInt("SIOP_CHALLENGE_TTL_SECONDS", 120)) * time.Second, + ClockSkew: time.Duration(getEnvInt("SIOP_CLOCK_SKEW_SECONDS", 60)) * time.Second, + ResolutionTimeout: time.Duration(getEnvInt("SIOP_DID_RESOLUTION_TIMEOUT_SECONDS", 5)) * time.Second, + MaxBodyBytes: int64(getEnvInt("SIOP_MAX_BODY_BYTES", 70000)), + }, Monitor: MonitorConfig{ Token: getEnv("MONITOR_TOKEN", ""), CPUPercent: getEnvInt("MONITOR_CPU_PCT", 90), diff --git a/internal/handler/passkey.go b/internal/handler/passkey.go index 0f3bd82..ad46a38 100644 --- a/internal/handler/passkey.go +++ b/internal/handler/passkey.go @@ -3,6 +3,7 @@ package handler import ( "encoding/binary" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -13,6 +14,7 @@ import ( "github.com/go-webauthn/webauthn/webauthn" "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/ic3software/vtafarm-api/internal/middleware" "github.com/ic3software/vtafarm-api/internal/model" @@ -256,23 +258,63 @@ func (h *PasskeyHandler) Delete(c *gin.Context) { return } - var res *gorm.DB - if role == model.RoleAdmin { - res = h.db.Where("id = ? AND admin_id = ?", pkID, uid).Delete(&model.AdminPasskey{}) - } else { - res = h.db.Where("id = ? AND user_id = ?", pkID, uid).Delete(&model.UserPasskey{}) - } - if res.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "could not delete passkey"}) + err = h.db.Transaction(func(tx *gorm.DB) error { + var passkeyCount, identityCount int64 + if role == model.RoleAdmin { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&model.Admin{}, uid).Error; err != nil { + return err + } + var passkey model.AdminPasskey + if err := tx.Where("id = ? AND admin_id = ?", pkID, uid).First(&passkey).Error; err != nil { + return err + } + if err := tx.Model(&model.AdminPasskey{}).Where("admin_id = ?", uid).Count(&passkeyCount).Error; err != nil { + return err + } + if err := tx.Model(&model.AdminSIOPIdentity{}).Where("admin_id = ?", uid).Count(&identityCount).Error; err != nil { + return err + } + if identityCount > 0 && passkeyCount <= 1 { + return errSIOPRequiresPasskey + } + return tx.Delete(&passkey).Error + } + + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&model.User{}, uid).Error; err != nil { + return err + } + var passkey model.UserPasskey + if err := tx.Where("id = ? AND user_id = ?", pkID, uid).First(&passkey).Error; err != nil { + return err + } + if err := tx.Model(&model.UserPasskey{}).Where("user_id = ?", uid).Count(&passkeyCount).Error; err != nil { + return err + } + if err := tx.Model(&model.UserSIOPIdentity{}).Where("user_id = ?", uid).Count(&identityCount).Error; err != nil { + return err + } + if identityCount > 0 && passkeyCount <= 1 { + return errSIOPRequiresPasskey + } + return tx.Delete(&passkey).Error + }) + if errors.Is(err, errSIOPRequiresPasskey) { + c.JSON(http.StatusConflict, gin.H{"error": "unlink VTA Wallet identities before removing the last passkey"}) return } - if res.RowsAffected == 0 { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "passkey not found"}) return } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not delete passkey"}) + return + } c.Status(http.StatusNoContent) } +var errSIOPRequiresPasskey = errors.New("SIOP-linked account must retain a passkey") + // loginBegin is the shared implementation for admin and user passkey login begin. func (h *PasskeyHandler) loginBegin(c *gin.Context) { opts, session, err := h.wa.BeginDiscoverableLogin() diff --git a/internal/handler/siop.go b/internal/handler/siop.go new file mode 100644 index 0000000..29306ad --- /dev/null +++ b/internal/handler/siop.go @@ -0,0 +1,486 @@ +package handler + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/ic3software/vtafarm-api/internal/middleware" + "github.com/ic3software/vtafarm-api/internal/model" + "github.com/ic3software/vtafarm-api/internal/siop" +) + +const maxPendingSIOPChallengesPerDID = 5 + +type SIOPHandlerConfig struct { + RPDID string + ChallengeTTL time.Duration + ClockSkew time.Duration + MaxBodyBytes int64 +} + +type SIOPHandler struct { + db *gorm.DB + resolver siop.AuthenticationKeyResolver + rpDID string + challengeTTL time.Duration + clockSkew time.Duration + maxBodyBytes int64 + jwtSecret string + cookieSecure bool + now func() time.Time +} + +func NewSIOPHandler( + db *gorm.DB, + resolver siop.AuthenticationKeyResolver, + cfg SIOPHandlerConfig, + jwtSecret string, + cookieSecure bool, +) *SIOPHandler { + return &SIOPHandler{ + db: db, resolver: resolver, rpDID: strings.TrimSpace(cfg.RPDID), + challengeTTL: cfg.ChallengeTTL, clockSkew: cfg.ClockSkew, + maxBodyBytes: cfg.MaxBodyBytes, jwtSecret: jwtSecret, + cookieSecure: cookieSecure, now: time.Now, + } +} + +func (h *SIOPHandler) enabled() bool { + return h != nil && h.db != nil && h.resolver != nil && h.rpDID != "" && + h.challengeTTL > 0 && h.clockSkew >= 0 && h.maxBodyBytes > 0 +} + +func (h *SIOPHandler) Metadata(c *gin.Context) { + c.Header("Cache-Control", "no-store") + if !h.enabled() { + c.JSON(http.StatusOK, gin.H{"enabled": false, "rp_did": ""}) + return + } + c.JSON(http.StatusOK, gin.H{"enabled": true, "rp_did": h.rpDID}) +} + +func (h *SIOPHandler) UserLoginChallenge(c *gin.Context) { + h.createChallenge(c, model.RoleUser, model.SIOPPurposeLogin) +} + +func (h *SIOPHandler) AdminLoginChallenge(c *gin.Context) { + h.createChallenge(c, model.RoleAdmin, model.SIOPPurposeLogin) +} + +func (h *SIOPHandler) UserLinkChallenge(c *gin.Context) { + h.createChallenge(c, model.RoleUser, model.SIOPPurposeLink) +} + +func (h *SIOPHandler) AdminLinkChallenge(c *gin.Context) { + h.createChallenge(c, model.RoleAdmin, model.SIOPPurposeLink) +} + +type siopChallengeRequest struct { + DID string `json:"did"` +} + +func (h *SIOPHandler) createChallenge(c *gin.Context, role, purpose string) { + if !h.prepare(c) { + return + } + var request siopChallengeRequest + if err := h.decodeJSON(c, &request); err != nil || !validSIOPDID(request.DID) { + h.respondError(c, http.StatusBadRequest, "invalid DID") + return + } + + challenge := model.SIOPChallenge{ + Purpose: purpose, AccountRole: role, ExpectedDID: request.DID, + ExpiresAt: h.now().Add(h.challengeTTL), + } + if purpose == model.SIOPPurposeLink { + accountID, contextRole := contextIDAndRole(c) + if contextRole != role { + h.respondError(c, http.StatusForbidden, "forbidden") + return + } + if !h.accountHasPasskey(accountID, role) { + h.respondError(c, http.StatusConflict, "register a passkey before linking a VTA Wallet identity") + return + } + if role == model.RoleAdmin { + challenge.AdminID = &accountID + } else { + challenge.UserID = &accountID + } + } + + nonce, err := randomOpaqueValue(32) + if err != nil { + h.respondError(c, http.StatusInternalServerError, "could not create challenge") + return + } + sessionID, err := randomOpaqueValue(32) + if err != nil { + h.respondError(c, http.StatusInternalServerError, "could not create challenge") + return + } + digest := sha256.Sum256([]byte(nonce)) + challenge.ID = sessionID + challenge.NonceSHA256 = digest[:] + + err = h.db.Transaction(func(tx *gorm.DB) error { + now := h.now() + if err := tx.Where("expires_at <= ?", now).Delete(&model.SIOPChallenge{}).Error; err != nil { + return err + } + var pending int64 + if err := tx.Model(&model.SIOPChallenge{}). + Where("account_role = ? AND expected_did = ? AND expires_at > ?", role, request.DID, now). + Count(&pending).Error; err != nil { + return err + } + if pending >= maxPendingSIOPChallengesPerDID { + return errTooManyPendingChallenges + } + return tx.Create(&challenge).Error + }) + if errors.Is(err, errTooManyPendingChallenges) { + h.respondError(c, http.StatusTooManyRequests, "too many pending challenges") + return + } + if err != nil { + h.respondError(c, http.StatusInternalServerError, "could not create challenge") + return + } + + log.Printf("siop challenge issued role=%s purpose=%s did_hash=%s", role, purpose, didAuditHash(request.DID)) + c.JSON(http.StatusOK, gin.H{ + "challenge": nonce, + "session_id": sessionID, + "expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339), + }) +} + +var errTooManyPendingChallenges = errors.New("too many pending SIOP challenges") + +type siopAuthenticateRequest struct { + IDToken string `json:"id_token"` + SessionID string `json:"session_id"` + Label string `json:"label,omitempty"` +} + +func (h *SIOPHandler) UserLoginAuthenticate(c *gin.Context) { + h.authenticate(c, model.RoleUser, model.SIOPPurposeLogin) +} + +func (h *SIOPHandler) AdminLoginAuthenticate(c *gin.Context) { + h.authenticate(c, model.RoleAdmin, model.SIOPPurposeLogin) +} + +func (h *SIOPHandler) UserLinkAuthenticate(c *gin.Context) { + h.authenticate(c, model.RoleUser, model.SIOPPurposeLink) +} + +func (h *SIOPHandler) AdminLinkAuthenticate(c *gin.Context) { + h.authenticate(c, model.RoleAdmin, model.SIOPPurposeLink) +} + +func (h *SIOPHandler) authenticate(c *gin.Context, role, purpose string) { + if !h.prepare(c) { + return + } + var request siopAuthenticateRequest + if err := h.decodeJSON(c, &request); err != nil || request.IDToken == "" || request.SessionID == "" || len(request.Label) > 80 { + h.respondError(c, http.StatusBadRequest, "invalid authentication request") + return + } + + challenge, err := h.consumeChallenge(request.SessionID) + if err != nil || challenge.AccountRole != role || challenge.Purpose != purpose { + h.respondError(c, http.StatusUnauthorized, "SIOP challenge is invalid or expired") + return + } + if purpose == model.SIOPPurposeLink { + accountID, contextRole := contextIDAndRole(c) + if contextRole != role || !challengeBelongsTo(challenge, accountID, role) { + h.respondError(c, http.StatusUnauthorized, "SIOP challenge is invalid or expired") + return + } + } + + nonce, err := siop.UnverifiedNonce(request.IDToken) + if err != nil || !nonceMatchesDigest(nonce, challenge.NonceSHA256) { + h.auditFailure(role, purpose, challenge.ExpectedDID, siop.ErrorNonceMismatch) + h.respondError(c, http.StatusUnauthorized, "SIOP verification failed") + return + } + verified, err := siop.VerifyIDTokenAt( + c.Request.Context(), request.IDToken, challenge.ExpectedDID, h.rpDID, + nonce, h.resolver, h.now(), h.clockSkew, + ) + if err != nil { + h.auditFailure(role, purpose, challenge.ExpectedDID, siop.ErrorCodeOf(err)) + h.respondError(c, http.StatusUnauthorized, "SIOP verification failed") + return + } + + if purpose == model.SIOPPurposeLink { + h.linkIdentity(c, challenge, verified, strings.TrimSpace(request.Label)) + return + } + h.loginIdentity(c, role, verified) +} + +func (h *SIOPHandler) consumeChallenge(id string) (model.SIOPChallenge, error) { + var challenge model.SIOPChallenge + result := h.db.Raw( + "DELETE FROM siop_challenges WHERE id = ? AND expires_at > ? RETURNING *", + id, h.now(), + ).Scan(&challenge) + if result.Error != nil { + return challenge, result.Error + } + if result.RowsAffected != 1 || challenge.ID == "" { + return challenge, gorm.ErrRecordNotFound + } + return challenge, nil +} + +func (h *SIOPHandler) loginIdentity(c *gin.Context, role string, verified siop.VerifiedIDToken) { + now := h.now() + var accountID uint + var uniqueID string + if role == model.RoleAdmin { + var identity model.AdminSIOPIdentity + if err := h.db.Where("did = ?", verified.Subject).First(&identity).Error; err != nil { + h.respondError(c, http.StatusUnauthorized, "this VTA Wallet identity is not linked to an admin account") + return + } + var account model.Admin + if err := h.db.First(&account, identity.AdminID).Error; err != nil { + h.respondError(c, http.StatusUnauthorized, "SIOP authentication failed") + return + } + accountID, uniqueID = account.ID, account.UniqueId + h.db.Model(&identity).Updates(map[string]any{"last_authenticated_at": now, "last_kid": verified.Kid}) + } else { + var identity model.UserSIOPIdentity + if err := h.db.Where("did = ?", verified.Subject).First(&identity).Error; err != nil { + h.respondError(c, http.StatusUnauthorized, "this VTA Wallet identity is not linked to a user account") + return + } + var account model.User + if err := h.db.First(&account, identity.UserID).Error; err != nil { + h.respondError(c, http.StatusUnauthorized, "SIOP authentication failed") + return + } + accountID, uniqueID = account.ID, account.UniqueId + h.db.Model(&identity).Updates(map[string]any{"last_authenticated_at": now, "last_kid": verified.Kid}) + } + + token, err := middleware.GenerateToken(accountID, role, h.jwtSecret) + if err != nil { + h.respondError(c, http.StatusInternalServerError, "could not create login session") + return + } + h.setCookie(c, role, token) + log.Printf("siop authentication succeeded role=%s did_hash=%s", role, didAuditHash(verified.Subject)) + c.JSON(http.StatusOK, gin.H{"user": gin.H{"id": accountID, "unique_id": uniqueID, "role": role}}) +} + +func (h *SIOPHandler) linkIdentity(c *gin.Context, challenge model.SIOPChallenge, verified siop.VerifiedIDToken, label string) { + accountID, _ := contextIDAndRole(c) + if !h.accountHasPasskey(accountID, challenge.AccountRole) { + h.respondError(c, http.StatusConflict, "register a passkey before linking a VTA Wallet identity") + return + } + if label == "" { + label = "VTA Wallet" + } + + var value any + if challenge.AccountRole == model.RoleAdmin { + value = &model.AdminSIOPIdentity{AdminID: accountID, DID: verified.Subject, Label: label, LastKID: verified.Kid} + } else { + value = &model.UserSIOPIdentity{UserID: accountID, DID: verified.Subject, Label: label, LastKID: verified.Kid} + } + if err := h.db.Create(value).Error; err != nil { + constraint := "user_siop_identities_did_unique" + if challenge.AccountRole == model.RoleAdmin { + constraint = "admin_siop_identities_did_unique" + } + if isUniqueViolation(err, constraint) { + h.respondError(c, http.StatusConflict, "this VTA Wallet identity is already linked") + } else { + h.respondError(c, http.StatusInternalServerError, "could not link VTA Wallet identity") + } + return + } + log.Printf("siop identity linked role=%s did_hash=%s", challenge.AccountRole, didAuditHash(verified.Subject)) + c.JSON(http.StatusCreated, value) +} + +func (h *SIOPHandler) UserIdentities(c *gin.Context) { h.listIdentities(c, model.RoleUser) } +func (h *SIOPHandler) AdminIdentities(c *gin.Context) { h.listIdentities(c, model.RoleAdmin) } + +func (h *SIOPHandler) listIdentities(c *gin.Context, role string) { + if !h.prepare(c) { + return + } + accountID, contextRole := contextIDAndRole(c) + if contextRole != role { + h.respondError(c, http.StatusForbidden, "forbidden") + return + } + if role == model.RoleAdmin { + var identities []model.AdminSIOPIdentity + if err := h.db.Where("admin_id = ?", accountID).Order("created_at asc").Find(&identities).Error; err != nil { + h.respondError(c, http.StatusInternalServerError, "could not fetch VTA Wallet identities") + return + } + c.JSON(http.StatusOK, identities) + return + } + var identities []model.UserSIOPIdentity + if err := h.db.Where("user_id = ?", accountID).Order("created_at asc").Find(&identities).Error; err != nil { + h.respondError(c, http.StatusInternalServerError, "could not fetch VTA Wallet identities") + return + } + c.JSON(http.StatusOK, identities) +} + +func (h *SIOPHandler) DeleteUserIdentity(c *gin.Context) { + h.deleteIdentity(c, model.RoleUser) +} + +func (h *SIOPHandler) DeleteAdminIdentity(c *gin.Context) { + h.deleteIdentity(c, model.RoleAdmin) +} + +func (h *SIOPHandler) deleteIdentity(c *gin.Context, role string) { + if !h.prepare(c) { + return + } + identityID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + h.respondError(c, http.StatusBadRequest, "invalid identity id") + return + } + accountID, contextRole := contextIDAndRole(c) + if contextRole != role { + h.respondError(c, http.StatusForbidden, "forbidden") + return + } + var result *gorm.DB + if role == model.RoleAdmin { + result = h.db.Where("id = ? AND admin_id = ?", identityID, accountID).Delete(&model.AdminSIOPIdentity{}) + } else { + result = h.db.Where("id = ? AND user_id = ?", identityID, accountID).Delete(&model.UserSIOPIdentity{}) + } + if result.Error != nil { + h.respondError(c, http.StatusInternalServerError, "could not unlink VTA Wallet identity") + return + } + if result.RowsAffected == 0 { + h.respondError(c, http.StatusNotFound, "VTA Wallet identity not found") + return + } + log.Printf("siop identity unlinked role=%s identity_id=%d", role, identityID) + c.Status(http.StatusNoContent) +} + +func (h *SIOPHandler) accountHasPasskey(accountID uint, role string) bool { + var count int64 + query := h.db + if role == model.RoleAdmin { + query = query.Model(&model.AdminPasskey{}).Where("admin_id = ?", accountID) + } else { + query = query.Model(&model.UserPasskey{}).Where("user_id = ?", accountID) + } + return query.Count(&count).Error == nil && count > 0 +} + +func (h *SIOPHandler) prepare(c *gin.Context) bool { + c.Header("Cache-Control", "no-store") + if !h.enabled() { + h.respondError(c, http.StatusServiceUnavailable, "VTA Wallet login is not enabled") + return false + } + return true +} + +func (h *SIOPHandler) decodeJSON(c *gin.Context, destination any) error { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxBodyBytes) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("request body must contain one JSON object") + } + return nil +} + +func (h *SIOPHandler) respondError(c *gin.Context, status int, message string) { + c.Header("Cache-Control", "no-store") + c.JSON(status, gin.H{"error": message}) +} + +func (h *SIOPHandler) setCookie(c *gin.Context, role, token string) { + c.SetSameSite(http.SameSiteStrictMode) + name := middleware.CookieUser + if role == model.RoleAdmin { + name = middleware.CookieAdmin + } + c.SetCookie(name, token, cookieMaxAge, "/", "", h.cookieSecure, true) +} + +func (h *SIOPHandler) auditFailure(role, purpose, did string, code siop.ErrorCode) { + log.Printf("siop verification failed role=%s purpose=%s did_hash=%s reason=%s", role, purpose, didAuditHash(did), code) +} + +func challengeBelongsTo(challenge model.SIOPChallenge, accountID uint, role string) bool { + if role == model.RoleAdmin { + return challenge.AdminID != nil && *challenge.AdminID == accountID && challenge.UserID == nil + } + return challenge.UserID != nil && *challenge.UserID == accountID && challenge.AdminID == nil +} + +func nonceMatchesDigest(nonce string, expected []byte) bool { + if nonce == "" || len(expected) != sha256.Size { + return false + } + digest := sha256.Sum256([]byte(nonce)) + return subtle.ConstantTimeCompare(digest[:], expected) == 1 +} + +func randomOpaqueValue(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func validSIOPDID(did string) bool { + if did == "" || len(did) > 2048 || strings.TrimSpace(did) != did || strings.ContainsAny(did, " \t\r\n?#") { + return false + } + return strings.HasPrefix(did, "did:key:") || strings.HasPrefix(did, "did:webvh:") +} + +func didAuditHash(did string) string { + digest := sha256.Sum256([]byte(did)) + return hex.EncodeToString(digest[:6]) +} diff --git a/internal/handler/siop_test.go b/internal/handler/siop_test.go new file mode 100644 index 0000000..49379d3 --- /dev/null +++ b/internal/handler/siop_test.go @@ -0,0 +1,86 @@ +package handler + +import ( + "crypto/sha256" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/ic3software/vtafarm-api/internal/model" +) + +func TestValidSIOPDID(t *testing.T) { + tests := []struct { + did string + want bool + }{ + {"did:key:z6MkExample", true}, + {"did:webvh:QmExample:identity.example", true}, + {"did:peer:2.EzExample", false}, + {"did:webvh:QmExample:localhost#key-1", false}, + {" did:key:z6MkExample", false}, + {"https://identity.example", false}, + {strings.Repeat("a", 2049), false}, + } + for _, test := range tests { + if got := validSIOPDID(test.did); got != test.want { + t.Errorf("validSIOPDID(%q) = %v, want %v", test.did, got, test.want) + } + } +} + +func TestNonceMatchesDigest(t *testing.T) { + digest := sha256.Sum256([]byte("challenge")) + if !nonceMatchesDigest("challenge", digest[:]) { + t.Fatal("matching nonce was rejected") + } + if nonceMatchesDigest("different", digest[:]) { + t.Fatal("different nonce was accepted") + } + if nonceMatchesDigest("challenge", digest[:8]) { + t.Fatal("short digest was accepted") + } +} + +func TestChallengeBelongsToRoleMatchedAccount(t *testing.T) { + userID, adminID := uint(7), uint(9) + if !challengeBelongsTo(model.SIOPChallenge{UserID: &userID}, userID, model.RoleUser) { + t.Fatal("user challenge did not match its user") + } + if challengeBelongsTo(model.SIOPChallenge{UserID: &userID}, adminID, model.RoleAdmin) { + t.Fatal("user challenge crossed into admin role") + } + if challengeBelongsTo(model.SIOPChallenge{AdminID: &adminID}, userID, model.RoleUser) { + t.Fatal("admin challenge crossed into user role") + } +} + +func TestSIOPMetadataFailsClosed(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/siop/metadata", nil) + + (&SIOPHandler{}).Metadata(context) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", recorder.Code) + } + if recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", recorder.Header().Get("Cache-Control")) + } + var body struct { + Enabled bool `json:"enabled"` + RPDID string `json:"rp_did"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Enabled || body.RPDID != "" { + t.Fatalf("metadata = %+v, want disabled without RP configuration", body) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 1413e8d..1949dde 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -52,6 +52,7 @@ func AuthRequired(secret, cookieName string) gin.HandlerFunc { } if tokenStr == "" { + c.Header("Cache-Control", "no-store") c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid token"}) return } @@ -64,6 +65,7 @@ func AuthRequired(secret, cookieName string) gin.HandlerFunc { return []byte(secret), nil }) if err != nil || !token.Valid { + c.Header("Cache-Control", "no-store") c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index db919ca..7e566bd 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -8,6 +8,13 @@ import ( "github.com/gin-gonic/gin" ) +func NoStore() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Next() + } +} + // RateLimit allows at most max requests per window per client IP. In-memory // sliding window — per replica, which is fine for the single-replica API; it // exists to blunt bulk abuse of public endpoints, not to be exact accounting. diff --git a/internal/model/siop_identity.go b/internal/model/siop_identity.go new file mode 100644 index 0000000..0127fe6 --- /dev/null +++ b/internal/model/siop_identity.go @@ -0,0 +1,40 @@ +package model + +import "time" + +const ( + SIOPPurposeLogin = "login" + SIOPPurposeLink = "link" +) + +type UserSIOPIdentity struct { + ID uint64 `json:"id"` + UserID uint `json:"-"` + DID string `json:"did" gorm:"column:did"` + Label string `json:"label"` + CreatedAt time.Time `json:"created_at"` + LastAuthenticatedAt *time.Time `json:"last_authenticated_at"` + LastKID string `json:"last_kid" gorm:"column:last_kid"` +} + +type AdminSIOPIdentity struct { + ID uint64 `json:"id"` + AdminID uint `json:"-"` + DID string `json:"did" gorm:"column:did"` + Label string `json:"label"` + CreatedAt time.Time `json:"created_at"` + LastAuthenticatedAt *time.Time `json:"last_authenticated_at"` + LastKID string `json:"last_kid" gorm:"column:last_kid"` +} + +type SIOPChallenge struct { + ID string + Purpose string + AccountRole string + ExpectedDID string `gorm:"column:expected_did"` + UserID *uint + AdminID *uint + NonceSHA256 []byte `gorm:"column:nonce_sha256"` + ExpiresAt time.Time + CreatedAt time.Time +} diff --git a/internal/model/siop_identity_test.go b/internal/model/siop_identity_test.go new file mode 100644 index 0000000..694574e --- /dev/null +++ b/internal/model/siop_identity_test.go @@ -0,0 +1,35 @@ +package model + +import ( + "sync" + "testing" + + "gorm.io/gorm/schema" +) + +func TestSIOPDatabaseColumnNamesMatchMigration(t *testing.T) { + tests := []struct { + model any + fields map[string]string + }{ + {UserSIOPIdentity{}, map[string]string{"DID": "did", "LastKID": "last_kid"}}, + {AdminSIOPIdentity{}, map[string]string{"DID": "did", "LastKID": "last_kid"}}, + {SIOPChallenge{}, map[string]string{"ExpectedDID": "expected_did", "NonceSHA256": "nonce_sha256"}}, + } + for _, test := range tests { + parsed, err := schema.Parse(test.model, &sync.Map{}, schema.NamingStrategy{}) + if err != nil { + t.Fatal(err) + } + for fieldName, want := range test.fields { + field := parsed.LookUpField(fieldName) + if field == nil || field.DBName != want { + got := "" + if field != nil { + got = field.DBName + } + t.Errorf("%T.%s column = %q, want %q", test.model, fieldName, got, want) + } + } + } +} diff --git a/internal/router/router.go b/internal/router/router.go index bff9624..04f1e76 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -20,6 +20,8 @@ import ( "github.com/ic3software/vtafarm-api/internal/model" "github.com/ic3software/vtafarm-api/internal/passkey" "github.com/ic3software/vtafarm-api/internal/setup" + "github.com/ic3software/vtafarm-api/internal/siop" + "github.com/ic3software/vtafarm-api/internal/siop/webvh" "github.com/ic3software/vtafarm-api/internal/upgrade" ) @@ -112,6 +114,21 @@ func Setup( v1.POST("/auth/user/passkey/begin", pkh.UserLoginBegin) v1.POST("/auth/user/passkey/complete", pkh.UserLoginComplete) + // VTA Wallet login is fail-closed until a dedicated RP DID is configured. + // Metadata remains public so the frontend can hide the feature when disabled. + siopResolver := siop.MethodResolver{WebVH: webvh.NewResolver(cfg.SIOP.ResolutionTimeout)} + siopH := handler.NewSIOPHandler(db, siopResolver, handler.SIOPHandlerConfig{ + RPDID: cfg.SIOP.RPDID, + ChallengeTTL: cfg.SIOP.ChallengeTTL, + ClockSkew: cfg.SIOP.ClockSkew, + MaxBodyBytes: cfg.SIOP.MaxBodyBytes, + }, cfg.JWTSecret, cfg.CookieSecure()) + v1.GET("/auth/siop/metadata", siopH.Metadata) + v1.POST("/auth/user/siop/challenge", middleware.NoStore(), middleware.RateLimit(20, time.Minute), siopH.UserLoginChallenge) + v1.POST("/auth/user/siop/authenticate", middleware.NoStore(), middleware.RateLimit(30, time.Minute), siopH.UserLoginAuthenticate) + v1.POST("/auth/admin/siop/challenge", middleware.NoStore(), middleware.RateLimit(20, time.Minute), siopH.AdminLoginChallenge) + v1.POST("/auth/admin/siop/authenticate", middleware.NoStore(), middleware.RateLimit(30, time.Minute), siopH.AdminLoginAuthenticate) + // Admin enrollment (public — no auth required) aeh := handler.NewAdminEnrollHandler(db, cfg.JWTSecret, cfg.CookieSecure()) v1.GET("/admin/enroll/:token", aeh.Validate) @@ -142,6 +159,10 @@ func Setup( adminAuth.POST("/admin/passkeys/register/complete", pkh.RegisterComplete) adminAuth.GET("/admin/passkeys", pkh.List) adminAuth.DELETE("/admin/passkeys/:id", pkh.Delete) + adminAuth.POST("/admin/siop/link/challenge", middleware.RateLimit(20, time.Minute), siopH.AdminLinkChallenge) + adminAuth.POST("/admin/siop/link/authenticate", middleware.RateLimit(30, time.Minute), siopH.AdminLinkAuthenticate) + adminAuth.GET("/admin/siop/identities", siopH.AdminIdentities) + adminAuth.DELETE("/admin/siop/identities/:id", siopH.DeleteAdminIdentity) adminAuth.POST("/admin/invitations", ih.Create) adminAuth.GET("/admin/invitations", ih.List) adminAuth.GET("/admin/setup-sessions", sh.AdminListSessions) @@ -244,6 +265,10 @@ func Setup( userAuth.POST("/user/passkeys/register/complete", pkh.RegisterComplete) userAuth.GET("/user/passkeys", pkh.List) userAuth.DELETE("/user/passkeys/:id", pkh.Delete) + userAuth.POST("/user/siop/link/challenge", middleware.RateLimit(20, time.Minute), siopH.UserLinkChallenge) + userAuth.POST("/user/siop/link/authenticate", middleware.RateLimit(30, time.Minute), siopH.UserLinkAuthenticate) + userAuth.GET("/user/siop/identities", siopH.UserIdentities) + userAuth.DELETE("/user/siop/identities/:id", siopH.DeleteUserIdentity) userAuth.POST("/setup/validate", sh.Validate) userAuth.GET("/setup/images", sh.Images) // Remaining per-mode cluster capacity — the create screen checks this to diff --git a/internal/siop/token.go b/internal/siop/token.go index 205c338..2e9881b 100644 --- a/internal/siop/token.go +++ b/internal/siop/token.go @@ -65,6 +65,17 @@ func parseCompactToken(compact string) (parsedToken, error) { }, nil } +// UnverifiedNonce returns the nonce from a structurally valid compact token. +// Callers may use it only to bind server-side challenge state; it is not +// authenticated until VerifyIDToken succeeds. +func UnverifiedNonce(compact string) (string, error) { + token, err := parseCompactToken(compact) + if err != nil { + return "", err + } + return token.claims.Nonce, nil +} + func parseProtectedHeader(data []byte) (protectedHeader, error) { fields, err := decodeUniqueObject(data) if err != nil { diff --git a/internal/siop/token_unverified_test.go b/internal/siop/token_unverified_test.go new file mode 100644 index 0000000..147c600 --- /dev/null +++ b/internal/siop/token_unverified_test.go @@ -0,0 +1,18 @@ +package siop + +import "testing" + +func TestUnverifiedNonceUsesStrictTokenParser(t *testing.T) { + identity := newTestIdentity(t, 31) + token := mintToken(t, identity, nil, nil) + nonce, err := UnverifiedNonce(token) + if err != nil { + t.Fatal(err) + } + if nonce != testNonce { + t.Fatalf("nonce = %q, want %q", nonce, testNonce) + } + if _, err := UnverifiedNonce(token + ".extra"); err == nil { + t.Fatal("accepted a token with four compact segments") + } +} diff --git a/migrations/000029_siop_identities.down.sql b/migrations/000029_siop_identities.down.sql new file mode 100644 index 0000000..46d0f91 --- /dev/null +++ b/migrations/000029_siop_identities.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS siop_challenges; +DROP TABLE IF EXISTS admin_siop_identities; +DROP TABLE IF EXISTS user_siop_identities; diff --git a/migrations/000029_siop_identities.up.sql b/migrations/000029_siop_identities.up.sql new file mode 100644 index 0000000..b985c8f --- /dev/null +++ b/migrations/000029_siop_identities.up.sql @@ -0,0 +1,52 @@ +CREATE TABLE user_siop_identities ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + did TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_authenticated_at TIMESTAMPTZ NULL, + last_kid TEXT NOT NULL DEFAULT '' +); + +CREATE UNIQUE INDEX user_siop_identities_did_unique + ON user_siop_identities (did); +CREATE INDEX user_siop_identities_user_id_idx + ON user_siop_identities (user_id); + +CREATE TABLE admin_siop_identities ( + id BIGSERIAL PRIMARY KEY, + admin_id BIGINT NOT NULL REFERENCES admins(id) ON DELETE CASCADE, + did TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_authenticated_at TIMESTAMPTZ NULL, + last_kid TEXT NOT NULL DEFAULT '' +); + +CREATE UNIQUE INDEX admin_siop_identities_did_unique + ON admin_siop_identities (did); +CREATE INDEX admin_siop_identities_admin_id_idx + ON admin_siop_identities (admin_id); + +CREATE TABLE siop_challenges ( + id TEXT PRIMARY KEY, + purpose TEXT NOT NULL CHECK (purpose IN ('login', 'link')), + account_role TEXT NOT NULL CHECK (account_role IN ('user', 'admin')), + expected_did TEXT NOT NULL, + user_id BIGINT NULL REFERENCES users(id) ON DELETE CASCADE, + admin_id BIGINT NULL REFERENCES admins(id) ON DELETE CASCADE, + nonce_sha256 BYTEA NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT siop_challenge_account_matches_purpose CHECK ( + (purpose = 'login' AND user_id IS NULL AND admin_id IS NULL) + OR (purpose = 'link' AND ( + (account_role = 'user' AND user_id IS NOT NULL AND admin_id IS NULL) + OR (account_role = 'admin' AND admin_id IS NOT NULL AND user_id IS NULL) + )) + ) +); + +CREATE INDEX siop_challenges_expires_at_idx ON siop_challenges (expires_at); +CREATE INDEX siop_challenges_pending_did_idx + ON siop_challenges (account_role, expected_did, expires_at); From 2345d0734d0100e03ee06a2d3397a06cdb392993 Mon Sep 17 00:00:00 2001 From: vthwang Date: Tue, 15 Sep 2026 21:29:10 -0700 Subject: [PATCH 3/6] docs: use PNM for RP DID provisioning Replace the wallet-first instructions with the minimal PNM flow. - select the persistent VTA and reuse its registered DID host - create the dedicated context before minting the RP DID Signed-off-by: vthwang --- README.md | 85 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5c7738d..986d31a 100644 --- a/README.md +++ b/README.md @@ -148,36 +148,61 @@ required. Do not reuse `DID_HOSTING_DID`: that `did:key` and its `DID_HOSTING_PRIVATE_KEY` are the privileged machine credential that vtafarm-api uses to upload DID logs and manage hosting ACLs. -The simplest UI flow is through the VTA Wallet management console because the -VTA must create and retain the DID's keys: - -1. Connect the wallet to the dedicated `vtafarm-auth` VTA. From the extension - popup, select **Manage this agent**. -2. Under **Identity & custody → Contexts**, create or select the - `vtafarm-auth` context. -3. Under **Identity & custody → DIDs**, use **New DID**. Enter the registered - hosting server ID, or leave it blank when that VTA has a default server. - Enable **Portable** if the identity must be movable to another hosting - domain later; this choice cannot be added after creation. -4. Select **Create DID** and complete any consent request. The resulting DID is - already signed by the VTA, published to the hosting server, and displayed in - the DIDs table. Copy the full `did:webvh:...` value into the production - `siop.rpDID` Helm value. - -The hosting server must already be registered with the dedicated VTA. This is -a one-time prerequisite; the current wallet management console can create DIDs -but does not have a hosting-server registration form. Read the server's actual -DID from its public API and register it with PNM: +Provision the RP DID with PNM so the selected VTA creates and retains its keys. +First select the persistent VTA that is already connected to the DID-hosting +daemon. The argument to `pnm vta use` is the local VTA slug, not the hosting +server DID: + +```bash +pnm vta use +pnm vta info +``` + +The hosting server must already be registered with that VTA. List the registry +and note the server ID used by the remaining commands: + +```bash +pnm did-mgmt servers list +``` + +If the server is already listed, do not add it again. Otherwise, read its +actual DID from the public API and register it once: ```bash curl 'https:///api/server-info' pnm did-mgmt servers add \ - --id primary \ + --id \ --did '' \ --label 'VTA Farm DID host' ``` +Create a dedicated context, then create the DID at the stable +`vtafarm-auth` path: + +```bash +pnm contexts create \ + --id vtafarm-auth \ + --name vtafarm-auth + +pnm did-mgmt dids create \ + --context vtafarm-auth \ + --server \ + --path vtafarm-auth +``` + +These are separate operations: `did-mgmt dids create` requires an existing +context. `pnm contexts provision` can create both, but it is intended to +onboard an external application and additionally produces a recipient-sealed +bootstrap bundle, so it is not appropriate for this RP identity. + +The minimal DID command deliberately omits optional settings. The hosting +daemon chooses its configured/default domain, `portable` defaults to `true`, +and pre-rotation defaults to zero. Add `--domain` only when the server hosts +multiple domains and its default is not the intended public hostname. Copy the +full returned `did:webvh:...` value into the production `siop.rpDID` Helm +value. + If the public hosting domain is not configured yet, log in to the DID-hosting admin UI first: @@ -190,22 +215,8 @@ admin UI first: Do not use **DIDs → New DID** in the DID-hosting admin UI as the only creation step. That screen calls `POST /api/dids` to reserve a path and displays **Pending upload**; it does not generate keys or create the first signed -`did.jsonl`. The VTA Wallet management flow above performs the complete mint -and publish operation. - -The wallet UI currently lets the hosting server assign the DID path and choose -its configured/default domain. When an exact path such as `vtafarm-auth` or an -explicit domain is required, use PNM instead: - -```bash -pnm did-mgmt dids create \ - --context vtafarm-auth \ - --server primary \ - --domain '' \ - --path vtafarm-auth \ - --label 'VTA Farm SIOP relying party' \ - --pre-rotation 1 -``` +`did.jsonl`. The PNM flow above performs the complete mint and publish +operation. Put the returned DID in the environment-specific Helm values used on every production deployment: From 0bec03ee35e688b639a6fe6a22c01186b9f7cb55 Mon Sep 17 00:00:00 2001 From: vthwang Date: Tue, 15 Sep 2026 21:44:52 -0700 Subject: [PATCH 4/6] docs: streamline RP DID setup Keep only the required PNM provisioning steps. - remove optional hosting and deployment guidance - make README conform to the project markdownlint rules Signed-off-by: vthwang --- README.md | 99 ++++++------------------------------------------------- 1 file changed, 10 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 986d31a..2e1d622 100644 --- a/README.md +++ b/README.md @@ -148,116 +148,37 @@ required. Do not reuse `DID_HOSTING_DID`: that `did:key` and its `DID_HOSTING_PRIVATE_KEY` are the privileged machine credential that vtafarm-api uses to upload DID logs and manage hosting ACLs. -Provision the RP DID with PNM so the selected VTA creates and retains its keys. -First select the persistent VTA that is already connected to the DID-hosting -daemon. The argument to `pnm vta use` is the local VTA slug, not the hosting -server DID: +Provision the RP DID with PNM: + +1. Select the VTA that is connected to the DID-hosting daemon: ```bash pnm vta use -pnm vta info ``` -The hosting server must already be registered with that VTA. List the registry -and note the server ID used by the remaining commands: +1. Find its registered hosting server ID: ```bash pnm did-mgmt servers list ``` -If the server is already listed, do not add it again. Otherwise, read its -actual DID from the public API and register it once: - -```bash -curl 'https:///api/server-info' - -pnm did-mgmt servers add \ - --id \ - --did '' \ - --label 'VTA Farm DID host' -``` - -Create a dedicated context, then create the DID at the stable -`vtafarm-auth` path: +1. Create the RP context: ```bash pnm contexts create \ --id vtafarm-auth \ --name vtafarm-auth +``` +1. Create the RP DID: + +```bash pnm did-mgmt dids create \ --context vtafarm-auth \ --server \ --path vtafarm-auth ``` -These are separate operations: `did-mgmt dids create` requires an existing -context. `pnm contexts provision` can create both, but it is intended to -onboard an external application and additionally produces a recipient-sealed -bootstrap bundle, so it is not appropriate for this RP identity. - -The minimal DID command deliberately omits optional settings. The hosting -daemon chooses its configured/default domain, `portable` defaults to `true`, -and pre-rotation defaults to zero. Add `--domain` only when the server hosts -multiple domains and its default is not the intended public hostname. Copy the -full returned `did:webvh:...` value into the production `siop.rpDID` Helm -value. - -If the public hosting domain is not configured yet, log in to the DID-hosting -admin UI first: - -1. Open **Domains → New domain**, enter the canonical public hostname, and set - it as the default if this deployment should use it by default. -2. Open **Servers**, choose the hosting instance, and use **Assign domain**. -3. In **Access Control**, ensure the dedicated VTA identity is allowed to - publish to that domain. - -Do not use **DIDs → New DID** in the DID-hosting admin UI as the only creation -step. That screen calls `POST /api/dids` to reserve a path and displays -**Pending upload**; it does not generate keys or create the first signed -`did.jsonl`. The PNM flow above performs the complete mint and publish -operation. - -Put the returned DID in the environment-specific Helm values used on every -production deployment: - -```yaml -siop: - rpDID: "did:webvh:::vtafarm-auth" -``` - -The chart renders this public identifier into the vtafarm-api ConfigMap as -`SIOP_RP_DID`; it is not a secret. Apply that values file with the normal Helm -upgrade, then restart the deployment because environment variables sourced -from a ConfigMap are read when the pod starts: - -```bash -VTAFARM_NAMESPACE=default -VTAFARM_PROD_VALUES=/path/to/production-values.yaml - -helm upgrade vtafarm-api ./helm/vtafarm-api \ - --install \ - --namespace "$VTAFARM_NAMESPACE" \ - --values "$VTAFARM_PROD_VALUES" \ - --atomic \ - --timeout 10m - -kubectl --namespace "$VTAFARM_NAMESPACE" rollout restart deployment/vtafarm-api -kubectl --namespace "$VTAFARM_NAMESPACE" rollout status deployment/vtafarm-api -``` - -Finally, fetch the returned DID's `did.jsonl` over public HTTPS from outside -the cluster, then verify the deployed API metadata: - -```bash -curl 'https:///vtafarm-auth/did.jsonl' -curl 'https:///api/v1/auth/siop/metadata' -``` - -The metadata response must contain `"enabled":true` and the same `rp_did`. -Only the DID string goes into `SIOP_RP_DID`; no RP private key is copied into -vtafarm-api. - ### Generating JWT_SECRET ```bash @@ -385,7 +306,7 @@ Verify before going further — this is the step whose failure shows up several minutes later as a mediator crash loop rather than as a TLS error. **Test against the origin, not the hostname.** Managed and platform records are -*proxied* through Cloudflare, so plain `curl https://` reports +_proxied_ through Cloudflare, so plain `curl https://` reports Cloudflare's edge certificate (issuer: Google Trust Services) and Cloudflare's status code — it tells you nothing about the cluster. Pin the node IP: From 7d6a9f9ed54faf309421ad0455c980096fbd36c3 Mon Sep 17 00:00:00 2001 From: vthwang Date: Tue, 15 Sep 2026 21:46:28 -0700 Subject: [PATCH 5/6] docs: fix RP DID step numbering Nest each command block under its ordered-list item so Markdown renders the steps sequentially. Signed-off-by: vthwang --- README.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2e1d622..59c2432 100644 --- a/README.md +++ b/README.md @@ -152,32 +152,32 @@ Provision the RP DID with PNM: 1. Select the VTA that is connected to the DID-hosting daemon: -```bash -pnm vta use -``` + ```bash + pnm vta use + ``` 1. Find its registered hosting server ID: -```bash -pnm did-mgmt servers list -``` + ```bash + pnm did-mgmt servers list + ``` 1. Create the RP context: -```bash -pnm contexts create \ - --id vtafarm-auth \ - --name vtafarm-auth -``` + ```bash + pnm contexts create \ + --id vtafarm-auth \ + --name vtafarm-auth + ``` 1. Create the RP DID: -```bash -pnm did-mgmt dids create \ - --context vtafarm-auth \ - --server \ - --path vtafarm-auth -``` + ```bash + pnm did-mgmt dids create \ + --context vtafarm-auth \ + --server \ + --path vtafarm-auth + ``` ### Generating JWT_SECRET From b833895953cde67d24b050390fae8be9e9e23167 Mon Sep 17 00:00:00 2001 From: vthwang Date: Tue, 15 Sep 2026 21:47:43 -0700 Subject: [PATCH 6/6] docs: reorder RP DID command flags Present the path before the hosting server in the minimal PNM command. Signed-off-by: vthwang --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 59c2432..58fd2f1 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,8 @@ Provision the RP DID with PNM: ```bash pnm did-mgmt dids create \ --context vtafarm-auth \ - --server \ - --path vtafarm-auth + --path vtafarm-auth \ + --server ``` ### Generating JWT_SECRET