pax_global_header00006660000000000000000000000064151110601040014477gustar00rootroot0000000000000052 comment=b6d6a0b27c123984bef7d14cdb7f487bdbdd68d2 xdg-go-scram-788bb0f/000077500000000000000000000000001511106010400144115ustar00rootroot00000000000000xdg-go-scram-788bb0f/.github/000077500000000000000000000000001511106010400157515ustar00rootroot00000000000000xdg-go-scram-788bb0f/.github/workflows/000077500000000000000000000000001511106010400200065ustar00rootroot00000000000000xdg-go-scram-788bb0f/.github/workflows/test.yml000066400000000000000000000012261511106010400215110ustar00rootroot00000000000000on: push: branches: [master] pull_request: name: CI # Cancel in-progress runs for same workflow + branch concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: ${{ matrix.os }} timeout-minutes: 10 # Minimal permissions permissions: contents: read strategy: matrix: go-version: [1.18.x, 1.25.x] os: [ubuntu-latest] steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Go uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - name: Test run: go test -race ./... xdg-go-scram-788bb0f/.gitignore000066400000000000000000000005741511106010400164070ustar00rootroot00000000000000# Local Claude code settings .claude/settings.local.json # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool *.out # Go workspace file go.work go.work.sum # Dependency directories vendor/ # Build output bin/ dist/ # IDE and editor files .vscode/ .idea/ *.swp *.swo *~ .DS_Store xdg-go-scram-788bb0f/CHANGELOG.md000066400000000000000000000031631511106010400162250ustar00rootroot00000000000000# CHANGELOG ## v1.2.0 - 2025-11-24 ### Added - **Channel binding support for SCRAM-PLUS variants** (RFC 5929, RFC 9266) - `GetStoredCredentialsWithError()` method that returns errors from PBKDF2 key derivation instead of panicking. - Support for Go 1.24+ stdlib `crypto/pbkdf2` package, which provides FIPS 140-3 compliance when using SHA-256 or SHA-512 hash functions. ### Changed - Minimum Go version bumped from 1.11 to 1.18. - Migrated from `github.com/xdg-go/pbkdf2` to stdlib `crypto/pbkdf2` on Go 1.24+. Legacy Go versions (<1.24) continue using the external library via build tags for backward compatibility. - Internal error handling improved for PBKDF2 key derivation failures. ### Deprecated - `GetStoredCredentials()` is deprecated in favor of `GetStoredCredentialsWithError()`. The old method panics on PBKDF2 errors to maintain backward compatibility but will be removed in a future major version. ### Notes - FIPS 140-3 compliance is available on Go 1.24+ when using SCRAM-SHA-256 or SCRAM-SHA-512 with appropriate salt lengths (≥16 bytes). SCRAM-SHA-1 is not FIPS-approved. ## v1.1.2 - 2022-12-07 - Bump stringprep dependency to v1.0.4 for upstream CVE fix. ## v1.1.1 - 2022-03-03 - Bump stringprep dependency to v1.0.3 for upstream CVE fix. ## v1.1.0 - 2022-01-16 - Add SHA-512 hash generator function for convenience. ## v1.0.2 - 2021-03-28 - Switch PBKDF2 dependency to github.com/xdg-go/pbkdf2 to minimize transitive dependencies and support Go 1.9+. ## v1.0.1 - 2021-03-27 - Bump stringprep dependency to v1.0.2 for Go 1.11 support. ## v1.0.0 - 2021-03-27 - First release as a Go module xdg-go-scram-788bb0f/CLAUDE.md000066400000000000000000000065451511106010400157020ustar00rootroot00000000000000# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview This is a Go library implementing SCRAM (Salted Challenge Response Authentication Mechanism) per RFC-5802 and RFC-7677. It provides both client and server implementations supporting SHA-1, SHA-256, and SHA-512 hash functions. ## Development Commands **Run tests:** ```bash go test ./... ``` **Run tests with race detection (CI configuration):** ```bash go test -race ./... ``` **Build (module-only, no binaries):** ```bash go build ./... ``` **Run single test:** ```bash go test -run TestName ./... ``` ## Architecture ### Core Components **Hash factory pattern:** The `HashGeneratorFcn` type (scram.go:23) is the entry point for creating clients and servers. Package-level variables `SHA1`, `SHA256`, `SHA512` provide pre-configured hash functions. All client/server creation flows through these hash generators. **Client (`client.go`):** Holds authentication configuration for a username/password/authzID tuple. Maintains a cache of derived keys (PBKDF2 results) indexed by `KeyFactors` (salt + iteration count). Thread-safe via RWMutex. Creates `ClientConversation` instances for individual auth attempts. **Server (`server.go`):** Holds credential lookup callback and nonce generator. Creates `ServerConversation` instances for individual auth attempts. **Conversations:** State machines implementing the SCRAM protocol exchange: - `ClientConversation` (client_conv.go): States are `clientStarting` → `clientFirst` → `clientFinal` → `clientDone` - `ServerConversation` (server_conv.go): States are `serverFirst` → `serverFinal` → `serverDone` Both use a `Step(string) (string, error)` method to advance through protocol stages. **Message parsing (`parse.go`):** Parses SCRAM protocol messages into structs. Separate parsers for client-first, server-first, client-final, and server-final messages. **Shared utilities (`common.go`):** - `NonceGeneratorFcn`: Default uses base64-encoded 24 bytes from crypto/rand - `derivedKeys`: Struct caching ClientKey, StoredKey, ServerKey - `KeyFactors`: Salt + iteration count, used as cache key - `StoredCredentials`: What servers must store for each user - `CredentialLookup`: Callback type servers use to retrieve stored credentials ### Key Design Patterns **Dependency injection:** Server requires a `CredentialLookup` callback, making storage mechanism pluggable. **Caching:** Client caches expensive PBKDF2 results in a map keyed by `KeyFactors`. This optimizes reconnection scenarios where salt/iteration count remain constant. **Factory methods:** Hash generators provide `.NewClient()` and `.NewServer()` methods that handle SASLprep normalization. Alternative `.NewClientUnprepped()` exists for custom normalization. **Configuration via chaining:** Both Client and Server support `.WithNonceGenerator()` for custom nonce generation (primarily for testing). ### Security Considerations - Default minimum PBKDF2 iterations: 4096 (client.go:45) - All string comparisons use `hmac.Equal()` for constant-time comparison - SASLprep normalization applied by default via xdg-go/stringprep dependency - Nonce generation uses crypto/rand ## Testing Tests include conversation state machine tests (client_conv_test.go, server_conv_test.go), integration tests, and examples (doc_test.go). Test data in testdata_test.go. xdg-go-scram-788bb0f/LICENSE000066400000000000000000000236361511106010400154300ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. xdg-go-scram-788bb0f/README.md000066400000000000000000000073311511106010400156740ustar00rootroot00000000000000[![Go Reference](https://pkg.go.dev/badge/github.com/xdg-go/scram.svg)](https://pkg.go.dev/github.com/xdg-go/scram) [![Go Report Card](https://goreportcard.com/badge/github.com/xdg-go/scram)](https://goreportcard.com/report/github.com/xdg-go/scram) [![Github Actions](https://github.com/xdg-go/scram/actions/workflows/test.yml/badge.svg)](https://github.com/xdg-go/scram/actions/workflows/test.yml) # scram – Go implementation of RFC-5802 ## Description Package scram provides client and server implementations of the Salted Challenge Response Authentication Mechanism (SCRAM) described in - [RFC-5802](https://tools.ietf.org/html/rfc5802) - [RFC-5929](https://tools.ietf.org/html/rfc5929) - [RFC-7677](https://tools.ietf.org/html/rfc7677) - [RFC-9266](https://tools.ietf.org/html/rfc9266) It includes both client and server side support. Channel binding is supported for SCRAM-PLUS variants, including: - `tls-unique` (RFC 5929) - insecure, but required - `tls-server-end-point` (RFC 5929) - works with all TLS versions - `tls-exporter` (RFC 9266) - recommended for TLS 1.3+ SCRAM message extensions are not supported. ## Examples ### Client side package main import "github.com/xdg-go/scram" func main() { // Get Client with username, password and (optional) authorization ID. clientSHA1, err := scram.SHA1.NewClient("mulder", "trustno1", "") if err != nil { panic(err) } // Prepare the authentication conversation. Use the empty string as the // initial server message argument to start the conversation. conv := clientSHA1.NewConversation() var serverMsg string // Get the first message, send it and read the response. firstMsg, err := conv.Step(serverMsg) if err != nil { panic(err) } serverMsg = sendClientMsg(firstMsg) // Get the second message, send it, and read the response. secondMsg, err := conv.Step(serverMsg) if err != nil { panic(err) } serverMsg = sendClientMsg(secondMsg) // Validate the server's final message. We have no further message to // send so ignore that return value. _, err = conv.Step(serverMsg) if err != nil { panic(err) } return } func sendClientMsg(s string) string { // A real implementation would send this to a server and read a reply. return "" } ### Client side with channel binding (SCRAM-PLUS) package main import ( "crypto/tls" "github.com/xdg-go/scram" ) func main() { // Establish TLS connection tlsConn, err := tls.Dial("tcp", "server:port", &tls.Config{MinVersion: tls.VersionTLS13}) if err != nil { panic(err) } defer tlsConn.Close() // Get Client with username, password client, err := scram.SHA256.NewClient("mulder", "trustno1", "") if err != nil { panic(err) } // Create channel binding from TLS connection (TLS 1.3 example) // Use NewTLSExporterBinding for TLS 1.3+, NewTLSServerEndpointBinding for all TLS versions channelBinding, err := scram.NewTLSExporterBinding(&tlsConn.ConnectionState()) if err != nil { panic(err) } // Create conversation with channel binding for SCRAM-SHA-256-PLUS conv := client.NewConversationWithChannelBinding(channelBinding) // ... rest of authentication conversation } ## Copyright and License Copyright 2018 by David A. Golden. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"). You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 xdg-go-scram-788bb0f/channel_binding.go000066400000000000000000000113351511106010400200450ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "crypto/hmac" "crypto/sha256" "crypto/sha512" "crypto/tls" "crypto/x509" "errors" "fmt" "hash" ) // ChannelBindingType represents the type of channel binding to use with // SCRAM-PLUS authentication variants. The type must match one of the // channel binding types defined in RFC 5056, RFC 5929, or RFC 9266. type ChannelBindingType string const ( // ChannelBindingNone indicates no channel binding is used. ChannelBindingNone ChannelBindingType = "" // ChannelBindingTLSUnique uses the TLS Finished message from the first // TLS handshake (RFC 5929). This is considered insecure, but is included // as required by RFC 5802. ChannelBindingTLSUnique ChannelBindingType = "tls-unique" // ChannelBindingTLSServerEndpoint uses a hash of the server's certificate // (RFC 5929). This works with all TLS versions including TLS 1.3. ChannelBindingTLSServerEndpoint ChannelBindingType = "tls-server-end-point" // ChannelBindingTLSExporter uses TLS Exported Keying Material with the // label "EXPORTER-Channel-Binding" (RFC 9266). This is the recommended // channel binding type for TLS 1.3. ChannelBindingTLSExporter ChannelBindingType = "tls-exporter" ) // ChannelBinding holds the channel binding type and data for SCRAM-PLUS // authentication. Use constructors to create type-specific bindings. type ChannelBinding struct { Type ChannelBindingType Data []byte } // IsSupported returns true if the channel binding is configured with a // non-empty type and data. func (cb ChannelBinding) IsSupported() bool { return cb.Type != ChannelBindingNone && len(cb.Data) > 0 } // Matches returns true if this channel binding matches another channel // binding in both type and data. func (cb ChannelBinding) Matches(other ChannelBinding) bool { if cb.Type != other.Type { return false } return hmac.Equal(cb.Data, other.Data) } // NewTLSUniqueBinding creates a ChannelBinding for tls-unique channel binding. // Since Go's standard library doesn't expose the TLS Finished message, // applications must provide the data directly. // // Note: tls-unique is considered insecure and should generally be avoided. func NewTLSUniqueBinding(data []byte) ChannelBinding { // Create a defensive copy to prevent caller from modifying the data cbData := make([]byte, len(data)) copy(cbData, data) return ChannelBinding{ Type: ChannelBindingTLSUnique, Data: cbData, } } // NewTLSServerEndpointBinding creates a ChannelBinding for tls-server-end-point // channel binding per RFC 5929. It extracts the server's certificate from // the TLS connection state and hashes it using the appropriate hash function // based on the certificate's signature algorithm. // // This works with all TLS versions including TLS 1.3. func NewTLSServerEndpointBinding(connState *tls.ConnectionState) (ChannelBinding, error) { if connState == nil { return ChannelBinding{}, errors.New("connection state is nil") } if len(connState.PeerCertificates) == 0 { return ChannelBinding{}, errors.New("no peer certificates") } cert := connState.PeerCertificates[0] // Determine hash algorithm per RFC 5929 var h hash.Hash switch cert.SignatureAlgorithm { case x509.MD5WithRSA, x509.SHA1WithRSA, x509.DSAWithSHA1, x509.ECDSAWithSHA1: h = sha256.New() // Use SHA-256 for MD5/SHA-1 case x509.SHA256WithRSA, x509.SHA256WithRSAPSS, x509.ECDSAWithSHA256: h = sha256.New() case x509.SHA384WithRSA, x509.SHA384WithRSAPSS, x509.ECDSAWithSHA384: h = sha512.New384() case x509.SHA512WithRSA, x509.SHA512WithRSAPSS, x509.ECDSAWithSHA512: h = sha512.New() default: return ChannelBinding{}, fmt.Errorf("unsupported signature algorithm: %v", cert.SignatureAlgorithm) } h.Write(cert.Raw) return ChannelBinding{ Type: ChannelBindingTLSServerEndpoint, Data: h.Sum(nil), }, nil } // NewTLSExporterBinding creates a ChannelBinding for tls-exporter channel binding // per RFC 9266. It uses TLS Exported Keying Material with the label // "EXPORTER-Channel-Binding" and an empty context. // // This is the recommended channel binding type for TLS 1.3+. func NewTLSExporterBinding(connState *tls.ConnectionState) (ChannelBinding, error) { if connState == nil { return ChannelBinding{}, errors.New("connection state is nil") } cbData, err := connState.ExportKeyingMaterial("EXPORTER-Channel-Binding", nil, 32) if err != nil { return ChannelBinding{}, fmt.Errorf("failed to export keying material: %w", err) } return ChannelBinding{ Type: ChannelBindingTLSExporter, Data: cbData, }, nil } xdg-go-scram-788bb0f/channel_binding_constructor_test.go000066400000000000000000000202021511106010400235420ustar00rootroot00000000000000// Copyright 2025 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "errors" "math/big" "net" "testing" "time" ) // === Helpers for Test Certs === // Helper function to generate a test certificate and private key func generateTestCertAndKey(t *testing.T) (*ecdsa.PrivateKey, []byte) { t.Helper() priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) assertNoError(t, err) serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) assertNoError(t, err) template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ Organization: []string{"Test Org"}, }, NotBefore: time.Now(), NotAfter: time.Now().Add(365 * 24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, SignatureAlgorithm: x509.ECDSAWithSHA256, } certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) assertNoError(t, err) return priv, certDER } // Helper function to create a test certificate with a mocked signature // algorithm. func mockTestCert(t *testing.T, sigAlg x509.SignatureAlgorithm) *x509.Certificate { t.Helper() _, certDER := generateTestCertAndKey(t) cert, err := x509.ParseCertificate(certDER) assertNoError(t, err) // Override the signature algorithm in the parsed cert to test different hash behaviors // What matters for server endpoint channel binding is the certificate's Raw // bytes and the SignatureAlgorithm field, not the actual key type match. cert.SignatureAlgorithm = sigAlg return cert } // Helper function to create a mock TLS connection state with a certificate func createMockConnState(t *testing.T, cert *x509.Certificate) *tls.ConnectionState { t.Helper() return &tls.ConnectionState{ Version: tls.VersionTLS13, HandshakeComplete: true, PeerCertificates: []*x509.Certificate{cert}, } } // Helper function to set up a TLS server with a test certificate func setupTLSServer(t *testing.T) net.Listener { t.Helper() priv, certDER := generateTestCertAndKey(t) // Create TLS config tlsCert := tls.Certificate{ Certificate: [][]byte{certDER}, PrivateKey: priv, } config := &tls.Config{ Certificates: []tls.Certificate{tlsCert}, MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, } // Create listener listener, err := tls.Listen("tcp", "localhost:0", config) assertNoError(t, err) return listener } // Helper function to connect to a TLS server func connectTLSClient(t *testing.T, serverAddr string) *tls.Conn { t.Helper() config := &tls.Config{ InsecureSkipVerify: true, // Self-signed cert MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, } conn, err := tls.Dial("tcp", serverAddr, config) assertNoError(t, err) return conn } // === Binding Constructors === func TestNewTLSUniqueBinding(t *testing.T) { testData := []byte("test-tls-unique-data") cb := NewTLSUniqueBinding(testData) if cb.Type != ChannelBindingTLSUnique { t.Errorf("Expected type %q, got %q", ChannelBindingTLSUnique, cb.Type) } if string(cb.Data) != string(testData) { t.Errorf("Expected data %q, got %q", testData, cb.Data) } if !cb.IsSupported() { t.Error("Expected channel binding to be supported") } // Verify defensive copy: modifying original data shouldn't affect channel binding originalData := []byte{0x01, 0x02, 0x03, 0x04} cb2 := NewTLSUniqueBinding(originalData) // Modify the original slice originalData[0] = 0xFF originalData[1] = 0xFF // Channel binding data should be unchanged if cb2.Data[0] != 0x01 || cb2.Data[1] != 0x02 { t.Errorf("Channel binding data was modified when original slice changed: got %v, expected [1 2 3 4]", cb2.Data) } } func TestNewTLSServerEndpointBinding(t *testing.T) { tests := []struct { name string sigAlg x509.SignatureAlgorithm hashLen int }{ {"SHA1WithRSA", x509.SHA1WithRSA, 32}, // Upgrade from SHA-1 to SHA-256 {"SHA256WithRSA", x509.SHA256WithRSA, 32}, {"ECDSAWithSHA256", x509.ECDSAWithSHA256, 32}, {"ECDSAWithSHA384", x509.ECDSAWithSHA384, 48}, {"ECDSAWithSHA512", x509.ECDSAWithSHA512, 64}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cert := mockTestCert(t, tt.sigAlg) connState := createMockConnState(t, cert) cb, err := NewTLSServerEndpointBinding(connState) assertNoError(t, err) if cb.Type != ChannelBindingTLSServerEndpoint { t.Errorf("Expected type %q, got %q", ChannelBindingTLSServerEndpoint, cb.Type) } if len(cb.Data) != tt.hashLen { t.Errorf("Expected hash length %d, got %d", tt.hashLen, len(cb.Data)) } if !cb.IsSupported() { t.Error("Expected channel binding to be supported") } }) } } func TestNewTLSServerEndpointBinding_NoPeerCertificates(t *testing.T) { connState := &tls.ConnectionState{ Version: tls.VersionTLS13, HandshakeComplete: true, PeerCertificates: []*x509.Certificate{}, } _, err := NewTLSServerEndpointBinding(connState) assertErrorContains(t, err, "no peer certificates") } func TestNewTLSServerEndpointBinding_NilConnectionState(t *testing.T) { _, err := NewTLSServerEndpointBinding(nil) assertErrorContains(t, err, "connection state is nil") } func TestNewTLSExporterBinding(t *testing.T) { _, err := NewTLSExporterBinding(nil) assertErrorContains(t, err, "connection state is nil") } func TestNewTLSExporterBinding_RealTLS13(t *testing.T) { // Set up TLS server listener := setupTLSServer(t) defer listener.Close() // Channel to receive server connection state and coordinate cleanup serverStateChan := make(chan *tls.ConnectionState, 1) errChan := make(chan error, 1) doneChan := make(chan struct{}) // Accept connection in goroutine go func() { conn, err := listener.Accept() if err != nil { errChan <- err return } defer conn.Close() tlsConn, ok := conn.(*tls.Conn) if !ok { errChan <- errors.New("failed to cast to *tls.Conn") return } // Ensure handshake completes err = tlsConn.Handshake() if err != nil { errChan <- err return } // Get server connection state state := tlsConn.ConnectionState() serverStateChan <- &state // Wait for test to complete before closing connection <-doneChan }() // Connect client clientConn := connectTLSClient(t, listener.Addr().String()) defer func() { close(doneChan) // Signal server to close clientConn.Close() }() // Ensure handshake completes on client side err := clientConn.Handshake() assertNoError(t, err) // Get client connection state clientState := clientConn.ConnectionState() // Wait for server connection state var serverState *tls.ConnectionState select { case serverState = <-serverStateChan: case err := <-errChan: t.Fatalf("Server error: %v", err) } // Test NewTLSExporterBinding on client side clientCB, err := NewTLSExporterBinding(&clientState) assertNoError(t, err) if clientCB.Type != ChannelBindingTLSExporter { t.Errorf("Expected type %q, got %q", ChannelBindingTLSExporter, clientCB.Type) } if len(clientCB.Data) == 0 { t.Error("Expected non-empty channel binding data") } if !clientCB.IsSupported() { t.Error("Expected channel binding to be supported") } // Test NewTLSExporterBinding on server side serverCB, err := NewTLSExporterBinding(serverState) assertNoError(t, err) if serverCB.Type != ChannelBindingTLSExporter { t.Errorf("Expected type %q, got %q", ChannelBindingTLSExporter, serverCB.Type) } if len(serverCB.Data) == 0 { t.Error("Expected non-empty channel binding data") } if !serverCB.IsSupported() { t.Error("Expected channel binding to be supported") } // Verify both sides export the same keying material if !clientCB.Matches(serverCB) { t.Errorf("Client and server channel binding data should match.\nClient: %x\nServer: %x", clientCB.Data, serverCB.Data) } } xdg-go-scram-788bb0f/channel_binding_test.go000066400000000000000000000353371511106010400211140ustar00rootroot00000000000000// Copyright 2025 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "encoding/base64" "errors" "fmt" "strings" "testing" ) // === Helpers === // Authentication flow step names const ( stepClientFirst = "client first" stepServerFirst = "server first" stepClientFinal = "client final" stepServerFinal = "server final" stepClientValidation = "client validation" stepFinalValidation = "final validation" stepFinished = "finished" ) // assertErrorContains verifies that err is non-nil and contains all specified substrings. func assertNoError(t *testing.T, err error) { t.Helper() if err != nil { t.Fatalf("Expected no error, got: %v", err) } } // assertErrorContains verifies that err is non-nil and contains all specified substrings. func assertErrorContains(t *testing.T, err error, substr string) { t.Helper() if err == nil { t.Fatal("Expected error, got nil") } if !strings.Contains(err.Error(), substr) { t.Errorf("Expected error to contain %q, got: %v", substr, err) } } func assertFinishedSuccessfully(t *testing.T, step string, err error) { t.Helper() assertNoError(t, err) if step != stepFinished { t.Errorf("Expected authentication to finish successfully, got stuck at step %q", step) } } // assertErrorAtStep verifies that an error occurred at the expected step and contains expected substrings. func assertErrorAtStep(t *testing.T, step string, err error, expectedStep string, expectedErrSubstrs ...string) { t.Helper() if step != expectedStep { t.Errorf("Expected error at step %q, got %q", expectedStep, step) } for _, substr := range expectedErrSubstrs { assertErrorContains(t, err, substr) } } // mockCredLookupFcn returns a CredentialLookup function for testing with the given password. // Uses a fixed salt and iteration count for consistent test behavior. func mockCredLookupFcn(password string) CredentialLookup { return func(username string) (StoredCredentials, error) { client, _ := SHA256.NewClient(username, password, "") salt := []byte("QSXCR+Q6sek8bf92") return client.GetStoredCredentials(KeyFactors{Salt: string(salt), Iters: 4096}), nil } } // setupClientServer creates a client and server for testing with standard test credentials. func setupClientServer(t *testing.T) (*Client, *Server) { t.Helper() client, err := SHA256.NewClient("user", "pencil", "") assertNoError(t, err) server, err := SHA256.NewServer(mockCredLookupFcn("pencil")) assertNoError(t, err) return client, server } // setupConversations creates client and server conversations with the specified channel bindings. func setupConversations(t *testing.T, client *Client, server *Server, clientCB, serverCB ChannelBinding, required bool) (*ClientConversation, *ServerConversation) { var clientConv *ClientConversation if clientCB.IsSupported() { clientConv = client.NewConversationWithChannelBinding(clientCB) } else { clientConv = client.NewConversation() } var serverConv *ServerConversation if required { if !serverCB.IsSupported() { t.Fatal("Server channel binding must be supported when required is true") } serverConv = server.NewConversationWithChannelBindingRequired(serverCB) } else if serverCB.IsSupported() { serverConv = server.NewConversationWithChannelBinding(serverCB) } else { serverConv = server.NewConversation() } return clientConv, serverConv } // runFullAuthFlow executes a complete SCRAM authentication between the given // client and server conversations. Returns the name of the step where an error // occurred (if any) and the error itself. func runFullAuthFlow(clientConv *ClientConversation, serverConv *ServerConversation) (string, error) { clientFirst, err := clientConv.Step("") if err != nil { return stepClientFirst, err } serverFirst, err := serverConv.Step(clientFirst) if err != nil { return stepServerFirst, err } clientFinal, err := clientConv.Step(serverFirst) if err != nil { return stepClientFinal, err } serverFinal, err := serverConv.Step(clientFinal) if err != nil { return stepServerFinal, err } _, err = clientConv.Step(serverFinal) if err != nil { return stepClientValidation, err } if !clientConv.Valid() || !serverConv.Valid() { return stepFinalValidation, errors.New("authentication failed: conversations not valid") } return stepFinished, nil } // extractNonce parses the nonce from a server-first message. // Server-first format: r=,s=,i= func extractNonce(serverFirst string) string { parts := strings.Split(serverFirst, ",") noncePart := parts[0] // r=clientnonce123servernonce... return noncePart[2:] // Strip "r=" prefix } // setupForClientFinal creates conversations and advances to the point where // client-final is expected. Returns the server conversation ready to receive // client-final, the nonce to use, and the GS2 header string. func setupForClientFinal(t *testing.T, client *Client, server *Server, cb ChannelBinding) (*ServerConversation, string, string) { t.Helper() clientConv, serverConv := setupConversations(t, client, server, cb, cb, false) clientFirst, err := clientConv.Step("") assertNoError(t, err) serverFirst, err := serverConv.Step(clientFirst) assertNoError(t, err) nonce := extractNonce(serverFirst) gs2Header := "p=" + string(cb.Type) + ",," return serverConv, nonce, gs2Header } // buildClientFinal constructs a valid client-final message from components. func buildClientFinal(nonce, gs2Header string, cbData []byte, proof string) string { cbMsg := append([]byte(gs2Header), cbData...) cAttr := base64.StdEncoding.EncodeToString(cbMsg) return fmt.Sprintf("c=%s,r=%s,p=%s", cAttr, nonce, proof) } // === Basic method tests === func TestChannelBindingIsSupported(t *testing.T) { // Positive case if !(ChannelBinding{Type: ChannelBindingTLSExporter, Data: []byte("test")}.IsSupported()) { t.Error("Channel binding with type and data should be supported") } // Negative cases if (ChannelBinding{}.IsSupported()) { t.Error("Empty channel binding should not be supported") } if (ChannelBinding{Type: ChannelBindingTLSExporter}.IsSupported()) { t.Error("Channel binding with type but no data should not be supported") } } func TestChannelBindingMatches_Data(t *testing.T) { cb1 := ChannelBinding{Type: ChannelBindingTLSExporter, Data: []byte("test")} cb2 := ChannelBinding{Type: ChannelBindingTLSExporter, Data: []byte("test")} cb3 := ChannelBinding{Type: ChannelBindingTLSExporter, Data: []byte("different")} if !cb1.Matches(cb2) { t.Error("Identical channel bindings should match") } if cb1.Matches(cb3) { t.Error("Different data should not match") } } func TestChannelBindingMatches_Type(t *testing.T) { cb1 := ChannelBinding{Type: ChannelBindingTLSUnique, Data: []byte("test")} cb2 := ChannelBinding{Type: ChannelBindingTLSExporter, Data: []byte("test")} if cb1.Matches(cb2) { t.Error("Different types should not match") } } // === Protocol encoding tests === func TestChannelBindingGS2Header(t *testing.T) { username := "user" password := "pencil" // Verify that client-first messages contain correct GS2 headers per RFC 5802. tests := []struct { name string setupConv func(client *Client) *ClientConversation expectedPrefix string authzID string }{ { name: "No channel binding - flag n", setupConv: func(client *Client) *ClientConversation { return client.NewConversation() }, expectedPrefix: "n,,", }, { name: "No channel binding - flag n, with authzID", setupConv: func(client *Client) *ClientConversation { return client.NewConversation() }, authzID: "admin", expectedPrefix: "n,a=admin,", }, { name: "With tls-exporter channel binding - flag p", setupConv: func(client *Client) *ClientConversation { return client.NewConversationWithChannelBinding(ChannelBinding{ Type: ChannelBindingTLSExporter, Data: []byte{0x01, 0x02, 0x03}, }) }, expectedPrefix: "p=tls-exporter,,", }, { name: "With tls-exporter channel binding - flag p, with authzID", setupConv: func(client *Client) *ClientConversation { return client.NewConversationWithChannelBinding(ChannelBinding{ Type: ChannelBindingTLSExporter, Data: []byte{0x01, 0x02, 0x03}, }) }, authzID: "admin", expectedPrefix: "p=tls-exporter,a=admin,", }, { name: "Advertising channel binding - flag y", setupConv: func(client *Client) *ClientConversation { return client.NewConversationAdvertisingChannelBinding() }, expectedPrefix: "y,,", }, { name: "Advertising channel binding - flag y, with authzID", setupConv: func(client *Client) *ClientConversation { return client.NewConversationAdvertisingChannelBinding() }, authzID: "admin", expectedPrefix: "y,a=admin,", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client, err := SHA256.NewClient(username, password, tt.authzID) assertNoError(t, err) conv := tt.setupConv(client) clientFirst, err := conv.Step("") assertNoError(t, err) if !strings.HasPrefix(clientFirst, tt.expectedPrefix) { t.Errorf("Expected client-first to start with %q, got %q", tt.expectedPrefix, clientFirst) } }) } } // === Channel binding conversation tests === func TestChannelBinding_Success_NoBinding(t *testing.T) { client, server := setupClientServer(t) clientConv, serverConv := setupConversations(t, client, server, ChannelBinding{}, ChannelBinding{}, false) step, err := runFullAuthFlow(clientConv, serverConv) assertFinishedSuccessfully(t, step, err) } func TestChannelBinding_Success_AllTypes(t *testing.T) { bindingTypes := []ChannelBindingType{ ChannelBindingTLSUnique, ChannelBindingTLSServerEndpoint, ChannelBindingTLSExporter, } for _, cbType := range bindingTypes { t.Run(string(cbType), func(t *testing.T) { cbData := []byte("test-data-for-" + string(cbType)) cb := ChannelBinding{Type: cbType, Data: cbData} client, server := setupClientServer(t) clientConv, serverConv := setupConversations(t, client, server, cb, cb, false) step, err := runFullAuthFlow(clientConv, serverConv) assertFinishedSuccessfully(t, step, err) }) } } func TestChannelBinding_Success_ClientBindingNotRequired(t *testing.T) { serverCB := ChannelBinding{Type: ChannelBindingTLSExporter, Data: []byte("test-data")} client, server := setupClientServer(t) clientConv, serverConv := setupConversations(t, client, server, ChannelBinding{}, serverCB, false) step, err := runFullAuthFlow(clientConv, serverConv) assertFinishedSuccessfully(t, step, err) } func TestChannelBinding_Failure_UnsupportedType(t *testing.T) { client, server := setupClientServer(t) // Run authentication conversation - client with tls-exporter, server with tls-server-end-point clientCB := ChannelBinding{ Type: ChannelBindingTLSExporter, Data: []byte("test-data"), } serverCB := ChannelBinding{ Type: ChannelBindingTLSServerEndpoint, Data: []byte("test-data"), } clientConv, serverConv := setupConversations(t, client, server, clientCB, serverCB, false) // Server should reject the unsupported channel binding type, reporting both types seen. step, err := runFullAuthFlow(clientConv, serverConv) assertErrorAtStep(t, step, err, stepServerFirst, "tls-exporter", "tls-server-end-point") } func TestChannelBinding_Failure_DataMismatch(t *testing.T) { client, server := setupClientServer(t) // Run authentication conversation with mismatched channel binding clientCB := ChannelBinding{ Type: ChannelBindingTLSExporter, Data: []byte("client-data"), } serverCB := ChannelBinding{ Type: ChannelBindingTLSExporter, Data: []byte("server-data"), } clientConv, serverConv := setupConversations(t, client, server, clientCB, serverCB, false) step, err := runFullAuthFlow(clientConv, serverConv) assertErrorAtStep(t, step, err, stepServerFinal, "channel binding mismatch") } func TestChannelBinding_Failure_Malformed(t *testing.T) { client, server := setupClientServer(t) cbData := []byte("test-cb-data") cb := NewTLSUniqueBinding(cbData) mockproof := base64.StdEncoding.EncodeToString([]byte("mock-proof")) t.Run("Invalid base64 in c attribute", func(t *testing.T) { serverConv, nonce, _ := setupForClientFinal(t, client, server, cb) // Construct malformed message with invalid base64 clientFinal := fmt.Sprintf("c=not-valid-base64!!!,r=%s,p=%s", nonce, mockproof) _, err := serverConv.Step(clientFinal) assertErrorContains(t, err, "illegal base64 data") }) t.Run("Truncated channel binding data", func(t *testing.T) { serverConv, nonce, gs2Header := setupForClientFinal(t, client, server, cb) // Use helper to build valid message structure, but with truncated data clientFinal := buildClientFinal(nonce, gs2Header, cbData[:2], mockproof) _, err := serverConv.Step(clientFinal) assertErrorContains(t, err, "channel binding mismatch") }) } func TestChannelBinding_Failure_ServerDoesntSupportBinding(t *testing.T) { client, server := setupClientServer(t) // Run authentication conversation - client with channel binding, server without clientCB := ChannelBinding{ Type: ChannelBindingTLSExporter, Data: []byte("test-data"), } clientConv, serverConv := setupConversations(t, client, server, clientCB, ChannelBinding{}, false) // Server should reject the client's channel binding request step, err := runFullAuthFlow(clientConv, serverConv) assertErrorAtStep(t, step, err, stepServerFirst, "client requires channel binding") } func TestChannelBinding_Failure_BindingRequired(t *testing.T) { client, server := setupClientServer(t) // Run authentication conversation - client without channel binding, server requires it serverCB := ChannelBinding{ Type: ChannelBindingTLSExporter, Data: []byte("test-data"), } clientConv, serverConv := setupConversations(t, client, server, ChannelBinding{}, serverCB, true) // Server should reject due to channel binding being required at server first step step, err := runFullAuthFlow(clientConv, serverConv) assertErrorAtStep(t, step, err, stepServerFirst, "server requires channel binding") } func TestChannelBinding_Failure_RejectDowngrade(t *testing.T) { client, server := setupClientServer(t) // Server with optional channel binding, client thinks server does not have // channel binding but advertises that client supports it. serverCB := ChannelBinding{ Type: ChannelBindingTLSExporter, Data: []byte("test-data"), } serverConv := server.NewConversationWithChannelBinding(serverCB) clientConv := client.NewConversationAdvertisingChannelBinding() step, err := runFullAuthFlow(clientConv, serverConv) assertErrorAtStep(t, step, err, stepServerFirst, "downgrade attack detected") } xdg-go-scram-788bb0f/client.go000066400000000000000000000150531511106010400162220ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "sync" ) // Client implements the client side of SCRAM authentication. It holds // configuration values needed to initialize new client-side conversations for // a specific username, password and authorization ID tuple. Client caches // the computationally-expensive parts of a SCRAM conversation as described in // RFC-5802. If repeated authentication conversations may be required for a // user (e.g. disconnect/reconnect), the user's Client should be preserved. // // For security reasons, Clients have a default minimum PBKDF2 iteration count // of 4096. If a server requests a smaller iteration count, an authentication // conversation will error. // // A Client can also be used by a server application to construct the hashed // authentication values to be stored for a new user. See StoredCredentials() // for more. type Client struct { sync.RWMutex username string password string authzID string minIters int nonceGen NonceGeneratorFcn hashGen HashGeneratorFcn cache map[KeyFactors]derivedKeys } func newClient(username, password, authzID string, fcn HashGeneratorFcn) *Client { return &Client{ username: username, password: password, authzID: authzID, minIters: 4096, nonceGen: defaultNonceGenerator, hashGen: fcn, cache: make(map[KeyFactors]derivedKeys), } } // WithMinIterations changes minimum required PBKDF2 iteration count. func (c *Client) WithMinIterations(n int) *Client { c.Lock() defer c.Unlock() c.minIters = n return c } // WithNonceGenerator replaces the default nonce generator (base64 encoding of // 24 bytes from crypto/rand) with a custom generator. This is provided for // testing or for users with custom nonce requirements. func (c *Client) WithNonceGenerator(ng NonceGeneratorFcn) *Client { c.Lock() defer c.Unlock() c.nonceGen = ng return c } // NewConversation constructs a client-side authentication conversation. // Conversations cannot be reused, so this must be called for each new // authentication attempt. func (c *Client) NewConversation() *ClientConversation { c.RLock() defer c.RUnlock() return &ClientConversation{ client: c, nonceGen: c.nonceGen, hashGen: c.hashGen, minIters: c.minIters, } } // NewConversationAdvertisingChannelBinding constructs a client-side // authentication conversation that advertises channel binding support without // using it. This generates the "y" GS2 flag, indicating the client supports // channel binding but the server did not advertise a PLUS variant mechanism. // // This helps detect downgrade attacks where a MITM strips PLUS mechanism // advertisements from the server's mechanism list. If the server actually // advertised PLUS variants, it will reject the "y" flag as a downgrade attack. // // Use this when: // - Your application supports channel binding (has access to TLS connection state) // - SASL mechanism negotiation showed the server does NOT advertise PLUS variants // (e.g., server advertised "SCRAM-SHA-256" but not "SCRAM-SHA-256-PLUS") // // Conversations cannot be reused, so this must be called for each new // authentication attempt. func (c *Client) NewConversationAdvertisingChannelBinding() *ClientConversation { c.RLock() defer c.RUnlock() return &ClientConversation{ client: c, nonceGen: c.nonceGen, hashGen: c.hashGen, minIters: c.minIters, advertiseChannelBinding: true, } } // NewConversationWithChannelBinding constructs a client-side authentication // conversation with channel binding for SCRAM-PLUS authentication. Channel // binding is connection-specific, so a new conversation should be created for // each connection being authenticated. Conversations cannot be reused, so this // must be called for each new authentication attempt. func (c *Client) NewConversationWithChannelBinding(cb ChannelBinding) *ClientConversation { c.RLock() defer c.RUnlock() return &ClientConversation{ client: c, nonceGen: c.nonceGen, hashGen: c.hashGen, minIters: c.minIters, channelBinding: cb, } } func (c *Client) getDerivedKeys(kf KeyFactors) (derivedKeys, error) { dk, ok := c.getCache(kf) if !ok { var err error dk, err = c.computeKeys(kf) if err != nil { return derivedKeys{}, err } c.setCache(kf, dk) } return dk, nil } // GetStoredCredentials takes a salt and iteration count structure and // provides the values that must be stored by a server to authenticate a // user. These values are what the Server credential lookup function must // return for a given username. // // Deprecated: Use GetStoredCredentialsWithError for proper error handling. // This method panics if PBKDF2 key derivation fails, which should only // occur with invalid KeyFactors parameters. func (c *Client) GetStoredCredentials(kf KeyFactors) StoredCredentials { creds, err := c.GetStoredCredentialsWithError(kf) if err != nil { panic("scram: GetStoredCredentials failed: " + err.Error()) } return creds } // GetStoredCredentialsWithError takes a salt and iteration count structure and // provides the values that must be stored by a server to authenticate a // user. These values are what the Server credential lookup function must // return for a given username. // // Returns an error if PBKDF2 key derivation fails, which can occur with // invalid parameters in Go 1.24+ (e.g., invalid iteration counts or key lengths). func (c *Client) GetStoredCredentialsWithError(kf KeyFactors) (StoredCredentials, error) { dk, err := c.getDerivedKeys(kf) return StoredCredentials{ KeyFactors: kf, StoredKey: dk.StoredKey, ServerKey: dk.ServerKey, }, err } func (c *Client) computeKeys(kf KeyFactors) (derivedKeys, error) { h := c.hashGen() saltedPassword, err := pbkdf2Key(c.hashGen, c.password, []byte(kf.Salt), kf.Iters, h.Size()) if err != nil { return derivedKeys{}, err } clientKey := computeHMAC(c.hashGen, saltedPassword, []byte("Client Key")) return derivedKeys{ ClientKey: clientKey, StoredKey: computeHash(c.hashGen, clientKey), ServerKey: computeHMAC(c.hashGen, saltedPassword, []byte("Server Key")), }, nil } func (c *Client) getCache(kf KeyFactors) (derivedKeys, bool) { c.RLock() defer c.RUnlock() dk, ok := c.cache[kf] return dk, ok } func (c *Client) setCache(kf KeyFactors, dk derivedKeys) { c.Lock() defer c.Unlock() c.cache[kf] = dk } xdg-go-scram-788bb0f/client_conv.go000066400000000000000000000121431511106010400172440ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "crypto/hmac" "encoding/base64" "errors" "fmt" "strings" ) type clientState int const ( clientStarting clientState = iota clientFirst clientFinal clientDone ) // ClientConversation implements the client-side of an authentication // conversation with a server. A new conversation must be created for // each authentication attempt. type ClientConversation struct { client *Client nonceGen NonceGeneratorFcn hashGen HashGeneratorFcn minIters int state clientState valid bool gs2 string nonce string c1b string serveSig []byte channelBinding ChannelBinding advertiseChannelBinding bool // if true, use "y" flag instead of "n" or "p" } // Step takes a string provided from a server (or just an empty string for the // very first conversation step) and attempts to move the authentication // conversation forward. It returns a string to be sent to the server or an // error if the server message is invalid. Calling Step after a conversation // completes is also an error. func (cc *ClientConversation) Step(challenge string) (response string, err error) { switch cc.state { case clientStarting: cc.state = clientFirst response, err = cc.firstMsg() case clientFirst: cc.state = clientFinal response, err = cc.finalMsg(challenge) case clientFinal: cc.state = clientDone response, err = cc.validateServer(challenge) default: response, err = "", errors.New("Conversation already completed") } return } // Done returns true if the conversation is completed or has errored. func (cc *ClientConversation) Done() bool { return cc.state == clientDone } // Valid returns true if the conversation successfully authenticated with the // server, including counter-validation that the server actually has the // user's stored credentials. func (cc *ClientConversation) Valid() bool { return cc.valid } func (cc *ClientConversation) firstMsg() (string, error) { // Values are cached for use in final message parameters cc.gs2 = cc.gs2Header() cc.nonce = cc.client.nonceGen() cc.c1b = fmt.Sprintf("n=%s,r=%s", encodeName(cc.client.username), cc.nonce) return cc.gs2 + cc.c1b, nil } func (cc *ClientConversation) finalMsg(s1 string) (string, error) { msg, err := parseServerFirst(s1) if err != nil { return "", err } // Check nonce prefix and update if !strings.HasPrefix(msg.nonce, cc.nonce) { return "", errors.New("server nonce did not extend client nonce") } cc.nonce = msg.nonce // Check iteration count vs minimum if msg.iters < cc.minIters { return "", fmt.Errorf("server requested too few iterations (%d)", msg.iters) } // Create channel binding data per RFC 5802: // - For "p" flag: gs2-header + channel-binding-data // - For "n" or "y" flags: gs2-header only (no channel-binding-data) cbindData := []byte(cc.gs2) if cc.channelBinding.IsSupported() { // Only append channel binding data when actually using it (flag "p") cbindData = append(cbindData, cc.channelBinding.Data...) } // Create client-final-message-without-proof c2wop := fmt.Sprintf( "c=%s,r=%s", base64.StdEncoding.EncodeToString(cbindData), cc.nonce, ) // Create auth message authMsg := cc.c1b + "," + s1 + "," + c2wop // Get derived keys from client cache dk, err := cc.client.getDerivedKeys(KeyFactors{Salt: string(msg.salt), Iters: msg.iters}) if err != nil { return "", err } // Create proof as clientkey XOR clientsignature clientSignature := computeHMAC(cc.hashGen, dk.StoredKey, []byte(authMsg)) clientProof, err := xorBytes(dk.ClientKey, clientSignature) if err != nil { return "", err } proof := base64.StdEncoding.EncodeToString(clientProof) // Cache ServerSignature for later validation cc.serveSig = computeHMAC(cc.hashGen, dk.ServerKey, []byte(authMsg)) return fmt.Sprintf("%s,p=%s", c2wop, proof), nil } func (cc *ClientConversation) validateServer(s2 string) (string, error) { msg, err := parseServerFinal(s2) if err != nil { return "", err } if len(msg.err) > 0 { return "", fmt.Errorf("server error: %s", msg.err) } if !hmac.Equal(msg.verifier, cc.serveSig) { return "", errors.New("server validation failed") } cc.valid = true return "", nil } func (cc *ClientConversation) gs2Header() string { var cbFlag string if cc.channelBinding.IsSupported() { // Client is using channel binding with specific type cbFlag = fmt.Sprintf("p=%s", cc.channelBinding.Type) } else if cc.advertiseChannelBinding { // Client supports channel binding but server didn't advertise PLUS cbFlag = "y" } else { // Client doesn't support channel binding cbFlag = "n" } authzPart := "" if cc.client.authzID != "" { authzPart = "a=" + encodeName(cc.client.authzID) } return fmt.Sprintf("%s,%s,", cbFlag, authzPart) } xdg-go-scram-788bb0f/client_conv_test.go000066400000000000000000000037461511106010400203140ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "strconv" "testing" ) func TestClientConv(t *testing.T) { cases, err := getTestData("good", "bad-server") if err != nil { t.Fatal(err) } for _, v := range cases { t.Run(v.Label, genClientSubTest(v)) } } func genClientSubTest(c TestCase) func(t *testing.T) { return func(t *testing.T) { hgf, err := getHGF(c.Digest) if err != nil { t.Fatal(err) } var client *Client if c.SkipSASLprep { client, err = hgf.NewClientUnprepped(c.User, c.Pass, c.AuthzID) } else { client, err = hgf.NewClient(c.User, c.Pass, c.AuthzID) } if err != nil { t.Errorf("%s: expected no error from NewClient, but got '%v'", c.Label, err) } if c.ClientNonce != "" { client = client.WithNonceGenerator(func() string { return c.ClientNonce }) } conv := client.NewConversation() for i, s := range clientSteps(c) { if conv.Done() { t.Errorf("%s: Premature end of conversation before step %d", c.Label, i+1) return } got, err := conv.Step(s.Input) if s.IsError && err == nil { t.Errorf("%s: step %d: expected error but didn't get one", c.Label, i+1) return } else if !s.IsError && err != nil { t.Errorf("%s: step %d: expected no error but got '%v'", c.Label, i+1, err) return } if got != s.Expect { t.Errorf("%s: step %d: incorrect step message; got %s, expected %s", c.Label, i+1, strconv.QuoteToASCII(got), strconv.QuoteToASCII(s.Expect), ) return } } if c.Valid != conv.Valid() { t.Errorf("%s: Conversation Valid() incorrect: got '%v', expected '%v'", c.Label, conv.Valid(), c.Valid) return } if !conv.Done() { t.Errorf("%s: Conversation not marked done after last step", c.Label) } } } xdg-go-scram-788bb0f/common.go000066400000000000000000000111661511106010400162350ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "crypto/hmac" "crypto/rand" "encoding/base64" "errors" "strings" ) // NonceGeneratorFcn defines a function that returns a string of high-quality // random printable ASCII characters EXCLUDING the comma (',') character. The // default nonce generator provides Base64 encoding of 24 bytes from // crypto/rand. type NonceGeneratorFcn func() string // derivedKeys collects the three cryptographically derived values // into one struct for caching. type derivedKeys struct { ClientKey []byte StoredKey []byte ServerKey []byte } // KeyFactors represent the two server-provided factors needed to compute // client credentials for authentication. Salt is decoded bytes (i.e. not // base64), but in string form so that KeyFactors can be used as a map key for // cached credentials. type KeyFactors struct { Salt string Iters int } // StoredCredentials are the values that a server must store for a given // username to allow authentication. They include the salt and iteration // count, plus the derived values to authenticate a client and for the server // to authenticate itself back to the client. // // NOTE: these are specific to a given hash function. To allow a user to // authenticate with either SCRAM-SHA-1 or SCRAM-SHA-256, two sets of // StoredCredentials must be created and stored, one for each hash function. type StoredCredentials struct { KeyFactors StoredKey []byte ServerKey []byte } // CredentialLookup is a callback to provide StoredCredentials for a given // username. This is used to configure Server objects. // // NOTE: these are specific to a given hash function. The callback provided // to a Server with a given hash function must provide the corresponding // StoredCredentials. type CredentialLookup func(string) (StoredCredentials, error) // Server error values as defined in RFC-5802 and RFC-7677. These are returned // by the server in error responses as "e=". const ( // ErrInvalidEncoding indicates the client message had invalid encoding ErrInvalidEncoding = "e=invalid-encoding" // ErrExtensionsNotSupported indicates unrecognized 'm' value ErrExtensionsNotSupported = "e=extensions-not-supported" // ErrInvalidProof indicates the authentication proof from the client was invalid ErrInvalidProof = "e=invalid-proof" // ErrChannelBindingsDontMatch indicates channel binding data didn't match expected value ErrChannelBindingsDontMatch = "e=channel-bindings-dont-match" // ErrServerDoesSupportChannelBinding indicates server does support channel // binding. This is returned if a downgrade attack is detected or if the // client does not support binding and channel binding is required. ErrServerDoesSupportChannelBinding = "e=server-does-support-channel-binding" // ErrChannelBindingNotSupported indicates channel binding is not supported ErrChannelBindingNotSupported = "e=channel-binding-not-supported" // ErrUnsupportedChannelBindingType indicates the requested channel binding type is not supported ErrUnsupportedChannelBindingType = "e=unsupported-channel-binding-type" // ErrUnknownUser indicates the specified user does not exist ErrUnknownUser = "e=unknown-user" // ErrInvalidUsernameEncoding indicates invalid username encoding (invalid UTF-8 or SASLprep failed) ErrInvalidUsernameEncoding = "e=invalid-username-encoding" // ErrNoResources indicates the server is out of resources ErrNoResources = "e=no-resources" // ErrOtherError is a catch-all for unspecified errors. The server may substitute // the real reason with this error to prevent information disclosure. ErrOtherError = "e=other-error" ) func defaultNonceGenerator() string { raw := make([]byte, 24) nonce := make([]byte, base64.StdEncoding.EncodedLen(len(raw))) _, _ = rand.Read(raw) base64.StdEncoding.Encode(nonce, raw) return string(nonce) } func encodeName(s string) string { return strings.Replace(strings.Replace(s, "=", "=3D", -1), ",", "=2C", -1) } func computeHash(hg HashGeneratorFcn, b []byte) []byte { h := hg() h.Write(b) return h.Sum(nil) } func computeHMAC(hg HashGeneratorFcn, key, data []byte) []byte { mac := hmac.New(hg, key) mac.Write(data) return mac.Sum(nil) } func xorBytes(a, b []byte) ([]byte, error) { if len(a) != len(b) { return nil, errors.New("internal error: xorBytes arguments must have equal length") } xor := make([]byte, len(a)) for i := range a { xor[i] = a[i] ^ b[i] } return xor, nil } xdg-go-scram-788bb0f/common_test.go000066400000000000000000000014001511106010400172620ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import "testing" func TestEncodeName(t *testing.T) { cases := []struct { input string expect string }{ {input: "arthur", expect: "arthur"}, {input: "doe,jane", expect: "doe=2Cjane"}, {input: "a,b,c,d", expect: "a=2Cb=2Cc=2Cd"}, {input: "a,b=c,d=", expect: "a=2Cb=3Dc=2Cd=3D"}, } for _, c := range cases { if got := encodeName(c.input); got != c.expect { t.Errorf("Failed encoding '%s', got '%s', expected '%s'", c.input, got, c.expect) } } } xdg-go-scram-788bb0f/doc.go000066400000000000000000000053071511106010400155120ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Package scram provides client and server implementations of the Salted // Challenge Response Authentication Mechanism (SCRAM) described in RFC-5802, // RFC-7677, and RFC-9266. // // # Usage // // The scram package provides variables, `SHA1`, `SHA256`, and `SHA512`, that // are used to construct Client or Server objects. // // clientSHA1, err := scram.SHA1.NewClient(username, password, authID) // clientSHA256, err := scram.SHA256.NewClient(username, password, authID) // clientSHA512, err := scram.SHA512.NewClient(username, password, authID) // // serverSHA1, err := scram.SHA1.NewServer(credentialLookupFcn) // serverSHA256, err := scram.SHA256.NewServer(credentialLookupFcn) // serverSHA512, err := scram.SHA512.NewServer(credentialLookupFcn) // // These objects are used to construct ClientConversation or // ServerConversation objects that are used to carry out authentication. // // clientConv := client.NewConversation() // serverConv := server.NewConversation() // // # Channel Binding (SCRAM-PLUS) // // The scram package supports channel binding for SCRAM-PLUS authentication // variants as described in RFC-5802, RFC-5929, and RFC-9266. Channel binding // cryptographically binds the SCRAM authentication to an underlying TLS // connection, preventing man-in-the-middle attacks. // // To use channel binding, create conversations with channel binding data // obtained from the TLS connection: // // // Client example with tls-exporter (TLS 1.3+) // client, _ := scram.SHA256.NewClient(username, password, "") // channelBinding, _ := scram.NewTLSExporterBinding(&tlsConn.ConnectionState()) // clientConv := client.NewConversationWithChannelBinding(channelBinding) // // // Server conversation with the same channel binding // server, _ := scram.SHA256.NewServer(credentialLookupFcn) // serverConv := server.NewConversationWithChannelBinding(channelBinding) // // Helper functions are provided to create ChannelBinding values from TLS connections: // - NewTLSServerEndpointBinding: Uses server certificate hash (RFC 5929, all TLS versions) // - NewTLSExporterBinding: Uses exported keying material (RFC 9266, recommended for TLS 1.3+) // // Channel binding is configured on conversations rather than clients or servers // because binding data is connection-specific. // // Channel binding type negotiation is not defined by the SCRAM protocol. // Applications must ensure both client and server agree on the same channel binding // type. package scram xdg-go-scram-788bb0f/doc_test.go000066400000000000000000000020711511106010400165440ustar00rootroot00000000000000package scram_test import "github.com/xdg-go/scram" func Example() { // Get Client with username, password and (optional) authorization ID. clientSHA1, err := scram.SHA1.NewClient("mulder", "trustno1", "") if err != nil { panic(err) } // Prepare the authentication conversation. Use the empty string as the // initial server message argument to start the conversation. conv := clientSHA1.NewConversation() var serverMsg string // Get the first message, send it and read the response. firstMsg, err := conv.Step(serverMsg) if err != nil { panic(err) } serverMsg = sendClientMsg(firstMsg) // Get the second message, send it, and read the response. secondMsg, err := conv.Step(serverMsg) if err != nil { panic(err) } serverMsg = sendClientMsg(secondMsg) // Validate the server's final message. We have no further message to // send so ignore that return value. _, err = conv.Step(serverMsg) if err != nil { panic(err) } } func sendClientMsg(s string) string { // A real implementation would send this to a server and read a reply. return "" } xdg-go-scram-788bb0f/example_channel_binding_test.go000066400000000000000000000037261511106010400226240ustar00rootroot00000000000000// Copyright 2025 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram_test import ( "fmt" "github.com/xdg-go/scram" ) // ExampleClient_channelBindingModes demonstrates the three channel binding modes // available when creating authentication conversations. func Example_channelBindingModes() { client, _ := scram.SHA256.NewClient("user", "password", "") // Mode 1: No channel binding support // Use when: Application doesn't support channel binding at all // GS2 header: "n,," conv1 := client.NewConversation() msg1, _ := conv1.Step("") fmt.Printf("Mode 1 GS2 header: %s\n", msg1[:3]) // Mode 2: Advertise channel binding support // Use when: Application supports CB but server didn't advertise PLUS variants // Example: Server advertised "SCRAM-SHA-256" but not "SCRAM-SHA-256-PLUS" // GS2 header: "y,," // Security: Helps detect downgrade attacks (MITM stripping PLUS from server list) conv2 := client.NewConversationAdvertisingChannelBinding() msg2, _ := conv2.Step("") fmt.Printf("Mode 2 GS2 header: %s\n", msg2[:3]) // Mode 3: Use channel binding // Use when: Server advertised PLUS variant AND app has TLS connection state // GS2 header: "p=,," // Note: In real code, get connState from actual TLS connection // var connState *tls.ConnectionState = tlsConn.ConnectionState() // cb, _ := scram.NewTLSExporterBinding(connState) // // For example purposes, create a dummy channel binding. cb := scram.ChannelBinding{ Type: scram.ChannelBindingTLSExporter, Data: []byte("example-cb-data"), } conv3 := client.NewConversationWithChannelBinding(cb) msg3, _ := conv3.Step("") fmt.Printf("Mode 3 GS2 header: %s\n", msg3[:16]) // Output: // Mode 1 GS2 header: n,, // Mode 2 GS2 header: y,, // Mode 3 GS2 header: p=tls-exporter,, } xdg-go-scram-788bb0f/go.mod000066400000000000000000000002511511106010400155150ustar00rootroot00000000000000module github.com/xdg-go/scram go 1.18 require ( github.com/xdg-go/pbkdf2 v1.0.0 github.com/xdg-go/stringprep v1.0.4 ) require golang.org/x/text v0.3.8 // indirect xdg-go-scram-788bb0f/go.sum000066400000000000000000000054471511106010400155560ustar00rootroot00000000000000github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= xdg-go-scram-788bb0f/parse.go000066400000000000000000000107121511106010400160530ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "encoding/base64" "errors" "fmt" "strconv" "strings" ) type c1Msg struct { gs2Header string gs2BindFlag string // "n", "y", or "p" channelBinding string // channel binding type name if gs2BindFlag is "p" authzID string username string nonce string c1b string } type c2Msg struct { cbind []byte nonce string proof []byte c2wop string } type s1Msg struct { nonce string salt []byte iters int } type s2Msg struct { verifier []byte err string } func parseField(s, k string) (string, error) { t := strings.TrimPrefix(s, k+"=") if t == s { return "", fmt.Errorf("error parsing '%s' for field '%s'", s, k) } return t, nil } // parseGS2Flag returns flag, channel binding type, and error. func parseGS2Flag(s string) (string, string, error) { if s == "n" || s == "y" { return s, "", nil } // If not "n" or "y", must be "p=..." or error. cbType, err := parseField(s, "p") if err != nil { return "", "", fmt.Errorf("error parsing '%s' for gs2 flag", s) } switch ChannelBindingType(cbType) { case ChannelBindingTLSUnique, ChannelBindingTLSServerEndpoint, ChannelBindingTLSExporter: // valid channel binding type default: return "", "", fmt.Errorf("invalid channel binding type: %s", cbType) } return "p", cbType, nil } func parseFieldBase64(s, k string) ([]byte, error) { raw, err := parseField(s, k) if err != nil { return nil, err } dec, err := base64.StdEncoding.DecodeString(raw) if err != nil { return nil, fmt.Errorf("failed decoding field '%s': %v", k, err) } return dec, nil } func parseFieldInt(s, k string) (int, error) { raw, err := parseField(s, k) if err != nil { return 0, err } num, err := strconv.Atoi(raw) if err != nil { return 0, fmt.Errorf("error parsing field '%s': %v", k, err) } return num, nil } func parseClientFirst(c1 string) (msg c1Msg, err error) { fields := strings.Split(c1, ",") if len(fields) < 4 { err = errors.New("not enough fields in first server message") return } msg.gs2BindFlag, msg.channelBinding, err = parseGS2Flag(fields[0]) if err != nil { return } // authzID content is optional, but the field must be present. if len(fields[1]) > 0 { msg.authzID, err = parseField(fields[1], "a") if err != nil { return } } // Check for unsupported extensions field "m". if strings.HasPrefix(fields[2], "m=") { err = errors.New("SCRAM message extensions are not supported") return } msg.username, err = parseField(fields[2], "n") if err != nil { return } msg.nonce, err = parseField(fields[3], "r") if err != nil { return } // Recombine the gs2Header: gs2-cbind-flag "," [ authzid ] "," msg.gs2Header = fields[0] + "," + fields[1] + "," // Recombine the client-first-message-bare: username "," nonce msg.c1b = strings.Join(fields[2:], ",") return } func parseClientFinal(c2 string) (msg c2Msg, err error) { fields := strings.Split(c2, ",") if len(fields) < 3 { err = errors.New("not enough fields in first server message") return } msg.cbind, err = parseFieldBase64(fields[0], "c") if err != nil { return } msg.nonce, err = parseField(fields[1], "r") if err != nil { return } // Extension fields may come between nonce and proof, so we // grab the *last* fields as proof. msg.proof, err = parseFieldBase64(fields[len(fields)-1], "p") if err != nil { return } msg.c2wop = c2[:strings.LastIndex(c2, ",")] return } func parseServerFirst(s1 string) (msg s1Msg, err error) { // Check for unsupported extensions field "m". if strings.HasPrefix(s1, "m=") { err = errors.New("SCRAM message extensions are not supported") return } fields := strings.Split(s1, ",") if len(fields) < 3 { err = errors.New("not enough fields in first server message") return } msg.nonce, err = parseField(fields[0], "r") if err != nil { return } msg.salt, err = parseFieldBase64(fields[1], "s") if err != nil { return } msg.iters, err = parseFieldInt(fields[2], "i") return } func parseServerFinal(s2 string) (msg s2Msg, err error) { fields := strings.Split(s2, ",") msg.verifier, err = parseFieldBase64(fields[0], "v") if err == nil { return } msg.err, err = parseField(fields[0], "e") return } xdg-go-scram-788bb0f/pbkdf2_go124.go000066400000000000000000000010041511106010400170170ustar00rootroot00000000000000// Copyright 2025 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 //go:build go1.24 package scram import ( "crypto/pbkdf2" "hash" ) func pbkdf2Key(h func() hash.Hash, password string, salt []byte, iter, keyLength int) ([]byte, error) { return pbkdf2.Key(h, password, salt, iter, keyLength) } xdg-go-scram-788bb0f/pbkdf2_legacy.go000066400000000000000000000010361511106010400174340ustar00rootroot00000000000000// Copyright 2025 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 //go:build !go1.24 package scram import ( "hash" "github.com/xdg-go/pbkdf2" ) func pbkdf2Key(h func() hash.Hash, password string, salt []byte, iter, keyLength int) ([]byte, error) { return pbkdf2.Key([]byte(password), salt, iter, keyLength, h), nil } xdg-go-scram-788bb0f/scram.go000066400000000000000000000057351511106010400160570ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "crypto/sha1" "crypto/sha256" "crypto/sha512" "fmt" "hash" "github.com/xdg-go/stringprep" ) // HashGeneratorFcn abstracts a factory function that returns a hash.Hash // value to be used for SCRAM operations. Generally, one would use the // provided package variables, `scram.SHA1` and `scram.SHA256`, for the most // common forms of SCRAM. type HashGeneratorFcn func() hash.Hash // SHA1 is a function that returns a crypto/sha1 hasher and should be used to // create Client objects configured for SHA-1 hashing. var SHA1 HashGeneratorFcn = func() hash.Hash { return sha1.New() } // SHA256 is a function that returns a crypto/sha256 hasher and should be used // to create Client objects configured for SHA-256 hashing. var SHA256 HashGeneratorFcn = func() hash.Hash { return sha256.New() } // SHA512 is a function that returns a crypto/sha512 hasher and should be used // to create Client objects configured for SHA-512 hashing. var SHA512 HashGeneratorFcn = func() hash.Hash { return sha512.New() } // NewClient constructs a SCRAM client component based on a given hash.Hash // factory receiver. This constructor will normalize the username, password // and authzID via the SASLprep algorithm, as recommended by RFC-5802. If // SASLprep fails, the method returns an error. func (f HashGeneratorFcn) NewClient(username, password, authzID string) (*Client, error) { var userprep, passprep, authprep string var err error if userprep, err = stringprep.SASLprep.Prepare(username); err != nil { return nil, fmt.Errorf("Error SASLprepping username '%s': %v", username, err) } if passprep, err = stringprep.SASLprep.Prepare(password); err != nil { return nil, fmt.Errorf("Error SASLprepping password '%s': %v", password, err) } if authprep, err = stringprep.SASLprep.Prepare(authzID); err != nil { return nil, fmt.Errorf("Error SASLprepping authzID '%s': %v", authzID, err) } return newClient(userprep, passprep, authprep, f), nil } // NewClientUnprepped acts like NewClient, except none of the arguments will // be normalized via SASLprep. This is not generally recommended, but is // provided for users that may have custom normalization needs. func (f HashGeneratorFcn) NewClientUnprepped(username, password, authzID string) (*Client, error) { return newClient(username, password, authzID, f), nil } // NewServer constructs a SCRAM server component based on a given hash.Hash // factory receiver. To be maximally generic, it uses dependency injection to // handle credential lookup, which is the process of turning a username string // into a struct with stored credentials for authentication. func (f HashGeneratorFcn) NewServer(cl CredentialLookup) (*Server, error) { return newServer(cl, f) } xdg-go-scram-788bb0f/server.go000066400000000000000000000100041511106010400162410ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import "sync" // Server implements the server side of SCRAM authentication. It holds // configuration values needed to initialize new server-side conversations. // Generally, this can be persistent within an application. type Server struct { sync.RWMutex credentialCB CredentialLookup nonceGen NonceGeneratorFcn hashGen HashGeneratorFcn } func newServer(cl CredentialLookup, fcn HashGeneratorFcn) (*Server, error) { return &Server{ credentialCB: cl, nonceGen: defaultNonceGenerator, hashGen: fcn, }, nil } // WithNonceGenerator replaces the default nonce generator (base64 encoding of // 24 bytes from crypto/rand) with a custom generator. This is provided for // testing or for users with custom nonce requirements. func (s *Server) WithNonceGenerator(ng NonceGeneratorFcn) *Server { s.Lock() defer s.Unlock() s.nonceGen = ng return s } // NewConversation constructs a server-side authentication conversation. // Conversations cannot be reused, so this must be called for each new // authentication attempt. func (s *Server) NewConversation() *ServerConversation { s.RLock() defer s.RUnlock() return &ServerConversation{ nonceGen: s.nonceGen, hashGen: s.hashGen, credentialCB: s.credentialCB, } } // NewConversationWithChannelBinding constructs a server-side authentication // conversation with channel binding for SCRAM-PLUS authentication. // // This signals that the server advertised PLUS mechanism variants (e.g., // SCRAM-SHA-256-PLUS) during SASL negotiation, but channel binding is NOT required. // Clients may authenticate using either the base mechanism (e.g., SCRAM-SHA-256) // or the PLUS variant (e.g., SCRAM-SHA-256-PLUS). // // The server will: // - Accept clients without channel binding support (using "n" flag) // - Accept clients with matching channel binding (using "p" flag) // - Reject downgrade attacks (clients using "y" flag when PLUS was advertised) // // Channel binding is connection-specific, so a new conversation should be // created for each connection being authenticated. // Conversations cannot be reused, so this must be called for each new // authentication attempt. func (s *Server) NewConversationWithChannelBinding(cb ChannelBinding) *ServerConversation { s.RLock() defer s.RUnlock() return &ServerConversation{ nonceGen: s.nonceGen, hashGen: s.hashGen, credentialCB: s.credentialCB, channelBinding: cb, } } // NewConversationWithChannelBindingRequired constructs a server-side authentication // conversation with mandatory channel binding for SCRAM-PLUS authentication. // // This signals that the server advertised ONLY SCRAM-PLUS mechanism variants // (e.g., only SCRAM-SHA-256-PLUS, not the base SCRAM-SHA-256) during SASL negotiation. // Channel binding is required for all authentication attempts. // // The server will: // - Accept only clients with matching channel binding (using "p" flag) // - Reject clients without channel binding support (using "n" flag) // - Reject downgrade attacks (clients using "y" flag when PLUS was advertised) // // This is intended for high-security deployments that advertise only SCRAM-PLUS // variants and want to enforce channel binding as mandatory. // // Channel binding is connection-specific, so a new conversation should be // created for each connection being authenticated. // Conversations cannot be reused, so this must be called for each new // authentication attempt. func (s *Server) NewConversationWithChannelBindingRequired(cb ChannelBinding) *ServerConversation { s.RLock() defer s.RUnlock() return &ServerConversation{ nonceGen: s.nonceGen, hashGen: s.hashGen, credentialCB: s.credentialCB, channelBinding: cb, requireChannelBinding: true, } } xdg-go-scram-788bb0f/server_conv.go000066400000000000000000000166041511106010400173020ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "crypto/hmac" "encoding/base64" "errors" "fmt" ) type serverState int const ( serverFirst serverState = iota serverFinal serverDone ) // ServerConversation implements the server-side of an authentication // conversation with a client. A new conversation must be created for // each authentication attempt. type ServerConversation struct { nonceGen NonceGeneratorFcn hashGen HashGeneratorFcn credentialCB CredentialLookup state serverState credential StoredCredentials valid bool gs2Header string username string authzID string nonce string c1b string s1 string channelBinding ChannelBinding requireChannelBinding bool clientCBType string clientCBFlag string } // Step takes a string provided from a client and attempts to move the // authentication conversation forward. It returns a string to be sent to the // client or an error if the client message is invalid. Calling Step after a // conversation completes is also an error. func (sc *ServerConversation) Step(challenge string) (response string, err error) { switch sc.state { case serverFirst: sc.state = serverFinal response, err = sc.firstMsg(challenge) case serverFinal: sc.state = serverDone response, err = sc.finalMsg(challenge) default: response, err = "", errors.New("Conversation already completed") } return } // Done returns true if the conversation is completed or has errored. func (sc *ServerConversation) Done() bool { return sc.state == serverDone } // Valid returns true if the conversation successfully authenticated the // client. func (sc *ServerConversation) Valid() bool { return sc.valid } // Username returns the client-provided username. This is valid to call // if the first conversation Step() is successful. func (sc *ServerConversation) Username() string { return sc.username } // AuthzID returns the (optional) client-provided authorization identity, if // any. If one was not provided, it returns the empty string. This is valid // to call if the first conversation Step() is successful. func (sc *ServerConversation) AuthzID() string { return sc.authzID } // validateChannelBindingFlag validates the client's channel binding flag against // server configuration. The validation logic follows RFC 5802 section 6, but // extends those semantics to cover the case of required channel binding. // // Client flag validation: // - "n": Client doesn't support channel binding // - "y": Client supports channel binding but server didn't advertise PLUS // - "p": Client requires channel binding with specific type // // Returns server error string (empty if validation passes) and error. func (sc *ServerConversation) validateChannelBindingFlag() (string, error) { advertised := sc.channelBinding.IsSupported() switch sc.clientCBFlag { case "n": // Client doesn't support channel binding if sc.requireChannelBinding { // Policy violation: server requires channel binding // Use ErrServerDoesSupportChannelBinding (defined for downgrade attacks) // as the best available match to signal that server requires channel binding return ErrServerDoesSupportChannelBinding, errors.New("server requires channel binding but client doesn't support it") } // OK: server either doesn't advertise PLUS or advertises it optionally return "", nil case "y": // Client supports channel binding but thinks server doesn't advertise PLUS if advertised { // Downgrade attack: we advertised PLUS but client didn't see it return ErrServerDoesSupportChannelBinding, errors.New("downgrade attack detected: client used 'y' but server advertised PLUS") } // OK: we didn't advertise PLUS, client correctly detected this return "", nil case "p": // Client requires channel binding with specific type if !advertised { // Server doesn't support channel binding return ErrChannelBindingNotSupported, errors.New("client requires channel binding but server doesn't support it") } if ChannelBindingType(sc.clientCBType) != sc.channelBinding.Type { // Server supports channel binding but not the requested type return ErrUnsupportedChannelBindingType, fmt.Errorf("client requested %s but server only supports %s", sc.clientCBType, sc.channelBinding.Type) } // OK: channel binding type matches return "", nil default: // Invalid flag (should have been caught by parser) return ErrOtherError, fmt.Errorf("invalid channel binding flag: %s", sc.clientCBFlag) } } func (sc *ServerConversation) firstMsg(c1 string) (string, error) { msg, err := parseClientFirst(c1) if err != nil { sc.state = serverDone return "", err } sc.gs2Header = msg.gs2Header sc.clientCBFlag = msg.gs2BindFlag sc.clientCBType = msg.channelBinding sc.username = msg.username sc.authzID = msg.authzID // Validate channel binding flag against server configuration if serverErr, err := sc.validateChannelBindingFlag(); err != nil { sc.state = serverDone return serverErr, err } sc.credential, err = sc.credentialCB(msg.username) if err != nil { sc.state = serverDone return ErrUnknownUser, err } sc.nonce = msg.nonce + sc.nonceGen() sc.c1b = msg.c1b sc.s1 = fmt.Sprintf("r=%s,s=%s,i=%d", sc.nonce, base64.StdEncoding.EncodeToString([]byte(sc.credential.Salt)), sc.credential.Iters, ) return sc.s1, nil } // For errors, returns server error message as well as non-nil error. Callers // can choose whether to send server error or not. func (sc *ServerConversation) finalMsg(c2 string) (string, error) { msg, err := parseClientFinal(c2) if err != nil { return "", err } // Check channel binding data matches what we expect var expectedCBind []byte if sc.clientCBFlag == "p" { // Client used channel binding - expect gs2 header + channel binding data expectedCBind = append([]byte(sc.gs2Header), sc.channelBinding.Data...) } else { // Client didn't use channel binding - just expect gs2 header expectedCBind = []byte(sc.gs2Header) } if !hmac.Equal(msg.cbind, expectedCBind) { return ErrChannelBindingsDontMatch, fmt.Errorf("channel binding mismatch: expected %x, got %x", expectedCBind, msg.cbind) } // Check nonce received matches what we sent if msg.nonce != sc.nonce { return ErrOtherError, errors.New("nonce received did not match nonce sent") } // Create auth message authMsg := sc.c1b + "," + sc.s1 + "," + msg.c2wop // Retrieve ClientKey from proof and verify it clientSignature := computeHMAC(sc.hashGen, sc.credential.StoredKey, []byte(authMsg)) clientKey, err := xorBytes([]byte(msg.proof), clientSignature) if err != nil { return ErrOtherError, err } storedKey := computeHash(sc.hashGen, clientKey) // Compare with constant-time function if !hmac.Equal(storedKey, sc.credential.StoredKey) { return ErrInvalidProof, errors.New("challenge proof invalid") } sc.valid = true // Compute and return server verifier serverSignature := computeHMAC(sc.hashGen, sc.credential.ServerKey, []byte(authMsg)) return "v=" + base64.StdEncoding.EncodeToString(serverSignature), nil } xdg-go-scram-788bb0f/server_conv_test.go000066400000000000000000000073301511106010400203350ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "encoding/base64" "fmt" "strconv" "testing" "github.com/xdg-go/stringprep" ) func TestServerConv(t *testing.T) { cases, err := getTestData("good", "bad-client") if err != nil { t.Fatal(err) } for _, v := range cases { t.Run(v.Label, genServerSubTest(v)) } } // Prep user credential callback for the case from Client func genServerCallback(c TestCase) (CredentialLookup, error) { salt, err := base64.StdEncoding.DecodeString(c.Salt64) if err != nil { return nil, fmt.Errorf("error decoding salt: %v", err) } hgf, err := getHGF(c.Digest) if err != nil { return nil, fmt.Errorf("error getting digest for credential callback: %v", err) } kf := KeyFactors{Salt: string(salt), Iters: c.Iters} var client *Client var userprep string if c.SkipSASLprep { client, err = hgf.NewClientUnprepped(c.User, c.Pass, c.AuthzID) userprep = c.User } else { client, err = hgf.NewClient(c.User, c.Pass, c.AuthzID) if err != nil { return nil, fmt.Errorf("error creating client for credential callback: %v", err) } if userprep, err = stringprep.SASLprep.Prepare(c.User); err != nil { return nil, fmt.Errorf("Error SASLprepping username '%s': %v", c.User, err) } } if err != nil { return nil, fmt.Errorf("error generating client for credential callback: %v", err) } stored, err := client.GetStoredCredentialsWithError(kf) if err != nil { return nil, fmt.Errorf("error getting stored credentials: %w", err) } cbFcn := func(s string) (StoredCredentials, error) { if s == userprep { return stored, nil } return StoredCredentials{}, fmt.Errorf("Unknown user %s", s) } return cbFcn, nil } func genServerSubTest(c TestCase) func(t *testing.T) { return func(t *testing.T) { hgf, err := getHGF(c.Digest) if err != nil { t.Fatal(err) } cbFcn, err := genServerCallback(c) if err != nil { t.Fatal(err) } server, err := hgf.NewServer(cbFcn) if err != nil { t.Fatalf("%s: expected no error from NewServer, but got '%v'", c.Label, err) } if c.ServerNonce != "" { server = server.WithNonceGenerator(func() string { return c.ServerNonce }) } conv := server.NewConversation() for i, s := range serverSteps(c) { if conv.Done() { t.Errorf("%s: Premature end of conversation before step %d", c.Label, i+1) return } got, err := conv.Step(s.Input) if s.IsError && err == nil { t.Errorf("%s: step %d: expected error but didn't get one", c.Label, i+1) return } else if !s.IsError && err != nil { t.Errorf("%s: step %d: expected no error but got '%v'", c.Label, i+1, err) return } if got != s.Expect { t.Errorf("%s: step %d: incorrect step message; got %s, expected %s", c.Label, i+1, strconv.QuoteToASCII(got), strconv.QuoteToASCII(s.Expect), ) return } } if c.Valid != conv.Valid() { t.Errorf("%s: Conversation Valid() incorrect: got '%v', expected '%v'", c.Label, conv.Valid(), c.Valid) return } if !conv.Done() { t.Errorf("%s: Conversation not marked done after last step", c.Label) } var expectedUser string if c.SkipSASLprep { expectedUser = c.User } else { if expectedUser, err = stringprep.SASLprep.Prepare(c.User); err != nil { t.Errorf("Error SASLprepping username '%s': %v", c.User, err) } } if conv.Valid() && conv.Username() != expectedUser { t.Errorf("%s: Conversation didn't record proper username: got '%s', expected '%s'", c.Label, conv.Username(), expectedUser) } } } xdg-go-scram-788bb0f/testdata/000077500000000000000000000000001511106010400162225ustar00rootroot00000000000000xdg-go-scram-788bb0f/testdata/bad-client/000077500000000000000000000000001511106010400202245ustar00rootroot00000000000000xdg-go-scram-788bb0f/testdata/bad-client/bad-user.json000066400000000000000000000006271511106010400226260ustar00rootroot00000000000000{ "label": "unknown user", "digest": "SHA-1", "user": "user", "pass": "pencil", "authID": "", "skipSASLprep": false, "salt64": "QSXCR+Q6sek8bf92", "iters": 4096, "clientNonce": "fyko+d2lbbFgONRv9qkxdawL", "serverNonce": "3rfcNHYJY1ZVvWVs7j", "valid": false, "steps" : [ "n,,n=doesntexist,r=fyko+d2lbbFgONRv9qkxdawL", "e=unknown-user" ] } xdg-go-scram-788bb0f/testdata/bad-client/rfc5802-bad-proof.json000066400000000000000000000011241511106010400240550ustar00rootroot00000000000000{ "label": "RFC 5802 example with bad proof", "digest": "SHA-1", "user": "user", "pass": "pencil", "authID": "", "skipSASLprep": false, "salt64": "QSXCR+Q6sek8bf92", "iters": 4096, "clientNonce": "fyko+d2lbbFgONRv9qkxdawL", "serverNonce": "3rfcNHYJY1ZVvWVs7j", "valid": false, "steps" : [ "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL", "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096", "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=AAAAAAAAAAAAAAAAAAAAAAAAAAA=", "e=invalid-proof" ] } xdg-go-scram-788bb0f/testdata/bad-client/rfc7677-bad-proof.json000066400000000000000000000012121511106010400240670ustar00rootroot00000000000000{ "label": "RFC 7677 example with bad proof", "digest": "SHA-256", "user": "user", "pass": "pencil", "authID": "", "skipSASLprep": false, "salt64": "W22ZaJ0SNY7soEsUEjb6gQ==", "iters": 4096, "clientNonce": "rOprNGfwEbeRWgbNEkqO", "serverNonce": "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "valid": false, "steps" : [ "n,,n=user,r=rOprNGfwEbeRWgbNEkqO", "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "e=invalid-proof" ] } xdg-go-scram-788bb0f/testdata/bad-server/000077500000000000000000000000001511106010400202545ustar00rootroot00000000000000xdg-go-scram-788bb0f/testdata/bad-server/rfc5802-bad-validator.json000066400000000000000000000011501511106010400247440ustar00rootroot00000000000000{ "label": "RFC 5802 example with bad validation", "digest": "SHA-1", "user": "user", "pass": "pencil", "authID": "", "skipSASLprep": false, "salt64": "QSXCR+Q6sek8bf92", "iters": 4096, "clientNonce": "fyko+d2lbbFgONRv9qkxdawL", "serverNonce": "3rfcNHYJY1ZVvWVs7j", "valid": false, "steps" : [ "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL", "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096", "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=", "v=AAAAAAAAAAAAAAAAAAAAAAAAAAA=" ] } xdg-go-scram-788bb0f/testdata/bad-server/rfc7677-bad-validator.json000066400000000000000000000012561511106010400247670ustar00rootroot00000000000000{ "label": "RFC 7677 example with bad validation", "digest": "SHA-256", "user": "user", "pass": "pencil", "authID": "", "skipSASLprep": false, "salt64": "W22ZaJ0SNY7soEsUEjb6gQ==", "iters": 4096, "clientNonce": "rOprNGfwEbeRWgbNEkqO", "serverNonce": "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "valid": false, "steps" : [ "n,,n=user,r=rOprNGfwEbeRWgbNEkqO", "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=", "v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" ] } xdg-go-scram-788bb0f/testdata/good/000077500000000000000000000000001511106010400171525ustar00rootroot00000000000000xdg-go-scram-788bb0f/testdata/good/rfc5802.json000066400000000000000000000011371511106010400211400ustar00rootroot00000000000000{ "label": "RFC 5802 example", "digest": "SHA-1", "user": "user", "pass": "pencil", "authID": "", "skipSASLprep": false, "salt64": "QSXCR+Q6sek8bf92", "iters": 4096, "clientNonce": "fyko+d2lbbFgONRv9qkxdawL", "serverNonce": "3rfcNHYJY1ZVvWVs7j", "valid": true, "steps" : [ "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL", "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096", "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=", "v=rmF9pqV8S7suAoZWja4dJRkFsKQ=", "" ] } xdg-go-scram-788bb0f/testdata/good/rfc7677.json000066400000000000000000000012451511106010400211540ustar00rootroot00000000000000{ "label": "RFC 7677 example", "digest": "SHA-256", "user": "user", "pass": "pencil", "authID": "", "skipSASLprep": false, "salt64": "W22ZaJ0SNY7soEsUEjb6gQ==", "iters": 4096, "clientNonce": "rOprNGfwEbeRWgbNEkqO", "serverNonce": "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "valid": true, "steps" : [ "n,,n=user,r=rOprNGfwEbeRWgbNEkqO", "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=", "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-1-ascii-pass.json000066400000000000000000000010271511106010400230100ustar00rootroot00000000000000{ "label" : "SHA-1 ASCII pass", "digest" : "SHA-1", "user" : "ram\u00f5n", "pass" : "pencil", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=ram\u00f5n,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=kvH02DJiH7oHwk+SKpN4plfpF04=", "v=BoA2mAPlV/b9A5WPDbHmHZi3EGc=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-1-ascii-user.json000066400000000000000000000010201511106010400230110ustar00rootroot00000000000000{ "label" : "SHA-1 ASCII user", "digest" : "SHA-1", "user" : "user", "pass" : "p\u00e8ncil", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=user,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=yn797N2/XhIwZBB29LhEs6D6XVw=", "v=a6QRQikpGygizEM4/rCOvkgdglI=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-1-ascii.json000066400000000000000000000010061511106010400220410ustar00rootroot00000000000000{ "label" : "SHA-1 ASCII", "digest" : "SHA-1", "user" : "user", "pass" : "pencil", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=user,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=I4oktcY7BOL0Agn0NlWRXlRP1mg=", "v=oKPvB1bE/9ydptJ+kohMgL+NdM0=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-1-no-saslprep.json000066400000000000000000000010401511106010400232120ustar00rootroot00000000000000{ "label" : "SHA-1 no-SASLprep", "digest" : "SHA-1", "user" : "ramo\u0301n", "pass" : "p\u212bssword", "authID" : "", "skipSASLprep" : true, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=ramo\u0301n,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=zLg8AlljNXeGOwWk0G2ay6a6qiM=", "v=sVH5eR1tapz4QrMVCIGAlrUCAfc=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-1-saslprep-non-normal.json000066400000000000000000000010501511106010400246570ustar00rootroot00000000000000{ "label" : "SHA-1 SASLprep non-normal", "digest" : "SHA-1", "user" : "ramo\u0301n", "pass" : "p\u212bssword", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=ram\u00f3n,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=92sLIo0pB5IdEBOhBXx+t6Ew4pA=", "v=xS0F7g5YU4fvigpFAb8jTE8/S0E=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-1-saslprep-normal.json000066400000000000000000000010441511106010400240720ustar00rootroot00000000000000{ "label" : "SHA-1 SASLprep normal", "digest" : "SHA-1", "user" : "ram\u00f5n", "pass" : "p\u00c5assword", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=ram\u00f5n,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=A1/CIzRGDxwgLpXqQ0CHSSOKX08=", "v=aCt2W88clBMnoAQauVf677Rjpho=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-256-ascii-pass.json000066400000000000000000000010731511106010400231650ustar00rootroot00000000000000{ "label" : "SHA-256 ASCII pass", "digest" : "SHA-256", "user" : "ram\u00f5n", "pass" : "pencil", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=ram\u00f5n,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=vRdD7SqiY5kMyAFX2enPOJK9BL+3YIVyuzCt1H2qc4o=", "v=sh7QPwVuquMatYobYpYOaPiNS+lqwTCmy3rdexRDDkE=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-256-ascii-user.json000066400000000000000000000010641511106010400231750ustar00rootroot00000000000000{ "label" : "SHA-256 ASCII user", "digest" : "SHA-256", "user" : "user", "pass" : "p\u00e8ncil", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=user,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=o6rKPfQCKSGHClFxHjdSeiVCPA6K53++gpY3XlP8lI8=", "v=rsyNAwnHfclZKxAKx1tKfInH3xPVAzCy237DQo5n/N8=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-256-ascii.json000066400000000000000000000010521511106010400222160ustar00rootroot00000000000000{ "label" : "SHA-256 ASCII", "digest" : "SHA-256", "user" : "user", "pass" : "pencil", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=user,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=ItXnHvCDW7VGij6H+4rv2o93HvkLwrQaLkfVjeSMfrc=", "v=P61v8wxOu6B9J7Uij+Sk4zewSK1e6en6f5rCFO4OUNE=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-256-no-saslprep.json000066400000000000000000000011041511106010400233670ustar00rootroot00000000000000{ "label" : "SHA-256 no-SASLprep", "digest" : "SHA-256", "user" : "ramo\u0301n", "pass" : "p\u212bssword", "authID" : "", "skipSASLprep" : true, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=ramo\u0301n,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=oTfTL+YxW2HglmsPRO5VLdQk+oVt48HHrKppt+kYP2Y=", "v=mtXS1UbPSI9Ks9flMJwHBDfnmwcUwjpI8A/NlAT5c98=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-256-saslprep-non-normal.json000066400000000000000000000011141511106010400250340ustar00rootroot00000000000000{ "label" : "SHA-256 SASLprep non-normal", "digest" : "SHA-256", "user" : "ramo\u0301n", "pass" : "p\u212bssword", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=ram\u00f3n,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=KXgIc8B+d5k3zx1P4rfs4TiybIlv11O85Jl1TrzEsfI=", "v=zG9u+MI5GPTROhnW/W1PUCKV4Uvp2SHzwFOZV9Hth/c=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-256-saslprep-normal.json000066400000000000000000000011101511106010400242400ustar00rootroot00000000000000{ "label" : "SHA-256 SASLprep normal", "digest" : "SHA-256", "user" : "ram\u00f5n", "pass" : "p\u00c5assword", "authID" : "", "skipSASLprep" : false, "salt64" : "c2FsdFNBTFRzYWx0\n", "iters" : 4096, "clientNonce" : "clientNONCE", "serverNonce" : "serverNONCE", "valid" : true, "steps" : [ "n,,n=ram\u00f5n,r=clientNONCE", "r=clientNONCEserverNONCE,s=c2FsdFNBTFRzYWx0,i=4096", "c=biws,r=clientNONCEserverNONCE,p=Km2zqmf/GbLdkItzscNI5D0c1f+GmLDi2fScTPm6d4k=", "v=30soY0l2BiInoDyrHxIuamz2LBvci1lFKo/tOMpqo98=", "" ] } xdg-go-scram-788bb0f/testdata/good/sha-512-ascii.json000066400000000000000000000014651511106010400222210ustar00rootroot00000000000000{ "label": "SHA-512 example", "digest": "SHA-512", "user": "user", "pass": "pencil", "authID": "", "skipSASLprep": false, "salt64": "W22ZaJ0SNY7soEsUEjb6gQ==", "iters": 10000, "clientNonce": "/uAp+YhRXyuq+1zS3YWsUsZHpxLfGkcQ", "serverNonce": "4FHzBxBhPzykjGStpoxdfs4Nyp1mtC9B", "valid": true, "steps" : [ "n,,n=user,r=/uAp+YhRXyuq+1zS3YWsUsZHpxLfGkcQ", "r=/uAp+YhRXyuq+1zS3YWsUsZHpxLfGkcQ4FHzBxBhPzykjGStpoxdfs4Nyp1mtC9B,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=10000", "c=biws,r=/uAp+YhRXyuq+1zS3YWsUsZHpxLfGkcQ4FHzBxBhPzykjGStpoxdfs4Nyp1mtC9B,p=E2tqtuwJz5ShFuck1/KeqZKCmGZe/Lxjyr6fTZio21wZtlpgyeYLNxxQXrLuK/FfMSCanyDRmI1q5KL1Tj54nQ==", "v=qKOkLNGdt0VrlVkoL3kXjhWmI4SjEfqw8ogXgikzbPOMh6gloo8Mrboq/q3cz0/7hIvcQ1N9VL4m7iiSTTyolQ==", "" ] } xdg-go-scram-788bb0f/testdata_test.go000066400000000000000000000070771511106010400176230ustar00rootroot00000000000000// Copyright 2018 by David A. Golden. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package scram import ( "encoding/json" "fmt" "io/ioutil" "path/filepath" "strings" ) type TestCase struct { Label string Digest string User string Pass string AuthzID string SkipSASLprep bool Salt64 string Iters int ClientNonce string ServerNonce string Valid bool Steps []string } type testStep struct { Input string Expect string IsError bool } func getHGF(s string) (HashGeneratorFcn, error) { switch s { case "SHA-1": return SHA1, nil case "SHA-256": return SHA256, nil case "SHA-512": return SHA512, nil default: panic(fmt.Sprintf("Unknown hash function '%s'", s)) } } func decodeFile(s string) (TestCase, error) { var tc TestCase data, err := ioutil.ReadFile(s) if err != nil { return tc, err } err = json.Unmarshal(data, &tc) if err != nil { return tc, fmt.Errorf("error unmarshaling '%s': %v", s, err) } return tc, nil } func getTestFiles(dir string) ([]string, error) { subdir := filepath.Join("testdata", dir) files, err := ioutil.ReadDir(subdir) if err != nil { return nil, err } filenames := make([]string, len(files)) for i, v := range files { filenames[i] = filepath.Join(subdir, v.Name()) } return filenames, nil } func getTestData(dirs ...string) ([]TestCase, error) { var err error filenames := make([]string, 0) for _, v := range dirs { names, err := getTestFiles(v) if err != nil { return nil, err } filenames = append(filenames, names...) } cases := make([]TestCase, len(filenames)) for i, v := range filenames { cases[i], err = decodeFile(v) if err != nil { return nil, err } } return cases, nil } // Even steps are client messages; odd steps are server responses. func clientSteps(c TestCase) []testStep { n := len(c.Steps) // Test case requires at least two steps: the first client step // (which cannot fail) and the first server response -- after which // an error would prevent further client steps. if n < 2 { panic("Incomplete conversation for this test case") } // First step needs empty input. steps := []testStep{{Input: "", Expect: c.Steps[0]}} // From i==1 until end, construct conversations from pairs of steps. We // know that (n >= 2). If the last pair is incomplete (no client Expect) // that indicates error. last := n - 1 for i := 1; i <= last; i += 2 { steps = append(steps, assembleStep(c, i, last)) } return steps } // Even steps are client messages; odd steps are server responses. func serverSteps(c TestCase) []testStep { n := len(c.Steps) // Test case requires at least one step: the first client step // after which an error would prevent further server steps. if n == 0 { panic("Incomplete conversation for this test case") } steps := make([]testStep, 0, 1) // From i==0 until end, construct conversations from pairs of steps. We // know that (n >= 1). If the last pair is incomplete (no server Expect) // that indicates error. last := n - 1 for i := 0; i < last; i += 2 { ts := assembleStep(c, i, last) steps = append(steps, ts) } return steps } func assembleStep(c TestCase, i int, last int) testStep { ts := testStep{Input: c.Steps[i]} if i == last { ts.IsError = true } else { ts.Expect = c.Steps[i+1] if strings.HasPrefix(ts.Expect, "e=") { ts.IsError = true } } return ts }